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
179 changes: 139 additions & 40 deletions src/Runtime/class-wp-agent-run-result-envelope.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,20 @@ final class WP_Agent_Run_Result_Envelope {
public const STATUS_INTERRUPTED = 'interrupted';

/**
* @param array<string,mixed> $outputs Consumer-defined outputs.
* @param array<int,array<string,mixed>> $artifact_refs Canonical artifact references.
* @param array<int,array<string,mixed>> $evidence_refs Canonical evidence/log references.
* @param array<string,mixed> $replay Replay/materialization metadata.
* @param array<string,mixed> $provenance Producer/source metadata.
* @param array<string,mixed> $timestamps started_at/ended_at/updated_at values.
* @param array<string,mixed> $error Stable error envelope.
* @param array<string,mixed> $cancellation Cancellation request/result metadata.
* @param array<string,mixed> $metadata Host/runtime metadata.
* @param array<int,array<string,mixed>> $logs Canonical log entries.
* @param array<string,mixed> $outputs Consumer-defined outputs.
* @param array<int,array<string,mixed>> $artifact_refs Canonical artifact references.
* @param array<int,array<string,mixed>> $evidence_refs Canonical evidence/log references.
* @param array<string,mixed> $replay Replay/materialization metadata.
* @param array<string,mixed> $provenance Producer/source metadata.
* @param array<string,mixed> $timestamps started_at/ended_at/updated_at values.
* @param array<string,mixed> $error Stable error envelope.
* @param array<string,mixed> $cancellation Cancellation request/result metadata.
* @param array<string,mixed> $metadata Host/runtime metadata.
* @param array<int,array<string,mixed>> $logs Canonical log entries.
* @param string $status_detail Opaque, consumer-defined raw status/reason preserved verbatim alongside the canonical `status`.
* @param array<int,array<string,mixed>> $steps Per-step result records for durable multi-step runs.
* @param string $parent_run_id The id of the run this run was fanned out from, if any.
* @param array<int,array<string,mixed>> $child_run_refs Canonical references to child runs fanned out from this run.
*/
public function __construct(
private string $run_id,
Expand All @@ -57,19 +61,25 @@ public function __construct(
private array $error = array(),
private array $cancellation = array(),
private array $metadata = array(),
private array $logs = array()
private array $logs = array(),
private string $status_detail = '',
private array $steps = array(),
private string $parent_run_id = '',
private array $child_run_refs = 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 );
$this->error = self::map_value( $this->error );
$this->cancellation = self::map_value( $this->cancellation );
$this->metadata = self::map_value( $this->metadata );
$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 );
$this->error = self::map_value( $this->error );
$this->cancellation = self::map_value( $this->cancellation );
$this->metadata = self::map_value( $this->metadata );
$this->steps = self::normalize_entries( $this->steps );
$this->child_run_refs = self::normalize_refs( $this->child_run_refs );
}

/** @return array<int,string> */
Expand Down Expand Up @@ -104,9 +114,18 @@ public static function from_array( array $value ): self {
}
}

$raw_status = $value['status'] ?? null;
$status_detail = self::string_value( $value['status_detail'] ?? '' );
if ( '' === $status_detail ) {
$raw_status_normalized = strtolower( trim( self::string_value( $raw_status ) ) );
if ( '' !== $raw_status_normalized && ! in_array( $raw_status_normalized, self::statuses(), true ) ) {
$status_detail = self::string_value( $raw_status );
}
}

return new self(
self::string_value( $value['run_id'] ?? '' ),
self::normalize_status( $value['status'] ?? null ),
self::normalize_status( $raw_status ),
self::map_value( $value['outputs'] ?? ( $value['output'] ?? array() ) ),
self::normalize_refs( $value['artifact_refs'] ?? array() ),
self::normalize_refs( $value['evidence_refs'] ?? array() ),
Expand All @@ -116,13 +135,64 @@ public static function from_array( array $value ): self {
self::map_value( $value['error'] ?? array() ),
self::map_value( $value['cancellation'] ?? array() ),
self::map_value( $value['metadata'] ?? array() ),
self::normalize_entries( $value['logs'] ?? array() )
self::normalize_entries( $value['logs'] ?? array() ),
$status_detail,
self::normalize_entries( $value['steps'] ?? array() ),
self::string_value( $value['parent_run_id'] ?? '' ),
self::normalize_refs( $value['child_run_refs'] ?? array() )
);
}

/**
* Normalize a raw status into the canonical, bounded vocabulary.
*
* Exact enum matches win outright. Empty/null input maps to `running`,
* matching historical behavior for in-progress producers that build the
* envelope before a terminal status is known. Any other unrecognised
* input other than the parked-but-alive states `suspended`/`waiting`
* (which map to `running`) is treated as a *finished* run whose canonical shape we can't
* fully infer: a recognisable terminal prefix/suffix (`failed…`,
* `completed…`, `cancel…`, `skipped`/`*_skipped`) maps to that terminal
* value; anything else maps to `incomplete`. Unknown input is never
* coerced to `running`, since that would make a finished run look alive
* to any observer. The raw value is preserved verbatim in
* `status_detail` by {@see self::from_array()}.
*/
public static function normalize_status( mixed $status ): string {
$status = strtolower( trim( self::string_value( $status ) ) );
return in_array( $status, self::statuses(), true ) ? $status : self::STATUS_RUNNING;
$normalized = strtolower( trim( self::string_value( $status ) ) );

if ( in_array( $normalized, self::statuses(), true ) ) {
return $normalized;
}

if ( '' === $normalized ) {
return self::STATUS_RUNNING;
}

// Parked-but-alive states (e.g. a workflow run suspended on async
// branches) are non-terminal: the run will resume. Keep them active;
// the raw value survives in `status_detail`.
if ( in_array( $normalized, array( 'suspended', 'waiting' ), true ) ) {
return self::STATUS_RUNNING;
}

if ( str_starts_with( $normalized, 'failed' ) ) {
return self::STATUS_FAILED;
}

if ( str_starts_with( $normalized, 'completed' ) ) {
return self::STATUS_COMPLETED;
}

if ( str_starts_with( $normalized, 'cancel' ) ) {
return self::STATUS_CANCELLED;
}

if ( str_starts_with( $normalized, 'skipped' ) || str_ends_with( $normalized, '_skipped' ) ) {
return self::STATUS_SKIPPED;
}

return self::STATUS_INCOMPLETE;
}

/**
Expand Down Expand Up @@ -218,23 +288,52 @@ public function get_metadata(): array {
return $this->metadata;
}

/**
* Opaque, consumer-defined raw status/reason, preserved verbatim
* alongside the canonical {@see self::get_status()}.
*/
public function get_status_detail(): string {
return $this->status_detail;
}

/** @return array<int,array<string,mixed>> */
public function get_steps(): array {
return $this->steps;
}

/**
* The id of the run this run was fanned out from, if any.
*/
public function get_parent_run_id(): string {
return $this->parent_run_id;
}

/** @return array<int,array<string,mixed>> */
public function get_child_run_refs(): array {
return $this->child_run_refs;
}

/** @return array<string,mixed> */
public function to_array(): array {
return array(
'schema' => self::SCHEMA,
'version' => self::VERSION,
'run_id' => $this->run_id,
'status' => $this->status,
'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,
'error' => $this->error,
'cancellation' => $this->cancellation,
'metadata' => $this->metadata,
'schema' => self::SCHEMA,
'version' => self::VERSION,
'run_id' => $this->run_id,
'status' => $this->status,
'status_detail' => $this->status_detail,
'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,
'error' => $this->error,
'cancellation' => $this->cancellation,
'metadata' => $this->metadata,
'steps' => $this->steps,
'parent_run_id' => $this->parent_run_id,
'child_run_refs' => $this->child_run_refs,
);
}

