Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/core-system/pending-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Data Machine implements the Agents API pending-action approval storage contract with WordPress-backed durable storage.

Pending actions are approval and audit records. Normal runtime requires the durable `datamachine_pending_actions` table; if database access is unavailable, store/resolution operations fail closed and emit `datamachine_pending_action_store_unavailable`. The transient store path is reserved for pure-PHP smoke tests or explicit pre-table boot by defining `DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK`.

## Boundary

- Agents API owns generic approval vocabulary and contracts.
Expand Down
53 changes: 50 additions & 3 deletions inc/Engine/AI/Actions/PendingActionStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
* 'context' => array( ... ), // free-form (session_id, bridge_app, etc.)
* )
*
* Uses durable WordPress database storage in normal runtime. Pure-PHP smoke
* tests and pre-table boot can still fall back to transient storage.
* Uses durable WordPress database storage in normal runtime. Pure-PHP smoke
* tests and explicitly opted-in pre-table boot can fall back to transient
* storage by defining DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK.
*
* @package DataMachine\Engine\AI\Actions
* @since 0.72.0
Expand Down Expand Up @@ -63,7 +64,7 @@ class PendingActionStore {
/**
* Transient fallback key prefix for pure-PHP smoke tests and pre-table boot.
*/
private const TRANSIENT_PREFIX = 'dm_pa_';
private const TRANSIENT_PREFIX = 'datamachine_pending_action_';

/**
* Agents API store contract singleton.
Expand Down Expand Up @@ -202,6 +203,11 @@ public static function store( string $action_id, array $payload ): bool {
$payload['context'] = $context;

if ( ! self::has_database() ) {
if ( ! self::allows_transient_fallback() ) {
self::warn_database_unavailable( 'store' );
return false;
}

$payload['created_at'] = time();
$payload['expires_at'] = time() + self::resolve_ttl( $payload );
$payload['action_id'] = $action_id;
Expand Down Expand Up @@ -269,6 +275,11 @@ public static function store( string $action_id, array $payload ): bool {
*/
public static function get( string $action_id, bool $include_resolved = false ): ?array {
if ( ! self::has_database() ) {
if ( ! self::allows_transient_fallback() ) {
self::warn_database_unavailable( 'get' );
return null;
}

$data = get_transient( self::TRANSIENT_PREFIX . $action_id );
return is_array( $data ) ? $data : null;
}
Expand Down Expand Up @@ -311,6 +322,11 @@ public static function get( string $action_id, bool $include_resolved = false ):
*/
public static function delete( string $action_id ): bool {
if ( ! self::has_database() ) {
if ( ! self::allows_transient_fallback() ) {
self::warn_database_unavailable( 'delete' );
return false;
}

return delete_transient( self::TRANSIENT_PREFIX . $action_id );
}

Expand All @@ -330,6 +346,11 @@ public static function record_resolution( string $action_id, string $decision, $
global $wpdb;

if ( ! self::has_database() ) {
if ( ! self::allows_transient_fallback() ) {
self::warn_database_unavailable( 'record_resolution' );
return false;
}

$payload = get_transient( self::TRANSIENT_PREFIX . $action_id );
$action = is_array( $payload ) ? self::action_from_payload( $payload ) : null;
$deleted = delete_transient( self::TRANSIENT_PREFIX . $action_id );
Expand Down Expand Up @@ -834,6 +855,32 @@ private static function has_database(): bool {
return is_object( $wpdb ) && method_exists( $wpdb, 'replace' ) && method_exists( $wpdb, 'get_row' );
}

/**
* Check whether the non-durable transient fallback has been explicitly enabled.
*/
private static function allows_transient_fallback(): bool {
return defined( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' )
&& true === DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK;
}

/**
* Report attempts to use pending actions without durable storage.
*/
private static function warn_database_unavailable( string $operation ): void {
$message = sprintf(
'PendingActionStore::%s() requires the durable pending-actions table; define DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK only for pure-PHP smoke tests or explicit pre-table boot.',
$operation
);

if ( function_exists( '_doing_it_wrong' ) ) {
_doing_it_wrong( esc_html( __CLASS__ . '::' . $operation ), esc_html( $message ), '1.0.0' );
}

if ( function_exists( 'do_action' ) ) {
do_action( 'datamachine_pending_action_store_unavailable', $operation, $message );
}
}

/**
* Normalize mixed timestamp input to a Unix timestamp.
*/
Expand Down
4 changes: 4 additions & 0 deletions tests/pending-action-resolver-contract-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
}

if ( ! defined( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' ) ) {
define( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK', true );
}

$GLOBALS['__resolver_filters'] = array();
$GLOBALS['__resolver_transients'] = array();

Expand Down
130 changes: 130 additions & 0 deletions tests/pending-action-store-durable-required-smoke.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php
/**
* Pure-PHP smoke coverage for the PendingActionStore no-database failure path.
*
* Run with: php tests/pending-action-store-durable-required-smoke.php
*
* @package DataMachine\Tests
*/

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
}

$GLOBALS['__pending_action_unavailable_events'] = array();
$GLOBALS['__pending_action_transient_writes'] = 0;

if ( ! function_exists( 'wp_json_encode' ) ) {
function wp_json_encode( $data, $options = 0, $depth = 512 ) {
return json_encode( $data, $options, $depth );
}
}

if ( ! function_exists( 'apply_filters' ) ) {
function apply_filters( string $_hook_name, $value ) {
return $value;
}
}

if ( ! function_exists( 'add_action' ) ) {
function add_action( string $_hook_name, $callback, int $_priority = 10, int $_accepted_args = 1 ): bool {
unset( $callback );
return true;
}
}

if ( ! function_exists( 'do_action' ) ) {
function do_action( string $hook_name, ...$args ): void {
$GLOBALS['__pending_action_unavailable_events'][] = array(
'hook' => $hook_name,
'args' => $args,
);
}
}

if ( ! function_exists( '_doing_it_wrong' ) ) {
function _doing_it_wrong( string $function_name, string $message, string $version ): void {
$GLOBALS['__pending_action_unavailable_events'][] = array(
'hook' => '_doing_it_wrong',
'args' => array( $function_name, $message, $version ),
);
}
}

if ( ! function_exists( 'esc_html' ) ) {
function esc_html( string $text ): string {
return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
}
}

if ( ! function_exists( 'set_transient' ) ) {
function set_transient( string $_key, $value, int $_expiration = 0 ): bool {
unset( $value );
++$GLOBALS['__pending_action_transient_writes'];
return true;
}
}

if ( ! function_exists( 'get_transient' ) ) {
function get_transient( string $_key ) {
return false;
}
}

if ( ! function_exists( 'delete_transient' ) ) {
function delete_transient( string $_key ): bool {
return true;
}
}

require_once dirname( __DIR__ ) . '/vendor/automattic/agents-api/agents-api.php';
require_once dirname( __DIR__ ) . '/inc/Core/Workspace/WordPressWorkspaceScope.php';
require_once dirname( __DIR__ ) . '/inc/Engine/AI/Actions/PendingActionObservers.php';
require_once dirname( __DIR__ ) . '/inc/Engine/AI/Actions/PendingActionStore.php';

use DataMachine\Engine\AI\Actions\PendingActionStore;

$failures = array();
$passes = 0;

echo "pending-action-store-durable-required-smoke\n";

function datamachine_pending_store_assert( bool $condition, string $message, array &$failures, int &$passes ): void {
if ( $condition ) {
++$passes;
echo "PASS: {$message}\n";
return;
}

$failures[] = $message;
echo "FAIL: {$message}\n";
}

$payload = array(
'kind' => 'durable_required',
'summary' => 'Durable storage required.',
'apply_input' => array( 'target' => 'approval' ),
);

datamachine_pending_store_assert( false === PendingActionStore::store( 'act_durable_required', $payload ), 'store fails closed without durable storage', $failures, $passes );
datamachine_pending_store_assert( 0 === $GLOBALS['__pending_action_transient_writes'], 'store does not silently write transient approvals', $failures, $passes );
datamachine_pending_store_assert( null === PendingActionStore::get( 'act_durable_required' ), 'get returns no action without durable storage', $failures, $passes );
datamachine_pending_store_assert( false === PendingActionStore::record_resolution( 'act_durable_required', 'accepted' ), 'resolution fails closed without durable storage', $failures, $passes );
datamachine_pending_store_assert( false === PendingActionStore::delete( 'act_durable_required' ), 'delete fails closed without durable storage', $failures, $passes );
datamachine_pending_store_assert( array() === PendingActionStore::list(), 'list remains empty without durable storage', $failures, $passes );
datamachine_pending_store_assert( 0 === PendingActionStore::summary()['total'], 'summary remains empty without durable storage', $failures, $passes );
datamachine_pending_store_assert( 0 === PendingActionStore::expire_due_actions(), 'expiration does not mutate without durable storage', $failures, $passes );

$event_hooks = array_map( static fn( array $event ): string => (string) ( $event['hook'] ?? '' ), $GLOBALS['__pending_action_unavailable_events'] );
datamachine_pending_store_assert( in_array( '_doing_it_wrong', $event_hooks, true ), 'missing durable storage emits a developer warning', $failures, $passes );
datamachine_pending_store_assert( in_array( 'datamachine_pending_action_store_unavailable', $event_hooks, true ), 'missing durable storage emits an operator diagnostic action', $failures, $passes );

if ( ! empty( $failures ) ) {
echo "\nFailures:\n";
foreach ( $failures as $failure ) {
echo '- ' . $failure . "\n";
}
exit( 1 );
}

echo "\nPending-action durable-required smoke passed ({$passes} assertions).\n";
9 changes: 8 additions & 1 deletion tests/pending-actions-agents-api-contract-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

echo "pending-actions-agents-api-contract-smoke\n";

if ( ! defined( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' ) ) {
define( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK', true );
}

function datamachine_pending_actions_assert( bool $condition, string $message, array &$failures, int &$passes ): void {
if ( $condition ) {
++$passes;
Expand Down Expand Up @@ -137,7 +141,10 @@ function datamachine_pending_actions_source( string $path ): string {
datamachine_pending_actions_assert( str_contains( $signer_source, 'hash_hmac' ) && str_contains( $signer_source, 'datamachine_pending_action_resolution_secret' ), 'signed pending-action resolution uses a stored HMAC secret', $failures, $passes );
datamachine_pending_actions_assert( str_contains( $runtime_source, 'datamachine_migrate_pending_actions_table' ), 'upgrade path creates pending-action table on deployed installs', $failures, $passes );

echo "\n[5] Store contract adapter preserves transient fallback behavior:\n";
echo "\n[5] Store contract adapter uses the explicit test transient fallback seam:\n";

datamachine_pending_actions_assert( str_contains( $store_source, 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' ), 'transient fallback requires an explicit opt-in constant', $failures, $passes );
datamachine_pending_actions_assert( str_contains( $store_source, 'warn_database_unavailable' ), 'missing durable storage reports a clear unavailable-store warning', $failures, $passes );

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
Expand Down
4 changes: 4 additions & 0 deletions tests/signed-pending-action-resolution-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
}

if ( ! defined( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK' ) ) {
define( 'DATAMACHINE_PENDING_ACTION_TRANSIENT_FALLBACK', true );
}

$GLOBALS['__signed_filters'] = array();
$GLOBALS['__signed_transients'] = array();
$GLOBALS['__signed_options'] = array();
Expand Down
Loading