From a9b06224e474afb9a0510c1a99933bbe6fb25f7b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 3 Jul 2026 12:53:14 -0400 Subject: [PATCH] feat: add first-class artifacts/logs fields to workflow run results --- .../class-wp-agent-run-result-envelope.php | 34 ++++++++++- .../class-wp-agent-workflow-run-result.php | 61 ++++++++++++++++++- .../class-wp-agent-workflow-runner.php | 8 ++- .../register-agents-workflow-abilities.php | 10 ++- tests/agents-workflow-ability-smoke.php | 4 ++ tests/run-result-envelope-smoke.php | 8 ++- tests/workflow-runner-smoke.php | 54 ++++++++++++++++ 7 files changed, 171 insertions(+), 8 deletions(-) diff --git a/src/Runtime/class-wp-agent-run-result-envelope.php b/src/Runtime/class-wp-agent-run-result-envelope.php index d87a7c4..d329172 100644 --- a/src/Runtime/class-wp-agent-run-result-envelope.php +++ b/src/Runtime/class-wp-agent-run-result-envelope.php @@ -43,6 +43,7 @@ final class WP_Agent_Run_Result_Envelope { * @param array $error Stable error envelope. * @param array $cancellation Cancellation request/result metadata. * @param array $metadata Host/runtime metadata. + * @param array> $logs Canonical log entries. */ public function __construct( private string $run_id, @@ -55,12 +56,14 @@ public function __construct( private array $timestamps = array(), private array $error = array(), private array $cancellation = array(), - private array $metadata = array() + private array $metadata = array(), + private array $logs = array() ) { $this->status = self::normalize_status( $this->status ); $this->outputs = self::map_value( $this->outputs ); $this->artifact_refs = self::normalize_refs( $this->artifact_refs ); $this->evidence_refs = self::normalize_refs( $this->evidence_refs ); + $this->logs = self::normalize_entries( $this->logs ); $this->replay = self::map_value( $this->replay ); $this->provenance = self::map_value( $this->provenance ); $this->timestamps = self::timestamps_value( $this->timestamps ); @@ -112,7 +115,8 @@ public static function from_array( array $value ): self { $timestamps, self::map_value( $value['error'] ?? array() ), self::map_value( $value['cancellation'] ?? array() ), - self::map_value( $value['metadata'] ?? array() ) + self::map_value( $value['metadata'] ?? array() ), + self::normalize_entries( $value['logs'] ?? array() ) ); } @@ -179,6 +183,11 @@ public function get_evidence_refs(): array { return $this->evidence_refs; } + /** @return array> */ + public function get_logs(): array { + return $this->logs; + } + /** @return array */ public function get_replay(): array { return $this->replay; @@ -219,6 +228,7 @@ public function to_array(): array { 'outputs' => $this->outputs, 'artifact_refs' => $this->artifact_refs, 'evidence_refs' => $this->evidence_refs, + 'logs' => $this->logs, 'replay' => $this->replay, 'provenance' => $this->provenance, 'timestamps' => $this->timestamps, @@ -248,6 +258,26 @@ private static function map_value( mixed $value ): array { return $map; } + /** + * @param mixed $value Raw entries. + * @return array> + */ + private static function normalize_entries( mixed $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $entries = array(); + foreach ( $value as $entry ) { + $entry = self::map_value( $entry ); + if ( array() !== $entry ) { + $entries[] = $entry; + } + } + + return $entries; + } + /** * @param array $value Raw timestamps. * @return array diff --git a/src/Workflows/class-wp-agent-workflow-run-result.php b/src/Workflows/class-wp-agent-workflow-run-result.php index 31f270b..ea841c8 100644 --- a/src/Workflows/class-wp-agent-workflow-run-result.php +++ b/src/Workflows/class-wp-agent-workflow-run-result.php @@ -58,6 +58,8 @@ final class WP_Agent_Workflow_Run_Result { * @param array $metadata Free-form metadata for recorders / tracers (Langfuse trace ids, etc.). * @param array $evidence_refs Neutral JSON-serializable artifact/log references owned by the host. * @param array $replay_metadata Deterministic metadata needed to replay or audit this run. + * @param array $artifacts Normalized artifact descriptors produced by this run. + * @param array $logs Normalized log entries produced by this run. */ public function __construct( private string $run_id, @@ -71,8 +73,13 @@ public function __construct( private int $ended_at, private array $metadata, private array $evidence_refs = array(), - private array $replay_metadata = array() - ) {} + private array $replay_metadata = array(), + private array $artifacts = array(), + private array $logs = array() + ) { + $this->artifacts = self::normalized_list( $this->artifacts ); + $this->logs = self::normalized_list( $this->logs ); + } /** * @param array $inputs @@ -102,7 +109,9 @@ public static function from_array( array $value ): self { self::int_value( $value['ended_at'] ?? 0 ), self::array_value( $value['metadata'] ?? array() ), self::array_value( $value['evidence_refs'] ?? array() ), - self::array_value( $value['replay'] ?? array() ) + self::array_value( $value['replay'] ?? array() ), + self::array_value( $value['artifacts'] ?? array() ), + self::array_value( $value['logs'] ?? array() ) ); } @@ -175,6 +184,20 @@ public function get_replay_metadata(): array { return $this->replay_metadata; } + /** + * @return array + */ + public function get_artifacts(): array { + return $this->artifacts; + } + + /** + * @return array + */ + public function get_logs(): array { + return $this->logs; + } + public function is_succeeded(): bool { return self::STATUS_SUCCEEDED === $this->status; } @@ -225,7 +248,9 @@ public function to_run_result_envelope(): WP_Agent_Run_Result_Envelope { 'run_id' => $this->run_id, 'status' => $this->status, 'outputs' => $this->output, + 'artifact_refs' => $this->artifacts, 'evidence_refs' => $this->evidence_refs, + 'logs' => $this->logs, 'replay' => $this->replay_metadata, 'provenance' => array( 'workflow_id' => $this->workflow_id ), 'timestamps' => array( @@ -268,6 +293,8 @@ public function with( array $patch ): self { self::array_patch_value( $patch, 'metadata', $this->metadata ), self::array_patch_value( $patch, 'evidence_refs', $this->evidence_refs ), self::array_patch_value( $patch, 'replay', $this->replay_metadata ), + self::array_patch_value( $patch, 'artifacts', $this->artifacts ), + self::array_patch_value( $patch, 'logs', $this->logs ), ); } @@ -309,6 +336,32 @@ private static function array_value( mixed $value ): array { return is_array( $value ) ? $value : array(); } + /** + * @param array $value + * @return array> + */ + private static function normalized_list( array $value ): array { + $normalized = array(); + foreach ( $value as $entry ) { + if ( ! is_array( $entry ) ) { + continue; + } + + $item = array(); + foreach ( $entry as $key => $entry_value ) { + if ( is_string( $key ) ) { + $item[ $key ] = $entry_value; + } + } + + if ( array() !== $item ) { + $normalized[] = $item; + } + } + + return $normalized; + } + /** * Render to a plain array. Useful for recorders that want to serialise * the run record verbatim (CPT meta, JSON column, REST response). @@ -331,6 +384,8 @@ public function to_array(): array { 'metadata' => $this->metadata, 'evidence_refs' => $this->evidence_refs, 'replay' => $this->replay_metadata, + 'artifacts' => $this->artifacts, + 'logs' => $this->logs, ); } } diff --git a/src/Workflows/class-wp-agent-workflow-runner.php b/src/Workflows/class-wp-agent-workflow-runner.php index 06a9d43..3f242a4 100644 --- a/src/Workflows/class-wp-agent-workflow-runner.php +++ b/src/Workflows/class-wp-agent-workflow-runner.php @@ -110,6 +110,8 @@ public function __construct( * - `continue_on_error` (bool): keep running after a failed step. Default false. * - `metadata` (array): forwarded to the run result. * - `evidence_refs` (array): neutral artifact/log references forwarded to the run result. + * - `artifacts` (array): artifact descriptors forwarded to the run result. + * - `logs` (array): log entries forwarded to the run result. * @return WP_Agent_Workflow_Run_Result */ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), array $options = array() ): WP_Agent_Workflow_Run_Result { @@ -117,6 +119,8 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra $run_id = self::string_value( $options['run_id'] ?? self::generate_run_id() ); $metadata = (array) ( $options['metadata'] ?? array() ); $evidence_refs = (array) ( $options['evidence_refs'] ?? array() ); + $artifacts = (array) ( $options['artifacts'] ?? array() ); + $logs = (array) ( $options['logs'] ?? array() ); $replay = self::build_replay_metadata( $spec ); // Build the initial RUNNING result and persist via recorder->start() @@ -135,7 +139,9 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra 0, $metadata, $evidence_refs, - $replay + $replay, + $artifacts, + $logs ); if ( $this->recorder ) { diff --git a/src/Workflows/register-agents-workflow-abilities.php b/src/Workflows/register-agents-workflow-abilities.php index 9a8fc8f..c72815d 100644 --- a/src/Workflows/register-agents-workflow-abilities.php +++ b/src/Workflows/register-agents-workflow-abilities.php @@ -462,7 +462,7 @@ function agents_run_workflow_input_schema(): array { ), 'options' => array( 'type' => 'object', - 'description' => 'Runtime options forwarded to the runner. Recognized keys: run_id, continue_on_error, metadata, evidence_refs.', + 'description' => 'Runtime options forwarded to the runner. Recognized keys: run_id, continue_on_error, metadata, evidence_refs, artifacts, logs.', 'default' => array(), ), ), @@ -498,6 +498,14 @@ function agents_run_workflow_output_schema(): array { 'type' => 'array', 'description' => 'Neutral JSON-serializable artifact/log references owned by the host runtime.', ), + 'artifacts' => array( + 'type' => 'array', + 'description' => 'Normalized artifact descriptors produced by the workflow run.', + ), + 'logs' => array( + 'type' => 'array', + 'description' => 'Normalized log entries produced by the workflow run.', + ), 'replay' => array( 'type' => 'object', 'properties' => array( diff --git a/tests/agents-workflow-ability-smoke.php b/tests/agents-workflow-ability-smoke.php index 4c3064a..0a2df6c 100644 --- a/tests/agents-workflow-ability-smoke.php +++ b/tests/agents-workflow-ability-smoke.php @@ -93,7 +93,11 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & $input_schema = agents_run_workflow_input_schema(); $output_schema = agents_run_workflow_output_schema(); smoke_assert( true, str_contains( $input_schema['properties']['options']['description'] ?? '', 'evidence_refs' ), 'input schema documents evidence_refs runtime option', $failures, $passes ); +smoke_assert( true, str_contains( $input_schema['properties']['options']['description'] ?? '', 'artifacts' ), 'input schema documents artifacts runtime option', $failures, $passes ); +smoke_assert( true, str_contains( $input_schema['properties']['options']['description'] ?? '', 'logs' ), 'input schema documents logs runtime option', $failures, $passes ); smoke_assert( 'array', $output_schema['properties']['evidence_refs']['type'] ?? '', 'output schema exposes evidence_refs', $failures, $passes ); +smoke_assert( 'array', $output_schema['properties']['artifacts']['type'] ?? '', 'output schema exposes artifacts', $failures, $passes ); +smoke_assert( 'array', $output_schema['properties']['logs']['type'] ?? '', 'output schema exposes logs', $failures, $passes ); smoke_assert( 'object', $output_schema['properties']['replay']['type'] ?? '', 'output schema exposes replay metadata', $failures, $passes ); smoke_assert( 'integer', $output_schema['properties']['replay']['properties']['run_record_schema_version']['type'] ?? '', 'replay schema includes schema version', $failures, $passes ); smoke_assert( 'string', $output_schema['properties']['replay']['properties']['workflow_spec_hash']['type'] ?? '', 'replay schema includes spec hash', $failures, $passes ); diff --git a/tests/run-result-envelope-smoke.php b/tests/run-result-envelope-smoke.php index 49bd6d2..7734d9d 100644 --- a/tests/run-result-envelope-smoke.php +++ b/tests/run-result-envelope-smoke.php @@ -45,6 +45,7 @@ 'ignored', ), 'evidence_refs' => array( array( 'type' => 'log', 'label' => 'runner' ) ), + 'logs' => array( array( 'level' => 'info', 'message' => 'started' ), 'ignored' ), 'replay' => array( 'seed' => 42 ), 'provenance' => array( 'producer' => 'smoke' ), 'started_at' => '2026-06-19T00:00:00Z', @@ -56,6 +57,7 @@ agents_api_smoke_assert_equals( 1, count( $envelope->get_artifact_refs() ), 'artifact refs drop non-array items', $failures, $passes ); agents_api_smoke_assert_equals( 'transcript', $envelope->get_artifact_refs()[0]['label'] ?? '', 'artifact ref string fields trim', $failures, $passes ); agents_api_smoke_assert_equals( 'runner', $envelope->get_evidence_refs()[0]['label'] ?? '', 'evidence refs normalize with same vocabulary', $failures, $passes ); +agents_api_smoke_assert_equals( 'started', $envelope->get_logs()[0]['message'] ?? '', 'logs drop non-array items and preserve string-keyed entries', $failures, $passes ); agents_api_smoke_assert_equals( '2026-06-19T00:00:00Z', $envelope->get_timestamps()['started_at'] ?? '', 'top-level started_at folds into timestamps', $failures, $passes ); echo "\n[2] Runtime package results convert without changing legacy arrays:\n"; @@ -90,13 +92,17 @@ 110, array( 'trace_id' => 'trace-1' ), array( array( 'type' => 'log', 'label' => 'workflow log' ) ), - array( 'workflow_version' => 2 ) + array( 'workflow_version' => 2 ), + array( array( 'type' => 'json', 'label' => 'workflow artifact' ) ), + array( array( 'level' => 'error', 'message' => 'workflow failed' ) ) ); $workflow_envelope = $workflow->to_run_result_envelope(); agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_FAILED, $workflow_envelope->get_status(), 'workflow status maps to canonical failed', $failures, $passes ); agents_api_smoke_assert_equals( 'build-site', $workflow_envelope->get_provenance()['workflow_id'] ?? '', 'workflow id maps to provenance', $failures, $passes ); agents_api_smoke_assert_equals( 2, $workflow_envelope->get_replay()['workflow_version'] ?? 0, 'workflow replay metadata is preserved', $failures, $passes ); agents_api_smoke_assert_equals( 'workflow log', $workflow_envelope->get_evidence_refs()[0]['label'] ?? '', 'workflow evidence refs are preserved', $failures, $passes ); +agents_api_smoke_assert_equals( 'workflow artifact', $workflow_envelope->get_artifact_refs()[0]['label'] ?? '', 'workflow artifacts map to canonical artifact refs', $failures, $passes ); +agents_api_smoke_assert_equals( 'workflow failed', $workflow_envelope->get_logs()[0]['message'] ?? '', 'workflow logs map to canonical logs', $failures, $passes ); echo "\n[4] Task run-control arrays convert to the canonical envelope:\n"; $task_envelope = WP_Agent_Task_Run_Control::to_run_result_envelope( diff --git a/tests/workflow-runner-smoke.php b/tests/workflow-runner-smoke.php index 8bac321..83c7feb 100644 --- a/tests/workflow-runner-smoke.php +++ b/tests/workflow-runner-smoke.php @@ -420,6 +420,60 @@ static function ( array $input ): \WP_Error { smoke_assert( $spec->to_array(), $last_write['result']['replay']['workflow_spec_snapshot'] ?? array(), 'recorder update preserves replay metadata', $failures, $passes ); smoke_assert( true, is_string( json_encode( $evidence_result->get_evidence_refs() ) ), 'evidence refs are JSON-serializable', $failures, $passes ); +// ─── Artifacts and logs stay first-class through results and recorders ─ + +$artifacts = array( + array( + 'type' => 'json', + 'id' => 'artifact:123', + 'label' => 'Workflow artifact', + ), + 'ignored', +); +$logs = array( + array( + 'level' => 'info', + 'message' => 'Workflow run completed.', + ), + 'ignored', +); +$artifact_metadata = array( + 'artifacts' => array( 'legacy metadata artifact stays untouched' ), + 'logs' => array( 'legacy metadata log stays untouched' ), +); +$artifact_recorder = new Capture_Recorder(); +$artifact_result = ( new WP_Agent_Workflow_Runner( $artifact_recorder ) )->run( + $spec, + array( 'text' => 'artifact' ), + array( + 'run_id' => 'artifact-run-1', + 'metadata' => $artifact_metadata, + 'artifacts' => $artifacts, + 'logs' => $logs, + ) +); +$artifact_roundtrip = WP_Agent_Workflow_Run_Result::from_array( $artifact_result->to_array() ); +$artifact_last = end( $artifact_recorder->writes ); +$expected_artifacts = array( $artifacts[0] ); +$expected_logs = array( $logs[0] ); + +smoke_assert( $expected_artifacts, $artifact_result->get_artifacts(), 'result exposes first-class artifacts', $failures, $passes ); +smoke_assert( $expected_logs, $artifact_result->get_logs(), 'result exposes first-class logs', $failures, $passes ); +smoke_assert( $artifact_metadata, $artifact_result->get_metadata(), 'metadata remains untouched when artifacts/logs are first-class', $failures, $passes ); +smoke_assert( $artifact_result->to_array(), $artifact_roundtrip->to_array(), 'artifacts/logs round-trip through to_array/from_array', $failures, $passes ); +smoke_assert( $expected_artifacts, $artifact_last['result']['artifacts'] ?? array(), 'recorder update preserves first-class artifacts', $failures, $passes ); +smoke_assert( $expected_logs, $artifact_last['result']['logs'] ?? array(), 'recorder update preserves first-class logs', $failures, $passes ); + +$legacy_result = WP_Agent_Workflow_Run_Result::from_array( + array( + 'run_id' => 'legacy-run', + 'workflow_id' => 'demo/legacy', + 'status' => WP_Agent_Workflow_Run_Result::STATUS_SUCCEEDED, + ) +); +smoke_assert( array(), $legacy_result->get_artifacts(), 'legacy result defaults artifacts to empty array', $failures, $passes ); +smoke_assert( array(), $legacy_result->get_logs(), 'legacy result defaults logs to empty array', $failures, $passes ); + // ─── Failed step short-circuits ─────────────────────────────────────── $bad_spec = WP_Agent_Workflow_Spec::from_array(