diff --git a/agents-api.php b/agents-api.php index 7f687c8..25a8934 100644 --- a/agents-api.php +++ b/agents-api.php @@ -316,6 +316,8 @@ require_once AGENTS_API_PATH . 'src/Channels/register-agents-chat-jsonrpc-route.php'; require_once AGENTS_API_PATH . 'src/Channels/register-agents-dispatch-message-ability.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-step-type-registry.php'; +require_once AGENTS_API_PATH . 'src/Workflows/register-workflow-step-types.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-spec.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-run-result.php'; diff --git a/composer.json b/composer.json index 513ad7b..e9b44b0 100644 --- a/composer.json +++ b/composer.json @@ -138,6 +138,7 @@ "php tests/context-authority-smoke.php", "php tests/guidelines-substrate-smoke.php", "php tests/workflow-bindings-smoke.php", + "php tests/workflow-step-type-registry-smoke.php", "php tests/workflow-spec-validator-smoke.php", "php tests/workflow-runner-smoke.php", "php tests/workflow-parallel-smoke.php", diff --git a/docs/channels-workflows-operations.md b/docs/channels-workflows-operations.md index 3e85283..0eeaaa2 100644 --- a/docs/channels-workflows-operations.md +++ b/docs/channels-workflows-operations.md @@ -226,7 +226,7 @@ Bridge authorization is intentionally separate from queue state. Connectors can ## Workflow specs and validation -Workflows are deterministic recipes composed of inputs, triggers, and steps. Agents API provides the spec value object, structural validator, in-memory registry, runner, default step handlers, store/recorder interfaces, optional Action Scheduler bridge and branch executor, and canonical abilities. It does not ship a durable workflow runtime, editor UI, product-specific step types, or run-history database. +Workflows are deterministic recipes composed of inputs, triggers, and steps. Agents API provides the spec value object, structural validator, step-type registry, in-memory workflow registry, runner, store/recorder interfaces, optional Action Scheduler bridge and branch executor, and canonical abilities. It does not ship a durable workflow runtime, editor UI, product-specific step types, or run-history database. `WP_Agent_Workflow_Spec_Validator::validate( array $spec ): array` performs structural validation only: @@ -234,13 +234,35 @@ Workflows are deterministic recipes composed of inputs, triggers, and steps. Age - `inputs`, when present, must be a map; - `steps` must be a non-empty list; - each step needs an `id` and `type`; -- built-in step types are `ability`, `agent`, `foreach`, and `parallel`; -- consumers can extend known step types through `wp_agent_workflow_known_step_types`; +- known step types and their field validation come from `WP_Agent_Workflow_Step_Type_Registry` (see below); - `wp_action` and `cron` trigger shapes are checked; - forward or unknown `${steps..output.*}` binding references are reported. The validator does not check whether referenced agents or abilities exist; the runner handles that at execution time. +### The step-type registry + +`WP_Agent_Workflow_Step_Type_Registry` pairs one step type name with its runner handler and its field validation contract, so a type can't validate and then fail at run time with no handler, or vice versa. Built-in types (`ability`, `agent`, `foreach`, `parallel`) are registered lazily on first access — no `init` hook dependency, so the registry behaves identically inside and outside a full WordPress bootstrap. + +Register a step type with `register_workflow_step_type( string $type, array $args ): bool`: + +```php +register_workflow_step_type( + 'branch', + array( + 'handler' => 'my_plugin_branch_step_handler', // callable( array $resolved_step, array $context ): array|WP_Error + 'required' => array( 'condition' ), // simple non-empty-string required-field check + 'validate' => 'my_plugin_validate_branch_step', // callable( array $step, string $path ): list<{path,code,message}> + ) +); +``` + +`required` covers the common case (a field must be a non-empty string); write a `validate` callback for anything more specific — shape checks, mutually-exclusive fields, recursion into nested `steps`, etc. Registering an already-registered type keeps the first registration and triggers `_doing_it_wrong` (or the non-WordPress equivalent) — no silent overwrite. + +The runner and the reconcile branch resolver (`agents_workflow_resolve_step_handlers()`) both read their handler map from `WP_Agent_Workflow_Step_Type_Registry::handlers()`, so one registration reaches structural validation, execution, and async branch reconciliation — a consumer no longer registers two unrelated filters that can drift apart. + +**Back-compat, one release only:** the legacy `wp_agent_workflow_known_step_types` and `wp_agent_workflow_step_handlers` filters are still applied on top of the registry's output, so an unmigrated consumer keeps working. Both filters are deprecated as of the registry's introduction and will be removed in a future release; a type registered only through these legacy filters gets no field validation. `WP_Agent_Workflow_Spec_Validator::KNOWN_STEP_TYPES` is likewise kept for back-compat only — it is no longer the source of truth for known step types, `WP_Agent_Workflow_Step_Type_Registry::types()` is. + ## Workflow runner `WP_Agent_Workflow_Runner` executes one `WP_Agent_Workflow_Spec` in order: @@ -266,7 +288,7 @@ The default `parallel` handler uses `wp_agent_workflow_step_executor` as its con The Action Scheduler executor requires a store that supports concurrent writes for parallel branch execution. MySQL and MariaDB can provide that concurrency. SQLite uses a single database-wide writer lock, so Action Scheduler claims and branch execution serialize even when multiple workers are requested. SQLite remains correct for async branch completion, but it provides no parallel speedup. Consumers that require parallel execution should use MySQL or MariaDB; Agents API does not detect or warn about the active database engine at runtime. -Consumers extend the runner through the constructor or the `wp_agent_workflow_step_handlers` filter. Product-specific steps such as `branch` or nested `workflow` belong in consumers. +Consumers extend the runner by registering a step type through `register_workflow_step_type()` (see [The step-type registry](#the-step-type-registry)), or, for a one-off handler override on a single runner instance, through the constructor's `$step_handlers` argument. Product-specific steps such as `branch` or nested `workflow` belong in consumers. Recorder behavior is conservative: `start()` runs before input validation, per-step `update()` calls follow state changes, and recorder-start failure returns a failed run result without executing steps. diff --git a/src/Workflows/class-wp-agent-workflow-runner.php b/src/Workflows/class-wp-agent-workflow-runner.php index 5baf1e8..479fba0 100644 --- a/src/Workflows/class-wp-agent-workflow-runner.php +++ b/src/Workflows/class-wp-agent-workflow-runner.php @@ -72,12 +72,7 @@ public function __construct( protected ?WP_Agent_Workflow_Run_Recorder $recorder = null, array $step_handlers = array() ) { - $defaults = array( - 'ability' => array( __CLASS__, 'default_ability_handler' ), - 'agent' => array( __CLASS__, 'default_agent_handler' ), - 'foreach' => array( __CLASS__, 'default_foreach_handler' ), - 'parallel' => array( __CLASS__, 'default_parallel_handler' ), - ); + $defaults = WP_Agent_Workflow_Step_Type_Registry::handlers(); /** * Filter the step-type handler map. Consumers add new step types @@ -86,9 +81,15 @@ public function __construct( * if a consumer wants to substitute a different ability / agent * runtime. * + * @deprecated 0.14.0 Register step types through + * {@see WP_Agent_Workflow_Step_Type_Registry::register()} + * or `register_workflow_step_type()` instead. This + * filter is kept for one release of back-compat and + * will be removed in a future version. + * * @since 0.103.0 * - * @param array $handlers Default + caller-supplied handlers. + * @param array $handlers Registry + caller-supplied handlers. */ $this->step_handlers = (array) apply_filters( 'wp_agent_workflow_step_handlers', @@ -1693,15 +1694,16 @@ private static function foreach_cancel_requested( array $context ): bool { * @return array */ private static function default_step_handlers(): array { + /** + * @deprecated 0.14.0 Register step types through + * {@see WP_Agent_Workflow_Step_Type_Registry::register()} + * or `register_workflow_step_type()` instead. Kept for + * one release of back-compat. + */ /** @var array $handlers */ $handlers = (array) apply_filters( 'wp_agent_workflow_step_handlers', - array( - 'ability' => array( __CLASS__, 'default_ability_handler' ), - 'agent' => array( __CLASS__, 'default_agent_handler' ), - 'foreach' => array( __CLASS__, 'default_foreach_handler' ), - 'parallel' => array( __CLASS__, 'default_parallel_handler' ), - ) + WP_Agent_Workflow_Step_Type_Registry::handlers() ); return $handlers; diff --git a/src/Workflows/class-wp-agent-workflow-spec-validator.php b/src/Workflows/class-wp-agent-workflow-spec-validator.php index 4b2f98b..9c62577 100644 --- a/src/Workflows/class-wp-agent-workflow-spec-validator.php +++ b/src/Workflows/class-wp-agent-workflow-spec-validator.php @@ -34,7 +34,17 @@ final class WP_Agent_Workflow_Spec_Validator { - /** @since 0.103.0 */ + /** + * Agents-api's built-in step type names. + * + * Kept for backward compatibility only — since 0.14.0 the source of + * truth for known step types is {@see WP_Agent_Workflow_Step_Type_Registry::types()} + * (see {@see self::known_step_types()}), not this constant. A consumer + * that still reads this constant directly sees agents-api's own four + * built-ins, not types a host has registered through the registry. + * + * @since 0.103.0 + */ public const KNOWN_STEP_TYPES = array( 'ability', 'agent', 'foreach', 'parallel' ); /** @since 0.103.0 */ @@ -108,10 +118,19 @@ public static function validate( array $spec ): array { } /** + * Validate a list of steps. Public because + * {@see WP_Agent_Workflow_Step_Type_Registry}'s built-in `foreach` and + * `parallel` validate callbacks recurse into a step's nested `steps` + * (or, for `parallel`, each branch's `steps`) through this same + * function — one recursive step-list validator instead of a copy + * living in the registry. + * + * @since 0.103.0 + * * @param array $steps * @return array */ - private static function validate_steps( array $steps ): array { + public static function validate_steps( array $steps ): array { $errors = array(); $seen = array(); @@ -152,204 +171,59 @@ private static function validate_steps( array $steps ): array { continue; // type is needed for the per-type checks below } - if ( ! in_array( $step['type'], self::KNOWN_STEP_TYPES, true ) ) { - /** - * Allow consumer-extended step types. Agents-api ships only - * `ability` and `agent`; extra types (`branch`, `parallel`, - * `workflow`) get registered by consumers via a filter on - * `wp_agent_workflow_known_step_types`. Filtered list wins. - * - * @since 0.103.0 - * - * @param array $known_types Default v0 set. - */ - $known = (array) apply_filters( 'wp_agent_workflow_known_step_types', self::KNOWN_STEP_TYPES ); - if ( ! in_array( $step['type'], $known, true ) ) { - $errors[] = array( - 'path' => "{$path}.type", - 'code' => 'unknown_step_type', - 'message' => sprintf( - 'unknown step type `%s` (known: %s)', - $step['type'], - implode( ', ', $known ) - ), - ); - continue; - } - } - - if ( 'ability' === $step['type'] ) { - if ( empty( $step['ability'] ) || ! is_string( $step['ability'] ) ) { - $errors[] = array( - 'path' => "{$path}.ability", - 'code' => 'missing_required', - 'message' => 'ability step is missing a non-empty `ability`', - ); - } - } - - if ( 'agent' === $step['type'] ) { - if ( empty( $step['agent'] ) || ! is_string( $step['agent'] ) ) { - $errors[] = array( - 'path' => "{$path}.agent", - 'code' => 'missing_required', - 'message' => 'agent step is missing a non-empty `agent`', - ); - } - if ( empty( $step['message'] ) || ! is_string( $step['message'] ) ) { - $errors[] = array( - 'path' => "{$path}.message", - 'code' => 'missing_required', - 'message' => 'agent step is missing a non-empty `message`', - ); - } - } - - if ( 'foreach' === $step['type'] ) { - if ( ! array_key_exists( 'items', $step ) ) { - $errors[] = array( - 'path' => "{$path}.items", - 'code' => 'missing_required', - 'message' => 'foreach step is missing required `items` field', - ); - } - if ( empty( $step['steps'] ) || ! is_array( $step['steps'] ) || array_values( $step['steps'] ) !== $step['steps'] ) { - $errors[] = array( - 'path' => "{$path}.steps", - 'code' => 'missing_required', - 'message' => 'foreach step must declare a non-empty `steps` list', - ); - } else { - foreach ( self::validate_steps( $step['steps'] ) as $inner_error ) { - $inner_path = (string) preg_replace( '/^steps\./', '', $inner_error['path'] ); - $inner_error['path'] = "{$path}.steps." . $inner_path; - $errors[] = $inner_error; - } - } + $known = self::known_step_types(); + if ( ! in_array( $step['type'], $known, true ) ) { + $errors[] = array( + 'path' => "{$path}.type", + 'code' => 'unknown_step_type', + 'message' => sprintf( + 'unknown step type `%s` (known: %s)', + $step['type'], + implode( ', ', $known ) + ), + ); + continue; } - if ( 'parallel' === $step['type'] ) { - $errors = array_merge( $errors, self::validate_parallel_step( $step, $path ) ); - } + $errors = array_merge( $errors, WP_Agent_Workflow_Step_Type_Registry::validate_step( $step, $path ) ); } return $errors; } /** - * Validate a `parallel` (agent fanout) step. The one step type expresses - * two shapes; exactly one must be present: + * Known step types: {@see WP_Agent_Workflow_Step_Type_Registry}'s + * registered types, extended by the legacy + * `wp_agent_workflow_known_step_types` filter for one release of + * back-compat. * - * - parallel-map: `items` + a non-empty nested `steps` list. - * - parallel-roles: a non-empty `branches` list, each branch a role - * contract with a `role` + nested `steps`. At most one branch may be - * flagged `is_aggregator` (the optional aggregator); zero is valid. + * @since 0.14.0 * - * @since 0.4.0 - * - * @param array $step Raw parallel step. - * @param string $path Error path prefix for this step. - * @return array + * @return array */ - private static function validate_parallel_step( array $step, string $path ): array { - $errors = array(); - $has_branches = isset( $step['branches'] ); - $has_items = array_key_exists( 'items', $step ); - - if ( $has_branches === $has_items ) { - $errors[] = array( - 'path' => $path, - 'code' => 'invalid_parallel_shape', - 'message' => 'parallel step must declare exactly one of `branches` (roles) or `items` (map)', - ); - // Without a clear shape there's nothing further to validate. - if ( ! $has_branches && ! $has_items ) { - return $errors; - } - } - - // parallel-map shape. - if ( $has_items ) { - if ( empty( $step['steps'] ) || ! is_array( $step['steps'] ) || array_values( $step['steps'] ) !== $step['steps'] ) { - $errors[] = array( - 'path' => "{$path}.steps", - 'code' => 'missing_required', - 'message' => 'parallel-map step must declare a non-empty `steps` list', - ); - } else { - foreach ( self::validate_steps( $step['steps'] ) as $inner_error ) { - $inner_path = (string) preg_replace( '/^steps\./', '', $inner_error['path'] ); - $inner_error['path'] = "{$path}.steps." . $inner_path; - $errors[] = $inner_error; - } - } - } - - // parallel-roles shape. - if ( $has_branches ) { - if ( ! is_array( $step['branches'] ) || array_values( $step['branches'] ) !== $step['branches'] || empty( $step['branches'] ) ) { - $errors[] = array( - 'path' => "{$path}.branches", - 'code' => 'missing_required', - 'message' => 'parallel-roles step must declare a non-empty list of `branches`', - ); - return $errors; - } + public static function known_step_types(): array { + $known = WP_Agent_Workflow_Step_Type_Registry::types(); - $aggregator_count = 0; - foreach ( $step['branches'] as $branch_idx => $branch ) { - $branch_path = "{$path}.branches.{$branch_idx}"; - if ( ! is_array( $branch ) ) { - $errors[] = array( - 'path' => $branch_path, - 'code' => 'invalid_type', - 'message' => 'parallel branch entry must be an array', - ); - continue; - } - - if ( empty( $branch['role'] ) || ! is_string( $branch['role'] ) ) { - $errors[] = array( - 'path' => "{$branch_path}.role", - 'code' => 'missing_required', - 'message' => 'parallel branch is missing a non-empty `role`', - ); - } - - if ( empty( $branch['steps'] ) || ! is_array( $branch['steps'] ) || array_values( $branch['steps'] ) !== $branch['steps'] ) { - $errors[] = array( - 'path' => "{$branch_path}.steps", - 'code' => 'missing_required', - 'message' => 'parallel branch must declare a non-empty `steps` list', - ); - } else { - foreach ( self::validate_steps( $branch['steps'] ) as $inner_error ) { - $inner_path = (string) preg_replace( '/^steps\./', '', $inner_error['path'] ); - $inner_error['path'] = "{$branch_path}.steps." . $inner_path; - $errors[] = $inner_error; - } - } - - if ( ! empty( $branch['is_aggregator'] ) ) { - ++$aggregator_count; - } - } - - // The aggregator branch is OPTIONAL: zero or one is valid, more than - // one is ambiguous (which output is the step's final?). - if ( $aggregator_count > 1 ) { - $errors[] = array( - 'path' => "{$path}.branches", - 'code' => 'invalid_parallel_aggregator', - 'message' => sprintf( - 'parallel-roles step may flag at most one branch with `is_aggregator` (the aggregator); found %d', - $aggregator_count - ), - ); - } + if ( ! function_exists( 'apply_filters' ) ) { + return $known; } - return $errors; + /** + * Filters the known workflow step types. + * + * @deprecated 0.14.0 Register step types through + * {@see WP_Agent_Workflow_Step_Type_Registry::register()} + * or `register_workflow_step_type()` instead. This + * filter is kept for one release of back-compat and + * will be removed in a future version. + * + * @since 0.103.0 + * + * @param array $known_types Registry-derived known types. + */ + $filtered = (array) apply_filters( 'wp_agent_workflow_known_step_types', $known ); + + return array_values( array_filter( $filtered, 'is_string' ) ); } /** diff --git a/src/Workflows/class-wp-agent-workflow-step-type-registry.php b/src/Workflows/class-wp-agent-workflow-step-type-registry.php new file mode 100644 index 0000000..c37c365 --- /dev/null +++ b/src/Workflows/class-wp-agent-workflow-step-type-registry.php @@ -0,0 +1,500 @@ +}> + */ + private static array $types = array(); + + /** + * Whether the built-in step types have been registered for this request. + */ + private static bool $booted = false; + + /** + * Register a step type's handler and field validation contract. + * + * Registering an already-registered type keeps the first registration + * and triggers `_doing_it_wrong` — no silent overwrite. This mirrors the + * duplicate-registration guard other agents-api registries use. + * + * @since 0.14.0 + * + * @param string $type Step type name (`ability`, `agent`, `foreach`, `parallel`, or a consumer type). + * @param array{ + * handler?: mixed, + * validate?: mixed, + * required?: mixed, + * } $args Registration arguments: + * - `handler` (callable, required): receives ( array $resolved_step, array $context ), + * returns array|WP_Error. + * - `validate` (callable, optional): receives ( array $step, string $path ), returns + * a list of `{path,code,message}` structured errors. + * - `required` (list, optional): field names checked for a non-empty string value. + * Types are declared as `mixed` here (rather than the documented shapes above) because this + * is a public registration boundary — a caller can pass the wrong shape at runtime and this + * method is exactly what validates and rejects that, rather than trusting the PHPDoc contract. + * @return bool True when registered, false when the type name was invalid, the handler + * wasn't callable, or the type was already registered. + */ + public static function register( string $type, array $args ): bool { + self::ensure_booted(); + + if ( '' === $type ) { + return false; + } + + if ( isset( self::$types[ $type ] ) ) { + if ( function_exists( '_doing_it_wrong' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + 'Workflow step type `%s` is already registered. The existing registration is kept.', + function_exists( 'esc_html' ) ? esc_html( $type ) : $type + ), + '0.14.0' + ); + } + return false; + } + + $handler = $args['handler'] ?? null; + if ( ! self::is_callable_shape( $handler ) ) { + if ( function_exists( '_doing_it_wrong' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + 'Workflow step type `%s` was not registered: a callable `handler` is required.', + function_exists( 'esc_html' ) ? esc_html( $type ) : $type + ), + '0.14.0' + ); + } + return false; + } + + $validate = $args['validate'] ?? null; + $required = array(); + if ( isset( $args['required'] ) && is_array( $args['required'] ) ) { + foreach ( $args['required'] as $field ) { + if ( is_string( $field ) && '' !== $field ) { + $required[] = $field; + } + } + } + + self::$types[ $type ] = array( + 'handler' => $handler, + 'validate' => self::is_callable_shape( $validate ) ? $validate : null, + 'required' => $required, + ); + + return true; + } + + /** + * Look up a registered step type's raw registration. + * + * @since 0.14.0 + * + * @param string $type Step type name. + * @return array{handler:callable,validate:?callable,required:array}|null + */ + public static function get( string $type ): ?array { + self::ensure_booted(); + return self::$types[ $type ] ?? null; + } + + /** + * All registered step types, keyed by type name. + * + * @since 0.14.0 + * + * @return array}> + */ + public static function all(): array { + self::ensure_booted(); + return self::$types; + } + + /** + * Registered step type names, in registration order. + * + * @since 0.14.0 + * + * @return array + */ + public static function types(): array { + self::ensure_booted(); + return array_keys( self::$types ); + } + + /** + * Registered step type → handler map, for the runner and reconcile resolver. + * + * @since 0.14.0 + * + * @return array + */ + public static function handlers(): array { + self::ensure_booted(); + $handlers = array(); + foreach ( self::$types as $type => $entry ) { + $handlers[ $type ] = $entry['handler']; + } + return $handlers; + } + + /** + * Validate one resolved step against its registered type's contract: + * the generic `required` field check first, then the type's `validate` + * callback. Unregistered types produce no errors here — the caller + * (the spec validator) is responsible for rejecting unknown types + * before reaching this point. + * + * @since 0.14.0 + * + * @param array $step Raw step, including its `type`. + * @param string $path Error path prefix for this step (e.g. `steps.0`). + * @return array + */ + public static function validate_step( array $step, string $path ): array { + self::ensure_booted(); + + $type = is_string( $step['type'] ?? null ) ? $step['type'] : ''; + if ( '' === $type || ! isset( self::$types[ $type ] ) ) { + return array(); + } + + $entry = self::$types[ $type ]; + $errors = array(); + + foreach ( $entry['required'] as $field ) { + if ( empty( $step[ $field ] ) || ! is_string( $step[ $field ] ) ) { + $errors[] = array( + 'path' => "{$path}.{$field}", + 'code' => 'missing_required', + 'message' => sprintf( '%s step is missing a non-empty `%s`', $type, $field ), + ); + } + } + + if ( is_callable( $entry['validate'] ) ) { + $callback_result = call_user_func( $entry['validate'], $step, $path ); + $errors = array_merge( $errors, self::normalize_errors( is_array( $callback_result ) ? $callback_result : array() ) ); + } + + return $errors; + } + + /** + * Filter a `validate` callback's raw return value down to well-formed + * `{path,code,message}` error tuples, dropping anything malformed + * instead of trusting a consumer-supplied callback's shape. + * + * @param array $raw Raw return value from a `validate` callback. + * @return array + */ + private static function normalize_errors( array $raw ): array { + $errors = array(); + foreach ( $raw as $error ) { + if ( + is_array( $error ) + && isset( $error['path'], $error['code'], $error['message'] ) + && is_string( $error['path'] ) + && is_string( $error['code'] ) + && is_string( $error['message'] ) + ) { + $errors[] = array( + 'path' => $error['path'], + 'code' => $error['code'], + 'message' => $error['message'], + ); + } + } + return $errors; + } + + /** + * Test-only: clear the registry, including the built-in registrations, + * so the next access re-boots from a clean slate. + * + * @since 0.14.0 + */ + public static function reset(): void { + self::$types = array(); + self::$booted = false; + } + + /** + * Structural callable check that never triggers autoloading. + * + * `is_callable()` on a `[class-string, method]` pair resolves the class + * through the autoloader to verify the method exists — which is exactly + * the wrong thing to do at registration time. Agents-api's own built-ins + * point at `WP_Agent_Workflow_Runner`, and consumers register their own + * handlers, often before that class (or the consumer's own class) has + * been loaded yet in the current request. This only checks the shape — + * a `Closure`, non-empty string, `[target, method]` pair, or invokable + * object — and defers real callability to `call_user_func()` at + * dispatch time, same as the pre-registry code that stored these + * handlers as plain array values without ever validating them. + * + * @param mixed $value Candidate handler or validate callback. + * @phpstan-assert-if-true callable $value + */ + private static function is_callable_shape( $value ): bool { + if ( $value instanceof \Closure ) { + return true; + } + + if ( is_string( $value ) ) { + return '' !== $value; + } + + if ( is_array( $value ) ) { + if ( 2 !== count( $value ) ) { + return false; + } + $target = $value[0] ?? null; + $method = $value[1] ?? null; + return ( is_string( $target ) || is_object( $target ) ) && is_string( $method ) && '' !== $method; + } + + return is_object( $value ) && method_exists( $value, '__invoke' ); + } + + /** + * Lazily register the built-in step types on first access. Lazy + * (rather than hooked to a WordPress action) so the registry behaves + * identically in and out of a full WordPress bootstrap, including in + * plain-PHP smoke tests that never fire `init`. + */ + private static function ensure_booted(): void { + if ( self::$booted ) { + return; + } + self::$booted = true; + self::register_builtin_types(); + } + + /** + * Register agents-api's four built-in step types. Field checks mirror + * the checks {@see WP_Agent_Workflow_Spec_Validator} performed inline + * before this registry existed — moved here so the validator, runner, + * and reconcile resolver read one contract instead of three. + */ + private static function register_builtin_types(): void { + self::register( + 'ability', + array( + 'handler' => array( WP_Agent_Workflow_Runner::class, 'default_ability_handler' ), + 'required' => array( 'ability' ), + ) + ); + + self::register( + 'agent', + array( + 'handler' => array( WP_Agent_Workflow_Runner::class, 'default_agent_handler' ), + 'required' => array( 'agent', 'message' ), + ) + ); + + self::register( + 'foreach', + array( + 'handler' => array( WP_Agent_Workflow_Runner::class, 'default_foreach_handler' ), + 'validate' => array( __CLASS__, 'validate_foreach_step' ), + ) + ); + + self::register( + 'parallel', + array( + 'handler' => array( WP_Agent_Workflow_Runner::class, 'default_parallel_handler' ), + 'validate' => array( __CLASS__, 'validate_parallel_step' ), + ) + ); + } + + /** + * `foreach` step field validation: requires an `items` key (any value, + * including a falsy one, is fine structurally — the runner resolves and + * type-checks it at run time) and a non-empty, list-shaped nested + * `steps`, recursively validated. + * + * @param array $step Raw `foreach` step. + * @param string $path Error path prefix for this step. + * @return array + */ + private static function validate_foreach_step( array $step, string $path ): array { + $errors = array(); + + if ( ! array_key_exists( 'items', $step ) ) { + $errors[] = array( + 'path' => "{$path}.items", + 'code' => 'missing_required', + 'message' => 'foreach step is missing required `items` field', + ); + } + + if ( empty( $step['steps'] ) || ! is_array( $step['steps'] ) || array_values( $step['steps'] ) !== $step['steps'] ) { + $errors[] = array( + 'path' => "{$path}.steps", + 'code' => 'missing_required', + 'message' => 'foreach step must declare a non-empty `steps` list', + ); + } else { + foreach ( WP_Agent_Workflow_Spec_Validator::validate_steps( $step['steps'] ) as $inner_error ) { + $inner_path = (string) preg_replace( '/^steps\./', '', $inner_error['path'] ); + $inner_error['path'] = "{$path}.steps." . $inner_path; + $errors[] = $inner_error; + } + } + + return $errors; + } + + /** + * `parallel` step field validation. The one step type expresses two + * shapes; exactly one must be present: + * + * - parallel-map: `items` + a non-empty nested `steps` list. + * - parallel-roles: a non-empty `branches` list, each branch a role + * contract with a `role` + nested `steps`. At most one branch may be + * flagged `is_aggregator` (the optional aggregator); zero is valid. + * + * @param array $step Raw `parallel` step. + * @param string $path Error path prefix for this step. + * @return array + */ + private static function validate_parallel_step( array $step, string $path ): array { + $errors = array(); + $has_branches = isset( $step['branches'] ); + $has_items = array_key_exists( 'items', $step ); + + if ( $has_branches === $has_items ) { + $errors[] = array( + 'path' => $path, + 'code' => 'invalid_parallel_shape', + 'message' => 'parallel step must declare exactly one of `branches` (roles) or `items` (map)', + ); + // Without a clear shape there's nothing further to validate. + if ( ! $has_branches && ! $has_items ) { + return $errors; + } + } + + // parallel-map shape. + if ( $has_items ) { + if ( empty( $step['steps'] ) || ! is_array( $step['steps'] ) || array_values( $step['steps'] ) !== $step['steps'] ) { + $errors[] = array( + 'path' => "{$path}.steps", + 'code' => 'missing_required', + 'message' => 'parallel-map step must declare a non-empty `steps` list', + ); + } else { + foreach ( WP_Agent_Workflow_Spec_Validator::validate_steps( $step['steps'] ) as $inner_error ) { + $inner_path = (string) preg_replace( '/^steps\./', '', $inner_error['path'] ); + $inner_error['path'] = "{$path}.steps." . $inner_path; + $errors[] = $inner_error; + } + } + } + + // parallel-roles shape. + if ( $has_branches ) { + if ( ! is_array( $step['branches'] ) || array_values( $step['branches'] ) !== $step['branches'] || empty( $step['branches'] ) ) { + $errors[] = array( + 'path' => "{$path}.branches", + 'code' => 'missing_required', + 'message' => 'parallel-roles step must declare a non-empty list of `branches`', + ); + return $errors; + } + + $aggregator_count = 0; + foreach ( $step['branches'] as $branch_idx => $branch ) { + $branch_path = "{$path}.branches.{$branch_idx}"; + if ( ! is_array( $branch ) ) { + $errors[] = array( + 'path' => $branch_path, + 'code' => 'invalid_type', + 'message' => 'parallel branch entry must be an array', + ); + continue; + } + + if ( empty( $branch['role'] ) || ! is_string( $branch['role'] ) ) { + $errors[] = array( + 'path' => "{$branch_path}.role", + 'code' => 'missing_required', + 'message' => 'parallel branch is missing a non-empty `role`', + ); + } + + if ( empty( $branch['steps'] ) || ! is_array( $branch['steps'] ) || array_values( $branch['steps'] ) !== $branch['steps'] ) { + $errors[] = array( + 'path' => "{$branch_path}.steps", + 'code' => 'missing_required', + 'message' => 'parallel branch must declare a non-empty `steps` list', + ); + } else { + foreach ( WP_Agent_Workflow_Spec_Validator::validate_steps( $branch['steps'] ) as $inner_error ) { + $inner_path = (string) preg_replace( '/^steps\./', '', $inner_error['path'] ); + $inner_error['path'] = "{$branch_path}.steps." . $inner_path; + $errors[] = $inner_error; + } + } + + if ( ! empty( $branch['is_aggregator'] ) ) { + ++$aggregator_count; + } + } + + // The aggregator branch is OPTIONAL: zero or one is valid, more than + // one is ambiguous (which output is the step's final?). + if ( $aggregator_count > 1 ) { + $errors[] = array( + 'path' => "{$path}.branches", + 'code' => 'invalid_parallel_aggregator', + 'message' => sprintf( + 'parallel-roles step may flag at most one branch with `is_aggregator` (the aggregator); found %d', + $aggregator_count + ), + ); + } + } + + return $errors; + } +} diff --git a/src/Workflows/register-reconcile-workflow-branch.php b/src/Workflows/register-reconcile-workflow-branch.php index 5665a82..c452b5e 100644 --- a/src/Workflows/register-reconcile-workflow-branch.php +++ b/src/Workflows/register-reconcile-workflow-branch.php @@ -953,15 +953,16 @@ function agents_workflow_resolve_runner( WP_Agent_Workflow_Run_Recorder $recorde * @return array */ function agents_workflow_resolve_step_handlers(): array { + /** + * @deprecated 0.14.0 Register step types through + * {@see WP_Agent_Workflow_Step_Type_Registry::register()} + * or `register_workflow_step_type()` instead. Kept for one + * release of back-compat. + */ /** @var array $handlers */ $handlers = (array) apply_filters( 'wp_agent_workflow_step_handlers', - array( - 'ability' => array( WP_Agent_Workflow_Runner::class, 'default_ability_handler' ), - 'agent' => array( WP_Agent_Workflow_Runner::class, 'default_agent_handler' ), - 'foreach' => array( WP_Agent_Workflow_Runner::class, 'default_foreach_handler' ), - 'parallel' => array( WP_Agent_Workflow_Runner::class, 'default_parallel_handler' ), - ) + WP_Agent_Workflow_Step_Type_Registry::handlers() ); return $handlers; diff --git a/src/Workflows/register-workflow-step-types.php b/src/Workflows/register-workflow-step-types.php new file mode 100644 index 0000000..f918cc4 --- /dev/null +++ b/src/Workflows/register-workflow-step-types.php @@ -0,0 +1,50 @@ + $step, string $path): array, + * required?: array, + * } $args Registration arguments: + * - `handler` (callable, required): receives ( array $resolved_step, array $context ), + * returns array|WP_Error. Dispatched by the runner exactly like a built-in step type. + * - `validate` (callable, optional): receives ( array $step, string $path ), returns a list of + * `{path,code,message}` structured errors (empty when valid). Runs during structural + * validation, before any handler executes. + * - `required` (list, optional): field names that must be present as non-empty + * strings. Checked by the registry itself before `validate` runs; use this for simple + * required-field contracts instead of writing a `validate` callback for them. + * @return bool True when registered, false when the handler wasn't callable or the type was + * already registered. + */ +function register_workflow_step_type( string $type, array $args ): bool { + return WP_Agent_Workflow_Step_Type_Registry::register( $type, $args ); +} diff --git a/tests/workflow-as-branch-smoke.php b/tests/workflow-as-branch-smoke.php index f8e1da5..810a6cf 100644 --- a/tests/workflow-as-branch-smoke.php +++ b/tests/workflow-as-branch-smoke.php @@ -314,6 +314,7 @@ function smoke_assert_true( $actual, string $name, array &$failures, int &$passe } require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-result.php'; diff --git a/tests/workflow-async-branch-payload-smoke.php b/tests/workflow-async-branch-payload-smoke.php index 7379420..4a711f3 100644 --- a/tests/workflow-async-branch-payload-smoke.php +++ b/tests/workflow-async-branch-payload-smoke.php @@ -322,6 +322,7 @@ function smoke_assert_true( $actual, string $name, array &$failures, int &$passe } require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-result.php'; diff --git a/tests/workflow-cancel-fencing-smoke.php b/tests/workflow-cancel-fencing-smoke.php index a51ad0d..0070dc8 100644 --- a/tests/workflow-cancel-fencing-smoke.php +++ b/tests/workflow-cancel-fencing-smoke.php @@ -47,6 +47,7 @@ function apply_filters( string $hook, $value, ...$args ) { require_once __DIR__ . '/../src/Runtime/interface-wp-agent-exclusive-run-control-store.php'; require_once __DIR__ . '/../src/Runtime/class-wp-agent-run-control-store-exception.php'; require_once __DIR__ . '/../src/Runtime/class-wp-agent-run-control.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Runtime/class-wp-agent-run-result-envelope.php'; diff --git a/tests/workflow-lifecycle-smoke.php b/tests/workflow-lifecycle-smoke.php index 10aff66..8daaf36 100644 --- a/tests/workflow-lifecycle-smoke.php +++ b/tests/workflow-lifecycle-smoke.php @@ -110,6 +110,7 @@ function as_unschedule_all_actions( string $hook, array $args = array(), string } } +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-action-scheduler-bridge.php'; diff --git a/tests/workflow-parallel-async-smoke.php b/tests/workflow-parallel-async-smoke.php index 115af11..e640f45 100644 --- a/tests/workflow-parallel-async-smoke.php +++ b/tests/workflow-parallel-async-smoke.php @@ -151,6 +151,7 @@ function smoke_assert_true( $actual, string $name, array &$failures, int &$passe } require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-result.php'; diff --git a/tests/workflow-parallel-smoke.php b/tests/workflow-parallel-smoke.php index dba33cb..404a8f9 100644 --- a/tests/workflow-parallel-smoke.php +++ b/tests/workflow-parallel-smoke.php @@ -141,6 +141,7 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & } require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-result.php'; diff --git a/tests/workflow-reconcile-branch-authorization-smoke.php b/tests/workflow-reconcile-branch-authorization-smoke.php index 8f40909..0cf4897 100644 --- a/tests/workflow-reconcile-branch-authorization-smoke.php +++ b/tests/workflow-reconcile-branch-authorization-smoke.php @@ -172,6 +172,7 @@ function smoke_assert_true( $actual, string $name, array &$failures, int &$passe } require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-result.php'; diff --git a/tests/workflow-reconcile-race-smoke.php b/tests/workflow-reconcile-race-smoke.php index 7d7c5c5..9f2bcff 100644 --- a/tests/workflow-reconcile-race-smoke.php +++ b/tests/workflow-reconcile-race-smoke.php @@ -180,6 +180,7 @@ function smoke_assert_true( $actual, string $name, array &$failures, int &$passe } require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-result.php'; diff --git a/tests/workflow-request-controller-smoke.php b/tests/workflow-request-controller-smoke.php index 8a807ee..f6e9f44 100644 --- a/tests/workflow-request-controller-smoke.php +++ b/tests/workflow-request-controller-smoke.php @@ -15,6 +15,7 @@ public function get_error_data(): mixed { return $this->data; } require_once __DIR__ . '/../src/Runtime/interface-wp-agent-exclusive-run-control-store.php'; require_once __DIR__ . '/../src/Runtime/class-wp-agent-run-control-store-exception.php'; require_once __DIR__ . '/../src/Runtime/class-wp-agent-run-control.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Runtime/class-wp-agent-run-result-envelope.php'; diff --git a/tests/workflow-runner-smoke.php b/tests/workflow-runner-smoke.php index 8768915..3027440 100644 --- a/tests/workflow-runner-smoke.php +++ b/tests/workflow-runner-smoke.php @@ -177,6 +177,7 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & } require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-result.php'; diff --git a/tests/workflow-scoped-seams-smoke.php b/tests/workflow-scoped-seams-smoke.php index 1d3e50e..b2f0ba8 100644 --- a/tests/workflow-scoped-seams-smoke.php +++ b/tests/workflow-scoped-seams-smoke.php @@ -282,6 +282,7 @@ function smoke_assert_true( $actual, string $name, array &$failures, int &$passe } require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-result.php'; diff --git a/tests/workflow-spec-validator-smoke.php b/tests/workflow-spec-validator-smoke.php index c1e4523..c6d1d00 100644 --- a/tests/workflow-spec-validator-smoke.php +++ b/tests/workflow-spec-validator-smoke.php @@ -61,6 +61,7 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & echo ' actual: ' . var_export( $actual, true ) . "\n"; } +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; diff --git a/tests/workflow-step-type-registry-smoke.php b/tests/workflow-step-type-registry-smoke.php new file mode 100644 index 0000000..2385426 --- /dev/null +++ b/tests/workflow-step-type-registry-smoke.php @@ -0,0 +1,386 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data() { return $this->data; } + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $value ): bool { + return $value instanceof WP_Error; + } +} + +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( $value ) { + return $value; + } +} + +$GLOBALS['__filters'] = array(); +$GLOBALS['__diw_calls'] = array(); + +if ( ! function_exists( 'add_filter' ) ) { + function add_filter( string $hook, callable $cb, int $priority = 10, int $accepted_args = 1 ): void { + unset( $accepted_args ); + $GLOBALS['__filters'][ $hook ][ $priority ][] = $cb; + } +} +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$args ) { + $cbs = $GLOBALS['__filters'][ $hook ] ?? array(); + ksort( $cbs ); + foreach ( $cbs as $bucket ) { + foreach ( $bucket as $cb ) { + $value = call_user_func_array( $cb, array_merge( array( $value ), $args ) ); + } + } + return $value; + } +} +if ( ! function_exists( '_doing_it_wrong' ) ) { + function _doing_it_wrong( string $function_name, string $message, string $version ): void { + $GLOBALS['__diw_calls'][] = array( + 'function' => $function_name, + 'message' => $message, + 'version' => $version, + ); + } +} +if ( ! function_exists( 'do_action' ) ) { + function do_action( string $hook, ...$args ): void { + $cbs = $GLOBALS['__filters'][ $hook ] ?? array(); + ksort( $cbs ); + foreach ( $cbs as $bucket ) { + foreach ( $bucket as $cb ) { + call_user_func_array( $cb, $args ); + } + } + } +} +if ( ! function_exists( 'wp_get_ability' ) ) { + function wp_get_ability( string $name ) { + unset( $name ); + return null; + } +} +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $option, $default = false ) { + return $GLOBALS['__options'][ $option ] ?? $default; + } +} +if ( ! function_exists( 'update_option' ) ) { + function update_option( string $option, $value, $autoload = null ): bool { + unset( $autoload ); + $GLOBALS['__options'][ $option ] = $value; + return true; + } +} +$GLOBALS['__options'] = array(); + +function smoke_assert( $expected, $actual, string $name, array &$failures, int &$passes ): void { + if ( $expected === $actual ) { + ++$passes; + echo " PASS {$name}\n"; + return; + } + $failures[] = $name; + echo " FAIL {$name}\n"; + echo ' expected: ' . var_export( $expected, true ) . "\n"; + echo ' actual: ' . var_export( $actual, true ) . "\n"; +} + +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-bindings.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-type-registry.php'; +require_once __DIR__ . '/../src/Workflows/register-workflow-step-types.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-result.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-store.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-recorder.php'; +require_once __DIR__ . '/../src/Abilities/class-wp-agent-ability-dispatcher.php'; +require_once __DIR__ . '/../src/Runtime/interface-wp-agent-run-control-store.php'; +require_once __DIR__ . '/../src/Runtime/class-wp-agent-option-run-control-store.php'; +require_once __DIR__ . '/../src/Runtime/class-wp-agent-run-control.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-context.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-executor.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-runner.php'; + +use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Run_Recorder; +use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Run_Result; +use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Runner; +use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Spec; +use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Spec_Validator; +use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Step_Type_Registry; +use function AgentsAPI\AI\Workflows\register_workflow_step_type; + +class Registry_Smoke_Capture_Recorder implements WP_Agent_Workflow_Run_Recorder { + public array $writes = array(); + public function start( WP_Agent_Workflow_Run_Result $result ) { + $this->writes[] = $result->get_status(); + return $result->get_run_id(); + } + public function update( WP_Agent_Workflow_Run_Result $result ) { + $this->writes[] = $result->get_status(); + return true; + } + public function find( string $run_id ): ?WP_Agent_Workflow_Run_Result { return null; } + public function recent( array $args = array() ): array { return array(); } +} + +// ─── Built-ins are registered lazily, before any explicit register() call ── + +smoke_assert( + array( 'ability', 'agent', 'foreach', 'parallel' ), + WP_Agent_Workflow_Step_Type_Registry::types(), + 'built-in step types registered lazily, in order', + $failures, + $passes +); + +// ─── Custom type: required + validate, invalid then valid, then runs ────── + +WP_Agent_Workflow_Step_Type_Registry::reset(); + +$registered = register_workflow_step_type( + 'greet', + array( + 'handler' => static function ( array $step, array $context ) { + unset( $context ); + return array( 'greeting' => 'hello ' . ( $step['name'] ?? '' ) ); + }, + 'required' => array( 'name' ), + 'validate' => static function ( array $step, string $path ): array { + $errors = array(); + if ( isset( $step['name'] ) && is_string( $step['name'] ) && strlen( $step['name'] ) > 40 ) { + $errors[] = array( + 'path' => "{$path}.name", + 'code' => 'name_too_long', + 'message' => 'greet step `name` must be 40 characters or fewer', + ); + } + return $errors; + }, + ) +); +smoke_assert( true, $registered, 'register_workflow_step_type() registers a custom type', $failures, $passes ); + +// Invalid: missing the required `name` field. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/greet-missing', + 'steps' => array( array( 'id' => 'a', 'type' => 'greet' ) ), + ) +); +smoke_assert( 'steps.0.name', $errors[0]['path'] ?? '', 'custom type missing required field has correct path', $failures, $passes ); +smoke_assert( 'missing_required', $errors[0]['code'] ?? '', 'custom type missing required field has correct code', $failures, $passes ); + +// Invalid: the custom `validate` callback's own rule. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/greet-long', + 'steps' => array( array( 'id' => 'a', 'type' => 'greet', 'name' => str_repeat( 'x', 41 ) ) ), + ) +); +$codes = array_column( $errors, 'code' ); +$paths = array_column( $errors, 'path' ); +smoke_assert( true, in_array( 'name_too_long', $codes, true ), 'custom validate callback flags its own rule', $failures, $passes ); +smoke_assert( true, in_array( 'steps.0.name', $paths, true ), 'custom validate callback error has correct path', $failures, $passes ); + +// Valid step passes both the required check and the validate callback. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/greet-ok', + 'steps' => array( array( 'id' => 'a', 'type' => 'greet', 'name' => 'Chris' ) ), + ) +); +smoke_assert( array(), $errors, 'valid custom step passes validation', $failures, $passes ); + +// The registered handler actually runs and produces output. +$handlers = WP_Agent_Workflow_Step_Type_Registry::handlers(); +smoke_assert( true, isset( $handlers['greet'] ) && is_callable( $handlers['greet'] ), 'custom handler is present in handlers()', $failures, $passes ); +$output = call_user_func( $handlers['greet'], array( 'name' => 'Chris' ), array() ); +smoke_assert( array( 'greeting' => 'hello Chris' ), $output, 'custom handler runs and returns its output', $failures, $passes ); + +// ─── Duplicate registration keeps the first, warns, doesn't overwrite ───── + +$GLOBALS['__diw_calls'] = array(); +$second = register_workflow_step_type( + 'greet', + array( + 'handler' => static function ( array $step, array $context ) { + unset( $step, $context ); + return array( 'greeting' => 'should not run' ); + }, + ) +); +smoke_assert( false, $second, 'duplicate registration returns false', $failures, $passes ); +smoke_assert( true, count( $GLOBALS['__diw_calls'] ) > 0, 'duplicate registration triggers _doing_it_wrong', $failures, $passes ); + +$handlers = WP_Agent_Workflow_Step_Type_Registry::handlers(); +$output = call_user_func( $handlers['greet'], array( 'name' => 'Chris' ), array() ); +smoke_assert( array( 'greeting' => 'hello Chris' ), $output, 'duplicate registration keeps the first handler', $failures, $passes ); + +// ─── Legacy two-filter registration still validates and runs ────────────── + +WP_Agent_Workflow_Step_Type_Registry::reset(); +$GLOBALS['__filters'] = array(); + +add_filter( + 'wp_agent_workflow_known_step_types', + static fn( $types ) => array_merge( (array) $types, array( 'legacy_branch' ) ) +); +add_filter( + 'wp_agent_workflow_step_handlers', + static function ( $handlers ) { + $handlers['legacy_branch'] = static function ( array $step, array $context ) { + unset( $step, $context ); + return array( 'legacy_ran' => true ); + }; + return $handlers; + } +); + +// Legacy-filter-registered types get NO registry field validation — this is +// the exact gap #574 calls out for unmigrated consumers. Structural +// validation still passes because the type is "known" via the filter. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/legacy', + 'steps' => array( array( 'id' => 'a', 'type' => 'legacy_branch' ) ), + ) +); +smoke_assert( array(), $errors, 'legacy filter-registered step type passes structural validation', $failures, $passes ); + +$legacy_spec = WP_Agent_Workflow_Spec::from_array( + array( + 'id' => 'demo/legacy-run', + 'steps' => array( array( 'id' => 'a', 'type' => 'legacy_branch' ) ), + ) +); +smoke_assert( false, $legacy_spec instanceof WP_Error, 'legacy spec constructs without WP_Error', $failures, $passes ); + +if ( ! ( $legacy_spec instanceof WP_Error ) ) { + $recorder = new Registry_Smoke_Capture_Recorder(); + $result = ( new WP_Agent_Workflow_Runner( $recorder ) )->run( $legacy_spec ); + smoke_assert( WP_Agent_Workflow_Run_Result::STATUS_SUCCEEDED, $result->get_status(), 'legacy filter-registered handler runs through the runner', $failures, $passes ); + smoke_assert( true, $result->get_output()['last']['legacy_ran'] ?? false, 'legacy filter-registered handler output reaches the run result', $failures, $passes ); +} + +// ─── Built-in error codes are unchanged after the move into the registry ── + +WP_Agent_Workflow_Step_Type_Registry::reset(); +$GLOBALS['__filters'] = array(); + +// ability: missing required `ability`. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/ability-missing', + 'steps' => array( array( 'id' => 'a', 'type' => 'ability' ) ), + ) +); +smoke_assert( 'steps.0.ability', $errors[0]['path'] ?? '', 'ability step missing field: unchanged path', $failures, $passes ); +smoke_assert( 'missing_required', $errors[0]['code'] ?? '', 'ability step missing field: unchanged code', $failures, $passes ); +smoke_assert( 'ability step is missing a non-empty `ability`', $errors[0]['message'] ?? '', 'ability step missing field: unchanged message', $failures, $passes ); + +// agent: missing required `agent` and `message`. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/agent-missing', + 'steps' => array( array( 'id' => 'a', 'type' => 'agent' ) ), + ) +); +$paths = array_column( $errors, 'path' ); +smoke_assert( true, in_array( 'steps.0.agent', $paths, true ), 'agent step missing `agent`: unchanged path', $failures, $passes ); +smoke_assert( true, in_array( 'steps.0.message', $paths, true ), 'agent step missing `message`: unchanged path', $failures, $passes ); + +// foreach: missing `items` and non-list `steps`. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/foreach-missing', + 'steps' => array( array( 'id' => 'a', 'type' => 'foreach' ) ), + ) +); +$paths = array_column( $errors, 'path' ); +$codes = array_column( $errors, 'code' ); +smoke_assert( true, in_array( 'steps.0.items', $paths, true ), 'foreach step missing `items`: unchanged path', $failures, $passes ); +smoke_assert( true, in_array( 'steps.0.steps', $paths, true ), 'foreach step missing `steps`: unchanged path', $failures, $passes ); +smoke_assert( true, in_array( 'missing_required', $codes, true ), 'foreach step missing fields: unchanged code', $failures, $passes ); + +// parallel: neither `branches` nor `items`. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/parallel-shapeless', + 'steps' => array( array( 'id' => 'a', 'type' => 'parallel' ) ), + ) +); +smoke_assert( 'invalid_parallel_shape', $errors[0]['code'] ?? '', 'parallel step with no shape: unchanged code', $failures, $passes ); + +// parallel-roles: more than one aggregator. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/parallel-two-aggregators', + 'steps' => array( + array( + 'id' => 'a', + 'type' => 'parallel', + 'branches' => array( + array( + 'role' => 'one', + 'is_aggregator' => true, + 'steps' => array( array( 'id' => 'a1', 'type' => 'ability', 'ability' => 'x' ) ), + ), + array( + 'role' => 'two', + 'is_aggregator' => true, + 'steps' => array( array( 'id' => 'a2', 'type' => 'ability', 'ability' => 'y' ) ), + ), + ), + ), + ), + ) +); +$codes = array_column( $errors, 'code' ); +smoke_assert( true, in_array( 'invalid_parallel_aggregator', $codes, true ), 'parallel-roles with two aggregators: unchanged code', $failures, $passes ); + +// foreach nested-step errors still report a nested path via the same recursive validator. +$errors = WP_Agent_Workflow_Spec_Validator::validate( + array( + 'id' => 'demo/foreach-nested', + 'steps' => array( + array( + 'id' => 'each', + 'type' => 'foreach', + 'items' => array(), + 'steps' => array( array( 'id' => 'inner', 'type' => 'agent' ) ), + ), + ), + ) +); +$paths = array_column( $errors, 'path' ); +smoke_assert( true, in_array( 'steps.0.steps.0.agent', $paths, true ), 'foreach nested step error reports nested path unchanged', $failures, $passes ); + +echo "Passed: {$passes}, Failed: " . count( $failures ) . "\n"; +exit( count( $failures ) > 0 ? 1 : 0 );