Expand Down
1 change: 1 addition & 0 deletions src/Workflows/class-wp-agent-workflow-run-result.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ public function to_run_result_envelope(): WP_Agent_Run_Result_Envelope {
'ended_at' => $this->ended_at,
),
'error' => $this->error,
'steps' => $this->steps,
'metadata' => $this->metadata + array(
'steps' => $this->steps,
'inputs' => $this->inputs,
Expand Down
82 changes: 82 additions & 0 deletions tests/run-result-envelope-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,86 @@
agents_api_smoke_assert_equals( 'demo-agent', $package_envelope->get_outputs()['agent_slug'] ?? '', 'package agent slug maps to outputs', $failures, $passes );
agents_api_smoke_assert_equals( 'agents/demo.md', $package_envelope->get_artifact_refs()[0]['source'] ?? '', 'package recorded artifacts map to artifact refs', $failures, $passes );

echo "\n[6] Multi-step fields round-trip through from_array()/to_array():\n";
$multistep = WP_Agent_Run_Result_Envelope::from_array(
array(
'run_id' => 'run-multistep',
'status' => 'succeeded',
'status_detail' => 'succeeded_no_items',
'steps' => array(
array( 'id' => 'step-1', 'status' => 'succeeded' ),
'ignored',
),
'parent_run_id' => 'run-parent',
'child_run_refs' => array(
array( 'type' => 'run', 'id' => ' run-child-1 ' ),
),
)
);
agents_api_smoke_assert_equals( 'succeeded_no_items', $multistep->get_status_detail(), 'status_detail round-trips verbatim when explicitly supplied', $failures, $passes );
agents_api_smoke_assert_equals( 1, count( $multistep->get_steps() ), 'steps drop non-array entries', $failures, $passes );
agents_api_smoke_assert_equals( 'step-1', $multistep->get_steps()[0]['id'] ?? '', 'steps preserve step record fields', $failures, $passes );
agents_api_smoke_assert_equals( 'run-parent', $multistep->get_parent_run_id(), 'parent_run_id round-trips', $failures, $passes );
agents_api_smoke_assert_equals( 'run-child-1', $multistep->get_child_run_refs()[0]['id'] ?? '', 'child_run_refs normalize like artifact_refs', $failures, $passes );

$multistep_array = $multistep->to_array();
agents_api_smoke_assert_equals( 'succeeded_no_items', $multistep_array['status_detail'] ?? '', 'to_array() carries status_detail', $failures, $passes );
agents_api_smoke_assert_equals( 1, count( $multistep_array['steps'] ?? array() ), 'to_array() carries steps', $failures, $passes );
agents_api_smoke_assert_equals( 'run-parent', $multistep_array['parent_run_id'] ?? '', 'to_array() carries parent_run_id', $failures, $passes );
agents_api_smoke_assert_equals( 'run-child-1', $multistep_array['child_run_refs'][0]['id'] ?? '', 'to_array() carries child_run_refs', $failures, $passes );

$multistep_rehydrated = WP_Agent_Run_Result_Envelope::from_array( $multistep_array );
agents_api_smoke_assert_equals( $multistep_array, $multistep_rehydrated->to_array(), 'multi-step fields survive a full round-trip', $failures, $passes );

echo "\n[7] normalize_status() never coerces unknown/terminal-looking input to running:\n";
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_INCOMPLETE, WP_Agent_Run_Result_Envelope::normalize_status( 'quux' ), 'unrecognised status maps to incomplete, never running', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_COMPLETED, WP_Agent_Run_Result_Envelope::normalize_status( 'completed_no_items' ), 'completed-prefixed status maps to completed', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_FAILED, WP_Agent_Run_Result_Envelope::normalize_status( 'failed - timeout' ), 'failed-prefixed status maps to failed', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_SKIPPED, WP_Agent_Run_Result_Envelope::normalize_status( 'agent_skipped' ), 'skipped-suffixed status maps to skipped', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_RUNNING, WP_Agent_Run_Result_Envelope::normalize_status( 'suspended' ), 'suspended (parked but alive) stays non-terminal', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_RUNNING, WP_Agent_Run_Result_Envelope::normalize_status( 'waiting' ), 'waiting stays non-terminal', $failures, $passes );
$suspended_workflow = WP_Agent_Run_Result_Envelope::from_array( array( 'run_id' => 'r-susp', 'status' => 'suspended' ) );
agents_api_smoke_assert_equals( 'suspended', $suspended_workflow->get_status_detail(), 'suspended raw status preserved in status_detail', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_CANCELLED, WP_Agent_Run_Result_Envelope::normalize_status( 'cancel_requested' ), 'cancel-prefixed status maps to cancelled', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_RUNNING, WP_Agent_Run_Result_Envelope::normalize_status( null ), 'null status preserves the historical running default', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_RUNNING, WP_Agent_Run_Result_Envelope::normalize_status( '' ), 'empty string status preserves the historical running default', $failures, $passes );
foreach ( WP_Agent_Run_Result_Envelope::statuses() as $exact_status ) {
agents_api_smoke_assert_equals( $exact_status, WP_Agent_Run_Result_Envelope::normalize_status( $exact_status ), "exact enum value '{$exact_status}' is unchanged", $failures, $passes );
}

echo "\n[8] from_array() preserves the raw status in status_detail only for lossy coercions:\n";
$unknown_envelope = WP_Agent_Run_Result_Envelope::from_array(
array(
'run_id' => 'run-unknown-status',
'status' => 'completed_no_items',
)
);
agents_api_smoke_assert_equals( WP_Agent_Run_Result_Envelope::STATUS_COMPLETED, $unknown_envelope->get_status(), 'unrecognised terminal-looking status normalizes to completed', $failures, $passes );
agents_api_smoke_assert_equals( 'completed_no_items', $unknown_envelope->get_status_detail(), 'raw status is preserved verbatim in status_detail when not an exact enum match', $failures, $passes );

$exact_envelope = WP_Agent_Run_Result_Envelope::from_array(
array(
'run_id' => 'run-exact-status',
'status' => 'succeeded',
)
);
agents_api_smoke_assert_equals( '', $exact_envelope->get_status_detail(), 'exact enum status does not synthesize a status_detail', $failures, $passes );

echo "\n[9] Workflow run result projection carries steps into the envelope:\n";
$workflow_with_steps = new WP_Agent_Workflow_Run_Result(
'workflow-run-steps',
'build-site',
WP_Agent_Workflow_Run_Result::STATUS_SUCCEEDED,
array(),
array( 'done' => true ),
array( array( 'id' => 'step-1', 'status' => 'succeeded' ), array( 'id' => 'step-2', 'status' => 'succeeded' ) ),
array(),
100,
110,
array()
);
$workflow_steps_envelope = $workflow_with_steps->to_run_result_envelope();
agents_api_smoke_assert_equals( 2, count( $workflow_steps_envelope->get_steps() ), 'workflow projection carries every step record into the canonical steps field', $failures, $passes );
agents_api_smoke_assert_equals( 'step-2', $workflow_steps_envelope->get_steps()[1]['id'] ?? '', 'workflow step records preserve their fields in the canonical steps field', $failures, $passes );

agents_api_smoke_finish( 'Agents API run result envelope', $failures, $passes );
Loading