diff --git a/Cargo.toml b/Cargo.toml index b16a502e4..6b31e1d56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } ulid = "1" -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4", "v7"] } rand = "0.8" dotenvy = "0.15" futures = "0.3" diff --git a/docs-internal/events-strategy.md b/docs-internal/events-strategy.md index 70c0c13ce..88a3e40b9 100644 --- a/docs-internal/events-strategy.md +++ b/docs-internal/events-strategy.md @@ -1,312 +1,164 @@ # Fabro Events Strategy -Fabro emits structured **workflow run events** during execution for observability. Events write to `progress.jsonl` (one JSON object per line) and `live.json` (latest event snapshot) inside the run's log directory. Events are the primary record of what happened during a run — they feed the retro system, CLI verbose output, `fabro attach`, `fabro logs`, and live monitoring. +Fabro emits structured **workflow run events** during execution for observability. Events are the durable audit trail for a run: they drive `progress.jsonl`, `live.json`, the run store, SSE streaming, CLI progress rendering, and retro analysis. -Events are distinct from tracing logs (see `logging-strategy.md`). Tracing is developer diagnostics; events are the structured audit trail consumed by tooling. +Events are distinct from tracing logs. Tracing is developer diagnostics; events are product-facing state transitions and activity records that other systems consume. -Detached runs rely on this distinction. If a warning or error needs to be visible to users after reattach, emit a `WorkflowRunEvent` rather than only writing to stderr/`detach.log`. +Detached runs rely on this distinction. If something needs to be visible after reattach, emit a `WorkflowRunEvent` rather than only logging to stderr or `detach.log`. ## Architecture -``` -Engine/Handler → WorkflowRunEvent enum → EventEmitter - ├─ .trace() → tracing log line (automatic) - ├─ flatten_event() → progress.jsonl + live.json - └─ on_event() callbacks → CLI output, cost tracking, etc. +```text +Engine/Handler -> WorkflowRunEvent -> EventEmitter::emit() + |- trace(raw event) + |- canonicalize -> RunEventEnvelope + `- on_event(&RunEventEnvelope) + |- progress.jsonl + live.json + |- run store + |- SSE + `- CLI / tests / metrics listeners ``` -**Three layers:** +The canonical envelope is built exactly once in `fabro-workflow/src/event.rs`. -1. **Rust enum** (`WorkflowRunEvent` in `event.rs`) — the source of truth for event structure. Variants use Rust naming and types. `AgentEvent` and `SandboxEvent` from `fabro-agent` are wrapped as `Agent { stage, event }` and `Sandbox { event }`. +- `WorkflowRunEvent` remains the internal typed source of truth. +- `EventEmitter` owns an immutable `run_id` and converts typed events into `RunEventEnvelope`. +- Every listener receives `&RunEventEnvelope`, not `&WorkflowRunEvent`. +- Bypass paths that cannot go through the emitter must call `canonicalize_event()` once and reuse the same envelope for every sink. -2. **Flattening** (`flatten_event()` in `event.rs`) — serializes the enum via serde, then restructures nested/tagged variants into `(event_name, flat_fields_map)`. Wrapper variants use dot notation: `Agent.ToolCallStarted`, `Sandbox.Initializing`. +## Canonical Envelope -3. **Field renaming** (`rename_fields()` in `event.rs`) — post-processes the flat fields to give them self-describing names for JSONL output. This avoids changing the Rust enum while making the external format unambiguous. - -## JSONL Envelope - -Every line in `progress.jsonl` has three envelope fields, then the event's own fields merged at the top level: +Each line in `progress.jsonl` is a `RunEventEnvelope`: ```json -{"ts":"2025-06-15T12:00:00.123Z","run_id":"01J...","event":"StageCompleted","node_id":"plan","node_label":"Plan","stage_index":0,"duration_ms":5000,"status":"success",...} -``` - -| Envelope field | Type | Description | -|---|---|---| -| `ts` | ISO 8601 string | UTC timestamp with millisecond precision | -| `run_id` | string | ULID for this workflow run | -| `event` | string | Event name (matches Rust variant, dot-separated for wrapped types) | - -The envelope is built in `fabro-workflow/src/event.rs` by `build_event_envelope()`, and file logging is handled by `ProgressLogger`. Field names from the event that collide with envelope keys (`ts`, `run_id`, `event`) are dropped — the `run_id` from `WorkflowRunStarted` populates the envelope itself. - -## Run Completion Contract - -`status.json` is the authoritative completion signal for detached runs. Write a terminal `RunStatus` only after all post-run work is complete, including retro generation, finalize-commit work, pull request creation, and sandbox cleanup. `conclusion.json` should be written at that same final point so `attach`, `wait`, and summary rendering all agree on when the run is actually finished. - -## Node Terminology - -- **`node_id`** — programmatic identifier (the id from the DOT graph). Stable, used for matching. -- **`node_label`** — display name (from the DOT `label` attribute, defaults to `node_id`). Human-readable. - -Every event that references a graph node should include both. Stage events carry both from the Rust enum. Events that only have an id (Agent, ParallelBranch, etc.) get `node_label` defaulted to `node_id` by `rename_fields()`. - -## Field Naming Conventions - -### Rules - -1. **Self-describing** — a field name should be unambiguous without knowing the event type. Use `node_id` not `name`, `stage_index` not `index`, `sandbox_provider` not `provider`. -2. **`_id` suffix** for identifiers — `node_id`, `from_node_id`, `to_node_id`, `start_node_id`, `tool_call_id`, `agent_id`. -3. **`_ms` suffix** for durations — `duration_ms`, `delay_ms`. Always milliseconds. -4. **`_count` suffix** for counts — `branch_count`, `command_count`, `tool_call_count`. -5. **No prefix** for fields that are already unambiguous — `error`, `status`, `command`, `question`, `answer`, `model`. -6. **`snake_case`** for all field names. - -### Rename Table (Rust enum → JSONL) - -The Rust enum uses short field names for ergonomics. `rename_fields()` transforms them for the JSONL output: - -| Rust field | JSONL field | Events | Reason | -|---|---|---|---| -| `name` | `workflow_name` | WorkflowRunStarted | Disambiguate | -| `name` | `node_label` | Stage* | Display name | -| `name` | `snapshot_name` | Sandbox.Snapshot* | Disambiguate | -| `index` | `stage_index` | Stage* | Disambiguate | -| `index` | `branch_index` | ParallelBranch* | Disambiguate | -| `index` | `command_index` | SetupCommand*, SetupFailed, DevcontainerLifecycleCommand*, DevcontainerLifecycleFailed | Disambiguate | -| `stage` | `node_id` | Agent.*, Interview*, Prompt | Unify terminology | -| `branch` | `node_id` | ParallelBranch* | Consistent | -| `node` | `node_id` | StallWatchdogTimeout | Consistent | -| `from_node` | `from_node_id` | EdgeSelected, LoopRestart | `_id` suffix | -| `to_node` | `to_node_id` | EdgeSelected, LoopRestart | `_id` suffix | -| `start_node` | `start_node_id` | SubgraphStarted | `_id` suffix | -| `provider` | `sandbox_provider` | Sandbox.* | Disambiguate | -| `text` | `prompt_text` | Prompt | Disambiguate | -| _(inserted)_ | `node_label` | Agent.*, ParallelBranch*, etc. | Defaults to `node_id` | - -Fields not in this table pass through unchanged. - -## Adding a New Event - -### Step 1: Add to the Rust enum - -Add a variant to `WorkflowRunEvent` in `event.rs`. Use the short Rust field names (they'll be renamed in step 3). - -```rust -MyNewEvent { - node_id: String, // if it references a graph node - name: String, // if it has a display label (will become node_label) - duration_ms: u64, - // ... -}, -``` - -For events that wrap `AgentEvent` or `SandboxEvent`, add the variant to those enums in `fabro-agent` instead — they're automatically wrapped by the existing `Agent { stage, event }` and `Sandbox { event }` variants. - -### Step 2: Add a trace() match arm - -Add a match arm in `WorkflowRunEvent::trace()`. Choose the tracing level per `logging-strategy.md`: -- INFO for lifecycle boundaries (started/completed at the workflow level) -- DEBUG for individual steps (stage started, tool call, etc.) -- WARN for retries and degraded behavior -- ERROR for terminal failures - -```rust -Self::MyNewEvent { node_id, duration_ms, .. } => { - debug!(node_id, duration_ms, "My new event happened"); +{ + "id": "01960d0c-5d16-7d6e-8f61-9fd6f4a532b5", + "ts": "2026-03-30T12:00:01.000Z", + "run_id": "01JQ...", + "event": "agent.tool.started", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "node_id": "code", + "node_label": "Code", + "properties": { + "tool_name": "read_file", + "tool_call_id": "call_1", + "arguments": {"path": "src/main.rs"} + } } ``` -### Step 3: Add rename rules (if needed) +Always-present fields: -If your event has fields that need renaming (ambiguous `name`, `index`, `stage`, etc.), add a branch in `rename_fields()` in `event.rs`. If your event references a graph node and only has `node_id`, call `default_node_label(fields)` to insert `node_label`. - -### Step 4: Emit from engine or handler - -Emit via the `EventEmitter`: - -```rust -self.services.emitter.emit(&WorkflowRunEvent::MyNewEvent { - node_id: node.id.clone(), - name: node.label().to_string(), - duration_ms: elapsed, -}); -``` - -### Step 5: Update format_event_summary - -Add a match arm in `format_event_summary()` in `cli/mod.rs` for `-v` verbose output: - -```rust -WorkflowRunEvent::MyNewEvent { node_id, duration_ms, .. } => { - format!("[MY_NEW_EVENT] node_id={node_id} duration={duration_ms}ms") -} -``` - -### Step 6: Update tests - -- Add a serialization test in `event.rs` (serde round-trip) -- Add a `rename_fields` test if you added rename rules -- Update integration test patterns in `integration.rs` if matching on the new event - -## Complete Event Reference - -### Workflow lifecycle - -| Event | JSONL fields | -|---|---| -| `WorkflowRunStarted` | `workflow_name`, `run_id`, `base_branch`?, `base_sha`?, `run_branch`?, `worktree_dir`? | -| `WorkflowRunCompleted` | `duration_ms`, `artifact_count`, `total_cost`?, `final_git_commit_sha`? | -| `WorkflowRunFailed` | `error`, `duration_ms`, `git_commit_sha`? | -| `RunNotice` | `level`, `code`, `message` | - -### Stage execution - -| Event | JSONL fields | -|---|---| -| `StageStarted` | `node_id`, `node_label`, `stage_index`, `handler_type`?, `attempt`, `max_attempts` | -| `StageCompleted` | `node_id`, `node_label`, `stage_index`, `duration_ms`, `status`, `preferred_label`?, `suggested_next_ids`, `usage`?, `failure_reason`?, `notes`?, `files_touched`, `attempt`, `max_attempts`, `failure_class`? | -| `StageFailed` | `node_id`, `node_label`, `stage_index`, `error`, `will_retry`, `failure_reason`?, `failure_class`? | -| `StageRetrying` | `node_id`, `node_label`, `stage_index`, `attempt`, `max_attempts`, `delay_ms` | - -### Parallel execution - -| Event | JSONL fields | -|---|---| -| `ParallelStarted` | `branch_count`, `join_policy` | -| `ParallelBranchStarted` | `node_id`, `node_label`, `branch_index` | -| `ParallelBranchCompleted` | `node_id`, `node_label`, `branch_index`, `duration_ms`, `status` | -| `ParallelCompleted` | `duration_ms`, `success_count`, `failure_count` | - -### Graph navigation - -| Event | JSONL fields | -|---|---| -| `EdgeSelected` | `from_node_id`, `to_node_id`, `label`?, `condition`? | -| `LoopRestart` | `from_node_id`, `to_node_id` | -| `SubgraphStarted` | `node_id`, `node_label`, `start_node_id` | -| `SubgraphCompleted` | `node_id`, `node_label`, `steps_executed`, `status`, `duration_ms` | - -### Checkpoints and git - -| Event | JSONL fields | -|---|---| -| `CheckpointCompleted` | `node_id`, `node_label`, `status`, `git_commit_sha` (optional) | -| `CheckpointFailed` | `node_id`, `node_label`, `error` | -| `GitCommit` | `node_id` (optional), `node_label` (optional), `sha` | -| `GitPush` | `branch`, `success` | -| `GitBranch` | `branch`, `sha` | -| `GitWorktreeAdd` | `path`, `branch` | -| `GitWorktreeRemove` | `path` | -| `GitFetch` | `branch`, `success` | -| `GitReset` | `sha` | - -### Human interaction - -| Event | JSONL fields | -|---|---| -| `InterviewStarted` | `question`, `node_id`, `node_label`, `question_type` | -| `InterviewCompleted` | `question`, `answer`, `duration_ms` | -| `InterviewTimeout` | `question`, `node_id`, `node_label`, `duration_ms` | -| `Prompt` | `node_id`, `node_label`, `prompt_text` | - -### Setup - -| Event | JSONL fields | -|---|---| -| `SetupStarted` | `command_count` | -| `SetupCommandStarted` | `command`, `command_index` | -| `SetupCommandCompleted` | `command`, `command_index`, `exit_code`, `duration_ms` | -| `SetupCompleted` | `duration_ms` | -| `SetupFailed` | `command`, `command_index`, `exit_code`, `stderr` | - -### Devcontainer - -| Event | JSONL fields | -|---|---| -| `DevcontainerResolved` | `dockerfile_lines`, `environment_count`, `lifecycle_command_count`, `workspace_folder` | -| `DevcontainerLifecycleStarted` | `phase`, `command_count` | -| `DevcontainerLifecycleCommandStarted` | `phase`, `command`, `command_index` | -| `DevcontainerLifecycleCommandCompleted` | `phase`, `command`, `command_index`, `exit_code`, `duration_ms` | -| `DevcontainerLifecycleCompleted` | `phase`, `duration_ms` | -| `DevcontainerLifecycleFailed` | `phase`, `command`, `command_index`, `exit_code`, `stderr` | - -### Stall detection - -| Event | JSONL fields | -|---|---| -| `StallWatchdogTimeout` | `node_id`, `node_label`, `idle_seconds` | - -### Agent events (prefixed `Agent.`) - -All agent events include `node_id` and `node_label`. - -| Event | Additional JSONL fields | -|---|---| -| `Agent.SessionStarted` | _(none)_ | -| `Agent.SessionEnded` | _(none)_ | -| `Agent.UserInput` | `text` | -| `Agent.AssistantTextStart` | _(none)_ | -| `Agent.AssistantMessage` | `text`, `model`, `usage` (object), `tool_call_count` | -| `Agent.TextDelta` | `delta` | -| `Agent.ToolCallStarted` | `tool_name`, `tool_call_id`, `arguments` | -| `Agent.ToolCallOutputDelta` | `delta` | -| `Agent.ToolCallCompleted` | `tool_name`, `tool_call_id`, `output`, `is_error` | -| `Agent.Error` | `error` | -| `Agent.Warning` | `kind`, `message`, `details` | -| `Agent.LoopDetected` | _(none)_ | -| `Agent.TurnLimitReached` | `max_turns` | -| `Agent.SkillExpanded` | `skill_name` | -| `Agent.SteeringInjected` | `text` | -| `Agent.CompactionStarted` | `estimated_tokens`, `context_window_size` | -| `Agent.CompactionCompleted` | `original_turn_count`, `preserved_turn_count`, `summary_token_estimate`, `tracked_file_count` | -| `Agent.LlmRetry` | `provider`, `model`, `attempt`, `delay_secs`, `error` | -| `Agent.SubAgentSpawned` | `agent_id`, `depth`, `task` | -| `Agent.SubAgentCompleted` | `agent_id`, `depth`, `success`, `turns_used` | -| `Agent.SubAgentFailed` | `agent_id`, `depth`, `error` | -| `Agent.SubAgentClosed` | `agent_id`, `depth` | -| `Agent.SubAgentEvent.*` | `agent_id`, `depth`, `nested_event` (JSON) | -| `Agent.McpServerReady` | `server_name`, `tool_count` | -| `Agent.McpServerFailed` | `server_name`, `error` | - -### Sandbox events (prefixed `Sandbox.`) - -| Event | JSONL fields | -|---|---| -| `Sandbox.Initializing` | `sandbox_provider` | -| `Sandbox.Ready` | `sandbox_provider`, `duration_ms` | -| `Sandbox.InitializeFailed` | `sandbox_provider`, `error`, `duration_ms` | -| `Sandbox.CleanupStarted` | `sandbox_provider` | -| `Sandbox.CleanupCompleted` | `sandbox_provider`, `duration_ms` | -| `Sandbox.CleanupFailed` | `sandbox_provider`, `error` | -| `Sandbox.SnapshotPulling` | `snapshot_name` | -| `Sandbox.SnapshotPulled` | `snapshot_name`, `duration_ms` | -| `Sandbox.SnapshotEnsuring` | `snapshot_name` | -| `Sandbox.SnapshotCreating` | `snapshot_name` | -| `Sandbox.SnapshotReady` | `snapshot_name`, `duration_ms` | -| `Sandbox.SnapshotFailed` | `snapshot_name`, `error` | -| `Sandbox.GitCloneStarted` | `url`, `branch` | -| `Sandbox.GitCloneCompleted` | `url`, `duration_ms` | -| `Sandbox.GitCloneFailed` | `url`, `error` | - -## Error Fields - -Error information is stored as plain strings. The `error` field contains the human-readable message; `failure_class` contains the machine-readable classification. - -| Field | Type | Events | Purpose | -|---|---|---|---| -| `error` | string | StageFailed, WorkflowRunFailed, Agent.Error, Agent.LlmRetry, Sandbox.*Failed, etc. | Human-readable error message | -| `failure_reason` | string? | StageFailed, StageCompleted | Outcome-level failure description | -| `failure_class` | string? | StageFailed, StageCompleted | Machine classification: `transient_infra`, `deterministic`, `budget_exhausted`, `compilation_loop`, `canceled`, `structural` | - -`failure_class` is derived from `FabroError::failure_class()` for handler errors, or from handler hints in `context_updates["failure_class"]` for outcome-based failures. See `error.rs` for the classification logic. - -## Consumers - -| Consumer | Reads | Purpose | +| Field | Type | Notes | |---|---|---| -| `retro.rs` `extract_stage_durations()` | `node_label`, `duration_ms` from `StageCompleted` | Build retro report | -| `cli/run.rs` non-verbose listener | `name`, `duration_ms`, `status`, `usage` from `StageCompleted/Failed` | CLI progress output | -| `cli/mod.rs` `format_event_summary()` | All events | `-v` verbose output | -| `cli/run.rs` cost accumulator | `usage` from `StageCompleted` | Total cost tracking | -| `cli/run.rs` git SHA tracker | `git_commit_sha` from `CheckpointCompleted` | Final SHA for `conclusion.json` | -| External tooling | `progress.jsonl` | Live monitoring, dashboards | +| `id` | string | UUIDv7 event id | +| `ts` | string | UTC timestamp with millisecond precision | +| `run_id` | string | Workflow run id | +| `event` | string | Lowercase dot-notation event name | + +Optional top-level fields: + +| Field | When present | +|---|---| +| `session_id` | Agent/session events | +| `parent_session_id` | Forwarded child-session events | +| `node_id` | Events tied to a graph node or branch | +| `node_label` | Display label for `node_id`; omitted when not applicable | + +Everything else lives inside `properties`. + +Important rules: + +- Optional envelope fields are omitted, not serialized as `null`. +- Event-specific fields do not get flattened into the top level. +- `EventPayload` validation requires `id`, `ts`, `run_id`, and `event`. + +## Naming + +The external event name is lowercase dot notation, for example: + +- `run.started` +- `stage.completed` +- `agent.tool.started` +- `sandbox.ready` +- `parallel.branch.completed` + +`event_name()` in `event.rs` is exhaustive. Do not use wildcard fallthroughs when adding new variants. + +## Node And Session Metadata + +`node_id` is the stable graph identifier. `node_label` is the human-facing display name. Stage events should surface both through the envelope when applicable. + +Agent events now use explicit session links: + +- `session_id` identifies the session that originally emitted the event. +- `parent_session_id` identifies the immediate parent session for forwarded child events. +- Nested sub-agents preserve immediate parentage across boundaries. + +`AgentEvent::SubAgentEvent` no longer exists. Child activity is forwarded as normal agent events with session linkage in the envelope. + +## Direct-Write Paths + +Most events flow through `EventEmitter::emit()`. The remaining direct-write paths must use: + +1. `canonicalize_event(run_id, event)` +2. Serialize and redact once +3. Reuse that exact serialized envelope for every sink + +Never canonicalize the same logical event twice if multiple sinks receive it. + +## Adding A New Event + +### 1. Add the typed event + +Add a variant to `WorkflowRunEvent`, `AgentEvent`, or `SandboxEvent` as appropriate. + +### 2. Add tracing + +Extend `WorkflowRunEvent::trace()` so the raw event is observable in tracing output. + +### 3. Add an external name + +Extend `event_name()` with the new lowercase dot-notation string. + +### 4. Map envelope fields + +Update `extract_envelope_fields()`: + +- Move `node_id`, `node_label`, `session_id`, and `parent_session_id` into the envelope when appropriate. +- Keep event-specific data in `properties`. +- Flatten structured failure details into explicit property keys when needed. + +### 5. Emit it + +Prefer `EventEmitter::emit(&WorkflowRunEvent::...)`. + +Use `canonicalize_event()` only for true bypass paths. + +### 6. Update consumers + +Check: + +- CLI progress parsing +- `fabro logs` +- retro duration extraction +- store validation +- tests or fixtures that inspect event names or fields + +## Consumer Guidance + +When writing listeners: + +- Match on `envelope.event`, not Rust variant names. +- Read event payload from `envelope.properties`. +- Read stage/branch identity from `node_id` and `node_label`. +- Read agent hierarchy from `session_id` and `parent_session_id`. + +Do not rebuild or mutate the envelope in downstream listeners. + +## Bypass And Persistence Guarantees + +`progress.jsonl`, the run store, and SSE should reflect the same canonical envelope bytes after redaction. + +`status.json` remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished. diff --git a/docs/agents/subagents.mdx b/docs/agents/subagents.mdx index 2d669184c..cc3741ed1 100644 --- a/docs/agents/subagents.mdx +++ b/docs/agents/subagents.mdx @@ -3,7 +3,7 @@ title: "Sub-agents" description: "Delegate subtasks to child agent sessions" --- -An agent can spawn **sub-agents** to delegate work to independent child sessions. Each sub-agent gets its own LLM session and tool access, runs concurrently with the parent, and returns its result when finished. This is useful for parallelizing research, isolating risky operations, or breaking complex tasks into focused pieces. +An agent can spawn **sub-agents** to delegate work to independent child sessions. Each sub-agent gets its own LLM session and tool access, runs concurrently with the parent, and returns its result when finished. Sub-agents are only available with the [API backend](/core-concepts/agents#api-backend-default) (the default). Agents using the [CLI backend](/core-concepts/agents#cli-backend) cannot spawn sub-agents. @@ -16,124 +16,84 @@ Sub-agent management is exposed through four built-in tools: | Tool | Description | |---|---| | `spawn_agent` | Create a new sub-agent with a task prompt | -| `send_input` | Send a follow-up message to a running sub-agent | +| `send_input` | Send follow-up input to a running sub-agent | | `wait` | Block until a sub-agent completes and return its result | | `close_agent` | Cancel and remove a running sub-agent | -These tools are registered automatically when the session starts. They inherit the parent's permissions -- no additional approval is needed for sub-agent tool calls. - -### spawn_agent - -Creates a new sub-agent session and starts it working on the given task. - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `task` | string | yes | The task description for the sub-agent | -| `working_dir` | string | no | Working directory for the sub-agent | -| `model` | string | no | Model to use for the sub-agent | -| `max_turns` | integer | no | Maximum number of turns (default: unlimited) | - -Returns the `agent_id` string used to reference this sub-agent in subsequent calls. - -### send_input - -Sends a follow-up message to a running sub-agent. The message is queued and processed by the sub-agent on its next turn. - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `agent_id` | string | yes | The ID returned by `spawn_agent` | -| `message` | string | yes | The message to send | - -### wait - -Blocks until the specified sub-agent completes, then returns its result. The result includes whether the sub-agent succeeded, how many turns it used, and the final text output. - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `agent_id` | string | yes | The ID returned by `spawn_agent` | - -Returns a formatted string: -``` -Agent completed (success: true, turns: 12) - - -``` - -### close_agent - -Cancels a running sub-agent and removes it. Use this to clean up sub-agents that are no longer needed. - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `agent_id` | string | yes | The ID returned by `spawn_agent` | +These tools are registered automatically when the session starts. They inherit the parent's permissions. ## Session isolation -Each sub-agent runs in its own independent session: +Each sub-agent runs in its own session: -- **Own LLM conversation** -- the sub-agent has a fresh conversation history starting from the task prompt. It does not see the parent's prior conversation. -- **Own tool access** -- the sub-agent has the same tools as the parent (shell, file editing, grep, etc.) and can use them independently. -- **Concurrent execution** -- after `spawn_agent`, the parent continues working immediately. The sub-agent runs in a background task. Use `wait` to collect the result when needed. +- **Own conversation** -- the child starts with a fresh LLM history +- **Own tool access** -- the child can use the same tools as the parent +- **Concurrent execution** -- the parent can continue working before calling `wait` -The parent can spawn multiple sub-agents and let them work in parallel, then `wait` on each to collect results: - -``` -1. spawn_agent("Research authentication patterns") -> agent_a -2. spawn_agent("Analyze the test suite structure") -> agent_b -3. ... parent does its own work ... -4. wait(agent_a) -> result_a -5. wait(agent_b) -> result_b -6. Synthesize both results -``` +The parent can spawn multiple sub-agents and synchronize with them later. ## Depth limits -Sub-agents can themselves spawn sub-agents, creating a hierarchy. The **maximum nesting depth** prevents runaway chains. By default, the depth limit is **1** -- a parent agent can spawn sub-agents, but those sub-agents cannot spawn their own children. +Sub-agents can themselves spawn sub-agents, creating a hierarchy. `max_subagent_depth` limits how deep that tree can grow. By default the depth limit is `1`. -If a sub-agent tries to spawn beyond the depth limit, the `spawn_agent` call returns an error: - -``` -Maximum subagent depth (1) reached -``` - -The depth limit is set via the `max_subagent_depth` field in the session configuration. +If a child tries to exceed the limit, `spawn_agent` returns an error immediately. ## Error handling -When a sub-agent fails, the error is captured and returned to the parent via the `wait` tool — the parent stage does **not** automatically fail. The parent LLM sees the error message and decides how to respond: retry, try a different approach, or report the failure. +Sub-agent failures do not automatically fail the parent stage. The parent receives the failure through `wait` and decides how to respond. -Specific failure modes: +Common cases: -- **Sub-agent hits `max_turns`** — The sub-agent stops naturally and returns its last output as a successful result. The parent sees a normal completion with the final assistant message. By default, there is no turn limit — sub-agents run until they complete their task or hit the context window. -- **Sub-agent panics or errors** — The error is captured and returned through `wait` as a failure result. The parent can inspect the error and decide what to do. -- **`spawn_agent` fails** (e.g. depth limit exceeded) — The error is returned immediately as a tool result. The parent can adjust its approach without waiting. +- **Hits `max_turns`** -- returns normally with its last output +- **Panics or errors** -- returned as a failed `wait` result +- **`spawn_agent` fails** -- returned immediately as a tool result ## Event forwarding -Sub-agent events (tool calls, assistant messages, errors) are forwarded to the parent session's event stream as `SubAgentEvent` wrappers. This means the parent's progress log captures the full activity of all children, giving you visibility into what sub-agents are doing. +Sub-agent observability now uses **session linkage**, not wrapper events. -Key lifecycle events: +Parent-owned lifecycle events: | Event | When | |---|---| -| `SubAgentSpawned` | A sub-agent was created | -| `SubAgentCompleted` | A sub-agent finished successfully | -| `SubAgentFailed` | A sub-agent encountered an error | -| `SubAgentClosed` | A sub-agent was cancelled | +| `agent.sub.spawned` | A sub-agent was created | +| `agent.sub.completed` | A sub-agent finished successfully | +| `agent.sub.failed` | A sub-agent failed | +| `agent.sub.closed` | A sub-agent was cancelled | -Streaming events (text deltas, tool output deltas) from sub-agents are filtered out to reduce noise. +Forwarded child activity: + +- Child tool calls, assistant messages, warnings, and other non-noisy session events are forwarded into the parent run's event stream as normal agent events. +- The forwarded event keeps the child's `session_id`. +- `parent_session_id` points to the child's immediate parent session. + +Example envelope: + +```json +{ + "event": "agent.tool.started", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "node_id": "code", + "properties": { + "tool_name": "read_file" + } +} +``` + +Nested sub-agents preserve the immediate parent-child relationship. A grandchild forwarded through multiple parents still carries its own `session_id`, and `parent_session_id` remains set to the grandchild's direct parent rather than the root. + +High-volume streaming events such as text deltas and tool output deltas are filtered out of the forwarded stream to reduce noise. ## When to use sub-agents -Sub-agents are most useful when: +Sub-agents are most useful for: -- **Parallel research** -- gather information from multiple parts of a codebase simultaneously -- **Isolation** -- let a sub-agent try a risky approach without polluting the parent's context -- **Divide and conquer** -- break a large task into independent pieces that can be worked on concurrently -- **Context management** -- offload work to a sub-agent when the parent's context window is getting full +- **Parallel research** -- inspect multiple code paths at once +- **Isolation** -- try a risky approach without polluting the parent's context +- **Divide and conquer** -- split an independent task into smaller pieces +- **Context management** -- offload work when the parent session is getting crowded -Sub-agents run with no turn limit by default — they continue until the task is complete or the context window is exhausted. Pass `max_turns` when spawning to cap execution for predictable cost control. The parent agent is blocked during a `wait` call, so spawn sub-agents before starting the wait to maximize concurrency. +Sub-agents run with no turn limit by default. Pass `max_turns` when you want predictable cost or time bounds. All active sub-agents are cleaned up automatically when the parent session closes. - -All active sub-agents are automatically cleaned up when the parent session closes — you don't need to explicitly `close_agent` on every sub-agent. diff --git a/docs/execution/observability.mdx b/docs/execution/observability.mdx index 494e6b7be..47148c782 100644 --- a/docs/execution/observability.mdx +++ b/docs/execution/observability.mdx @@ -3,171 +3,119 @@ title: "Observability" description: "How to monitor, inspect, and analyze workflow runs" --- -Fabro captures a structured event for every significant action during a workflow run — stage starts and completions, agent tool calls, edge selections, retries, failovers, sandbox lifecycle, and more. These events power real-time monitoring, post-run analysis, and cross-run analytics. +Fabro captures a structured event for every significant action during a workflow run. These events cover stage execution, agent tool calls, retries, routing, sandbox lifecycle, git checkpoints, retro generation, and more. ## Event stream -The event stream is the foundation of Fabro's observability. Every workflow run emits a sequence of `WorkflowRunEvent` records that are: +Every workflow run emits a sequence of canonical **run event envelopes** that are: -- **Written to `progress.jsonl`** in the run's directory (one JSON object per line) -- **Broadcast via SSE** to connected API clients in real time -- **Logged via `tracing`** to the daily log file at `~/.fabro/logs/` +- Written to `progress.jsonl` in the run directory +- Broadcast over SSE to connected API clients +- Stored for later analysis and retro generation +- Rendered by CLI progress and log tooling -### Event types +### Event names -Events fall into several categories: +Event names use lowercase dot notation, for example: -**Run lifecycle** — bookend events for the entire run: +- `run.started` +- `stage.started` +- `stage.completed` +- `agent.tool.started` +- `agent.tool.completed` +- `sandbox.ready` +- `parallel.branch.completed` -| Event | Key fields | Description | -|---|---|---| -| `WorkflowRunStarted` | `name`, `run_id`, `base_sha`, `run_branch`, `goal` | Run begins | -| `WorkflowRunCompleted` | `duration_ms`, `artifact_count`, `total_cost`, `status`, `usage` | Run finishes successfully | -| `WorkflowRunFailed` | `error`, `duration_ms` | Run terminates with an error | +### Envelope format -**Stage lifecycle** — events for each node execution: - -| Event | Key fields | Description | -|---|---|---| -| `StageStarted` | `node_id`, `name`, `handler_type`, `attempt`, `max_attempts` | Node begins executing | -| `StageCompleted` | `node_id`, `duration_ms`, `status`, `usage`, `files_touched` | Node finishes | -| `StageFailed` | `node_id`, `failure`, `will_retry` | Node fails (may or may not retry) | -| `StageRetrying` | `node_id`, `attempt`, `max_attempts`, `delay_ms` | Retry scheduled after failure | - -**Agent activity** — forwarded from the LLM agent session, prefixed with `Agent.`: - -| Event | Key fields | Description | -|---|---|---| -| `Agent.SessionStarted` | `stage` | Agent session begins | -| `Agent.ToolCallStarted` | `stage`, `tool_name`, `arguments` | Agent invokes a tool | -| `Agent.ToolCallCompleted` | `stage`, `tool_name`, `output`, `is_error` | Tool returns a result | -| `Agent.AssistantMessage` | `stage`, `text`, `model`, `usage` | LLM responds with text | -| `Agent.Error` | `stage`, `error` | Agent-level error | -| `Agent.LoopDetected` | `stage` | Repeated tool call pattern detected | -| `Agent.SteeringInjected` | `stage`, `text` | Human steering message injected | -| `Agent.Warning` | `stage`, `kind`, `message`, `details` | Non-fatal warning (e.g. context window usage) | -| `Agent.CompactionStarted` | `stage`, `estimated_tokens`, `context_window_size` | Context compaction triggered | -| `Agent.CompactionCompleted` | `stage`, `original_turn_count`, `preserved_turn_count`, `summary_token_estimate`, `tracked_file_count` | Context compaction finished | -| `Agent.LlmRetry` | `stage`, `provider`, `model`, `attempt`, `delay_secs` | LLM API call retried | -| `Agent.SubAgentSpawned` | `stage`, `agent_id`, `task` | Sub-agent launched | -| `Agent.SubAgentCompleted` | `stage`, `agent_id`, `success`, `turns_used` | Sub-agent finished | - -**Routing and control flow:** - -| Event | Key fields | Description | -|---|---|---| -| `EdgeSelected` | `from_node`, `to_node`, `label`, `condition`, `reason`, `stage_status` | Transition between nodes | -| `LoopRestart` | `from_node`, `to_node` | Loop restart edge taken | -| `CheckpointCompleted` | `node_id`, `git_commit_sha` (optional) | Checkpoint saved (with git SHA when git is enabled) | -| `GitCommit` | `node_id`, `sha` | Git commit created | -| `GitPush` | `branch`, `success` | Git push attempted | -| `GitBranch` | `branch`, `sha` | Git branch created | -| `GitWorktreeAdd` | `path`, `branch` | Git worktree added | -| `GitWorktreeRemove` | `path` | Git worktree removed | -| `GitFetch` | `branch`, `success` | Git fetch attempted | -| `GitReset` | `sha` | Git reset executed | -| `Failover` | `stage`, `from_provider`, `to_provider`, `error` | LLM provider failover | -| `RetroStarted` | — | Retrospective generation begins | -| `RetroCompleted` | `duration_ms` | Retrospective generation finished | -| `RetroFailed` | `error`, `duration_ms` | Retrospective generation failed | - -**Parallel execution:** - -| Event | Key fields | Description | -|---|---|---| -| `ParallelStarted` | `branch_count`, `join_policy` | Fan-out begins | -| `ParallelBranchStarted` | `branch`, `index` | Individual branch begins | -| `ParallelBranchCompleted` | `branch`, `duration_ms`, `status` | Branch finishes | -| `ParallelCompleted` | `duration_ms`, `success_count`, `failure_count` | All branches done | - -**Human-in-the-loop:** - -| Event | Key fields | Description | -|---|---|---| -| `InterviewStarted` | `question`, `stage`, `question_type` | Human input requested | -| `InterviewCompleted` | `question`, `answer`, `duration_ms` | Human responded | -| `InterviewTimeout` | `question`, `stage`, `duration_ms` | Human didn't respond in time | - -**Sandbox and setup:** - -| Event | Key fields | Description | -|---|---|---| -| `Sandbox.Initializing` | `provider` | Sandbox creation started | -| `Sandbox.Ready` | `provider`, `duration_ms`, `cpu`, `memory` | Sandbox ready | -| `SetupStarted` | `command_count` | Setup commands beginning | -| `SetupCommandCompleted` | `command`, `exit_code`, `duration_ms` | Single setup command finished | -| `SetupFailed` | `command`, `exit_code`, `stderr` | Setup command failed | -| `SshAccessReady` | `ssh_command` | SSH connection command printed | -| `StallWatchdogTimeout` | `node`, `idle_seconds` | No activity for too long | - -### Event envelope format - -Each line in `progress.jsonl` is a JSON object with a standard envelope: +Each line in `progress.jsonl` is a JSON object with a stable envelope: ```json { - "ts": "2026-03-05T14:30:01.234Z", - "run_id": "01JKXYZ...", - "event": "Agent.ToolCallStarted", - "stage": "implement", - "tool_name": "shell", - "arguments": {"command": "cargo test"} + "id": "01960d0c-5d16-7d6e-8f61-9fd6f4a532b5", + "ts": "2026-03-30T12:00:01.000Z", + "run_id": "01JQ...", + "event": "agent.tool.started", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "node_id": "implement", + "node_label": "Implement", + "properties": { + "tool_name": "shell", + "tool_call_id": "call_1", + "arguments": {"command": "cargo test"} + } } ``` -The `ts`, `run_id`, and `event` fields are always present. The remaining fields vary by event type. Events are flattened — nested variants like agent and sandbox events use dot notation (e.g. `Agent.ToolCallStarted`, `Sandbox.Ready`). +Envelope fields: -## Log files +| Field | Description | +|---|---| +| `id` | Unique event id | +| `ts` | UTC timestamp | +| `run_id` | Workflow run id | +| `event` | Event name | +| `session_id` | Session that emitted the event, when applicable | +| `parent_session_id` | Immediate parent session for forwarded child events | +| `node_id` | Node or branch id, when applicable | +| `node_label` | Human-facing label for `node_id`, when applicable | +| `properties` | Event-specific payload | -Fabro writes two kinds of logs: +Only `id`, `ts`, `run_id`, and `event` are always present. Optional fields are omitted when they do not apply. -### Run logs (`progress.jsonl`) +## Reading `progress.jsonl` -Every run writes its event stream to `{run_dir}/progress.jsonl`. This is the primary data source for post-run analysis — [retros](/execution/retros) read it, and you can query it directly with standard tools: +Because event payload lives in `properties`, most shell queries should look there. ```bash # Count tool calls in a run -grep "ToolCallStarted" ~/.fabro/runs/01JKXYZ.../progress.jsonl | wc -l +jq -r 'select(.event == "agent.tool.started") | .properties.tool_name' \ + ~/.fabro/runs/01JKXYZ.../progress.jsonl | wc -l -# Find all failures -grep -E "StageFailed|WorkflowRunFailed" ~/.fabro/runs/01JKXYZ.../progress.jsonl | jq . +# Find stage failures +jq 'select(.event == "stage.failed")' \ + ~/.fabro/runs/01JKXYZ.../progress.jsonl # See which edges were taken -grep "EdgeSelected" ~/.fabro/runs/01JKXYZ.../progress.jsonl | jq '{from: .from_node, to: .to_node}' +jq '{from: .properties.from_node, to: .properties.to_node, label: .properties.label}' \ + ~/.fabro/runs/01JKXYZ.../progress.jsonl | head ``` -### Live snapshot (`live.json`) +`live.json` is still a pretty-printed copy of the most recent event envelope. -During execution, Fabro also writes `live.json` — a pretty-printed copy of the most recent event. This is useful for quick status checks while a run is in progress: +## Event categories -```bash -cat ~/.fabro/runs/01JKXYZ.../live.json -``` +Common categories include: -### Application logs - -Fabro uses the `tracing` crate to write structured logs to `~/.fabro/logs/YYYY-MM-DD.log`. Control the log level with the `FABRO_LOG` environment variable: - -```bash -FABRO_LOG=debug fabro run workflow.fabro -``` - -| Level | What's logged | +| Category | Example events | |---|---| -| `error` | Run failures, stage failures that won't retry | -| `warn` | Retries, timeouts, interview timeouts, failovers, early terminations | -| `info` | Run start/complete, SSH access ready | -| `debug` | Stage start/complete, edge selections, checkpoints, parallel branches, tool calls | +| Run lifecycle | `run.started`, `run.completed`, `run.failed`, `run.notice` | +| Stage lifecycle | `stage.started`, `stage.completed`, `stage.failed`, `stage.retrying` | +| Agent activity | `agent.message`, `agent.tool.started`, `agent.warning`, `agent.sub.spawned` | +| Routing | `edge.selected`, `loop.restart`, `parallel.started` | +| Git and checkpoints | `checkpoint.completed`, `git.commit`, `git.push` | +| Setup and sandbox | `sandbox.initializing`, `sandbox.ready`, `setup.started` | +| Retro | `retro.started`, `retro.completed`, `retro.failed` | + +## Sub-agent visibility + +Sub-agent activity now appears as normal agent events with session linkage: + +- `session_id` identifies the child session +- `parent_session_id` identifies its immediate parent + +Lifecycle events such as `agent.sub.spawned` and `agent.sub.completed` are emitted by the parent session. Tool calls and other child activity are forwarded with their original `session_id`. ## Real-time monitoring ### API: Server-Sent Events -When running workflows through the API server, subscribe to a live event stream via the [run events endpoint](/api-reference/runs/stream-run-events). Each event is a JSON-serialized `WorkflowRunEvent`. The stream stays open until the run completes. +When running workflows through the API server, subscribe to the [run events endpoint](/api-reference/runs/stream-run-events). Each SSE payload is a serialized run event envelope in the same shape used by `progress.jsonl`. ### Web UI -The web frontend connects to the SSE stream automatically and displays run progress in real time — stage transitions, agent tool calls, and human gate prompts are all visible as they happen. +The web frontend consumes the SSE stream automatically and shows stage progress, tool calls, and human interaction as they happen. Fabro web UI run stages showing agent conversation with tool calls @@ -175,90 +123,20 @@ The web frontend connects to the SSE stream automatically and displays run progr ### CLI progress -The CLI displays a live progress bar during execution with per-stage status, duration, and cost tracking. This is rendered to stderr so it doesn't interfere with output piping. +The CLI renders live progress from the same envelope format. This is written to stderr so stdout remains pipe-friendly. ## Post-run analysis -### Listing runs - -Browse your run history with `fabro ps`: - -```bash -fabro ps -fabro ps --workflow PlanImplement -fabro ps --before 2026-03-01 -fabro ps --label team=platform -fabro ps --json -``` - -This scans `~/.fabro/runs/` and displays each run's ID, workflow name, status, and start time. Use `--json` for machine-readable output. - -### Run artifacts - -Each run's directory contains a standard set of files: +Run artifacts still include: | File | Description | |---|---| -| `run.json` | Run record — ID, config, graph, workflow slug, labels | -| `start.json` | Start record — run ID, start time, run branch, base SHA | -| `progress.jsonl` | Full event stream | -| `live.json` | Last event snapshot (overwritten during run) | +| `run.json` | Run metadata and graph | +| `start.json` | Start record | +| `progress.jsonl` | Full event envelope stream | +| `live.json` | Latest event envelope | | `checkpoint.json` | Final execution state | -| `retro.json` | Retrospective (if retro generation is enabled) | -| `conclusion.json` | Terminal status (`completed`, `failed`, `canceled`) | +| `retro.json` | Retrospective, when enabled | +| `conclusion.json` | Terminal summary | -### Inspecting stages and turns - -The API provides endpoints for drilling into individual stages and the agent turns within them. See the [stages](/api-reference/run-internals/list-run-stages) and [turns](/api-reference/run-internals/list-stage-turns) API reference pages. - -## Insights (SQL analytics) - -The Insights feature lets you run SQL queries across your run data using DuckDB. This is useful for aggregate analysis — finding slow workflows, tracking failure rates, comparing model costs, and spotting trends. - -Insights is managed through the [saved queries](/api-reference/insights/list-saved-queries) and [execute query](/api-reference/insights/execute-query) API reference pages. You can save, update, and execute queries programmatically. - -### Example queries - -**Average run duration by workflow:** - -```sql -SELECT workflow_name, AVG(duration_seconds) as avg_duration, - COUNT(*) as run_count -FROM runs -GROUP BY workflow_name -ORDER BY avg_duration DESC -LIMIT 20 -``` - -**Daily failure rate:** - -```sql -SELECT date_trunc('day', created_at) as day, - COUNT(*) FILTER (WHERE status = 'failed') as failures, - COUNT(*) as total -FROM runs -GROUP BY 1 -ORDER BY 1 DESC -LIMIT 30 -``` - -**Top repositories by activity:** - -```sql -SELECT repo, COUNT(*) as runs -FROM runs -GROUP BY repo -ORDER BY runs DESC -``` - -## Aggregate usage - -The API server tracks aggregate usage counters across all runs — total run count, total runtime, and per-model breakdowns of token usage and cost. See the [usage endpoint](/api-reference/usage/aggregate-usage) in the API reference. Counters reset on server restart. - - - Fabro web UI run usage showing per-stage and per-model token and cost breakdown - - -## Credential redaction - -All event output — `progress.jsonl`, SSE streams, and log files — is automatically redacted before being written. Fabro detects and replaces patterns that look like API keys, AWS credentials, bearer tokens, and other secrets with `REDACTED`. This ensures sensitive values that appear in tool call arguments or command output are never persisted to disk. +See [retros](/execution/retros), [stages](/api-reference/run-internals/list-run-stages), and [turns](/api-reference/run-internals/list-stage-turns) for higher-level analysis views built on top of this event stream. diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 5865cfe11..b5fdb06a7 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -486,7 +486,7 @@ pub async fn run_with_args_and_client( manager_for_callback .lock() .await - .set_event_callback(session.event_callback()); + .set_event_callback(session.sub_agent_event_callback()); // SIGINT handler let cancel_token = session.cancel_token(); @@ -522,6 +522,11 @@ pub async fn run_with_args_and_client( OutputFormat::Text => { let s = styles; while let Ok(event) = rx.recv().await { + let child_prefix = if event.parent_session_id.is_some() { + format!("[child {}] ", event.session_id) + } else { + String::new() + }; match &event.event { AgentEvent::ToolCallStarted { tool_name, @@ -531,7 +536,7 @@ pub async fn run_with_args_and_client( eprintln!( " {} {}{}", s.dim.apply_to("\u{25cf}"), - s.bold_cyan.apply_to(tool_name), + s.bold_cyan.apply_to(format!("{child_prefix}{tool_name}")), s.dim.apply_to(format!( "({})", format_tool_args(arguments, &cwd_str) @@ -551,13 +556,17 @@ pub async fn run_with_args_and_client( }; eprintln!( " {}\n{}", - s.dim.apply_to(format!("[{label}] {tool_name}:")), + s.dim + .apply_to(format!("[{label}] {child_prefix}{tool_name}:")), serde_json::to_string_pretty(output) .unwrap_or_else(|_| output.to_string()), ); } AgentEvent::Error { error } => { - eprintln!(" {}", s.red.apply_to(format!("\u{2717} {error}")),); + eprintln!( + " {}", + s.red.apply_to(format!("\u{2717} {child_prefix}{error}")), + ); } AgentEvent::SubAgentSpawned { agent_id, @@ -573,7 +582,7 @@ pub async fn run_with_args_and_client( eprintln!( " {}", s.dim.apply_to(format!( - "\u{25b6} subagent {agent_id} spawned (depth={depth}) task={task_preview:?}" + "{child_prefix}\u{25b6} subagent {agent_id} spawned (depth={depth}) task={task_preview:?}" )), ); } @@ -586,7 +595,7 @@ pub async fn run_with_args_and_client( eprintln!( " {}", s.dim.apply_to(format!( - "\u{25a0} subagent {agent_id} completed (depth={depth}, success={success}, turns={turns_used})" + "{child_prefix}\u{25a0} subagent {agent_id} completed (depth={depth}, success={success}, turns={turns_used})" )), ); } @@ -598,7 +607,7 @@ pub async fn run_with_args_and_client( eprintln!( " {}", s.red.apply_to(format!( - "\u{2717} subagent {agent_id} failed (depth={depth}): {error}" + "{child_prefix}\u{2717} subagent {agent_id} failed (depth={depth}): {error}" )), ); } @@ -606,21 +615,10 @@ pub async fn run_with_args_and_client( eprintln!( " {}", s.dim.apply_to(format!( - "\u{25a0} subagent {agent_id} closed (depth={depth})" + "{child_prefix}\u{25a0} subagent {agent_id} closed (depth={depth})" )), ); } - AgentEvent::SubAgentEvent { - agent_id, - event: child_event, - .. - } if verbose => { - eprintln!( - " {}", - s.dim - .apply_to(format!("[subagent {agent_id}] {child_event:?}")), - ); - } _ => {} } } diff --git a/lib/crates/fabro-agent/src/event.rs b/lib/crates/fabro-agent/src/event.rs index 95a00931d..60dbbe9e7 100644 --- a/lib/crates/fabro-agent/src/event.rs +++ b/lib/crates/fabro-agent/src/event.rs @@ -20,11 +20,16 @@ impl EventEmitter { event, timestamp: SystemTime::now(), session_id, + parent_session_id: None, }; // Ignore send error (no receivers) let _ = self.sender.send(wrapped); } + pub fn forward(&self, event: SessionEvent) { + let _ = self.sender.send(event); + } + #[must_use] pub fn subscribe(&self) -> broadcast::Receiver { self.sender.subscribe() @@ -52,6 +57,7 @@ mod tests { let event = receiver.recv().await.unwrap(); assert!(matches!(event.event, AgentEvent::SessionStarted)); assert_eq!(event.session_id, "sess-1"); + assert_eq!(event.parent_session_id, None); } #[tokio::test] @@ -70,6 +76,7 @@ mod tests { assert!( matches!(&event.event, AgentEvent::Error { error } if error.to_string().contains("something went wrong")) ); + assert_eq!(event.parent_session_id, None); } #[tokio::test] @@ -86,6 +93,8 @@ mod tests { assert!(matches!(e2.event, AgentEvent::SessionEnded)); assert_eq!(e1.session_id, "sess-3"); assert_eq!(e2.session_id, "sess-3"); + assert_eq!(e1.parent_session_id, None); + assert_eq!(e2.parent_session_id, None); } #[test] @@ -104,4 +113,22 @@ mod tests { let emitter = EventEmitter::default(); let _rx = emitter.subscribe(); } + + #[tokio::test] + async fn forward_preserves_session_ids() { + let emitter = EventEmitter::new(); + let mut receiver = emitter.subscribe(); + + emitter.forward(SessionEvent { + event: AgentEvent::SessionStarted, + timestamp: SystemTime::now(), + session_id: "child".into(), + parent_session_id: Some("parent".into()), + }); + + let event = receiver.recv().await.unwrap(); + assert_eq!(event.session_id, "child"); + assert_eq!(event.parent_session_id.as_deref(), Some("parent")); + assert!(matches!(event.event, AgentEvent::SessionStarted)); + } } diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 16b773fa2..29b64280f 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -13,7 +13,7 @@ use crate::sandbox::Sandbox; use crate::skills::{ ExpandedInput, Skill, default_skill_dirs, discover_skills, expand_skill, make_use_skill_tool, }; -use crate::subagent::{SubAgentEventCallback, SubAgentManager}; +use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentManager}; use crate::tool_execution::execute_tool_calls; use crate::types::{AgentEvent, SessionEvent, SessionState, Turn}; use fabro_llm::client::Client; @@ -93,6 +93,11 @@ impl Session { self.tool_env = Some(env); } + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + /// Initialize session by discovering project docs and capturing environment context. /// Call before `process_input`. pub async fn initialize(&mut self) { @@ -453,13 +458,22 @@ impl Session { self.cancel_token.clone() } - /// Build a callback that forwards `AgentEvent`s through this session's emitter. + /// Build a callback that forwards sub-agent lifecycle and child session events + /// through this session's emitter. #[must_use] - pub fn event_callback(&self) -> SubAgentEventCallback { + pub fn sub_agent_event_callback(&self) -> SubAgentEventCallback { let emitter = self.event_emitter.clone(); - let session_id = self.id.clone(); - Arc::new(move |event| { - emitter.emit(session_id.clone(), event); + let parent_session_id = self.id.clone(); + Arc::new(move |event| match event { + SubAgentCallbackEvent::Lifecycle(event) => { + emitter.emit(parent_session_id.clone(), event); + } + SubAgentCallbackEvent::Forwarded(mut event) => { + if event.parent_session_id.is_none() { + event.parent_session_id = Some(parent_session_id.clone()); + } + emitter.forward(event); + } }) } @@ -2720,7 +2734,7 @@ mod tests { manager .lock() .await - .set_event_callback(session.event_callback()); + .set_event_callback(session.sub_agent_event_callback()); // Spawn a subagent let child = make_session(vec![text_response("child done")]).await; diff --git a/lib/crates/fabro-agent/src/subagent.rs b/lib/crates/fabro-agent/src/subagent.rs index b3562b06c..92d5c25d5 100644 --- a/lib/crates/fabro-agent/src/subagent.rs +++ b/lib/crates/fabro-agent/src/subagent.rs @@ -2,7 +2,7 @@ use crate::error::AgentError; use crate::session::Session; use crate::tool_registry::RegisteredTool; use crate::tools::required_str; -use crate::types::{AgentEvent, Turn}; +use crate::types::{AgentEvent, SessionEvent, Turn}; use fabro_llm::types::ToolDefinition; use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; @@ -11,7 +11,14 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; pub type SessionFactory = Arc Session + Send + Sync>; -pub type SubAgentEventCallback = Arc; + +#[derive(Debug, Clone)] +pub enum SubAgentCallbackEvent { + Lifecycle(AgentEvent), + Forwarded(SessionEvent), +} + +pub type SubAgentEventCallback = Arc; #[derive(Debug, Clone)] pub struct SubAgentResult { @@ -57,7 +64,7 @@ impl SubAgentManager { fn emit_event(&self, event: AgentEvent) { if let Some(ref cb) = self.event_callback { - cb(event); + cb(SubAgentCallbackEvent::Lifecycle(event)); } } @@ -82,8 +89,6 @@ impl SubAgentManager { if let Some(ref cb) = self.event_callback { let mut rx = session.subscribe(); let cb = cb.clone(); - let fwd_agent_id = agent_id.clone(); - let child_depth = depth + 1; tokio::spawn(async move { while let Ok(event) = rx.recv().await { // Skip streaming / noise events @@ -101,11 +106,7 @@ impl SubAgentManager { ) { continue; } - cb(AgentEvent::SubAgentEvent { - agent_id: fwd_agent_id.clone(), - depth: child_depth, - event: Box::new(event.event), - }); + cb(SubAgentCallbackEvent::Forwarded(event)); } }); } @@ -620,8 +621,11 @@ mod tests { assert!(close_required.contains(&serde_json::json!("agent_id"))); } - fn captured_events() -> (SubAgentEventCallback, Arc>>) { - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + fn captured_events() -> ( + SubAgentEventCallback, + Arc>>, + ) { + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); let events_clone = events.clone(); let cb: SubAgentEventCallback = Arc::new(move |event| { events_clone.lock().unwrap().push(event); @@ -640,9 +644,11 @@ mod tests { let captured = events.lock().unwrap(); assert_eq!(captured.len(), 1); - assert!( - matches!(&captured[0], AgentEvent::SubAgentSpawned { depth: 1, task, .. } if task == "test task") - ); + assert!(matches!( + &captured[0], + SubAgentCallbackEvent::Lifecycle(AgentEvent::SubAgentSpawned { depth: 1, task, .. }) + if task == "test task" + )); } #[tokio::test] @@ -658,11 +664,11 @@ mod tests { let captured = events.lock().unwrap(); assert!(captured.iter().any(|e| matches!( e, - AgentEvent::SubAgentCompleted { + SubAgentCallbackEvent::Lifecycle(AgentEvent::SubAgentCompleted { success: true, depth: 1, .. - } + }) ))); } @@ -677,11 +683,10 @@ mod tests { manager.close(&agent_id).unwrap(); let captured = events.lock().unwrap(); - assert!( - captured - .iter() - .any(|e| matches!(e, AgentEvent::SubAgentClosed { depth: 2, .. })) - ); + assert!(captured.iter().any(|e| matches!( + e, + SubAgentCallbackEvent::Lifecycle(AgentEvent::SubAgentClosed { depth: 2, .. }) + ))); } #[tokio::test] @@ -702,15 +707,41 @@ mod tests { let captured = events.lock().unwrap(); let forwarded_count = captured .iter() - .filter(|e| matches!(e, AgentEvent::SubAgentEvent { .. })) + .filter(|e| matches!(e, SubAgentCallbackEvent::Forwarded(_))) .count(); - // Child session emits at least UserInput and AssistantMessage (filtered from SessionStarted/SessionEnded/etc) assert!( forwarded_count > 0, "expected at least one forwarded child event, got {forwarded_count}" ); } + #[tokio::test] + async fn session_callback_stamps_parent_only_once() { + let parent = make_session(vec![text_response("parent")]).await; + let callback = parent.sub_agent_event_callback(); + let mut rx = parent.subscribe(); + + callback(SubAgentCallbackEvent::Forwarded(SessionEvent { + event: AgentEvent::SessionStarted, + timestamp: std::time::SystemTime::now(), + session_id: "child".into(), + parent_session_id: None, + })); + callback(SubAgentCallbackEvent::Forwarded(SessionEvent { + event: AgentEvent::SessionStarted, + timestamp: std::time::SystemTime::now(), + session_id: "grandchild".into(), + parent_session_id: Some("child".into()), + })); + + let child = rx.recv().await.unwrap(); + let grandchild = rx.recv().await.unwrap(); + assert_eq!(child.session_id, "child"); + assert_eq!(child.parent_session_id.as_deref(), Some(parent.id())); + assert_eq!(grandchild.session_id, "grandchild"); + assert_eq!(grandchild.parent_session_id.as_deref(), Some("child")); + } + #[test] fn no_callback_does_not_panic() { // Manager without callback should not panic on emit diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs index 17a2b49d9..d9bdc4ed4 100644 --- a/lib/crates/fabro-agent/src/types.rs +++ b/lib/crates/fabro-agent/src/types.rs @@ -185,11 +185,6 @@ pub enum AgentEvent { agent_id: String, depth: usize, }, - SubAgentEvent { - agent_id: String, - depth: usize, - event: Box, - }, McpServerReady { server_name: String, tool_count: usize, @@ -237,8 +232,7 @@ impl AgentEvent { Self::TextDelta { .. } | Self::ReasoningDelta { .. } | Self::AssistantOutputReplace { .. } - | Self::ToolCallOutputDelta { .. } - | Self::SubAgentEvent { .. } => {} + | Self::ToolCallOutputDelta { .. } => {} Self::ToolCallStarted { tool_name, tool_call_id, @@ -392,6 +386,8 @@ pub struct SessionEvent { #[serde(with = "system_time_iso8601")] pub timestamp: SystemTime, pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, } #[cfg(test)] @@ -404,9 +400,11 @@ mod tests { event: AgentEvent::SessionStarted, timestamp: SystemTime::now(), session_id: "sess_1".into(), + parent_session_id: None, }; assert!(matches!(event.event, AgentEvent::SessionStarted)); assert_eq!(event.session_id, "sess_1"); + assert_eq!(event.parent_session_id, None); } #[test] @@ -498,21 +496,6 @@ mod tests { assert!(matches!(event, AgentEvent::SubAgentClosed { depth: 2, .. })); } - #[test] - fn subagent_event_wraps_child_event() { - let child = AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc-1".into(), - arguments: serde_json::json!({}), - }; - let event = AgentEvent::SubAgentEvent { - agent_id: "sa-1".into(), - depth: 1, - event: Box::new(child), - }; - assert!(matches!(event, AgentEvent::SubAgentEvent { depth: 1, .. })); - } - #[test] fn subagent_events_serde_round_trip() { let events = vec![ @@ -536,39 +519,53 @@ mod tests { agent_id: "sa-1".into(), depth: 0, }, - AgentEvent::SubAgentEvent { - agent_id: "sa-1".into(), - depth: 1, - event: Box::new(AgentEvent::SessionStarted), - }, ]; let json = serde_json::to_string(&events).unwrap(); let deserialized: Vec = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.len(), 5); - assert!( - matches!(&deserialized[4], AgentEvent::SubAgentEvent { event, .. } if matches!(event.as_ref(), AgentEvent::SessionStarted)) - ); + assert_eq!(deserialized.len(), 4); } #[test] - fn session_event_serde_round_trip() { + fn session_event_serde_round_trip_without_parent_session_id() { let event = SessionEvent { event: AgentEvent::SessionStarted, timestamp: SystemTime::now(), session_id: "sess_42".into(), + parent_session_id: None, }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("sess_42")); assert!(json.contains("SessionStarted")); - // Timestamp should be ISO-8601 + assert!(!json.contains("parent_session_id")); assert!(json.contains('T')); assert!(json.contains('Z')); let deserialized: SessionEvent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.session_id, "sess_42"); + assert_eq!(deserialized.parent_session_id, None); assert!(matches!(deserialized.event, AgentEvent::SessionStarted)); } + #[test] + fn session_event_serde_round_trip_with_parent_session_id() { + let event = SessionEvent { + event: AgentEvent::SessionStarted, + timestamp: SystemTime::now(), + session_id: "sess_child".into(), + parent_session_id: Some("sess_parent".into()), + }; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("sess_child")); + assert!(json.contains("sess_parent")); + + let deserialized: SessionEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.session_id, "sess_child"); + assert_eq!( + deserialized.parent_session_id.as_deref(), + Some("sess_parent") + ); + } + #[test] fn mcp_server_ready_constructible() { let event = AgentEvent::McpServerReady { diff --git a/lib/crates/fabro-cli/src/commands/run/detached.rs b/lib/crates/fabro-cli/src/commands/run/detached.rs index 855bf4bf4..56ea45633 100644 --- a/lib/crates/fabro-cli/src/commands/run/detached.rs +++ b/lib/crates/fabro-cli/src/commands/run/detached.rs @@ -37,7 +37,7 @@ pub(crate) async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bo let services = StartServices { cancel_token: None, - emitter: Arc::new(EventEmitter::new()), + emitter: Arc::new(EventEmitter::new(run_record.run_id)), interviewer: Arc::new(FileInterviewer::new( runtime_state.interview_request_path(), runtime_state.interview_response_path(), diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index c3ad02f10..fba06204e 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -314,8 +314,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option let ts = format_timestamp(envelope.get("ts")?.as_str()?); match event { - "WorkflowRunStarted" => { - let name = str_field(&envelope, "workflow_name").unwrap_or("?"); + "run.started" => { + let name = prop_str_field(&envelope, "name").unwrap_or("?"); let run_id = str_field(&envelope, "run_id").unwrap_or("?"); let header = format!( "{} {} {} {}", @@ -324,7 +324,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.bold.apply_to(name), styles.dim.apply_to(run_id), ); - match str_field(&envelope, "goal") { + match prop_str_field(&envelope, "goal") { Some(goal) if !goal.is_empty() => { let body = render_indented_markdown(styles, goal, " "); Some(format!("{header}\n{body}\n")) @@ -332,9 +332,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option _ => Some(header), } } - "WorkflowRunCompleted" => { - let duration = format_duration_ms(envelope.get("duration_ms")); - let status_str = match str_field(&envelope, "status") { + "run.completed" => { + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); + let status_str = match prop_str_field(&envelope, "status") { Some(status) if !status.is_empty() => status, _ => "success", }; @@ -343,7 +343,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option "success" | "partial_success" => &styles.bold_green, _ => &styles.bold_red, }; - let cost = format_cost(envelope.get("total_cost")); + let cost = format_cost(prop_field(&envelope, "total_cost")); let mut lines = vec![format!( "{} {} {} {}", @@ -353,7 +353,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&cost), )]; - if let Some(usage) = envelope.get("usage") { + if let Some(usage) = prop_field(&envelope, "usage") { let total = usage .get("total_tokens") .and_then(serde_json::Value::as_i64) @@ -406,8 +406,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option Some(lines.join("\n")) } - "WorkflowRunFailed" => { - let error = str_field(&envelope, "error").unwrap_or("unknown error"); + "run.failed" => { + let error = prop_str_field(&envelope, "error").unwrap_or("unknown error"); Some(format!( "{} {} {}", styles.dim.apply_to(&ts), @@ -415,10 +415,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.red.apply_to(error), )) } - "RunNotice" => { - let level = str_field(&envelope, "level").unwrap_or("info"); - let code = str_field(&envelope, "code").unwrap_or(""); - let message = str_field(&envelope, "message").unwrap_or(""); + "run.notice" => { + let level = prop_str_field(&envelope, "level").unwrap_or("info"); + let code = prop_str_field(&envelope, "code").unwrap_or(""); + let message = prop_str_field(&envelope, "message").unwrap_or(""); let label = match level { "warn" => styles.yellow.apply_to("Warning:").to_string(), "error" => styles.bold_red.apply_to("Error:").to_string(), @@ -437,7 +437,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option code_suffix, )) } - "StageStarted" => { + "stage.started" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); Some(format!( "{} {} {}", @@ -446,36 +446,39 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.bold.apply_to(label), )) } - "StageCompleted" => { + "stage.completed" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); - let duration = format_duration_ms(envelope.get("duration_ms")); - let cost = format_cost(envelope.get("cost")); - let turns = envelope - .get("turns") + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); + let usage = prop_field(&envelope, "usage"); + let cost = format_cost(usage.and_then(|value| value.get("cost"))); + let input_tokens = usage + .and_then(|value| value.get("input_tokens")) .and_then(serde_json::Value::as_u64) .unwrap_or(0); - let tools = envelope - .get("tool_calls") + let output_tokens = usage + .and_then(|value| value.get("output_tokens")) .and_then(serde_json::Value::as_u64) .unwrap_or(0); - let tokens = envelope - .get("total_tokens") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let stats = format!("({turns} turns, {tools} tools, {})", format_tokens(tokens)); - Some(format!( - "{} {} {} {} {} {}", + let token_total = input_tokens.saturating_add(output_tokens); + let mut line = format!( + "{} {} {} {} {}", styles.dim.apply_to(&ts), styles.green.apply_to("\u{2713}"), styles.bold.apply_to(label), cost, duration, - styles.dim.apply_to(&stats), - )) + ); + if token_total > 0 { + line.push_str(&format!( + " {}", + styles.dim.apply_to(format_tokens(token_total)) + )); + } + Some(line) } - "StageFailed" => { + "stage.failed" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); - let error = str_field(&envelope, "error").unwrap_or("unknown error"); + let error = prop_str_field(&envelope, "error").unwrap_or("unknown error"); Some(format!( "{} {} {} {}", styles.dim.apply_to(&ts), @@ -484,10 +487,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.red.apply_to(error), )) } - "Agent.AssistantMessage" => { + "agent.message" => { let stage = str_field(&envelope, "node_id").unwrap_or("?"); - let model = str_field(&envelope, "model").unwrap_or("?"); - let text = str_field(&envelope, "text").unwrap_or(""); + let model = prop_str_field(&envelope, "model").unwrap_or("?"); + let text = prop_str_field(&envelope, "text").unwrap_or(""); let header = format!( "{} {} {} {}{}{}", styles.dim.apply_to(&ts), @@ -500,8 +503,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option let body = render_indented_markdown(styles, text, " "); Some(format!("{header}\n{body}\n")) } - "Agent.ToolCallStarted" => { - let tool = str_field(&envelope, "tool_name").unwrap_or("?"); + "agent.tool.started" => { + let tool = prop_str_field(&envelope, "tool_name").unwrap_or("?"); let detail = tool_detail(&envelope); let display = match detail { Some(value) => format!("{tool}({value})"), @@ -514,10 +517,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&display), )) } - "Agent.ToolCallCompleted" => { - let tool = str_field(&envelope, "tool_name").unwrap_or("?"); - let is_error = envelope - .get("is_error") + "agent.tool.completed" => { + let tool = prop_str_field(&envelope, "tool_name").unwrap_or("?"); + let is_error = prop_field(&envelope, "is_error") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let detail = tool_detail(&envelope); @@ -534,10 +536,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option display, )) } - "EdgeSelected" => { - let to = str_field(&envelope, "to_node_id").unwrap_or("?"); - let reason = str_field(&envelope, "reason").unwrap_or("?"); - let condition = str_field(&envelope, "condition"); + "edge.selected" => { + let to = prop_str_field(&envelope, "to_node").unwrap_or("?"); + let reason = prop_str_field(&envelope, "reason").unwrap_or("?"); + let condition = prop_str_field(&envelope, "condition"); let detail = match condition { Some(value) => format!(" [{value}]"), None => String::new(), @@ -551,9 +553,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&detail), )) } - "Sandbox.Ready" => { - let provider = str_field(&envelope, "sandbox_provider").unwrap_or("?"); - let duration = format_duration_ms(envelope.get("duration_ms")); + "sandbox.ready" => { + let provider = prop_str_field(&envelope, "provider").unwrap_or("?"); + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); Some(format!( "{} Sandbox: {} {}", styles.dim.apply_to(&ts), @@ -561,12 +563,11 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&duration), )) } - "SetupCompleted" => { - let count = envelope - .get("command_count") + "setup.completed" => { + let count = prop_field(&envelope, "command_count") .and_then(serde_json::Value::as_u64) .unwrap_or(0); - let duration = format_duration_ms(envelope.get("duration_ms")); + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); Some(format!( "{} Setup: {} commands {}", styles.dim.apply_to(&ts), @@ -574,13 +575,11 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&duration), )) } - "Agent.CompactionCompleted" => { - let original = envelope - .get("original_turn_count") + "agent.compaction.completed" => { + let original = prop_field(&envelope, "original_turn_count") .and_then(serde_json::Value::as_u64) .unwrap_or(0); - let preserved = envelope - .get("preserved_turn_count") + let preserved = prop_field(&envelope, "preserved_turn_count") .and_then(serde_json::Value::as_u64) .unwrap_or(0); Some(format!( @@ -591,9 +590,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option .apply_to(format!("compaction: {original}\u{2192}{preserved} turns")), )) } - "ParallelStarted" => { - let count = envelope - .get("branch_count") + "parallel.started" => { + let count = prop_field(&envelope, "branch_count") .and_then(serde_json::Value::as_u64) .unwrap_or(0); Some(format!( @@ -603,7 +601,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option count, )) } - "ParallelBranchStarted" => { + "parallel.branch.started" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); Some(format!( "{} {} {}", @@ -612,7 +610,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option label, )) } - "ParallelBranchCompleted" => { + "parallel.branch.completed" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); Some(format!( "{} {} {}", @@ -621,8 +619,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option label, )) } - "ParallelCompleted" => { - let duration = format_duration_ms(envelope.get("duration_ms")); + "parallel.completed" => { + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); Some(format!( "{} {} Parallel {}", styles.dim.apply_to(&ts), @@ -630,10 +628,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option duration, )) } - "PullRequestCreated" => { - let url = str_field(&envelope, "pr_url").unwrap_or("?"); - let draft = envelope - .get("draft") + "pull_request.created" => { + let url = prop_str_field(&envelope, "pr_url").unwrap_or("?"); + let draft = prop_field(&envelope, "draft") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let label = if draft { "Draft PR:" } else { "PR:" }; @@ -644,8 +641,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option url, )) } - "PullRequestFailed" => { - let error = str_field(&envelope, "error").unwrap_or("unknown error"); + "pull_request.failed" => { + let error = prop_str_field(&envelope, "error").unwrap_or("unknown error"); Some(format!( "{} {} {}", styles.dim.apply_to(&ts), @@ -653,8 +650,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.red.apply_to(error), )) } - "RetroCompleted" => { - let duration = format_duration_ms(envelope.get("duration_ms")); + "retro.completed" => { + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); Some(format!( "{} {} Retro {}", styles.dim.apply_to(&ts), @@ -662,9 +659,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option duration, )) } - "RetroFailed" => { - let error = str_field(&envelope, "error").unwrap_or("unknown error"); - let duration = format_duration_ms(envelope.get("duration_ms")); + "retro.failed" => { + let error = prop_str_field(&envelope, "error").unwrap_or("unknown error"); + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); Some(format!( "{} {} Retro {} {}", styles.dim.apply_to(&ts), @@ -673,7 +670,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.red.apply_to(error), )) } - "RetroStarted" => Some(format!( + "retro.started" => Some(format!( "{} {} Retro", styles.dim.apply_to(&ts), styles.bold_cyan.apply_to("\u{25b6}"), @@ -686,6 +683,14 @@ fn str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> { value.get(key)?.as_str() } +fn prop_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> { + value.get("properties")?.get(key) +} + +fn prop_str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> { + prop_field(value, key)?.as_str() +} + fn format_timestamp(ts: &str) -> String { ts.parse::>() .map_or_else(|_| ts.to_string(), |dt| dt.format("%H:%M:%S").to_string()) @@ -724,8 +729,8 @@ fn format_tokens(tokens: u64) -> String { } fn tool_detail(envelope: &serde_json::Value) -> Option { - let tool_name = str_field(envelope, "tool_name")?; - let arguments = envelope.get("arguments")?; + let tool_name = prop_str_field(envelope, "tool_name")?; + let arguments = prop_field(envelope, "arguments")?; let arg = |key: &str| arguments.get(key).and_then(|v| v.as_str()); match tool_name { diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs index 139290cb5..072113618 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs @@ -240,256 +240,218 @@ pub(super) enum ProgressEvent { } #[allow(clippy::needless_pass_by_value)] -pub(super) fn from_flattened_fields( +pub(super) fn from_envelope_fields( event_name: &str, - fields: Map, + fields: &Map, ) -> Option { match event_name { - "WorkflowRunStarted" => Some(ProgressEvent::WorkflowStarted { - worktree_dir: string_field(&fields, "worktree_dir"), - base_branch: string_field(&fields, "base_branch"), - base_sha: string_field(&fields, "base_sha"), + "run.started" => Some(ProgressEvent::WorkflowStarted { + worktree_dir: prop_string_field(fields, "worktree_dir"), + base_branch: prop_string_field(fields, "base_branch"), + base_sha: prop_string_field(fields, "base_sha"), }), - "SandboxInitialized" => Some(ProgressEvent::WorkingDirectorySet { - working_directory: string_field(&fields, "working_directory")?, + "sandbox.initialized" => Some(ProgressEvent::WorkingDirectorySet { + working_directory: prop_string_field(fields, "working_directory")?, }), - "Sandbox.Initializing" => Some(ProgressEvent::SandboxInitializing { - provider: string_field(&fields, "sandbox_provider") - .or_else(|| string_field(&fields, "provider")) + "sandbox.initializing" => Some(ProgressEvent::SandboxInitializing { + provider: prop_string_field(fields, "provider") .unwrap_or_else(|| "unknown".to_string()), }), - "Sandbox.Ready" => Some(ProgressEvent::SandboxReady { - provider: string_field(&fields, "sandbox_provider") - .or_else(|| string_field(&fields, "provider")) + "sandbox.ready" => Some(ProgressEvent::SandboxReady { + provider: prop_string_field(fields, "provider") .unwrap_or_else(|| "unknown".to_string()), - duration_ms: u64_field(&fields, "duration_ms"), - name: string_field(&fields, "name"), - cpu: f64_field(&fields, "cpu"), - memory: f64_field(&fields, "memory"), - url: string_field(&fields, "url"), + duration_ms: prop_u64_field(fields, "duration_ms"), + name: prop_string_field(fields, "name"), + cpu: prop_f64_field(fields, "cpu"), + memory: prop_f64_field(fields, "memory"), + url: prop_string_field(fields, "url"), }), - "SshAccessReady" => Some(ProgressEvent::SshAccessReady { - ssh_command: string_field(&fields, "ssh_command")?, + "ssh.ready" => Some(ProgressEvent::SshAccessReady { + ssh_command: prop_string_field(fields, "ssh_command")?, }), - "SetupStarted" => Some(ProgressEvent::SetupStarted { - command_count: u64_field(&fields, "command_count"), + "setup.started" => Some(ProgressEvent::SetupStarted { + command_count: prop_u64_field(fields, "command_count"), }), - "SetupCompleted" => Some(ProgressEvent::SetupCompleted { - duration_ms: u64_field(&fields, "duration_ms"), + "setup.completed" => Some(ProgressEvent::SetupCompleted { + duration_ms: prop_u64_field(fields, "duration_ms"), }), - "SetupCommandCompleted" => Some(ProgressEvent::SetupCommandCompleted { - command: string_field(&fields, "command").unwrap_or_else(|| "?".to_string()), - command_index: u64_field(&fields, "command_index").max(u64_field(&fields, "index")), - exit_code: i64_field(&fields, "exit_code"), - duration_ms: u64_field(&fields, "duration_ms"), + "setup.command.completed" => Some(ProgressEvent::SetupCommandCompleted { + command: prop_string_field(fields, "command").unwrap_or_else(|| "?".to_string()), + command_index: prop_u64_field(fields, "index"), + exit_code: prop_i64_field(fields, "exit_code"), + duration_ms: prop_u64_field(fields, "duration_ms"), }), - "CliEnsureStarted" => Some(ProgressEvent::CliEnsureStarted { - cli_name: string_field(&fields, "cli_name").unwrap_or_else(|| "?".to_string()), + "cli.ensure.started" => Some(ProgressEvent::CliEnsureStarted { + cli_name: prop_string_field(fields, "cli_name").unwrap_or_else(|| "?".to_string()), }), - "CliEnsureCompleted" => Some(ProgressEvent::CliEnsureCompleted { - cli_name: string_field(&fields, "cli_name").unwrap_or_else(|| "?".to_string()), - already_installed: bool_field(&fields, "already_installed"), - duration_ms: u64_field(&fields, "duration_ms"), + "cli.ensure.completed" => Some(ProgressEvent::CliEnsureCompleted { + cli_name: prop_string_field(fields, "cli_name").unwrap_or_else(|| "?".to_string()), + already_installed: prop_bool_field(fields, "already_installed"), + duration_ms: prop_u64_field(fields, "duration_ms"), }), - "CliEnsureFailed" => Some(ProgressEvent::CliEnsureFailed { - cli_name: string_field(&fields, "cli_name").unwrap_or_else(|| "?".to_string()), + "cli.ensure.failed" => Some(ProgressEvent::CliEnsureFailed { + cli_name: prop_string_field(fields, "cli_name").unwrap_or_else(|| "?".to_string()), }), - "DevcontainerResolved" => Some(ProgressEvent::DevcontainerResolved { - dockerfile_lines: u64_field(&fields, "dockerfile_lines"), - environment_count: u64_field(&fields, "environment_count"), - lifecycle_command_count: u64_field(&fields, "lifecycle_command_count"), - workspace_folder: string_field(&fields, "workspace_folder") + "devcontainer.resolved" => Some(ProgressEvent::DevcontainerResolved { + dockerfile_lines: prop_u64_field(fields, "dockerfile_lines"), + environment_count: prop_u64_field(fields, "environment_count"), + lifecycle_command_count: prop_u64_field(fields, "lifecycle_command_count"), + workspace_folder: prop_string_field(fields, "workspace_folder") .unwrap_or_else(|| "?".to_string()), }), - "DevcontainerLifecycleStarted" => Some(ProgressEvent::DevcontainerLifecycleStarted { - phase: string_field(&fields, "phase").unwrap_or_else(|| "?".to_string()), - command_count: u64_field(&fields, "command_count"), + "devcontainer.lifecycle.started" => Some(ProgressEvent::DevcontainerLifecycleStarted { + phase: prop_string_field(fields, "phase").unwrap_or_else(|| "?".to_string()), + command_count: prop_u64_field(fields, "command_count"), }), - "DevcontainerLifecycleCompleted" => Some(ProgressEvent::DevcontainerLifecycleCompleted { - phase: string_field(&fields, "phase").unwrap_or_else(|| "?".to_string()), - duration_ms: u64_field(&fields, "duration_ms"), + "devcontainer.lifecycle.completed" => Some(ProgressEvent::DevcontainerLifecycleCompleted { + phase: prop_string_field(fields, "phase").unwrap_or_else(|| "?".to_string()), + duration_ms: prop_u64_field(fields, "duration_ms"), }), - "DevcontainerLifecycleFailed" => Some(ProgressEvent::DevcontainerLifecycleFailed { - phase: string_field(&fields, "phase").unwrap_or_else(|| "?".to_string()), - command: string_field(&fields, "command").unwrap_or_else(|| "?".to_string()), - exit_code: i64_field(&fields, "exit_code"), - stderr: display_field(&fields, "stderr").unwrap_or_default(), + "devcontainer.lifecycle.failed" => Some(ProgressEvent::DevcontainerLifecycleFailed { + phase: prop_string_field(fields, "phase").unwrap_or_else(|| "?".to_string()), + command: prop_string_field(fields, "command").unwrap_or_else(|| "?".to_string()), + exit_code: prop_i64_field(fields, "exit_code"), + stderr: prop_display_field(fields, "stderr").unwrap_or_default(), }), - "DevcontainerLifecycleCommandCompleted" => { + "devcontainer.lifecycle.command.completed" => { Some(ProgressEvent::DevcontainerLifecycleCommandCompleted { - command: string_field(&fields, "command").unwrap_or_else(|| "?".to_string()), - command_index: u64_field(&fields, "command_index").max(u64_field(&fields, "index")), - exit_code: i64_field(&fields, "exit_code"), - duration_ms: u64_field(&fields, "duration_ms"), + command: prop_string_field(fields, "command").unwrap_or_else(|| "?".to_string()), + command_index: prop_u64_field(fields, "index"), + exit_code: prop_i64_field(fields, "exit_code"), + duration_ms: prop_u64_field(fields, "duration_ms"), }) } - "StageStarted" => Some(ProgressEvent::StageStarted { - node_id: string_field(&fields, "node_id").unwrap_or_else(|| "?".to_string()), - name: string_field(&fields, "node_label") - .or_else(|| string_field(&fields, "name")) - .unwrap_or_else(|| "?".to_string()), - script: string_field(&fields, "script"), + "stage.started" => Some(ProgressEvent::StageStarted { + node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + name: string_field(fields, "node_label").unwrap_or_else(|| "?".to_string()), + script: prop_string_field(fields, "script"), }), - "StageCompleted" => Some(ProgressEvent::StageCompleted { - node_id: string_field(&fields, "node_id").unwrap_or_else(|| "?".to_string()), - name: string_field(&fields, "node_label") - .or_else(|| string_field(&fields, "name")) - .unwrap_or_else(|| "?".to_string()), - duration_ms: u64_field(&fields, "duration_ms"), - status: string_field(&fields, "status").unwrap_or_else(|| "success".to_string()), - usage: fields.get("usage").and_then(ProgressUsage::from_value), + "stage.completed" => Some(ProgressEvent::StageCompleted { + node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + name: string_field(fields, "node_label").unwrap_or_else(|| "?".to_string()), + duration_ms: prop_u64_field(fields, "duration_ms"), + status: prop_string_field(fields, "status").unwrap_or_else(|| "success".to_string()), + usage: prop_value(fields, "usage").and_then(ProgressUsage::from_value), }), - "StageFailed" => Some(ProgressEvent::StageFailed { - node_id: string_field(&fields, "node_id").unwrap_or_else(|| "?".to_string()), - name: string_field(&fields, "node_label") - .or_else(|| string_field(&fields, "name")) - .unwrap_or_else(|| "?".to_string()), - error: display_field(&fields, "error") - .or_else(|| display_field(&fields, "failure_reason")) + "stage.failed" => Some(ProgressEvent::StageFailed { + node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + name: string_field(fields, "node_label").unwrap_or_else(|| "?".to_string()), + error: prop_display_field(fields, "error") .unwrap_or_else(|| "unknown error".to_string()), }), - "StageRetrying" => Some(ProgressEvent::StageRetrying { - name: string_field(&fields, "node_label") - .or_else(|| string_field(&fields, "name")) - .unwrap_or_else(|| "?".to_string()), - attempt: u64_field(&fields, "attempt"), - max_attempts: u64_field(&fields, "max_attempts"), - delay_ms: u64_field(&fields, "delay_ms"), + "stage.retrying" => Some(ProgressEvent::StageRetrying { + name: string_field(fields, "node_label").unwrap_or_else(|| "?".to_string()), + attempt: prop_u64_field(fields, "attempt"), + max_attempts: prop_u64_field(fields, "max_attempts"), + delay_ms: prop_u64_field(fields, "delay_ms"), }), - "ParallelStarted" => Some(ProgressEvent::ParallelStarted), - "ParallelBranchStarted" => Some(ProgressEvent::ParallelBranchStarted { - branch: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "branch")) - .unwrap_or_else(|| "?".to_string()), + "parallel.started" => Some(ProgressEvent::ParallelStarted), + "parallel.branch.started" => Some(ProgressEvent::ParallelBranchStarted { + branch: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), }), - "ParallelBranchCompleted" => Some(ProgressEvent::ParallelBranchCompleted { - branch: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "branch")) - .unwrap_or_else(|| "?".to_string()), - duration_ms: u64_field(&fields, "duration_ms"), - status: string_field(&fields, "status").unwrap_or_else(|| "success".to_string()), + "parallel.branch.completed" => Some(ProgressEvent::ParallelBranchCompleted { + branch: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + duration_ms: prop_u64_field(fields, "duration_ms"), + status: prop_string_field(fields, "status").unwrap_or_else(|| "success".to_string()), }), - "ParallelCompleted" => Some(ProgressEvent::ParallelCompleted), - "Agent.AssistantMessage" => Some(ProgressEvent::AssistantMessage { - stage_node_id: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "stage")) - .unwrap_or_else(|| "?".to_string()), - model: string_field(&fields, "model").unwrap_or_else(|| "?".to_string()), + "parallel.completed" => Some(ProgressEvent::ParallelCompleted), + "agent.message" => Some(ProgressEvent::AssistantMessage { + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + model: prop_string_field(fields, "model").unwrap_or_else(|| "?".to_string()), }), - "Agent.ToolCallStarted" => Some(ProgressEvent::ToolCallStarted { - stage_node_id: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "stage")) + "agent.tool.started" => Some(ProgressEvent::ToolCallStarted { + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + tool_name: prop_string_field(fields, "tool_name").unwrap_or_else(|| "?".to_string()), + tool_call_id: prop_string_field(fields, "tool_call_id") .unwrap_or_else(|| "?".to_string()), - tool_name: string_field(&fields, "tool_name").unwrap_or_else(|| "?".to_string()), - tool_call_id: string_field(&fields, "tool_call_id").unwrap_or_else(|| "?".to_string()), - arguments: fields - .get("arguments") + arguments: prop_value(fields, "arguments") .cloned() .unwrap_or_else(|| Value::Object(Map::new())), - timestamp: timestamp_field(&fields, "ts"), + timestamp: timestamp_field(fields, "ts"), }), - "Agent.ToolCallCompleted" => Some(ProgressEvent::ToolCallCompleted { - stage_node_id: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "stage")) + "agent.tool.completed" => Some(ProgressEvent::ToolCallCompleted { + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + tool_call_id: prop_string_field(fields, "tool_call_id") .unwrap_or_else(|| "?".to_string()), - tool_call_id: string_field(&fields, "tool_call_id").unwrap_or_else(|| "?".to_string()), - is_error: bool_field(&fields, "is_error"), - duration_ms: optional_u64_field(&fields, "duration_ms"), - timestamp: timestamp_field(&fields, "ts"), + is_error: prop_bool_field(fields, "is_error"), + duration_ms: prop_optional_u64_field(fields, "duration_ms"), + timestamp: timestamp_field(fields, "ts"), }), - "Agent.Warning" if string_field(&fields, "kind").as_deref() == Some("context_window") => { - let usage_percent = fields - .get("details") + "agent.warning" + if prop_string_field(fields, "kind").as_deref() == Some("context_window") => + { + let usage_percent = prop_value(fields, "details") .and_then(Value::as_object) .and_then(|details| details.get("usage_percent")) .and_then(Value::as_u64) .unwrap_or(0); Some(ProgressEvent::ContextWindowWarning { - stage_node_id: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "stage")) - .unwrap_or_else(|| "?".to_string()), + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), usage_percent, }) } - "Agent.CompactionStarted" => Some(ProgressEvent::CompactionStarted { - stage_node_id: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "stage")) - .unwrap_or_else(|| "?".to_string()), + "agent.compaction.started" => Some(ProgressEvent::CompactionStarted { + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), }), - "Agent.CompactionCompleted" => Some(ProgressEvent::CompactionCompleted { - stage_node_id: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "stage")) - .unwrap_or_else(|| "?".to_string()), - original_turn_count: u64_field(&fields, "original_turn_count"), - preserved_turn_count: u64_field(&fields, "preserved_turn_count"), - tracked_file_count: u64_field(&fields, "tracked_file_count"), + "agent.compaction.completed" => Some(ProgressEvent::CompactionCompleted { + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + original_turn_count: prop_u64_field(fields, "original_turn_count"), + preserved_turn_count: prop_u64_field(fields, "preserved_turn_count"), + tracked_file_count: prop_u64_field(fields, "tracked_file_count"), }), - "Agent.LlmRetry" => { - let delay_secs = f64_field(&fields, "delay_secs").unwrap_or(0.0); + "agent.llm.retry" => { + let delay_secs = prop_f64_field(fields, "delay_secs").unwrap_or(0.0); #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let delay_ms = (delay_secs * 1000.0) as u64; Some(ProgressEvent::LlmRetry { - stage_node_id: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "stage")) - .unwrap_or_else(|| "?".to_string()), - model: string_field(&fields, "model").unwrap_or_else(|| "?".to_string()), - attempt: u64_field(&fields, "attempt"), + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + model: prop_string_field(fields, "model").unwrap_or_else(|| "?".to_string()), + attempt: prop_u64_field(fields, "attempt"), delay_ms, - error: display_field(&fields, "error") + error: prop_display_field(fields, "error") .unwrap_or_else(|| "unknown error".to_string()), }) } - "Agent.SubAgentSpawned" => Some(ProgressEvent::SubagentSpawned { - stage_node_id: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "stage")) - .unwrap_or_else(|| "?".to_string()), - agent_id: string_field(&fields, "agent_id").unwrap_or_else(|| "?".to_string()), - task: string_field(&fields, "task").unwrap_or_default(), + "agent.sub.spawned" => Some(ProgressEvent::SubagentSpawned { + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + agent_id: prop_string_field(fields, "agent_id").unwrap_or_else(|| "?".to_string()), + task: prop_string_field(fields, "task").unwrap_or_default(), }), - "Agent.SubAgentCompleted" => Some(ProgressEvent::SubagentCompleted { - stage_node_id: string_field(&fields, "node_id") - .or_else(|| string_field(&fields, "stage")) - .unwrap_or_else(|| "?".to_string()), - agent_id: string_field(&fields, "agent_id").unwrap_or_else(|| "?".to_string()), - success: bool_field(&fields, "success"), - turns_used: u64_field(&fields, "turns_used"), + "agent.sub.completed" => Some(ProgressEvent::SubagentCompleted { + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + agent_id: prop_string_field(fields, "agent_id").unwrap_or_else(|| "?".to_string()), + success: prop_bool_field(fields, "success"), + turns_used: prop_u64_field(fields, "turns_used"), }), - "EdgeSelected" => Some(ProgressEvent::EdgeSelected { - from_node: string_field(&fields, "from_node_id") - .or_else(|| string_field(&fields, "from_node")) - .unwrap_or_else(|| "?".to_string()), - to_node: string_field(&fields, "to_node_id") - .or_else(|| string_field(&fields, "to_node")) - .unwrap_or_else(|| "?".to_string()), - label: string_field(&fields, "label"), - condition: string_field(&fields, "condition"), + "edge.selected" => Some(ProgressEvent::EdgeSelected { + from_node: prop_string_field(fields, "from_node").unwrap_or_else(|| "?".to_string()), + to_node: prop_string_field(fields, "to_node").unwrap_or_else(|| "?".to_string()), + label: prop_string_field(fields, "label"), + condition: prop_string_field(fields, "condition"), }), - "LoopRestart" => Some(ProgressEvent::LoopRestart { - from_node: string_field(&fields, "from_node_id") - .or_else(|| string_field(&fields, "from_node")) - .unwrap_or_else(|| "?".to_string()), - to_node: string_field(&fields, "to_node_id") - .or_else(|| string_field(&fields, "to_node")) - .unwrap_or_else(|| "?".to_string()), + "loop.restart" => Some(ProgressEvent::LoopRestart { + from_node: prop_string_field(fields, "from_node").unwrap_or_else(|| "?".to_string()), + to_node: prop_string_field(fields, "to_node").unwrap_or_else(|| "?".to_string()), }), - "RetroStarted" => Some(ProgressEvent::RetroStarted), - "RetroCompleted" => Some(ProgressEvent::RetroCompleted { - duration_ms: u64_field(&fields, "duration_ms"), + "retro.started" => Some(ProgressEvent::RetroStarted), + "retro.completed" => Some(ProgressEvent::RetroCompleted { + duration_ms: prop_u64_field(fields, "duration_ms"), }), - "RetroFailed" => Some(ProgressEvent::RetroFailed { - duration_ms: u64_field(&fields, "duration_ms"), + "retro.failed" => Some(ProgressEvent::RetroFailed { + duration_ms: prop_u64_field(fields, "duration_ms"), }), - "RunNotice" => Some(ProgressEvent::RunNotice { - level: parse_run_notice_level(string_field(&fields, "level").as_deref()), - code: string_field(&fields, "code").unwrap_or_default(), - message: string_field(&fields, "message").unwrap_or_default(), + "run.notice" => Some(ProgressEvent::RunNotice { + level: parse_run_notice_level(prop_string_field(fields, "level").as_deref()), + code: prop_string_field(fields, "code").unwrap_or_default(), + message: prop_string_field(fields, "message").unwrap_or_default(), }), - "PullRequestCreated" => Some(ProgressEvent::PullRequestCreated { - pr_url: string_field(&fields, "pr_url").unwrap_or_else(|| "?".to_string()), - draft: bool_field(&fields, "draft"), + "pull_request.created" => Some(ProgressEvent::PullRequestCreated { + pr_url: prop_string_field(fields, "pr_url").unwrap_or_else(|| "?".to_string()), + draft: prop_bool_field(fields, "draft"), }), - "PullRequestFailed" => Some(ProgressEvent::PullRequestFailed { - error: display_field(&fields, "error").unwrap_or_else(|| "unknown error".to_string()), + "pull_request.failed" => Some(ProgressEvent::PullRequestFailed { + error: prop_display_field(fields, "error") + .unwrap_or_else(|| "unknown error".to_string()), }), _ => None, } @@ -507,8 +469,21 @@ fn string_field(fields: &Map, key: &str) -> Option { fields.get(key).and_then(Value::as_str).map(str::to_owned) } -fn display_field(fields: &Map, key: &str) -> Option { - let value = fields.get(key)?; +fn prop_value<'a>(fields: &'a Map, key: &str) -> Option<&'a Value> { + fields + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get(key)) +} + +fn prop_string_field(fields: &Map, key: &str) -> Option { + prop_value(fields, key) + .and_then(Value::as_str) + .map(str::to_owned) +} + +fn prop_display_field(fields: &Map, key: &str) -> Option { + let value = prop_value(fields, key)?; match value { Value::Null => None, Value::String(value) => Some(value.clone()), @@ -540,20 +515,30 @@ fn u64_field(fields: &Map, key: &str) -> u64 { fields.get(key).and_then(Value::as_u64).unwrap_or(0) } -fn optional_u64_field(fields: &Map, key: &str) -> Option { - fields.get(key).and_then(Value::as_u64) +fn prop_u64_field(fields: &Map, key: &str) -> u64 { + prop_value(fields, key).and_then(Value::as_u64).unwrap_or(0) } -fn i64_field(fields: &Map, key: &str) -> i64 { - fields.get(key).and_then(Value::as_i64).unwrap_or(0) +fn prop_optional_u64_field(fields: &Map, key: &str) -> Option { + prop_value(fields, key).and_then(Value::as_u64) +} + +fn prop_i64_field(fields: &Map, key: &str) -> i64 { + prop_value(fields, key).and_then(Value::as_i64).unwrap_or(0) } fn f64_field(fields: &Map, key: &str) -> Option { fields.get(key).and_then(Value::as_f64) } -fn bool_field(fields: &Map, key: &str) -> bool { - fields.get(key).and_then(Value::as_bool).unwrap_or(false) +fn prop_f64_field(fields: &Map, key: &str) -> Option { + prop_value(fields, key).and_then(Value::as_f64) +} + +fn prop_bool_field(fields: &Map, key: &str) -> bool { + prop_value(fields, key) + .and_then(Value::as_bool) + .unwrap_or(false) } fn timestamp_field(fields: &Map, key: &str) -> Option> { @@ -566,8 +551,8 @@ fn timestamp_field(fields: &Map, key: &str) -> Option (String, Map) { + let envelope = canonicalize_event(&fixtures::RUN_1, event); + let event_name = envelope.event.clone(); + let fields = json_map(serde_json::to_value(envelope).expect("serializable envelope")); + (event_name, fields) + } + #[test] fn parse_edge_selected() { let fields = json_map(serde_json::json!({ @@ -583,7 +575,7 @@ mod tests { "label": "yes" })); - let event = from_flattened_fields("EdgeSelected", fields).unwrap(); + let event = from_envelope_fields("edge.selected", &fields).unwrap(); assert!(matches!( event, ProgressEvent::EdgeSelected { @@ -613,8 +605,8 @@ mod tests { max_attempts: 1, }; - let (name, fields) = flatten_event(&event); - let parsed = from_flattened_fields(&name, fields).unwrap(); + let (name, fields) = canonical_fields(&event); + let parsed = from_envelope_fields(&name, &fields).unwrap(); assert!(matches!( parsed, ProgressEvent::StageCompleted { @@ -635,10 +627,12 @@ mod tests { tool_call_id: "tc1".into(), arguments: serde_json::json!({"path": "src/main.rs"}), }, + session_id: None, + parent_session_id: None, }; - let (name, fields) = flatten_event(&event); - let parsed = from_flattened_fields(&name, fields).unwrap(); + let (name, fields) = canonical_fields(&event); + let parsed = from_envelope_fields(&name, &fields).unwrap(); assert!(matches!( parsed, ProgressEvent::ToolCallStarted { @@ -655,20 +649,24 @@ mod tests { let started_fields = json_map(serde_json::json!({ "ts": "2026-03-30T12:00:00.000Z", "node_id": "code", - "tool_name": "read_file", - "tool_call_id": "tc1", - "arguments": {"path": "src/main.rs"} + "properties": { + "tool_name": "read_file", + "tool_call_id": "tc1", + "arguments": {"path": "src/main.rs"} + } })); let completed_fields = json_map(serde_json::json!({ "ts": "2026-03-30T12:00:00.500Z", "node_id": "code", - "tool_call_id": "tc1", - "is_error": false, - "duration_ms": 500 + "properties": { + "tool_call_id": "tc1", + "is_error": false, + "duration_ms": 500 + } })); - let started = from_flattened_fields("Agent.ToolCallStarted", started_fields).unwrap(); - let completed = from_flattened_fields("Agent.ToolCallCompleted", completed_fields).unwrap(); + let started = from_envelope_fields("agent.tool.started", &started_fields).unwrap(); + let completed = from_envelope_fields("agent.tool.completed", &completed_fields).unwrap(); assert!(matches!( started, @@ -704,8 +702,8 @@ mod tests { }, }; - let (name, fields) = flatten_event(&event); - let parsed = from_flattened_fields(&name, fields).unwrap(); + let (name, fields) = canonical_fields(&event); + let parsed = from_envelope_fields(&name, &fields).unwrap(); assert!(matches!( parsed, ProgressEvent::SandboxReady { @@ -725,8 +723,8 @@ mod tests { message: "sandbox cleanup failed".into(), }; - let (name, fields) = flatten_event(&event); - let parsed = from_flattened_fields(&name, fields).unwrap(); + let (name, fields) = canonical_fields(&event); + let parsed = from_envelope_fields(&name, &fields).unwrap(); assert!(matches!( parsed, ProgressEvent::RunNotice { diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 34be7b23d..88518def9 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -1,6 +1,6 @@ use serde_json::Value; -use fabro_workflow::event::{WorkflowRunEvent, flatten_event}; +use fabro_workflow::event::RunEventEnvelope; mod event; mod info_display; @@ -9,7 +9,7 @@ mod setup_display; mod stage_display; mod styles; -use event::{ProgressEvent, from_flattened_fields}; +use event::{ProgressEvent, from_envelope_fields}; use info_display::InfoDisplay; use renderer::ProgressRenderer; use setup_display::SetupDisplay; @@ -68,24 +68,23 @@ impl ProgressUI { } #[cfg_attr(not(test), allow(dead_code))] - pub(crate) fn handle_event(&mut self, event: &WorkflowRunEvent) { - let (event_name, fields) = flatten_event(event); - if let Some(progress_event) = from_flattened_fields(&event_name, fields) { + pub(crate) fn handle_event(&mut self, event: &RunEventEnvelope) { + let Ok(Value::Object(envelope)) = serde_json::to_value(event) else { + return; + }; + if let Some(progress_event) = from_envelope_fields(&event.event, &envelope) { self.dispatch(progress_event); } } pub(crate) fn handle_json_line(&mut self, line: &str) { - let Ok(Value::Object(mut envelope)) = serde_json::from_str(line) else { + let Ok(Value::Object(envelope)) = serde_json::from_str(line) else { return; }; - let Some(event_name) = envelope - .remove("event") - .and_then(|value| value.as_str().map(str::to_owned)) - else { + let Some(event_name) = envelope.get("event").and_then(|value| value.as_str()) else { return; }; - if let Some(progress_event) = from_flattened_fields(&event_name, envelope) { + if let Some(progress_event) = from_envelope_fields(event_name, &envelope) { self.dispatch(progress_event); } } @@ -428,7 +427,8 @@ mod tests { use fabro_agent::{AgentEvent, SandboxEvent}; use fabro_llm::types::Usage; - use fabro_workflow::event::{RunNoticeLevel, flatten_event}; + use fabro_types::fixtures; + use fabro_workflow::event::{RunNoticeLevel, WorkflowRunEvent, canonicalize_event}; use fabro_workflow::outcome::StageUsage; use super::*; @@ -469,6 +469,25 @@ mod tests { .expect("valid utf-8") } + fn emit(ui: &mut ProgressUI, event: WorkflowRunEvent) { + let envelope = canonicalize_event(&fixtures::RUN_1, &event); + ui.handle_event(&envelope); + } + + fn emit_ref(ui: &mut ProgressUI, event: &WorkflowRunEvent) { + let envelope = canonicalize_event(&fixtures::RUN_1, event); + ui.handle_event(&envelope); + } + + fn agent_event(stage: &str, event: AgentEvent) -> WorkflowRunEvent { + WorkflowRunEvent::Agent { + stage: stage.into(), + event, + session_id: None, + parent_session_id: None, + } + } + fn stage_started(node_id: &str, name: &str) -> WorkflowRunEvent { WorkflowRunEvent::StageStarted { node_id: node_id.into(), @@ -482,15 +501,15 @@ mod tests { } fn assistant_message(stage: &str, model: &str) -> WorkflowRunEvent { - WorkflowRunEvent::Agent { - stage: stage.into(), - event: AgentEvent::AssistantMessage { + agent_event( + stage, + AgentEvent::AssistantMessage { text: "done".into(), model: model.into(), usage: Usage::default(), tool_call_count: 0, }, - } + ) } fn stage_completed(node_id: &str, name: &str) -> WorkflowRunEvent { @@ -524,20 +543,26 @@ mod tests { fn parallel_branches_tracked_as_tool_calls() { let mut ui = ProgressUI::new(true, false); - ui.handle_event(&stage_started("fork1", "Fork Analysis")); + emit(&mut ui, stage_started("fork1", "Fork Analysis")); assert!(ui.stage.active_stages.contains_key("fork1")); assert!(ui.stage.parallel_parent.is_none()); - ui.handle_event(&WorkflowRunEvent::ParallelStarted { - branch_count: 2, - join_policy: "wait_all".into(), - }); + emit( + &mut ui, + WorkflowRunEvent::ParallelStarted { + branch_count: 2, + join_policy: "wait_all".into(), + }, + ); assert_eq!(ui.stage.parallel_parent.as_deref(), Some("fork1")); - ui.handle_event(&WorkflowRunEvent::ParallelBranchStarted { - branch: "security".into(), - index: 0, - }); + emit( + &mut ui, + WorkflowRunEvent::ParallelBranchStarted { + branch: "security".into(), + index: 0, + }, + ); let stage = &ui.stage.active_stages["fork1"]; assert_eq!(stage.tool_calls.len(), 1); assert_eq!(stage.tool_calls[0].tool_call_id, "security"); @@ -546,12 +571,15 @@ mod tests { ToolCallStatus::Running )); - ui.handle_event(&WorkflowRunEvent::ParallelBranchCompleted { - branch: "security".into(), - index: 0, - duration_ms: 2000, - status: "success".into(), - }); + emit( + &mut ui, + WorkflowRunEvent::ParallelBranchCompleted { + branch: "security".into(), + index: 0, + duration_ms: 2000, + status: "success".into(), + }, + ); let stage = &ui.stage.active_stages["fork1"]; assert!(matches!( stage.tool_calls[0].status, @@ -563,15 +591,21 @@ mod tests { fn parallel_branch_running_shows_triangle_glyph() { let mut ui = ProgressUI::new(true, false); - ui.handle_event(&stage_started("fork1", "Fork")); - ui.handle_event(&WorkflowRunEvent::ParallelStarted { - branch_count: 1, - join_policy: "wait_all".into(), - }); - ui.handle_event(&WorkflowRunEvent::ParallelBranchStarted { - branch: "security".into(), - index: 0, - }); + emit(&mut ui, stage_started("fork1", "Fork")); + emit( + &mut ui, + WorkflowRunEvent::ParallelStarted { + branch_count: 1, + join_policy: "wait_all".into(), + }, + ); + emit( + &mut ui, + WorkflowRunEvent::ParallelBranchStarted { + branch: "security".into(), + index: 0, + }, + ); let stage = &ui.stage.active_stages["fork1"]; let message = stage.tool_calls[0].bar.message(); @@ -585,27 +619,33 @@ mod tests { fn compaction_sets_and_clears_bar() { let mut ui = ProgressUI::new(true, false); - ui.handle_event(&stage_started("s1", "Build")); + emit(&mut ui, stage_started("s1", "Build")); assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); - ui.handle_event(&WorkflowRunEvent::Agent { - stage: "s1".into(), - event: AgentEvent::CompactionStarted { - estimated_tokens: 5000, - context_window_size: 8000, - }, - }); + emit( + &mut ui, + agent_event( + "s1", + AgentEvent::CompactionStarted { + estimated_tokens: 5000, + context_window_size: 8000, + }, + ), + ); assert!(ui.stage.active_stages["s1"].compaction_bar.is_some()); - ui.handle_event(&WorkflowRunEvent::Agent { - stage: "s1".into(), - event: AgentEvent::CompactionCompleted { - original_turn_count: 20, - preserved_turn_count: 6, - summary_token_estimate: 500, - tracked_file_count: 3, - }, - }); + emit( + &mut ui, + agent_event( + "s1", + AgentEvent::CompactionCompleted { + original_turn_count: 20, + preserved_turn_count: 6, + summary_token_estimate: 500, + tracked_file_count: 3, + }, + ), + ); assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); } @@ -625,16 +665,16 @@ mod tests { WorkflowRunEvent::SandboxInitialized { working_directory: "/home/daytona/workspace".into(), }, - WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::ToolCallStarted { + agent_event( + "code", + AgentEvent::ToolCallStarted { tool_name: "read_file".into(), tool_call_id: "tc1".into(), arguments: serde_json::json!({ "file_path": "/home/daytona/workspace/src/main.rs" }), }, - }, + ), assistant_message("code", "gpt-5-mini"), WorkflowRunEvent::EdgeSelected { from_node: "code".into(), @@ -655,17 +695,17 @@ mod tests { max_attempts: 3, delay_ms: 1500, }, - WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::Warning { + agent_event( + "code", + AgentEvent::Warning { kind: "context_window".into(), message: "high usage".into(), details: serde_json::json!({"usage_percent": 92}), }, - }, - WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::LlmRetry { + ), + agent_event( + "code", + AgentEvent::LlmRetry { provider: "openai".into(), model: "gpt-5-mini".into(), attempt: 2, @@ -675,24 +715,24 @@ mod tests { source: None, }, }, - }, - WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::SubAgentSpawned { + ), + agent_event( + "code", + AgentEvent::SubAgentSpawned { agent_id: "a1".into(), depth: 1, task: "review recent changes".into(), }, - }, - WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::SubAgentCompleted { + ), + agent_event( + "code", + AgentEvent::SubAgentCompleted { agent_id: "a1".into(), depth: 1, success: true, turns_used: 3, }, - }, + ), WorkflowRunEvent::SetupStarted { command_count: 1 }, WorkflowRunEvent::SetupCommandCompleted { command: "bun install".into(), @@ -720,16 +760,12 @@ mod tests { let (mut event_ui, event_buffer) = capture_ui(true); for event in &events { - event_ui.handle_event(event); + emit_ref(&mut event_ui, event); } let (mut json_ui, json_buffer) = capture_ui(true); for event in &events { - let (event_name, fields) = flatten_event(event); - let mut envelope = serde_json::Map::new(); - envelope.insert("event".into(), event_name.into()); - envelope.extend(fields); - let line = serde_json::to_string(&serde_json::Value::Object(envelope)).unwrap(); + let line = serde_json::to_string(&canonicalize_event(&fixtures::RUN_1, event)).unwrap(); json_ui.handle_json_line(&line); } @@ -740,26 +776,32 @@ mod tests { fn plain_default_stage_snapshot() { let (mut ui, buffer) = capture_ui(false); - ui.handle_event(&stage_started("plan", "Plan")); - ui.handle_event(&assistant_message("plan", "gpt-5-mini")); - ui.handle_event(&WorkflowRunEvent::Agent { - stage: "plan".into(), - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }, - }); - ui.handle_event(&WorkflowRunEvent::Agent { - stage: "plan".into(), - event: AgentEvent::ToolCallCompleted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - output: serde_json::json!({"ok": true}), - is_error: false, - }, - }); - ui.handle_event(&stage_completed("plan", "Plan")); + emit(&mut ui, stage_started("plan", "Plan")); + emit(&mut ui, assistant_message("plan", "gpt-5-mini")); + emit( + &mut ui, + agent_event( + "plan", + AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }, + ), + ); + emit( + &mut ui, + agent_event( + "plan", + AgentEvent::ToolCallCompleted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + output: serde_json::json!({"ok": true}), + is_error: false, + }, + ), + ); + emit(&mut ui, stage_completed("plan", "Plan")); insta::assert_snapshot!(rendered(&buffer), @r" ✓ Plan $0.12 5s @@ -770,47 +812,71 @@ mod tests { fn plain_default_setup_snapshot() { let (mut ui, buffer) = capture_ui(false); - ui.handle_event(&WorkflowRunEvent::Sandbox { - event: SandboxEvent::Initializing { - provider: "daytona".into(), + emit( + &mut ui, + WorkflowRunEvent::Sandbox { + event: SandboxEvent::Initializing { + provider: "daytona".into(), + }, }, - }); - ui.handle_event(&WorkflowRunEvent::Sandbox { - event: SandboxEvent::Ready { - provider: "daytona".into(), - duration_ms: 2500, - name: Some("sandbox-1".into()), - cpu: Some(4.0), - memory: Some(8.0), - url: None, + ); + emit( + &mut ui, + WorkflowRunEvent::Sandbox { + event: SandboxEvent::Ready { + provider: "daytona".into(), + duration_ms: 2500, + name: Some("sandbox-1".into()), + cpu: Some(4.0), + memory: Some(8.0), + url: None, + }, }, - }); - ui.handle_event(&WorkflowRunEvent::SshAccessReady { - ssh_command: "ssh daytona@example".into(), - }); - ui.handle_event(&WorkflowRunEvent::SetupStarted { command_count: 2 }); - ui.handle_event(&WorkflowRunEvent::SetupCompleted { duration_ms: 8200 }); - ui.handle_event(&WorkflowRunEvent::CliEnsureCompleted { - cli_name: "gh".into(), - provider: "github".into(), - already_installed: false, - node_installed: false, - duration_ms: 600, - }); - ui.handle_event(&WorkflowRunEvent::DevcontainerResolved { - dockerfile_lines: 24, - environment_count: 3, - lifecycle_command_count: 2, - workspace_folder: "/workspace".into(), - }); - ui.handle_event(&WorkflowRunEvent::DevcontainerLifecycleStarted { - phase: "postCreate".into(), - command_count: 2, - }); - ui.handle_event(&WorkflowRunEvent::DevcontainerLifecycleCompleted { - phase: "postCreate".into(), - duration_ms: 1800, - }); + ); + emit( + &mut ui, + WorkflowRunEvent::SshAccessReady { + ssh_command: "ssh daytona@example".into(), + }, + ); + emit(&mut ui, WorkflowRunEvent::SetupStarted { command_count: 2 }); + emit( + &mut ui, + WorkflowRunEvent::SetupCompleted { duration_ms: 8200 }, + ); + emit( + &mut ui, + WorkflowRunEvent::CliEnsureCompleted { + cli_name: "gh".into(), + provider: "github".into(), + already_installed: false, + node_installed: false, + duration_ms: 600, + }, + ); + emit( + &mut ui, + WorkflowRunEvent::DevcontainerResolved { + dockerfile_lines: 24, + environment_count: 3, + lifecycle_command_count: 2, + workspace_folder: "/workspace".into(), + }, + ); + emit( + &mut ui, + WorkflowRunEvent::DevcontainerLifecycleStarted { + phase: "postCreate".into(), + command_count: 2, + }, + ); + emit( + &mut ui, + WorkflowRunEvent::DevcontainerLifecycleCompleted { + phase: "postCreate".into(), + duration_ms: 1800, + }, + ); insta::assert_snapshot!(rendered(&buffer), @r" Sandbox: daytona (ready in 2s) @@ -829,102 +895,141 @@ mod tests { fn plain_verbose_snapshot() { let (mut ui, buffer) = capture_ui(true); - ui.handle_event(&stage_started("code", "Code")); - ui.handle_event(&WorkflowRunEvent::SandboxInitialized { - working_directory: "/home/daytona/workspace".into(), - }); - ui.handle_event(&WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({ - "file_path": "/home/daytona/workspace/src/main.rs" - }), + emit(&mut ui, stage_started("code", "Code")); + emit( + &mut ui, + WorkflowRunEvent::SandboxInitialized { + working_directory: "/home/daytona/workspace".into(), }, - }); - ui.handle_event(&assistant_message("code", "gpt-5-mini")); - ui.handle_event(&WorkflowRunEvent::EdgeSelected { - from_node: "code".into(), - to_node: "review".into(), - label: Some("ship".into()), - condition: None, - reason: "condition".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "success".into(), - is_jump: false, - }); - ui.handle_event(&WorkflowRunEvent::StageRetrying { - node_id: "code".into(), - name: "Code".into(), - index: 0, - attempt: 2, - max_attempts: 3, - delay_ms: 1500, - }); - ui.handle_event(&WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::Warning { - kind: "context_window".into(), - message: "high usage".into(), - details: serde_json::json!({"usage_percent": 92}), - }, - }); - ui.handle_event(&WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::LlmRetry { - provider: "openai".into(), - model: "gpt-5-mini".into(), - attempt: 2, - delay_secs: 1.5, - error: fabro_llm::error::SdkError::Configuration { - message: "busy".into(), - source: None, + ); + emit( + &mut ui, + agent_event( + "code", + AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({ + "file_path": "/home/daytona/workspace/src/main.rs" + }), }, + ), + ); + emit(&mut ui, assistant_message("code", "gpt-5-mini")); + emit( + &mut ui, + WorkflowRunEvent::EdgeSelected { + from_node: "code".into(), + to_node: "review".into(), + label: Some("ship".into()), + condition: None, + reason: "condition".into(), + preferred_label: None, + suggested_next_ids: Vec::new(), + stage_status: "success".into(), + is_jump: false, }, - }); - ui.handle_event(&WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::SubAgentSpawned { - agent_id: "a1".into(), - depth: 1, - task: "review recent changes".into(), + ); + emit( + &mut ui, + WorkflowRunEvent::StageRetrying { + node_id: "code".into(), + name: "Code".into(), + index: 0, + attempt: 2, + max_attempts: 3, + delay_ms: 1500, }, - }); - ui.handle_event(&WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::SubAgentCompleted { - agent_id: "a1".into(), - depth: 1, - success: true, - turns_used: 3, + ); + emit( + &mut ui, + agent_event( + "code", + AgentEvent::Warning { + kind: "context_window".into(), + message: "high usage".into(), + details: serde_json::json!({"usage_percent": 92}), + }, + ), + ); + emit( + &mut ui, + agent_event( + "code", + AgentEvent::LlmRetry { + provider: "openai".into(), + model: "gpt-5-mini".into(), + attempt: 2, + delay_secs: 1.5, + error: fabro_llm::error::SdkError::Configuration { + message: "busy".into(), + source: None, + }, + }, + ), + ); + emit( + &mut ui, + agent_event( + "code", + AgentEvent::SubAgentSpawned { + agent_id: "a1".into(), + depth: 1, + task: "review recent changes".into(), + }, + ), + ); + emit( + &mut ui, + agent_event( + "code", + AgentEvent::SubAgentCompleted { + agent_id: "a1".into(), + depth: 1, + success: true, + turns_used: 3, + }, + ), + ); + emit(&mut ui, WorkflowRunEvent::SetupStarted { command_count: 1 }); + emit( + &mut ui, + WorkflowRunEvent::SetupCommandCompleted { + command: "bun install".into(), + index: 0, + exit_code: 0, + duration_ms: 2200, }, - }); - ui.handle_event(&WorkflowRunEvent::SetupStarted { command_count: 1 }); - ui.handle_event(&WorkflowRunEvent::SetupCommandCompleted { - command: "bun install".into(), - index: 0, - exit_code: 0, - duration_ms: 2200, - }); - ui.handle_event(&WorkflowRunEvent::SetupCompleted { duration_ms: 2200 }); - ui.handle_event(&WorkflowRunEvent::DevcontainerLifecycleStarted { - phase: "postCreate".into(), - command_count: 1, - }); - ui.handle_event(&WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { - phase: "postCreate".into(), - command: "npm run setup".into(), - index: 0, - exit_code: 0, - duration_ms: 1400, - }); - ui.handle_event(&WorkflowRunEvent::DevcontainerLifecycleCompleted { - phase: "postCreate".into(), - duration_ms: 1400, - }); - ui.handle_event(&stage_completed("code", "Code")); + ); + emit( + &mut ui, + WorkflowRunEvent::SetupCompleted { duration_ms: 2200 }, + ); + emit( + &mut ui, + WorkflowRunEvent::DevcontainerLifecycleStarted { + phase: "postCreate".into(), + command_count: 1, + }, + ); + emit( + &mut ui, + WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { + phase: "postCreate".into(), + command: "npm run setup".into(), + index: 0, + exit_code: 0, + duration_ms: 1400, + }, + ); + emit( + &mut ui, + WorkflowRunEvent::DevcontainerLifecycleCompleted { + phase: "postCreate".into(), + duration_ms: 1400, + }, + ); + emit(&mut ui, stage_completed("code", "Code")); insta::assert_snapshot!(rendered(&buffer), @r#" → code → review "ship" @@ -946,19 +1051,28 @@ mod tests { fn plain_notice_snapshot() { let (mut ui, buffer) = capture_ui(false); - ui.handle_event(&WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Warn, - code: "sandbox_cleanup_failed".into(), - message: "sandbox cleanup failed".into(), - }); - ui.handle_event(&WorkflowRunEvent::PullRequestCreated { - pr_url: "https://github.com/fabro-sh/fabro/pull/42".into(), - pr_number: 42, - draft: true, - }); - ui.handle_event(&WorkflowRunEvent::PullRequestFailed { - error: "auth token expired".into(), - }); + emit( + &mut ui, + WorkflowRunEvent::RunNotice { + level: RunNoticeLevel::Warn, + code: "sandbox_cleanup_failed".into(), + message: "sandbox cleanup failed".into(), + }, + ); + emit( + &mut ui, + WorkflowRunEvent::PullRequestCreated { + pr_url: "https://github.com/fabro-sh/fabro/pull/42".into(), + pr_number: 42, + draft: true, + }, + ); + emit( + &mut ui, + WorkflowRunEvent::PullRequestFailed { + error: "auth token expired".into(), + }, + ); insta::assert_snapshot!(rendered(&buffer), @r" Warning: sandbox cleanup failed [sandbox_cleanup_failed] @@ -971,21 +1085,30 @@ mod tests { fn tty_parallel_branch_completion_uses_recorded_duration() { let mut ui = ProgressUI::new(true, false); - ui.handle_event(&stage_started("fork1", "Fork")); - ui.handle_event(&WorkflowRunEvent::ParallelStarted { - branch_count: 1, - join_policy: "wait_all".into(), - }); - ui.handle_event(&WorkflowRunEvent::ParallelBranchStarted { - branch: "security".into(), - index: 0, - }); - ui.handle_event(&WorkflowRunEvent::ParallelBranchCompleted { - branch: "security".into(), - index: 0, - duration_ms: 500, - status: "success".into(), - }); + emit(&mut ui, stage_started("fork1", "Fork")); + emit( + &mut ui, + WorkflowRunEvent::ParallelStarted { + branch_count: 1, + join_policy: "wait_all".into(), + }, + ); + emit( + &mut ui, + WorkflowRunEvent::ParallelBranchStarted { + branch: "security".into(), + index: 0, + }, + ); + emit( + &mut ui, + WorkflowRunEvent::ParallelBranchCompleted { + branch: "security".into(), + index: 0, + duration_ms: 500, + status: "success".into(), + }, + ); let stage = &ui.stage.active_stages["fork1"]; assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms"); @@ -996,13 +1119,13 @@ mod tests { let mut ui = ProgressUI::new(true, false); ui.handle_json_line( - r#"{"ts":"2026-03-30T12:00:00.000Z","event":"StageStarted","node_id":"code","node_label":"Code"}"#, + r#"{"ts":"2026-03-30T12:00:00.000Z","event":"stage.started","node_id":"code","node_label":"Code","properties":{"attempt":1,"max_attempts":1}}"#, ); ui.handle_json_line( - r#"{"ts":"2026-03-30T12:00:00.000Z","event":"Agent.ToolCallStarted","node_id":"code","tool_name":"read_file","tool_call_id":"tc1","arguments":{"path":"src/main.rs"}}"#, + r#"{"ts":"2026-03-30T12:00:00.000Z","event":"agent.tool.started","node_id":"code","properties":{"tool_name":"read_file","tool_call_id":"tc1","arguments":{"path":"src/main.rs"}}}"#, ); ui.handle_json_line( - r#"{"ts":"2026-03-30T12:00:00.500Z","event":"Agent.ToolCallCompleted","node_id":"code","tool_call_id":"tc1","is_error":false}"#, + r#"{"ts":"2026-03-30T12:00:00.500Z","event":"agent.tool.completed","node_id":"code","properties":{"tool_call_id":"tc1","is_error":false}}"#, ); let stage = &ui.stage.active_stages["code"]; diff --git a/lib/crates/fabro-retro/src/retro.rs b/lib/crates/fabro-retro/src/retro.rs index 6c07964b9..034085aa3 100644 --- a/lib/crates/fabro-retro/src/retro.rs +++ b/lib/crates/fabro-retro/src/retro.rs @@ -51,14 +51,16 @@ pub fn extract_stage_durations(run_dir: &Path) -> HashMap { let Ok(envelope) = serde_json::from_str::(line) else { continue; }; - if envelope.get("event").and_then(|v| v.as_str()) != Some("StageCompleted") { + if envelope.get("event").and_then(|v| v.as_str()) != Some("stage.completed") { continue; } let Some(name) = envelope.get("node_id").and_then(|v| v.as_str()) else { continue; }; let Some(duration_ms) = envelope - .get("duration_ms") + .get("properties") + .and_then(serde_json::Value::as_object) + .and_then(|properties| properties.get("duration_ms")) .and_then(serde_json::Value::as_u64) else { continue; diff --git a/lib/crates/fabro-retro/src/retro_agent.rs b/lib/crates/fabro-retro/src/retro_agent.rs index 84cbefb2b..10273c0f6 100644 --- a/lib/crates/fabro-retro/src/retro_agent.rs +++ b/lib/crates/fabro-retro/src/retro_agent.rs @@ -649,6 +649,7 @@ mod tests { event: AgentEvent::SessionStarted, timestamp: SystemTime::now(), session_id: "retro-test".into(), + parent_session_id: None, }) .unwrap(); diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 29f2d3e4e..4b9dbcefa 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -45,7 +45,7 @@ use crate::sessions::{SessionStore, new_session_store}; use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer}; use fabro_retro::RetroExt; use fabro_workflow::context::Context; -use fabro_workflow::event::{EventEmitter, WorkflowRunEvent}; +use fabro_workflow::event::{EventEmitter, RunEventEnvelope}; use fabro_workflow::operations::{self, CreateRunInput, WorkflowInput}; use fabro_workflow::pipeline::Persisted; use fabro_workflow::records::{Checkpoint, CheckpointExt}; @@ -92,7 +92,7 @@ struct ManagedRun { created_at: chrono::DateTime, // Populated when running: interviewer: Option>, - event_tx: Option>, + event_tx: Option>, context: Option, checkpoint: Option, cancel_tx: Option>, @@ -627,7 +627,7 @@ async fn execute_run(state: Arc, run_id: RunId) { // Create interviewer and event plumbing (this is the "provisioning" phase) let interviewer = Arc::new(WebInterviewer::new()); let context = Context::new(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::new(run_id); if let Some(tx_clone) = event_tx { emitter.on_event(move |event| { let _ = tx_clone.send(event.clone()); diff --git a/lib/crates/fabro-store/src/types.rs b/lib/crates/fabro-store/src/types.rs index 9aa89df99..14dbe9097 100644 --- a/lib/crates/fabro-store/src/types.rs +++ b/lib/crates/fabro-store/src/types.rs @@ -81,7 +81,7 @@ impl EventPayload { StoreError::InvalidEvent("event payload must be a JSON object".into()) })?; - for field in ["ts", "run_id", "event"] { + for field in ["id", "ts", "run_id", "event"] { match obj.get(field) { Some(serde_json::Value::String(_)) => {} _ => { diff --git a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs b/lib/crates/fabro-workflow/src/devcontainer_bridge.rs index 334a276a6..2c1cfeab9 100644 --- a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs +++ b/lib/crates/fabro-workflow/src/devcontainer_bridge.rs @@ -348,7 +348,7 @@ mod tests { #[tokio::test] async fn shell_command_executed() { let sandbox = TestSandbox::new(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())]; run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000) .await @@ -361,7 +361,7 @@ mod tests { #[tokio::test] async fn args_command_joins() { let sandbox = TestSandbox::new(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let commands = vec![fabro_devcontainer::Command::Args(vec![ "echo".to_string(), "hi".to_string(), @@ -380,8 +380,8 @@ mod tests { #[tokio::test] async fn emits_started_and_completed_events() { - let emitter = EventEmitter::new(); - let events = Arc::new(Mutex::new(Vec::new())); + let emitter = EventEmitter::default(); + let events = Arc::new(Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); emitter.on_event(move |event| { events_clone.lock().unwrap().push(event.clone()); @@ -392,28 +392,27 @@ mod tests { .await .unwrap(); let events = events.lock().unwrap(); - assert!(matches!( - &events[0], - WorkflowRunEvent::DevcontainerLifecycleStarted { phase, command_count } if phase == "on_create" && *command_count == 1 - )); - assert!(matches!( - &events[1], - WorkflowRunEvent::DevcontainerLifecycleCommandStarted { phase, index, .. } if phase == "on_create" && *index == 0 - )); - assert!(matches!( - &events[2], - WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { phase, index, exit_code, .. } if phase == "on_create" && *index == 0 && *exit_code == 0 - )); - assert!(matches!( - &events[3], - WorkflowRunEvent::DevcontainerLifecycleCompleted { phase, .. } if phase == "on_create" - )); + assert_eq!(events[0].event, "devcontainer.lifecycle.started"); + assert_eq!(events[0].properties["phase"], "on_create"); + assert_eq!(events[0].properties["command_count"], 1); + + assert_eq!(events[1].event, "devcontainer.lifecycle.command.started"); + assert_eq!(events[1].properties["phase"], "on_create"); + assert_eq!(events[1].properties["index"], 0); + + assert_eq!(events[2].event, "devcontainer.lifecycle.command.completed"); + assert_eq!(events[2].properties["phase"], "on_create"); + assert_eq!(events[2].properties["index"], 0); + assert_eq!(events[2].properties["exit_code"], 0); + + assert_eq!(events[3].event, "devcontainer.lifecycle.completed"); + assert_eq!(events[3].properties["phase"], "on_create"); } #[tokio::test] async fn failed_command_emits_failed_and_returns_error() { - let emitter = EventEmitter::new(); - let events = Arc::new(Mutex::new(Vec::new())); + let emitter = EventEmitter::default(); + let events = Arc::new(Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); emitter.on_event(move |event| { events_clone.lock().unwrap().push(event.clone()); @@ -424,15 +423,16 @@ mod tests { run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000).await; assert!(result.is_err()); let events = events.lock().unwrap(); - assert!(events.iter().any(|e| matches!( - e, - WorkflowRunEvent::DevcontainerLifecycleFailed { phase, exit_code, .. } if phase == "on_create" && *exit_code == 1 - ))); + assert!(events.iter().any(|event| { + event.event == "devcontainer.lifecycle.failed" + && event.properties["phase"] == "on_create" + && event.properties["exit_code"] == 1 + })); } #[tokio::test] async fn empty_commands_is_noop() { - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = Arc::new(Mutex::new(Vec::new())); let events_clone = Arc::clone(&events); emitter.on_event(move |event| { @@ -448,7 +448,7 @@ mod tests { #[tokio::test] async fn parallel_commands_run() { let sandbox = TestSandbox::new(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let mut map = HashMap::new(); map.insert("install".to_string(), "npm install".to_string()); map.insert("build".to_string(), "npm run build".to_string()); diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index d6f19bed7..a1add2891 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -8,7 +8,9 @@ use chrono::{SecondsFormat, Utc}; use fabro_store::{EventPayload, RunStore}; use fabro_types::RunId; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use tokio::sync::{mpsc, oneshot}; +use uuid::Uuid; use crate::error::FabroError; use crate::outcome::{FailureDetail, StageUsage}; @@ -24,6 +26,23 @@ pub enum RunNoticeLevel { Error, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunEventEnvelope { + pub id: String, + pub ts: String, + pub run_id: String, + pub event: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_label: Option, + pub properties: serde_json::Value, +} + /// Events emitted during workflow run execution for observability. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum WorkflowRunEvent { @@ -206,6 +225,10 @@ pub enum WorkflowRunEvent { Agent { stage: String, event: AgentEvent, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parent_session_id: Option, }, SubgraphStarted { node_id: String, @@ -769,81 +792,376 @@ impl WorkflowRunEvent { } } -/// Flatten a `WorkflowRunEvent` into its event name and a map of top-level fields. -/// -/// Simple variants like `StageStarted` return `("StageStarted", {fields})`. -/// Wrapper variants use dot notation: -/// - `Agent { stage, event: ToolCallStarted { .. } }` → `"Agent.ToolCallStarted"` -/// - `Sandbox { event: Initializing { .. } }` → `"Sandbox.Initializing"` -/// - `Agent { stage, event: SubAgentEvent { event: inner, .. } }` → `"Agent.SubAgentEvent.{Inner}"` -/// with one level of flattening; deeper nesting stays as JSON. -pub fn flatten_event( - event: &WorkflowRunEvent, -) -> (String, serde_json::Map) { - let value = serde_json::to_value(event).expect("WorkflowRunEvent must serialize"); - let (event_name, mut fields) = match value { - serde_json::Value::Object(map) => { - // Externally-tagged enum: { "VariantName": { fields } } - let (variant_name, inner) = map.into_iter().next().expect("enum must have one key"); - match variant_name.as_str() { - "Agent" => flatten_agent(inner), - "Sandbox" => flatten_sandbox(inner), - _ => { - let fields = match inner { - serde_json::Value::Object(m) => m, - _ => serde_json::Map::new(), - }; - (variant_name, fields) +pub fn event_name(event: &WorkflowRunEvent) -> &'static str { + match event { + WorkflowRunEvent::WorkflowRunStarted { .. } => "run.started", + WorkflowRunEvent::WorkflowRunCompleted { .. } => "run.completed", + WorkflowRunEvent::WorkflowRunFailed { .. } => "run.failed", + WorkflowRunEvent::RunNotice { .. } => "run.notice", + WorkflowRunEvent::StageStarted { .. } => "stage.started", + WorkflowRunEvent::StageCompleted { .. } => "stage.completed", + WorkflowRunEvent::StageFailed { .. } => "stage.failed", + WorkflowRunEvent::StageRetrying { .. } => "stage.retrying", + WorkflowRunEvent::ParallelStarted { .. } => "parallel.started", + WorkflowRunEvent::ParallelBranchStarted { .. } => "parallel.branch.started", + WorkflowRunEvent::ParallelBranchCompleted { .. } => "parallel.branch.completed", + WorkflowRunEvent::ParallelCompleted { .. } => "parallel.completed", + WorkflowRunEvent::InterviewStarted { .. } => "interview.started", + WorkflowRunEvent::InterviewCompleted { .. } => "interview.completed", + WorkflowRunEvent::InterviewTimeout { .. } => "interview.timeout", + WorkflowRunEvent::CheckpointCompleted { .. } => "checkpoint.completed", + WorkflowRunEvent::CheckpointFailed { .. } => "checkpoint.failed", + WorkflowRunEvent::GitCommit { .. } => "git.commit", + WorkflowRunEvent::GitPush { .. } => "git.push", + WorkflowRunEvent::GitBranch { .. } => "git.branch", + WorkflowRunEvent::GitWorktreeAdd { .. } => "git.worktree.added", + WorkflowRunEvent::GitWorktreeRemove { .. } => "git.worktree.removed", + WorkflowRunEvent::GitFetch { .. } => "git.fetch", + WorkflowRunEvent::GitReset { .. } => "git.reset", + WorkflowRunEvent::EdgeSelected { .. } => "edge.selected", + WorkflowRunEvent::LoopRestart { .. } => "loop.restart", + WorkflowRunEvent::Prompt { .. } => "stage.prompt", + WorkflowRunEvent::Agent { event, .. } => match event { + AgentEvent::SessionStarted => "agent.session.started", + AgentEvent::SessionEnded => "agent.session.ended", + AgentEvent::ProcessingEnd => "agent.processing.end", + AgentEvent::UserInput { .. } => "agent.input", + AgentEvent::AssistantTextStart => "agent.output.start", + AgentEvent::AssistantOutputReplace { .. } => "agent.output.replace", + AgentEvent::AssistantMessage { .. } => "agent.message", + AgentEvent::TextDelta { .. } => "agent.text.delta", + AgentEvent::ReasoningDelta { .. } => "agent.reasoning.delta", + AgentEvent::ToolCallStarted { .. } => "agent.tool.started", + AgentEvent::ToolCallOutputDelta { .. } => "agent.tool.output.delta", + AgentEvent::ToolCallCompleted { .. } => "agent.tool.completed", + AgentEvent::Error { .. } => "agent.error", + AgentEvent::Warning { .. } => "agent.warning", + AgentEvent::LoopDetected => "agent.loop.detected", + AgentEvent::TurnLimitReached { .. } => "agent.turn.limit", + AgentEvent::SkillExpanded { .. } => "agent.skill.expanded", + AgentEvent::SteeringInjected { .. } => "agent.steering.injected", + AgentEvent::CompactionStarted { .. } => "agent.compaction.started", + AgentEvent::CompactionCompleted { .. } => "agent.compaction.completed", + AgentEvent::LlmRetry { .. } => "agent.llm.retry", + AgentEvent::SubAgentSpawned { .. } => "agent.sub.spawned", + AgentEvent::SubAgentCompleted { .. } => "agent.sub.completed", + AgentEvent::SubAgentFailed { .. } => "agent.sub.failed", + AgentEvent::SubAgentClosed { .. } => "agent.sub.closed", + AgentEvent::McpServerReady { .. } => "agent.mcp.ready", + AgentEvent::McpServerFailed { .. } => "agent.mcp.failed", + }, + WorkflowRunEvent::SubgraphStarted { .. } => "subgraph.started", + WorkflowRunEvent::SubgraphCompleted { .. } => "subgraph.completed", + WorkflowRunEvent::Sandbox { event } => match event { + SandboxEvent::Initializing { .. } => "sandbox.initializing", + SandboxEvent::Ready { .. } => "sandbox.ready", + SandboxEvent::InitializeFailed { .. } => "sandbox.failed", + SandboxEvent::CleanupStarted { .. } => "sandbox.cleanup.started", + SandboxEvent::CleanupCompleted { .. } => "sandbox.cleanup.completed", + SandboxEvent::CleanupFailed { .. } => "sandbox.cleanup.failed", + SandboxEvent::SnapshotPulling { .. } => "sandbox.snapshot.pulling", + SandboxEvent::SnapshotPulled { .. } => "sandbox.snapshot.pulled", + SandboxEvent::SnapshotEnsuring { .. } => "sandbox.snapshot.ensuring", + SandboxEvent::SnapshotCreating { .. } => "sandbox.snapshot.creating", + SandboxEvent::SnapshotReady { .. } => "sandbox.snapshot.ready", + SandboxEvent::SnapshotFailed { .. } => "sandbox.snapshot.failed", + SandboxEvent::GitCloneStarted { .. } => "sandbox.git.started", + SandboxEvent::GitCloneCompleted { .. } => "sandbox.git.completed", + SandboxEvent::GitCloneFailed { .. } => "sandbox.git.failed", + }, + WorkflowRunEvent::SandboxInitialized { .. } => "sandbox.initialized", + WorkflowRunEvent::SetupStarted { .. } => "setup.started", + WorkflowRunEvent::SetupCommandStarted { .. } => "setup.command.started", + WorkflowRunEvent::SetupCommandCompleted { .. } => "setup.command.completed", + WorkflowRunEvent::SetupCompleted { .. } => "setup.completed", + WorkflowRunEvent::SetupFailed { .. } => "setup.failed", + WorkflowRunEvent::StallWatchdogTimeout { .. } => "watchdog.timeout", + WorkflowRunEvent::AssetCaptured { .. } => "asset.captured", + WorkflowRunEvent::SshAccessReady { .. } => "ssh.ready", + WorkflowRunEvent::Failover { .. } => "agent.failover", + WorkflowRunEvent::CliEnsureStarted { .. } => "cli.ensure.started", + WorkflowRunEvent::CliEnsureCompleted { .. } => "cli.ensure.completed", + WorkflowRunEvent::CliEnsureFailed { .. } => "cli.ensure.failed", + WorkflowRunEvent::PullRequestCreated { .. } => "pull_request.created", + WorkflowRunEvent::PullRequestFailed { .. } => "pull_request.failed", + WorkflowRunEvent::DevcontainerResolved { .. } => "devcontainer.resolved", + WorkflowRunEvent::DevcontainerLifecycleStarted { .. } => "devcontainer.lifecycle.started", + WorkflowRunEvent::DevcontainerLifecycleCommandStarted { .. } => { + "devcontainer.lifecycle.command.started" + } + WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { .. } => { + "devcontainer.lifecycle.command.completed" + } + WorkflowRunEvent::DevcontainerLifecycleCompleted { .. } => { + "devcontainer.lifecycle.completed" + } + WorkflowRunEvent::DevcontainerLifecycleFailed { .. } => "devcontainer.lifecycle.failed", + WorkflowRunEvent::RetroStarted => "retro.started", + WorkflowRunEvent::RetroCompleted { .. } => "retro.completed", + WorkflowRunEvent::RetroFailed { .. } => "retro.failed", + } +} + +#[derive(Debug)] +struct EnvelopeFields { + session_id: Option, + parent_session_id: Option, + node_id: Option, + node_label: Option, + properties: Value, +} + +fn tagged_variant_fields(value: &T) -> Map { + tagged_variant_fields_from_value(serde_json::to_value(value).expect("serializable event")) +} + +fn tagged_variant_fields_from_value(value: Value) -> Map { + match value { + Value::Object(map) => { + let (_, inner) = map.into_iter().next().expect("enum must have one variant"); + match inner { + Value::Object(fields) => fields, + Value::String(_) | Value::Null => Map::new(), + other => { + let mut fields = Map::new(); + fields.insert("value".to_string(), other); + fields } } } - // Unit variants serialize as strings - serde_json::Value::String(name) => (name, serde_json::Map::new()), - _ => ("Unknown".to_string(), serde_json::Map::new()), - }; - rename_fields(&event_name, &mut fields); - (event_name, fields) -} - -pub fn build_event_envelope(event: &WorkflowRunEvent, run_id: &RunId) -> serde_json::Value { - let (event_name, event_fields) = flatten_event(event); - let mut envelope = serde_json::Map::new(); - envelope.insert( - "ts".to_string(), - serde_json::Value::String(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)), - ); - envelope.insert( - "run_id".to_string(), - serde_json::Value::String(run_id.to_string()), - ); - envelope.insert("event".to_string(), serde_json::Value::String(event_name)); - for (k, v) in event_fields { - if k != "ts" && k != "run_id" && k != "event" { - envelope.insert(k, v); + Value::String(_) | Value::Null => Map::new(), + other => { + let mut fields = Map::new(); + fields.insert("value".to_string(), other); + fields } } - serde_json::Value::Object(envelope) +} + +fn remove_string(fields: &mut Map, key: &str) -> Option { + match fields.remove(key) { + Some(Value::String(value)) => Some(value), + _ => None, + } +} + +fn flatten_failure_detail(fields: &mut Map) { + let Some(Value::Object(failure)) = fields.remove("failure") else { + return; + }; + if let Some(message) = failure.get("message").cloned() { + fields.insert("error".to_string(), message); + } + if let Some(failure_class) = failure.get("failure_class").cloned() { + fields.insert("failure_class".to_string(), failure_class); + } + if let Some(failure_signature) = failure.get("failure_signature").cloned() { + if !failure_signature.is_null() { + fields.insert("failure_signature".to_string(), failure_signature); + } + } +} + +fn default_node_label(node_id: &Option, node_label: Option) -> Option { + node_label.or_else(|| node_id.clone()) +} + +fn extract_envelope_fields(event: &WorkflowRunEvent) -> EnvelopeFields { + match event { + WorkflowRunEvent::WorkflowRunStarted { .. } => { + let mut fields = tagged_variant_fields(event); + fields.remove("run_id"); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id: None, + node_label: None, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::WorkflowRunFailed { error, .. } => { + let mut fields = tagged_variant_fields(event); + fields.insert("error".to_string(), Value::String(error.to_string())); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id: None, + node_label: None, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::StageCompleted { .. } | WorkflowRunEvent::StageFailed { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node_id"); + let node_label = default_node_label(&node_id, remove_string(&mut fields, "name")); + flatten_failure_detail(&mut fields); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::StageStarted { .. } | WorkflowRunEvent::StageRetrying { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node_id"); + let node_label = default_node_label(&node_id, remove_string(&mut fields, "name")); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::Agent { + session_id, + parent_session_id, + .. + } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "stage"); + let node_label = default_node_label(&node_id, None); + fields.remove("session_id"); + fields.remove("parent_session_id"); + let properties = fields.remove("event").map_or_else( + || Value::Object(Map::new()), + |value| Value::Object(tagged_variant_fields_from_value(value)), + ); + EnvelopeFields { + session_id: session_id.clone(), + parent_session_id: parent_session_id.clone(), + node_id, + node_label, + properties, + } + } + WorkflowRunEvent::Sandbox { .. } => { + let mut fields = tagged_variant_fields(event); + let properties = fields.remove("event").map_or_else( + || Value::Object(Map::new()), + |value| Value::Object(tagged_variant_fields_from_value(value)), + ); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id: None, + node_label: None, + properties, + } + } + WorkflowRunEvent::GitCommit { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node_id"); + let node_label = default_node_label(&node_id, None); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::ParallelBranchStarted { .. } + | WorkflowRunEvent::ParallelBranchCompleted { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "branch"); + let node_label = default_node_label(&node_id, None); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::Prompt { .. } + | WorkflowRunEvent::InterviewStarted { .. } + | WorkflowRunEvent::InterviewTimeout { .. } + | WorkflowRunEvent::Failover { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "stage"); + let node_label = default_node_label(&node_id, None); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::CheckpointCompleted { .. } + | WorkflowRunEvent::CheckpointFailed { .. } + | WorkflowRunEvent::SubgraphStarted { .. } + | WorkflowRunEvent::SubgraphCompleted { .. } + | WorkflowRunEvent::AssetCaptured { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node_id"); + let node_label = default_node_label(&node_id, remove_string(&mut fields, "name")); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::StallWatchdogTimeout { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node"); + let node_label = default_node_label(&node_id, None); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + _ => EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id: None, + node_label: None, + properties: Value::Object(tagged_variant_fields(event)), + }, + } +} + +pub fn canonicalize_event(run_id: &RunId, event: &WorkflowRunEvent) -> RunEventEnvelope { + let fields = extract_envelope_fields(event); + RunEventEnvelope { + id: Uuid::now_v7().to_string(), + ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + run_id: run_id.to_string(), + event: event_name(event).to_string(), + session_id: fields.session_id, + parent_session_id: fields.parent_session_id, + node_id: fields.node_id, + node_label: fields.node_label, + properties: fields.properties, + } } pub fn build_redacted_event_payload( - event: &WorkflowRunEvent, + envelope: &RunEventEnvelope, run_id: &RunId, ) -> Result { - let envelope = build_event_envelope(event, run_id); - let line = serde_json::to_string(&envelope)?; - let line = redact_jsonl_line(&line); - let value = serde_json::from_str(&line).context("Failed to parse redacted event payload")?; - EventPayload::new(value, run_id).map_err(anyhow::Error::from) + let line = redacted_event_json(envelope)?; + event_payload_from_redacted_json(&line, run_id) } -pub fn append_progress_event( +pub fn append_progress_event(run_dir: &Path, envelope: &RunEventEnvelope) -> Result<()> { + let line = redacted_event_json(envelope)?; + append_progress_event_with_line(run_dir, envelope, &line) +} + +pub fn append_progress_event_with_line( run_dir: &Path, - run_id: &RunId, - event: &WorkflowRunEvent, + envelope: &RunEventEnvelope, + line: &str, ) -> Result<()> { - let envelope = build_event_envelope(event, run_id); - let line = serde_json::to_string(&envelope)?; - let line = redact_jsonl_line(&line); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) @@ -856,7 +1174,7 @@ pub fn append_progress_event( })?; writeln!(file, "{line}")?; - let pretty = serde_json::to_string_pretty(&envelope)?; + let pretty = serde_json::to_string_pretty(envelope)?; let pretty = redact_jsonl_line(&pretty); std::fs::write(run_dir.join("live.json"), pretty) .with_context(|| format!("Failed to write {}", run_dir.join("live.json").display()))?; @@ -864,32 +1182,32 @@ pub fn append_progress_event( Ok(()) } +pub fn redacted_event_json(envelope: &RunEventEnvelope) -> Result { + let line = serde_json::to_string(envelope)?; + Ok(redact_jsonl_line(&line)) +} + +pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result { + let value = serde_json::from_str(line).context("Failed to parse redacted event payload")?; + EventPayload::new(value, run_id).map_err(anyhow::Error::from) +} + pub struct ProgressLogger { run_dir: PathBuf, - run_id: RunId, } impl ProgressLogger { #[must_use] - pub fn new(run_dir: impl Into, run_id: RunId) -> Self { + pub fn new(run_dir: impl Into) -> Self { Self { run_dir: run_dir.into(), - run_id, } } pub fn register(self, emitter: &EventEmitter) { let run_dir = self.run_dir; - let run_id = Arc::new(std::sync::Mutex::new(self.run_id)); emitter.on_event(move |event| { - if let WorkflowRunEvent::WorkflowRunStarted { - run_id: started_run_id, - .. - } = event - { - (*run_id.lock().unwrap()).clone_from(started_run_id); - } - let _ = append_progress_event(&run_dir, &run_id.lock().unwrap(), event); + let _ = append_progress_event(&run_dir, event); }); } } @@ -902,12 +1220,11 @@ enum StoreProgressCommand { #[derive(Clone)] pub struct StoreProgressLogger { tx: mpsc::UnboundedSender, - run_id: Arc>, } impl StoreProgressLogger { #[must_use] - pub fn new(run_store: Arc, run_id: RunId) -> Self { + pub fn new(run_store: Arc) -> Self { let (tx, mut rx) = mpsc::unbounded_channel(); tokio::spawn(async move { @@ -925,25 +1242,16 @@ impl StoreProgressLogger { } }); - Self { - tx, - run_id: Arc::new(std::sync::Mutex::new(run_id)), - } + Self { tx } } pub fn register(&self, emitter: &EventEmitter) { let tx = self.tx.clone(); - let run_id = Arc::clone(&self.run_id); emitter.on_event(move |event| { - if let WorkflowRunEvent::WorkflowRunStarted { - run_id: started_run_id, - .. - } = event - { - (*run_id.lock().unwrap()).clone_from(started_run_id); - } - - let run_id = *run_id.lock().unwrap(); + let Ok(run_id) = event.run_id.parse::() else { + tracing::warn!(run_id = %event.run_id, "Invalid run id on event envelope"); + return; + }; match build_redacted_event_payload(event, &run_id) { Ok(payload) => { if tx.send(StoreProgressCommand::Event(payload)).is_err() { @@ -971,232 +1279,6 @@ impl StoreProgressLogger { } } -fn flatten_agent(inner: serde_json::Value) -> (String, serde_json::Map) { - let serde_json::Value::Object(mut agent_fields) = inner else { - return ("Agent".to_string(), serde_json::Map::new()); - }; - let stage = agent_fields.remove("stage"); - let agent_event = agent_fields - .remove("event") - .unwrap_or(serde_json::Value::Null); - - match agent_event { - serde_json::Value::Object(event_map) => { - let (inner_name, inner_value) = event_map - .into_iter() - .next() - .expect("agent event must have one key"); - if inner_name == "SubAgentEvent" { - flatten_sub_agent_event(stage, inner_value) - } else { - let mut fields = match inner_value { - serde_json::Value::Object(m) => m, - _ => serde_json::Map::new(), - }; - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - (format!("Agent.{inner_name}"), fields) - } - } - // Unit variant inside Agent (e.g. SessionStarted) - serde_json::Value::String(name) => { - let mut fields = serde_json::Map::new(); - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - (format!("Agent.{name}"), fields) - } - _ => { - let mut fields = serde_json::Map::new(); - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - ("Agent".to_string(), fields) - } - } -} - -fn flatten_sandbox( - inner: serde_json::Value, -) -> (String, serde_json::Map) { - let serde_json::Value::Object(mut sandbox_fields) = inner else { - return ("Sandbox".to_string(), serde_json::Map::new()); - }; - let sandbox_event = sandbox_fields - .remove("event") - .unwrap_or(serde_json::Value::Null); - - match sandbox_event { - serde_json::Value::Object(event_map) => { - let (inner_name, inner_value) = event_map - .into_iter() - .next() - .expect("sandbox event must have one key"); - let fields = match inner_value { - serde_json::Value::Object(m) => m, - _ => serde_json::Map::new(), - }; - (format!("Sandbox.{inner_name}"), fields) - } - serde_json::Value::String(name) => (format!("Sandbox.{name}"), serde_json::Map::new()), - _ => ("Sandbox".to_string(), serde_json::Map::new()), - } -} - -fn flatten_sub_agent_event( - stage: Option, - inner_value: serde_json::Value, -) -> (String, serde_json::Map) { - let serde_json::Value::Object(mut sub_fields) = inner_value else { - let mut fields = serde_json::Map::new(); - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - return ("Agent.SubAgentEvent".to_string(), fields); - }; - - // Extract the inner event name for dot notation, but keep full inner - // event as `nested_event` JSON to avoid field collisions when sub-agents - // are themselves nested (SubAgentEvent wrapping SubAgentEvent). - let nested_event = sub_fields - .remove("event") - .unwrap_or(serde_json::Value::Null); - let inner_name = match &nested_event { - serde_json::Value::Object(map) => map.keys().next().cloned(), - serde_json::Value::String(name) => Some(name.clone()), - _ => None, - }; - - let event_name = match &inner_name { - Some(name) => format!("Agent.SubAgentEvent.{name}"), - None => "Agent.SubAgentEvent".to_string(), - }; - - // Start with the SubAgentEvent's own fields (agent_id, depth) - let mut fields = sub_fields; - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - fields.insert("nested_event".to_string(), nested_event); - - (event_name, fields) -} - -/// Rename flattened event fields for clarity in progress.jsonl output. -/// -/// Applied as a post-processing step after `flatten_event` serialization to -/// give fields self-describing names without changing the Rust enum. -fn rename_fields(event_name: &str, fields: &mut serde_json::Map) { - /// Move a key from `old` to `new` if present. - fn rename(fields: &mut serde_json::Map, old: &str, new: &str) { - if let Some(v) = fields.remove(old) { - fields.insert(new.to_string(), v); - } - } - - /// Insert `node_label` defaulting to the value of `node_id`, if not already present. - fn default_node_label(fields: &mut serde_json::Map) { - if !fields.contains_key("node_label") { - if let Some(id) = fields.get("node_id").cloned() { - fields.insert("node_label".to_string(), id); - } - } - } - - if event_name.starts_with("Stage") { - // name → node_label, index → stage_index, node_id stays - rename(fields, "name", "node_label"); - rename(fields, "index", "stage_index"); - // Flatten FailureDetail into top-level fields for backward compat - if let Some(serde_json::Value::Object(failure)) = fields.remove("failure") { - if let Some(msg) = failure.get("message") { - fields.insert("error".to_string(), msg.clone()); - fields.insert("failure_reason".to_string(), msg.clone()); - } - if let Some(fc) = failure.get("failure_class") { - fields.insert("failure_class".to_string(), fc.clone()); - } - if let Some(sig) = failure.get("failure_signature") { - if !sig.is_null() { - fields.insert("failure_signature".to_string(), sig.clone()); - } - } - } - // node_id already present from Rust enum - } else if event_name == "WorkflowRunFailed" { - // Flatten FabroError to a string for backward compat in progress.jsonl - if let Some(error_val) = fields.get("error") { - if error_val.is_object() { - // Extract the display message from the FabroError serde format - let display = error_val - .get("data") - .and_then(|d| { - // For struct variants (Handler/Engine): { "data": { "message": "..." } } - d.get("message").and_then(|m| m.as_str().map(String::from)) - }) - .or_else(|| { - // For newtype string variants: { "data": "..." } - error_val - .get("data") - .and_then(|d| d.as_str().map(String::from)) - }) - .unwrap_or_else(|| error_val.to_string()); - fields.insert("error".to_string(), serde_json::Value::String(display)); - } - } - } else if event_name == "WorkflowRunStarted" { - rename(fields, "name", "workflow_name"); - } else if event_name.starts_with("Agent.") || event_name == "Agent" { - rename(fields, "stage", "node_id"); - default_node_label(fields); - } else if event_name.starts_with("Sandbox.Snapshot") { - // Must check before generic Sandbox.* to catch Snapshot* first - rename(fields, "name", "snapshot_name"); - rename(fields, "provider", "sandbox_provider"); - } else if event_name.starts_with("Sandbox.") { - rename(fields, "provider", "sandbox_provider"); - } else if event_name.starts_with("ParallelBranch") { - rename(fields, "branch", "node_id"); - default_node_label(fields); - rename(fields, "index", "branch_index"); - } else if event_name.starts_with("SetupCommand") || event_name == "SetupFailed" { - rename(fields, "index", "command_index"); - } else if event_name == "EdgeSelected" || event_name == "LoopRestart" { - rename(fields, "from_node", "from_node_id"); - rename(fields, "to_node", "to_node_id"); - } else if event_name == "StallWatchdogTimeout" { - rename(fields, "node", "node_id"); - default_node_label(fields); - } else if event_name == "Prompt" { - rename(fields, "stage", "node_id"); - default_node_label(fields); - rename(fields, "text", "prompt_text"); - } else if event_name == "AssetCaptured" { - default_node_label(fields); - } else if event_name.starts_with("Interview") && event_name != "InterviewCompleted" { - // InterviewStarted, InterviewTimeout have `stage` - rename(fields, "stage", "node_id"); - default_node_label(fields); - } else if event_name == "SubgraphStarted" { - default_node_label(fields); - rename(fields, "start_node", "start_node_id"); - } else if event_name == "SubgraphCompleted" - || event_name == "CheckpointCompleted" - || event_name == "CheckpointFailed" - { - default_node_label(fields); - } else if event_name == "GitCommit" { - if fields.contains_key("node_id") { - default_node_label(fields); - } - } else if event_name.starts_with("DevcontainerLifecycleCommand") - || event_name == "DevcontainerLifecycleFailed" - { - rename(fields, "index", "command_index"); - } -} - /// Current time as epoch milliseconds. fn epoch_millis() -> i64 { let millis = std::time::SystemTime::now() @@ -1207,10 +1289,11 @@ fn epoch_millis() -> i64 { } /// Listener callback type for workflow run events. -type EventListener = Arc; +type EventListener = Arc; /// Callback-based event emitter for workflow run events. pub struct EventEmitter { + run_id: RunId, listeners: std::sync::Mutex>, /// Epoch milliseconds of the last `emit()` or `touch()` call. 0 until first event. last_event_at: AtomicI64, @@ -1220,6 +1303,7 @@ impl std::fmt::Debug for EventEmitter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let count = self.listeners.lock().map(|l| l.len()).unwrap_or(0); f.debug_struct("EventEmitter") + .field("run_id", &self.run_id) .field("listener_count", &count) .field("last_event_at", &self.last_event_at.load(Ordering::Relaxed)) .finish() @@ -1228,20 +1312,21 @@ impl std::fmt::Debug for EventEmitter { impl Default for EventEmitter { fn default() -> Self { - Self::new() + Self::new(RunId::new()) } } impl EventEmitter { #[must_use] - pub fn new() -> Self { + pub fn new(run_id: RunId) -> Self { Self { + run_id, listeners: std::sync::Mutex::new(Vec::new()), last_event_at: AtomicI64::new(0), } } - pub fn on_event(&self, listener: impl Fn(&WorkflowRunEvent) + Send + Sync + 'static) { + pub fn on_event(&self, listener: impl Fn(&RunEventEnvelope) + Send + Sync + 'static) { self.listeners .lock() .expect("listeners lock poisoned") @@ -1251,6 +1336,13 @@ impl EventEmitter { pub fn emit(&self, event: &WorkflowRunEvent) { self.last_event_at.store(epoch_millis(), Ordering::Relaxed); event.trace(); + if let WorkflowRunEvent::WorkflowRunStarted { run_id, .. } = event { + debug_assert_eq!( + *run_id, self.run_id, + "workflow run started event must match emitter run_id" + ); + } + let envelope = canonicalize_event(&self.run_id, event); // Clone the listener list so we don't hold the lock during dispatch. // This prevents deadlocks if a listener calls emit() reentrantly. // Note: listeners added during this emit() won't receive the current event. @@ -1260,7 +1352,7 @@ impl EventEmitter { .expect("listeners lock poisoned") .clone(); for listener in &snapshot { - listener(event); + listener(&envelope); } } @@ -1295,27 +1387,22 @@ impl EventEmitter { #[cfg(test)] mod tests { use super::*; - use fabro_llm::types::Usage; use fabro_types::fixtures; use std::sync::{Arc, Mutex}; #[test] fn event_emitter_new_has_no_listeners() { - let emitter = EventEmitter::new(); + let emitter = EventEmitter::new(fixtures::RUN_1); assert_eq!(emitter.listeners.lock().unwrap().len(), 0); } #[test] - fn event_emitter_calls_listener() { - let emitter = EventEmitter::new(); + fn event_emitter_calls_listener_with_envelope() { + let emitter = EventEmitter::new(fixtures::RUN_1); let received = Arc::new(Mutex::new(Vec::new())); let received_clone = Arc::clone(&received); emitter.on_event(move |event| { - let name = match event { - WorkflowRunEvent::WorkflowRunStarted { name, .. } => name.clone(), - _ => "other".to_string(), - }; - received_clone.lock().unwrap().push(name); + received_clone.lock().unwrap().push(event.clone()); }); emitter.emit(&WorkflowRunEvent::WorkflowRunStarted { name: "test".to_string(), @@ -1328,39 +1415,9 @@ mod tests { }); let events = received.lock().unwrap(); assert_eq!(events.len(), 1); - assert_eq!(events[0], "test"); - } - - #[test] - fn workflow_run_event_serialization() { - let event = WorkflowRunEvent::StageStarted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - handler_type: Some("agent".to_string()), - script: None, - attempt: 1, - max_attempts: 3, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("StageStarted")); - assert!(json.contains("plan")); - assert!(json.contains("\"handler_type\":\"agent\"")); - assert!(json.contains("\"attempt\":1")); - assert!(json.contains("\"max_attempts\":3")); - - // None handler_type serializes as null - let event_none = WorkflowRunEvent::StageStarted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - handler_type: None, - script: None, - attempt: 1, - max_attempts: 1, - }; - let json_none = serde_json::to_string(&event_none).unwrap(); - assert!(json_none.contains("\"handler_type\":null")); + assert_eq!(events[0].event, "run.started"); + assert_eq!(events[0].run_id, fixtures::RUN_1.to_string()); + assert!(events[0].id.len() >= 32); } #[test] @@ -1370,1454 +1427,173 @@ mod tests { } #[test] - fn agent_event_wrapper_serialization() { - let event = WorkflowRunEvent::Agent { - stage: "plan".to_string(), - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".to_string(), - tool_call_id: "call_1".to_string(), - arguments: serde_json::json!({"path": "/tmp/test.txt"}), - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("Agent")); - assert!(json.contains("ToolCallStarted")); - assert!(json.contains("read_file")); - assert!(json.contains("plan")); - - // Verify round-trip - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Agent { stage, .. } if stage == "plan")); - } - - #[test] - fn agent_assistant_message_serialization() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::AssistantMessage { - text: "Here is the implementation".to_string(), - model: "claude-opus-4-6".to_string(), - usage: Usage { - input_tokens: 1000, - output_tokens: 500, - total_tokens: 1500, - cache_read_tokens: Some(800), - cache_write_tokens: Some(50), - reasoning_tokens: Some(100), - speed: None, - raw: None, - }, - tool_call_count: 3, - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("AssistantMessage")); - assert!(json.contains("claude-opus-4-6")); - assert!(json.contains("\"cache_read_tokens\":800")); - assert!(json.contains("\"reasoning_tokens\":100")); - - // Round-trip - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - match deserialized { - WorkflowRunEvent::Agent { - event: AgentEvent::AssistantMessage { usage, .. }, - .. - } => { - assert_eq!(usage.cache_read_tokens, Some(800)); - assert_eq!(usage.reasoning_tokens, Some(100)); - } - _ => panic!("expected Agent(AssistantMessage)"), - } - } - - #[test] - fn agent_assistant_message_without_cache_tokens_omits_them() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::AssistantMessage { - text: "response".to_string(), - model: "test-model".to_string(), - usage: Usage { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, - ..Default::default() - }, - tool_call_count: 0, - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(!json.contains("cache_read_tokens")); - assert!(!json.contains("reasoning_tokens")); - } - - #[test] - fn agent_assistant_output_replace_serialization() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("AssistantOutputReplace")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - match deserialized { - WorkflowRunEvent::Agent { - stage, - event: AgentEvent::AssistantOutputReplace { text, reasoning }, - } => { - assert_eq!(stage, "code"); - assert!(text.is_empty()); - assert_eq!(reasoning, None); - } - _ => panic!("expected Agent(AssistantOutputReplace)"), - } - } - - #[test] - fn stage_completed_event_serialization_with_new_fields() { - use crate::outcome::{FailureCategory, FailureDetail}; - - let event = WorkflowRunEvent::StageCompleted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - duration_ms: 1500, - status: "partial_success".to_string(), - preferred_label: None, - suggested_next_ids: vec![], - usage: None, - failure: Some(FailureDetail::new( - "lint errors remain", - FailureCategory::Deterministic, - )), - notes: Some("fixed 3 of 5 issues".to_string()), - files_touched: vec!["src/main.rs".to_string()], - attempt: 2, - max_attempts: 3, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("lint errors remain")); - assert!(json.contains("\"notes\":\"fixed 3 of 5 issues\"")); - assert!(json.contains("src/main.rs")); - assert!(json.contains("\"attempt\":2")); - assert!(json.contains("\"max_attempts\":3")); - - let event_none = WorkflowRunEvent::StageCompleted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - duration_ms: 1500, - status: "success".to_string(), - preferred_label: None, - suggested_next_ids: vec![], - usage: None, - failure: None, - notes: None, - files_touched: vec![], - attempt: 1, - max_attempts: 1, - }; - let json_none = serde_json::to_string(&event_none).unwrap(); - assert!(json_none.contains("\"notes\":null")); - } - - #[test] - fn stage_failed_event_serialization() { - use crate::outcome::{FailureCategory, FailureDetail}; - - let event = WorkflowRunEvent::StageFailed { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - failure: FailureDetail { - message: "LLM request timed out".to_string(), - category: FailureCategory::TransientInfra, - signature: None, - }, - will_retry: true, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("LLM request timed out")); - assert!(json.contains("transient_infra")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::StageFailed { failure, .. } if failure.category == FailureCategory::TransientInfra - )); - - let event_terminal = WorkflowRunEvent::StageFailed { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - failure: FailureDetail::new("timeout", FailureCategory::Deterministic), - will_retry: false, - }; - let json_terminal = serde_json::to_string(&event_terminal).unwrap(); - assert!(json_terminal.contains("deterministic")); - } - - #[test] - fn parallel_branch_completed_event_serialization() { - let event = WorkflowRunEvent::ParallelBranchCompleted { - branch: "branch_a".to_string(), - index: 0, - duration_ms: 1500, - status: "success".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"status\":\"success\"")); - assert!(!json.contains("\"success\":")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::ParallelBranchCompleted { status, .. } if status == "success") - ); - } - - #[test] - fn parallel_started_event_serialization() { - let event = WorkflowRunEvent::ParallelStarted { - branch_count: 3, - join_policy: "wait_all".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"join_policy\":\"wait_all\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::ParallelStarted { join_policy, .. } if join_policy == "wait_all") - ); - } - - #[test] - fn interview_started_event_serialization() { - let event = WorkflowRunEvent::InterviewStarted { - question: "Review changes?".to_string(), - stage: "gate".to_string(), - question_type: "multiple_choice".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"question_type\":\"multiple_choice\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::InterviewStarted { question_type, .. } if question_type == "multiple_choice") - ); - } - - #[test] - fn agent_compaction_event_serialization() { - let started = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::CompactionStarted { - estimated_tokens: 5000, - context_window_size: 8000, - }, - }; - let json = serde_json::to_string(&started).unwrap(); - assert!(json.contains("CompactionStarted")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Agent { stage, .. } if stage == "code")); - - let completed = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::CompactionCompleted { - original_turn_count: 20, - preserved_turn_count: 6, - summary_token_estimate: 500, - tracked_file_count: 3, - }, - }; - let json = serde_json::to_string(&completed).unwrap(); - assert!(json.contains("CompactionCompleted")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Agent { stage, .. } if stage == "code")); - } - - #[test] - fn edge_selected_event_serialization() { - let event = WorkflowRunEvent::EdgeSelected { - from_node: "plan".to_string(), - to_node: "code".to_string(), - label: Some("success".to_string()), - condition: Some("outcome == 'success'".to_string()), - reason: "condition".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "success".to_string(), - is_jump: false, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("EdgeSelected")); - assert!(json.contains("\"from_node\":\"plan\"")); - assert!(json.contains("\"to_node\":\"code\"")); - assert!(json.contains("\"label\":\"success\"")); - assert!(json.contains("\"condition\":\"outcome == 'success'\"")); - assert!(json.contains("\"reason\":\"condition\"")); - assert!(json.contains("\"stage_status\":\"success\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::EdgeSelected { from_node, to_node, .. } if from_node == "plan" && to_node == "code") - ); - - // None label/condition - let event_none = WorkflowRunEvent::EdgeSelected { - from_node: "a".to_string(), - to_node: "b".to_string(), - label: None, - condition: None, - reason: "unconditional".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "success".to_string(), - is_jump: false, - }; - let json_none = serde_json::to_string(&event_none).unwrap(); - assert!(json_none.contains("\"label\":null")); - assert!(json_none.contains("\"condition\":null")); - } - - #[test] - fn loop_restart_event_serialization() { - let event = WorkflowRunEvent::LoopRestart { - from_node: "review".to_string(), - to_node: "code".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("LoopRestart")); - assert!(json.contains("\"from_node\":\"review\"")); - assert!(json.contains("\"to_node\":\"code\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::LoopRestart { from_node, to_node } if from_node == "review" && to_node == "code") - ); - } - - #[test] - fn stage_retrying_event_serialization() { - let event = WorkflowRunEvent::StageRetrying { - node_id: "lint".to_string(), - name: "lint".to_string(), - index: 2, - attempt: 3, - max_attempts: 5, - delay_ms: 400, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("StageRetrying")); - assert!(json.contains("\"attempt\":3")); - assert!(json.contains("\"max_attempts\":5")); - assert!(json.contains("\"delay_ms\":400")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::StageRetrying { - max_attempts: 5, - .. - } - )); - } - - #[test] - fn agent_llm_retry_event_serialization() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::LlmRetry { - provider: "anthropic".to_string(), - model: "claude-opus-4-6".to_string(), - attempt: 2, - delay_secs: 1.5, - error: fabro_llm::error::SdkError::Network { - message: "rate limited".to_string(), - source: None, - }, - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("LlmRetry")); - assert!(json.contains("\"provider\":\"anthropic\"")); - assert!(json.contains("\"delay_secs\":1.5")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Agent { stage, .. } if stage == "code")); - } - - #[test] - fn subgraph_started_event_serialization() { - let event = WorkflowRunEvent::SubgraphStarted { - node_id: "sub_1".to_string(), - start_node: "start".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("SubgraphStarted")); - assert!(json.contains("\"node_id\":\"sub_1\"")); - assert!(json.contains("\"start_node\":\"start\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::SubgraphStarted { node_id, .. } if node_id == "sub_1") - ); - } - - #[test] - fn subgraph_completed_event_serialization() { - let event = WorkflowRunEvent::SubgraphCompleted { - node_id: "sub_1".to_string(), - steps_executed: 5, - status: "success".to_string(), - duration_ms: 3200, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("SubgraphCompleted")); - assert!(json.contains("\"steps_executed\":5")); - assert!(json.contains("\"duration_ms\":3200")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::SubgraphCompleted { - steps_executed: 5, - .. - } - )); - } - - #[test] - fn sandbox_event_wrapper_serialization() { - use fabro_agent::SandboxEvent; - - let event = WorkflowRunEvent::Sandbox { - event: SandboxEvent::Initializing { - provider: "docker".into(), - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("Sandbox")); - assert!(json.contains("Initializing")); - assert!(json.contains("docker")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Sandbox { .. })); - } - - #[test] - fn emitter_last_event_at_initially_zero() { - let emitter = EventEmitter::new(); - assert_eq!(emitter.last_event_at(), 0); - } - - #[test] - fn emitter_last_event_at_updates_after_emit() { - let emitter = EventEmitter::new(); - assert_eq!(emitter.last_event_at(), 0); - emitter.emit(&WorkflowRunEvent::WorkflowRunStarted { - name: "test".to_string(), - run_id: fixtures::RUN_1, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: None, - }); - assert!(emitter.last_event_at() > 0); - } - - #[test] - fn emitter_touch_updates_last_event_at() { - let emitter = EventEmitter::new(); - assert_eq!(emitter.last_event_at(), 0); - emitter.touch(); - assert!(emitter.last_event_at() > 0); - } - - #[test] - fn stall_watchdog_timeout_serialization() { - let event = WorkflowRunEvent::StallWatchdogTimeout { - node: "work".to_string(), - idle_seconds: 600, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("StallWatchdogTimeout")); - assert!(json.contains("\"node\":\"work\"")); - assert!(json.contains("\"idle_seconds\":600")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::StallWatchdogTimeout { node, idle_seconds } if node == "work" && idle_seconds == 600) - ); - } - - #[test] - fn serde_round_trip_asset_captured() { - let event = WorkflowRunEvent::AssetCaptured { - node_id: "work".to_string(), - attempt: 2, - node_slug: "work-visit_3".to_string(), - path: "coverage/lcov.info".to_string(), - mime: "application/octet-stream".to_string(), - content_md5: "abc123".to_string(), - content_sha256: "def456".to_string(), - bytes: 512, - }; - let json = serde_json::to_string(&event).unwrap(); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::AssetCaptured { node_slug, bytes, .. } - if node_slug == "work-visit_3" && bytes == 512 - )); - } - - #[test] - fn flatten_event_simple_variant() { - let event = WorkflowRunEvent::StageStarted { - node_id: "plan".to_string(), - name: "Plan Stage".to_string(), - index: 0, - handler_type: Some("agent".to_string()), - script: None, - attempt: 1, - max_attempts: 3, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "StageStarted"); - assert_eq!(fields["node_id"], "plan"); - assert_eq!(fields["node_label"], "Plan Stage"); - assert_eq!(fields["stage_index"], 0); - assert_eq!(fields["handler_type"], "agent"); - assert_eq!(fields["attempt"], 1); - assert_eq!(fields["max_attempts"], 3); - // Old keys should not be present - assert!(!fields.contains_key("name")); - assert!(!fields.contains_key("index")); - } - - #[test] - fn flatten_event_agent_tool_call_started() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".to_string(), - tool_call_id: "call_1".to_string(), - arguments: serde_json::json!({"path": "/tmp/test.txt"}), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.ToolCallStarted"); - assert_eq!(fields["node_id"], "code"); - assert_eq!(fields["node_label"], "code"); - assert_eq!(fields["tool_name"], "read_file"); - assert_eq!(fields["tool_call_id"], "call_1"); - assert!(!fields.contains_key("stage")); - } - - #[test] - fn flatten_event_sandbox_initializing() { - let event = WorkflowRunEvent::Sandbox { - event: SandboxEvent::Initializing { - provider: "docker".into(), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Sandbox.Initializing"); - assert_eq!(fields["sandbox_provider"], "docker"); - assert!(!fields.contains_key("provider")); - } - - #[test] - fn flatten_event_agent_sub_agent_event() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::SubAgentEvent { - agent_id: "sub_1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "write_file".to_string(), - tool_call_id: "call_2".to_string(), - arguments: serde_json::json!({}), - }), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.SubAgentEvent.ToolCallStarted"); - assert_eq!(fields["node_id"], "code"); - assert_eq!(fields["node_label"], "code"); - assert_eq!(fields["agent_id"], "sub_1"); - assert_eq!(fields["depth"], 1); - assert!(!fields.contains_key("stage")); - // Inner event preserved as nested_event JSON (not flattened) - let nested = fields["nested_event"].as_object().unwrap(); - let tool_call = nested["ToolCallStarted"].as_object().unwrap(); - assert_eq!(tool_call["tool_name"], "write_file"); - } - - #[test] - fn flatten_event_doubly_nested_sub_agent_preserves_all_data() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::SubAgentEvent { - agent_id: "sub_1".to_string(), - depth: 1, - event: Box::new(AgentEvent::SubAgentEvent { - agent_id: "sub_2".to_string(), - depth: 2, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "read_file".to_string(), - tool_call_id: "call_3".to_string(), - arguments: serde_json::json!({}), - }), - }), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.SubAgentEvent.SubAgentEvent"); - // Outer SubAgentEvent fields at top level - assert_eq!(fields["agent_id"], "sub_1"); - assert_eq!(fields["depth"], 1); - assert_eq!(fields["node_id"], "code"); - assert_eq!(fields["node_label"], "code"); - assert!(!fields.contains_key("stage")); - // Inner SubAgentEvent preserved in nested_event with all data intact - let nested = fields["nested_event"].as_object().unwrap(); - let inner_sub = nested["SubAgentEvent"].as_object().unwrap(); - assert_eq!(inner_sub["agent_id"], "sub_2"); - assert_eq!(inner_sub["depth"], 2); - let inner_event = inner_sub["event"].as_object().unwrap(); - let tool_call = inner_event["ToolCallStarted"].as_object().unwrap(); - assert_eq!(tool_call["tool_name"], "read_file"); - } - - #[test] - fn flatten_event_agent_session_started() { - let event = WorkflowRunEvent::Agent { - stage: "plan".to_string(), - event: AgentEvent::SessionStarted, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.SessionStarted"); - assert_eq!(fields["node_id"], "plan"); - assert_eq!(fields["node_label"], "plan"); - assert!(!fields.contains_key("stage")); - } - - #[test] - fn flatten_event_agent_assistant_output_replace() { - let event = WorkflowRunEvent::Agent { - stage: "plan".to_string(), - event: AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.AssistantOutputReplace"); - assert_eq!(fields["node_id"], "plan"); - assert_eq!(fields["node_label"], "plan"); - assert_eq!(fields["text"], ""); - assert!(!fields.contains_key("stage")); - } - - #[test] - fn rename_fields_workflow_run_started() { - let event = WorkflowRunEvent::WorkflowRunStarted { - name: "my_pipeline".to_string(), - run_id: fixtures::RUN_1, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: None, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "WorkflowRunStarted"); - assert_eq!(fields["workflow_name"], "my_pipeline"); - assert!(!fields.contains_key("name")); - } - - #[test] - fn rename_fields_parallel_branch_started() { - let event = WorkflowRunEvent::ParallelBranchStarted { - branch: "lint".to_string(), - index: 0, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "ParallelBranchStarted"); - assert_eq!(fields["node_id"], "lint"); - assert_eq!(fields["node_label"], "lint"); - assert_eq!(fields["branch_index"], 0); - assert!(!fields.contains_key("branch")); - assert!(!fields.contains_key("index")); - } - - #[test] - fn rename_fields_parallel_branch_completed() { - let event = WorkflowRunEvent::ParallelBranchCompleted { - branch: "lint".to_string(), - index: 0, - duration_ms: 1000, - status: "success".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "ParallelBranchCompleted"); - assert_eq!(fields["node_id"], "lint"); - assert_eq!(fields["node_label"], "lint"); - assert_eq!(fields["branch_index"], 0); - } - - #[test] - fn rename_fields_setup_command_started() { - let event = WorkflowRunEvent::SetupCommandStarted { - command: "npm install".to_string(), - index: 2, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "SetupCommandStarted"); - assert_eq!(fields["command_index"], 2); - assert!(!fields.contains_key("index")); - } - - #[test] - fn rename_fields_setup_failed() { - let event = WorkflowRunEvent::SetupFailed { - command: "npm test".to_string(), - index: 1, - exit_code: 1, - stderr: "fail".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "SetupFailed"); - assert_eq!(fields["command_index"], 1); - assert!(!fields.contains_key("index")); - } - - #[test] - fn rename_fields_edge_selected() { - let event = WorkflowRunEvent::EdgeSelected { - from_node: "plan".to_string(), - to_node: "code".to_string(), - label: Some("success".to_string()), - condition: None, - reason: "preferred_label".to_string(), - preferred_label: Some("success".to_string()), - suggested_next_ids: Vec::new(), - stage_status: "success".to_string(), - is_jump: false, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "EdgeSelected"); - assert_eq!(fields["from_node_id"], "plan"); - assert_eq!(fields["to_node_id"], "code"); - assert!(!fields.contains_key("from_node")); - assert!(!fields.contains_key("to_node")); - } - - #[test] - fn rename_fields_loop_restart() { - let event = WorkflowRunEvent::LoopRestart { - from_node: "review".to_string(), - to_node: "code".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "LoopRestart"); - assert_eq!(fields["from_node_id"], "review"); - assert_eq!(fields["to_node_id"], "code"); - } - - #[test] - fn rename_fields_stall_watchdog_timeout() { - let event = WorkflowRunEvent::StallWatchdogTimeout { - node: "work".to_string(), - idle_seconds: 600, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "StallWatchdogTimeout"); - assert_eq!(fields["node_id"], "work"); - assert_eq!(fields["node_label"], "work"); - assert!(!fields.contains_key("node")); - } - - #[test] - fn rename_fields_prompt() { - let event = WorkflowRunEvent::Prompt { - stage: "gate".to_string(), - text: "Approve?".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Prompt"); - assert_eq!(fields["node_id"], "gate"); - assert_eq!(fields["node_label"], "gate"); - assert_eq!(fields["prompt_text"], "Approve?"); - assert!(!fields.contains_key("stage")); - assert!(!fields.contains_key("text")); - } - - #[test] - fn rename_fields_asset_captured() { - let event = WorkflowRunEvent::AssetCaptured { - node_id: "test_stage".to_string(), - attempt: 1, - node_slug: "test_stage".to_string(), - path: "test-results/report.xml".to_string(), - mime: "text/xml".to_string(), - content_md5: "d41d8cd98f00b204e9800998ecf8427e".to_string(), - content_sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - .to_string(), - bytes: 1024, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "AssetCaptured"); - assert_eq!(fields["node_id"], "test_stage"); - assert_eq!(fields["node_label"], "test_stage"); - assert_eq!(fields["path"], "test-results/report.xml"); - assert_eq!(fields["bytes"], 1024); - } - - #[test] - fn rename_fields_interview_started() { - let event = WorkflowRunEvent::InterviewStarted { - question: "OK?".to_string(), - stage: "gate".to_string(), - question_type: "yes_no".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "InterviewStarted"); - assert_eq!(fields["node_id"], "gate"); - assert_eq!(fields["node_label"], "gate"); - assert!(!fields.contains_key("stage")); - } - - #[test] - fn rename_fields_subgraph_started() { - let event = WorkflowRunEvent::SubgraphStarted { - node_id: "sub_1".to_string(), - start_node: "start".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "SubgraphStarted"); - assert_eq!(fields["node_id"], "sub_1"); - assert_eq!(fields["node_label"], "sub_1"); - assert_eq!(fields["start_node_id"], "start"); - assert!(!fields.contains_key("start_node")); - } - - #[test] - fn rename_fields_checkpoint_completed() { - let event = WorkflowRunEvent::CheckpointCompleted { - node_id: "work".to_string(), - status: "success".to_string(), - git_commit_sha: Some("abc123".to_string()), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "CheckpointCompleted"); - assert_eq!(fields["node_id"], "work"); - assert_eq!(fields["node_label"], "work"); - - // Without git_commit_sha - let event_no_git = WorkflowRunEvent::CheckpointCompleted { - node_id: "plan".to_string(), - status: "success".to_string(), - git_commit_sha: None, - }; - let json = serde_json::to_string(&event_no_git).unwrap(); - assert!(!json.contains("git_commit_sha")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::CheckpointCompleted { - git_commit_sha: None, - .. - } - )); - } - - #[test] - fn rename_fields_checkpoint_failed() { - let event = WorkflowRunEvent::CheckpointFailed { - node_id: "fix_lints".to_string(), - error: "git add failed (exit 1): fatal: not a git repository".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "CheckpointFailed"); - assert_eq!(fields["node_id"], "fix_lints"); - assert_eq!(fields["node_label"], "fix_lints"); - assert_eq!( - fields["error"], - "git add failed (exit 1): fatal: not a git repository" - ); - } - - #[test] - fn rename_fields_git_commit_with_node_id() { - let event = WorkflowRunEvent::GitCommit { - node_id: Some("work".to_string()), - sha: "abc123".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "GitCommit"); - assert_eq!(fields["node_id"], "work"); - assert_eq!(fields["node_label"], "work"); - assert_eq!(fields["sha"], "abc123"); - } - - #[test] - fn rename_fields_git_commit_without_node_id() { - let event = WorkflowRunEvent::GitCommit { - node_id: None, - sha: "abc123".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "GitCommit"); - assert!(!fields.contains_key("node_label")); - assert_eq!(fields["sha"], "abc123"); - } - - #[test] - fn git_commit_serialization() { - let event = WorkflowRunEvent::GitCommit { - node_id: Some("work".to_string()), - sha: "abc123".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitCommit")); - assert!(json.contains("\"sha\":\"abc123\"")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::GitCommit { sha, .. } if sha == "abc123")); - - // node_id None is omitted - let event_none = WorkflowRunEvent::GitCommit { - node_id: None, - sha: "def456".to_string(), - }; - let json_none = serde_json::to_string(&event_none).unwrap(); - assert!(!json_none.contains("node_id")); - } - - #[test] - fn git_push_serialization() { - let event = WorkflowRunEvent::GitPush { - branch: "fabro/run/123".to_string(), - success: true, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitPush")); - assert!(json.contains("\"success\":true")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::GitPush { success: true, .. } - )); - } - - #[test] - fn git_branch_serialization() { - let event = WorkflowRunEvent::GitBranch { - branch: "fabro/run/123/work".to_string(), - sha: "abc123".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitBranch")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::GitBranch { branch, .. } if branch == "fabro/run/123/work") - ); - } - - #[test] - fn git_worktree_add_serialization() { - let event = WorkflowRunEvent::GitWorktreeAdd { - path: "/tmp/wt".to_string(), - branch: "work".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitWorktreeAdd")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::GitWorktreeAdd { path, .. } if path == "/tmp/wt") - ); - } - - #[test] - fn git_worktree_remove_serialization() { - let event = WorkflowRunEvent::GitWorktreeRemove { - path: "/tmp/wt".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitWorktreeRemove")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::GitWorktreeRemove { path } if path == "/tmp/wt") - ); - } - - #[test] - fn git_fetch_serialization() { - let event = WorkflowRunEvent::GitFetch { - branch: "main".to_string(), - success: false, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitFetch")); - assert!(json.contains("\"success\":false")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::GitFetch { success: false, .. } - )); - } - - #[test] - fn git_reset_serialization() { - let event = WorkflowRunEvent::GitReset { - sha: "abc123".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitReset")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::GitReset { sha } if sha == "abc123")); - } - - #[test] - fn rename_fields_sandbox_snapshot_pulling() { - let event = WorkflowRunEvent::Sandbox { - event: SandboxEvent::SnapshotPulling { - name: "base-image".into(), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Sandbox.SnapshotPulling"); - assert_eq!(fields["snapshot_name"], "base-image"); - assert!(!fields.contains_key("name")); - } - - #[test] - fn cli_ensure_events_serialization() { - let events = vec![ - WorkflowRunEvent::CliEnsureStarted { - cli_name: "claude".into(), - provider: "anthropic".into(), - }, - WorkflowRunEvent::CliEnsureCompleted { - cli_name: "claude".into(), - provider: "anthropic".into(), - already_installed: false, - node_installed: true, - duration_ms: 45000, - }, - WorkflowRunEvent::CliEnsureFailed { - cli_name: "codex".into(), - provider: "openai".into(), - error: "npm install failed".into(), - duration_ms: 30000, - }, - ]; - - for event in &events { - let json = serde_json::to_string(event).unwrap(); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - let json2 = serde_json::to_string(&deserialized).unwrap(); - assert_eq!(json, json2); - } - } - - #[test] - fn setup_events_serialization() { - let events = vec![ - WorkflowRunEvent::SetupStarted { command_count: 3 }, - WorkflowRunEvent::SetupCommandStarted { - command: "npm install".into(), + fn canonicalize_stage_completed_places_node_fields_in_envelope() { + let envelope = canonicalize_event( + &fixtures::RUN_2, + &WorkflowRunEvent::StageCompleted { + node_id: "plan".to_string(), + name: "Plan".to_string(), index: 0, - }, - WorkflowRunEvent::SetupCommandCompleted { - command: "npm install".into(), - index: 0, - exit_code: 0, duration_ms: 5000, + status: "success".to_string(), + preferred_label: None, + suggested_next_ids: Vec::new(), + usage: None, + failure: None, + notes: None, + files_touched: Vec::new(), + attempt: 1, + max_attempts: 1, }, - WorkflowRunEvent::SetupCompleted { duration_ms: 8000 }, - WorkflowRunEvent::SetupFailed { - command: "npm test".into(), - index: 1, - exit_code: 1, - stderr: "test failed".into(), - }, - ]; - - for event in &events { - let json = serde_json::to_string(event).unwrap(); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - let json2 = serde_json::to_string(&deserialized).unwrap(); - assert_eq!(json, json2); - } - } - - #[test] - fn pull_request_created_event_serialization() { - let event = WorkflowRunEvent::PullRequestCreated { - pr_url: "https://github.com/owner/repo/pull/42".to_string(), - pr_number: 42, - draft: true, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("PullRequestCreated")); - assert!(json.contains("\"pr_number\":42")); - assert!(json.contains("\"draft\":true")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::PullRequestCreated { - pr_number: 42, - draft: true, - .. - } - )); - } - - #[test] - fn pull_request_failed_event_serialization() { - let event = WorkflowRunEvent::PullRequestFailed { - error: "auth failed".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("PullRequestFailed")); - assert!(json.contains("\"error\":\"auth failed\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::PullRequestFailed { error } if error == "auth failed") ); + + assert_eq!(envelope.event, "stage.completed"); + assert_eq!(envelope.run_id, fixtures::RUN_2.to_string()); + assert_eq!(envelope.node_id.as_deref(), Some("plan")); + assert_eq!(envelope.node_label.as_deref(), Some("Plan")); + assert_eq!(envelope.properties["duration_ms"], 5000); + assert_eq!(envelope.properties["status"], "success"); + assert!(envelope.session_id.is_none()); } #[test] - fn run_notice_event_serialization() { - let event = WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Warn, - code: "sandbox_cleanup_failed".to_string(), - message: "sandbox cleanup failed: boom".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("RunNotice")); - assert!(json.contains("\"level\":\"warn\"")); + fn canonicalize_stage_failure_flattens_failure_detail() { + let envelope = canonicalize_event( + &fixtures::RUN_3, + &WorkflowRunEvent::StageFailed { + node_id: "code".to_string(), + name: "Code".to_string(), + index: 1, + failure: FailureDetail::new( + "lint failed", + crate::outcome::FailureCategory::Deterministic, + ), + will_retry: true, + }, + ); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::RunNotice { + assert_eq!(envelope.event, "stage.failed"); + assert_eq!(envelope.properties["error"], "lint failed"); + assert_eq!(envelope.properties["failure_class"], "deterministic"); + assert_eq!(envelope.properties["will_retry"], true); + assert!(envelope.properties.get("failure").is_none()); + } + + #[test] + fn canonicalize_agent_tool_started_moves_session_metadata_to_envelope() { + let envelope = canonicalize_event( + &fixtures::RUN_4, + &WorkflowRunEvent::Agent { + stage: "code".to_string(), + event: AgentEvent::ToolCallStarted { + tool_name: "read_file".to_string(), + tool_call_id: "call_1".to_string(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }, + session_id: Some("ses_child".to_string()), + parent_session_id: Some("ses_parent".to_string()), + }, + ); + + assert_eq!(envelope.event, "agent.tool.started"); + assert_eq!(envelope.node_id.as_deref(), Some("code")); + assert_eq!(envelope.node_label.as_deref(), Some("code")); + assert_eq!(envelope.session_id.as_deref(), Some("ses_child")); + assert_eq!(envelope.parent_session_id.as_deref(), Some("ses_parent")); + assert_eq!(envelope.properties["tool_name"], "read_file"); + assert_eq!(envelope.properties["tool_call_id"], "call_1"); + } + + #[test] + fn canonicalize_sandbox_event_keeps_properties_nested() { + let envelope = canonicalize_event( + &fixtures::RUN_5, + &WorkflowRunEvent::Sandbox { + event: SandboxEvent::Ready { + provider: "daytona".to_string(), + duration_ms: 2500, + name: Some("sandbox-1".to_string()), + cpu: Some(4.0), + memory: Some(8.0), + url: Some("https://example.test".to_string()), + }, + }, + ); + + assert_eq!(envelope.event, "sandbox.ready"); + assert!(envelope.node_id.is_none()); + assert_eq!(envelope.properties["provider"], "daytona"); + assert_eq!(envelope.properties["duration_ms"], 2500); + } + + #[test] + fn canonicalize_workflow_failure_flattens_error_display() { + let envelope = canonicalize_event( + &fixtures::RUN_6, + &WorkflowRunEvent::WorkflowRunFailed { + error: FabroError::handler("boom"), + duration_ms: 900, + git_commit_sha: Some("abc123".to_string()), + }, + ); + + assert_eq!(envelope.event, "run.failed"); + assert_eq!(envelope.properties["error"], "boom"); + assert_eq!(envelope.properties["duration_ms"], 900); + } + + #[test] + fn append_progress_event_writes_envelope_shape() { + let dir = tempfile::tempdir().unwrap(); + let envelope = canonicalize_event( + &fixtures::RUN_7, + &WorkflowRunEvent::RunNotice { level: RunNoticeLevel::Warn, - code, - .. - } if code == "sandbox_cleanup_failed" - )); + code: "example".to_string(), + message: "notice".to_string(), + }, + ); + + append_progress_event(dir.path(), &envelope).unwrap(); + + let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap(); + let line: serde_json::Value = serde_json::from_str(progress.trim()).unwrap(); + assert!(line.get("id").is_some()); + assert_eq!(line["event"], "run.notice"); + assert_eq!(line["properties"]["code"], "example"); } #[test] - fn flatten_event_run_notice() { - let event = WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Error, - code: "bootstrap_failed".to_string(), - message: "working directory missing".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "RunNotice"); - assert_eq!(fields.get("level").and_then(|v| v.as_str()), Some("error")); + fn build_redacted_event_payload_requires_id() { + let envelope = canonicalize_event(&fixtures::RUN_8, &WorkflowRunEvent::RetroStarted); + + let payload = build_redacted_event_payload(&envelope, &fixtures::RUN_8).unwrap(); + assert_eq!(payload.as_value()["id"], envelope.id); + assert_eq!(payload.as_value()["event"], "retro.started"); + } + + #[test] + fn event_name_matches_new_dot_notation() { + assert_eq!(event_name(&WorkflowRunEvent::RetroStarted), "retro.started"); assert_eq!( - fields.get("code").and_then(|v| v.as_str()), - Some("bootstrap_failed") - ); - } - - #[test] - fn workflow_run_completed_serialization_with_status_and_usage() { - let event = WorkflowRunEvent::WorkflowRunCompleted { - duration_ms: 30000, - artifact_count: 2, - status: "success".to_string(), - total_cost: Some(1.23), - final_git_commit_sha: Some("abc123".to_string()), - usage: Some(Usage { - input_tokens: 5000, - output_tokens: 2000, - total_tokens: 7000, - cache_read_tokens: Some(3000), - cache_write_tokens: Some(500), - reasoning_tokens: Some(800), - speed: None, - raw: None, + event_name(&WorkflowRunEvent::ParallelBranchStarted { + branch: "fork".to_string(), + index: 0, }), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"status\":\"success\"")); - assert!(json.contains("\"total_tokens\":7000")); - assert!(json.contains("\"cache_read_tokens\":3000")); - assert!(json.contains("\"reasoning_tokens\":800")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::WorkflowRunCompleted { status, usage: Some(u), .. } - if status == "success" && u.total_tokens == 7000 - )); - } - - #[test] - fn workflow_run_completed_backward_compat_without_new_fields() { - // Old JSONL without status/usage should deserialize with defaults - let json = - r#"{"WorkflowRunCompleted":{"duration_ms":5000,"artifact_count":1,"total_cost":0.25}}"#; - let deserialized: WorkflowRunEvent = serde_json::from_str(json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::WorkflowRunCompleted { status, usage, .. } - if status.is_empty() && usage.is_none() - )); - } - - #[test] - fn devcontainer_resolved_serializes() { - let event = WorkflowRunEvent::DevcontainerResolved { - dockerfile_lines: 15, - environment_count: 3, - lifecycle_command_count: 5, - workspace_folder: "/workspaces/myrepo".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("DevcontainerResolved")); - assert!(json.contains("\"dockerfile_lines\":15")); - assert!(json.contains("\"workspace_folder\":\"/workspaces/myrepo\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::DevcontainerResolved { - dockerfile_lines: 15, - .. - } - )); - } - - #[test] - fn devcontainer_lifecycle_events_serialization() { - let events = vec![ - WorkflowRunEvent::DevcontainerLifecycleStarted { - phase: "on_create".into(), - command_count: 2, - }, - WorkflowRunEvent::DevcontainerLifecycleCommandStarted { - phase: "on_create".into(), - command: "npm install".into(), - index: 0, - }, - WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { - phase: "on_create".into(), - command: "npm install".into(), - index: 0, - exit_code: 0, - duration_ms: 5000, - }, - WorkflowRunEvent::DevcontainerLifecycleCompleted { - phase: "on_create".into(), - duration_ms: 8000, - }, - WorkflowRunEvent::DevcontainerLifecycleFailed { - phase: "post_create".into(), - command: "npm test".into(), - index: 1, - exit_code: 1, - stderr: "test failed".into(), - }, - ]; - - for event in &events { - let json = serde_json::to_string(event).unwrap(); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - let json2 = serde_json::to_string(&deserialized).unwrap(); - assert_eq!(json, json2); - } - } - - #[test] - fn flatten_devcontainer_lifecycle_command_renames_index() { - let event = WorkflowRunEvent::DevcontainerLifecycleCommandStarted { - phase: "on_create".into(), - command: "npm install".into(), - index: 2, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "DevcontainerLifecycleCommandStarted"); - assert_eq!(fields["command_index"], 2); - assert!(!fields.contains_key("index")); - } - - #[test] - fn flatten_devcontainer_lifecycle_failed_renames_index() { - let event = WorkflowRunEvent::DevcontainerLifecycleFailed { - phase: "post_create".into(), - command: "npm test".into(), - index: 1, - exit_code: 1, - stderr: "fail".into(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "DevcontainerLifecycleFailed"); - assert_eq!(fields["command_index"], 1); - assert!(!fields.contains_key("index")); - } - - #[test] - fn workflow_run_started_with_goal_round_trip() { - let event = WorkflowRunEvent::WorkflowRunStarted { - name: "my_workflow".to_string(), - run_id: fixtures::RUN_42, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: Some("Fix the bug".to_string()), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"goal\":\"Fix the bug\"")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::WorkflowRunStarted { goal: Some(g), .. } if g == "Fix the bug") + "parallel.branch.started" ); - } - - #[test] - fn workflow_run_started_without_goal_backward_compat() { - // Old JSONL without `goal` field should deserialize to `goal: None` - let json = - r#"{"WorkflowRunStarted":{"name":"old_wf","run_id":"00000000000000000000000001"}}"#; - let deserialized: WorkflowRunEvent = serde_json::from_str(json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::WorkflowRunStarted { goal: None, .. } - )); - } - - #[test] - fn workflow_run_started_goal_none_omitted_from_json() { - let event = WorkflowRunEvent::WorkflowRunStarted { - name: "wf".to_string(), - run_id: fixtures::RUN_1, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: None, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!( - !json.contains("goal"), - "goal: None should be skipped, got: {json}" + assert_eq!( + event_name(&WorkflowRunEvent::Agent { + stage: "code".to_string(), + event: AgentEvent::SubAgentSpawned { + agent_id: "a1".to_string(), + depth: 1, + task: "do it".to_string(), + }, + session_id: None, + parent_session_id: None, + }), + "agent.sub.spawned" ); } - - #[test] - fn retro_started_event_serialization() { - let event = WorkflowRunEvent::RetroStarted; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("RetroStarted")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::RetroStarted)); - } - - #[test] - fn retro_completed_event_serialization() { - let event = WorkflowRunEvent::RetroCompleted { duration_ms: 5000 }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"duration_ms\":5000")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::RetroCompleted { duration_ms: 5000 } - )); - } - - #[test] - fn retro_failed_event_serialization() { - let event = WorkflowRunEvent::RetroFailed { - error: "LLM timeout".to_string(), - duration_ms: 3000, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("LLM timeout")); - assert!(json.contains("\"duration_ms\":3000")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::RetroFailed { .. })); - } - - #[test] - fn flatten_retro_started() { - let event = WorkflowRunEvent::RetroStarted; - let (name, _fields) = flatten_event(&event); - assert_eq!(name, "RetroStarted"); - } - - #[test] - fn flatten_retro_failed() { - let event = WorkflowRunEvent::RetroFailed { - error: "timeout".to_string(), - duration_ms: 1000, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "RetroFailed"); - assert_eq!(fields["error"], "timeout"); - assert_eq!(fields["duration_ms"], 1000); - } - - #[test] - fn emitter_captures_retro_events() { - let emitter = EventEmitter::new(); - let received = Arc::new(Mutex::new(Vec::new())); - let r = Arc::clone(&received); - emitter.on_event(move |event| { - if let WorkflowRunEvent::RetroStarted = event { - r.lock().unwrap().push("started".to_string()); - } - if let WorkflowRunEvent::RetroCompleted { .. } = event { - r.lock().unwrap().push("completed".to_string()); - } - }); - emitter.emit(&WorkflowRunEvent::RetroStarted); - emitter.emit(&WorkflowRunEvent::RetroCompleted { duration_ms: 100 }); - let events = received.lock().unwrap(); - assert_eq!(events.len(), 2); - assert_eq!(events[0], "started"); - assert_eq!(events[1], "completed"); - } - - #[test] - fn sandbox_initialized_event_serialization() { - let event = WorkflowRunEvent::SandboxInitialized { - working_directory: "/workspace/project".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("SandboxInitialized")); - assert!(json.contains("/workspace/project")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::SandboxInitialized { - working_directory - } if working_directory == "/workspace/project" - )); - } - - #[test] - fn flatten_sandbox_initialized() { - let event = WorkflowRunEvent::SandboxInitialized { - working_directory: "/workspace".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "SandboxInitialized"); - assert_eq!(fields["working_directory"], "/workspace"); - } } diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index d81328bb7..997231e10 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -48,8 +48,6 @@ struct FileTracking { last: Option, } -/// Recursively extract file-tracking events from agent events, including -/// those wrapped in one or more layers of `SubAgentEvent`. fn track_file_event(event: &AgentEvent, state: &mut FileTracking) { match event { AgentEvent::ToolCallStarted { @@ -75,9 +73,6 @@ fn track_file_event(event: &AgentEvent, state: &mut FileTracking) { } } } - AgentEvent::SubAgentEvent { event: inner, .. } => { - track_file_event(inner, state); - } _ => {} } } @@ -116,6 +111,8 @@ fn spawn_event_forwarder( emitter.emit(&WorkflowRunEvent::Agent { stage: node_id.clone(), event: event.event.clone(), + session_id: Some(event.session_id.clone()), + parent_session_id: event.parent_session_id.clone(), }); } } @@ -261,7 +258,7 @@ impl AgentApiBackend { manager_for_callback .lock() .await - .set_event_callback(session.event_callback()); + .set_event_callback(session.sub_agent_event_callback()); Ok(session) } @@ -730,7 +727,7 @@ mod tests { } #[test] - fn track_file_event_unwraps_sub_agent_edit() { + fn track_file_event_tracks_edit_file() { let mut state = new_file_tracking(); let mut args = serde_json::Map::new(); @@ -739,32 +736,22 @@ mod tests { serde_json::Value::String("/src/lib.rs".to_string()), ); - // ToolCallStarted wrapped in SubAgentEvent track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "edit_file".to_string(), - tool_call_id: "tc-sub".to_string(), - arguments: serde_json::Value::Object(args), - }), + &AgentEvent::ToolCallStarted { + tool_name: "edit_file".to_string(), + tool_call_id: "tc-sub".to_string(), + arguments: serde_json::Value::Object(args), }, &mut state, ); assert_eq!(state.pending.get("tc-sub").unwrap(), "/src/lib.rs"); - // ToolCallCompleted wrapped in SubAgentEvent track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallCompleted { - tool_call_id: "tc-sub".to_string(), - tool_name: "edit_file".to_string(), - is_error: false, - output: serde_json::Value::String("ok".to_string()), - }), + &AgentEvent::ToolCallCompleted { + tool_call_id: "tc-sub".to_string(), + tool_name: "edit_file".to_string(), + is_error: false, + output: serde_json::Value::String("ok".to_string()), }, &mut state, ); @@ -772,55 +759,6 @@ mod tests { assert_eq!(state.last.as_deref(), Some("/src/lib.rs")); } - #[test] - fn track_file_event_unwraps_nested_sub_sub_agent() { - let mut state = new_file_tracking(); - - let mut args = serde_json::Map::new(); - args.insert( - "file_path".to_string(), - serde_json::Value::String("/deep/file.rs".to_string()), - ); - - // Double-wrapped SubAgentEvent → SubAgentEvent → ToolCallStarted - track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-outer".to_string(), - depth: 1, - event: Box::new(AgentEvent::SubAgentEvent { - agent_id: "sub-inner".to_string(), - depth: 2, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "write_file".to_string(), - tool_call_id: "tc-deep".to_string(), - arguments: serde_json::Value::Object(args), - }), - }), - }, - &mut state, - ); - assert!(state.pending.contains_key("tc-deep")); - - track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-outer".to_string(), - depth: 1, - event: Box::new(AgentEvent::SubAgentEvent { - agent_id: "sub-inner".to_string(), - depth: 2, - event: Box::new(AgentEvent::ToolCallCompleted { - tool_call_id: "tc-deep".to_string(), - tool_name: "write_file".to_string(), - is_error: false, - output: serde_json::Value::String("ok".to_string()), - }), - }), - }, - &mut state, - ); - assert!(state.touched.contains("/deep/file.rs")); - } - #[test] fn track_file_event_error_removes_pending() { let mut state = new_file_tracking(); @@ -832,28 +770,20 @@ mod tests { ); track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "edit_file".to_string(), - tool_call_id: "tc-err".to_string(), - arguments: serde_json::Value::Object(args), - }), + &AgentEvent::ToolCallStarted { + tool_name: "edit_file".to_string(), + tool_call_id: "tc-err".to_string(), + arguments: serde_json::Value::Object(args), }, &mut state, ); track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallCompleted { - tool_call_id: "tc-err".to_string(), - tool_name: "edit_file".to_string(), - is_error: true, - output: serde_json::Value::String("failed".to_string()), - }), + &AgentEvent::ToolCallCompleted { + tool_call_id: "tc-err".to_string(), + tool_name: "edit_file".to_string(), + is_error: true, + output: serde_json::Value::String("failed".to_string()), }, &mut state, ); diff --git a/lib/crates/fabro-workflow/src/handler/llm/cli.rs b/lib/crates/fabro-workflow/src/handler/llm/cli.rs index 472207571..7b43ab6b6 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/cli.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/cli.rs @@ -969,7 +969,7 @@ mod tests { #[allow(unsafe_code)] async fn ensure_cli_skips_install_when_present() { let sandbox: Arc = Arc::new(CliMockSandbox::new(vec![ok_result()])); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await; assert!(result.is_ok()); @@ -988,7 +988,7 @@ mod tests { fail_result(127), // claude --version ok_result(), // combined node + npm install ])); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await; assert!(result.is_ok()); @@ -1005,7 +1005,7 @@ mod tests { fail_result(127), // claude --version fail_result(1), // combined install fails ])); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await; assert!(result.is_err()); diff --git a/lib/crates/fabro-workflow/src/handler/mod.rs b/lib/crates/fabro-workflow/src/handler/mod.rs index 7e8df9140..93f62de2d 100644 --- a/lib/crates/fabro-workflow/src/handler/mod.rs +++ b/lib/crates/fabro-workflow/src/handler/mod.rs @@ -71,7 +71,7 @@ impl EngineServices { pub fn test_default() -> Self { Self { registry: Arc::new(HandlerRegistry::new(Box::new(start::StartHandler))), - emitter: Arc::new(EventEmitter::new()), + emitter: Arc::new(EventEmitter::default()), sandbox: Arc::new(fabro_agent::LocalSandbox::new( std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), )), diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs index 50ff37577..53d91c91e 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/crates/fabro-workflow/src/lib.rs @@ -113,13 +113,18 @@ pub fn extract_stage_durations_from_events(events: &[EventEnvelope]) -> HashMap< let mut durations = HashMap::new(); for envelope in events { let value = envelope.payload.as_value(); - if value.get("event").and_then(serde_json::Value::as_str) != Some("StageCompleted") { + if value.get("event").and_then(serde_json::Value::as_str) != Some("stage.completed") { continue; } let Some(node_id) = value.get("node_id").and_then(serde_json::Value::as_str) else { continue; }; - let Some(duration_ms) = value.get("duration_ms").and_then(serde_json::Value::as_u64) else { + let Some(duration_ms) = value + .get("properties") + .and_then(serde_json::Value::as_object) + .and_then(|properties| properties.get("duration_ms")) + .and_then(serde_json::Value::as_u64) + else { continue; }; durations.insert(node_id.to_string(), duration_ms); diff --git a/lib/crates/fabro-workflow/src/operations/hydrate.rs b/lib/crates/fabro-workflow/src/operations/hydrate.rs index c37aba4de..8b3b40857 100644 --- a/lib/crates/fabro-workflow/src/operations/hydrate.rs +++ b/lib/crates/fabro-workflow/src/operations/hydrate.rs @@ -227,7 +227,7 @@ mod tests { use fabro_types::{Conclusion, RunStatus, RunStatusRecord, StageStatus, fixtures}; use super::open_or_hydrate_run; - use crate::event::{WorkflowRunEvent, append_progress_event}; + use crate::event::{WorkflowRunEvent, append_progress_event, canonicalize_event}; use crate::records::{Checkpoint, CheckpointExt, ConclusionExt, RunRecord, RunRecordExt}; use crate::run_status::RunStatusRecordExt; @@ -286,16 +286,15 @@ mod tests { has_pricing: false, }; conclusion.save(&run_dir.join("conclusion.json")).unwrap(); - append_progress_event( - run_dir, + let envelope = canonicalize_event( &test_run_id(), &WorkflowRunEvent::RunNotice { level: crate::event::RunNoticeLevel::Info, code: "hydrated".to_string(), message: "hello".to_string(), }, - ) - .unwrap(); + ); + append_progress_event(run_dir, &envelope).unwrap(); } #[tokio::test] diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 48aa377d7..15ea909e5 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -19,7 +19,8 @@ use crate::context::Context; use crate::error::FabroError; use crate::event::{ EventEmitter, RunNoticeLevel, StoreProgressLogger, WorkflowRunEvent, append_progress_event, - build_redacted_event_payload, + append_progress_event_with_line, canonicalize_event, event_payload_from_redacted_json, + redacted_event_json, }; use crate::handler::HandlerRegistry; use crate::outcome::{Outcome, StageStatus}; @@ -127,8 +128,7 @@ pub(super) async fn execute_persisted_run( // Write directly to progress.jsonl/live.json so projection failures do not // recurse back through the decorated store.append_event() path. - let _ = append_progress_event( - &projection_run_dir, + let envelope = canonicalize_event( &run_id, &WorkflowRunEvent::RunNotice { level: RunNoticeLevel::Warn, @@ -145,6 +145,7 @@ pub(super) async fn execute_persisted_run( ), }, ); + let _ = append_progress_event(&projection_run_dir, &envelope); }), ), ); @@ -455,23 +456,38 @@ impl RunSession { { let sha_clone = Arc::clone(&last_git_sha); self.emitter.on_event(move |event| match event { - WorkflowRunEvent::CheckpointCompleted { - git_commit_sha: Some(sha), - .. + envelope if envelope.event == "checkpoint.completed" => { + if let Some(sha) = envelope + .properties + .get("git_commit_sha") + .and_then(serde_json::Value::as_str) + { + *sha_clone.lock().unwrap() = Some(sha.to_string()); + } } - | WorkflowRunEvent::WorkflowRunCompleted { - final_git_commit_sha: Some(sha), - .. + envelope if envelope.event == "run.completed" => { + if let Some(sha) = envelope + .properties + .get("final_git_commit_sha") + .and_then(serde_json::Value::as_str) + { + *sha_clone.lock().unwrap() = Some(sha.to_string()); + } } - | WorkflowRunEvent::GitCommit { sha, .. } => { - *sha_clone.lock().unwrap() = Some(sha.clone()); + envelope if envelope.event == "git.commit" => { + if let Some(sha) = envelope + .properties + .get("sha") + .and_then(serde_json::Value::as_str) + { + *sha_clone.lock().unwrap() = Some(sha.to_string()); + } } _ => {} }); } - let store_progress_logger = - StoreProgressLogger::new(Arc::clone(&self.run_store), record.run_id); + let store_progress_logger = StoreProgressLogger::new(Arc::clone(&self.run_store)); store_progress_logger.register(self.emitter.as_ref()); let init_options = InitOptions { @@ -690,9 +706,8 @@ impl Drop for DetachedRunCompletionGuard { if !self.run_dir.join("conclusion.json").exists() { let _ = write_failure_conclusion(&self.run_dir, message, Some(reason)); } - if let Some(run_id) = load_run_id(&self.run_dir) { - let _ = append_progress_event( - &self.run_dir, + let serialized_notice = load_run_id(&self.run_dir).and_then(|run_id| { + let envelope = canonicalize_event( &run_id, &WorkflowRunEvent::RunNotice { level: RunNoticeLevel::Error, @@ -700,7 +715,19 @@ impl Drop for DetachedRunCompletionGuard { message: message.to_string(), }, ); - } + let line = match redacted_event_json(&envelope) { + Ok(line) => line, + Err(err) => { + tracing::warn!(error = %err, "Failed to serialize post-run abort event"); + return None; + } + }; + if let Err(err) = append_progress_event_with_line(&self.run_dir, &envelope, &line) { + tracing::warn!(error = %err, "Failed to append post-run abort event"); + return None; + } + Some((run_id, line)) + }); let run_store = Arc::clone(&self.run_store); let run_id = self.run_id; if let Ok(handle) = Handle::try_current() { @@ -720,13 +747,23 @@ impl Drop for DetachedRunCompletionGuard { "Failed to save post-run abort conclusion to store" ); } - if let Some(run_id) = run_id { - let event = WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Error, - code: code.to_string(), - message: message.to_string(), - }; - match build_redacted_event_payload(&event, &run_id) { + if let Some((run_id, line)) = serialized_notice.or(run_id + .map(|run_id| { + let envelope = canonicalize_event( + &run_id, + &WorkflowRunEvent::RunNotice { + level: RunNoticeLevel::Error, + code: code.to_string(), + message: message.to_string(), + }, + ); + redacted_event_json(&envelope) + .ok() + .map(|line| (run_id, line)) + }) + .flatten()) + { + match event_payload_from_redacted_json(&line, &run_id) { Ok(payload) => { let _ = run_store.append_event(&payload).await; } @@ -801,22 +838,16 @@ async fn persist_detached_failure( } if let Some(run_id) = load_run_id(run_dir) { - append_progress_event( - run_dir, - &run_id, - &WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Error, - code: format!("{phase}_failed"), - message: message.clone(), - }, - ) - .map_err(|err| FabroError::Io(err.to_string()))?; let event = WorkflowRunEvent::RunNotice { level: RunNoticeLevel::Error, code: format!("{phase}_failed"), - message, + message: message.clone(), }; - match build_redacted_event_payload(&event, &run_id) { + let envelope = canonicalize_event(&run_id, &event); + let line = redacted_event_json(&envelope).map_err(|err| FabroError::Io(err.to_string()))?; + append_progress_event_with_line(run_dir, &envelope, &line) + .map_err(|err| FabroError::Io(err.to_string()))?; + match event_payload_from_redacted_json(&line, &run_id) { Ok(payload) => { if let Err(err) = run_store.append_event(&payload).await { tracing::warn!(error = %err, "Failed to append detached failure event to store"); @@ -943,7 +974,7 @@ mod tests { async fn start_captures_checkpoint_git_sha_in_conclusion() { let temp = tempfile::tempdir().unwrap(); let run_dir = temp.path().join("run"); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let registry = Arc::new(test_registry()); let injected = Arc::new(AtomicBool::new(false)); @@ -954,15 +985,13 @@ mod tests { if injected.load(Ordering::SeqCst) { return; } - if let WorkflowRunEvent::StageStarted { node_id, .. } = event { - if node_id == "start" { - injected.store(true, Ordering::SeqCst); - emitter_for_injection.emit(&WorkflowRunEvent::CheckpointCompleted { - node_id: node_id.clone(), - status: "success".to_string(), - git_commit_sha: Some("sha-test".to_string()), - }); - } + if event.event == "stage.started" && event.node_id.as_deref() == Some("start") { + injected.store(true, Ordering::SeqCst); + emitter_for_injection.emit(&WorkflowRunEvent::CheckpointCompleted { + node_id: "start".to_string(), + status: "success".to_string(), + git_commit_sha: Some("sha-test".to_string()), + }); } }); } @@ -987,7 +1016,7 @@ mod tests { async fn start_loads_persisted_from_run_dir() { let temp = tempfile::tempdir().unwrap(); let run_dir = temp.path().join("run"); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let registry = Arc::new(test_registry()); persisted_workflow(MINIMAL_DOT, &run_dir); @@ -1007,7 +1036,7 @@ mod tests { async fn start_invokes_on_node_callback_before_execution() { let temp = tempfile::tempdir().unwrap(); let run_dir = temp.path().join("run"); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let registry = Arc::new(test_registry()); let visited = Arc::new(Mutex::new(Vec::new())); @@ -1036,7 +1065,7 @@ mod tests { async fn start_errors_when_checkpoint_exists() { let temp = tempfile::tempdir().unwrap(); let run_dir = temp.path().join("run"); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let registry = Arc::new(test_registry()); persisted_workflow(MINIMAL_DOT, &run_dir); @@ -1073,7 +1102,7 @@ mod tests { async fn resume_errors_when_checkpoint_missing() { let temp = tempfile::tempdir().unwrap(); let run_dir = temp.path().join("run"); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let registry = Arc::new(test_registry()); persisted_workflow(MINIMAL_DOT, &run_dir); @@ -1095,7 +1124,7 @@ mod tests { async fn resume_errors_when_run_already_finished_successfully() { let temp = tempfile::tempdir().unwrap(); let run_dir = temp.path().join("run"); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let registry = Arc::new(test_registry()); persisted_workflow(MINIMAL_DOT, &run_dir); diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index 0d7890d8c..0f32bc21f 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -20,7 +20,7 @@ use fabro_types::{RunId, fixtures}; use super::*; use crate::context::{self, Context}; use crate::error::FabroError; -use crate::event::{EventEmitter, WorkflowRunEvent}; +use crate::event::{EventEmitter, RunEventEnvelope}; use crate::handler::start::StartHandler; use crate::handler::{Handler as HandlerTrait, HandlerRegistry}; use crate::outcome::{Outcome, OutcomeExt, StageStatus}; @@ -169,7 +169,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { run_id: test_run_id("run-test"), run_store: test_run_store(&run_dir, &test_run_id("run-test")).await, dry_run: false, - emitter: Arc::new(crate::event::EventEmitter::new()), + emitter: Arc::new(crate::event::EventEmitter::default()), sandbox: SandboxSpec::Local { working_directory: std::env::current_dir().unwrap(), }, @@ -426,7 +426,7 @@ async fn execute_runs_simple_workflow() { let dir = tempfile::tempdir().unwrap(); let outcome = run_graph( make_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &simple_graph(), &test_run_options(dir.path(), "test-run"), @@ -441,7 +441,7 @@ async fn execute_saves_checkpoint() { let dir = tempfile::tempdir().unwrap(); run_graph( make_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &simple_graph(), &test_run_options(dir.path(), "test-run"), @@ -456,7 +456,7 @@ async fn execute_emits_events() { let dir = tempfile::tempdir().unwrap(); let events = Arc::new(std::sync::Mutex::new(Vec::new())); let events_clone = Arc::clone(&events); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); emitter.on_event(move |event| { events_clone.lock().unwrap().push(format!("{event:?}")); }); @@ -479,7 +479,7 @@ async fn execute_error_when_no_start_node() { let dir = tempfile::tempdir().unwrap(); let result = run_graph( make_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &Graph::new("empty"), &test_run_options(dir.path(), "test-run"), @@ -493,7 +493,7 @@ async fn execute_mirrors_graph_goal_to_context() { let dir = tempfile::tempdir().unwrap(); run_graph( make_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &simple_graph(), &test_run_options(dir.path(), "test-run"), @@ -542,7 +542,7 @@ async fn execute_conditional_routing_uses_unconditional_success_path() { run_graph( make_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -567,7 +567,7 @@ async fn execute_writes_start_json_and_node_status() { run_graph( make_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &simple_graph(), &run_options, @@ -629,7 +629,7 @@ async fn timeout_causes_fail_status_json() { registry.register("slow", Box::new(SlowHandler { sleep_ms: 500 })); run_graph( registry, - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -671,7 +671,7 @@ async fn execute_cancelled_mid_run() { let result = run_graph( registry, - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &g, &run_options, @@ -689,7 +689,7 @@ async fn max_node_visits_errors_on_cycle() { let result = run_graph( make_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -724,7 +724,7 @@ async fn panic_handler_writes_panic_txt() { registry.register("panicker", Box::new(PanickingHandler)); let _ = run_graph( registry, - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -745,7 +745,7 @@ async fn loop_circuit_breaker_aborts_on_repeated_failure() { let result = run_graph( registry, - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &looping_fail_graph(), &test_run_options(dir.path(), "test-run"), @@ -794,7 +794,7 @@ async fn stall_watchdog_triggers_on_hung_handler() { registry.register("slow", Box::new(SlowHandler { sleep_ms: 60_000 })); let result = run_graph( registry, - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -841,9 +841,9 @@ async fn retry_emits_stage_started_per_attempt() { g.edges.push(Edge::new("start", "work")); g.edges.push(Edge::new("work", "exit")); - let events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let events = Arc::new(std::sync::Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); emitter.on_event(move |event| { events_clone.lock().unwrap().push(event.clone()); }); @@ -870,11 +870,9 @@ async fn retry_emits_stage_started_per_attempt() { let collected = events.lock().unwrap(); let work_started: Vec<_> = collected .iter() - .filter_map(|e| match e { - WorkflowRunEvent::StageStarted { - node_id, attempt, .. - } if node_id == "work" => Some(*attempt), - _ => None, + .filter_map(|event| { + (event.event == "stage.started" && event.node_id.as_deref() == Some("work")) + .then(|| event.properties["attempt"].as_u64().unwrap()) }) .collect(); assert_eq!(work_started, vec![1, 2]); @@ -885,13 +883,13 @@ async fn run_with_lifecycle_emits_initialize_and_setup_events() { let dir = tempfile::tempdir().unwrap(); let events = Arc::new(std::sync::Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); emitter.on_event(move |event| { - let name = match event { - WorkflowRunEvent::SandboxInitialized { .. } => "SandboxInitialized", - WorkflowRunEvent::SetupStarted { .. } => "SetupStarted", - WorkflowRunEvent::SetupCompleted { .. } => "SetupCompleted", - WorkflowRunEvent::WorkflowRunStarted { .. } => "WorkflowRunStarted", + let name = match event.event.as_str() { + "sandbox.initialized" => "SandboxInitialized", + "setup.started" => "SetupStarted", + "setup.completed" => "SetupCompleted", + "run.started" => "WorkflowRunStarted", _ => return, }; events_clone.lock().unwrap().push(name.to_string()); @@ -965,9 +963,9 @@ async fn git_checkpoint_skips_start_node() { g.edges.push(Edge::new("start", "work")); g.edges.push(Edge::new("work", "exit")); - let events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let events = Arc::new(std::sync::Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); emitter.on_event(move |event| { events_clone.lock().unwrap().push(event.clone()); }); @@ -996,13 +994,15 @@ async fn git_checkpoint_skips_start_node() { let collected = events.lock().unwrap(); let checkpoint_node_ids: Vec<&str> = collected .iter() - .filter_map(|e| match e { - WorkflowRunEvent::CheckpointCompleted { - node_id, - git_commit_sha: Some(_), - .. - } => Some(node_id.as_str()), - _ => None, + .filter_map(|event| { + (event.event == "checkpoint.completed" + && event + .properties + .get("git_commit_sha") + .and_then(|value| value.as_str()) + .is_some()) + .then(|| event.node_id.as_deref()) + .flatten() }) .collect(); assert!(!checkpoint_node_ids.contains(&"start")); diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index a37474e08..f9731637a 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -489,7 +489,7 @@ mod tests { run_options: test_run_options(&run_dir), run_store: Arc::clone(&run_store), hook_runner: None, - emitter: Arc::new(EventEmitter::new()), + emitter: Arc::new(EventEmitter::default()), sandbox: Arc::new(fabro_agent::LocalSandbox::new( std::env::current_dir().unwrap(), )), diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 640738518..fbd6d0726 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -736,7 +736,7 @@ mod tests { std::fs::create_dir_all(&run_dir).unwrap(); let (graph, source) = simple_graph(); let persisted = test_persisted(graph, source.clone(), &run_dir); - let emitter = Arc::new(crate::event::EventEmitter::new()); + let emitter = Arc::new(crate::event::EventEmitter::default()); let initialized = initialize( persisted, @@ -814,7 +814,7 @@ mod tests { std::fs::create_dir_all(&run_dir).unwrap(); let (graph, source) = simple_graph(); let persisted = test_persisted(graph, source, &run_dir); - let emitter = Arc::new(crate::event::EventEmitter::new()); + let emitter = Arc::new(crate::event::EventEmitter::default()); let initialized = initialize( persisted, diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index f4e13d365..297b5bd6b 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -80,6 +80,8 @@ pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option { emitter.emit(&WorkflowRunEvent::Agent { stage: "retro".to_string(), event: event.event.clone(), + session_id: Some(event.session_id.clone()), + parent_session_id: event.parent_session_id.clone(), }); } }) @@ -178,7 +180,7 @@ mod tests { use super::*; use crate::context::Context; - use crate::event::{EventEmitter, WorkflowRunEvent}; + use crate::event::EventEmitter; use crate::pipeline::types::Executed; use crate::records::{Checkpoint, CheckpointExt}; use crate::run_options::RunOptions; @@ -249,7 +251,7 @@ mod tests { std::fs::create_dir_all(&run_dir).unwrap(); let checkpoint = write_checkpoint(&run_dir); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let sandbox: Arc = Arc::new(fabro_agent::LocalSandbox::new( std::env::current_dir().unwrap(), )); @@ -299,11 +301,11 @@ mod tests { std::fs::create_dir_all(&run_dir).unwrap(); let checkpoint = write_checkpoint(&run_dir); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let seen = Arc::new(Mutex::new(Vec::new())); emitter.on_event({ let seen = Arc::clone(&seen); - move |event| seen.lock().unwrap().push(event.clone()) + move |event| seen.lock().unwrap().push(event.event.clone()) }); let retro = run_retro( @@ -330,13 +332,7 @@ mod tests { assert!(retro.is_some()); let seen = seen.lock().unwrap(); - assert!( - seen.iter() - .any(|event| matches!(event, WorkflowRunEvent::RetroStarted)) - ); - assert!( - seen.iter() - .any(|event| matches!(event, WorkflowRunEvent::RetroCompleted { .. })) - ); + assert!(seen.iter().any(|event| event == "retro.started")); + assert!(seen.iter().any(|event| event == "retro.completed")); } } diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 81dbd520b..a4988a26c 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -407,7 +407,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env.clone()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -584,7 +584,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { // Set up event collection let dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = Arc::new(std::sync::Mutex::new(Vec::new())); { let events_clone = Arc::clone(&events); @@ -627,16 +627,13 @@ async fn daytona_git_checkpoint_remote_emits_events() { let git_events: Vec<_> = events .iter() .filter_map(|e| { - if let fabro_workflow::event::WorkflowRunEvent::CheckpointCompleted { - node_id, - git_commit_sha: Some(sha), - .. - } = e - { - Some((node_id.clone(), sha.clone())) - } else { - None + if e.event != "checkpoint.completed" { + return None; } + Some(( + e.node_id.clone()?, + e.properties.get("git_commit_sha")?.as_str()?.to_string(), + )) }) .collect(); // Only the "work" node gets a checkpoint — start is skipped and exit breaks @@ -766,7 +763,7 @@ async fn daytona_parallel_git_branching_e2e() { graph.edges.push(Edge::new("fan_in", "exit")); let run_tmp = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = Arc::new(std::sync::Mutex::new(Vec::new())); { let events_clone = Arc::clone(&events); @@ -879,12 +876,7 @@ async fn daytona_parallel_git_branching_e2e() { let events = events.lock().unwrap(); let parallel_started: Vec<_> = events .iter() - .filter(|e| { - matches!( - e, - fabro_workflow::event::WorkflowRunEvent::ParallelStarted { .. } - ) - }) + .filter(|e| e.event == "parallel.started") .collect(); assert_eq!( parallel_started.len(), @@ -893,12 +885,7 @@ async fn daytona_parallel_git_branching_e2e() { ); let parallel_completed: Vec<_> = events .iter() - .filter(|e| { - matches!( - e, - fabro_workflow::event::WorkflowRunEvent::ParallelCompleted { .. } - ) - }) + .filter(|e| e.event == "parallel.completed") .collect(); assert_eq!( parallel_completed.len(), @@ -983,7 +970,7 @@ async fn run_daytona_cli_test(provider: Provider, model: &str, install_command: let backend = AgentCliBackend::new(model.to_string(), provider); let node = Node::new("daytona_cli_test"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -1157,7 +1144,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { registry.register("exit", Box::new(ExitHandler)); let meta_branch = MetadataStore::branch_name(&run_id.to_string()); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env.clone()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1263,7 +1250,7 @@ async fn daytona_asset_collection() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env.clone()); let mut graph = Graph::new("DaytonaAssetTest"); graph.attrs.insert( @@ -1546,7 +1533,7 @@ async fn daytona_git_push_run_branch_to_origin() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env.clone()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 034cbe879..3ddd885e5 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -30,7 +30,7 @@ use fabro_types::RunId; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; use fabro_workflow::error::{FabroError, FailureSignatureExt}; -use fabro_workflow::event::{EventEmitter, WorkflowRunEvent}; +use fabro_workflow::event::{EventEmitter, RunEventEnvelope, WorkflowRunEvent}; use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenResult}; use fabro_workflow::handler::command::CommandHandler; use fabro_workflow::handler::conditional::ConditionalHandler; @@ -213,7 +213,7 @@ async fn end_to_end_linear_pipeline() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -355,7 +355,7 @@ async fn end_to_end_branching_pipeline() { registry.register("agent", Box::new(AgentHandler::new(None))); registry.register("conditional", Box::new(ConditionalHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -474,7 +474,7 @@ async fn end_to_end_human_gate_pipeline() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -569,7 +569,7 @@ async fn human_gate_aborted_input_fails_closed_without_fail_route() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -679,7 +679,7 @@ async fn human_gate_aborted_input_routes_via_outcome_fail_condition() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -791,7 +791,7 @@ async fn goal_gate_routes_to_retry_target_on_failure() { registry.register("exit", Box::new(ExitHandler)); registry.register("always_fail", Box::new(AlwaysFailHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -911,7 +911,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1222,7 +1222,7 @@ async fn retry_on_failure_then_succeed() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1294,7 +1294,7 @@ async fn pipeline_with_many_nodes() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -1479,7 +1479,7 @@ impl Handler for ContextSetterHandler { } } -fn collect_events(emitter: &EventEmitter) -> Arc>> { +fn collect_events(emitter: &EventEmitter) -> Arc>> { let events = Arc::new(std::sync::Mutex::new(Vec::new())); let events_clone = Arc::clone(&events); emitter.on_event(move |event| { @@ -1617,7 +1617,7 @@ async fn smoke_test_with_mock_codergen_backend() { ); registry.register("conditional", Box::new(ConditionalHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1717,7 +1717,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1829,7 +1829,7 @@ async fn resume_from_checkpoint_completes_pipeline() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1927,7 +1927,7 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1967,7 +1967,7 @@ async fn graph_goal_in_context() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2002,7 +2002,7 @@ async fn event_streaming_lifecycle() { }"#; let graph = parse(input).expect("parse"); let dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new(make_linear_registry(), Arc::new(emitter), local_env()); let run_options = RunOptions { @@ -2021,50 +2021,32 @@ async fn event_streaming_lifecycle() { engine.run(&graph, &run_options).await.expect("run"); let collected = events.lock().unwrap(); + assert!(collected.iter().any(|e| e.event == "run.started")); assert!( collected .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })) + .any(|e| e.event == "stage.started" && e.node_id.as_deref() == Some("start")) ); assert!( collected .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageStarted { name, .. } if name == "start")) + .any(|e| e.event == "stage.completed" && e.node_id.as_deref() == Some("start")) ); assert!( collected .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageCompleted { name, .. } if name == "start")) + .any(|e| e.event == "stage.started" && e.node_id.as_deref() == Some("task")) ); assert!( collected .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageStarted { name, .. } if name == "task")) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageCompleted { name, .. } if name == "task")) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::CheckpointCompleted { .. })) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) + .any(|e| e.event == "stage.completed" && e.node_id.as_deref() == Some("task")) ); + assert!(collected.iter().any(|e| e.event == "checkpoint.completed")); + assert!(collected.iter().any(|e| e.event == "run.completed")); // WorkflowRunStarted first, WorkflowRunCompleted last - assert!(matches!( - collected.first().unwrap(), - WorkflowRunEvent::WorkflowRunStarted { .. } - )); - assert!(matches!( - collected.last().unwrap(), - WorkflowRunEvent::WorkflowRunCompleted { .. } - )); + assert_eq!(collected.first().unwrap().event, "run.started"); + assert_eq!(collected.last().unwrap().event, "run.completed"); } #[tokio::test] @@ -2095,7 +2077,7 @@ async fn context_flow_between_stages() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2147,7 +2129,7 @@ async fn tool_handler_e2e() { let interviewer = Arc::new(AutoApproveInterviewer); let engine = WorkflowRunner::new( make_full_registry(interviewer), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2218,7 +2200,7 @@ async fn auto_approve_interviewer_e2e() { let interviewer = Arc::new(AutoApproveInterviewer); let engine = WorkflowRunner::new( make_full_registry(interviewer), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2254,7 +2236,7 @@ async fn codergen_without_backend_simulated() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2360,7 +2342,7 @@ async fn branching_loop_back_on_failure() { call_count: std::sync::atomic::AtomicU32::new(0), }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2442,7 +2424,7 @@ async fn human_gate_loops_back() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2493,7 +2475,7 @@ async fn scenario_ship_a_feature() { let interviewer = Arc::new(AutoApproveInterviewer); let dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new( make_full_registry(interviewer), @@ -2528,16 +2510,8 @@ async fn scenario_ship_a_feature() { assert!(cp.completed_nodes.contains(&"review".to_string())); let collected = events.lock().unwrap(); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) - ); + assert!(collected.iter().any(|e| e.event == "run.started")); + assert!(collected.iter().any(|e| e.event == "run.completed")); } #[tokio::test] @@ -2588,7 +2562,7 @@ async fn scenario_parallel_expert_review() { ); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2671,7 +2645,7 @@ async fn scenario_node_retries_on_retry_status() { call_count: std::sync::atomic::AtomicU32::new(0), }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2732,7 +2706,7 @@ async fn scenario_loop_restart_resets_context() { call_count: Arc::clone(&call_count), }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2799,7 +2773,7 @@ async fn scenario_bug_triage_router() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("conditional", Box::new(ConditionalHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2857,7 +2831,7 @@ async fn scenario_crash_recovery() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2965,7 +2939,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { registry.register("exit", Box::new(ExitHandler)); registry.register("done_setter", Box::new(DoneSetterHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3043,7 +3017,7 @@ async fn manager_loop_max_cycles_exceeded_e2e() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3180,7 +3154,7 @@ async fn conditional_branching_success_fail_paths() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("always_fail", Box::new(AlwaysFailHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3232,7 +3206,7 @@ async fn edge_selection_condition_match_wins_over_weight() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3278,7 +3252,7 @@ async fn edge_selection_weight_breaks_ties() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3316,7 +3290,7 @@ async fn edge_selection_lexical_tiebreak() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3373,7 +3347,7 @@ async fn context_updates_visible_across_nodes() { registry.register("exit", Box::new(ExitHandler)); registry.register("conditional", Box::new(ConditionalHandler)); registry.register("context_setter", Box::new(ContextSetterHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3414,7 +3388,7 @@ async fn stylesheet_applies_model_override() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -3471,7 +3445,7 @@ async fn custom_handler_registration_and_execution() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("my_custom", Box::new(CustomHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3535,7 +3509,7 @@ async fn integration_smoke_plan_implement_review_done() { // Run pipeline let interviewer = Arc::new(AutoApproveInterviewer); let dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new( make_full_registry(interviewer), @@ -3582,16 +3556,8 @@ async fn integration_smoke_plan_implement_review_done() { // Verify events let collected = events.lock().unwrap(); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) - ); + assert!(collected.iter().any(|e| e.event == "run.started")); + assert!(collected.iter().any(|e| e.event == "run.completed")); } // =========================================================================== @@ -3650,7 +3616,7 @@ async fn manager_loop_runs_child_engine_e2e() { registry.register("exit", Box::new(ExitHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3783,7 +3749,7 @@ async fn manager_loop_context_flows_e2e() { registry.register("setter", Box::new(SetterHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3855,7 +3821,7 @@ async fn manager_loop_child_dotfile_e2e() { registry.register("exit", Box::new(ExitHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3954,7 +3920,7 @@ async fn import_e2e_through_engine() { let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -4109,7 +4075,7 @@ async fn fidelity_default_is_compact() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4165,7 +4131,7 @@ async fn fidelity_graph_default_applied() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4217,7 +4183,7 @@ async fn fidelity_node_overrides_graph_default() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4275,7 +4241,7 @@ async fn fidelity_edge_overrides_node_and_graph() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4323,7 +4289,7 @@ async fn fidelity_full_produces_empty_preamble() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4381,7 +4347,7 @@ async fn fidelity_truncate_preamble_minimal() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4452,7 +4418,7 @@ async fn fidelity_summary_low_mode() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4518,7 +4484,7 @@ async fn fidelity_summary_medium_mode() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4584,7 +4550,7 @@ async fn fidelity_summary_high_mode() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4643,7 +4609,7 @@ async fn fidelity_full_sets_thread_id_in_context() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4713,7 +4679,7 @@ async fn fidelity_full_nodes_share_thread_id() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4793,7 +4759,7 @@ async fn fidelity_resume_degrades_full_to_summary_high() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4889,7 +4855,7 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4972,7 +4938,7 @@ async fn fidelity_resume_no_degrade_when_not_full() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5013,7 +4979,7 @@ async fn fidelity_stored_in_checkpoint_context() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5098,7 +5064,7 @@ async fn fidelity_precedence_multi_node_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5165,7 +5131,7 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5239,7 +5205,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { captures: captures_low.clone(), }), ); - let engine_low = WorkflowRunner::new(registry_low, Arc::new(EventEmitter::new()), local_env()); + let engine_low = + WorkflowRunner::new(registry_low, Arc::new(EventEmitter::default()), local_env()); let run_options_low = RunOptions { settings: FabroSettings::default(), run_dir: dir_low.path().to_path_buf(), @@ -5305,7 +5272,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { captures: captures_med.clone(), }), ); - let engine_med = WorkflowRunner::new(registry_med, Arc::new(EventEmitter::new()), local_env()); + let engine_med = + WorkflowRunner::new(registry_med, Arc::new(EventEmitter::default()), local_env()); let run_options_med = RunOptions { settings: FabroSettings::default(), run_dir: dir_med.path().to_path_buf(), @@ -5375,7 +5343,7 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5428,7 +5396,7 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5484,7 +5452,7 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5541,7 +5509,7 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5608,7 +5576,7 @@ async fn fidelity_from_parsed_dot_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5655,7 +5623,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5724,7 +5692,7 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5810,7 +5778,7 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6024,7 +5992,7 @@ mod real_llm { )))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6137,7 +6105,7 @@ mod real_llm { Box::new(AgentHandler::new(Some(make_llm_backend(client)))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6275,7 +6243,7 @@ mod real_llm { ); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6381,7 +6349,7 @@ mod real_llm { ))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6476,7 +6444,7 @@ async fn human_gate_freeform_only_routes_text() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6605,7 +6573,7 @@ async fn human_gate_freeform_with_fixed_choice_match() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6719,7 +6687,7 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6846,7 +6814,7 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6953,7 +6921,7 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7210,23 +7178,23 @@ impl HookTestRunner { fn emitter_with_events() -> ( Arc, - Arc>>, + Arc>>, ) { - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); (Arc::new(emitter), events) } fn engine_with_hooks(hooks: Vec) -> HookTestRunner { HookTestRunner { - emitter: Arc::new(EventEmitter::new()), + emitter: Arc::new(EventEmitter::default()), hook_runner: hook_runner_from_defs(hooks), } } fn engine_with_hooks_and_events( hooks: Vec, -) -> (HookTestRunner, Arc>>) { +) -> (HookTestRunner, Arc>>) { let (emitter, events) = emitter_with_events(); ( HookTestRunner { @@ -7336,17 +7304,13 @@ async fn hook_run_start_block_prevents_run() { // WorkflowRunStarted should still have been emitted (it fires before the hook) let captured = events.lock().unwrap(); assert!( - captured - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })), + captured.iter().any(|e| e.event == "run.started"), "WorkflowRunStarted should be emitted before hook blocks" ); // But no StageStarted — the run never reached node execution assert!( - !captured - .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageStarted { .. })), + !captured.iter().any(|e| e.event == "stage.started"), "No stage should start when RunStart hook blocks" ); } @@ -7427,8 +7391,13 @@ async fn hook_stage_start_skip_bypasses_node() { let stage_starts: Vec<_> = captured .iter() .filter(|e| { - matches!(e, WorkflowRunEvent::StageStarted { handler_type, .. } - if handler_type.as_deref() != Some("start") && handler_type.as_deref() != Some("exit")) + e.event == "stage.started" + && !matches!( + e.properties + .get("handler_type") + .and_then(|value| value.as_str()), + Some("start" | "exit") + ) }) .collect(); assert!( @@ -7750,9 +7719,10 @@ async fn hook_edge_selected_override_redirects_routing() { let captured = events.lock().unwrap(); let completed_nodes: Vec = captured .iter() - .filter_map(|e| match e { - WorkflowRunEvent::StageCompleted { node_id, .. } => Some(node_id.clone()), - _ => None, + .filter_map(|e| { + (e.event == "stage.completed") + .then(|| e.node_id.clone()) + .flatten() }) .collect(); assert!( @@ -8275,16 +8245,13 @@ async fn hooks_do_not_duplicate_workflow_events() { let captured = events.lock().unwrap(); // Count WorkflowRunStarted — should be exactly 1 - let run_started = captured - .iter() - .filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })) - .count(); + let run_started = captured.iter().filter(|e| e.event == "run.started").count(); assert_eq!(run_started, 1, "Should have exactly 1 WorkflowRunStarted"); // Count WorkflowRunCompleted — should be exactly 1 let run_completed = captured .iter() - .filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) + .filter(|e| e.event == "run.completed") .count(); assert_eq!( run_completed, 1, @@ -8292,10 +8259,7 @@ async fn hooks_do_not_duplicate_workflow_events() { ); // No WorkflowRunFailed - let run_failed = captured - .iter() - .filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunFailed { .. })) - .count(); + let run_failed = captured.iter().filter(|e| e.event == "run.failed").count(); assert_eq!(run_failed, 0, "Should have 0 WorkflowRunFailed"); } @@ -8342,7 +8306,7 @@ async fn arc_e2e_with_real_llm() { }); let run_dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: run_dir.path().to_path_buf(), @@ -8469,7 +8433,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -8665,7 +8629,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { @@ -8723,14 +8687,15 @@ async fn large_context_values_are_offloaded_to_artifact_store() { let evts = events.lock().unwrap(); let completed_event = evts .iter() - .find(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) + .find(|e| e.event == "run.completed") .expect("should have WorkflowRunCompleted event"); - if let WorkflowRunEvent::WorkflowRunCompleted { artifact_count, .. } = completed_event { - assert!( - *artifact_count > 0, - "artifact_count should be > 0, got {artifact_count}" - ); - } + let artifact_count = completed_event.properties["artifact_count"] + .as_u64() + .expect("run.completed should include artifact_count"); + assert!( + artifact_count > 0, + "artifact_count should be > 0, got {artifact_count}" + ); } // --------------------------------------------------------------------------- @@ -8880,7 +8845,11 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { registry.register("exit", Box::new(ExitHandler)); let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), remote_env.clone()); + let engine = WorkflowRunner::new( + registry, + Arc::new(EventEmitter::default()), + remote_env.clone(), + ); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -9009,7 +8978,7 @@ async fn node_dir_uses_visit_count_on_revisit() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -9291,7 +9260,7 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() { let node = Node::new("fix_code"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -9365,7 +9334,7 @@ async fn cli_backend_run_detects_changed_files() { let node = Node::new("implement"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -9400,7 +9369,7 @@ async fn cli_backend_run_with_codex_provider() { let node = Node::new("implement"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -9566,7 +9535,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() { .with_poll_interval(Duration::from_millis(10)); let node = Node::new("step"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let _ = env; // unused, just for the above struct @@ -9607,7 +9576,7 @@ async fn cli_backend_run_fails_on_unparseable_output() { let node = Node::new("step"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -9650,7 +9619,7 @@ async fn cli_backend_run_uses_node_model_override() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); backend @@ -9701,7 +9670,7 @@ async fn cli_backend_run_uses_node_provider_override() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); backend @@ -9736,7 +9705,7 @@ async fn cli_backend_run_writes_provider_used_json() { let node = Node::new("step"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); backend @@ -9789,7 +9758,7 @@ async fn backend_router_delegates_to_cli_for_cli_node() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = router @@ -9833,7 +9802,7 @@ async fn backend_router_delegates_to_api_for_normal_node() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = router @@ -9880,7 +9849,7 @@ async fn backend_router_delegates_to_cli_for_backend_attr() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = router @@ -9982,7 +9951,7 @@ async fn full_pipeline_with_cli_backend_node() { ); let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -10112,7 +10081,7 @@ async fn stylesheet_backend_property_routes_to_cli() { ); let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -10156,7 +10125,7 @@ async fn run_real_cli_test(provider: Provider, model: &str) { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -10382,7 +10351,7 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { // 4. Set up event collection and engine let run_dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let env: Arc = @@ -10421,16 +10390,13 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { let git_events: Vec<_> = events .iter() .filter_map(|e| { - if let WorkflowRunEvent::CheckpointCompleted { - node_id, - git_commit_sha: Some(sha), - .. - } = e - { - Some((node_id.clone(), sha.clone())) - } else { - None + if e.event != "checkpoint.completed" { + return None; } + Some(( + e.node_id.clone()?, + e.properties.get("git_commit_sha")?.as_str()?.to_string(), + )) }) .collect(); // work node gets a checkpoint commit (start is skipped, exit is terminal) @@ -10584,7 +10550,7 @@ async fn git_checkpoint_host_writes_shadow_branch() { serde_json::to_string(&run_record_json).unwrap(), ) .unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let env: Arc = Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone())); @@ -10774,7 +10740,7 @@ async fn parallel_git_branching_host_e2e() { // 4. Set up engine with FileWriterHandler for branches let run_dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let env: Arc = @@ -10944,7 +10910,7 @@ async fn parallel_git_branching_host_e2e() { let events = events.lock().unwrap(); let parallel_started: Vec<_> = events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::ParallelStarted { .. })) + .filter(|e| e.event == "parallel.started") .collect(); assert_eq!( parallel_started.len(), @@ -10954,7 +10920,7 @@ async fn parallel_git_branching_host_e2e() { let parallel_completed: Vec<_> = events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::ParallelCompleted { .. })) + .filter(|e| e.event == "parallel.completed") .collect(); assert_eq!( parallel_completed.len(), @@ -11044,7 +11010,7 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { graph.edges.push(Edge::new("work", "exit")); let run_dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let _events = collect_events(&emitter); let env: Arc = @@ -11434,7 +11400,7 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { )), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11480,7 +11446,7 @@ async fn e2e_circuit_breaker_custom_limit() { Box::new(DeterministicFailHandler::new("same error every time")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11519,7 +11485,7 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { registry.register("exit", Box::new(ExitHandler)); registry.register("test_handler", Box::new(TransientInfraFailHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11565,7 +11531,7 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11604,7 +11570,7 @@ async fn e2e_circuit_breaker_loop_restart() { Box::new(DeterministicFailHandler::new("verify step failed")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11665,7 +11631,7 @@ async fn e2e_failure_signature_persisted_in_context() { Box::new(DeterministicFailHandler::new("test assertion failed")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11728,7 +11694,7 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { registry.register("exit", Box::new(ExitHandler)); registry.register("hint_handler", Box::new(SignatureHintHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11783,7 +11749,7 @@ async fn e2e_signature_maps_persist_in_checkpoint() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11898,7 +11864,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { let dir = tempfile::tempdir().unwrap(); let graph = circuit_breaker_self_loop_graph(Some(3)); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let mut registry = HandlerRegistry::new(Box::new(StartHandler)); @@ -11928,9 +11894,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { let events = events.lock().unwrap(); // Should have at least WorkflowRunStarted and some StageFailed/StageCompleted events - let has_pipeline_started = events - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })); + let has_pipeline_started = events.iter().any(|e| e.event == "run.started"); assert!( has_pipeline_started, "WorkflowRunStarted event should be emitted" @@ -11941,11 +11905,11 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { // the stage event for that iteration is emitted, so we see limit-1 events. let stage_failed_count = events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::StageFailed { name, .. } if name == "work")) + .filter(|e| e.event == "stage.failed" && e.node_id.as_deref() == Some("work")) .count(); let stage_completed_count = events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::StageCompleted { name, .. } if name == "work")) + .filter(|e| e.event == "stage.completed" && e.node_id.as_deref() == Some("work")) .count(); let total_work_events = stage_completed_count + stage_failed_count; // With limit=3, the breaker fires on the 3rd failure before its event is emitted. @@ -11975,7 +11939,7 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12070,7 +12034,7 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { )), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12166,7 +12130,7 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { Box::new(ClassifiedFailHandler::always("deterministic")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12205,7 +12169,7 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { Box::new(ClassifiedFailHandler::always("structural")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12244,7 +12208,7 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { Box::new(ClassifiedFailHandler::always("budget_exhausted")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12283,7 +12247,7 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { Box::new(ClassifiedFailHandler::always("canceled")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12319,7 +12283,7 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { Box::new(ClassifiedFailHandler::always("compilation_loop")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12359,7 +12323,7 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { Box::new(ClassifiedFailHandler::succeed_on("transient_infra", 1)), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12457,7 +12421,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { let events = Arc::new(std::sync::Mutex::new(Vec::new())); let events_clone = events.clone(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); emitter.on_event(move |event| { events_clone.lock().unwrap().push(format!("{event:?}")); }); @@ -12517,7 +12481,7 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12562,7 +12526,7 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { registry.register("exit", Box::new(ExitHandler)); registry.register("slow", Box::new(SlowTestHandler { sleep_ms: 50 })); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12626,7 +12590,7 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { registry.register("exit", Box::new(ExitHandler)); registry.register("hanging", Box::new(HangingHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12720,7 +12684,7 @@ async fn asset_collection_local_sandbox_success() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new(registry, Arc::new(emitter), sandbox.clone()); @@ -12806,31 +12770,33 @@ async fn asset_collection_local_sandbox_success() { // Check that AssetCaptured events were emitted let captured_events = events.lock().unwrap(); - let asset_events: Vec<&WorkflowRunEvent> = captured_events + let asset_events: Vec<&RunEventEnvelope> = captured_events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::AssetCaptured { .. })) + .filter(|e| e.event == "asset.captured") .collect(); assert!( !asset_events.is_empty(), "should emit at least one AssetCaptured event" ); - if let WorkflowRunEvent::AssetCaptured { - path, - mime, - content_md5, - content_sha256, - bytes, - attempt, - .. - } = asset_events[0] - { - assert!(!path.is_empty()); - assert!(!mime.is_empty()); - assert_eq!(content_md5.len(), 32); - assert_eq!(content_sha256.len(), 64); - assert!(*bytes > 0); - assert_eq!(*attempt, 1); - } + let asset_event = asset_events[0]; + assert!(!asset_event.properties["path"].as_str().unwrap().is_empty()); + assert!(!asset_event.properties["mime"].as_str().unwrap().is_empty()); + assert_eq!( + asset_event.properties["content_md5"] + .as_str() + .unwrap() + .len(), + 32 + ); + assert_eq!( + asset_event.properties["content_sha256"] + .as_str() + .unwrap() + .len(), + 64 + ); + assert!(asset_event.properties["bytes"].as_u64().unwrap() > 0); + assert_eq!(asset_event.properties["attempt"].as_u64().unwrap(), 1); } /// Local sandbox: assets are still collected even when the handler fails. @@ -12848,7 +12814,7 @@ async fn asset_collection_local_sandbox_on_failure() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), sandbox.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), sandbox.clone()); let mut graph = Graph::new("AssetCollectionFailTest"); graph.attrs.insert( @@ -12939,7 +12905,7 @@ async fn asset_collection_docker_sandbox() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), sandbox.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), sandbox.clone()); let mut graph = Graph::new("DockerAssetTest"); graph.attrs.insert( @@ -13038,7 +13004,7 @@ async fn wait_timer_e2e() { let interviewer = Arc::new(AutoApproveInterviewer); let engine = WorkflowRunner::new( make_full_registry(interviewer), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions {