Refactor run event envelopes and subagent linking

This commit is contained in:
Bryan Helmkamp 2026-03-30 17:09:07 -04:00
parent bd82539516
commit f43fac10ce
No known key found for this signature in database
31 changed files with 2108 additions and 3534 deletions

View file

@ -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"

View file

@ -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.

View file

@ -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.
<Note>
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)
<final assistant message text>
```
### 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
<Note>
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.
</Note>
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.

View file

@ -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.
<Frame caption="The Stages tab shows the full agent conversation including tool calls and responses.">
<img src="/images/web/run-stages.png" alt="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.
<Frame caption="The Usage tab breaks down token counts and costs by stage and by model.">
<img src="/images/web/run-usage.png" alt="Fabro web UI run usage showing per-stage and per-model token and cost breakdown" />
</Frame>
## 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.

View file

@ -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:?}")),
);
}
_ => {}
}
}

View file

@ -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<SessionEvent> {
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));
}
}

View file

@ -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;

View file

@ -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<dyn Fn() -> Session + Send + Sync>;
pub type SubAgentEventCallback = Arc<dyn Fn(AgentEvent) + Send + Sync>;
#[derive(Debug, Clone)]
pub enum SubAgentCallbackEvent {
Lifecycle(AgentEvent),
Forwarded(SessionEvent),
}
pub type SubAgentEventCallback = Arc<dyn Fn(SubAgentCallbackEvent) + Send + Sync>;
#[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<Mutex<Vec<AgentEvent>>>) {
let events: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
fn captured_events() -> (
SubAgentEventCallback,
Arc<Mutex<Vec<SubAgentCallbackEvent>>>,
) {
let events: Arc<Mutex<Vec<SubAgentCallbackEvent>>> = 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

View file

@ -185,11 +185,6 @@ pub enum AgentEvent {
agent_id: String,
depth: usize,
},
SubAgentEvent {
agent_id: String,
depth: usize,
event: Box<Self>,
},
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<String>,
}
#[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<AgentEvent> = 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 {

View file

@ -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(),

View file

@ -314,8 +314,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
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<String>
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<String>
_ => 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<String>
"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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
.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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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<String>
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::<DateTime<Utc>>()
.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<String> {
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 {

View file

@ -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<String, Value>,
fields: &Map<String, Value>,
) -> Option<ProgressEvent> {
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<String, Value>, key: &str) -> Option<String> {
fields.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn display_field(fields: &Map<String, Value>, key: &str) -> Option<String> {
let value = fields.get(key)?;
fn prop_value<'a>(fields: &'a Map<String, Value>, 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<String, Value>, key: &str) -> Option<String> {
prop_value(fields, key)
.and_then(Value::as_str)
.map(str::to_owned)
}
fn prop_display_field(fields: &Map<String, Value>, key: &str) -> Option<String> {
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<String, Value>, key: &str) -> u64 {
fields.get(key).and_then(Value::as_u64).unwrap_or(0)
}
fn optional_u64_field(fields: &Map<String, Value>, key: &str) -> Option<u64> {
fields.get(key).and_then(Value::as_u64)
fn prop_u64_field(fields: &Map<String, Value>, key: &str) -> u64 {
prop_value(fields, key).and_then(Value::as_u64).unwrap_or(0)
}
fn i64_field(fields: &Map<String, Value>, key: &str) -> i64 {
fields.get(key).and_then(Value::as_i64).unwrap_or(0)
fn prop_optional_u64_field(fields: &Map<String, Value>, key: &str) -> Option<u64> {
prop_value(fields, key).and_then(Value::as_u64)
}
fn prop_i64_field(fields: &Map<String, Value>, key: &str) -> i64 {
prop_value(fields, key).and_then(Value::as_i64).unwrap_or(0)
}
fn f64_field(fields: &Map<String, Value>, key: &str) -> Option<f64> {
fields.get(key).and_then(Value::as_f64)
}
fn bool_field(fields: &Map<String, Value>, key: &str) -> bool {
fields.get(key).and_then(Value::as_bool).unwrap_or(false)
fn prop_f64_field(fields: &Map<String, Value>, key: &str) -> Option<f64> {
prop_value(fields, key).and_then(Value::as_f64)
}
fn prop_bool_field(fields: &Map<String, Value>, key: &str) -> bool {
prop_value(fields, key)
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn timestamp_field(fields: &Map<String, Value>, key: &str) -> Option<DateTime<Utc>> {
@ -566,8 +551,8 @@ fn timestamp_field(fields: &Map<String, Value>, key: &str) -> Option<DateTime<Ut
#[cfg(test)]
mod tests {
use fabro_agent::AgentEvent;
use fabro_workflow::event::WorkflowRunEvent;
use fabro_workflow::event::flatten_event;
use fabro_types::fixtures;
use fabro_workflow::event::{WorkflowRunEvent, canonicalize_event};
use super::*;
@ -575,6 +560,13 @@ mod tests {
value.as_object().cloned().expect("json object")
}
fn canonical_fields(event: &WorkflowRunEvent) -> (String, Map<String, Value>) {
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 {

View file

@ -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"];

View file

@ -51,14 +51,16 @@ pub fn extract_stage_durations(run_dir: &Path) -> HashMap<String, u64> {
let Ok(envelope) = serde_json::from_str::<serde_json::Value>(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;

View file

@ -649,6 +649,7 @@ mod tests {
event: AgentEvent::SessionStarted,
timestamp: SystemTime::now(),
session_id: "retro-test".into(),
parent_session_id: None,
})
.unwrap();

View file

@ -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<chrono::Utc>,
// Populated when running:
interviewer: Option<Arc<WebInterviewer>>,
event_tx: Option<broadcast::Sender<WorkflowRunEvent>>,
event_tx: Option<broadcast::Sender<RunEventEnvelope>>,
context: Option<Context>,
checkpoint: Option<Checkpoint>,
cancel_tx: Option<oneshot::Sender<()>>,
@ -627,7 +627,7 @@ async fn execute_run(state: Arc<AppState>, 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());

View file

@ -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(_)) => {}
_ => {

View file

@ -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::<crate::event::RunEventEnvelope>::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::<crate::event::RunEventEnvelope>::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());

File diff suppressed because it is too large Load diff

View file

@ -48,8 +48,6 @@ struct FileTracking {
last: Option<String>,
}
/// 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,
);

View file

@ -969,7 +969,7 @@ mod tests {
#[allow(unsafe_code)]
async fn ensure_cli_skips_install_when_present() {
let sandbox: Arc<dyn Sandbox> = 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());

View file

@ -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(".")),
)),

View file

@ -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);

View file

@ -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]

View file

@ -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);

View file

@ -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::<WorkflowRunEvent>::new()));
let events = Arc::new(std::sync::Mutex::new(Vec::<RunEventEnvelope>::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::<String>::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::<WorkflowRunEvent>::new()));
let events = Arc::new(std::sync::Mutex::new(Vec::<RunEventEnvelope>::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"));

View file

@ -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(),
)),

View file

@ -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,

View file

@ -80,6 +80,8 @@ pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option<Retro> {
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<dyn fabro_agent::Sandbox> = 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"));
}
}

View file

@ -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(),

File diff suppressed because it is too large Load diff