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

Large diffs are not rendered by default.

283 changes: 277 additions & 6 deletions src/Workflows/class-wp-agent-workflow-branch-store.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
*/
final class WP_Agent_Workflow_Branch_Store {

public const BACKEND_BUILTIN = 'builtin';
public const BACKEND_CUSTOM = 'custom';
public const BACKEND_LEGACY = 'legacy';
public const BACKEND_TRANSITION = 'transition';

/**
* Option-name prefix for a per-branch descriptor row. The run id and handle
* id are folded into the key so rows never collide across runs/branches and
Expand All @@ -73,6 +78,9 @@ final class WP_Agent_Workflow_Branch_Store {
*/
private const CONTEXT_PREFIX = 'agents_wf_branch_ctx_';

/** Reconcile receipt key stored inside each branch descriptor. */
private const RECONCILE_RECEIPT_KEY = '_agents_reconcile_receipt';

/**
* Option-name prefix for the per-run index of branch ref keys, so
* {@see self::forget_run()} can delete every branch row a run wrote without
Expand Down Expand Up @@ -190,12 +198,25 @@ public static function admission_status( string $token ): string {
* @return string The store ref (the option name) placed in the AS args.
*/
public static function put_branch( string $run_id, string $handle_id, array $descriptor ): string {
$stored = self::put_branch_with_provenance( $run_id, $handle_id, $descriptor );
return $stored['ref'];
}

/**
* Persist a descriptor and report which backend owns its opaque ref.
*
* @param string $run_id Run id.
* @param string $handle_id Branch handle id.
* @param array<string,mixed> $descriptor Branch descriptor.
* @return array{ref:string,backend:string}
*/
public static function put_branch_with_provenance( string $run_id, string $handle_id, array $descriptor ): array {
$override = self::filtered_put_branch( $run_id, $handle_id, $descriptor );
if ( is_string( $override ) ) {
return $override;
return array( 'ref' => $override, 'backend' => self::BACKEND_CUSTOM );
}

$ref = self::BRANCH_PREFIX . md5( $run_id . ':' . $handle_id );
$ref = self::branch_ref( $run_id, $handle_id );
self::write_row(
$ref,
array(
Expand All @@ -206,7 +227,7 @@ public static function put_branch( string $run_id, string $handle_id, array $des
)
);
self::index_ref( $run_id, $ref );
return $ref;
return array( 'ref' => $ref, 'backend' => self::BACKEND_BUILTIN );
}

/**
Expand Down Expand Up @@ -272,6 +293,174 @@ public static function get_branch( string $store_ref, string $context_ref ): ?ar
return $descriptor;
}

/**
* Persist a terminal branch result and opaque continuation state as a
* receipt-only record. This deliberately replaces neither a stale descriptor
* snapshot nor the shared run index, so terminal cleanup cannot be followed by
* descriptor resurrection.
*
* @since 0.7.0
*
* @param string $run_id Run the branch belongs to.
* @param string $handle_id Branch handle id.
* @param string $store_ref Existing branch descriptor ref.
* @param string $context_ref Existing shared-context ref.
* @param string $backend Explicit descriptor backend provenance.
* @param array<string,mixed> $branch_result Terminal BranchResult.
* @param array<string,mixed> $continuation Opaque reconcile continuation state.
* @return string|\WP_Error Durable receipt ref, or a hard persistence failure.
*/
public static function put_reconcile_receipt( string $run_id, string $handle_id, string $store_ref, string $context_ref, string $backend, array $branch_result, array $continuation = array() ) {
$receipt = array(
'branch_result' => $branch_result,
'continuation' => $continuation,
);

if ( self::BACKEND_BUILTIN === $backend ) {
self::write_row(
$store_ref,
array(
'run_id' => $run_id,
'handle_id' => $handle_id,
self::RECONCILE_RECEIPT_KEY => $receipt,
'expires' => time() + self::TTL_SECONDS,
)
);
$persisted = self::read_row( $store_ref );
if ( $receipt === ( $persisted[ self::RECONCILE_RECEIPT_KEY ] ?? null ) ) {
return $store_ref;
}

return new \WP_Error( 'workflow_branch_result_persistence_failed', sprintf( 'Could not durably persist the terminal result for branch `%s` in run `%s`.', $handle_id, $run_id ) );
}

if ( self::BACKEND_CUSTOM !== $backend ) {
return new \WP_Error( 'workflow_branch_receipt_backend_unknown', sprintf( 'Branch `%s` in run `%s` has no explicit receipt backend provenance.', $handle_id, $run_id ) );
}

$result_ref = self::filtered_put_receipt( $store_ref, $run_id, $handle_id, $receipt );
if ( null === $result_ref ) {
return new \WP_Error( 'workflow_branch_receipt_backend_unsupported', sprintf( 'The custom branch store that owns `%s` does not provide durable reconcile receipt persistence.', $store_ref ) );
}

$persisted = self::filtered_get_receipt( $result_ref, $context_ref );
if ( null === $persisted || $receipt !== $persisted ) {
return new \WP_Error( 'workflow_branch_result_persistence_failed', sprintf( 'The branch store could not verify the terminal result for branch `%s` in run `%s`.', $handle_id, $run_id ) );
}

return $result_ref;
}

/**
* Read a durable reconcile receipt through the same built-in or custom store
* that owns the branch descriptor.
*
* @since 0.7.0
*
* @param string $result_ref Opaque receipt ref.
* @param string $context_ref Existing shared-context ref.
* @param string $backend Explicit descriptor backend provenance.
* @return array{branch_result:array<string,mixed>,continuation:array<string,mixed>}|null
*/
public static function get_reconcile_receipt( string $result_ref, string $context_ref, string $backend ): ?array {
if ( self::BACKEND_CUSTOM === $backend ) {
$receipt = self::filtered_get_receipt( $result_ref, $context_ref );
if ( null === $receipt || ! is_array( $receipt['branch_result'] ?? null ) ) {
return null;
}
return array(
'branch_result' => self::string_keyed_array( $receipt['branch_result'] ),
'continuation' => is_array( $receipt['continuation'] ?? null ) ? self::string_keyed_array( $receipt['continuation'] ) : array(),
);
}
if ( self::BACKEND_BUILTIN !== $backend ) {
return null;
}
$row = self::read_row( $result_ref );
if ( null !== $row && is_array( $row[ self::RECONCILE_RECEIPT_KEY ] ?? null ) ) {
$receipt = $row[ self::RECONCILE_RECEIPT_KEY ];
} else {
$descriptor = null !== $row && is_array( $row['descriptor'] ?? null )
? $row['descriptor']
: self::filtered_get_branch( $result_ref, $context_ref );
$receipt = is_array( $descriptor[ self::RECONCILE_RECEIPT_KEY ] ?? null ) ? $descriptor[ self::RECONCILE_RECEIPT_KEY ] : array();
}
if ( ! is_array( $receipt['branch_result'] ?? null ) ) {
return null;
}

return array(
'branch_result' => self::string_keyed_array( $receipt['branch_result'] ),
'continuation' => is_array( $receipt['continuation'] ?? null ) ? self::string_keyed_array( $receipt['continuation'] ) : array(),
);
}

/**
* Resolve a receipt ref from explicit backend ownership and descriptor identity.
*
* @return string Empty when no receipt is discoverable.
*/
public static function locate_reconcile_receipt( string $store_ref, string $run_id, string $handle_id, string $context_ref, string $backend ): string {
if ( self::BACKEND_BUILTIN === $backend ) {
return $store_ref;
}
if ( self::BACKEND_CUSTOM !== $backend || ! function_exists( 'apply_filters' ) ) {
return '';
}
/**
* Locate a custom reconcile receipt by its owning descriptor identity.
*
* @since 0.7.0
*
* @param string $receipt_ref No locator by default.
* @param string $store_ref Descriptor ref.
* @param string $run_id Run id.
* @param string $handle_id Handle id.
* @param string $context_ref Shared-context ref.
*/
$receipt_ref = apply_filters( 'wp_agent_workflow_branch_receipt_locate', '', $store_ref, $run_id, $handle_id, $context_ref );
return $receipt_ref;
}

/**
* Delete one successfully reconciled receipt without touching sibling refs.
*
* @since 0.7.0
*
* @param string $run_id Run id.
* @param string $handle_id Branch handle id.
* @param string $result_ref Receipt ref.
* @param string $context_ref Shared-context ref for custom stores.
* @param string $backend Explicit descriptor backend provenance.
* @return void
*/
public static function forget_reconcile_receipt( string $run_id, string $handle_id, string $result_ref, string $context_ref, string $backend ): void {
if ( self::BACKEND_CUSTOM === $backend ) {
self::filtered_forget_receipt( $result_ref, $run_id, $handle_id, $context_ref );
return;
}
if ( self::BACKEND_BUILTIN !== $backend ) {
return;
}
$row = self::read_row( $result_ref );
if ( null === $row ) {
return;
}

// Descriptor-backed and consumer-owned receipts belong to run-level cleanup.
// Never rewrite them here: a resume may have already called forget_run()
// after this read, and a write would resurrect the terminal run's storage.
if ( is_array( $row['descriptor'] ?? null ) ) {
return;
}

// Receipt-only missing-descriptor rows are safe to delete exactly. Deletion
// remains harmless if terminal cleanup won the race first.
if ( self::branch_ref( $run_id, $handle_id ) === $result_ref && function_exists( 'delete_option' ) ) {
delete_option( $result_ref );
}
}

/**
* Delete every row a run wrote (its branch descriptors, its shared-context
* row, and its index) once the run resolves. Same cleanup discipline the
Expand Down Expand Up @@ -323,12 +512,13 @@ private static function admission_ref( string $token ): string {
*
* @param string $option Option name.
* @param array<string,mixed> $value Row value.
* @return void
* @return bool Whether WordPress reported that it changed the row.
*/
private static function write_row( string $option, array $value ): void {
private static function write_row( string $option, array $value ): bool {
if ( function_exists( 'update_option' ) ) {
update_option( $option, $value, false );
return update_option( $option, $value, false );
}
return false;
}

/**
Expand All @@ -349,6 +539,9 @@ private static function read_row( string $option ): ?array {
}
$expires = is_numeric( $row['expires'] ?? null ) ? (int) $row['expires'] : 0;
if ( $expires > 0 && $expires <= time() ) {
if ( function_exists( 'delete_option' ) ) {
delete_option( $option );
}
return null;
}

Expand Down Expand Up @@ -386,6 +579,11 @@ private static function index_ref( string $run_id, string $ref ): void {
}
}

/** Return the deterministic built-in descriptor ref for one branch. */
private static function branch_ref( string $run_id, string $handle_id ): string {
return self::BRANCH_PREFIX . md5( $run_id . ':' . $handle_id );
}

/**
* Offer a consumer's store the chance to persist a branch descriptor. A
* filter that returns a non-empty string ref owns persistence for this
Expand Down Expand Up @@ -446,6 +644,62 @@ private static function filtered_get_branch( string $store_ref, string $context_
return is_array( $descriptor ) ? $descriptor : null;
}

/** @param array<string,mixed> $receipt */
private static function filtered_put_receipt( string $store_ref, string $run_id, string $handle_id, array $receipt ): ?string {
if ( ! function_exists( 'apply_filters' ) ) {
return null;
}
/**
* Persist a reconcile receipt in the custom backend that owns a branch ref.
*
* @since 0.7.0
*
* @param string|null $receipt_ref No implementation by default.
* @param string $store_ref Owning descriptor ref.
* @param string $run_id Run id.
* @param string $handle_id Handle id.
* @param array<string,mixed> $receipt Receipt payload.
*/
$receipt_ref = apply_filters( 'wp_agent_workflow_branch_receipt_put', null, $store_ref, $run_id, $handle_id, $receipt );
return is_string( $receipt_ref ) && '' !== $receipt_ref ? $receipt_ref : null;
}

/** @return array<string,mixed>|null */
private static function filtered_get_receipt( string $receipt_ref, string $context_ref ): ?array {
if ( ! function_exists( 'apply_filters' ) ) {
return null;
}
/**
* Read a reconcile receipt from a custom branch backend.
*
* @since 0.7.0
*
* @param array<string,mixed>|null $receipt No implementation by default.
* @param string $receipt_ref Receipt ref.
* @param string $context_ref Shared-context ref.
*/
$receipt = apply_filters( 'wp_agent_workflow_branch_receipt_get', null, $receipt_ref, $context_ref );
return is_array( $receipt ) ? $receipt : null;
}

private static function filtered_forget_receipt( string $receipt_ref, string $run_id, string $handle_id, string $context_ref ): bool {
if ( ! function_exists( 'apply_filters' ) ) {
return false;
}
/**
* Delete one reconcile receipt from a custom branch backend.
*
* @since 0.7.0
*
* @param bool $handled No implementation by default.
* @param string $receipt_ref Receipt ref.
* @param string $run_id Run id.
* @param string $handle_id Handle id.
* @param string $context_ref Shared-context ref.
*/
return (bool) apply_filters( 'wp_agent_workflow_branch_receipt_delete', false, $receipt_ref, $run_id, $handle_id, $context_ref );
}

/**
* Offer a consumer's store the chance to release a run's payload rows.
*
Expand All @@ -470,4 +724,21 @@ private static function filtered_forget_run( string $run_id ): bool {
*/
return (bool) apply_filters( 'wp_agent_workflow_branch_store_forget', false, $run_id );
}

/**
* Normalize an array to string keys for static-analysis-safe public returns.
*
* @param array<mixed> $value Value to normalize.
* @return array<string,mixed>
*/
private static function string_keyed_array( array $value ): array {
$normalized = array();
foreach ( $value as $key => $item ) {
if ( is_string( $key ) ) {
$normalized[ $key ] = $item;
}
}
return $normalized;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ private function release_lease( string $operation_id, string $token ): void {
} );
}

/** Remove only this run's AS branch/resume actions; no shared group is touched. */
/** Remove only this run's AS branch/reconcile/resume actions; no shared group is touched. */
private function cleanup_operation_actions( string $run_id ): void {
if ( '' === $run_id || ! function_exists( 'as_unschedule_all_actions' ) ) {
return;
Expand Down
1 change: 1 addition & 0 deletions src/Workflows/class-wp-agent-workflow-scoped-drain.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ public static function is_available(): bool {
public static function default_hooks(): array {
return array(
WP_Agent_Workflow_Action_Scheduler_Branch_Executor::BRANCH_HOOK,
WP_Agent_Workflow_Action_Scheduler_Branch_Executor::RECONCILE_HOOK,
WP_Agent_Workflow_Action_Scheduler_Branch_Executor::RESUME_HOOK,
);
}
Expand Down
Loading