diff --git a/AGENTS.md b/AGENTS.md index 403711ce8..9539a80c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `cd apps/marketing && bunx vercel --prod` — deploy to Vercel (project: website, domain: fabro.sh) ### Dev servers -1. `fabro serve` — starts the Rust API server (demo mode is per-request via `X-Fabro-Demo: 1` header) +1. `fabro server start` — starts the Rust API server (demo mode is per-request via `X-Fabro-Demo: 1` header) 2. `cd apps/fabro-web && bun run dev` — starts the React dev server 3. Mintlify docs dev server (requires Docker — `mintlify dev` needs Node LTS which may not match the host): ``` @@ -50,7 +50,7 @@ The OpenAPI spec at `docs/api-reference/fabro-api.yaml` is the source of truth f Fabro is an AI-powered workflow orchestration platform. Workflows are defined as Graphviz graphs, where each node is a stage (agent, prompt, command, conditional, human, parallel, etc.) executed by the workflow engine. ### Rust crates (`lib/crates/`) -- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `init`, `install`, `ps`, `system prune`, `llm` +- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`, `llm` - **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, retros, and human-in-the-loop interactions - **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments - **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header @@ -61,7 +61,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as - **fabro-mcp** — Model Context Protocol client/server - **fabro-slack** — Slack integration (socket mode, blocks API) - **fabro-devcontainer** — Parses `.devcontainer/devcontainer.json` for container setup -- **fabro-git-storage** — Git-based storage with branch store and snapshots +- **fabro-checkpoint** — Git-based checkpoint storage with branch store and metadata branches - **fabro-telemetry** — CLI analytics (Segment) and crash reporting (Sentry), with anonymous IDs, command sanitization, and detached subprocess delivery - **fabro-util** — Shared utilities (redaction, terminal formatting) @@ -75,12 +75,13 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as - **OpenAPI-first** — `fabro-api.yaml` drives both Rust type generation (typify) and TypeScript client generation (openapi-generator) - **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed -## Logging and events +## Strategy docs When working on Rust crates, read the relevant strategy doc **before** making changes: -- **`files-internal/logging-strategy.md`** — read when adding `tracing` calls (`info!`, `debug!`, `warn!`, `error!`), working on error handling paths, or adding new operations that should be observable -- **`files-internal/events-strategy.md`** — read when adding or modifying `WorkflowRunEvent` variants, touching `EventEmitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types +- **`docs-internal/logging-strategy.md`** — read when adding `tracing` calls (`info!`, `debug!`, `warn!`, `error!`), working on error handling paths, or adding new operations that should be observable +- **`docs-internal/events-strategy.md`** — read when adding or modifying `WorkflowRunEvent` variants, touching `EventEmitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types +- **`files-internal/testing-strategy.md`** — read when adding or reorganizing tests, choosing between unit vs `tests/it`, deciding whether a test belongs in `cmd` vs `workflow` vs `scenario`, or deciding how to structure snapshots and fixtures ## Shell quoting in sandbox code @@ -92,6 +93,16 @@ When interpolating values into shell command strings (in `fabro-workflow`), alwa - **Functions**: import the parent module, call as `module::function()` — `use fabro_workflow::operations; operations::create(...)` - **No glob imports** in production code (`use foo::*`). Globs are acceptable in test modules and preludes. Enforced by clippy `wildcard_imports` lint. +## Snapshot tests (insta) + +Many CLI tests use `insta` inline snapshots. When a snapshot needs updating: + +1. Run `cargo insta pending-snapshots` to list what changed +2. Verify each pending snapshot is expected +3. Run `cargo insta accept` to accept all, or `cargo insta accept --snapshot ` for a specific one + +Never run `cargo insta accept` without first checking what's pending — it accepts *all* pending snapshots, which may include unrelated changes. + ## Testing workflows - `fabro run ` — run a workflow by name (resolves `fabro/workflows//workflow.toml`), e.g. `fabro run repl` diff --git a/Cargo.lock b/Cargo.lock index 185c806c0..4309f9aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -628,6 +628,15 @@ dependencies = [ "strsim 0.11.1", ] +[[package]] +name = "clap_complete" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.55" @@ -1437,6 +1446,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "fabro-checkpoint" +version = "0.176.2" +dependencies = [ + "chrono", + "fabro-types", + "git2", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "fabro-cli" version = "0.176.2" @@ -1448,6 +1471,7 @@ dependencies = [ "base64", "chrono", "clap", + "clap_complete", "cli-table", "console 0.15.11", "core-foundation 0.9.4", @@ -1456,9 +1480,9 @@ dependencies = [ "dirs", "dotenvy", "fabro-agent", + "fabro-checkpoint", "fabro-config", "fabro-devcontainer", - "fabro-git-storage", "fabro-github", "fabro-graphviz", "fabro-hooks", @@ -1571,17 +1595,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "fabro-git-storage" -version = "0.176.2" -dependencies = [ - "git2", - "tempfile", - "thiserror 2.0.18", - "tracing", - "walkdir", -] - [[package]] name = "fabro-github" version = "0.176.2" @@ -1983,10 +1996,10 @@ dependencies = [ "dirs", "dotenvy", "fabro-agent", + "fabro-checkpoint", "fabro-config", "fabro-core", "fabro-devcontainer", - "fabro-git-storage", "fabro-github", "fabro-graphviz", "fabro-hooks", diff --git a/Cargo.toml b/Cargo.toml index ddc1772ac..6b31e1d56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } ulid = "1" -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4", "v7"] } rand = "0.8" dotenvy = "0.15" futures = "0.3" @@ -26,6 +26,7 @@ base64 = "0.22" bytes = "1" tokio-util = "0.7" clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4" jsonschema = { version = "0.42", default-features = false } chrono = { version = "0.4", features = ["clock"] } bollard = "0.18" diff --git a/README.md b/README.md index 4ccea8c5a..7a9fe2e39 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Then initialize Fabro in your project: fabro install # one-time setup cd my-project -fabro init # per project +fabro repo init # per project ``` --- diff --git a/docs-internal/events-strategy.md b/docs-internal/events-strategy.md index 70c0c13ce..88a3e40b9 100644 --- a/docs-internal/events-strategy.md +++ b/docs-internal/events-strategy.md @@ -1,312 +1,164 @@ # Fabro Events Strategy -Fabro emits structured **workflow run events** during execution for observability. Events write to `progress.jsonl` (one JSON object per line) and `live.json` (latest event snapshot) inside the run's log directory. Events are the primary record of what happened during a run — they feed the retro system, CLI verbose output, `fabro attach`, `fabro logs`, and live monitoring. +Fabro emits structured **workflow run events** during execution for observability. Events are the durable audit trail for a run: they drive `progress.jsonl`, `live.json`, the run store, SSE streaming, CLI progress rendering, and retro analysis. -Events are distinct from tracing logs (see `logging-strategy.md`). Tracing is developer diagnostics; events are the structured audit trail consumed by tooling. +Events are distinct from tracing logs. Tracing is developer diagnostics; events are product-facing state transitions and activity records that other systems consume. -Detached runs rely on this distinction. If a warning or error needs to be visible to users after reattach, emit a `WorkflowRunEvent` rather than only writing to stderr/`detach.log`. +Detached runs rely on this distinction. If something needs to be visible after reattach, emit a `WorkflowRunEvent` rather than only logging to stderr or `detach.log`. ## Architecture -``` -Engine/Handler → WorkflowRunEvent enum → EventEmitter - ├─ .trace() → tracing log line (automatic) - ├─ flatten_event() → progress.jsonl + live.json - └─ on_event() callbacks → CLI output, cost tracking, etc. +```text +Engine/Handler -> WorkflowRunEvent -> EventEmitter::emit() + |- trace(raw event) + |- canonicalize -> RunEventEnvelope + `- on_event(&RunEventEnvelope) + |- progress.jsonl + live.json + |- run store + |- SSE + `- CLI / tests / metrics listeners ``` -**Three layers:** +The canonical envelope is built exactly once in `fabro-workflow/src/event.rs`. -1. **Rust enum** (`WorkflowRunEvent` in `event.rs`) — the source of truth for event structure. Variants use Rust naming and types. `AgentEvent` and `SandboxEvent` from `fabro-agent` are wrapped as `Agent { stage, event }` and `Sandbox { event }`. +- `WorkflowRunEvent` remains the internal typed source of truth. +- `EventEmitter` owns an immutable `run_id` and converts typed events into `RunEventEnvelope`. +- Every listener receives `&RunEventEnvelope`, not `&WorkflowRunEvent`. +- Bypass paths that cannot go through the emitter must call `canonicalize_event()` once and reuse the same envelope for every sink. -2. **Flattening** (`flatten_event()` in `event.rs`) — serializes the enum via serde, then restructures nested/tagged variants into `(event_name, flat_fields_map)`. Wrapper variants use dot notation: `Agent.ToolCallStarted`, `Sandbox.Initializing`. +## Canonical Envelope -3. **Field renaming** (`rename_fields()` in `event.rs`) — post-processes the flat fields to give them self-describing names for JSONL output. This avoids changing the Rust enum while making the external format unambiguous. - -## JSONL Envelope - -Every line in `progress.jsonl` has three envelope fields, then the event's own fields merged at the top level: +Each line in `progress.jsonl` is a `RunEventEnvelope`: ```json -{"ts":"2025-06-15T12:00:00.123Z","run_id":"01J...","event":"StageCompleted","node_id":"plan","node_label":"Plan","stage_index":0,"duration_ms":5000,"status":"success",...} -``` - -| Envelope field | Type | Description | -|---|---|---| -| `ts` | ISO 8601 string | UTC timestamp with millisecond precision | -| `run_id` | string | ULID for this workflow run | -| `event` | string | Event name (matches Rust variant, dot-separated for wrapped types) | - -The envelope is built in `fabro-workflow/src/event.rs` by `build_event_envelope()`, and file logging is handled by `ProgressLogger`. Field names from the event that collide with envelope keys (`ts`, `run_id`, `event`) are dropped — the `run_id` from `WorkflowRunStarted` populates the envelope itself. - -## Run Completion Contract - -`status.json` is the authoritative completion signal for detached runs. Write a terminal `RunStatus` only after all post-run work is complete, including retro generation, finalize-commit work, pull request creation, and sandbox cleanup. `conclusion.json` should be written at that same final point so `attach`, `wait`, and summary rendering all agree on when the run is actually finished. - -## Node Terminology - -- **`node_id`** — programmatic identifier (the id from the DOT graph). Stable, used for matching. -- **`node_label`** — display name (from the DOT `label` attribute, defaults to `node_id`). Human-readable. - -Every event that references a graph node should include both. Stage events carry both from the Rust enum. Events that only have an id (Agent, ParallelBranch, etc.) get `node_label` defaulted to `node_id` by `rename_fields()`. - -## Field Naming Conventions - -### Rules - -1. **Self-describing** — a field name should be unambiguous without knowing the event type. Use `node_id` not `name`, `stage_index` not `index`, `sandbox_provider` not `provider`. -2. **`_id` suffix** for identifiers — `node_id`, `from_node_id`, `to_node_id`, `start_node_id`, `tool_call_id`, `agent_id`. -3. **`_ms` suffix** for durations — `duration_ms`, `delay_ms`. Always milliseconds. -4. **`_count` suffix** for counts — `branch_count`, `command_count`, `tool_call_count`. -5. **No prefix** for fields that are already unambiguous — `error`, `status`, `command`, `question`, `answer`, `model`. -6. **`snake_case`** for all field names. - -### Rename Table (Rust enum → JSONL) - -The Rust enum uses short field names for ergonomics. `rename_fields()` transforms them for the JSONL output: - -| Rust field | JSONL field | Events | Reason | -|---|---|---|---| -| `name` | `workflow_name` | WorkflowRunStarted | Disambiguate | -| `name` | `node_label` | Stage* | Display name | -| `name` | `snapshot_name` | Sandbox.Snapshot* | Disambiguate | -| `index` | `stage_index` | Stage* | Disambiguate | -| `index` | `branch_index` | ParallelBranch* | Disambiguate | -| `index` | `command_index` | SetupCommand*, SetupFailed, DevcontainerLifecycleCommand*, DevcontainerLifecycleFailed | Disambiguate | -| `stage` | `node_id` | Agent.*, Interview*, Prompt | Unify terminology | -| `branch` | `node_id` | ParallelBranch* | Consistent | -| `node` | `node_id` | StallWatchdogTimeout | Consistent | -| `from_node` | `from_node_id` | EdgeSelected, LoopRestart | `_id` suffix | -| `to_node` | `to_node_id` | EdgeSelected, LoopRestart | `_id` suffix | -| `start_node` | `start_node_id` | SubgraphStarted | `_id` suffix | -| `provider` | `sandbox_provider` | Sandbox.* | Disambiguate | -| `text` | `prompt_text` | Prompt | Disambiguate | -| _(inserted)_ | `node_label` | Agent.*, ParallelBranch*, etc. | Defaults to `node_id` | - -Fields not in this table pass through unchanged. - -## Adding a New Event - -### Step 1: Add to the Rust enum - -Add a variant to `WorkflowRunEvent` in `event.rs`. Use the short Rust field names (they'll be renamed in step 3). - -```rust -MyNewEvent { - node_id: String, // if it references a graph node - name: String, // if it has a display label (will become node_label) - duration_ms: u64, - // ... -}, -``` - -For events that wrap `AgentEvent` or `SandboxEvent`, add the variant to those enums in `fabro-agent` instead — they're automatically wrapped by the existing `Agent { stage, event }` and `Sandbox { event }` variants. - -### Step 2: Add a trace() match arm - -Add a match arm in `WorkflowRunEvent::trace()`. Choose the tracing level per `logging-strategy.md`: -- INFO for lifecycle boundaries (started/completed at the workflow level) -- DEBUG for individual steps (stage started, tool call, etc.) -- WARN for retries and degraded behavior -- ERROR for terminal failures - -```rust -Self::MyNewEvent { node_id, duration_ms, .. } => { - debug!(node_id, duration_ms, "My new event happened"); +{ + "id": "01960d0c-5d16-7d6e-8f61-9fd6f4a532b5", + "ts": "2026-03-30T12:00:01.000Z", + "run_id": "01JQ...", + "event": "agent.tool.started", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "node_id": "code", + "node_label": "Code", + "properties": { + "tool_name": "read_file", + "tool_call_id": "call_1", + "arguments": {"path": "src/main.rs"} + } } ``` -### Step 3: Add rename rules (if needed) +Always-present fields: -If your event has fields that need renaming (ambiguous `name`, `index`, `stage`, etc.), add a branch in `rename_fields()` in `event.rs`. If your event references a graph node and only has `node_id`, call `default_node_label(fields)` to insert `node_label`. - -### Step 4: Emit from engine or handler - -Emit via the `EventEmitter`: - -```rust -self.services.emitter.emit(&WorkflowRunEvent::MyNewEvent { - node_id: node.id.clone(), - name: node.label().to_string(), - duration_ms: elapsed, -}); -``` - -### Step 5: Update format_event_summary - -Add a match arm in `format_event_summary()` in `cli/mod.rs` for `-v` verbose output: - -```rust -WorkflowRunEvent::MyNewEvent { node_id, duration_ms, .. } => { - format!("[MY_NEW_EVENT] node_id={node_id} duration={duration_ms}ms") -} -``` - -### Step 6: Update tests - -- Add a serialization test in `event.rs` (serde round-trip) -- Add a `rename_fields` test if you added rename rules -- Update integration test patterns in `integration.rs` if matching on the new event - -## Complete Event Reference - -### Workflow lifecycle - -| Event | JSONL fields | -|---|---| -| `WorkflowRunStarted` | `workflow_name`, `run_id`, `base_branch`?, `base_sha`?, `run_branch`?, `worktree_dir`? | -| `WorkflowRunCompleted` | `duration_ms`, `artifact_count`, `total_cost`?, `final_git_commit_sha`? | -| `WorkflowRunFailed` | `error`, `duration_ms`, `git_commit_sha`? | -| `RunNotice` | `level`, `code`, `message` | - -### Stage execution - -| Event | JSONL fields | -|---|---| -| `StageStarted` | `node_id`, `node_label`, `stage_index`, `handler_type`?, `attempt`, `max_attempts` | -| `StageCompleted` | `node_id`, `node_label`, `stage_index`, `duration_ms`, `status`, `preferred_label`?, `suggested_next_ids`, `usage`?, `failure_reason`?, `notes`?, `files_touched`, `attempt`, `max_attempts`, `failure_class`? | -| `StageFailed` | `node_id`, `node_label`, `stage_index`, `error`, `will_retry`, `failure_reason`?, `failure_class`? | -| `StageRetrying` | `node_id`, `node_label`, `stage_index`, `attempt`, `max_attempts`, `delay_ms` | - -### Parallel execution - -| Event | JSONL fields | -|---|---| -| `ParallelStarted` | `branch_count`, `join_policy` | -| `ParallelBranchStarted` | `node_id`, `node_label`, `branch_index` | -| `ParallelBranchCompleted` | `node_id`, `node_label`, `branch_index`, `duration_ms`, `status` | -| `ParallelCompleted` | `duration_ms`, `success_count`, `failure_count` | - -### Graph navigation - -| Event | JSONL fields | -|---|---| -| `EdgeSelected` | `from_node_id`, `to_node_id`, `label`?, `condition`? | -| `LoopRestart` | `from_node_id`, `to_node_id` | -| `SubgraphStarted` | `node_id`, `node_label`, `start_node_id` | -| `SubgraphCompleted` | `node_id`, `node_label`, `steps_executed`, `status`, `duration_ms` | - -### Checkpoints and git - -| Event | JSONL fields | -|---|---| -| `CheckpointCompleted` | `node_id`, `node_label`, `status`, `git_commit_sha` (optional) | -| `CheckpointFailed` | `node_id`, `node_label`, `error` | -| `GitCommit` | `node_id` (optional), `node_label` (optional), `sha` | -| `GitPush` | `branch`, `success` | -| `GitBranch` | `branch`, `sha` | -| `GitWorktreeAdd` | `path`, `branch` | -| `GitWorktreeRemove` | `path` | -| `GitFetch` | `branch`, `success` | -| `GitReset` | `sha` | - -### Human interaction - -| Event | JSONL fields | -|---|---| -| `InterviewStarted` | `question`, `node_id`, `node_label`, `question_type` | -| `InterviewCompleted` | `question`, `answer`, `duration_ms` | -| `InterviewTimeout` | `question`, `node_id`, `node_label`, `duration_ms` | -| `Prompt` | `node_id`, `node_label`, `prompt_text` | - -### Setup - -| Event | JSONL fields | -|---|---| -| `SetupStarted` | `command_count` | -| `SetupCommandStarted` | `command`, `command_index` | -| `SetupCommandCompleted` | `command`, `command_index`, `exit_code`, `duration_ms` | -| `SetupCompleted` | `duration_ms` | -| `SetupFailed` | `command`, `command_index`, `exit_code`, `stderr` | - -### Devcontainer - -| Event | JSONL fields | -|---|---| -| `DevcontainerResolved` | `dockerfile_lines`, `environment_count`, `lifecycle_command_count`, `workspace_folder` | -| `DevcontainerLifecycleStarted` | `phase`, `command_count` | -| `DevcontainerLifecycleCommandStarted` | `phase`, `command`, `command_index` | -| `DevcontainerLifecycleCommandCompleted` | `phase`, `command`, `command_index`, `exit_code`, `duration_ms` | -| `DevcontainerLifecycleCompleted` | `phase`, `duration_ms` | -| `DevcontainerLifecycleFailed` | `phase`, `command`, `command_index`, `exit_code`, `stderr` | - -### Stall detection - -| Event | JSONL fields | -|---|---| -| `StallWatchdogTimeout` | `node_id`, `node_label`, `idle_seconds` | - -### Agent events (prefixed `Agent.`) - -All agent events include `node_id` and `node_label`. - -| Event | Additional JSONL fields | -|---|---| -| `Agent.SessionStarted` | _(none)_ | -| `Agent.SessionEnded` | _(none)_ | -| `Agent.UserInput` | `text` | -| `Agent.AssistantTextStart` | _(none)_ | -| `Agent.AssistantMessage` | `text`, `model`, `usage` (object), `tool_call_count` | -| `Agent.TextDelta` | `delta` | -| `Agent.ToolCallStarted` | `tool_name`, `tool_call_id`, `arguments` | -| `Agent.ToolCallOutputDelta` | `delta` | -| `Agent.ToolCallCompleted` | `tool_name`, `tool_call_id`, `output`, `is_error` | -| `Agent.Error` | `error` | -| `Agent.Warning` | `kind`, `message`, `details` | -| `Agent.LoopDetected` | _(none)_ | -| `Agent.TurnLimitReached` | `max_turns` | -| `Agent.SkillExpanded` | `skill_name` | -| `Agent.SteeringInjected` | `text` | -| `Agent.CompactionStarted` | `estimated_tokens`, `context_window_size` | -| `Agent.CompactionCompleted` | `original_turn_count`, `preserved_turn_count`, `summary_token_estimate`, `tracked_file_count` | -| `Agent.LlmRetry` | `provider`, `model`, `attempt`, `delay_secs`, `error` | -| `Agent.SubAgentSpawned` | `agent_id`, `depth`, `task` | -| `Agent.SubAgentCompleted` | `agent_id`, `depth`, `success`, `turns_used` | -| `Agent.SubAgentFailed` | `agent_id`, `depth`, `error` | -| `Agent.SubAgentClosed` | `agent_id`, `depth` | -| `Agent.SubAgentEvent.*` | `agent_id`, `depth`, `nested_event` (JSON) | -| `Agent.McpServerReady` | `server_name`, `tool_count` | -| `Agent.McpServerFailed` | `server_name`, `error` | - -### Sandbox events (prefixed `Sandbox.`) - -| Event | JSONL fields | -|---|---| -| `Sandbox.Initializing` | `sandbox_provider` | -| `Sandbox.Ready` | `sandbox_provider`, `duration_ms` | -| `Sandbox.InitializeFailed` | `sandbox_provider`, `error`, `duration_ms` | -| `Sandbox.CleanupStarted` | `sandbox_provider` | -| `Sandbox.CleanupCompleted` | `sandbox_provider`, `duration_ms` | -| `Sandbox.CleanupFailed` | `sandbox_provider`, `error` | -| `Sandbox.SnapshotPulling` | `snapshot_name` | -| `Sandbox.SnapshotPulled` | `snapshot_name`, `duration_ms` | -| `Sandbox.SnapshotEnsuring` | `snapshot_name` | -| `Sandbox.SnapshotCreating` | `snapshot_name` | -| `Sandbox.SnapshotReady` | `snapshot_name`, `duration_ms` | -| `Sandbox.SnapshotFailed` | `snapshot_name`, `error` | -| `Sandbox.GitCloneStarted` | `url`, `branch` | -| `Sandbox.GitCloneCompleted` | `url`, `duration_ms` | -| `Sandbox.GitCloneFailed` | `url`, `error` | - -## Error Fields - -Error information is stored as plain strings. The `error` field contains the human-readable message; `failure_class` contains the machine-readable classification. - -| Field | Type | Events | Purpose | -|---|---|---|---| -| `error` | string | StageFailed, WorkflowRunFailed, Agent.Error, Agent.LlmRetry, Sandbox.*Failed, etc. | Human-readable error message | -| `failure_reason` | string? | StageFailed, StageCompleted | Outcome-level failure description | -| `failure_class` | string? | StageFailed, StageCompleted | Machine classification: `transient_infra`, `deterministic`, `budget_exhausted`, `compilation_loop`, `canceled`, `structural` | - -`failure_class` is derived from `FabroError::failure_class()` for handler errors, or from handler hints in `context_updates["failure_class"]` for outcome-based failures. See `error.rs` for the classification logic. - -## Consumers - -| Consumer | Reads | Purpose | +| Field | Type | Notes | |---|---|---| -| `retro.rs` `extract_stage_durations()` | `node_label`, `duration_ms` from `StageCompleted` | Build retro report | -| `cli/run.rs` non-verbose listener | `name`, `duration_ms`, `status`, `usage` from `StageCompleted/Failed` | CLI progress output | -| `cli/mod.rs` `format_event_summary()` | All events | `-v` verbose output | -| `cli/run.rs` cost accumulator | `usage` from `StageCompleted` | Total cost tracking | -| `cli/run.rs` git SHA tracker | `git_commit_sha` from `CheckpointCompleted` | Final SHA for `conclusion.json` | -| External tooling | `progress.jsonl` | Live monitoring, dashboards | +| `id` | string | UUIDv7 event id | +| `ts` | string | UTC timestamp with millisecond precision | +| `run_id` | string | Workflow run id | +| `event` | string | Lowercase dot-notation event name | + +Optional top-level fields: + +| Field | When present | +|---|---| +| `session_id` | Agent/session events | +| `parent_session_id` | Forwarded child-session events | +| `node_id` | Events tied to a graph node or branch | +| `node_label` | Display label for `node_id`; omitted when not applicable | + +Everything else lives inside `properties`. + +Important rules: + +- Optional envelope fields are omitted, not serialized as `null`. +- Event-specific fields do not get flattened into the top level. +- `EventPayload` validation requires `id`, `ts`, `run_id`, and `event`. + +## Naming + +The external event name is lowercase dot notation, for example: + +- `run.started` +- `stage.completed` +- `agent.tool.started` +- `sandbox.ready` +- `parallel.branch.completed` + +`event_name()` in `event.rs` is exhaustive. Do not use wildcard fallthroughs when adding new variants. + +## Node And Session Metadata + +`node_id` is the stable graph identifier. `node_label` is the human-facing display name. Stage events should surface both through the envelope when applicable. + +Agent events now use explicit session links: + +- `session_id` identifies the session that originally emitted the event. +- `parent_session_id` identifies the immediate parent session for forwarded child events. +- Nested sub-agents preserve immediate parentage across boundaries. + +`AgentEvent::SubAgentEvent` no longer exists. Child activity is forwarded as normal agent events with session linkage in the envelope. + +## Direct-Write Paths + +Most events flow through `EventEmitter::emit()`. The remaining direct-write paths must use: + +1. `canonicalize_event(run_id, event)` +2. Serialize and redact once +3. Reuse that exact serialized envelope for every sink + +Never canonicalize the same logical event twice if multiple sinks receive it. + +## Adding A New Event + +### 1. Add the typed event + +Add a variant to `WorkflowRunEvent`, `AgentEvent`, or `SandboxEvent` as appropriate. + +### 2. Add tracing + +Extend `WorkflowRunEvent::trace()` so the raw event is observable in tracing output. + +### 3. Add an external name + +Extend `event_name()` with the new lowercase dot-notation string. + +### 4. Map envelope fields + +Update `extract_envelope_fields()`: + +- Move `node_id`, `node_label`, `session_id`, and `parent_session_id` into the envelope when appropriate. +- Keep event-specific data in `properties`. +- Flatten structured failure details into explicit property keys when needed. + +### 5. Emit it + +Prefer `EventEmitter::emit(&WorkflowRunEvent::...)`. + +Use `canonicalize_event()` only for true bypass paths. + +### 6. Update consumers + +Check: + +- CLI progress parsing +- `fabro logs` +- retro duration extraction +- store validation +- tests or fixtures that inspect event names or fields + +## Consumer Guidance + +When writing listeners: + +- Match on `envelope.event`, not Rust variant names. +- Read event payload from `envelope.properties`. +- Read stage/branch identity from `node_id` and `node_label`. +- Read agent hierarchy from `session_id` and `parent_session_id`. + +Do not rebuild or mutate the envelope in downstream listeners. + +## Bypass And Persistence Guarantees + +`progress.jsonl`, the run store, and SSE should reflect the same canonical envelope bytes after redaction. + +`status.json` remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished. diff --git a/docs-internal/product/current-state.md b/docs-internal/product/current-state.md index 739d0f7f3..b878e4178 100644 --- a/docs-internal/product/current-state.md +++ b/docs-internal/product/current-state.md @@ -7,7 +7,7 @@ This snapshot is intentionally brief and omits volatile counts, benchmarks, and Fabro currently presents as: - a CLI for defining and running workflows -- an API server via `fabro serve` +- an API server via `fabro server start` - a React web app for monitoring runs and inspecting workflows - a docs site and example workflows diff --git a/docs-internal/product/personas.md b/docs-internal/product/personas.md index c1ed33a76..9d933e5a1 100644 --- a/docs-internal/product/personas.md +++ b/docs-internal/product/personas.md @@ -6,7 +6,7 @@ Fabro is for expert engineers who want to encode software processes as workflows - **Solo expert engineer**: runs workflows locally to automate planning, implementation, testing, and review loops. - **Tech lead or platform engineer**: defines shared workflows, model routing rules, and quality gates for a team. -- **Infrastructure engineer**: deploys `fabro serve`, configures sandboxes and auth, and keeps the system reliable. +- **Infrastructure engineer**: deploys `fabro server start`, configures sandboxes and auth, and keeps the system reliable. ## Good fits diff --git a/docs/administration/deploy-server.mdx b/docs/administration/deploy-server.mdx index 643d5183d..c946b27f5 100644 --- a/docs/administration/deploy-server.mdx +++ b/docs/administration/deploy-server.mdx @@ -7,7 +7,7 @@ description: "Run Fabro as an API server with a web UI, concurrent runs, and tea Server mode is in private early access. Contact [bryan@qlty.sh](mailto:bryan@qlty.sh) if you're interested in trying it. -Fabro has two modes: **standalone** and **server**. Standalone mode (`fabro run`) executes a single workflow synchronously in your terminal. Server mode (`fabro serve`) starts an HTTP API that queues runs, streams events, and serves a web UI — so you can close your laptop and let workflows run. +Fabro has two modes: **standalone** and **server**. Standalone mode (`fabro run`) executes a single workflow synchronously in your terminal. Server mode (`fabro server start`) starts an HTTP API that queues runs, streams events, and serves a web UI — so you can close your laptop and let workflows run. Both modes use the same workflow engine, the same Graphviz files, and the same sandbox providers. The difference is how you interact with them. @@ -15,7 +15,7 @@ Both modes use the same workflow engine, the same Graphviz files, and the same s | | Standalone | Server | |---|---|---| -| **Command** | `fabro run workflow.fabro` | `fabro serve` | +| **Command** | `fabro run workflow.fabro` | `fabro server start` | | **Best for** | Local development, one-off runs, CI/CD | Production, team use, running at scale | | **Execution** | Synchronous, one run per process | Asynchronous, queued with configurable concurrency | | **Human-in-the-loop** | Terminal prompts | Web UI or HTTP endpoints | @@ -27,13 +27,13 @@ Both modes use the same workflow engine, the same Graphviz files, and the same s ## Starting the server ```bash -fabro serve +fabro server start ``` This starts the API on `127.0.0.1:3000` by default. To also run the web UI: ```bash -fabro serve # API on port 3000 +fabro server start # API on port 3000 cd apps/fabro-web && bun run dev # Web UI on port 5173 ``` diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index 085fa0599..44bcafe53 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -5,7 +5,7 @@ description: "Server config file, CLI overrides, and environment variables" ## Config file -The server config file at `~/.fabro/server.toml` controls how `fabro serve` behaves — API binding, authentication, run defaults, and more. The [Quick Start](/getting-started/quick-start) doesn't require one, but production deployments should configure it explicitly. +The server config file at `~/.fabro/server.toml` controls how `fabro server start` behaves — API binding, authentication, run defaults, and more. The [Quick Start](/getting-started/quick-start) doesn't require one, but production deployments should configure it explicitly. ### Full reference @@ -83,7 +83,7 @@ default_branch = "main" ### CLI overrides -Several `server.toml` settings can be overridden via `fabro serve` flags: +Several `server.toml` settings can be overridden via `fabro server start` flags: | Flag | Default | Description | |---|---|---| @@ -123,7 +123,7 @@ The CLI can also set `[git.author]` in `user.toml` to override the server defaul ### `[git.webhooks]` section -Enable automatic GitHub webhook delivery via Tailscale funnel. When configured, `fabro serve` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. +Enable automatic GitHub webhook delivery via Tailscale funnel. When configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. | Key | Description | Values | |---|---|---| diff --git a/docs/administration/troubleshooting.mdx b/docs/administration/troubleshooting.mdx index 93f149083..e1327a515 100644 --- a/docs/administration/troubleshooting.mdx +++ b/docs/administration/troubleshooting.mdx @@ -28,7 +28,7 @@ It checks: **Sandbox creation failures** — For Docker: ensure the Docker daemon is running and the configured image exists. For Daytona: verify `DAYTONA_API_KEY` is set and the `gh` CLI is authenticated. For Exe: verify your SSH keys are configured for `exe.dev` and that `ssh exe.dev` connects successfully. -**Port already in use** — Change the port with `fabro serve --port 3001` or stop the conflicting process. +**Port already in use** — Change the port with `fabro server start --port 3001` or stop the conflicting process. **SSE streams disconnecting** — If using a reverse proxy, ensure buffering is disabled and the connection timeout is long enough for workflow runs. See the [reverse proxy example](/administration/deployment#binding-and-tls). diff --git a/docs/agents/subagents.mdx b/docs/agents/subagents.mdx index 2d669184c..cc3741ed1 100644 --- a/docs/agents/subagents.mdx +++ b/docs/agents/subagents.mdx @@ -3,7 +3,7 @@ title: "Sub-agents" description: "Delegate subtasks to child agent sessions" --- -An agent can spawn **sub-agents** to delegate work to independent child sessions. Each sub-agent gets its own LLM session and tool access, runs concurrently with the parent, and returns its result when finished. This is useful for parallelizing research, isolating risky operations, or breaking complex tasks into focused pieces. +An agent can spawn **sub-agents** to delegate work to independent child sessions. Each sub-agent gets its own LLM session and tool access, runs concurrently with the parent, and returns its result when finished. Sub-agents are only available with the [API backend](/core-concepts/agents#api-backend-default) (the default). Agents using the [CLI backend](/core-concepts/agents#cli-backend) cannot spawn sub-agents. @@ -16,124 +16,84 @@ Sub-agent management is exposed through four built-in tools: | Tool | Description | |---|---| | `spawn_agent` | Create a new sub-agent with a task prompt | -| `send_input` | Send a follow-up message to a running sub-agent | +| `send_input` | Send follow-up input to a running sub-agent | | `wait` | Block until a sub-agent completes and return its result | | `close_agent` | Cancel and remove a running sub-agent | -These tools are registered automatically when the session starts. They inherit the parent's permissions -- no additional approval is needed for sub-agent tool calls. - -### spawn_agent - -Creates a new sub-agent session and starts it working on the given task. - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `task` | string | yes | The task description for the sub-agent | -| `working_dir` | string | no | Working directory for the sub-agent | -| `model` | string | no | Model to use for the sub-agent | -| `max_turns` | integer | no | Maximum number of turns (default: unlimited) | - -Returns the `agent_id` string used to reference this sub-agent in subsequent calls. - -### send_input - -Sends a follow-up message to a running sub-agent. The message is queued and processed by the sub-agent on its next turn. - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `agent_id` | string | yes | The ID returned by `spawn_agent` | -| `message` | string | yes | The message to send | - -### wait - -Blocks until the specified sub-agent completes, then returns its result. The result includes whether the sub-agent succeeded, how many turns it used, and the final text output. - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `agent_id` | string | yes | The ID returned by `spawn_agent` | - -Returns a formatted string: -``` -Agent completed (success: true, turns: 12) - - -``` - -### close_agent - -Cancels a running sub-agent and removes it. Use this to clean up sub-agents that are no longer needed. - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `agent_id` | string | yes | The ID returned by `spawn_agent` | +These tools are registered automatically when the session starts. They inherit the parent's permissions. ## Session isolation -Each sub-agent runs in its own independent session: +Each sub-agent runs in its own session: -- **Own LLM conversation** -- the sub-agent has a fresh conversation history starting from the task prompt. It does not see the parent's prior conversation. -- **Own tool access** -- the sub-agent has the same tools as the parent (shell, file editing, grep, etc.) and can use them independently. -- **Concurrent execution** -- after `spawn_agent`, the parent continues working immediately. The sub-agent runs in a background task. Use `wait` to collect the result when needed. +- **Own conversation** -- the child starts with a fresh LLM history +- **Own tool access** -- the child can use the same tools as the parent +- **Concurrent execution** -- the parent can continue working before calling `wait` -The parent can spawn multiple sub-agents and let them work in parallel, then `wait` on each to collect results: - -``` -1. spawn_agent("Research authentication patterns") -> agent_a -2. spawn_agent("Analyze the test suite structure") -> agent_b -3. ... parent does its own work ... -4. wait(agent_a) -> result_a -5. wait(agent_b) -> result_b -6. Synthesize both results -``` +The parent can spawn multiple sub-agents and synchronize with them later. ## Depth limits -Sub-agents can themselves spawn sub-agents, creating a hierarchy. The **maximum nesting depth** prevents runaway chains. By default, the depth limit is **1** -- a parent agent can spawn sub-agents, but those sub-agents cannot spawn their own children. +Sub-agents can themselves spawn sub-agents, creating a hierarchy. `max_subagent_depth` limits how deep that tree can grow. By default the depth limit is `1`. -If a sub-agent tries to spawn beyond the depth limit, the `spawn_agent` call returns an error: - -``` -Maximum subagent depth (1) reached -``` - -The depth limit is set via the `max_subagent_depth` field in the session configuration. +If a child tries to exceed the limit, `spawn_agent` returns an error immediately. ## Error handling -When a sub-agent fails, the error is captured and returned to the parent via the `wait` tool — the parent stage does **not** automatically fail. The parent LLM sees the error message and decides how to respond: retry, try a different approach, or report the failure. +Sub-agent failures do not automatically fail the parent stage. The parent receives the failure through `wait` and decides how to respond. -Specific failure modes: +Common cases: -- **Sub-agent hits `max_turns`** — The sub-agent stops naturally and returns its last output as a successful result. The parent sees a normal completion with the final assistant message. By default, there is no turn limit — sub-agents run until they complete their task or hit the context window. -- **Sub-agent panics or errors** — The error is captured and returned through `wait` as a failure result. The parent can inspect the error and decide what to do. -- **`spawn_agent` fails** (e.g. depth limit exceeded) — The error is returned immediately as a tool result. The parent can adjust its approach without waiting. +- **Hits `max_turns`** -- returns normally with its last output +- **Panics or errors** -- returned as a failed `wait` result +- **`spawn_agent` fails** -- returned immediately as a tool result ## Event forwarding -Sub-agent events (tool calls, assistant messages, errors) are forwarded to the parent session's event stream as `SubAgentEvent` wrappers. This means the parent's progress log captures the full activity of all children, giving you visibility into what sub-agents are doing. +Sub-agent observability now uses **session linkage**, not wrapper events. -Key lifecycle events: +Parent-owned lifecycle events: | Event | When | |---|---| -| `SubAgentSpawned` | A sub-agent was created | -| `SubAgentCompleted` | A sub-agent finished successfully | -| `SubAgentFailed` | A sub-agent encountered an error | -| `SubAgentClosed` | A sub-agent was cancelled | +| `agent.sub.spawned` | A sub-agent was created | +| `agent.sub.completed` | A sub-agent finished successfully | +| `agent.sub.failed` | A sub-agent failed | +| `agent.sub.closed` | A sub-agent was cancelled | -Streaming events (text deltas, tool output deltas) from sub-agents are filtered out to reduce noise. +Forwarded child activity: + +- Child tool calls, assistant messages, warnings, and other non-noisy session events are forwarded into the parent run's event stream as normal agent events. +- The forwarded event keeps the child's `session_id`. +- `parent_session_id` points to the child's immediate parent session. + +Example envelope: + +```json +{ + "event": "agent.tool.started", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "node_id": "code", + "properties": { + "tool_name": "read_file" + } +} +``` + +Nested sub-agents preserve the immediate parent-child relationship. A grandchild forwarded through multiple parents still carries its own `session_id`, and `parent_session_id` remains set to the grandchild's direct parent rather than the root. + +High-volume streaming events such as text deltas and tool output deltas are filtered out of the forwarded stream to reduce noise. ## When to use sub-agents -Sub-agents are most useful when: +Sub-agents are most useful for: -- **Parallel research** -- gather information from multiple parts of a codebase simultaneously -- **Isolation** -- let a sub-agent try a risky approach without polluting the parent's context -- **Divide and conquer** -- break a large task into independent pieces that can be worked on concurrently -- **Context management** -- offload work to a sub-agent when the parent's context window is getting full +- **Parallel research** -- inspect multiple code paths at once +- **Isolation** -- try a risky approach without polluting the parent's context +- **Divide and conquer** -- split an independent task into smaller pieces +- **Context management** -- offload work when the parent session is getting crowded -Sub-agents run with no turn limit by default — they continue until the task is complete or the context window is exhausted. Pass `max_turns` when spawning to cap execution for predictable cost control. The parent agent is blocked during a `wait` call, so spawn sub-agents before starting the wait to maximize concurrency. +Sub-agents run with no turn limit by default. Pass `max_turns` when you want predictable cost or time bounds. All active sub-agents are cleaned up automatically when the parent session closes. - -All active sub-agents are automatically cleaned up when the parent session closes — you don't need to explicitly `close_agent` on every sub-agent. diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 44539e5ae..2f1db6a2b 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -11,7 +11,7 @@ The Fabro API is a REST API for managing workflow runs, interactive sessions, an ## Base URL -The versioned API is served by `fabro serve`, which defaults to: +The versioned API is served by `fabro server start`, which defaults to: ``` http://localhost:3000/api/v1 diff --git a/docs/core-concepts/how-fabro-works.mdx b/docs/core-concepts/how-fabro-works.mdx index 1b2ea8f21..70cf0c37d 100644 --- a/docs/core-concepts/how-fabro-works.mdx +++ b/docs/core-concepts/how-fabro-works.mdx @@ -14,7 +14,7 @@ Fabro is a workflow engine that reads a graph definition, executes nodes one at Fabro has two interfaces, both backed by the same workflow engine: - **Standalone mode** (`fabro run`) — Run a single workflow synchronously in your terminal. Best for local development, one-off runs, and CI/CD. -- **Server mode** (`fabro serve`) — Start an HTTP API server with a web UI, concurrent run scheduling, and team access. Best for production use and running at scale. +- **Server mode** (`fabro server start`) — Start an HTTP API server with a web UI, concurrent run scheduling, and team access. Best for production use and running at scale. Both modes parse the same Graphviz files, use the same execution engine, and support the same sandbox providers. See [Server Mode](/administration/deploy-server) for a detailed comparison and setup guide, or [Architecture](/reference/architecture) for internals. diff --git a/docs/docs.json b/docs/docs.json index cd07c8d33..7397e739f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -103,6 +103,7 @@ "pages": [ "reference/dot-language", "reference/cli", + "reference/shell-completions", "reference/user-configuration", "reference/run-directory", "reference/sdk", diff --git a/docs/execution/observability.mdx b/docs/execution/observability.mdx index 494e6b7be..47148c782 100644 --- a/docs/execution/observability.mdx +++ b/docs/execution/observability.mdx @@ -3,171 +3,119 @@ title: "Observability" description: "How to monitor, inspect, and analyze workflow runs" --- -Fabro captures a structured event for every significant action during a workflow run — stage starts and completions, agent tool calls, edge selections, retries, failovers, sandbox lifecycle, and more. These events power real-time monitoring, post-run analysis, and cross-run analytics. +Fabro captures a structured event for every significant action during a workflow run. These events cover stage execution, agent tool calls, retries, routing, sandbox lifecycle, git checkpoints, retro generation, and more. ## Event stream -The event stream is the foundation of Fabro's observability. Every workflow run emits a sequence of `WorkflowRunEvent` records that are: +Every workflow run emits a sequence of canonical **run event envelopes** that are: -- **Written to `progress.jsonl`** in the run's directory (one JSON object per line) -- **Broadcast via SSE** to connected API clients in real time -- **Logged via `tracing`** to the daily log file at `~/.fabro/logs/` +- Written to `progress.jsonl` in the run directory +- Broadcast over SSE to connected API clients +- Stored for later analysis and retro generation +- Rendered by CLI progress and log tooling -### Event types +### Event names -Events fall into several categories: +Event names use lowercase dot notation, for example: -**Run lifecycle** — bookend events for the entire run: +- `run.started` +- `stage.started` +- `stage.completed` +- `agent.tool.started` +- `agent.tool.completed` +- `sandbox.ready` +- `parallel.branch.completed` -| Event | Key fields | Description | -|---|---|---| -| `WorkflowRunStarted` | `name`, `run_id`, `base_sha`, `run_branch`, `goal` | Run begins | -| `WorkflowRunCompleted` | `duration_ms`, `artifact_count`, `total_cost`, `status`, `usage` | Run finishes successfully | -| `WorkflowRunFailed` | `error`, `duration_ms` | Run terminates with an error | +### Envelope format -**Stage lifecycle** — events for each node execution: - -| Event | Key fields | Description | -|---|---|---| -| `StageStarted` | `node_id`, `name`, `handler_type`, `attempt`, `max_attempts` | Node begins executing | -| `StageCompleted` | `node_id`, `duration_ms`, `status`, `usage`, `files_touched` | Node finishes | -| `StageFailed` | `node_id`, `failure`, `will_retry` | Node fails (may or may not retry) | -| `StageRetrying` | `node_id`, `attempt`, `max_attempts`, `delay_ms` | Retry scheduled after failure | - -**Agent activity** — forwarded from the LLM agent session, prefixed with `Agent.`: - -| Event | Key fields | Description | -|---|---|---| -| `Agent.SessionStarted` | `stage` | Agent session begins | -| `Agent.ToolCallStarted` | `stage`, `tool_name`, `arguments` | Agent invokes a tool | -| `Agent.ToolCallCompleted` | `stage`, `tool_name`, `output`, `is_error` | Tool returns a result | -| `Agent.AssistantMessage` | `stage`, `text`, `model`, `usage` | LLM responds with text | -| `Agent.Error` | `stage`, `error` | Agent-level error | -| `Agent.LoopDetected` | `stage` | Repeated tool call pattern detected | -| `Agent.SteeringInjected` | `stage`, `text` | Human steering message injected | -| `Agent.Warning` | `stage`, `kind`, `message`, `details` | Non-fatal warning (e.g. context window usage) | -| `Agent.CompactionStarted` | `stage`, `estimated_tokens`, `context_window_size` | Context compaction triggered | -| `Agent.CompactionCompleted` | `stage`, `original_turn_count`, `preserved_turn_count`, `summary_token_estimate`, `tracked_file_count` | Context compaction finished | -| `Agent.LlmRetry` | `stage`, `provider`, `model`, `attempt`, `delay_secs` | LLM API call retried | -| `Agent.SubAgentSpawned` | `stage`, `agent_id`, `task` | Sub-agent launched | -| `Agent.SubAgentCompleted` | `stage`, `agent_id`, `success`, `turns_used` | Sub-agent finished | - -**Routing and control flow:** - -| Event | Key fields | Description | -|---|---|---| -| `EdgeSelected` | `from_node`, `to_node`, `label`, `condition`, `reason`, `stage_status` | Transition between nodes | -| `LoopRestart` | `from_node`, `to_node` | Loop restart edge taken | -| `CheckpointCompleted` | `node_id`, `git_commit_sha` (optional) | Checkpoint saved (with git SHA when git is enabled) | -| `GitCommit` | `node_id`, `sha` | Git commit created | -| `GitPush` | `branch`, `success` | Git push attempted | -| `GitBranch` | `branch`, `sha` | Git branch created | -| `GitWorktreeAdd` | `path`, `branch` | Git worktree added | -| `GitWorktreeRemove` | `path` | Git worktree removed | -| `GitFetch` | `branch`, `success` | Git fetch attempted | -| `GitReset` | `sha` | Git reset executed | -| `Failover` | `stage`, `from_provider`, `to_provider`, `error` | LLM provider failover | -| `RetroStarted` | — | Retrospective generation begins | -| `RetroCompleted` | `duration_ms` | Retrospective generation finished | -| `RetroFailed` | `error`, `duration_ms` | Retrospective generation failed | - -**Parallel execution:** - -| Event | Key fields | Description | -|---|---|---| -| `ParallelStarted` | `branch_count`, `join_policy` | Fan-out begins | -| `ParallelBranchStarted` | `branch`, `index` | Individual branch begins | -| `ParallelBranchCompleted` | `branch`, `duration_ms`, `status` | Branch finishes | -| `ParallelCompleted` | `duration_ms`, `success_count`, `failure_count` | All branches done | - -**Human-in-the-loop:** - -| Event | Key fields | Description | -|---|---|---| -| `InterviewStarted` | `question`, `stage`, `question_type` | Human input requested | -| `InterviewCompleted` | `question`, `answer`, `duration_ms` | Human responded | -| `InterviewTimeout` | `question`, `stage`, `duration_ms` | Human didn't respond in time | - -**Sandbox and setup:** - -| Event | Key fields | Description | -|---|---|---| -| `Sandbox.Initializing` | `provider` | Sandbox creation started | -| `Sandbox.Ready` | `provider`, `duration_ms`, `cpu`, `memory` | Sandbox ready | -| `SetupStarted` | `command_count` | Setup commands beginning | -| `SetupCommandCompleted` | `command`, `exit_code`, `duration_ms` | Single setup command finished | -| `SetupFailed` | `command`, `exit_code`, `stderr` | Setup command failed | -| `SshAccessReady` | `ssh_command` | SSH connection command printed | -| `StallWatchdogTimeout` | `node`, `idle_seconds` | No activity for too long | - -### Event envelope format - -Each line in `progress.jsonl` is a JSON object with a standard envelope: +Each line in `progress.jsonl` is a JSON object with a stable envelope: ```json { - "ts": "2026-03-05T14:30:01.234Z", - "run_id": "01JKXYZ...", - "event": "Agent.ToolCallStarted", - "stage": "implement", - "tool_name": "shell", - "arguments": {"command": "cargo test"} + "id": "01960d0c-5d16-7d6e-8f61-9fd6f4a532b5", + "ts": "2026-03-30T12:00:01.000Z", + "run_id": "01JQ...", + "event": "agent.tool.started", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "node_id": "implement", + "node_label": "Implement", + "properties": { + "tool_name": "shell", + "tool_call_id": "call_1", + "arguments": {"command": "cargo test"} + } } ``` -The `ts`, `run_id`, and `event` fields are always present. The remaining fields vary by event type. Events are flattened — nested variants like agent and sandbox events use dot notation (e.g. `Agent.ToolCallStarted`, `Sandbox.Ready`). +Envelope fields: -## Log files +| Field | Description | +|---|---| +| `id` | Unique event id | +| `ts` | UTC timestamp | +| `run_id` | Workflow run id | +| `event` | Event name | +| `session_id` | Session that emitted the event, when applicable | +| `parent_session_id` | Immediate parent session for forwarded child events | +| `node_id` | Node or branch id, when applicable | +| `node_label` | Human-facing label for `node_id`, when applicable | +| `properties` | Event-specific payload | -Fabro writes two kinds of logs: +Only `id`, `ts`, `run_id`, and `event` are always present. Optional fields are omitted when they do not apply. -### Run logs (`progress.jsonl`) +## Reading `progress.jsonl` -Every run writes its event stream to `{run_dir}/progress.jsonl`. This is the primary data source for post-run analysis — [retros](/execution/retros) read it, and you can query it directly with standard tools: +Because event payload lives in `properties`, most shell queries should look there. ```bash # Count tool calls in a run -grep "ToolCallStarted" ~/.fabro/runs/01JKXYZ.../progress.jsonl | wc -l +jq -r 'select(.event == "agent.tool.started") | .properties.tool_name' \ + ~/.fabro/runs/01JKXYZ.../progress.jsonl | wc -l -# Find all failures -grep -E "StageFailed|WorkflowRunFailed" ~/.fabro/runs/01JKXYZ.../progress.jsonl | jq . +# Find stage failures +jq 'select(.event == "stage.failed")' \ + ~/.fabro/runs/01JKXYZ.../progress.jsonl # See which edges were taken -grep "EdgeSelected" ~/.fabro/runs/01JKXYZ.../progress.jsonl | jq '{from: .from_node, to: .to_node}' +jq '{from: .properties.from_node, to: .properties.to_node, label: .properties.label}' \ + ~/.fabro/runs/01JKXYZ.../progress.jsonl | head ``` -### Live snapshot (`live.json`) +`live.json` is still a pretty-printed copy of the most recent event envelope. -During execution, Fabro also writes `live.json` — a pretty-printed copy of the most recent event. This is useful for quick status checks while a run is in progress: +## Event categories -```bash -cat ~/.fabro/runs/01JKXYZ.../live.json -``` +Common categories include: -### Application logs - -Fabro uses the `tracing` crate to write structured logs to `~/.fabro/logs/YYYY-MM-DD.log`. Control the log level with the `FABRO_LOG` environment variable: - -```bash -FABRO_LOG=debug fabro run workflow.fabro -``` - -| Level | What's logged | +| Category | Example events | |---|---| -| `error` | Run failures, stage failures that won't retry | -| `warn` | Retries, timeouts, interview timeouts, failovers, early terminations | -| `info` | Run start/complete, SSH access ready | -| `debug` | Stage start/complete, edge selections, checkpoints, parallel branches, tool calls | +| Run lifecycle | `run.started`, `run.completed`, `run.failed`, `run.notice` | +| Stage lifecycle | `stage.started`, `stage.completed`, `stage.failed`, `stage.retrying` | +| Agent activity | `agent.message`, `agent.tool.started`, `agent.warning`, `agent.sub.spawned` | +| Routing | `edge.selected`, `loop.restart`, `parallel.started` | +| Git and checkpoints | `checkpoint.completed`, `git.commit`, `git.push` | +| Setup and sandbox | `sandbox.initializing`, `sandbox.ready`, `setup.started` | +| Retro | `retro.started`, `retro.completed`, `retro.failed` | + +## Sub-agent visibility + +Sub-agent activity now appears as normal agent events with session linkage: + +- `session_id` identifies the child session +- `parent_session_id` identifies its immediate parent + +Lifecycle events such as `agent.sub.spawned` and `agent.sub.completed` are emitted by the parent session. Tool calls and other child activity are forwarded with their original `session_id`. ## Real-time monitoring ### API: Server-Sent Events -When running workflows through the API server, subscribe to a live event stream via the [run events endpoint](/api-reference/runs/stream-run-events). Each event is a JSON-serialized `WorkflowRunEvent`. The stream stays open until the run completes. +When running workflows through the API server, subscribe to the [run events endpoint](/api-reference/runs/stream-run-events). Each SSE payload is a serialized run event envelope in the same shape used by `progress.jsonl`. ### Web UI -The web frontend connects to the SSE stream automatically and displays run progress in real time — stage transitions, agent tool calls, and human gate prompts are all visible as they happen. +The web frontend consumes the SSE stream automatically and shows stage progress, tool calls, and human interaction as they happen. Fabro web UI run stages showing agent conversation with tool calls @@ -175,90 +123,20 @@ The web frontend connects to the SSE stream automatically and displays run progr ### CLI progress -The CLI displays a live progress bar during execution with per-stage status, duration, and cost tracking. This is rendered to stderr so it doesn't interfere with output piping. +The CLI renders live progress from the same envelope format. This is written to stderr so stdout remains pipe-friendly. ## Post-run analysis -### Listing runs - -Browse your run history with `fabro ps`: - -```bash -fabro ps -fabro ps --workflow PlanImplement -fabro ps --before 2026-03-01 -fabro ps --label team=platform -fabro ps --json -``` - -This scans `~/.fabro/runs/` and displays each run's ID, workflow name, status, and start time. Use `--json` for machine-readable output. - -### Run artifacts - -Each run's directory contains a standard set of files: +Run artifacts still include: | File | Description | |---|---| -| `run.json` | Run record — ID, config, graph, workflow slug, labels | -| `start.json` | Start record — run ID, start time, run branch, base SHA | -| `progress.jsonl` | Full event stream | -| `live.json` | Last event snapshot (overwritten during run) | +| `run.json` | Run metadata and graph | +| `start.json` | Start record | +| `progress.jsonl` | Full event envelope stream | +| `live.json` | Latest event envelope | | `checkpoint.json` | Final execution state | -| `retro.json` | Retrospective (if retro generation is enabled) | -| `conclusion.json` | Terminal status (`completed`, `failed`, `canceled`) | +| `retro.json` | Retrospective, when enabled | +| `conclusion.json` | Terminal summary | -### Inspecting stages and turns - -The API provides endpoints for drilling into individual stages and the agent turns within them. See the [stages](/api-reference/run-internals/list-run-stages) and [turns](/api-reference/run-internals/list-stage-turns) API reference pages. - -## Insights (SQL analytics) - -The Insights feature lets you run SQL queries across your run data using DuckDB. This is useful for aggregate analysis — finding slow workflows, tracking failure rates, comparing model costs, and spotting trends. - -Insights is managed through the [saved queries](/api-reference/insights/list-saved-queries) and [execute query](/api-reference/insights/execute-query) API reference pages. You can save, update, and execute queries programmatically. - -### Example queries - -**Average run duration by workflow:** - -```sql -SELECT workflow_name, AVG(duration_seconds) as avg_duration, - COUNT(*) as run_count -FROM runs -GROUP BY workflow_name -ORDER BY avg_duration DESC -LIMIT 20 -``` - -**Daily failure rate:** - -```sql -SELECT date_trunc('day', created_at) as day, - COUNT(*) FILTER (WHERE status = 'failed') as failures, - COUNT(*) as total -FROM runs -GROUP BY 1 -ORDER BY 1 DESC -LIMIT 30 -``` - -**Top repositories by activity:** - -```sql -SELECT repo, COUNT(*) as runs -FROM runs -GROUP BY repo -ORDER BY runs DESC -``` - -## Aggregate usage - -The API server tracks aggregate usage counters across all runs — total run count, total runtime, and per-model breakdowns of token usage and cost. See the [usage endpoint](/api-reference/usage/aggregate-usage) in the API reference. Counters reset on server restart. - - - Fabro web UI run usage showing per-stage and per-model token and cost breakdown - - -## Credential redaction - -All event output — `progress.jsonl`, SSE streams, and log files — is automatically redacted before being written. Fabro detects and replaces patterns that look like API keys, AWS credentials, bearer tokens, and other secrets with `REDACTED`. This ensures sensitive values that appear in tool call arguments or command output are never persisted to disk. +See [retros](/execution/retros), [stages](/api-reference/run-internals/list-run-stages), and [turns](/api-reference/run-internals/list-stage-turns) for higher-level analysis views built on top of this event stream. diff --git a/docs/execution/run-configuration.mdx b/docs/execution/run-configuration.mdx index f2f61122d..ceeb2f483 100644 --- a/docs/execution/run-configuration.mdx +++ b/docs/execution/run-configuration.mdx @@ -423,7 +423,7 @@ Project defaults are merged with run config values using the same rules as serve ### Server defaults -When running via `fabro serve`, the server config at `~/.fabro/server.toml` can set default values for `[llm]`, `[setup]`, `[sandbox]`, and `[vars]`. These defaults are applied to every run unless the run config overrides them. +When running via `fabro server start`, the server config at `~/.fabro/server.toml` can set default values for `[llm]`, `[setup]`, `[sandbox]`, and `[vars]`. These defaults are applied to every run unless the run config overrides them. For variables, defaults and run config are **merged** — the run config wins on key collisions: diff --git a/docs/getting-started/quick-start.mdx b/docs/getting-started/quick-start.mdx index e9eed957e..6fcd44250 100644 --- a/docs/getting-started/quick-start.mdx +++ b/docs/getting-started/quick-start.mdx @@ -34,7 +34,7 @@ Fabro has two modes: ```bash cd my-repo/ -fabro init +fabro repo init ``` This creates a default workflow and configuration in your project directory. diff --git a/docs/integrations/daytona.mdx b/docs/integrations/daytona.mdx index 85094eeb4..cb6e5e07f 100644 --- a/docs/integrations/daytona.mdx +++ b/docs/integrations/daytona.mdx @@ -156,7 +156,7 @@ auto_stop_interval = 30 ## Server defaults -When running via `fabro serve`, the server config at `~/.fabro/server.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely). +When running via `fabro server start`, the server config at `~/.fabro/server.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely). See [Server Configuration](/administration/server-configuration) for details. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index e69d60c8c..c26e8c61f 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -25,10 +25,10 @@ CLI mode is ideal for: ## API mode ```bash -fabro serve +fabro server start ``` -`fabro serve` starts an HTTP server (default `127.0.0.1:3000`) backed by SQLite for run persistence. Runs are submitted via the REST API and executed asynchronously. +`fabro server start` starts an HTTP server (default `127.0.0.1:3000`) backed by SQLite for run persistence. Runs are submitted via the REST API and executed asynchronously. ### Configuration @@ -72,10 +72,10 @@ Demo mode is per-request: send the `X-Fabro-Demo: 1` HTTP header to get static m ## Web UI -The web UI is a React app (`apps/fabro-web`) that connects to the API server. Start it alongside `fabro serve`: +The web UI is a React app (`apps/fabro-web`) that connects to the API server. Start it alongside `fabro server start`: ```bash -fabro serve # API on port 3000 +fabro server start # API on port 3000 cd apps/fabro-web && bun run dev # Web UI on port 5173 ``` diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index c325cab61..aed773d69 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -327,14 +327,14 @@ fabro model test -m claude-sonnet-4-5 --- -## `fabro serve` +## `fabro server start` Start the HTTP API server that exposes the [REST API](/api-reference) for launching and managing workflow runs. ```bash -fabro serve -fabro serve --port 8080 --host 0.0.0.0 -fabro serve --sandbox daytona --max-concurrent-runs 4 +fabro server start +fabro server start --port 8080 --host 0.0.0.0 +fabro server start --sandbox daytona --max-concurrent-runs 4 ``` | Flag | Description | Default | @@ -648,10 +648,6 @@ The command must be run inside a git repository. It creates: After creating files, it checks whether the GitHub App is installed for the repository. If the app is not installed and the repository owner differs from the app owner, it warns that the app may need to be [made public](/integrations/github#github-app-is-private-but-this-repo-belongs-to-a-different-owner) first. - -The old `fabro init` still works but prints a deprecation warning. Use `fabro repo init` instead. - - ## `fabro repo deinit` Remove Fabro from a project by deleting `fabro.toml` and the `fabro/` directory. Fails with an error if the project is not initialized. @@ -888,6 +884,24 @@ fabro store dump abc123 -o ./debug-output --- +## `fabro completion` + +Generate shell completion scripts for bash, zsh, fish, elvish, and PowerShell. + +```bash +fabro completion bash +fabro completion zsh +fabro completion fish +``` + +| Argument | Description | +|---|---| +| `` | Shell to generate completions for: `bash`, `zsh`, `fish`, `elvish`, `powershell` (required) | + +See [Shell Completions](/reference/shell-completions) for installation instructions for each shell. + +--- + ## `fabro docs` Open the Fabro documentation website in your default browser. diff --git a/docs/reference/shell-completions.mdx b/docs/reference/shell-completions.mdx new file mode 100644 index 000000000..9c8155149 --- /dev/null +++ b/docs/reference/shell-completions.mdx @@ -0,0 +1,70 @@ +--- +title: "Shell Completions" +description: "Set up tab completion for the fabro CLI in your shell" +--- + +The `fabro completion` command generates shell completion scripts for tab-completing commands, flags, and arguments. + +## Bash + +Add to your `~/.bashrc`: + +```bash +eval "$(fabro completion bash)" +``` + +Or generate a file and source it: + +```bash +fabro completion bash > ~/.local/share/bash-completion/completions/fabro +``` + +## Zsh + +Add to your `~/.zshrc` (before `compinit`): + +```bash +eval "$(fabro completion zsh)" +``` + +Or generate a file: + +```bash +fabro completion zsh > "${fpath[1]}/_fabro" +``` + +You may need to run `compinit` or start a new shell session for changes to take effect. + +## Fish + +```bash +fabro completion fish | source +``` + +Or persist to the completions directory: + +```bash +fabro completion fish > ~/.config/fish/completions/fabro.fish +``` + +## PowerShell + +Add to your PowerShell profile: + +```powershell +fabro completion powershell | Out-String | Invoke-Expression +``` + +## Elvish + +```bash +eval (fabro completion elvish | slurp) +``` + +## Supported shells + +Run `fabro completion --help` to see all supported shells: + +```bash +fabro completion --help +``` diff --git a/files-internal/testing-strategy.md b/files-internal/testing-strategy.md new file mode 100644 index 000000000..cd565ec80 --- /dev/null +++ b/files-internal/testing-strategy.md @@ -0,0 +1,318 @@ +# Testing Strategy + +This document defines the default testing rules for this repository, with extra emphasis on CLI integration tests. + +The goal is to make the correct test shape obvious: + +- put each test in the right layer +- create state through public interfaces +- prefer stable black-box assertions +- avoid brittle tests that mirror implementation details + +## Core principles + +- Test the public contract of the layer you are in. +- Prefer command-driven or API-driven setup over manually fabricating internal state. +- Prefer snapshots over ad hoc string matching. +- Prefer structured snapshots over parsing JSON and checking one field. +- If a test is only practical by writing internal runtime files directly, it probably belongs in a lower-level test. + +## Test layers + +Use the narrowest layer that can express the behavior cleanly. + +### Unit and crate-level integration tests + +Use unit tests or crate-local integration tests when the behavior under test is implementation-facing rather than CLI-facing. + +This is the right place for: + +- helper logic +- parsing and normalization +- rendering internals +- interview file claim/response mechanics +- retry bookkeeping +- asset manifest parsing +- event formatting + +If the setup requires direct writes to internal run files or runtime directories, prefer this layer over `fabro-cli/tests/it`. + +### `lib/crates/fabro-cli/tests/it/cmd/*.rs` + +`cmd/*` tests are command-owned tests. + +Each file should focus on one command's public contract. Setup may use other commands for convenience, but the final assertion should still be about the command under test. + +Examples: + +- `cmd/run.rs` tests `fabro run` +- `cmd/create.rs` tests `fabro create` +- `cmd/start.rs` tests `fabro start` +- `cmd/attach.rs` tests `fabro attach` + +Good command-test assertions: + +- help and clap behavior +- required-argument failures +- command-owned persisted state +- command-owned selection or lookup behavior +- user-visible output and lifecycle behavior owned by that command + +Bad command-test assertions: + +- long multi-command narratives where no single command is the subject +- behavior primarily owned by another command +- runtime internals that only exist because the test planted them by hand + +### `lib/crates/fabro-cli/tests/it/workflow/*.rs` + +`workflow/*` tests are black-box workflow-behavior tests. + +Use this layer when the workflow content is the thing under test, even if the harness command is `fabro run`. + +Examples: + +- branching behavior +- conditional routing +- parallel execution shape +- representative fixture workflows + +These tests should focus on the workflow's observed behavior, not on CLI help text or command argument validation. + +### `lib/crates/fabro-cli/tests/it/scenario/*.rs` + +`scenario/*` tests are cross-command lifecycle tests. + +Use this layer when the point of the test is the interaction among commands or command families. + +Examples: + +- create -> start -> attach flows +- detached run -> attach flows +- rewind / fork recovery flows +- lookup behavior that spans several commands + +Scenario tests are allowed to be broader, but they should still stay command-driven and black-box. + +## Placement rules + +When choosing where a test belongs, ask: "What is the main contract I am trying to prove?" + +- If the answer is a single command, use `cmd/*`. +- If the answer is a workflow fixture or workflow shape, use `workflow/*`. +- If the answer is a multi-command flow, use `scenario/*`. +- If the answer is an implementation detail, use a lower-level test near the code. + +If a test starts in `cmd/*` and grows into a workflow or lifecycle narrative, move it. + +## State setup rules + +Integration tests should create state through public interfaces. + +Allowed setup: + +- checked-in workflow fixtures +- temp `.fabro` workflow files +- temp `workflow.toml` and `fabro.toml` +- temp git repositories +- temp user config and environment variables +- invoking commands to create runs, checkpoints, branches, and persisted state + +Disallowed setup in `fabro-cli/tests/it`: + +- writing `run.json` directly +- writing `status.json` directly +- writing `progress.jsonl` directly +- writing `conclusion.json` directly +- writing runtime interview files directly +- writing cached workflow files into run dirs directly +- writing asset manifests directly +- planting files into run internals solely to simulate engine output + +The rule is simple: do not hand-author run-directory internals in CLI integration tests. + +### Exceptions + +Exceptions should be rare. + +Only keep a direct internal-state setup when all of the following are true: + +- the behavior cannot be reproduced through public commands at reasonable cost +- the behavior is still best validated at the CLI integration layer +- the test clearly documents why the exception exists +- no cleaner lower-level test would cover the behavior better + +If those conditions are not met, move the test down a layer. + +## Assertion rules + +Default to snapshot-first assertions. + +### Use transcript snapshots for CLI behavior + +Use `fabro_snapshot!` for: + +- `--help` +- clap errors +- normal CLI stderr/stdout transcripts +- detached/start/attach lifecycle output + +Do not replace full-output snapshots with a handful of `contains()` checks unless the output is intentionally partial and a full snapshot would be noisy or unstable. + +### Use structured snapshots for persisted state + +When verifying JSON or JSONL: + +1. parse it +2. normalize or compact it if needed +3. snapshot the parsed structure + +Use `insta` directly or shared helpers such as `fabro_json_snapshot!`. + +Good structured snapshot targets: + +- `run.json` +- `status.json` +- `inspect` output +- `live.json` +- compacted `progress.jsonl` event sequences +- workflow conclusions and checkpoint summaries + +### Keep direct assertions for relational invariants + +Use direct assertions when the point is an exact relationship rather than a representation. + +Examples: + +- exact selected run id +- equality before and after a rejected mutation attempt +- `live.json` equals the last progress event +- exact SHA lineage across rewind/fork +- exact file existence semantics + +## Snapshot rules + +- Prefer inline snapshots unless the payload is too large to read comfortably. +- Normalize unstable values: timestamps, durations, ULIDs, temp paths, storage paths, run dirs, and SHAs. +- Never accept snapshot churn blindly. +- Review pending snapshots before accepting them. + +For CLI snapshot updates: + +1. run `cargo insta pending-snapshots` +2. inspect each pending change +3. accept only the intended updates + +## Helpers and fixtures + +Use the test helpers that reinforce the rules above. + +### `TestContext` + +Use `TestContext` for CLI integration tests so each test gets isolated home, storage, and temp directories. + +Prefer helpers like: + +- `context.command()` +- `context.run_cmd()` +- `context.find_run_dir(...)` +- `context.single_run_dir()` + +### Shared `tests/it/support` + +Shared integration-test helpers may: + +- locate fixtures +- read and parse JSON / JSONL +- normalize output +- compact structured events +- poll for stable command-created conditions + +Shared integration-test helpers should not: + +- fabricate run internals +- write runtime files the engine is supposed to own +- hide broad scenario setup behind opaque helper functions + +### Fixtures + +Prefer checked-in fixtures when they express a reusable workflow or scenario shape. + +Use temporary inline fixtures when the test needs a small one-off input and a checked-in fixture would add noise. + +Keep fixtures user-facing: + +- workflow sources +- config files +- repo contents + +Do not turn fixtures into prebuilt run directories. + +## Determinism rules + +Tests should be stable on any developer machine. + +- Use fixed run ids when practical. +- Prefer dry-run where it still exercises the intended public behavior. +- Use local temp directories, never ambient user state. +- Mark tests that require real providers, real sandboxes, or external services with `#[ignore]` and a clear reason. +- Filter or normalize machine-specific output in snapshots. + +If a test depends on `.env` or real credentials, it must be clearly marked and opt-in. + +## Naming rules + +Name tests after the contract they prove. + +Prefer: + +- `start_by_workflow_name_prefers_newly_created_submitted_run` +- `detached_uses_cached_graph_after_source_deleted` +- `attach_requires_run_arg` + +Avoid: + +- `bug4_test` +- `regression_123` +- names that describe setup rather than behavior + +If a test exists because of a regression, mention the bug number in a comment or commit message, not in the primary test name unless the bug id is itself part of the contract. + +## Review checklist + +Before merging a test change, check: + +- Is the test in the correct layer: unit, `cmd`, `workflow`, or `scenario`? +- Is the state created through public commands or public inputs? +- Does the test avoid hand-writing run-directory internals? +- Does the assertion use snapshots where snapshots are the better tool? +- Is JSON / JSONL asserted structurally rather than via substring matching? +- Are unstable values normalized? +- Is the test name describing the contract? +- Would a lower-level test be cleaner and less brittle? + +## Anti-patterns + +Avoid these patterns in CLI integration tests: + +- manually creating fake run directories +- writing `progress.jsonl` lines by hand +- writing runtime interview files by hand +- writing asset manifests by hand +- scattering the same workflow setup across many files instead of using fixtures +- asserting one field from a parsed JSON payload when the full structure is the behavior +- using many `contains()` checks for output that should be snapshot-tested +- keeping scenario tests in `cmd/*` +- keeping workflow-shape tests in `cmd/*` + +## Defaults for new tests + +When adding a new CLI integration test, the default choice should be: + +1. decide the layer (`cmd`, `workflow`, `scenario`, or lower-level test) +2. create input state through public files and commands +3. use `TestContext` +4. assert with snapshots +5. keep the test focused on one contract + +If you need to break one of these defaults, document why in the test itself. diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 5865cfe11..b5fdb06a7 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -486,7 +486,7 @@ pub async fn run_with_args_and_client( manager_for_callback .lock() .await - .set_event_callback(session.event_callback()); + .set_event_callback(session.sub_agent_event_callback()); // SIGINT handler let cancel_token = session.cancel_token(); @@ -522,6 +522,11 @@ pub async fn run_with_args_and_client( OutputFormat::Text => { let s = styles; while let Ok(event) = rx.recv().await { + let child_prefix = if event.parent_session_id.is_some() { + format!("[child {}] ", event.session_id) + } else { + String::new() + }; match &event.event { AgentEvent::ToolCallStarted { tool_name, @@ -531,7 +536,7 @@ pub async fn run_with_args_and_client( eprintln!( " {} {}{}", s.dim.apply_to("\u{25cf}"), - s.bold_cyan.apply_to(tool_name), + s.bold_cyan.apply_to(format!("{child_prefix}{tool_name}")), s.dim.apply_to(format!( "({})", format_tool_args(arguments, &cwd_str) @@ -551,13 +556,17 @@ pub async fn run_with_args_and_client( }; eprintln!( " {}\n{}", - s.dim.apply_to(format!("[{label}] {tool_name}:")), + s.dim + .apply_to(format!("[{label}] {child_prefix}{tool_name}:")), serde_json::to_string_pretty(output) .unwrap_or_else(|_| output.to_string()), ); } AgentEvent::Error { error } => { - eprintln!(" {}", s.red.apply_to(format!("\u{2717} {error}")),); + eprintln!( + " {}", + s.red.apply_to(format!("\u{2717} {child_prefix}{error}")), + ); } AgentEvent::SubAgentSpawned { agent_id, @@ -573,7 +582,7 @@ pub async fn run_with_args_and_client( eprintln!( " {}", s.dim.apply_to(format!( - "\u{25b6} subagent {agent_id} spawned (depth={depth}) task={task_preview:?}" + "{child_prefix}\u{25b6} subagent {agent_id} spawned (depth={depth}) task={task_preview:?}" )), ); } @@ -586,7 +595,7 @@ pub async fn run_with_args_and_client( eprintln!( " {}", s.dim.apply_to(format!( - "\u{25a0} subagent {agent_id} completed (depth={depth}, success={success}, turns={turns_used})" + "{child_prefix}\u{25a0} subagent {agent_id} completed (depth={depth}, success={success}, turns={turns_used})" )), ); } @@ -598,7 +607,7 @@ pub async fn run_with_args_and_client( eprintln!( " {}", s.red.apply_to(format!( - "\u{2717} subagent {agent_id} failed (depth={depth}): {error}" + "{child_prefix}\u{2717} subagent {agent_id} failed (depth={depth}): {error}" )), ); } @@ -606,21 +615,10 @@ pub async fn run_with_args_and_client( eprintln!( " {}", s.dim.apply_to(format!( - "\u{25a0} subagent {agent_id} closed (depth={depth})" + "{child_prefix}\u{25a0} subagent {agent_id} closed (depth={depth})" )), ); } - AgentEvent::SubAgentEvent { - agent_id, - event: child_event, - .. - } if verbose => { - eprintln!( - " {}", - s.dim - .apply_to(format!("[subagent {agent_id}] {child_event:?}")), - ); - } _ => {} } } diff --git a/lib/crates/fabro-agent/src/event.rs b/lib/crates/fabro-agent/src/event.rs index 95a00931d..60dbbe9e7 100644 --- a/lib/crates/fabro-agent/src/event.rs +++ b/lib/crates/fabro-agent/src/event.rs @@ -20,11 +20,16 @@ impl EventEmitter { event, timestamp: SystemTime::now(), session_id, + parent_session_id: None, }; // Ignore send error (no receivers) let _ = self.sender.send(wrapped); } + pub fn forward(&self, event: SessionEvent) { + let _ = self.sender.send(event); + } + #[must_use] pub fn subscribe(&self) -> broadcast::Receiver { self.sender.subscribe() @@ -52,6 +57,7 @@ mod tests { let event = receiver.recv().await.unwrap(); assert!(matches!(event.event, AgentEvent::SessionStarted)); assert_eq!(event.session_id, "sess-1"); + assert_eq!(event.parent_session_id, None); } #[tokio::test] @@ -70,6 +76,7 @@ mod tests { assert!( matches!(&event.event, AgentEvent::Error { error } if error.to_string().contains("something went wrong")) ); + assert_eq!(event.parent_session_id, None); } #[tokio::test] @@ -86,6 +93,8 @@ mod tests { assert!(matches!(e2.event, AgentEvent::SessionEnded)); assert_eq!(e1.session_id, "sess-3"); assert_eq!(e2.session_id, "sess-3"); + assert_eq!(e1.parent_session_id, None); + assert_eq!(e2.parent_session_id, None); } #[test] @@ -104,4 +113,22 @@ mod tests { let emitter = EventEmitter::default(); let _rx = emitter.subscribe(); } + + #[tokio::test] + async fn forward_preserves_session_ids() { + let emitter = EventEmitter::new(); + let mut receiver = emitter.subscribe(); + + emitter.forward(SessionEvent { + event: AgentEvent::SessionStarted, + timestamp: SystemTime::now(), + session_id: "child".into(), + parent_session_id: Some("parent".into()), + }); + + let event = receiver.recv().await.unwrap(); + assert_eq!(event.session_id, "child"); + assert_eq!(event.parent_session_id.as_deref(), Some("parent")); + assert!(matches!(event.event, AgentEvent::SessionStarted)); + } } diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 16b773fa2..29b64280f 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -13,7 +13,7 @@ use crate::sandbox::Sandbox; use crate::skills::{ ExpandedInput, Skill, default_skill_dirs, discover_skills, expand_skill, make_use_skill_tool, }; -use crate::subagent::{SubAgentEventCallback, SubAgentManager}; +use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentManager}; use crate::tool_execution::execute_tool_calls; use crate::types::{AgentEvent, SessionEvent, SessionState, Turn}; use fabro_llm::client::Client; @@ -93,6 +93,11 @@ impl Session { self.tool_env = Some(env); } + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + /// Initialize session by discovering project docs and capturing environment context. /// Call before `process_input`. pub async fn initialize(&mut self) { @@ -453,13 +458,22 @@ impl Session { self.cancel_token.clone() } - /// Build a callback that forwards `AgentEvent`s through this session's emitter. + /// Build a callback that forwards sub-agent lifecycle and child session events + /// through this session's emitter. #[must_use] - pub fn event_callback(&self) -> SubAgentEventCallback { + pub fn sub_agent_event_callback(&self) -> SubAgentEventCallback { let emitter = self.event_emitter.clone(); - let session_id = self.id.clone(); - Arc::new(move |event| { - emitter.emit(session_id.clone(), event); + let parent_session_id = self.id.clone(); + Arc::new(move |event| match event { + SubAgentCallbackEvent::Lifecycle(event) => { + emitter.emit(parent_session_id.clone(), event); + } + SubAgentCallbackEvent::Forwarded(mut event) => { + if event.parent_session_id.is_none() { + event.parent_session_id = Some(parent_session_id.clone()); + } + emitter.forward(event); + } }) } @@ -2720,7 +2734,7 @@ mod tests { manager .lock() .await - .set_event_callback(session.event_callback()); + .set_event_callback(session.sub_agent_event_callback()); // Spawn a subagent let child = make_session(vec![text_response("child done")]).await; diff --git a/lib/crates/fabro-agent/src/subagent.rs b/lib/crates/fabro-agent/src/subagent.rs index b3562b06c..92d5c25d5 100644 --- a/lib/crates/fabro-agent/src/subagent.rs +++ b/lib/crates/fabro-agent/src/subagent.rs @@ -2,7 +2,7 @@ use crate::error::AgentError; use crate::session::Session; use crate::tool_registry::RegisteredTool; use crate::tools::required_str; -use crate::types::{AgentEvent, Turn}; +use crate::types::{AgentEvent, SessionEvent, Turn}; use fabro_llm::types::ToolDefinition; use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; @@ -11,7 +11,14 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; pub type SessionFactory = Arc Session + Send + Sync>; -pub type SubAgentEventCallback = Arc; + +#[derive(Debug, Clone)] +pub enum SubAgentCallbackEvent { + Lifecycle(AgentEvent), + Forwarded(SessionEvent), +} + +pub type SubAgentEventCallback = Arc; #[derive(Debug, Clone)] pub struct SubAgentResult { @@ -57,7 +64,7 @@ impl SubAgentManager { fn emit_event(&self, event: AgentEvent) { if let Some(ref cb) = self.event_callback { - cb(event); + cb(SubAgentCallbackEvent::Lifecycle(event)); } } @@ -82,8 +89,6 @@ impl SubAgentManager { if let Some(ref cb) = self.event_callback { let mut rx = session.subscribe(); let cb = cb.clone(); - let fwd_agent_id = agent_id.clone(); - let child_depth = depth + 1; tokio::spawn(async move { while let Ok(event) = rx.recv().await { // Skip streaming / noise events @@ -101,11 +106,7 @@ impl SubAgentManager { ) { continue; } - cb(AgentEvent::SubAgentEvent { - agent_id: fwd_agent_id.clone(), - depth: child_depth, - event: Box::new(event.event), - }); + cb(SubAgentCallbackEvent::Forwarded(event)); } }); } @@ -620,8 +621,11 @@ mod tests { assert!(close_required.contains(&serde_json::json!("agent_id"))); } - fn captured_events() -> (SubAgentEventCallback, Arc>>) { - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + fn captured_events() -> ( + SubAgentEventCallback, + Arc>>, + ) { + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); let events_clone = events.clone(); let cb: SubAgentEventCallback = Arc::new(move |event| { events_clone.lock().unwrap().push(event); @@ -640,9 +644,11 @@ mod tests { let captured = events.lock().unwrap(); assert_eq!(captured.len(), 1); - assert!( - matches!(&captured[0], AgentEvent::SubAgentSpawned { depth: 1, task, .. } if task == "test task") - ); + assert!(matches!( + &captured[0], + SubAgentCallbackEvent::Lifecycle(AgentEvent::SubAgentSpawned { depth: 1, task, .. }) + if task == "test task" + )); } #[tokio::test] @@ -658,11 +664,11 @@ mod tests { let captured = events.lock().unwrap(); assert!(captured.iter().any(|e| matches!( e, - AgentEvent::SubAgentCompleted { + SubAgentCallbackEvent::Lifecycle(AgentEvent::SubAgentCompleted { success: true, depth: 1, .. - } + }) ))); } @@ -677,11 +683,10 @@ mod tests { manager.close(&agent_id).unwrap(); let captured = events.lock().unwrap(); - assert!( - captured - .iter() - .any(|e| matches!(e, AgentEvent::SubAgentClosed { depth: 2, .. })) - ); + assert!(captured.iter().any(|e| matches!( + e, + SubAgentCallbackEvent::Lifecycle(AgentEvent::SubAgentClosed { depth: 2, .. }) + ))); } #[tokio::test] @@ -702,15 +707,41 @@ mod tests { let captured = events.lock().unwrap(); let forwarded_count = captured .iter() - .filter(|e| matches!(e, AgentEvent::SubAgentEvent { .. })) + .filter(|e| matches!(e, SubAgentCallbackEvent::Forwarded(_))) .count(); - // Child session emits at least UserInput and AssistantMessage (filtered from SessionStarted/SessionEnded/etc) assert!( forwarded_count > 0, "expected at least one forwarded child event, got {forwarded_count}" ); } + #[tokio::test] + async fn session_callback_stamps_parent_only_once() { + let parent = make_session(vec![text_response("parent")]).await; + let callback = parent.sub_agent_event_callback(); + let mut rx = parent.subscribe(); + + callback(SubAgentCallbackEvent::Forwarded(SessionEvent { + event: AgentEvent::SessionStarted, + timestamp: std::time::SystemTime::now(), + session_id: "child".into(), + parent_session_id: None, + })); + callback(SubAgentCallbackEvent::Forwarded(SessionEvent { + event: AgentEvent::SessionStarted, + timestamp: std::time::SystemTime::now(), + session_id: "grandchild".into(), + parent_session_id: Some("child".into()), + })); + + let child = rx.recv().await.unwrap(); + let grandchild = rx.recv().await.unwrap(); + assert_eq!(child.session_id, "child"); + assert_eq!(child.parent_session_id.as_deref(), Some(parent.id())); + assert_eq!(grandchild.session_id, "grandchild"); + assert_eq!(grandchild.parent_session_id.as_deref(), Some("child")); + } + #[test] fn no_callback_does_not_panic() { // Manager without callback should not panic on emit diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs index 17a2b49d9..d9bdc4ed4 100644 --- a/lib/crates/fabro-agent/src/types.rs +++ b/lib/crates/fabro-agent/src/types.rs @@ -185,11 +185,6 @@ pub enum AgentEvent { agent_id: String, depth: usize, }, - SubAgentEvent { - agent_id: String, - depth: usize, - event: Box, - }, McpServerReady { server_name: String, tool_count: usize, @@ -237,8 +232,7 @@ impl AgentEvent { Self::TextDelta { .. } | Self::ReasoningDelta { .. } | Self::AssistantOutputReplace { .. } - | Self::ToolCallOutputDelta { .. } - | Self::SubAgentEvent { .. } => {} + | Self::ToolCallOutputDelta { .. } => {} Self::ToolCallStarted { tool_name, tool_call_id, @@ -392,6 +386,8 @@ pub struct SessionEvent { #[serde(with = "system_time_iso8601")] pub timestamp: SystemTime, pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, } #[cfg(test)] @@ -404,9 +400,11 @@ mod tests { event: AgentEvent::SessionStarted, timestamp: SystemTime::now(), session_id: "sess_1".into(), + parent_session_id: None, }; assert!(matches!(event.event, AgentEvent::SessionStarted)); assert_eq!(event.session_id, "sess_1"); + assert_eq!(event.parent_session_id, None); } #[test] @@ -498,21 +496,6 @@ mod tests { assert!(matches!(event, AgentEvent::SubAgentClosed { depth: 2, .. })); } - #[test] - fn subagent_event_wraps_child_event() { - let child = AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc-1".into(), - arguments: serde_json::json!({}), - }; - let event = AgentEvent::SubAgentEvent { - agent_id: "sa-1".into(), - depth: 1, - event: Box::new(child), - }; - assert!(matches!(event, AgentEvent::SubAgentEvent { depth: 1, .. })); - } - #[test] fn subagent_events_serde_round_trip() { let events = vec![ @@ -536,39 +519,53 @@ mod tests { agent_id: "sa-1".into(), depth: 0, }, - AgentEvent::SubAgentEvent { - agent_id: "sa-1".into(), - depth: 1, - event: Box::new(AgentEvent::SessionStarted), - }, ]; let json = serde_json::to_string(&events).unwrap(); let deserialized: Vec = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.len(), 5); - assert!( - matches!(&deserialized[4], AgentEvent::SubAgentEvent { event, .. } if matches!(event.as_ref(), AgentEvent::SessionStarted)) - ); + assert_eq!(deserialized.len(), 4); } #[test] - fn session_event_serde_round_trip() { + fn session_event_serde_round_trip_without_parent_session_id() { let event = SessionEvent { event: AgentEvent::SessionStarted, timestamp: SystemTime::now(), session_id: "sess_42".into(), + parent_session_id: None, }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("sess_42")); assert!(json.contains("SessionStarted")); - // Timestamp should be ISO-8601 + assert!(!json.contains("parent_session_id")); assert!(json.contains('T')); assert!(json.contains('Z')); let deserialized: SessionEvent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.session_id, "sess_42"); + assert_eq!(deserialized.parent_session_id, None); assert!(matches!(deserialized.event, AgentEvent::SessionStarted)); } + #[test] + fn session_event_serde_round_trip_with_parent_session_id() { + let event = SessionEvent { + event: AgentEvent::SessionStarted, + timestamp: SystemTime::now(), + session_id: "sess_child".into(), + parent_session_id: Some("sess_parent".into()), + }; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("sess_child")); + assert!(json.contains("sess_parent")); + + let deserialized: SessionEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.session_id, "sess_child"); + assert_eq!( + deserialized.parent_session_id.as_deref(), + Some("sess_parent") + ); + } + #[test] fn mcp_server_ready_constructible() { let event = AgentEvent::McpServerReady { diff --git a/lib/crates/fabro-git-storage/Cargo.toml b/lib/crates/fabro-checkpoint/Cargo.toml similarity index 60% rename from lib/crates/fabro-git-storage/Cargo.toml rename to lib/crates/fabro-checkpoint/Cargo.toml index 4580c4d80..02a87e246 100644 --- a/lib/crates/fabro-git-storage/Cargo.toml +++ b/lib/crates/fabro-checkpoint/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "fabro-git-storage" +name = "fabro-checkpoint" edition.workspace = true version.workspace = true publish = false license.workspace = true -description = "Store structured data in git without touching the working directory" +description = "Git-backed checkpoint storage for Fabro workflows" repository = "https://github.com/brynary/arc" [lib] @@ -14,10 +14,13 @@ doctest = false workspace = true [dependencies] +fabro-types = { path = "../fabro-types" } git2.workspace = true +serde.workspace = true +serde_json.workspace = true thiserror.workspace = true tracing.workspace = true -walkdir.workspace = true [dev-dependencies] +chrono.workspace = true tempfile = "3" diff --git a/lib/crates/fabro-checkpoint/src/author.rs b/lib/crates/fabro-checkpoint/src/author.rs new file mode 100644 index 000000000..857ed5572 --- /dev/null +++ b/lib/crates/fabro-checkpoint/src/author.rs @@ -0,0 +1,56 @@ +use std::fmt::Write; + +use fabro_types::settings::server::GitAuthorSettings; + +/// Resolved git author identity for checkpoint commits. +#[derive(Debug, Clone, PartialEq)] +pub struct GitAuthor { + pub name: String, + pub email: String, +} + +impl Default for GitAuthor { + fn default() -> Self { + Self { + name: "Fabro".into(), + email: "noreply@fabro.sh".into(), + } + } +} + +impl GitAuthor { + /// Create a `GitAuthor` from optional name/email, falling back to defaults. + pub fn from_options(name: Option, email: Option) -> Self { + let defaults = Self::default(); + Self { + name: name.unwrap_or(defaults.name), + email: email.unwrap_or(defaults.email), + } + } + + /// Returns true when this identity matches the default Fabro identity. + pub fn is_default(&self) -> bool { + let defaults = Self::default(); + self.name == defaults.name && self.email == defaults.email + } + + /// Append the Fabro footer (and Co-Authored-By when the author is not the + /// default identity) to a commit message. + pub fn append_footer(&self, message: &mut String) { + message.push_str("\n\u{2692}\u{fe0f} Generated with [Fabro](https://fabro.sh)\n"); + if !self.is_default() { + let defaults = Self::default(); + let _ = write!( + message, + "\nCo-Authored-By: {} <{}>\n", + defaults.name, defaults.email + ); + } + } +} + +impl From<&GitAuthorSettings> for GitAuthor { + fn from(value: &GitAuthorSettings) -> Self { + Self::from_options(value.name.clone(), value.email.clone()) + } +} diff --git a/lib/crates/fabro-git-storage/src/branchstore.rs b/lib/crates/fabro-checkpoint/src/branch.rs similarity index 98% rename from lib/crates/fabro-git-storage/src/branchstore.rs rename to lib/crates/fabro-checkpoint/src/branch.rs index 408cb040f..f5adc10a8 100644 --- a/lib/crates/fabro-git-storage/src/branchstore.rs +++ b/lib/crates/fabro-checkpoint/src/branch.rs @@ -2,7 +2,7 @@ use git2::{Oid, Signature}; use tracing::{debug, warn}; use crate::Result; -use crate::gitobj::{FileMode, Store, TreeEntries}; +use crate::git::{FileMode, Store, TreeEntries}; /// Metadata about a commit, returned by `log`. #[derive(Debug)] @@ -47,7 +47,7 @@ impl<'a> BranchStore<'a> { self.objects .write_commit(empty_tree, &[], "initialize branch", &self.author)?; self.objects.update_ref(&self.branch, commit_oid)?; - debug!(branch = %self.branch, "Created git storage branch"); + debug!(branch = %self.branch, "Created checkpoint branch"); Ok(()) } @@ -74,7 +74,7 @@ impl<'a> BranchStore<'a> { self.objects .write_commit(new_tree, &[parent_oid], message, &self.author)?; self.objects.update_ref(&self.branch, commit_oid)?; - debug!(branch = %self.branch, commit = %commit_oid, "Wrote git storage commit"); + debug!(branch = %self.branch, commit = %commit_oid, "Wrote checkpoint commit"); Ok(commit_oid) } @@ -219,7 +219,7 @@ pub fn sharded_path(id: &str, prefix_len: usize) -> String { #[cfg(test)] mod tests { use super::*; - use crate::gitobj::FileMode; + use crate::git::FileMode; use git2::Repository; fn temp_repo() -> (tempfile::TempDir, Store) { diff --git a/lib/crates/fabro-git-storage/src/error.rs b/lib/crates/fabro-checkpoint/src/error.rs similarity index 55% rename from lib/crates/fabro-git-storage/src/error.rs rename to lib/crates/fabro-checkpoint/src/error.rs index 07f3ee442..fa5df4467 100644 --- a/lib/crates/fabro-git-storage/src/error.rs +++ b/lib/crates/fabro-checkpoint/src/error.rs @@ -16,3 +16,17 @@ pub enum Error { #[error("branch {branch} not found")] BranchNotFound { branch: String }, } + +#[derive(Debug, thiserror::Error)] +pub enum MetadataError { + #[error(transparent)] + Storage(#[from] Error), + + #[error("deserialize {entity} on branch {branch}: {source}")] + Deserialize { + entity: &'static str, + branch: String, + #[source] + source: serde_json::Error, + }, +} diff --git a/lib/crates/fabro-git-storage/src/gitobj.rs b/lib/crates/fabro-checkpoint/src/git.rs similarity index 99% rename from lib/crates/fabro-git-storage/src/gitobj.rs rename to lib/crates/fabro-checkpoint/src/git.rs index 1a4e2cbe4..ce4d1f2cc 100644 --- a/lib/crates/fabro-git-storage/src/gitobj.rs +++ b/lib/crates/fabro-checkpoint/src/git.rs @@ -565,7 +565,7 @@ mod tests { fn read_blob_at_returns_content() { let (_dir, store) = temp_repo(); let sig = Signature::now("Test", "test@example.com").unwrap(); - let bs = crate::branchstore::BranchStore::new(&store, "test/data", &sig); + let bs = crate::branch::BranchStore::new(&store, "test/data", &sig); bs.ensure_branch().unwrap(); bs.write_entry("hello.txt", b"world", "add hello").unwrap(); @@ -580,7 +580,7 @@ mod tests { fn read_blob_at_returns_none_for_missing_path() { let (_dir, store) = temp_repo(); let sig = Signature::now("Test", "test@example.com").unwrap(); - let bs = crate::branchstore::BranchStore::new(&store, "test/data", &sig); + let bs = crate::branch::BranchStore::new(&store, "test/data", &sig); bs.ensure_branch().unwrap(); bs.write_entry("hello.txt", b"world", "add hello").unwrap(); diff --git a/lib/crates/fabro-checkpoint/src/lib.rs b/lib/crates/fabro-checkpoint/src/lib.rs new file mode 100644 index 000000000..7bdc82a5e --- /dev/null +++ b/lib/crates/fabro-checkpoint/src/lib.rs @@ -0,0 +1,10 @@ +pub mod author; +pub mod branch; +pub mod error; +pub mod git; +pub mod metadata; +pub mod trailer; + +pub const META_BRANCH_PREFIX: &str = "fabro/meta/"; + +pub use error::{Error, MetadataError, Result}; diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs new file mode 100644 index 000000000..e73800870 --- /dev/null +++ b/lib/crates/fabro-checkpoint/src/metadata.rs @@ -0,0 +1,436 @@ +use std::path::{Path, PathBuf}; + +use fabro_types::{Checkpoint, RunRecord, StartRecord}; +use git2::{Repository, Signature}; + +use crate::META_BRANCH_PREFIX; +use crate::author::GitAuthor; +use crate::branch::BranchStore; +use crate::error::{Error, MetadataError}; +use crate::git::Store; + +/// Git-native metadata storage for pipeline runs. +/// +/// Stores checkpoint data, run records, and metadata on an orphan branch +/// (`fabro/meta/{run_id}`) so that runs can be resumed from git alone. +pub struct MetadataStore { + repo_path: PathBuf, + author: GitAuthor, +} + +impl MetadataStore { + pub fn new(repo_path: impl Into, author: &GitAuthor) -> Self { + Self { + repo_path: repo_path.into(), + author: author.clone(), + } + } + + /// Returns the branch name for a run: `fabro/meta/{run_id}`. + pub fn branch_name(run_id: &str) -> String { + format!("{META_BRANCH_PREFIX}{run_id}") + } + + /// Format a commit message with the standard Fabro footer appended. + fn commit_message(&self, subject: &str) -> String { + let mut msg = format!("{subject}\n"); + self.author.append_footer(&mut msg); + msg + } + + fn open_store(&self) -> Result<(Store, Signature<'static>), MetadataError> { + let repo = Repository::discover(&self.repo_path).map_err(Error::from)?; + let store = Store::new(repo); + let sig = Signature::now(&self.author.name, &self.author.email).map_err(Error::from)?; + Ok((store, sig)) + } + + /// Initialize a run's metadata branch with the given files. + /// + /// Callers pass all files (run.json, start.json, sandbox.json, etc.) + /// via the `files` slice. + pub fn init_run(&self, run_id: &str, files: &[(&str, &[u8])]) -> Result<(), MetadataError> { + let (store, sig) = self.open_store()?; + let branch = Self::branch_name(run_id); + let branch_store = BranchStore::new(&store, &branch, &sig); + branch_store.ensure_branch()?; + let message = self.commit_message("init run"); + branch_store.write_entries(files, &message)?; + Ok(()) + } + + /// Write arbitrary files to the metadata branch without overwriting checkpoint.json. + pub fn write_files( + &self, + run_id: &str, + entries: &[(&str, &[u8])], + message: &str, + ) -> Result<(), MetadataError> { + let (store, sig) = self.open_store()?; + let branch = Self::branch_name(run_id); + let branch_store = BranchStore::new(&store, &branch, &sig); + let message = self.commit_message(message); + branch_store.write_entries(entries, &message)?; + Ok(()) + } + + /// Write checkpoint data (and optional artifacts) to the metadata branch. + /// Returns the SHA of the new commit on the shadow branch. + pub fn write_checkpoint( + &self, + run_id: &str, + checkpoint_json: &[u8], + artifacts: &[(&str, &[u8])], + ) -> Result { + let (store, sig) = self.open_store()?; + let branch = Self::branch_name(run_id); + let branch_store = BranchStore::new(&store, &branch, &sig); + let mut entries: Vec<(&str, &[u8])> = vec![("checkpoint.json", checkpoint_json)]; + entries.extend_from_slice(artifacts); + let message = self.commit_message("checkpoint"); + let oid = branch_store.write_entries(&entries, &message)?; + Ok(oid.to_string()) + } + + /// Read a single file from the metadata branch. Returns `None` if branch or path doesn't exist. + fn read_file( + repo_path: &Path, + run_id: &str, + path: &str, + ) -> Result>, MetadataError> { + let Ok(repo) = Repository::discover(repo_path) else { + return Ok(None); + }; + let store = Store::new(repo); + let sig = Signature::now("Fabro", "noreply@fabro.sh").map_err(Error::from)?; + let branch = Self::branch_name(run_id); + let branch_store = BranchStore::new(&store, &branch, &sig); + Ok(branch_store.read_entry(path)?) + } + + /// Read a checkpoint from the metadata branch. Returns `None` if branch or file doesn't exist. + pub fn read_checkpoint( + repo_path: &Path, + run_id: &str, + ) -> Result, MetadataError> { + let branch = Self::branch_name(run_id); + match Self::read_file(repo_path, run_id, "checkpoint.json")? { + Some(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|source| { + MetadataError::Deserialize { + entity: "checkpoint", + branch, + source, + } + }), + None => Ok(None), + } + } + + /// Read the run record from the metadata branch. Returns `None` if not found. + pub fn read_run_record( + repo_path: &Path, + run_id: &str, + ) -> Result, MetadataError> { + let branch = Self::branch_name(run_id); + match Self::read_file(repo_path, run_id, "run.json")? { + Some(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|source| { + MetadataError::Deserialize { + entity: "run record", + branch, + source, + } + }), + None => Ok(None), + } + } + + /// Read the start record from the metadata branch. Returns `None` if not found. + pub fn read_start_record( + repo_path: &Path, + run_id: &str, + ) -> Result, MetadataError> { + let branch = Self::branch_name(run_id); + match Self::read_file(repo_path, run_id, "start.json")? { + Some(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|source| { + MetadataError::Deserialize { + entity: "start record", + branch, + source, + } + }), + None => Ok(None), + } + } + + /// Read an artifact from the metadata branch. Returns `None` if not found. + pub fn read_artifact( + repo_path: &Path, + run_id: &str, + key: &str, + ) -> Result>, MetadataError> { + Self::read_file(repo_path, run_id, &format!("artifacts/{key}.json")) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use chrono::{TimeZone, Utc}; + use fabro_types::{FabroSettings, Graph, fixtures}; + + /// Create a temporary git repo with an initial commit. + fn init_repo(dir: &Path) { + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", + "user.name=test", + "-c", + "user.email=test@test", + "commit", + "--allow-empty", + "-m", + "init", + ]) + .current_dir(dir) + .output() + .unwrap(); + } + + fn test_run_record(run_id: fabro_types::RunId) -> RunRecord { + RunRecord { + run_id, + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(), + settings: FabroSettings::default(), + graph: Graph::new("test"), + workflow_slug: None, + working_directory: PathBuf::from("/tmp"), + host_repo_path: None, + base_branch: None, + labels: HashMap::new(), + } + } + + fn test_checkpoint( + current_node: &str, + completed_nodes: Vec, + next_node_id: Option, + ) -> Checkpoint { + Checkpoint { + timestamp: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(), + current_node: current_node.to_string(), + completed_nodes, + node_retries: HashMap::new(), + context_values: HashMap::new(), + node_outcomes: HashMap::new(), + next_node_id, + git_commit_sha: None, + loop_failure_signatures: HashMap::new(), + restart_failure_signatures: HashMap::new(), + node_visits: HashMap::new(), + } + } + + fn branch_entry(repo_dir: &Path, run_id: &str, path: &str) -> Vec { + let repo = Repository::discover(repo_dir).unwrap(); + let store = Store::new(repo); + let sig = Signature::now("Test", "test@example.com").unwrap(); + let branch = MetadataStore::branch_name(run_id); + let branch_store = BranchStore::new(&store, &branch, &sig); + branch_store.read_entry(path).unwrap().unwrap() + } + + #[test] + fn metadata_store_init_run_and_read() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + + let store = MetadataStore::new(dir.path(), &GitAuthor::default()); + let run_id = fixtures::RUN_1.to_string(); + let run_record = serde_json::to_vec_pretty(&test_run_record(fixtures::RUN_1)).unwrap(); + store + .init_run(&run_id, &[("run.json", &run_record)]) + .unwrap(); + + let read_record = MetadataStore::read_run_record(dir.path(), &run_id) + .unwrap() + .unwrap(); + assert_eq!(read_record.run_id, fixtures::RUN_1); + assert_eq!(read_record.graph.name, "test"); + } + + #[test] + fn metadata_store_write_and_read_checkpoint() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + + let run_id = fixtures::RUN_2.to_string(); + let store = MetadataStore::new(dir.path(), &GitAuthor::default()); + store.init_run(&run_id, &[]).unwrap(); + + let mut checkpoint = test_checkpoint( + "node_a", + vec!["start".to_string()], + Some("node_b".to_string()), + ); + checkpoint + .context_values + .insert("goal".to_string(), serde_json::json!("test")); + let checkpoint_json = serde_json::to_vec_pretty(&checkpoint).unwrap(); + store + .write_checkpoint(&run_id, &checkpoint_json, &[]) + .unwrap(); + + let loaded = MetadataStore::read_checkpoint(dir.path(), &run_id) + .unwrap() + .unwrap(); + assert_eq!(loaded.current_node, "node_a"); + assert_eq!(loaded.completed_nodes, vec!["start"]); + assert_eq!(loaded.next_node_id.as_deref(), Some("node_b")); + assert_eq!( + loaded.context_values.get("goal"), + Some(&serde_json::json!("test")) + ); + } + + #[test] + fn metadata_store_write_checkpoint_overwrites() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + + let run_id = fixtures::RUN_3.to_string(); + let store = MetadataStore::new(dir.path(), &GitAuthor::default()); + store.init_run(&run_id, &[]).unwrap(); + + let checkpoint_one = + serde_json::to_vec_pretty(&test_checkpoint("node_a", vec!["start".to_string()], None)) + .unwrap(); + store + .write_checkpoint(&run_id, &checkpoint_one, &[]) + .unwrap(); + + let checkpoint_two = serde_json::to_vec_pretty(&test_checkpoint( + "node_b", + vec!["start".to_string(), "node_a".to_string()], + Some("node_c".to_string()), + )) + .unwrap(); + store + .write_checkpoint(&run_id, &checkpoint_two, &[]) + .unwrap(); + + let loaded = MetadataStore::read_checkpoint(dir.path(), &run_id) + .unwrap() + .unwrap(); + assert_eq!(loaded.current_node, "node_b"); + assert_eq!(loaded.completed_nodes.len(), 2); + } + + #[test] + fn metadata_store_read_checkpoint_missing_branch() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + + let result = MetadataStore::read_checkpoint(dir.path(), "NONEXISTENT").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn metadata_store_artifact_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + + let run_id = fixtures::RUN_4.to_string(); + let store = MetadataStore::new(dir.path(), &GitAuthor::default()); + store.init_run(&run_id, &[]).unwrap(); + + let artifact_data = br#"{"large_output":"some data"}"#; + let checkpoint_json = + serde_json::to_vec_pretty(&test_checkpoint("node_a", Vec::new(), None)).unwrap(); + store + .write_checkpoint( + &run_id, + &checkpoint_json, + &[("artifacts/response.plan.json", artifact_data.as_slice())], + ) + .unwrap(); + + let read_back = MetadataStore::read_artifact(dir.path(), &run_id, "response.plan") + .unwrap() + .unwrap(); + assert_eq!(read_back, artifact_data); + } + + #[test] + fn metadata_store_write_files() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + + let run_id = fixtures::RUN_5.to_string(); + let store = MetadataStore::new(dir.path(), &GitAuthor::default()); + let run_record = serde_json::to_vec_pretty(&test_run_record(fixtures::RUN_5)).unwrap(); + store + .init_run(&run_id, &[("run.json", &run_record)]) + .unwrap(); + + store + .write_files( + &run_id, + &[("retro.json", b"{\"status\":\"ok\"}")], + "finalize run", + ) + .unwrap(); + + let data = branch_entry(dir.path(), &run_id, "retro.json"); + assert_eq!(data, b"{\"status\":\"ok\"}"); + + let record = MetadataStore::read_run_record(dir.path(), &run_id) + .unwrap() + .unwrap(); + assert_eq!(record.run_id, fixtures::RUN_5); + } + + #[test] + fn metadata_store_init_run_with_extra_files() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + + let run_id = fixtures::RUN_6.to_string(); + let store = MetadataStore::new(dir.path(), &GitAuthor::default()); + store + .init_run(&run_id, &[("sandbox.json", b"{\"type\":\"local\"}")]) + .unwrap(); + + let data = branch_entry(dir.path(), &run_id, "sandbox.json"); + assert_eq!(data, b"{\"type\":\"local\"}"); + } + + #[test] + fn metadata_store_read_start_record_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + + let run_id = fixtures::RUN_6.to_string(); + let store = MetadataStore::new(dir.path(), &GitAuthor::default()); + let start_record = StartRecord { + run_id: fixtures::RUN_6, + start_time: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(), + run_branch: Some("fabro/run/test".to_string()), + base_sha: None, + }; + let bytes = serde_json::to_vec_pretty(&start_record).unwrap(); + store.init_run(&run_id, &[("start.json", &bytes)]).unwrap(); + + let loaded = MetadataStore::read_start_record(dir.path(), &run_id) + .unwrap() + .unwrap(); + assert_eq!(loaded.run_id, fixtures::RUN_6); + assert_eq!(loaded.run_branch.as_deref(), Some("fabro/run/test")); + } +} diff --git a/lib/crates/fabro-git-storage/src/trailerlink.rs b/lib/crates/fabro-checkpoint/src/trailer.rs similarity index 100% rename from lib/crates/fabro-git-storage/src/trailerlink.rs rename to lib/crates/fabro-checkpoint/src/trailer.rs diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 0a713f587..dc375eede 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -32,7 +32,7 @@ fabro-mcp = { path = "../fabro-mcp" } fabro-proctitle = { path = "../fabro-proctitle" } fabro-retro = { path = "../fabro-retro" } fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] } -fabro-git-storage = { path = "../fabro-git-storage" } +fabro-checkpoint = { path = "../fabro-checkpoint" } fabro-graphviz = { path = "../fabro-graphviz" } fabro-validate = { path = "../fabro-validate" } fabro-workflow = { path = "../fabro-workflow" } @@ -42,6 +42,7 @@ fabro-store = { path = "../fabro-store" } fabro-types = { path = "../fabro-types" } fabro-util = { path = "../fabro-util" } clap.workspace = true +clap_complete.workspace = true cli-table.workspace = true console.workspace = true indicatif.workspace = true @@ -92,7 +93,7 @@ chrono = { workspace = true } [dev-dependencies] assert_cmd = "2" -insta = { workspace = true } +insta = { workspace = true, features = ["filters"] } paste = "1" predicates = "3" serde_json.workspace = true diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index ec4051064..ebaea83d9 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -764,9 +764,9 @@ pub(crate) enum Commands { #[command(subcommand)] command: Option, }, - /// Start the HTTP API server + /// Server operations #[cfg(feature = "server")] - Serve(fabro_server::serve::ServeArgs), + Server(ServerNamespace), /// Check environment and integration health Doctor { /// Show detailed information for each check @@ -777,9 +777,6 @@ pub(crate) enum Commands { #[arg(long)] dry_run: bool, }, - /// Initialize a new project (deprecated: use `repo init`) - #[command(hide = true)] - Init, /// Set up the Fabro environment (LLMs, certs, GitHub) Install { /// Base URL for the web UI (used for OAuth callback URLs) @@ -812,6 +809,8 @@ pub(crate) enum Commands { #[command(subcommand)] command: SandboxCommand, }, + /// Generate shell completions + Completion(CompletionArgs), /// System maintenance commands System(SystemNamespace), /// Send a queued analytics event (internal) @@ -855,13 +854,14 @@ impl Commands { None => "model", }, #[cfg(feature = "server")] - Self::Serve(_) => "serve", + Self::Server(ns) => match &ns.command { + ServerCommand::Start(_) => "server start", + }, Self::Doctor { .. } => "doctor", Self::Repo(ns) => match &ns.command { RepoCommand::Init { .. } => "repo init", RepoCommand::Deinit => "repo deinit", }, - Self::Init => "init", Self::Install { .. } => "install", Self::Pr(ns) => match &ns.command { PrCommand::Create(_) => "pr create", @@ -891,6 +891,7 @@ impl Commands { ProviderCommand::Login(_) => "provider login", }, Self::Sandbox { command } => command.name(), + Self::Completion(_) => "completion", Self::System(ns) => match &ns.command { SystemCommand::Prune(_) => "system prune", SystemCommand::Df(_) => "system df", @@ -966,6 +967,20 @@ pub(crate) enum SecretCommand { Set(SecretSetArgs), } +#[cfg(feature = "server")] +#[derive(Args)] +pub(crate) struct ServerNamespace { + #[command(subcommand)] + pub(crate) command: ServerCommand, +} + +#[cfg(feature = "server")] +#[derive(Subcommand)] +pub(crate) enum ServerCommand { + /// Start the HTTP API server + Start(fabro_server::serve::ServeArgs), +} + #[derive(Args)] pub(crate) struct SystemNamespace { #[command(subcommand)] @@ -1024,6 +1039,12 @@ pub(crate) enum ProviderCommand { Login(ProviderLoginArgs), } +#[derive(Args)] +pub(crate) struct CompletionArgs { + /// Shell to generate completions for + pub shell: clap_complete::Shell, +} + #[derive(Args)] pub(crate) struct LlmNamespace { #[command(subcommand)] diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index b05ac10f8..327f7a877 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -745,7 +745,7 @@ pub(crate) async fn run_install(web_url: &str) -> Result<()> { eprintln!(" To start Arc, run these commands:"); eprintln!(); - eprintln!(" fabro serve"); + eprintln!(" fabro server start"); eprintln!(" cd apps/fabro-web && npx react-router dev"); eprintln!(); } diff --git a/lib/crates/fabro-cli/src/commands/run/detached.rs b/lib/crates/fabro-cli/src/commands/run/detached.rs index 855bf4bf4..56ea45633 100644 --- a/lib/crates/fabro-cli/src/commands/run/detached.rs +++ b/lib/crates/fabro-cli/src/commands/run/detached.rs @@ -37,7 +37,7 @@ pub(crate) async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bo let services = StartServices { cancel_token: None, - emitter: Arc::new(EventEmitter::new()), + emitter: Arc::new(EventEmitter::new(run_record.run_id)), interviewer: Arc::new(FileInterviewer::new( runtime_state.interview_request_path(), runtime_state.interview_response_path(), diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index 733013509..6de549381 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -1,7 +1,7 @@ use anyhow::Context; use anyhow::Result; +use fabro_checkpoint::git::Store; use fabro_config::FabroSettingsExt; -use fabro_git_storage::gitobj::Store; use fabro_util::terminal::Styles; use fabro_workflow::operations::{ ForkRunInput, RewindTarget, build_timeline_or_rebuild, find_run_id_by_prefix_or_store, fork, diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index 6650b63b1..3b25b1c40 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -341,8 +341,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option let ts = format_timestamp(envelope.get("ts")?.as_str()?); match event { - "WorkflowRunStarted" => { - let name = str_field(&envelope, "workflow_name").unwrap_or("?"); + "run.started" => { + let name = prop_str_field(&envelope, "name").unwrap_or("?"); let run_id = str_field(&envelope, "run_id").unwrap_or("?"); let header = format!( "{} {} {} {}", @@ -351,7 +351,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.bold.apply_to(name), styles.dim.apply_to(run_id), ); - match str_field(&envelope, "goal") { + match prop_str_field(&envelope, "goal") { Some(goal) if !goal.is_empty() => { let body = render_indented_markdown(styles, goal, " "); Some(format!("{header}\n{body}\n")) @@ -359,9 +359,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option _ => Some(header), } } - "WorkflowRunCompleted" => { - let duration = format_duration_ms(envelope.get("duration_ms")); - let status_str = match str_field(&envelope, "status") { + "run.completed" => { + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); + let status_str = match prop_str_field(&envelope, "status") { Some(status) if !status.is_empty() => status, _ => "success", }; @@ -370,7 +370,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option "success" | "partial_success" => &styles.bold_green, _ => &styles.bold_red, }; - let cost = format_cost(envelope.get("total_cost")); + let cost = format_cost(prop_field(&envelope, "total_cost")); let mut lines = vec![format!( "{} {} {} {}", @@ -380,7 +380,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&cost), )]; - if let Some(usage) = envelope.get("usage") { + if let Some(usage) = prop_field(&envelope, "usage") { let total = usage .get("total_tokens") .and_then(serde_json::Value::as_i64) @@ -433,8 +433,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option Some(lines.join("\n")) } - "WorkflowRunFailed" => { - let error = str_field(&envelope, "error").unwrap_or("unknown error"); + "run.failed" => { + let error = prop_str_field(&envelope, "error").unwrap_or("unknown error"); Some(format!( "{} {} {}", styles.dim.apply_to(&ts), @@ -442,10 +442,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.red.apply_to(error), )) } - "RunNotice" => { - let level = str_field(&envelope, "level").unwrap_or("info"); - let code = str_field(&envelope, "code").unwrap_or(""); - let message = str_field(&envelope, "message").unwrap_or(""); + "run.notice" => { + let level = prop_str_field(&envelope, "level").unwrap_or("info"); + let code = prop_str_field(&envelope, "code").unwrap_or(""); + let message = prop_str_field(&envelope, "message").unwrap_or(""); let label = match level { "warn" => styles.yellow.apply_to("Warning:").to_string(), "error" => styles.bold_red.apply_to("Error:").to_string(), @@ -464,7 +464,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option code_suffix, )) } - "StageStarted" => { + "stage.started" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); Some(format!( "{} {} {}", @@ -473,36 +473,39 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.bold.apply_to(label), )) } - "StageCompleted" => { + "stage.completed" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); - let duration = format_duration_ms(envelope.get("duration_ms")); - let cost = format_cost(envelope.get("cost")); - let turns = envelope - .get("turns") + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); + let usage = prop_field(&envelope, "usage"); + let cost = format_cost(usage.and_then(|value| value.get("cost"))); + let input_tokens = usage + .and_then(|value| value.get("input_tokens")) .and_then(serde_json::Value::as_u64) .unwrap_or(0); - let tools = envelope - .get("tool_calls") + let output_tokens = usage + .and_then(|value| value.get("output_tokens")) .and_then(serde_json::Value::as_u64) .unwrap_or(0); - let tokens = envelope - .get("total_tokens") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let stats = format!("({turns} turns, {tools} tools, {})", format_tokens(tokens)); - Some(format!( - "{} {} {} {} {} {}", + let token_total = input_tokens.saturating_add(output_tokens); + let mut line = format!( + "{} {} {} {} {}", styles.dim.apply_to(&ts), styles.green.apply_to("\u{2713}"), styles.bold.apply_to(label), cost, duration, - styles.dim.apply_to(&stats), - )) + ); + if token_total > 0 { + line.push_str(&format!( + " {}", + styles.dim.apply_to(format_tokens(token_total)) + )); + } + Some(line) } - "StageFailed" => { + "stage.failed" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); - let error = str_field(&envelope, "error").unwrap_or("unknown error"); + let error = prop_str_field(&envelope, "error").unwrap_or("unknown error"); Some(format!( "{} {} {} {}", styles.dim.apply_to(&ts), @@ -511,10 +514,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.red.apply_to(error), )) } - "Agent.AssistantMessage" => { + "agent.message" => { let stage = str_field(&envelope, "node_id").unwrap_or("?"); - let model = str_field(&envelope, "model").unwrap_or("?"); - let text = str_field(&envelope, "text").unwrap_or(""); + let model = prop_str_field(&envelope, "model").unwrap_or("?"); + let text = prop_str_field(&envelope, "text").unwrap_or(""); let header = format!( "{} {} {} {}{}{}", styles.dim.apply_to(&ts), @@ -527,8 +530,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option let body = render_indented_markdown(styles, text, " "); Some(format!("{header}\n{body}\n")) } - "Agent.ToolCallStarted" => { - let tool = str_field(&envelope, "tool_name").unwrap_or("?"); + "agent.tool.started" => { + let tool = prop_str_field(&envelope, "tool_name").unwrap_or("?"); let detail = tool_detail(&envelope); let display = match detail { Some(value) => format!("{tool}({value})"), @@ -541,10 +544,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&display), )) } - "Agent.ToolCallCompleted" => { - let tool = str_field(&envelope, "tool_name").unwrap_or("?"); - let is_error = envelope - .get("is_error") + "agent.tool.completed" => { + let tool = prop_str_field(&envelope, "tool_name").unwrap_or("?"); + let is_error = prop_field(&envelope, "is_error") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let detail = tool_detail(&envelope); @@ -561,10 +563,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option display, )) } - "EdgeSelected" => { - let to = str_field(&envelope, "to_node_id").unwrap_or("?"); - let reason = str_field(&envelope, "reason").unwrap_or("?"); - let condition = str_field(&envelope, "condition"); + "edge.selected" => { + let to = prop_str_field(&envelope, "to_node").unwrap_or("?"); + let reason = prop_str_field(&envelope, "reason").unwrap_or("?"); + let condition = prop_str_field(&envelope, "condition"); let detail = match condition { Some(value) => format!(" [{value}]"), None => String::new(), @@ -578,9 +580,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&detail), )) } - "Sandbox.Ready" => { - let provider = str_field(&envelope, "sandbox_provider").unwrap_or("?"); - let duration = format_duration_ms(envelope.get("duration_ms")); + "sandbox.ready" => { + let provider = prop_str_field(&envelope, "provider").unwrap_or("?"); + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); Some(format!( "{} Sandbox: {} {}", styles.dim.apply_to(&ts), @@ -588,26 +590,28 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&duration), )) } - "SetupCompleted" => { - let count = envelope - .get("command_count") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let duration = format_duration_ms(envelope.get("duration_ms")); - Some(format!( - "{} Setup: {} commands {}", - styles.dim.apply_to(&ts), - count, - styles.dim.apply_to(&duration), - )) + "setup.completed" => { + let count = prop_field(&envelope, "command_count").and_then(serde_json::Value::as_u64); + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); + Some(match count { + Some(count) => format!( + "{} Setup: {} commands {}", + styles.dim.apply_to(&ts), + count, + styles.dim.apply_to(&duration), + ), + None => format!( + "{} Setup: {}", + styles.dim.apply_to(&ts), + 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!( @@ -618,9 +622,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option .apply_to(format!("compaction: {original}\u{2192}{preserved} turns")), )) } - "ParallelStarted" => { - let count = envelope - .get("branch_count") + "parallel.started" => { + let count = prop_field(&envelope, "branch_count") .and_then(serde_json::Value::as_u64) .unwrap_or(0); Some(format!( @@ -630,7 +633,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option count, )) } - "ParallelBranchStarted" => { + "parallel.branch.started" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); Some(format!( "{} {} {}", @@ -639,7 +642,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option label, )) } - "ParallelBranchCompleted" => { + "parallel.branch.completed" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); Some(format!( "{} {} {}", @@ -648,8 +651,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option label, )) } - "ParallelCompleted" => { - let duration = format_duration_ms(envelope.get("duration_ms")); + "parallel.completed" => { + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); Some(format!( "{} {} Parallel {}", styles.dim.apply_to(&ts), @@ -657,10 +660,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option duration, )) } - "PullRequestCreated" => { - let url = str_field(&envelope, "pr_url").unwrap_or("?"); - let draft = envelope - .get("draft") + "pull_request.created" => { + let url = prop_str_field(&envelope, "pr_url").unwrap_or("?"); + let draft = prop_field(&envelope, "draft") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let label = if draft { "Draft PR:" } else { "PR:" }; @@ -671,8 +673,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option url, )) } - "PullRequestFailed" => { - let error = str_field(&envelope, "error").unwrap_or("unknown error"); + "pull_request.failed" => { + let error = prop_str_field(&envelope, "error").unwrap_or("unknown error"); Some(format!( "{} {} {}", styles.dim.apply_to(&ts), @@ -680,8 +682,8 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.red.apply_to(error), )) } - "RetroCompleted" => { - let duration = format_duration_ms(envelope.get("duration_ms")); + "retro.completed" => { + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); Some(format!( "{} {} Retro {}", styles.dim.apply_to(&ts), @@ -689,9 +691,9 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option duration, )) } - "RetroFailed" => { - let error = str_field(&envelope, "error").unwrap_or("unknown error"); - let duration = format_duration_ms(envelope.get("duration_ms")); + "retro.failed" => { + let error = prop_str_field(&envelope, "error").unwrap_or("unknown error"); + let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); Some(format!( "{} {} Retro {} {}", styles.dim.apply_to(&ts), @@ -700,7 +702,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.red.apply_to(error), )) } - "RetroStarted" => Some(format!( + "retro.started" => Some(format!( "{} {} Retro", styles.dim.apply_to(&ts), styles.bold_cyan.apply_to("\u{25b6}"), @@ -713,6 +715,14 @@ fn str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> { value.get(key)?.as_str() } +fn prop_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> { + value.get("properties")?.get(key) +} + +fn prop_str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> { + prop_field(value, key)?.as_str() +} + fn format_timestamp(ts: &str) -> String { ts.parse::>() .map_or_else(|_| ts.to_string(), |dt| dt.format("%H:%M:%S").to_string()) @@ -751,8 +761,8 @@ fn format_tokens(tokens: u64) -> String { } fn tool_detail(envelope: &serde_json::Value) -> Option { - let tool_name = str_field(envelope, "tool_name")?; - let arguments = envelope.get("arguments")?; + let tool_name = prop_str_field(envelope, "tool_name")?; + let arguments = prop_field(envelope, "arguments")?; let arg = |key: &str| arguments.get(key).and_then(|v| v.as_str()); match tool_name { @@ -861,9 +871,9 @@ mod tests { fn since_filters_by_timestamp() { let cutoff = "2026-01-01T12:00:00Z".parse::>().unwrap(); let lines = vec![ - r#"{"ts":"2026-01-01T11:00:00Z","event":"StageStarted"}"#.to_string(), - r#"{"ts":"2026-01-01T12:30:00Z","event":"StageCompleted"}"#.to_string(), - r#"{"ts":"2026-01-01T13:00:00Z","event":"WorkflowRunCompleted"}"#.to_string(), + r#"{"ts":"2026-01-01T11:00:00Z","event":"stage.started"}"#.to_string(), + r#"{"ts":"2026-01-01T12:30:00Z","event":"stage.completed"}"#.to_string(), + r#"{"ts":"2026-01-01T13:00:00Z","event":"run.completed"}"#.to_string(), ]; let result = apply_filters(&lines, Some(&cutoff), None); assert_eq!(result.len(), 2); @@ -872,7 +882,7 @@ mod tests { #[test] fn raw_lines_pass_through_verbatim() { let lines = vec![ - r#"{"ts":"2026-01-01T12:00:00Z","event":"StageStarted","node_label":"plan"}"# + r#"{"ts":"2026-01-01T12:00:00Z","event":"stage.started","node_label":"plan"}"# .to_string(), ]; let result = apply_filters(&lines, None, None); @@ -882,7 +892,7 @@ mod tests { #[test] fn pretty_stage_started() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:09Z","event":"StageStarted","node_label":"plan","node_id":"plan","stage_index":0}"#; + let line = r#"{"ts":"2026-01-01T14:23:09Z","event":"stage.started","node_label":"plan","node_id":"plan","properties":{"index":0}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("plan"), "got: {result}"); assert!(result.contains("\u{25b6}"), "got: {result}"); @@ -891,18 +901,18 @@ mod tests { #[test] fn pretty_stage_completed() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:15Z","event":"StageCompleted","node_label":"plan","cost":0.12,"duration_ms":8000,"turns":3,"tool_calls":2,"total_tokens":15200}"#; + let line = r#"{"ts":"2026-01-01T14:23:15Z","event":"stage.completed","node_label":"plan","properties":{"duration_ms":8000,"status":"success","usage":{"cost":0.12,"input_tokens":10000,"output_tokens":5200}}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("plan"), "got: {result}"); assert!(result.contains("$0.12"), "got: {result}"); assert!(result.contains("8s"), "got: {result}"); - assert!(result.contains("3 turns"), "got: {result}"); + assert!(result.contains("15.2k toks"), "got: {result}"); } #[test] fn pretty_assistant_message() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:12Z","event":"Agent.AssistantMessage","node_id":"plan","model":"claude-opus-4-6","text":"I'll start by reading the code.","usage":{"input_tokens":100,"output_tokens":50},"tool_call_count":0}"#; + let line = r#"{"ts":"2026-01-01T14:23:12Z","event":"agent.message","node_id":"plan","properties":{"model":"claude-opus-4-6","text":"I'll start by reading the code.","usage":{"input_tokens":100,"output_tokens":50},"tool_call_count":0}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("plan"), "got: {result}"); assert!(result.contains("claude-opus-4-6"), "got: {result}"); @@ -912,7 +922,7 @@ mod tests { #[test] fn pretty_tool_call_started() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:12Z","event":"Agent.ToolCallStarted","tool_name":"read_file","tool_call_id":"tc_1","arguments":{"path":"src/main.rs"}}"#; + let line = r#"{"ts":"2026-01-01T14:23:12Z","event":"agent.tool.started","properties":{"tool_name":"read_file","tool_call_id":"tc_1","arguments":{"path":"src/main.rs"}}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("read_file"), "got: {result}"); assert!(result.contains("src/main.rs"), "got: {result}"); @@ -921,15 +931,14 @@ mod tests { #[test] fn pretty_skips_noise_events() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:12Z","event":"Agent.TextDelta","delta":"hello"}"#; + let line = r#"{"ts":"2026-01-01T14:23:12Z","event":"agent.text.delta","properties":{"delta":"hello"}}"#; assert!(format_event_pretty(line, &styles).is_none()); } #[test] fn pretty_skips_assistant_output_replace_noise_event() { let styles = no_color_styles(); - let line = - r#"{"ts":"2026-01-01T14:23:12Z","event":"Agent.AssistantOutputReplace","text":""}"#; + let line = r#"{"ts":"2026-01-01T14:23:12Z","event":"agent.output.replace","properties":{"text":""}}"#; assert!(format_event_pretty(line, &styles).is_none()); } @@ -943,7 +952,7 @@ mod tests { #[test] fn pretty_workflow_run_started() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:01Z","run_id":"abc123","event":"WorkflowRunStarted","workflow_name":"smoke"}"#; + let line = r#"{"ts":"2026-01-01T14:23:01Z","run_id":"abc123","event":"run.started","properties":{"name":"smoke"}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("smoke"), "got: {result}"); assert!(result.contains("abc123"), "got: {result}"); @@ -952,7 +961,7 @@ mod tests { #[test] fn pretty_workflow_run_started_with_goal() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:01Z","run_id":"abc123","event":"WorkflowRunStarted","workflow_name":"smoke","goal":"Fix the bug"}"#; + let line = r#"{"ts":"2026-01-01T14:23:01Z","run_id":"abc123","event":"run.started","properties":{"name":"smoke","goal":"Fix the bug"}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("smoke"), "got: {result}"); assert!(result.contains("abc123"), "got: {result}"); @@ -963,7 +972,7 @@ mod tests { #[test] fn pretty_workflow_run_started_without_goal_no_extra_lines() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:01Z","run_id":"abc123","event":"WorkflowRunStarted","workflow_name":"smoke"}"#; + let line = r#"{"ts":"2026-01-01T14:23:01Z","run_id":"abc123","event":"run.started","properties":{"name":"smoke"}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(!result.contains('\n'), "got: {result}"); } @@ -971,7 +980,7 @@ mod tests { #[test] fn pretty_workflow_run_completed() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"WorkflowRunCompleted","duration_ms":25000,"status":"success","total_cost":0.57,"usage":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}"#; + let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"status":"success","total_cost":0.57,"usage":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("SUCCESS"), "got: {result}"); assert!(result.contains("25s"), "got: {result}"); @@ -985,7 +994,7 @@ mod tests { #[test] fn pretty_workflow_run_completed_backward_compat() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"WorkflowRunCompleted","duration_ms":25000,"total_cost":0.57}"#; + let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"total_cost":0.57}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("SUCCESS"), "got: {result}"); assert!(result.contains("25s"), "got: {result}"); @@ -996,7 +1005,7 @@ mod tests { #[test] fn pretty_workflow_run_completed_fail_status() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:32Z","event":"WorkflowRunCompleted","duration_ms":25000,"status":"fail"}"#; + let line = r#"{"ts":"2026-01-01T14:23:32Z","event":"run.completed","properties":{"duration_ms":25000,"status":"fail"}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("FAIL"), "got: {result}"); } @@ -1004,7 +1013,7 @@ mod tests { #[test] fn pretty_pull_request_created() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"PullRequestCreated","pr_url":"https://github.com/owner/repo/pull/42","pr_number":42,"draft":false}"#; + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"pull_request.created","properties":{"pr_url":"https://github.com/owner/repo/pull/42","pr_number":42,"draft":false}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("PR:"), "got: {result}"); assert!( @@ -1016,7 +1025,7 @@ mod tests { #[test] fn pretty_pull_request_created_draft() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"PullRequestCreated","pr_url":"https://github.com/owner/repo/pull/42","pr_number":42,"draft":true}"#; + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"pull_request.created","properties":{"pr_url":"https://github.com/owner/repo/pull/42","pr_number":42,"draft":true}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("Draft PR:"), "got: {result}"); } @@ -1024,7 +1033,7 @@ mod tests { #[test] fn pretty_pull_request_failed() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"PullRequestFailed","error":"auth token expired"}"#; + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"pull_request.failed","properties":{"error":"auth token expired"}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("PR failed:"), "got: {result}"); assert!(result.contains("auth token expired"), "got: {result}"); @@ -1033,7 +1042,7 @@ mod tests { #[test] fn pretty_run_notice_warn() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"RunNotice","level":"warn","code":"sandbox_cleanup_failed","message":"sandbox cleanup failed: boom"}"#; + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"run.notice","properties":{"level":"warn","code":"sandbox_cleanup_failed","message":"sandbox cleanup failed: boom"}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("Warning:"), "got: {result}"); assert!( @@ -1046,7 +1055,7 @@ mod tests { #[test] fn pretty_run_notice_error() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"RunNotice","level":"error","code":"launch_failed","message":"failed to start engine"}"#; + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"run.notice","properties":{"level":"error","code":"launch_failed","message":"failed to start engine"}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("Error:"), "got: {result}"); assert!(result.contains("failed to start engine"), "got: {result}"); @@ -1056,12 +1065,22 @@ mod tests { #[test] fn pretty_workflow_run_failed() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"WorkflowRunFailed","error":"sandbox timeout"}"#; + let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.failed","properties":{"error":"sandbox timeout"}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("Failed"), "got: {result}"); assert!(result.contains("sandbox timeout"), "got: {result}"); } + #[test] + fn pretty_setup_completed_without_command_count() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:23:32Z","event":"setup.completed","properties":{"duration_ms":800}}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("Setup:"), "got: {result}"); + assert!(result.contains("800ms"), "got: {result}"); + assert!(!result.contains("0 commands"), "got: {result}"); + } + #[test] fn format_duration_ms_subsecond() { assert_eq!(format_duration_ms(Some(&serde_json::json!(500))), "500ms"); diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index e4c03f152..604b6af56 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -2,8 +2,8 @@ use anyhow::Context; use anyhow::Result; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; +use fabro_checkpoint::git::Store; use fabro_config::FabroSettingsExt; -use fabro_git_storage::gitobj::Store; use fabro_util::terminal::Styles; use fabro_workflow::git::MetadataStore; use fabro_workflow::operations::{ diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress.rs b/lib/crates/fabro-cli/src/commands/run/run_progress.rs deleted file mode 100644 index 20374d796..000000000 --- a/lib/crates/fabro-cli/src/commands/run/run_progress.rs +++ /dev/null @@ -1,2155 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::path::Path; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; - -use async_trait::async_trait; -use console::Style; -use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; - -use fabro_agent::AgentEvent; -use fabro_interview::{Answer, ConsoleInterviewer, Interviewer, Question}; -use fabro_util::version::FABRO_VERSION; -use fabro_workflow::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent}; -use fabro_workflow::outcome::StageStatus; - -use crate::shared::{format_duration_ms, format_tokens_human, tilde_path}; -use fabro_workflow::outcome::{compute_stage_cost, format_cost}; - -// ── Cached styles ─────────────────────────────────────────────────────── - -macro_rules! cached_style { - ($name:ident, $template:expr) => { - fn $name() -> ProgressStyle { - static STYLE: OnceLock = OnceLock::new(); - STYLE - .get_or_init(|| ProgressStyle::with_template($template).expect("valid template")) - .clone() - } - }; -} - -cached_style!( - style_header_running, - " {spinner:.dim} {wide_msg} {elapsed:.dim}" -); -cached_style!(style_header_done, " {wide_msg:.dim} {prefix:.dim}"); -cached_style!( - style_stage_running, - " {spinner:.cyan} {wide_msg} {elapsed:.dim}" -); -cached_style!(style_stage_done, " {wide_msg} {prefix:.dim}"); -cached_style!( - style_tool_running, - " {spinner:.dim} {wide_msg} {elapsed:.dim}" -); -cached_style!(style_tool_done, " {wide_msg} {prefix:.dim}"); -cached_style!(style_subagent_info, " {wide_msg}"); -cached_style!(style_branch_done, " {wide_msg} {prefix:.dim}"); -cached_style!(style_static_dim, " {wide_msg:.dim}"); -cached_style!(style_sandbox_detail, " {wide_msg:.dim}"); -cached_style!(style_empty, " "); - -// ── Cached glyphs ─────────────────────────────────────────────────────── - -fn green_check() -> &'static str { - static GLYPH: OnceLock = OnceLock::new(); - GLYPH.get_or_init(|| Style::new().green().apply_to("\u{2713}").to_string()) -} - -fn red_cross() -> &'static str { - static GLYPH: OnceLock = OnceLock::new(); - GLYPH.get_or_init(|| Style::new().red().apply_to("\u{2717}").to_string()) -} - -// ── Duration formatting ───────────────────────────────────────────────── - -pub(crate) fn format_duration_short(d: Duration) -> String { - let secs = d.as_secs(); - if secs >= 60 { - format!("{}m{:02}s", secs / 60, secs % 60) - } else if d.as_millis() >= 1000 { - format!("{secs}s") - } else { - format!("{}ms", d.as_millis()) - } -} - -/// Wrap `text` in an OSC 8 terminal hyperlink pointing to `url`. -fn terminal_hyperlink(url: &str, text: &str) -> String { - format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\") -} - -/// Format a number as an integer if whole, one decimal otherwise. -fn format_number(n: f64) -> String { - if (n - n.round()).abs() < f64::EPSILON { - #[allow(clippy::cast_possible_truncation)] // f64-to-integer: intentional rounding - let i = n as i64; - format!("{i}") - } else { - format!("{n:.1}") - } -} - -// ── Tool call display name ────────────────────────────────────────────── - -fn truncate(s: &str, max: usize) -> String { - let single_line: String = s.split_whitespace().collect::>().join(" "); - if single_line.len() > max { - let mut t: String = single_line.chars().take(max - 3).collect(); - t.push_str("..."); - t - } else { - single_line - } -} - -fn last_line_truncated(s: &str, max: usize) -> String { - let line = s - .trim() - .lines() - .rfind(|l| !l.trim().is_empty()) - .unwrap_or("") - .trim(); - if line.len() > max { - let mut t: String = line.chars().take(max - 3).collect(); - t.push_str("..."); - t - } else { - line.to_string() - } -} - -fn shorten_path(path: &str, working_directory: Option<&str>) -> String { - if let Some(wd) = working_directory { - if let Ok(rel) = std::path::Path::new(path).strip_prefix(wd) { - return rel.display().to_string(); - } - } - if let Ok(cwd) = std::env::current_dir() { - if let Ok(rel) = std::path::Path::new(path).strip_prefix(&cwd) { - return rel.display().to_string(); - } - } - path.to_string() -} - -// ── Tool call entry ───────────────────────────────────────────────────── - -enum ToolCallStatus { - Running, - Succeeded, - Failed, -} - -struct ToolCallEntry { - display_name: String, - tool_call_id: String, - status: ToolCallStatus, - bar: ProgressBar, - is_branch: bool, -} - -// ── Active stage ──────────────────────────────────────────────────────── - -struct ActiveStage { - display_name: String, - has_model: bool, - spinner: ProgressBar, - tool_calls: VecDeque, - compaction_bar: Option, -} - -impl ActiveStage { - fn last_bar(&self) -> &ProgressBar { - self.tool_calls.back().map_or(&self.spinner, |e| &e.bar) - } -} - -const MAX_TOOL_CALLS: usize = 5; - -// ── Renderer variants ─────────────────────────────────────────────────── - -struct TtyRenderer { - multi: MultiProgress, -} - -enum ProgressRenderer { - Tty(TtyRenderer), - Plain, -} - -// ── ProgressUI ────────────────────────────────────────────────────────── - -pub(crate) struct ProgressUI { - renderer: ProgressRenderer, - verbose: bool, - active_stages: HashMap, - /// Turn and tool-call counts per stage, tracked independently of the - /// renderer so that Plain (non-TTY) mode reports accurate stats. - stage_counts: HashMap, - setup_command_count: usize, - devcontainer_command_count: usize, - sandbox_bar: Option, - setup_bar: Option, - devcontainer_bar: Option, - cli_ensure_bar: Option, - any_stage_started: bool, - parallel_parent: Option, - working_directory: Option, -} - -#[allow(dead_code)] -impl ProgressUI { - pub(crate) fn new(is_tty: bool, verbose: bool) -> Self { - let renderer = if is_tty { - ProgressRenderer::Tty(TtyRenderer { - multi: MultiProgress::new(), - }) - } else { - ProgressRenderer::Plain - }; - Self { - renderer, - verbose, - active_stages: HashMap::new(), - stage_counts: HashMap::new(), - setup_command_count: 0, - devcontainer_command_count: 0, - sandbox_bar: None, - setup_bar: None, - devcontainer_bar: None, - cli_ensure_bar: None, - any_stage_started: false, - parallel_parent: None, - working_directory: None, - } - } - - pub(crate) fn set_working_directory(&mut self, dir: String) { - self.working_directory = Some(dir); - } - - fn tool_display_name(&self, tool_name: &str, arguments: &serde_json::Value) -> String { - let dim = Style::new().dim(); - let arg = |key: &str| arguments.get(key).and_then(|v| v.as_str()); - let wd = self.working_directory.as_deref(); - let path_arg = || { - arg("path") - .or_else(|| arg("file_path")) - .map(|p| truncate(&shorten_path(p, wd), 60)) - }; - - let detail = match tool_name { - "bash" | "shell" | "execute_command" => arg("command").map(|c| truncate(c, 60)), - "glob" => arg("pattern").map(String::from), - "grep" | "ripgrep" => arg("pattern").map(|p| truncate(p, 40)), - "read_file" | "read" | "write_file" | "write" | "create_file" | "edit_file" - | "edit" | "list_dir" => path_arg(), - "web_search" => arg("query").map(|q| truncate(q, 60)), - "web_fetch" => arg("url").map(|u| truncate(u, 60)), - "spawn_agent" => arg("task").map(|t| truncate(t, 60)), - "wait" | "send_input" | "close_agent" => arg("agent_id").map(String::from), - "use_skill" => arg("skill_name").map(String::from), - "apply_patch" => Some("…".into()), - "read_many_files" => arguments - .get("paths") - .and_then(|v| v.as_array()) - .map(|a| format!("{} files", a.len())), - _ => None, - }; - - match detail { - Some(d) => format!("{tool_name}{}", dim.apply_to(format!("({d})"))), - None => tool_name.to_string(), - } - } - - /// Register event handlers on the emitter. - pub(crate) fn register(progress: &Arc>, emitter: &EventEmitter) { - let p = Arc::clone(progress); - emitter.on_event(move |event| { - let mut ui = p.lock().expect("progress lock poisoned"); - ui.handle_event(event); - }); - } - - /// Hide indicatif progress bars (for interview prompts in attach mode). - pub(crate) fn hide_bars(&self) { - if let ProgressRenderer::Tty(tty) = &self.renderer { - tty.multi.set_draw_target(ProgressDrawTarget::hidden()); - } - } - - /// Show indicatif progress bars after an interview prompt. - pub(crate) fn show_bars(&self) { - if let ProgressRenderer::Tty(tty) = &self.renderer { - tty.multi.set_draw_target(ProgressDrawTarget::stderr()); - } - } - - /// Clear all active bars and release the terminal for normal stderr output. - pub(crate) fn finish(&mut self) { - for (_id, stage) in self.active_stages.drain() { - for entry in &stage.tool_calls { - if entry.is_branch || self.verbose { - entry.bar.abandon(); - } else { - entry.bar.finish_and_clear(); - } - } - stage.spinner.finish_and_clear(); - } - if let ProgressRenderer::Tty(tty) = &self.renderer { - // Add a trailing blank line through indicatif so it survives the final redraw - let sep = tty.multi.add(ProgressBar::new_spinner()); - sep.set_style(style_empty()); - sep.finish(); - tty.multi.set_draw_target(ProgressDrawTarget::hidden()); - } - } - - // ── Event dispatch ────────────────────────────────────────────────── - - pub(crate) fn handle_event(&mut self, event: &WorkflowRunEvent) { - match event { - WorkflowRunEvent::WorkflowRunStarted { - base_branch, - base_sha, - worktree_dir, - .. - } => { - if let Some(worktree_dir) = worktree_dir { - self.show_worktree(std::path::Path::new(worktree_dir)); - } - if let Some(base_sha) = base_sha { - self.show_base_info(base_branch.as_deref(), base_sha); - } - } - WorkflowRunEvent::Sandbox { - event: sandbox_event, - } => { - self.on_sandbox_event(sandbox_event); - } - WorkflowRunEvent::SetupStarted { command_count } => { - self.on_setup_started(*command_count); - } - WorkflowRunEvent::SetupCompleted { duration_ms } => { - self.on_setup_completed(*duration_ms); - } - WorkflowRunEvent::StageStarted { - node_id, - name, - script, - .. - } => { - self.on_stage_started(node_id, name, script.as_deref()); - } - WorkflowRunEvent::StageCompleted { - node_id, - name, - duration_ms, - status, - usage, - .. - } => { - let succeeded = status - .parse::() - .map(|s| matches!(s, StageStatus::Success | StageStatus::PartialSuccess)) - .unwrap_or(false); - let dur = format_duration_ms(*duration_ms); - let cost_str = usage - .as_ref() - .and_then(compute_stage_cost) - .map(|c| format!("{} ", format_cost(c))) - .unwrap_or_default(); - let stats_str = if self.verbose { - let counts = self.stage_counts.get(node_id); - let turn_count = counts.map_or(0, |c| c.0); - let tool_call_count = counts.map_or(0, |c| c.1); - let total_tokens = usage - .as_ref() - .map_or(0, |u| u.input_tokens + u.output_tokens); - if turn_count > 0 || tool_call_count > 0 || total_tokens > 0 { - let dim = Style::new().dim(); - format!( - " {}", - dim.apply_to(format!( - "({} turns, {} tools, {} toks)", - turn_count, - tool_call_count, - format_tokens_human(total_tokens), - )) - ) - } else { - String::new() - } - } else { - String::new() - }; - let prefix = format!("{cost_str}{dur}{stats_str}"); - let glyph = if succeeded { - green_check() - } else { - red_cross() - }; - self.finish_stage(node_id, name, glyph, &prefix); - } - WorkflowRunEvent::StageFailed { - node_id, - name, - failure, - .. - } => { - self.finish_stage(node_id, name, red_cross(), ""); - let red = Style::new().red(); - let summary = last_line_truncated(&failure.message, 120); - self.insert_info_line(&format!("{} {}", red.apply_to("Error:"), summary)); - } - WorkflowRunEvent::ParallelStarted { .. } => { - // The fork stage is the (only) active stage at this point. - // In Plain mode active_stages is empty, so use a sentinel. - self.parallel_parent = self - .active_stages - .keys() - .next() - .cloned() - .or_else(|| Some(String::new())); - } - WorkflowRunEvent::ParallelBranchStarted { branch, .. } => { - self.on_parallel_branch_started(branch); - } - WorkflowRunEvent::ParallelBranchCompleted { - branch, - duration_ms, - status, - .. - } => { - self.on_parallel_branch_completed(branch, *duration_ms, status); - } - WorkflowRunEvent::ParallelCompleted { .. } => { - self.parallel_parent = None; - } - WorkflowRunEvent::Agent { stage, event } => { - self.on_agent_event(stage, event); - } - WorkflowRunEvent::SshAccessReady { ssh_command } => { - self.on_ssh_access_ready(ssh_command); - } - WorkflowRunEvent::EdgeSelected { - from_node, - to_node, - label, - condition, - .. - } if self.verbose => { - let detail = if let Some(c) = condition { - format!(" [{c}]") - } else if let Some(l) = label { - format!(" \"{l}\"") - } else { - String::new() - }; - self.insert_info_line(&format!("\u{2192} {from_node} \u{2192} {to_node}{detail}")); - } - WorkflowRunEvent::LoopRestart { from_node, to_node } if self.verbose => { - self.insert_info_line(&format!( - "\u{21ba} {from_node} \u{2192} {to_node} (loop restart)" - )); - } - WorkflowRunEvent::SetupCommandCompleted { - command, - index, - exit_code, - duration_ms, - } if self.verbose => { - let total = self.setup_command_count; - let dur = format_duration_ms(*duration_ms); - let glyph = if *exit_code == 0 { - green_check() - } else { - red_cross() - }; - let msg = format!("{glyph} [{}/{total}] {}", index + 1, truncate(command, 60),); - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = if let Some(ref setup_bar) = self.setup_bar { - tty.multi - .insert_before(setup_bar, ProgressBar::new_spinner()) - } else { - tty.multi.add(ProgressBar::new_spinner()) - }; - bar.set_style(style_tool_done()); - bar.set_prefix(dur); - bar.finish_with_message(msg); - } - ProgressRenderer::Plain => { - eprintln!(" {msg} {dur}"); - } - } - } - WorkflowRunEvent::StageRetrying { - node_id: _, - name, - attempt, - max_attempts, - delay_ms, - .. - } if self.verbose => { - let dur = format_duration_ms(*delay_ms); - self.insert_info_line(&format!( - "\u{21bb} {name}: retrying (attempt {attempt}/{max_attempts}, delay {dur})" - )); - } - WorkflowRunEvent::CliEnsureStarted { cli_name, .. } => { - self.on_cli_ensure_started(cli_name); - } - WorkflowRunEvent::CliEnsureCompleted { - cli_name, - already_installed, - duration_ms, - .. - } => { - self.on_cli_ensure_completed(cli_name, *already_installed, *duration_ms); - } - WorkflowRunEvent::CliEnsureFailed { cli_name, .. } => { - self.on_cli_ensure_failed(cli_name); - } - WorkflowRunEvent::DevcontainerResolved { - dockerfile_lines, - environment_count, - lifecycle_command_count, - workspace_folder, - } => { - let detail = format!( - "{dockerfile_lines} Dockerfile lines, {environment_count} env vars, \ - {lifecycle_command_count} lifecycle cmds, {workspace_folder}" - ); - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_header_done()); - bar.finish_with_message("Devcontainer: resolved".to_string()); - let detail_bar = tty.multi.insert_after(&bar, ProgressBar::new_spinner()); - detail_bar.set_style(style_sandbox_detail()); - detail_bar.finish_with_message(detail); - } - ProgressRenderer::Plain => { - eprintln!(" Devcontainer: resolved"); - eprintln!(" {detail}"); - } - } - } - WorkflowRunEvent::DevcontainerLifecycleStarted { - phase, - command_count, - } => { - self.devcontainer_command_count = *command_count; - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_header_running()); - bar.set_message(format!( - "Running devcontainer {phase} ({command_count} commands)..." - )); - bar.enable_steady_tick(Duration::from_millis(100)); - self.devcontainer_bar = Some(bar); - } - ProgressRenderer::Plain => { - eprintln!(" Running devcontainer {phase} ({command_count} commands)..."); - } - } - } - WorkflowRunEvent::DevcontainerLifecycleCompleted { - phase, duration_ms, .. - } => { - let dur = format_duration_ms(*duration_ms); - match &self.renderer { - ProgressRenderer::Tty(_) => { - if let Some(bar) = self.devcontainer_bar.take() { - bar.set_style(style_header_done()); - bar.set_prefix(dur); - bar.finish_with_message(format!("Devcontainer: {phase}")); - } - } - ProgressRenderer::Plain => { - eprintln!(" Devcontainer: {phase} ({dur})"); - } - } - } - WorkflowRunEvent::DevcontainerLifecycleFailed { - phase, - command, - exit_code, - stderr, - .. - } => { - if let Some(bar) = self.devcontainer_bar.take() { - bar.abandon(); - } - let red = console::Style::new().red(); - let summary = if stderr.len() > 120 { - &stderr[..120] - } else { - stderr.as_str() - }; - self.insert_info_line(&format!( - "{} Devcontainer {phase} command failed (exit {exit_code}): {command}\n {summary}", - red.apply_to("Error:") - )); - } - WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { - command, - index, - exit_code, - duration_ms, - .. - } if self.verbose => { - let total = self.devcontainer_command_count; - let dur = format_duration_ms(*duration_ms); - let glyph = if *exit_code == 0 { - green_check() - } else { - red_cross() - }; - let msg = format!("{glyph} [{}/{total}] {}", index + 1, truncate(command, 60),); - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = if let Some(ref dc_bar) = self.devcontainer_bar { - tty.multi.insert_before(dc_bar, ProgressBar::new_spinner()) - } else { - tty.multi.add(ProgressBar::new_spinner()) - }; - bar.set_style(style_tool_done()); - bar.set_prefix(dur); - bar.finish_with_message(msg); - } - ProgressRenderer::Plain => { - eprintln!(" {msg} {dur}"); - } - } - } - WorkflowRunEvent::RetroStarted => { - self.on_stage_started("retro", "Retro", None); - } - WorkflowRunEvent::RetroCompleted { duration_ms } => { - let dur = format_duration_ms(*duration_ms); - self.finish_stage("retro", "Retro", green_check(), &dur); - } - WorkflowRunEvent::RetroFailed { duration_ms, .. } => { - let dur = format_duration_ms(*duration_ms); - self.finish_stage("retro", "Retro", red_cross(), &dur); - } - WorkflowRunEvent::RunNotice { - level, - code, - message, - } => { - self.on_run_notice(*level, code, message); - } - WorkflowRunEvent::PullRequestCreated { pr_url, draft, .. } => { - self.on_pull_request_created(pr_url, *draft); - } - WorkflowRunEvent::PullRequestFailed { error } => { - self.on_pull_request_failed(error); - } - _ => {} - } - } - - // ── JSONL dispatch ──────────────────────────────────────────────── - - /// Parse a JSONL envelope line and dispatch to internal rendering methods. - /// Used by the attach loop to render events from progress.jsonl. - pub(crate) fn handle_json_line(&mut self, line: &str) { - let envelope: serde_json::Value = match serde_json::from_str(line) { - Ok(v) => v, - Err(_) => return, - }; - let Some(event_name) = envelope.get("event").and_then(|v| v.as_str()) else { - return; - }; - - let str_field = |key: &str| -> Option<&str> { envelope.get(key).and_then(|v| v.as_str()) }; - let u64_field = |key: &str| -> u64 { - envelope - .get(key) - .and_then(serde_json::Value::as_u64) - .unwrap_or(0) - }; - - match event_name { - "WorkflowRunStarted" => { - if let Some(worktree_dir) = str_field("worktree_dir") { - self.show_worktree(std::path::Path::new(worktree_dir)); - } - if let Some(base_sha) = str_field("base_sha") { - self.show_base_info(str_field("base_branch"), base_sha); - } - } - "Sandbox.Initializing" => { - let provider = str_field("sandbox_provider") - .unwrap_or("unknown") - .to_string(); - self.on_sandbox_event(&fabro_agent::SandboxEvent::Initializing { provider }); - } - "Sandbox.Ready" => { - let provider = str_field("sandbox_provider") - .unwrap_or("unknown") - .to_string(); - let duration_ms = u64_field("duration_ms"); - let name = str_field("name").map(String::from); - let cpu = envelope.get("cpu").and_then(serde_json::Value::as_f64); - let memory = envelope.get("memory").and_then(serde_json::Value::as_f64); - let url = str_field("url").map(String::from); - self.on_sandbox_event(&fabro_agent::SandboxEvent::Ready { - provider, - duration_ms, - name, - cpu, - memory, - url, - }); - } - "SandboxInitialized" => { - if let Some(wd) = str_field("working_directory") { - self.set_working_directory(wd.to_string()); - } - } - "SetupStarted" => { - let count = usize::try_from(u64_field("command_count")).unwrap(); - self.on_setup_started(count); - } - "SetupCompleted" => { - let duration_ms = u64_field("duration_ms"); - self.on_setup_completed(duration_ms); - } - "StageStarted" => { - let node_id = str_field("node_id").unwrap_or("?"); - let name = str_field("node_label").unwrap_or("?"); - let script = str_field("script"); - self.on_stage_started(node_id, name, script); - } - "StageCompleted" => { - let node_id = str_field("node_id").unwrap_or("?"); - let name = str_field("node_label").unwrap_or("?"); - let duration_ms = u64_field("duration_ms"); - let status = str_field("status").unwrap_or("success"); - let succeeded = matches!(status, "success" | "partial_success"); - - let dur = format_duration_ms(duration_ms); - - // Parse usage for cost - let cost_str = envelope - .get("usage") - .and_then(|u| u.get("cost")) - .and_then(serde_json::Value::as_f64) - .map(|c| format!("{} ", format_cost(c))) - .unwrap_or_default(); - - let stats_str = if self.verbose { - let counts = self.stage_counts.get(node_id); - let turn_count = counts.map_or(0, |c| c.0); - let tool_call_count = counts.map_or(0, |c| c.1); - let total_tokens = envelope.get("usage").map_or(0, |u| { - u.get("input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0) - + u.get("output_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0) - }); - if turn_count > 0 || tool_call_count > 0 || total_tokens > 0 { - let dim = Style::new().dim(); - format!( - " {}", - dim.apply_to(format!( - "({} turns, {} tools, {} toks)", - turn_count, - tool_call_count, - format_tokens_human(total_tokens), - )) - ) - } else { - String::new() - } - } else { - String::new() - }; - - let prefix = format!("{cost_str}{dur}{stats_str}"); - let glyph = if succeeded { - green_check() - } else { - red_cross() - }; - self.finish_stage(node_id, name, glyph, &prefix); - } - "StageFailed" => { - let node_id = str_field("node_id").unwrap_or("?"); - let name = str_field("node_label").unwrap_or("?"); - let message = str_field("error") - .or_else(|| str_field("failure_reason")) - .unwrap_or("unknown error"); - self.finish_stage(node_id, name, red_cross(), ""); - let red = Style::new().red(); - let summary = last_line_truncated(message, 120); - self.insert_info_line(&format!("{} {}", red.apply_to("Error:"), summary)); - } - "ParallelStarted" => { - self.parallel_parent = self - .active_stages - .keys() - .next() - .cloned() - .or_else(|| Some(String::new())); - } - "ParallelBranchStarted" => { - if let Some(branch) = str_field("node_id") { - self.on_parallel_branch_started(branch); - } - } - "ParallelBranchCompleted" => { - if let Some(branch) = str_field("node_id") { - let duration_ms = u64_field("duration_ms"); - let status = str_field("status").unwrap_or("success"); - self.on_parallel_branch_completed(branch, duration_ms, status); - } - } - "ParallelCompleted" => { - self.parallel_parent = None; - } - "Agent.ToolCallStarted" => { - let stage = str_field("node_id").unwrap_or("?"); - let tool_name = str_field("tool_name").unwrap_or("?"); - let tool_call_id = str_field("tool_call_id").unwrap_or("?"); - let empty = serde_json::Value::Object(serde_json::Map::new()); - let arguments = envelope.get("arguments").unwrap_or(&empty); - // Update tool_call count - if let Some(counts) = self.stage_counts.get_mut(stage) { - counts.1 += 1; - } - self.on_tool_call_started(stage, tool_name, tool_call_id, arguments); - } - "Agent.ToolCallCompleted" => { - let stage = str_field("node_id").unwrap_or("?"); - let tool_call_id = str_field("tool_call_id").unwrap_or("?"); - let is_error = envelope - .get("is_error") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - self.on_tool_call_completed(stage, tool_call_id, is_error); - } - "Agent.AssistantMessage" => { - let stage = str_field("node_id").unwrap_or("?"); - let model = str_field("model").unwrap_or("?"); - // Update turn count - if let Some(counts) = self.stage_counts.get_mut(stage) { - counts.0 += 1; - } - // Update model display on stage bar - if let ProgressRenderer::Tty(_) = &self.renderer { - if let Some(active_stage) = self.active_stages.get_mut(stage) { - if !active_stage.has_model { - active_stage.has_model = true; - let dim = Style::new().dim(); - let suffix = format!(" {}", dim.apply_to(format!("[{model}]"))); - active_stage - .spinner - .set_message(format!("{}{}", active_stage.display_name, suffix)); - } - } - } - } - "Agent.CompactionStarted" => { - let stage = str_field("node_id").unwrap_or("?"); - if let ProgressRenderer::Tty(tty) = &self.renderer { - if let Some(active_stage) = self.active_stages.get_mut(stage) { - if let Some(old) = active_stage.compaction_bar.take() { - old.finish_and_clear(); - } - let bar = tty - .multi - .insert_after(active_stage.last_bar(), ProgressBar::new_spinner()); - bar.set_style(style_tool_running()); - bar.set_message("\u{27f3} compacting context\u{2026}"); - bar.enable_steady_tick(Duration::from_millis(100)); - active_stage.compaction_bar = Some(bar); - } - } - } - "Agent.CompactionCompleted" => { - let stage = str_field("node_id").unwrap_or("?"); - let original = u64_field("original_turn_count"); - let preserved = u64_field("preserved_turn_count"); - let tracked = u64_field("tracked_file_count"); - let msg = format!( - "\u{27f3} compaction: {original} \u{2192} {preserved} turns, {tracked} files" - ); - match &self.renderer { - ProgressRenderer::Tty(_) => { - if let Some(bar) = self - .active_stages - .get_mut(stage) - .and_then(|s| s.compaction_bar.take()) - { - bar.set_style(style_tool_done()); - bar.finish_with_message(msg); - } else { - self.insert_info_line_for_stage(stage, &msg); - } - } - ProgressRenderer::Plain => { - eprintln!(" {msg}"); - } - } - } - "SshAccessReady" => { - if let Some(cmd) = str_field("ssh_command") { - self.on_ssh_access_ready(cmd); - } - } - "RetroStarted" => { - self.on_stage_started("retro", "Retro", None); - } - "RetroCompleted" => { - let dur = format_duration_ms(u64_field("duration_ms")); - self.finish_stage("retro", "Retro", green_check(), &dur); - } - "RetroFailed" => { - let dur = format_duration_ms(u64_field("duration_ms")); - self.finish_stage("retro", "Retro", red_cross(), &dur); - } - "RunNotice" => { - let level = match str_field("level").unwrap_or("info") { - "warn" => RunNoticeLevel::Warn, - "error" => RunNoticeLevel::Error, - _ => RunNoticeLevel::Info, - }; - let code = str_field("code").unwrap_or(""); - let message = str_field("message").unwrap_or(""); - self.on_run_notice(level, code, message); - } - "PullRequestCreated" => { - let pr_url = str_field("pr_url").unwrap_or("?"); - let draft = envelope - .get("draft") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - self.on_pull_request_created(pr_url, draft); - } - "PullRequestFailed" => { - let error = str_field("error").unwrap_or("unknown error"); - self.on_pull_request_failed(error); - } - "DevcontainerResolved" => { - let dockerfile_lines = u64_field("dockerfile_lines"); - let environment_count = u64_field("environment_count"); - let lifecycle_command_count = u64_field("lifecycle_command_count"); - let workspace_folder = str_field("workspace_folder").unwrap_or("?").to_string(); - let detail = format!( - "{dockerfile_lines} Dockerfile lines, {environment_count} env vars, \ - {lifecycle_command_count} lifecycle cmds, {workspace_folder}" - ); - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_header_done()); - bar.finish_with_message("Devcontainer: resolved".to_string()); - let detail_bar = tty.multi.insert_after(&bar, ProgressBar::new_spinner()); - detail_bar.set_style(style_sandbox_detail()); - detail_bar.finish_with_message(detail); - } - ProgressRenderer::Plain => { - eprintln!(" Devcontainer: resolved"); - eprintln!(" {detail}"); - } - } - } - "DevcontainerLifecycleStarted" => { - let phase = str_field("phase").unwrap_or("?"); - let command_count = usize::try_from(u64_field("command_count")).unwrap(); - self.devcontainer_command_count = command_count; - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_header_running()); - bar.set_message(format!( - "Running devcontainer {phase} ({command_count} commands)..." - )); - bar.enable_steady_tick(Duration::from_millis(100)); - self.devcontainer_bar = Some(bar); - } - ProgressRenderer::Plain => { - eprintln!(" Running devcontainer {phase} ({command_count} commands)..."); - } - } - } - "DevcontainerLifecycleCompleted" => { - let phase = str_field("phase").unwrap_or("?"); - let duration_ms = u64_field("duration_ms"); - let dur = format_duration_ms(duration_ms); - match &self.renderer { - ProgressRenderer::Tty(_) => { - if let Some(bar) = self.devcontainer_bar.take() { - bar.set_style(style_header_done()); - bar.set_prefix(dur); - bar.finish_with_message(format!("Devcontainer: {phase}")); - } - } - ProgressRenderer::Plain => { - eprintln!(" Devcontainer: {phase} ({dur})"); - } - } - } - "DevcontainerLifecycleFailed" => { - let phase = str_field("phase").unwrap_or("?"); - let command = str_field("command").unwrap_or("?"); - let exit_code = u64_field("exit_code"); - let stderr_text = str_field("stderr").unwrap_or(""); - if let Some(bar) = self.devcontainer_bar.take() { - bar.abandon(); - } - let red = Style::new().red(); - let summary = if stderr_text.len() > 120 { - &stderr_text[..120] - } else { - stderr_text - }; - self.insert_info_line(&format!( - "{} Devcontainer {phase} command failed (exit {exit_code}): {command}\n {summary}", - red.apply_to("Error:") - )); - } - "CliEnsureStarted" => { - if let Some(cli_name) = str_field("cli_name") { - self.on_cli_ensure_started(cli_name); - } - } - "CliEnsureCompleted" => { - if let Some(cli_name) = str_field("cli_name") { - let already_installed = envelope - .get("already_installed") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let duration_ms = u64_field("duration_ms"); - self.on_cli_ensure_completed(cli_name, already_installed, duration_ms); - } - } - "CliEnsureFailed" => { - if let Some(cli_name) = str_field("cli_name") { - self.on_cli_ensure_failed(cli_name); - } - } - _ => {} - } - } - - // ── Sandbox ───────────────────────────────────────────────────────── - - fn on_sandbox_event(&mut self, event: &fabro_agent::SandboxEvent) { - use fabro_agent::SandboxEvent; - match event { - SandboxEvent::Initializing { provider } => { - if let ProgressRenderer::Tty(tty) = &self.renderer { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_header_running()); - bar.set_message(format!("Initializing {provider} sandbox...")); - bar.enable_steady_tick(Duration::from_millis(100)); - self.sandbox_bar = Some(bar); - } - } - SandboxEvent::Ready { - provider, - duration_ms, - name, - cpu, - memory, - url, - } => { - let dur = format_duration_ms(*duration_ms); - let detail = match (name, cpu, memory) { - (Some(n), Some(c), Some(m)) => Some(format!( - "{n} ({} cpu, {} GB)", - format_number(*c), - format_number(*m) - )), - (Some(n), _, _) => Some(n.clone()), - _ => None, - }; - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let display_provider = match url { - Some(u) => terminal_hyperlink(u, provider), - None => provider.clone(), - }; - if let Some(bar) = self.sandbox_bar.take() { - bar.set_style(style_header_done()); - bar.set_prefix(dur); - bar.finish_with_message(format!("Sandbox: {display_provider}")); - if let Some(detail_str) = &detail { - let detail_bar = - tty.multi.insert_after(&bar, ProgressBar::new_spinner()); - detail_bar.set_style(style_sandbox_detail()); - detail_bar.finish_with_message(detail_str.clone()); - } - } - } - ProgressRenderer::Plain => { - eprintln!(" Sandbox: {provider} (ready in {dur})"); - if let Some(detail_str) = &detail { - eprintln!(" {detail_str}"); - } - } - } - } - _ => {} - } - } - - // ── SSH access ────────────────────────────────────────────────────── - - fn on_ssh_access_ready(&mut self, ssh_command: &str) { - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_sandbox_detail()); - bar.finish_with_message(ssh_command.to_string()); - } - ProgressRenderer::Plain => { - eprintln!(" {ssh_command}"); - } - } - } - - // ── Setup ─────────────────────────────────────────────────────────── - - fn on_setup_started(&mut self, command_count: usize) { - self.setup_command_count = command_count; - if let ProgressRenderer::Tty(tty) = &self.renderer { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_header_running()); - bar.set_message(format!( - "Setup: {command_count} command{}...", - if command_count == 1 { "" } else { "s" } - )); - bar.enable_steady_tick(Duration::from_millis(100)); - self.setup_bar = Some(bar); - } - } - - fn on_setup_completed(&mut self, duration_ms: u64) { - let dur = format_duration_ms(duration_ms); - let count = self.setup_command_count; - let suffix = if count == 1 { "" } else { "s" }; - match &self.renderer { - ProgressRenderer::Tty(_) => { - if let Some(bar) = self.setup_bar.take() { - bar.set_style(style_header_done()); - bar.set_prefix(dur); - bar.finish_with_message(format!("Setup: {count} command{suffix}")); - } - } - ProgressRenderer::Plain => { - eprintln!(" Setup: {count} command{suffix} ({dur})"); - } - } - } - - // ── CLI ensure ──────────────────────────────────────────────────────── - - fn on_cli_ensure_started(&mut self, cli_name: &str) { - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_header_running()); - bar.set_message(format!("CLI: ensuring {cli_name}...")); - bar.enable_steady_tick(Duration::from_millis(100)); - self.cli_ensure_bar = Some(bar); - } - ProgressRenderer::Plain => {} - } - } - - fn on_cli_ensure_completed( - &mut self, - cli_name: &str, - already_installed: bool, - duration_ms: u64, - ) { - let dur = format_duration_ms(duration_ms); - let status = if already_installed { - "found" - } else { - "installed" - }; - match &self.renderer { - ProgressRenderer::Tty(_) => { - if let Some(bar) = self.cli_ensure_bar.take() { - bar.set_style(style_header_done()); - bar.set_prefix(dur); - bar.finish_with_message(format!("CLI: {cli_name} ({status})")); - } - } - ProgressRenderer::Plain => { - eprintln!(" CLI: {cli_name} ({status}, {dur})"); - } - } - } - - fn on_cli_ensure_failed(&mut self, cli_name: &str) { - match &self.renderer { - ProgressRenderer::Tty(_) => { - if let Some(bar) = self.cli_ensure_bar.take() { - bar.set_style(style_header_done()); - bar.finish_with_message(format!( - "{} CLI: {cli_name} install failed", - red_cross() - )); - } - } - ProgressRenderer::Plain => { - eprintln!(" {} CLI: {cli_name} install failed", red_cross()); - } - } - } - - // ── Logs dir (called externally) ──────────────────────────────────── - - pub(crate) fn show_run_dir(&mut self, run_dir: &Path) { - let path_str = tilde_path(run_dir); - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_static_dim()); - bar.finish_with_message(format!("Run: {path_str}")); - } - ProgressRenderer::Plain => { - eprintln!(" Run: {path_str}"); - } - } - } - - pub(crate) fn show_version(&mut self) { - let version = FABRO_VERSION; - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_static_dim()); - bar.finish_with_message(format!("Version: {version}")); - } - ProgressRenderer::Plain => { - eprintln!(" Version: {version}"); - } - } - } - - pub(crate) fn show_run_id(&mut self, run_id: &str) { - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_static_dim()); - bar.finish_with_message(format!("Run: {run_id}")); - } - ProgressRenderer::Plain => { - eprintln!(" Run: {run_id}"); - } - } - } - - pub(crate) fn show_time(&mut self, time: &str) { - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_static_dim()); - bar.finish_with_message(format!("Time: {time}")); - } - ProgressRenderer::Plain => { - eprintln!(" Time: {time}"); - } - } - } - - pub(crate) fn show_worktree(&mut self, path: &Path) { - let path_str = tilde_path(path); - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_static_dim()); - bar.finish_with_message(format!("Worktree: {path_str}")); - } - ProgressRenderer::Plain => { - eprintln!(" Worktree: {path_str}"); - } - } - } - - pub(crate) fn show_base_info(&mut self, branch: Option<&str>, sha: &str) { - let short_sha = &sha[..sha.len().min(12)]; - let text = match branch { - Some(b) => format!("Base: {b} ({short_sha})"), - None => format!("Base: {short_sha}"), - }; - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_static_dim()); - bar.finish_with_message(text); - } - ProgressRenderer::Plain => { - eprintln!(" {text}"); - } - } - } - - // ── Stages ────────────────────────────────────────────────────────── - - fn on_stage_started(&mut self, node_id: &str, name: &str, script: Option<&str>) { - self.stage_counts.insert(node_id.to_string(), (0, 0)); - let display_name = match script { - Some(s) => { - let dim = Style::new().dim(); - format!("{name} {}", dim.apply_to(truncate(s, 60))) - } - None => name.to_string(), - }; - if let ProgressRenderer::Tty(tty) = &self.renderer { - if !self.any_stage_started { - self.any_stage_started = true; - let sep = tty.multi.add(ProgressBar::new_spinner()); - sep.set_style(style_empty()); - sep.finish(); - } - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_stage_running()); - bar.set_message(display_name.clone()); - bar.enable_steady_tick(Duration::from_millis(100)); - self.active_stages.insert( - node_id.to_string(), - ActiveStage { - display_name, - has_model: false, - spinner: bar, - tool_calls: VecDeque::new(), - compaction_bar: None, - }, - ); - } - } - - fn finish_stage(&mut self, node_id: &str, name: &str, glyph: &str, prefix: &str) { - match &self.renderer { - ProgressRenderer::Tty(_) => { - if let Some(stage) = self.active_stages.remove(node_id) { - if let Some(bar) = stage.compaction_bar { - bar.finish_and_clear(); - } - for entry in &stage.tool_calls { - if entry.is_branch || self.verbose { - // Keep visible: branches always, all entries in verbose mode - entry.bar.abandon(); - } else { - entry.bar.finish_and_clear(); - } - } - stage.spinner.set_style(style_stage_done()); - stage.spinner.set_prefix(prefix.to_string()); - stage - .spinner - .finish_with_message(format!("{glyph} {}", stage.display_name)); - } - } - ProgressRenderer::Plain => { - if prefix.is_empty() { - eprintln!(" {glyph} {name}"); - } else { - eprintln!(" {glyph} {name} {prefix}"); - } - } - } - } - - // ── Agent / tool calls ────────────────────────────────────────────── - - fn on_agent_event(&mut self, stage_node_id: &str, event: &AgentEvent) { - match event { - AgentEvent::AssistantMessage { model, .. } => { - if let Some(counts) = self.stage_counts.get_mut(stage_node_id) { - counts.0 += 1; - } - if let ProgressRenderer::Tty(_) = &self.renderer { - if let Some(stage) = self.active_stages.get_mut(stage_node_id) { - if !stage.has_model { - stage.has_model = true; - let dim = Style::new().dim(); - let suffix = format!(" {}", dim.apply_to(format!("[{model}]"))); - stage.display_name.push_str(&suffix); - stage.spinner.set_message(stage.display_name.clone()); - } - } - } - } - AgentEvent::ToolCallStarted { - tool_name, - tool_call_id, - arguments, - } => { - self.on_tool_call_started(stage_node_id, tool_name, tool_call_id, arguments); - } - AgentEvent::ToolCallCompleted { - tool_call_id, - is_error, - .. - } => { - if let Some(counts) = self.stage_counts.get_mut(stage_node_id) { - counts.1 += 1; - } - self.on_tool_call_completed(stage_node_id, tool_call_id, *is_error); - } - AgentEvent::Warning { kind, details, .. } - if kind == "context_window" && self.verbose => - { - let usage_percent = details - .get("usage_percent") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let yellow = Style::new().yellow(); - self.insert_info_line_for_stage( - stage_node_id, - &format!( - "{} context window: {usage_percent}% used", - yellow.apply_to("\u{26a0}") - ), - ); - } - AgentEvent::CompactionStarted { .. } => match &self.renderer { - ProgressRenderer::Tty(tty) => { - if let Some(stage) = self.active_stages.get_mut(stage_node_id) { - if let Some(old) = stage.compaction_bar.take() { - old.finish_and_clear(); - } - let bar = tty - .multi - .insert_after(stage.last_bar(), ProgressBar::new_spinner()); - bar.set_style(style_tool_running()); - bar.set_message("\u{27f3} compacting context\u{2026}"); - bar.enable_steady_tick(Duration::from_millis(100)); - stage.compaction_bar = Some(bar); - } - } - ProgressRenderer::Plain => {} - }, - AgentEvent::CompactionCompleted { - original_turn_count, - preserved_turn_count, - tracked_file_count, - .. - } => { - let msg = format!( - "\u{27f3} compaction: {original_turn_count} \u{2192} {preserved_turn_count} turns, {tracked_file_count} files" - ); - match &self.renderer { - ProgressRenderer::Tty(_) => { - if let Some(bar) = self - .active_stages - .get_mut(stage_node_id) - .and_then(|s| s.compaction_bar.take()) - { - bar.set_style(style_tool_done()); - bar.finish_with_message(msg); - } else { - self.insert_info_line_for_stage(stage_node_id, &msg); - } - } - ProgressRenderer::Plain => { - eprintln!(" {msg}"); - } - } - } - AgentEvent::LlmRetry { - model, - attempt, - delay_secs, - error, - .. - } if self.verbose => { - let yellow = Style::new().yellow(); - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - // f64-to-integer: delay is non-negative and fits in u64 - let delay_ms = (*delay_secs * 1000.0) as u64; - let dur = format_duration_ms(delay_ms); - self.insert_info_line_for_stage( - stage_node_id, - &format!( - "{} retry: {model} attempt {attempt} ({error}, delay {dur})", - yellow.apply_to("\u{26a0}") - ), - ); - } - AgentEvent::SubAgentSpawned { agent_id, task, .. } if self.verbose => { - let dim = Style::new().dim(); - self.insert_subagent_line_for_stage( - stage_node_id, - &dim.apply_to(format!( - "\u{25b8} subagent[{agent_id}] \"{}\"", - truncate(task, 50) - )) - .to_string(), - ); - } - AgentEvent::SubAgentCompleted { - agent_id, - turns_used, - success, - .. - } if self.verbose => { - let glyph = if *success { green_check() } else { red_cross() }; - self.insert_subagent_line_for_stage( - stage_node_id, - &format!("{glyph} subagent[{agent_id}] ({turns_used} turns)"), - ); - } - _ => {} - } - } - - fn on_tool_call_started( - &mut self, - stage_node_id: &str, - tool_name: &str, - tool_call_id: &str, - arguments: &serde_json::Value, - ) { - let display_name = self.tool_display_name(tool_name, arguments); - - if let ProgressRenderer::Tty(tty) = &self.renderer { - if let Some(stage) = self.active_stages.get_mut(stage_node_id) { - // Evict oldest if at capacity (prefer completed entries); skip in verbose mode - if !self.verbose && stage.tool_calls.len() >= MAX_TOOL_CALLS { - let evict_idx = stage - .tool_calls - .iter() - .position(|e| !matches!(e.status, ToolCallStatus::Running)) - .unwrap_or(0); - if let Some(evicted) = stage.tool_calls.remove(evict_idx) { - evicted.bar.finish_and_clear(); - } - } - let bar = tty - .multi - .insert_after(stage.last_bar(), ProgressBar::new_spinner()); - bar.set_style(style_tool_running()); - bar.set_message(display_name.clone()); - bar.enable_steady_tick(Duration::from_millis(100)); - stage.tool_calls.push_back(ToolCallEntry { - display_name, - tool_call_id: tool_call_id.to_string(), - status: ToolCallStatus::Running, - bar, - is_branch: false, - }); - } - } - } - - // ── Parallel branches ───────────────────────────────────────────── - - fn on_parallel_branch_started(&mut self, branch: &str) { - let parent_id = match &self.parallel_parent { - Some(id) => id.clone(), - None => return, - }; - - if let ProgressRenderer::Tty(tty) = &self.renderer { - if let Some(stage) = self.active_stages.get_mut(&parent_id) { - let bar = tty - .multi - .insert_after(stage.last_bar(), ProgressBar::new_spinner()); - bar.set_style(style_subagent_info()); - let dim = Style::new().dim(); - bar.set_message(dim.apply_to(format!("\u{25b8} {branch}")).to_string()); - stage.tool_calls.push_back(ToolCallEntry { - display_name: branch.to_string(), - tool_call_id: branch.to_string(), - status: ToolCallStatus::Running, - bar, - is_branch: true, - }); - } - } - } - - fn on_parallel_branch_completed(&mut self, branch: &str, duration_ms: u64, status: &str) { - let succeeded = matches!(status, "success" | "partial_success"); - let glyph = if succeeded { - green_check() - } else { - red_cross() - }; - let dur = format_duration_ms(duration_ms); - - let parent_id = match &self.parallel_parent { - Some(id) => id.clone(), - None => return, - }; - - match &self.renderer { - ProgressRenderer::Tty(_) => { - if let Some(stage) = self.active_stages.get_mut(&parent_id) { - if let Some(entry) = stage - .tool_calls - .iter_mut() - .find(|e| e.tool_call_id == branch) - { - entry.status = if succeeded { - ToolCallStatus::Succeeded - } else { - ToolCallStatus::Failed - }; - let elapsed = format_duration_short(entry.bar.elapsed()); - entry.bar.set_style(style_branch_done()); - entry.bar.set_prefix(elapsed); - entry - .bar - .finish_with_message(format!("{glyph} {}", entry.display_name)); - } - } - } - ProgressRenderer::Plain => { - eprintln!(" {glyph} {branch} {dur}"); - } - } - } - - /// Insert a static info line (verbose-only) at the current position. - fn insert_info_line(&mut self, message: &str) { - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = tty.multi.add(ProgressBar::new_spinner()); - bar.set_style(style_static_dim()); - bar.finish_with_message(message.to_string()); - } - ProgressRenderer::Plain => { - eprintln!(" {message}"); - } - } - } - - /// Insert a static info line nested under a stage's tool calls. - fn insert_info_line_for_stage(&mut self, stage_node_id: &str, message: &str) { - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = if let Some(stage) = self.active_stages.get(stage_node_id) { - tty.multi - .insert_after(stage.last_bar(), ProgressBar::new_spinner()) - } else { - tty.multi.add(ProgressBar::new_spinner()) - }; - bar.set_style(style_tool_done()); - bar.finish_with_message(message.to_string()); - } - ProgressRenderer::Plain => { - eprintln!(" {message}"); - } - } - } - - fn on_run_notice(&mut self, level: RunNoticeLevel, code: &str, message: &str) { - let dim = Style::new().dim(); - let label = match level { - RunNoticeLevel::Info => Style::new().bold().apply_to("Info:"), - RunNoticeLevel::Warn => Style::new().yellow().apply_to("Warning:"), - RunNoticeLevel::Error => Style::new().red().apply_to("Error:"), - }; - let code_suffix = if code.is_empty() { - String::new() - } else { - format!(" {}", dim.apply_to(format!("[{code}]"))) - }; - self.insert_info_line(&format!("{label} {message}{code_suffix}")); - } - - fn on_pull_request_created(&mut self, pr_url: &str, draft: bool) { - let label = if draft { "Draft PR:" } else { "PR:" }; - let bold = Style::new().bold(); - self.insert_info_line(&format!("{} {pr_url}", bold.apply_to(label))); - } - - fn on_pull_request_failed(&mut self, error: &str) { - let red = Style::new().red(); - self.insert_info_line(&format!("{} {error}", red.apply_to("PR failed:"))); - } - - /// Insert a static info line for a subagent, indented deeper than tool calls. - fn insert_subagent_line_for_stage(&mut self, stage_node_id: &str, message: &str) { - match &self.renderer { - ProgressRenderer::Tty(tty) => { - let bar = if let Some(stage) = self.active_stages.get(stage_node_id) { - tty.multi - .insert_after(stage.last_bar(), ProgressBar::new_spinner()) - } else { - tty.multi.add(ProgressBar::new_spinner()) - }; - bar.set_style(style_subagent_info()); - bar.finish_with_message(message.to_string()); - } - ProgressRenderer::Plain => { - eprintln!(" {message}"); - } - } - } - - fn on_tool_call_completed(&mut self, stage_node_id: &str, tool_call_id: &str, is_error: bool) { - if let ProgressRenderer::Tty(_) = &self.renderer { - if let Some(stage) = self.active_stages.get_mut(stage_node_id) { - if let Some(entry) = stage - .tool_calls - .iter_mut() - .find(|e| e.tool_call_id == tool_call_id) - { - let glyph = if is_error { red_cross() } else { green_check() }; - entry.status = if is_error { - ToolCallStatus::Failed - } else { - ToolCallStatus::Succeeded - }; - let elapsed = format_duration_short(entry.bar.elapsed()); - entry.bar.set_style(style_tool_done()); - entry.bar.set_prefix(elapsed); - entry - .bar - .finish_with_message(format!("{glyph} {}", entry.display_name)); - } - } - } - } -} - -// ── ProgressAwareInterviewer ──────────────────────────────────────────── - -/// Wraps a `ConsoleInterviewer` so that progress bars are hidden during -/// interactive prompts (avoids garbled output from concurrent writes). -#[allow(dead_code)] -pub(crate) struct ProgressAwareInterviewer { - inner: ConsoleInterviewer, - progress: Arc>, -} - -#[allow(dead_code)] -impl ProgressAwareInterviewer { - pub(crate) fn new(inner: ConsoleInterviewer, progress: Arc>) -> Self { - Self { inner, progress } - } -} - -#[async_trait] -impl Interviewer for ProgressAwareInterviewer { - async fn ask(&self, question: Question) -> Answer { - self.progress - .lock() - .expect("progress lock poisoned") - .hide_bars(); - let answer = self.inner.ask(question).await; - self.progress - .lock() - .expect("progress lock poisoned") - .show_bars(); - answer - } - - async fn inform(&self, message: &str, stage: &str) { - self.progress - .lock() - .expect("progress lock poisoned") - .hide_bars(); - self.inner.inform(message, stage).await; - self.progress - .lock() - .expect("progress lock poisoned") - .show_bars(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn stage_started(node_id: &str, name: &str) -> WorkflowRunEvent { - WorkflowRunEvent::StageStarted { - node_id: node_id.into(), - name: name.into(), - index: 0, - handler_type: None, - script: None, - attempt: 1, - max_attempts: 1, - } - } - - #[test] - fn parallel_branches_tracked_as_tool_calls() { - let mut ui = ProgressUI::new(true, false); - - ui.handle_event(&stage_started("fork1", "Fork Analysis")); - assert!(ui.active_stages.contains_key("fork1")); - assert!(ui.parallel_parent.is_none()); - - ui.handle_event(&WorkflowRunEvent::ParallelStarted { - branch_count: 2, - join_policy: "wait_all".into(), - }); - assert_eq!(ui.parallel_parent.as_deref(), Some("fork1")); - - // Branch started → creates a tool_call entry - ui.handle_event(&WorkflowRunEvent::ParallelBranchStarted { - branch: "security".into(), - index: 0, - }); - let stage = &ui.active_stages["fork1"]; - assert_eq!(stage.tool_calls.len(), 1); - assert_eq!(stage.tool_calls[0].tool_call_id, "security"); - assert!(matches!( - stage.tool_calls[0].status, - ToolCallStatus::Running - )); - - // Branch completed → marks entry as succeeded - ui.handle_event(&WorkflowRunEvent::ParallelBranchCompleted { - branch: "security".into(), - index: 0, - duration_ms: 2000, - status: "success".into(), - }); - let stage = &ui.active_stages["fork1"]; - assert!(matches!( - stage.tool_calls[0].status, - ToolCallStatus::Succeeded - )); - - // Second branch - ui.handle_event(&WorkflowRunEvent::ParallelBranchStarted { - branch: "quality".into(), - index: 1, - }); - ui.handle_event(&WorkflowRunEvent::ParallelBranchCompleted { - branch: "quality".into(), - index: 1, - duration_ms: 3000, - status: "success".into(), - }); - let stage = &ui.active_stages["fork1"]; - assert_eq!(stage.tool_calls.len(), 2); - - // Parallel completed → clears parent - ui.handle_event(&WorkflowRunEvent::ParallelCompleted { - duration_ms: 3000, - success_count: 2, - failure_count: 0, - }); - assert!(ui.parallel_parent.is_none()); - } - - #[test] - 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, - }); - - let stage = &ui.active_stages["fork1"]; - let bar = &stage.tool_calls[0].bar; - let msg = bar.message(); - assert!( - msg.contains('\u{25b8}'), - "expected bar message to contain ▸, got: {msg:?}" - ); - } - - #[test] - fn parallel_branch_failure_tracked() { - 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: "risky".into(), - index: 0, - }); - ui.handle_event(&WorkflowRunEvent::ParallelBranchCompleted { - branch: "risky".into(), - index: 0, - duration_ms: 500, - status: "fail".into(), - }); - - let stage = &ui.active_stages["fork1"]; - assert!(matches!(stage.tool_calls[0].status, ToolCallStatus::Failed)); - } - - #[test] - fn compaction_sets_and_clears_bar() { - let mut ui = ProgressUI::new(true, false); - - ui.handle_event(&stage_started("s1", "Build")); - assert!(ui.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, - }, - }); - assert!(ui.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, - }, - }); - assert!(ui.active_stages["s1"].compaction_bar.is_none()); - } - - #[test] - fn tool_display_name_shortens_path_relative_to_working_directory() { - let mut ui = ProgressUI::new(true, false); - ui.set_working_directory("/home/daytona/workspace".to_string()); - - let args = serde_json::json!({"file_path": "/home/daytona/workspace/output/js/physics.js"}); - let display = ui.tool_display_name("write_file", &args); - assert!( - display.contains("output/js/physics.js"), - "expected relative path in: {display}" - ); - assert!( - !display.contains("/home/daytona/workspace/"), - "should not contain absolute working dir in: {display}" - ); - } - - #[test] - fn tool_display_name_preserves_path_outside_working_directory() { - let mut ui = ProgressUI::new(true, false); - ui.set_working_directory("/home/daytona/workspace".to_string()); - - let args = serde_json::json!({"file_path": "/etc/config.json"}); - let display = ui.tool_display_name("read_file", &args); - assert!( - display.contains("/etc/config.json"), - "expected absolute path preserved in: {display}" - ); - } - - #[test] - fn tool_display_name_without_working_directory_shows_full_path() { - let ui = ProgressUI::new(true, false); - - let args = serde_json::json!({"file_path": "/home/daytona/workspace/output/js/physics.js"}); - let display = ui.tool_display_name("write_file", &args); - assert!( - display.contains("/home/daytona/workspace/output/js/physics.js"), - "expected full path when no working dir set: {display}" - ); - } - - #[test] - fn plain_mode_sets_parallel_parent() { - let mut ui = ProgressUI::new(false, false); - - ui.handle_event(&stage_started("fork1", "Fork")); - ui.handle_event(&WorkflowRunEvent::ParallelStarted { - branch_count: 2, - join_policy: "wait_all".into(), - }); - // In Plain mode, active_stages is empty so parallel_parent is a sentinel - assert!(ui.parallel_parent.is_some()); - - ui.handle_event(&WorkflowRunEvent::ParallelCompleted { - duration_ms: 1000, - success_count: 2, - failure_count: 0, - }); - assert!(ui.parallel_parent.is_none()); - } - - #[test] - fn handle_json_line_stage_started_and_completed() { - let mut ui = ProgressUI::new(false, false); - - let started = r#"{"ts":"2026-01-01T12:00:00Z","event":"StageStarted","node_id":"plan","node_label":"Plan","stage_index":0,"script":null,"attempt":1,"max_attempts":1}"#; - ui.handle_json_line(started); - assert!(ui.stage_counts.contains_key("plan")); - - let completed = r#"{"ts":"2026-01-01T12:00:10Z","event":"StageCompleted","node_id":"plan","node_label":"Plan","stage_index":0,"duration_ms":10000,"status":"success"}"#; - ui.handle_json_line(completed); - // In Plain mode, finish_stage just prints, so verify no panic - } - - #[test] - fn handle_json_line_tool_call_round_trip() { - let mut ui = ProgressUI::new(false, true); // verbose - - // Start a stage first - let started = r#"{"ts":"2026-01-01T12:00:00Z","event":"StageStarted","node_id":"code","node_label":"Code","stage_index":0,"attempt":1,"max_attempts":1}"#; - ui.handle_json_line(started); - - let tc_start = r#"{"ts":"2026-01-01T12:00:01Z","event":"Agent.ToolCallStarted","node_id":"code","node_label":"code","tool_name":"read_file","tool_call_id":"tc1","arguments":{"path":"src/main.rs"}}"#; - ui.handle_json_line(tc_start); - assert_eq!(ui.stage_counts.get("code").map(|c| c.1), Some(1)); - - let tc_done = r#"{"ts":"2026-01-01T12:00:02Z","event":"Agent.ToolCallCompleted","node_id":"code","node_label":"code","tool_name":"read_file","tool_call_id":"tc1","is_error":false}"#; - ui.handle_json_line(tc_done); - } - - #[test] - fn handle_json_line_retro_events() { - let mut ui = ProgressUI::new(false, false); - - let retro_started = r#"{"ts":"2026-01-01T12:00:00Z","event":"RetroStarted"}"#; - ui.handle_json_line(retro_started); - - let retro_completed = - r#"{"ts":"2026-01-01T12:00:05Z","event":"RetroCompleted","duration_ms":5000}"#; - ui.handle_json_line(retro_completed); - } - - #[test] - fn handle_json_line_ignores_invalid_json() { - let mut ui = ProgressUI::new(false, false); - ui.handle_json_line("not valid json"); - ui.handle_json_line(""); - ui.handle_json_line("{}"); // no event field - } - - // ── Bug regression tests (post-rename JSONL field names) ───────── - - // Bug 1: handle_json_line reads pre-rename field names but real JSONL - // uses post-rename names from rename_fields(). These tests use the - // actual JSONL format produced by the engine. - - #[test] - fn bug1_stage_started_uses_node_label_not_name() { - // Real JSONL: rename_fields renames "name" → "node_label" - let mut ui = ProgressUI::new(true, false); - let started = r#"{"ts":"2026-01-01T12:00:00Z","event":"StageStarted","node_id":"plan","node_label":"Plan","stage_index":0,"script":null,"attempt":1,"max_attempts":1}"#; - ui.handle_json_line(started); - - let stage = ui - .active_stages - .get("plan") - .expect("stage should be tracked"); - assert_eq!( - stage.display_name, "Plan", - "display name should come from node_label field, not be '?'" - ); - } - - #[test] - fn bug1_agent_tool_call_uses_node_id_not_stage() { - // Real JSONL: rename_fields renames "stage" → "node_id" for Agent.* events - let mut ui = ProgressUI::new(false, true); - - // First create the stage - let started = r#"{"ts":"2026-01-01T12:00:00Z","event":"StageStarted","node_id":"code","node_label":"Code","stage_index":0,"attempt":1,"max_attempts":1}"#; - ui.handle_json_line(started); - assert_eq!(ui.stage_counts.get("code").map(|c| c.1), Some(0)); - - // Tool call with post-rename field: "node_id" instead of "stage" - let tc_start = r#"{"ts":"2026-01-01T12:00:01Z","event":"Agent.ToolCallStarted","node_id":"code","node_label":"code","tool_name":"read_file","tool_call_id":"tc1","arguments":{"path":"src/main.rs"}}"#; - ui.handle_json_line(tc_start); - - assert_eq!( - ui.stage_counts.get("code").map(|c| c.1), - Some(1), - "tool call count should increment using node_id field" - ); - } - - #[test] - fn bug1_agent_assistant_message_uses_node_id_not_stage() { - // Real JSONL: rename_fields renames "stage" → "node_id" - let mut ui = ProgressUI::new(false, true); - - let started = r#"{"ts":"2026-01-01T12:00:00Z","event":"StageStarted","node_id":"code","node_label":"Code","stage_index":0,"attempt":1,"max_attempts":1}"#; - ui.handle_json_line(started); - assert_eq!(ui.stage_counts.get("code").map(|c| c.0), Some(0)); - - // AssistantMessage with post-rename field: "node_id" instead of "stage" - let msg = r#"{"ts":"2026-01-01T12:00:01Z","event":"Agent.AssistantMessage","node_id":"code","node_label":"code","model":"claude-sonnet-4-20250514"}"#; - ui.handle_json_line(msg); - - assert_eq!( - ui.stage_counts.get("code").map(|c| c.0), - Some(1), - "turn count should increment using node_id field" - ); - } - - #[test] - fn bug1_parallel_branch_uses_node_id_not_branch() { - // Real JSONL: rename_fields renames "branch" → "node_id" - let mut ui = ProgressUI::new(true, false); - - // Set up a parent stage and start parallel - let parent = r#"{"ts":"2026-01-01T12:00:00Z","event":"StageStarted","node_id":"fork","node_label":"Fork","stage_index":0,"attempt":1,"max_attempts":1}"#; - ui.handle_json_line(parent); - let par = r#"{"ts":"2026-01-01T12:00:01Z","event":"ParallelStarted","branch_count":2,"join_policy":"wait_all"}"#; - ui.handle_json_line(par); - assert!(ui.parallel_parent.is_some()); - - // ParallelBranchStarted with post-rename field: "node_id" instead of "branch" - let branch = r#"{"ts":"2026-01-01T12:00:02Z","event":"ParallelBranchStarted","node_id":"lint","node_label":"lint","branch_index":0}"#; - ui.handle_json_line(branch); - - // Branch should have been registered as a tool_call entry on the parent - let parent_stage = &ui.active_stages["fork"]; - assert!( - !parent_stage.tool_calls.is_empty(), - "parallel branch should be registered using node_id field" - ); - } - - // Bug 5: start_run should write Starting status before spawning engine - // (tested in start.rs) - - // Bug 6: handle_json_line is missing devcontainer event dispatch - - #[test] - fn bug6_devcontainer_lifecycle_started_dispatched() { - let mut ui = ProgressUI::new(false, false); - let event = r#"{"ts":"2026-01-01T12:00:00Z","event":"DevcontainerLifecycleStarted","phase":"postCreate","command_count":2}"#; - ui.handle_json_line(event); - assert_eq!( - ui.devcontainer_command_count, 2, - "devcontainer_command_count should be set by DevcontainerLifecycleStarted" - ); - } - - #[test] - fn handle_json_line_run_notice_warn() { - let mut ui = ProgressUI::new(false, false); - let event = r#"{"ts":"2026-01-01T12:00:00Z","event":"RunNotice","level":"warn","code":"sandbox_cleanup_failed","message":"sandbox cleanup failed: boom"}"#; - ui.handle_json_line(event); - } - - #[test] - fn handle_json_line_pull_request_failed() { - let mut ui = ProgressUI::new(false, false); - let event = r#"{"ts":"2026-01-01T12:00:00Z","event":"PullRequestFailed","error":"auth token expired"}"#; - ui.handle_json_line(event); - } -} diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs new file mode 100644 index 000000000..c7ef9f6e0 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs @@ -0,0 +1,739 @@ +use std::convert::TryFrom; + +use chrono::{DateTime, Utc}; +use fabro_workflow::event::RunNoticeLevel; +use fabro_workflow::outcome::{StageUsage, compute_stage_cost}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone)] +pub(super) struct ProgressUsage { + pub(super) model: Option, + pub(super) input_tokens: u64, + pub(super) output_tokens: u64, + pub(super) speed: Option, + pub(super) cost: Option, +} + +impl ProgressUsage { + pub(super) fn from_value(value: &Value) -> Option { + let Value::Object(fields) = value else { + return None; + }; + + Some(Self { + model: string_field(fields, "model"), + input_tokens: u64_field(fields, "input_tokens"), + output_tokens: u64_field(fields, "output_tokens"), + speed: string_field(fields, "speed"), + cost: f64_field(fields, "cost"), + }) + } + + pub(super) fn total_tokens(&self) -> u64 { + self.input_tokens.saturating_add(self.output_tokens) + } + + pub(super) fn display_cost(&self) -> Option { + self.cost.or_else(|| { + let model = self.model.clone()?; + let input_tokens = i64::try_from(self.input_tokens).ok()?; + let output_tokens = i64::try_from(self.output_tokens).ok()?; + let usage = StageUsage { + model, + input_tokens, + output_tokens, + cache_read_tokens: None, + cache_write_tokens: None, + reasoning_tokens: None, + speed: self.speed.clone(), + cost: None, + }; + compute_stage_cost(&usage) + }) + } +} + +#[derive(Debug, Clone)] +pub(super) enum ProgressEvent { + WorkflowStarted { + worktree_dir: Option, + base_branch: Option, + base_sha: Option, + }, + WorkingDirectorySet { + working_directory: String, + }, + SandboxInitializing { + provider: String, + }, + SandboxReady { + provider: String, + duration_ms: u64, + name: Option, + cpu: Option, + memory: Option, + url: Option, + }, + SshAccessReady { + ssh_command: String, + }, + SetupStarted { + command_count: u64, + }, + SetupCompleted { + duration_ms: u64, + }, + SetupCommandCompleted { + command: String, + command_index: u64, + exit_code: i64, + duration_ms: u64, + }, + CliEnsureStarted { + cli_name: String, + }, + CliEnsureCompleted { + cli_name: String, + already_installed: bool, + duration_ms: u64, + }, + CliEnsureFailed { + cli_name: String, + }, + DevcontainerResolved { + dockerfile_lines: u64, + environment_count: u64, + lifecycle_command_count: u64, + workspace_folder: String, + }, + DevcontainerLifecycleStarted { + phase: String, + command_count: u64, + }, + DevcontainerLifecycleCompleted { + phase: String, + duration_ms: u64, + }, + DevcontainerLifecycleFailed { + phase: String, + command: String, + exit_code: i64, + stderr: String, + }, + DevcontainerLifecycleCommandCompleted { + command: String, + command_index: u64, + exit_code: i64, + duration_ms: u64, + }, + StageStarted { + node_id: String, + name: String, + script: Option, + }, + StageCompleted { + node_id: String, + name: String, + duration_ms: u64, + status: String, + usage: Option, + }, + StageFailed { + node_id: String, + name: String, + error: String, + }, + StageRetrying { + name: String, + attempt: u64, + max_attempts: u64, + delay_ms: u64, + }, + ParallelStarted, + ParallelBranchStarted { + branch: String, + }, + ParallelBranchCompleted { + branch: String, + duration_ms: u64, + status: String, + }, + ParallelCompleted, + AssistantMessage { + stage_node_id: String, + model: String, + }, + ToolCallStarted { + stage_node_id: String, + tool_name: String, + tool_call_id: String, + arguments: Value, + timestamp: Option>, + }, + ToolCallCompleted { + stage_node_id: String, + tool_call_id: String, + is_error: bool, + duration_ms: Option, + timestamp: Option>, + }, + ContextWindowWarning { + stage_node_id: String, + usage_percent: u64, + }, + CompactionStarted { + stage_node_id: String, + }, + CompactionCompleted { + stage_node_id: String, + original_turn_count: u64, + preserved_turn_count: u64, + tracked_file_count: u64, + }, + LlmRetry { + stage_node_id: String, + model: String, + attempt: u64, + delay_ms: u64, + error: String, + }, + SubagentSpawned { + stage_node_id: String, + agent_id: String, + task: String, + }, + SubagentCompleted { + stage_node_id: String, + agent_id: String, + success: bool, + turns_used: u64, + }, + EdgeSelected { + from_node: String, + to_node: String, + label: Option, + condition: Option, + }, + LoopRestart { + from_node: String, + to_node: String, + }, + RetroStarted, + RetroCompleted { + duration_ms: u64, + }, + RetroFailed { + duration_ms: u64, + }, + RunNotice { + level: RunNoticeLevel, + code: String, + message: String, + }, + PullRequestCreated { + pr_url: String, + draft: bool, + }, + PullRequestFailed { + error: String, + }, +} + +#[allow(clippy::needless_pass_by_value)] +pub(super) fn from_envelope_fields( + event_name: &str, + fields: &Map, +) -> Option { + match event_name { + "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"), + }), + "sandbox.initialized" => Some(ProgressEvent::WorkingDirectorySet { + working_directory: prop_string_field(fields, "working_directory")?, + }), + "sandbox.initializing" => Some(ProgressEvent::SandboxInitializing { + provider: prop_string_field(fields, "provider") + .unwrap_or_else(|| "unknown".to_string()), + }), + "sandbox.ready" => Some(ProgressEvent::SandboxReady { + provider: prop_string_field(fields, "provider") + .unwrap_or_else(|| "unknown".to_string()), + 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"), + }), + "ssh.ready" => Some(ProgressEvent::SshAccessReady { + ssh_command: prop_string_field(fields, "ssh_command")?, + }), + "setup.started" => Some(ProgressEvent::SetupStarted { + command_count: prop_u64_field(fields, "command_count"), + }), + "setup.completed" => Some(ProgressEvent::SetupCompleted { + duration_ms: prop_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"), + }), + "cli.ensure.started" => Some(ProgressEvent::CliEnsureStarted { + cli_name: prop_string_field(fields, "cli_name").unwrap_or_else(|| "?".to_string()), + }), + "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"), + }), + "cli.ensure.failed" => Some(ProgressEvent::CliEnsureFailed { + cli_name: prop_string_field(fields, "cli_name").unwrap_or_else(|| "?".to_string()), + }), + "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()), + }), + "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"), + }), + "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"), + }), + "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(), + }), + "devcontainer.lifecycle.command.completed" => { + Some(ProgressEvent::DevcontainerLifecycleCommandCompleted { + 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"), + }) + } + "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"), + }), + "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), + }), + "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()), + }), + "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"), + }), + "parallel.started" => Some(ProgressEvent::ParallelStarted), + "parallel.branch.started" => Some(ProgressEvent::ParallelBranchStarted { + branch: string_field(fields, "node_id").unwrap_or_else(|| "?".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()), + }), + "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.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()), + arguments: prop_value(fields, "arguments") + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())), + timestamp: timestamp_field(fields, "ts"), + }), + "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()), + 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 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").unwrap_or_else(|| "?".to_string()), + usage_percent, + }) + } + "agent.compaction.started" => Some(ProgressEvent::CompactionStarted { + stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + }), + "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.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").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: prop_display_field(fields, "error") + .unwrap_or_else(|| "unknown error".to_string()), + }) + } + "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.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"), + }), + "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"), + }), + "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()), + }), + "retro.started" => Some(ProgressEvent::RetroStarted), + "retro.completed" => Some(ProgressEvent::RetroCompleted { + duration_ms: prop_u64_field(fields, "duration_ms"), + }), + "retro.failed" => Some(ProgressEvent::RetroFailed { + duration_ms: prop_u64_field(fields, "duration_ms"), + }), + "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(), + }), + "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"), + }), + "pull_request.failed" => Some(ProgressEvent::PullRequestFailed { + error: prop_display_field(fields, "error") + .unwrap_or_else(|| "unknown error".to_string()), + }), + _ => None, + } +} + +fn parse_run_notice_level(level: Option<&str>) -> RunNoticeLevel { + match level.unwrap_or("info") { + "warn" => RunNoticeLevel::Warn, + "error" => RunNoticeLevel::Error, + _ => RunNoticeLevel::Info, + } +} + +fn string_field(fields: &Map, key: &str) -> Option { + fields.get(key).and_then(Value::as_str).map(str::to_owned) +} + +fn prop_value<'a>(fields: &'a Map, key: &str) -> Option<&'a Value> { + fields + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get(key)) +} + +fn prop_string_field(fields: &Map, key: &str) -> Option { + prop_value(fields, key) + .and_then(Value::as_str) + .map(str::to_owned) +} + +fn prop_display_field(fields: &Map, key: &str) -> Option { + let value = prop_value(fields, key)?; + match value { + Value::Null => None, + Value::String(value) => Some(value.clone()), + Value::Object(map) => map + .get("message") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| { + map.get("detail") + .and_then(Value::as_object) + .and_then(|detail| detail.get("message")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .or_else(|| { + map.get("data") + .and_then(Value::as_object) + .and_then(|detail| detail.get("message")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .or_else(|| map.get("data").and_then(Value::as_str).map(str::to_owned)) + .or_else(|| Some(value.to_string())), + _ => Some(value.to_string()), + } +} + +fn u64_field(fields: &Map, key: &str) -> u64 { + fields.get(key).and_then(Value::as_u64).unwrap_or(0) +} + +fn prop_u64_field(fields: &Map, key: &str) -> u64 { + prop_value(fields, key).and_then(Value::as_u64).unwrap_or(0) +} + +fn prop_optional_u64_field(fields: &Map, key: &str) -> Option { + prop_value(fields, key).and_then(Value::as_u64) +} + +fn prop_i64_field(fields: &Map, key: &str) -> i64 { + prop_value(fields, key).and_then(Value::as_i64).unwrap_or(0) +} + +fn f64_field(fields: &Map, key: &str) -> Option { + fields.get(key).and_then(Value::as_f64) +} + +fn prop_f64_field(fields: &Map, key: &str) -> Option { + prop_value(fields, key).and_then(Value::as_f64) +} + +fn prop_bool_field(fields: &Map, key: &str) -> bool { + prop_value(fields, key) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn timestamp_field(fields: &Map, key: &str) -> Option> { + let value = fields.get(key)?.as_str()?; + DateTime::parse_from_rfc3339(value) + .ok() + .map(|timestamp| timestamp.with_timezone(&Utc)) +} + +#[cfg(test)] +mod tests { + use fabro_agent::AgentEvent; + use fabro_types::fixtures; + use fabro_workflow::event::{WorkflowRunEvent, canonicalize_event}; + + use super::*; + + fn json_map(value: Value) -> Map { + value.as_object().cloned().expect("json object") + } + + fn canonical_fields(event: &WorkflowRunEvent) -> (String, Map) { + let envelope = canonicalize_event(&fixtures::RUN_1, event); + let event_name = envelope.event.clone(); + let fields = json_map(serde_json::to_value(envelope).expect("serializable envelope")); + (event_name, fields) + } + + #[test] + fn parse_edge_selected() { + let fields = json_map(serde_json::json!({ + "properties": { + "from_node": "a", + "to_node": "b", + "label": "yes" + } + })); + + let event = from_envelope_fields("edge.selected", &fields).unwrap(); + assert!(matches!( + event, + ProgressEvent::EdgeSelected { + from_node, + to_node, + label, + .. + } if from_node == "a" && to_node == "b" && label.as_deref() == Some("yes") + )); + } + + #[test] + fn round_trip_stage_completed() { + let event = WorkflowRunEvent::StageCompleted { + node_id: "plan".into(), + name: "Plan".into(), + index: 0, + duration_ms: 5000, + status: "success".into(), + preferred_label: None, + suggested_next_ids: Vec::new(), + usage: None, + failure: None, + notes: None, + files_touched: Vec::new(), + attempt: 1, + max_attempts: 1, + }; + + let (name, fields) = canonical_fields(&event); + let parsed = from_envelope_fields(&name, &fields).unwrap(); + assert!(matches!( + parsed, + ProgressEvent::StageCompleted { + node_id, + name, + duration_ms, + .. + } if node_id == "plan" && name == "Plan" && duration_ms == 5000 + )); + } + + #[test] + fn round_trip_agent_tool_call() { + let event = WorkflowRunEvent::Agent { + stage: "code".into(), + event: AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }, + session_id: None, + parent_session_id: None, + }; + + let (name, fields) = canonical_fields(&event); + let parsed = from_envelope_fields(&name, &fields).unwrap(); + assert!(matches!( + parsed, + ProgressEvent::ToolCallStarted { + stage_node_id, + tool_name, + tool_call_id, + .. + } if stage_node_id == "code" && tool_name == "read_file" && tool_call_id == "tc1" + )); + } + + #[test] + fn parse_tool_call_timestamps_from_jsonl_envelope() { + let started_fields = json_map(serde_json::json!({ + "ts": "2026-03-30T12:00:00.000Z", + "node_id": "code", + "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", + "properties": { + "tool_call_id": "tc1", + "is_error": false, + "duration_ms": 500 + } + })); + + 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, + ProgressEvent::ToolCallStarted { + timestamp: Some(timestamp), + .. + } if timestamp == DateTime::parse_from_rfc3339("2026-03-30T12:00:00.000Z") + .unwrap() + .with_timezone(&Utc) + )); + assert!(matches!( + completed, + ProgressEvent::ToolCallCompleted { + duration_ms: Some(500), + timestamp: Some(timestamp), + .. + } if timestamp == DateTime::parse_from_rfc3339("2026-03-30T12:00:00.500Z") + .unwrap() + .with_timezone(&Utc) + )); + } + + #[test] + fn round_trip_sandbox_ready() { + let event = WorkflowRunEvent::Sandbox { + event: fabro_agent::SandboxEvent::Ready { + provider: "daytona".into(), + duration_ms: 2500, + name: Some("sandbox-1".into()), + cpu: Some(4.0), + memory: Some(8.0), + url: Some("https://example.test".into()), + }, + }; + + let (name, fields) = canonical_fields(&event); + let parsed = from_envelope_fields(&name, &fields).unwrap(); + assert!(matches!( + parsed, + ProgressEvent::SandboxReady { + provider, + duration_ms, + name, + .. + } if provider == "daytona" && duration_ms == 2500 && name.as_deref() == Some("sandbox-1") + )); + } + + #[test] + fn round_trip_run_notice() { + let event = WorkflowRunEvent::RunNotice { + level: RunNoticeLevel::Warn, + code: "sandbox_cleanup_failed".into(), + message: "sandbox cleanup failed".into(), + }; + + let (name, fields) = canonical_fields(&event); + let parsed = from_envelope_fields(&name, &fields).unwrap(); + assert!(matches!( + parsed, + ProgressEvent::RunNotice { + level: RunNoticeLevel::Warn, + code, + message, + } if code == "sandbox_cleanup_failed" && message == "sandbox cleanup failed" + )); + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/info_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/info_display.rs new file mode 100644 index 000000000..4d3c1bb35 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/info_display.rs @@ -0,0 +1,137 @@ +use std::path::Path; + +use fabro_workflow::event::RunNoticeLevel; + +use super::renderer::ProgressRenderer; +use super::styles; +use crate::shared::{format_duration_ms, tilde_path}; + +pub(super) struct InfoDisplay { + verbose: bool, +} + +impl InfoDisplay { + pub(super) fn new(verbose: bool) -> Self { + Self { verbose } + } + + pub(super) fn show_worktree(renderer: &ProgressRenderer, path: &Path) { + Self::insert_info_line(renderer, &format!("Worktree: {}", tilde_path(path))); + } + + pub(super) fn show_base_info(renderer: &ProgressRenderer, branch: Option<&str>, sha: &str) { + let short_sha = &sha[..sha.len().min(12)]; + let text = match branch { + Some(branch) => format!("Base: {branch} ({short_sha})"), + None => format!("Base: {short_sha}"), + }; + Self::insert_info_line(renderer, &text); + } + + pub(super) fn on_run_notice( + renderer: &ProgressRenderer, + level: RunNoticeLevel, + code: &str, + message: &str, + ) { + let styles = renderer.styles(); + let label = match level { + RunNoticeLevel::Info => styles.bold.apply_to("Info:").to_string(), + RunNoticeLevel::Warn => styles.yellow.apply_to("Warning:").to_string(), + RunNoticeLevel::Error => styles.red.apply_to("Error:").to_string(), + }; + let code_suffix = if code.is_empty() { + String::new() + } else { + format!(" {}", styles.dim.apply_to(format!("[{code}]"))) + }; + Self::insert_info_line(renderer, &format!("{label} {message}{code_suffix}")); + } + + pub(super) fn on_pull_request_created(renderer: &ProgressRenderer, pr_url: &str, draft: bool) { + let label = if draft { "Draft PR:" } else { "PR:" }; + Self::insert_info_line( + renderer, + &format!("{} {pr_url}", renderer.styles().bold.apply_to(label)), + ); + } + + pub(super) fn on_pull_request_failed(renderer: &ProgressRenderer, error: &str) { + Self::insert_info_line( + renderer, + &format!("{} {error}", renderer.styles().red.apply_to("PR failed:")), + ); + } + + pub(super) fn on_edge_selected( + &self, + renderer: &ProgressRenderer, + from_node: &str, + to_node: &str, + label: Option<&str>, + condition: Option<&str>, + ) { + if !self.verbose { + return; + } + + let detail = if let Some(condition) = condition { + format!(" [{condition}]") + } else if let Some(label) = label { + format!(" \"{label}\"") + } else { + String::new() + }; + Self::insert_info_line( + renderer, + &format!("\u{2192} {from_node} \u{2192} {to_node}{detail}"), + ); + } + + pub(super) fn on_loop_restart( + &self, + renderer: &ProgressRenderer, + from_node: &str, + to_node: &str, + ) { + if !self.verbose { + return; + } + + Self::insert_info_line( + renderer, + &format!("\u{21ba} {from_node} \u{2192} {to_node} (loop restart)"), + ); + } + + pub(super) fn on_stage_retrying( + &self, + renderer: &ProgressRenderer, + name: &str, + attempt: u64, + max_attempts: u64, + delay_ms: u64, + ) { + if !self.verbose { + return; + } + + Self::insert_info_line( + renderer, + &format!( + "\u{21bb} {name}: retrying (attempt {attempt}/{max_attempts}, delay {})", + format_duration_ms(delay_ms) + ), + ); + } + + fn insert_info_line(renderer: &ProgressRenderer, message: &str) { + if renderer.is_tty() { + let bar = renderer.add_spinner(); + bar.set_style(styles::style_static_dim()); + bar.finish_with_message(message.to_string()); + } else { + renderer.print_line(4, message); + } + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs new file mode 100644 index 000000000..88518def9 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -0,0 +1,1134 @@ +use serde_json::Value; + +use fabro_workflow::event::RunEventEnvelope; + +mod event; +mod info_display; +mod renderer; +mod setup_display; +mod stage_display; +mod styles; + +use event::{ProgressEvent, from_envelope_fields}; +use info_display::InfoDisplay; +use renderer::ProgressRenderer; +use setup_display::SetupDisplay; +use stage_display::StageDisplay; + +pub(crate) struct ProgressUI { + renderer: ProgressRenderer, + stage: StageDisplay, + setup: SetupDisplay, + info: InfoDisplay, +} + +impl ProgressUI { + pub(crate) fn new(is_tty: bool, verbose: bool) -> Self { + let renderer = if is_tty { + ProgressRenderer::new_tty() + } else { + ProgressRenderer::new_plain( + Box::new(std::io::stderr()), + console::colors_enabled_stderr(), + ) + }; + Self::with_renderer(renderer, verbose) + } + + fn with_renderer(renderer: ProgressRenderer, verbose: bool) -> Self { + Self { + renderer, + stage: StageDisplay::new(verbose), + setup: SetupDisplay::new(verbose), + info: InfoDisplay::new(verbose), + } + } + + #[cfg(test)] + fn new_plain_test(out: Box, verbose: bool, colors: bool) -> Self { + Self::with_renderer(ProgressRenderer::new_plain(out, colors), verbose) + } + + pub(crate) fn set_working_directory(&mut self, dir: String) { + self.stage.set_working_directory(dir); + } + + pub(crate) fn hide_bars(&self) { + self.renderer.hide(); + } + + pub(crate) fn show_bars(&self) { + self.renderer.show(); + } + + pub(crate) fn finish(&mut self) { + self.stage.finish(); + self.setup.finish(); + self.renderer.finish(); + } + + #[cfg_attr(not(test), allow(dead_code))] + 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(envelope)) = serde_json::from_str(line) else { + return; + }; + let Some(event_name) = envelope.get("event").and_then(|value| value.as_str()) else { + return; + }; + if let Some(progress_event) = from_envelope_fields(event_name, &envelope) { + self.dispatch(progress_event); + } + } + + fn dispatch(&mut self, event: ProgressEvent) { + let renderer = &self.renderer; + match event { + ProgressEvent::WorkflowStarted { + worktree_dir, + base_branch, + base_sha, + } => { + if let Some(worktree_dir) = worktree_dir { + InfoDisplay::show_worktree(renderer, std::path::Path::new(&worktree_dir)); + } + if let Some(base_sha) = base_sha { + InfoDisplay::show_base_info(renderer, base_branch.as_deref(), &base_sha); + } + } + ProgressEvent::WorkingDirectorySet { working_directory } => { + self.set_working_directory(working_directory); + } + ProgressEvent::SandboxInitializing { provider } => { + self.setup.on_sandbox_initializing(renderer, &provider); + } + ProgressEvent::SandboxReady { + provider, + duration_ms, + name, + cpu, + memory, + url, + } => { + self.setup.on_sandbox_ready( + renderer, + &provider, + duration_ms, + name.as_deref(), + cpu, + memory, + url.as_deref(), + ); + } + ProgressEvent::SshAccessReady { ssh_command } => { + SetupDisplay::on_ssh_access_ready(renderer, &ssh_command); + } + ProgressEvent::SetupStarted { command_count } => { + self.setup.on_setup_started(renderer, command_count); + } + ProgressEvent::SetupCompleted { duration_ms } => { + self.setup.on_setup_completed(renderer, duration_ms); + } + ProgressEvent::SetupCommandCompleted { + command, + command_index, + exit_code, + duration_ms, + } => { + self.setup.on_setup_command_completed( + renderer, + &command, + command_index, + exit_code, + duration_ms, + ); + } + ProgressEvent::CliEnsureStarted { cli_name } => { + self.setup.on_cli_ensure_started(renderer, &cli_name); + } + ProgressEvent::CliEnsureCompleted { + cli_name, + already_installed, + duration_ms, + } => { + self.setup.on_cli_ensure_completed( + renderer, + &cli_name, + already_installed, + duration_ms, + ); + } + ProgressEvent::CliEnsureFailed { cli_name } => { + self.setup.on_cli_ensure_failed(renderer, &cli_name); + } + ProgressEvent::DevcontainerResolved { + dockerfile_lines, + environment_count, + lifecycle_command_count, + workspace_folder, + } => { + SetupDisplay::on_devcontainer_resolved( + renderer, + dockerfile_lines, + environment_count, + lifecycle_command_count, + &workspace_folder, + ); + } + ProgressEvent::DevcontainerLifecycleStarted { + phase, + command_count, + } => { + self.setup + .on_devcontainer_lifecycle_started(renderer, &phase, command_count); + } + ProgressEvent::DevcontainerLifecycleCompleted { phase, duration_ms } => { + self.setup + .on_devcontainer_lifecycle_completed(renderer, &phase, duration_ms); + } + ProgressEvent::DevcontainerLifecycleFailed { + phase, + command, + exit_code, + stderr, + } => { + self.setup.on_devcontainer_lifecycle_failed( + renderer, &phase, &command, exit_code, &stderr, + ); + } + ProgressEvent::DevcontainerLifecycleCommandCompleted { + command, + command_index, + exit_code, + duration_ms, + } => { + self.setup.on_devcontainer_lifecycle_command_completed( + renderer, + &command, + command_index, + exit_code, + duration_ms, + ); + } + ProgressEvent::StageStarted { + node_id, + name, + script, + } => { + self.stage + .on_stage_started(renderer, &node_id, &name, script.as_deref()); + } + ProgressEvent::StageCompleted { + node_id, + name, + duration_ms, + status, + usage, + } => { + self.stage.on_stage_completed( + renderer, + &node_id, + &name, + duration_ms, + &status, + usage.as_ref(), + ); + } + ProgressEvent::StageFailed { + node_id, + name, + error, + } => { + self.stage + .on_stage_failed(renderer, &node_id, &name, &error); + } + ProgressEvent::StageRetrying { + name, + attempt, + max_attempts, + delay_ms, + } => { + self.info + .on_stage_retrying(renderer, &name, attempt, max_attempts, delay_ms); + } + ProgressEvent::ParallelStarted => { + self.stage.on_parallel_started(); + } + ProgressEvent::ParallelBranchStarted { branch } => { + self.stage.on_parallel_branch_started(renderer, &branch); + } + ProgressEvent::ParallelBranchCompleted { + branch, + duration_ms, + status, + } => { + self.stage + .on_parallel_branch_completed(renderer, &branch, duration_ms, &status); + } + ProgressEvent::ParallelCompleted => { + self.stage.on_parallel_completed(); + } + ProgressEvent::AssistantMessage { + stage_node_id, + model, + } => { + self.stage + .on_assistant_message(renderer, &stage_node_id, &model); + } + ProgressEvent::ToolCallStarted { + stage_node_id, + tool_name, + tool_call_id, + arguments, + timestamp, + } => { + self.stage.on_tool_call_started( + renderer, + &stage_node_id, + &tool_name, + &tool_call_id, + &arguments, + timestamp, + ); + } + ProgressEvent::ToolCallCompleted { + stage_node_id, + tool_call_id, + is_error, + duration_ms, + timestamp, + } => { + self.stage.on_tool_call_completed( + renderer, + &stage_node_id, + &tool_call_id, + is_error, + duration_ms, + timestamp, + ); + } + ProgressEvent::ContextWindowWarning { + stage_node_id, + usage_percent, + } => { + self.stage + .on_context_window_warning(renderer, &stage_node_id, usage_percent); + } + ProgressEvent::CompactionStarted { stage_node_id } => { + self.stage.on_compaction_started(renderer, &stage_node_id); + } + ProgressEvent::CompactionCompleted { + stage_node_id, + original_turn_count, + preserved_turn_count, + tracked_file_count, + } => { + self.stage.on_compaction_completed( + renderer, + &stage_node_id, + original_turn_count, + preserved_turn_count, + tracked_file_count, + ); + } + ProgressEvent::LlmRetry { + stage_node_id, + model, + attempt, + delay_ms, + error, + } => { + self.stage.on_llm_retry( + renderer, + &stage_node_id, + &model, + attempt, + delay_ms, + &error, + ); + } + ProgressEvent::SubagentSpawned { + stage_node_id, + agent_id, + task, + } => { + self.stage + .on_subagent_spawned(renderer, &stage_node_id, &agent_id, &task); + } + ProgressEvent::SubagentCompleted { + stage_node_id, + agent_id, + success, + turns_used, + } => { + self.stage.on_subagent_completed( + renderer, + &stage_node_id, + &agent_id, + success, + turns_used, + ); + } + ProgressEvent::EdgeSelected { + from_node, + to_node, + label, + condition, + } => { + self.info.on_edge_selected( + renderer, + &from_node, + &to_node, + label.as_deref(), + condition.as_deref(), + ); + } + ProgressEvent::LoopRestart { from_node, to_node } => { + self.info.on_loop_restart(renderer, &from_node, &to_node); + } + ProgressEvent::RetroStarted => { + self.stage.on_retro_started(renderer); + } + ProgressEvent::RetroCompleted { duration_ms } => { + self.stage.on_retro_completed(renderer, duration_ms); + } + ProgressEvent::RetroFailed { duration_ms } => { + self.stage.on_retro_failed(renderer, duration_ms); + } + ProgressEvent::RunNotice { + level, + code, + message, + } => { + InfoDisplay::on_run_notice(renderer, level, &code, &message); + } + ProgressEvent::PullRequestCreated { pr_url, draft } => { + InfoDisplay::on_pull_request_created(renderer, &pr_url, draft); + } + ProgressEvent::PullRequestFailed { error } => { + InfoDisplay::on_pull_request_failed(renderer, &error); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::io::{self, Write}; + use std::sync::{Arc, Mutex}; + + use fabro_agent::{AgentEvent, SandboxEvent}; + use fabro_llm::types::Usage; + use fabro_types::fixtures; + use fabro_workflow::event::{RunNoticeLevel, WorkflowRunEvent, canonicalize_event}; + use fabro_workflow::outcome::StageUsage; + + use super::*; + use crate::commands::run::run_progress::stage_display::ToolCallStatus; + + struct SharedBuffer { + inner: Arc>>, + } + + impl Write for SharedBuffer { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.inner + .lock() + .expect("buffer lock poisoned") + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + fn capture_ui(verbose: bool) -> (ProgressUI, Arc>>) { + let buffer = Arc::new(Mutex::new(Vec::new())); + let ui = ProgressUI::new_plain_test( + Box::new(SharedBuffer { + inner: Arc::clone(&buffer), + }), + verbose, + false, + ); + (ui, buffer) + } + + fn rendered(buffer: &Arc>>) -> String { + String::from_utf8(buffer.lock().expect("buffer lock poisoned").clone()) + .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(), + name: name.into(), + index: 0, + handler_type: None, + script: None, + attempt: 1, + max_attempts: 1, + } + } + + fn assistant_message(stage: &str, model: &str) -> WorkflowRunEvent { + 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 { + WorkflowRunEvent::StageCompleted { + node_id: node_id.into(), + name: name.into(), + index: 0, + duration_ms: 5000, + status: "success".into(), + preferred_label: None, + suggested_next_ids: Vec::new(), + usage: Some(StageUsage { + model: "gpt-5-mini".into(), + input_tokens: 1200, + output_tokens: 300, + cache_read_tokens: None, + cache_write_tokens: None, + reasoning_tokens: None, + speed: None, + cost: Some(0.12), + }), + failure: None, + notes: None, + files_touched: Vec::new(), + attempt: 1, + max_attempts: 1, + } + } + + #[test] + fn parallel_branches_tracked_as_tool_calls() { + let mut ui = ProgressUI::new(true, false); + + emit(&mut ui, stage_started("fork1", "Fork Analysis")); + assert!(ui.stage.active_stages.contains_key("fork1")); + assert!(ui.stage.parallel_parent.is_none()); + + emit( + &mut ui, + WorkflowRunEvent::ParallelStarted { + branch_count: 2, + join_policy: "wait_all".into(), + }, + ); + assert_eq!(ui.stage.parallel_parent.as_deref(), Some("fork1")); + + 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"); + assert!(matches!( + stage.tool_calls[0].status, + ToolCallStatus::Running + )); + + 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, + ToolCallStatus::Succeeded + )); + } + + #[test] + fn parallel_branch_running_shows_triangle_glyph() { + let mut ui = ProgressUI::new(true, false); + + 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(); + assert!( + message.contains('\u{25b8}'), + "expected branch message to contain ▸, got: {message:?}" + ); + } + + #[test] + fn compaction_sets_and_clears_bar() { + let mut ui = ProgressUI::new(true, false); + + emit(&mut ui, stage_started("s1", "Build")); + assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); + + 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()); + + 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()); + } + + #[test] + fn handle_json_line_ignores_invalid_json() { + let (mut ui, buffer) = capture_ui(false); + ui.handle_json_line("not valid json"); + ui.handle_json_line(""); + ui.handle_json_line("{}"); + assert!(rendered(&buffer).is_empty()); + } + + #[test] + fn handle_json_line_matches_handle_event_for_verbose_events() { + let events = vec![ + stage_started("code", "Code"), + WorkflowRunEvent::SandboxInitialized { + working_directory: "/home/daytona/workspace".into(), + }, + 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(), + 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, + }, + WorkflowRunEvent::StageRetrying { + node_id: "code".into(), + name: "Code".into(), + index: 0, + attempt: 2, + max_attempts: 3, + delay_ms: 1500, + }, + agent_event( + "code", + AgentEvent::Warning { + kind: "context_window".into(), + message: "high usage".into(), + details: serde_json::json!({"usage_percent": 92}), + }, + ), + 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, + }, + }, + ), + agent_event( + "code", + AgentEvent::SubAgentSpawned { + agent_id: "a1".into(), + depth: 1, + task: "review recent changes".into(), + }, + ), + 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(), + index: 0, + exit_code: 0, + duration_ms: 2200, + }, + WorkflowRunEvent::SetupCompleted { duration_ms: 2200 }, + WorkflowRunEvent::DevcontainerLifecycleStarted { + phase: "postCreate".into(), + command_count: 1, + }, + WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { + phase: "postCreate".into(), + command: "npm run setup".into(), + index: 0, + exit_code: 0, + duration_ms: 1400, + }, + WorkflowRunEvent::DevcontainerLifecycleCompleted { + phase: "postCreate".into(), + duration_ms: 1400, + }, + ]; + + let (mut event_ui, event_buffer) = capture_ui(true); + for event in &events { + emit_ref(&mut event_ui, event); + } + + let (mut json_ui, json_buffer) = capture_ui(true); + for event in &events { + let line = serde_json::to_string(&canonicalize_event(&fixtures::RUN_1, event)).unwrap(); + json_ui.handle_json_line(&line); + } + + assert_eq!(rendered(&event_buffer), rendered(&json_buffer)); + } + + #[test] + fn plain_default_stage_snapshot() { + let (mut ui, buffer) = capture_ui(false); + + 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 + "); + } + + #[test] + fn plain_default_setup_snapshot() { + let (mut ui, buffer) = capture_ui(false); + + emit( + &mut ui, + WorkflowRunEvent::Sandbox { + event: SandboxEvent::Initializing { + provider: "daytona".into(), + }, + }, + ); + 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, + }, + }, + ); + 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) + sandbox-1 (4 cpu, 8 GB) + ssh daytona@example + Setup: 2 commands (8s) + CLI: gh (installed, 600ms) + Devcontainer: resolved + 24 Dockerfile lines, 3 env vars, 2 lifecycle cmds, /workspace + Running devcontainer postCreate (2 commands)... + Devcontainer: postCreate (1s) + "); + } + + #[test] + fn plain_verbose_snapshot() { + let (mut ui, buffer) = capture_ui(true); + + emit(&mut ui, stage_started("code", "Code")); + emit( + &mut ui, + WorkflowRunEvent::SandboxInitialized { + working_directory: "/home/daytona/workspace".into(), + }, + ); + 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, + }, + ); + emit( + &mut ui, + WorkflowRunEvent::StageRetrying { + node_id: "code".into(), + name: "Code".into(), + index: 0, + attempt: 2, + max_attempts: 3, + delay_ms: 1500, + }, + ); + 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, + }, + ); + 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" + ↻ Code: retrying (attempt 2/3, delay 1s) + ⚠ context window: 92% used + ⚠ retry: gpt-5-mini attempt 2 (busy, delay 1s) + ▸ subagent[a1] "review recent changes" + ✓ subagent[a1] (3 turns) + ✓ [1/1] bun install 2s + Setup: 1 command (2s) + Running devcontainer postCreate (1 commands)... + ✓ [1/1] npm run setup 1s + Devcontainer: postCreate (1s) + ✓ Code $0.12 5s (1 turns, 0 tools, 1.5k toks) + "#); + } + + #[test] + fn plain_notice_snapshot() { + let (mut ui, buffer) = capture_ui(false); + + 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] + Draft PR: https://github.com/fabro-sh/fabro/pull/42 + PR failed: auth token expired + "); + } + + #[test] + fn tty_parallel_branch_completion_uses_recorded_duration() { + let mut ui = ProgressUI::new(true, false); + + 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"); + } + + #[test] + fn tty_tool_call_completion_uses_jsonl_timestamps() { + let mut ui = ProgressUI::new(true, false); + + ui.handle_json_line( + 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.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.tool.completed","node_id":"code","properties":{"tool_call_id":"tc1","is_error":false}}"#, + ); + + let stage = &ui.stage.active_stages["code"]; + assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms"); + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs new file mode 100644 index 000000000..0f25fbf09 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs @@ -0,0 +1,94 @@ +use std::io::Write; +use std::sync::Mutex; + +use fabro_util::terminal::Styles; +use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget}; + +use super::styles; + +enum RendererInner { + Tty { multi: MultiProgress }, + Plain { out: Mutex> }, +} + +pub(super) struct ProgressRenderer { + inner: RendererInner, + styles: Styles, +} + +impl ProgressRenderer { + pub(super) fn new_tty() -> Self { + Self { + inner: RendererInner::Tty { + multi: MultiProgress::new(), + }, + styles: Styles::new(console::colors_enabled_stderr()), + } + } + + pub(super) fn new_plain(out: Box, colors: bool) -> Self { + Self { + inner: RendererInner::Plain { + out: Mutex::new(out), + }, + styles: Styles::new(colors), + } + } + + pub(super) fn add_spinner(&self) -> ProgressBar { + match &self.inner { + RendererInner::Tty { multi } => multi.add(ProgressBar::new_spinner()), + RendererInner::Plain { .. } => ProgressBar::hidden(), + } + } + + pub(super) fn insert_after(&self, after: &ProgressBar) -> ProgressBar { + match &self.inner { + RendererInner::Tty { multi } => multi.insert_after(after, ProgressBar::new_spinner()), + RendererInner::Plain { .. } => ProgressBar::hidden(), + } + } + + pub(super) fn insert_before(&self, before: &ProgressBar) -> ProgressBar { + match &self.inner { + RendererInner::Tty { multi } => multi.insert_before(before, ProgressBar::new_spinner()), + RendererInner::Plain { .. } => ProgressBar::hidden(), + } + } + + pub(super) fn print_line(&self, indent: usize, message: &str) { + if let RendererInner::Plain { out } = &self.inner { + let mut out = out.lock().expect("plain renderer lock poisoned"); + let _ = writeln!(out, "{}{message}", " ".repeat(indent)); + } + } + + pub(super) fn is_tty(&self) -> bool { + matches!(self.inner, RendererInner::Tty { .. }) + } + + pub(super) fn styles(&self) -> &Styles { + &self.styles + } + + pub(super) fn hide(&self) { + if let RendererInner::Tty { multi } = &self.inner { + multi.set_draw_target(ProgressDrawTarget::hidden()); + } + } + + pub(super) fn show(&self) { + if let RendererInner::Tty { multi } = &self.inner { + multi.set_draw_target(ProgressDrawTarget::stderr()); + } + } + + pub(super) fn finish(&self) { + if let RendererInner::Tty { multi } = &self.inner { + let sep = multi.add(ProgressBar::new_spinner()); + sep.set_style(styles::style_empty()); + sep.finish(); + multi.set_draw_target(ProgressDrawTarget::hidden()); + } + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs new file mode 100644 index 000000000..cb4aedf18 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/setup_display.rs @@ -0,0 +1,378 @@ +use std::time::Duration; + +use indicatif::ProgressBar; + +use super::renderer::ProgressRenderer; +use super::styles; +use crate::shared::format_duration_ms; + +pub(super) struct SetupDisplay { + verbose: bool, + pub(super) sandbox_bar: Option, + pub(super) setup_bar: Option, + pub(super) setup_command_count: u64, + pub(super) devcontainer_bar: Option, + pub(super) devcontainer_command_count: u64, + pub(super) cli_ensure_bar: Option, +} + +impl SetupDisplay { + pub(super) fn new(verbose: bool) -> Self { + Self { + verbose, + sandbox_bar: None, + setup_bar: None, + setup_command_count: 0, + devcontainer_bar: None, + devcontainer_command_count: 0, + cli_ensure_bar: None, + } + } + + pub(super) fn finish(&mut self) { + if let Some(bar) = self.sandbox_bar.take() { + bar.finish_and_clear(); + } + if let Some(bar) = self.setup_bar.take() { + bar.finish_and_clear(); + } + if let Some(bar) = self.devcontainer_bar.take() { + bar.finish_and_clear(); + } + if let Some(bar) = self.cli_ensure_bar.take() { + bar.finish_and_clear(); + } + } + + pub(super) fn on_sandbox_initializing(&mut self, renderer: &ProgressRenderer, provider: &str) { + if renderer.is_tty() { + let bar = renderer.add_spinner(); + bar.set_style(styles::style_header_running()); + bar.set_message(format!("Initializing {provider} sandbox...")); + bar.enable_steady_tick(Duration::from_millis(100)); + self.sandbox_bar = Some(bar); + } + } + + pub(super) fn on_sandbox_ready( + &mut self, + renderer: &ProgressRenderer, + provider: &str, + duration_ms: u64, + name: Option<&str>, + cpu: Option, + memory: Option, + url: Option<&str>, + ) { + let dur = format_duration_ms(duration_ms); + let detail = match (name, cpu, memory) { + (Some(name), Some(cpu), Some(memory)) => Some(format!( + "{name} ({} cpu, {} GB)", + styles::format_number(cpu), + styles::format_number(memory) + )), + (Some(name), _, _) => Some(name.to_string()), + _ => None, + }; + + if renderer.is_tty() { + let display_provider = match url { + Some(url) => styles::terminal_hyperlink(url, provider), + None => provider.to_string(), + }; + + if let Some(bar) = self.sandbox_bar.take() { + bar.set_style(styles::style_header_done()); + bar.set_prefix(dur); + bar.finish_with_message(format!("Sandbox: {display_provider}")); + if let Some(detail) = detail { + let detail_bar = renderer.insert_after(&bar); + detail_bar.set_style(styles::style_sandbox_detail()); + detail_bar.finish_with_message(detail); + } + } + } else { + renderer.print_line(4, &format!("Sandbox: {provider} (ready in {dur})")); + if let Some(detail) = detail { + renderer.print_line(13, &detail); + } + } + } + + pub(super) fn on_ssh_access_ready(renderer: &ProgressRenderer, ssh_command: &str) { + if renderer.is_tty() { + let bar = renderer.add_spinner(); + bar.set_style(styles::style_sandbox_detail()); + bar.finish_with_message(ssh_command.to_string()); + } else { + renderer.print_line(13, ssh_command); + } + } + + pub(super) fn on_setup_started(&mut self, renderer: &ProgressRenderer, command_count: u64) { + self.setup_command_count = command_count; + if renderer.is_tty() { + let bar = renderer.add_spinner(); + bar.set_style(styles::style_header_running()); + bar.set_message(format!( + "Setup: {command_count} command{}...", + if command_count == 1 { "" } else { "s" } + )); + bar.enable_steady_tick(Duration::from_millis(100)); + self.setup_bar = Some(bar); + } + } + + pub(super) fn on_setup_completed(&mut self, renderer: &ProgressRenderer, duration_ms: u64) { + let dur = format_duration_ms(duration_ms); + let suffix = if self.setup_command_count == 1 { + "" + } else { + "s" + }; + + if renderer.is_tty() { + if let Some(bar) = self.setup_bar.take() { + bar.set_style(styles::style_header_done()); + bar.set_prefix(dur); + bar.finish_with_message(format!( + "Setup: {} command{suffix}", + self.setup_command_count + )); + } + } else { + renderer.print_line( + 4, + &format!( + "Setup: {} command{suffix} ({dur})", + self.setup_command_count + ), + ); + } + } + + pub(super) fn on_setup_command_completed( + &self, + renderer: &ProgressRenderer, + command: &str, + command_index: u64, + exit_code: i64, + duration_ms: u64, + ) { + if !self.verbose { + return; + } + + let glyph = if exit_code == 0 { + styles::green_check(renderer.styles()) + } else { + styles::red_cross(renderer.styles()) + }; + let msg = format!( + "{glyph} [{}/{}] {}", + command_index + 1, + self.setup_command_count, + styles::truncate(command, 60) + ); + let dur = format_duration_ms(duration_ms); + + if renderer.is_tty() { + let bar = match &self.setup_bar { + Some(setup_bar) => renderer.insert_before(setup_bar), + None => renderer.add_spinner(), + }; + bar.set_style(styles::style_tool_done()); + bar.set_prefix(dur); + bar.finish_with_message(msg); + } else { + renderer.print_line(6, &format!("{msg} {dur}")); + } + } + + pub(super) fn on_cli_ensure_started(&mut self, renderer: &ProgressRenderer, cli_name: &str) { + if renderer.is_tty() { + let bar = renderer.add_spinner(); + bar.set_style(styles::style_header_running()); + bar.set_message(format!("CLI: ensuring {cli_name}...")); + bar.enable_steady_tick(Duration::from_millis(100)); + self.cli_ensure_bar = Some(bar); + } + } + + pub(super) fn on_cli_ensure_completed( + &mut self, + renderer: &ProgressRenderer, + cli_name: &str, + already_installed: bool, + duration_ms: u64, + ) { + let status = if already_installed { + "found" + } else { + "installed" + }; + let dur = format_duration_ms(duration_ms); + + if renderer.is_tty() { + if let Some(bar) = self.cli_ensure_bar.take() { + bar.set_style(styles::style_header_done()); + bar.set_prefix(dur); + bar.finish_with_message(format!("CLI: {cli_name} ({status})")); + } + } else { + renderer.print_line(4, &format!("CLI: {cli_name} ({status}, {dur})")); + } + } + + pub(super) fn on_cli_ensure_failed(&mut self, renderer: &ProgressRenderer, cli_name: &str) { + let message = format!( + "{} CLI: {cli_name} install failed", + styles::red_cross(renderer.styles()) + ); + if renderer.is_tty() { + if let Some(bar) = self.cli_ensure_bar.take() { + bar.set_style(styles::style_header_done()); + bar.finish_with_message(message); + } + } else { + renderer.print_line(4, &message); + } + } + + pub(super) fn on_devcontainer_resolved( + renderer: &ProgressRenderer, + dockerfile_lines: u64, + environment_count: u64, + lifecycle_command_count: u64, + workspace_folder: &str, + ) { + let detail = format!( + "{dockerfile_lines} Dockerfile lines, {environment_count} env vars, \ + {lifecycle_command_count} lifecycle cmds, {workspace_folder}" + ); + + if renderer.is_tty() { + let bar = renderer.add_spinner(); + bar.set_style(styles::style_header_done()); + bar.finish_with_message("Devcontainer: resolved".to_string()); + let detail_bar = renderer.insert_after(&bar); + detail_bar.set_style(styles::style_sandbox_detail()); + detail_bar.finish_with_message(detail); + } else { + renderer.print_line(4, "Devcontainer: resolved"); + renderer.print_line(13, &detail); + } + } + + pub(super) fn on_devcontainer_lifecycle_started( + &mut self, + renderer: &ProgressRenderer, + phase: &str, + command_count: u64, + ) { + self.devcontainer_command_count = command_count; + + if renderer.is_tty() { + let bar = renderer.add_spinner(); + bar.set_style(styles::style_header_running()); + bar.set_message(format!( + "Running devcontainer {phase} ({command_count} commands)..." + )); + bar.enable_steady_tick(Duration::from_millis(100)); + self.devcontainer_bar = Some(bar); + } else { + renderer.print_line( + 4, + &format!("Running devcontainer {phase} ({command_count} commands)..."), + ); + } + } + + pub(super) fn on_devcontainer_lifecycle_completed( + &mut self, + renderer: &ProgressRenderer, + phase: &str, + duration_ms: u64, + ) { + let dur = format_duration_ms(duration_ms); + + if renderer.is_tty() { + if let Some(bar) = self.devcontainer_bar.take() { + bar.set_style(styles::style_header_done()); + bar.set_prefix(dur); + bar.finish_with_message(format!("Devcontainer: {phase}")); + } + } else { + renderer.print_line(4, &format!("Devcontainer: {phase} ({dur})")); + } + } + + pub(super) fn on_devcontainer_lifecycle_failed( + &mut self, + renderer: &ProgressRenderer, + phase: &str, + command: &str, + exit_code: i64, + stderr: &str, + ) { + if let Some(bar) = self.devcontainer_bar.take() { + bar.abandon(); + } + + let summary = if stderr.len() > 120 { + &stderr[..120] + } else { + stderr + }; + let message = format!( + "{} Devcontainer {phase} command failed (exit {exit_code}): {command}\n {summary}", + renderer.styles().red.apply_to("Error:") + ); + + if renderer.is_tty() { + let bar = renderer.add_spinner(); + bar.set_style(styles::style_static_dim()); + bar.finish_with_message(message); + } else { + renderer.print_line(4, &message); + } + } + + pub(super) fn on_devcontainer_lifecycle_command_completed( + &self, + renderer: &ProgressRenderer, + command: &str, + command_index: u64, + exit_code: i64, + duration_ms: u64, + ) { + if !self.verbose { + return; + } + + let glyph = if exit_code == 0 { + styles::green_check(renderer.styles()) + } else { + styles::red_cross(renderer.styles()) + }; + let msg = format!( + "{glyph} [{}/{}] {}", + command_index + 1, + self.devcontainer_command_count, + styles::truncate(command, 60) + ); + let dur = format_duration_ms(duration_ms); + + if renderer.is_tty() { + let bar = match &self.devcontainer_bar { + Some(devcontainer_bar) => renderer.insert_before(devcontainer_bar), + None => renderer.add_spinner(), + }; + bar.set_style(styles::style_tool_done()); + bar.set_prefix(dur); + bar.finish_with_message(msg); + } else { + renderer.print_line(6, &format!("{msg} {dur}")); + } + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs new file mode 100644 index 000000000..32cb3475c --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs @@ -0,0 +1,743 @@ +use std::collections::{HashMap, VecDeque}; +use std::convert::TryFrom; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use indicatif::ProgressBar; + +use fabro_workflow::outcome::{StageStatus, format_cost}; + +use super::event::ProgressUsage; +use super::renderer::ProgressRenderer; +use super::styles; +use crate::shared::{format_duration_ms, format_tokens_human}; + +const MAX_TOOL_CALLS: usize = 5; + +#[derive(Debug)] +pub(super) enum ToolCallStatus { + Running, + Succeeded, + Failed, +} + +#[derive(Debug)] +pub(super) struct ToolCallEntry { + pub(super) display_name: String, + pub(super) tool_call_id: String, + pub(super) status: ToolCallStatus, + pub(super) bar: ProgressBar, + pub(super) is_branch: bool, + pub(super) started_at: Option>, +} + +#[derive(Debug)] +pub(super) struct ActiveStage { + pub(super) display_name: String, + pub(super) has_model: bool, + pub(super) spinner: ProgressBar, + pub(super) tool_calls: VecDeque, + pub(super) compaction_bar: Option, +} + +impl ActiveStage { + fn last_bar(&self) -> &ProgressBar { + self.tool_calls + .back() + .map_or(&self.spinner, |entry| &entry.bar) + } +} + +pub(super) struct StageDisplay { + verbose: bool, + pub(super) active_stages: HashMap, + pub(super) stage_counts: HashMap, + pub(super) parallel_parent: Option, + any_stage_started: bool, + working_directory: Option, +} + +impl StageDisplay { + pub(super) fn new(verbose: bool) -> Self { + Self { + verbose, + active_stages: HashMap::new(), + stage_counts: HashMap::new(), + parallel_parent: None, + any_stage_started: false, + working_directory: None, + } + } + + pub(super) fn set_working_directory(&mut self, dir: String) { + self.working_directory = Some(dir); + } + + pub(super) fn finish(&mut self) { + for (_node_id, stage) in self.active_stages.drain() { + if let Some(bar) = stage.compaction_bar { + bar.finish_and_clear(); + } + for entry in &stage.tool_calls { + if entry.is_branch || self.verbose { + entry.bar.abandon(); + } else { + entry.bar.finish_and_clear(); + } + } + stage.spinner.finish_and_clear(); + } + } + + pub(super) fn on_stage_started( + &mut self, + renderer: &ProgressRenderer, + node_id: &str, + name: &str, + script: Option<&str>, + ) { + self.stage_counts.insert(node_id.to_string(), (0, 0)); + let display_name = match script { + Some(script) => format!( + "{name} {}", + renderer.styles().dim.apply_to(styles::truncate(script, 60)) + ), + None => name.to_string(), + }; + + if renderer.is_tty() && !self.any_stage_started { + self.any_stage_started = true; + let sep = renderer.add_spinner(); + sep.set_style(styles::style_empty()); + sep.finish(); + } + + let bar = renderer.add_spinner(); + bar.set_style(styles::style_stage_running()); + bar.set_message(display_name.clone()); + if renderer.is_tty() { + bar.enable_steady_tick(Duration::from_millis(100)); + } + self.active_stages.insert( + node_id.to_string(), + ActiveStage { + display_name, + has_model: false, + spinner: bar, + tool_calls: VecDeque::new(), + compaction_bar: None, + }, + ); + } + + pub(super) fn on_stage_completed( + &mut self, + renderer: &ProgressRenderer, + node_id: &str, + name: &str, + duration_ms: u64, + status: &str, + usage: Option<&ProgressUsage>, + ) { + let succeeded = status.parse::().map_or_else( + |_| matches!(status, "success" | "partial_success"), + |status| matches!(status, StageStatus::Success | StageStatus::PartialSuccess), + ); + let cost_str = usage + .and_then(ProgressUsage::display_cost) + .map(|cost| format!("{} ", format_cost(cost))) + .unwrap_or_default(); + let stats_str = if self.verbose { + let (turn_count, tool_call_count) = + self.stage_counts.get(node_id).copied().unwrap_or((0, 0)); + let total_tokens = usage.map_or(0, ProgressUsage::total_tokens); + if turn_count > 0 || tool_call_count > 0 || total_tokens > 0 { + let total_tokens = i64::try_from(total_tokens).unwrap_or(i64::MAX); + format!( + " {}", + renderer.styles().dim.apply_to(format!( + "({} turns, {} tools, {} toks)", + turn_count, + tool_call_count, + format_tokens_human(total_tokens), + )) + ) + } else { + String::new() + } + } else { + String::new() + }; + let prefix = format!("{cost_str}{}{stats_str}", format_duration_ms(duration_ms)); + let glyph = if succeeded { + styles::green_check(renderer.styles()) + } else { + styles::red_cross(renderer.styles()) + }; + self.finish_stage(renderer, node_id, name, &glyph, &prefix); + } + + pub(super) fn on_stage_failed( + &mut self, + renderer: &ProgressRenderer, + node_id: &str, + name: &str, + error: &str, + ) { + self.finish_stage( + renderer, + node_id, + name, + &styles::red_cross(renderer.styles()), + "", + ); + let summary = styles::last_line_truncated(error, 120); + Self::insert_global_info_line( + renderer, + &format!("{} {summary}", renderer.styles().red.apply_to("Error:")), + ); + } + + pub(super) fn on_parallel_started(&mut self) { + self.parallel_parent = self + .active_stages + .keys() + .next() + .cloned() + .or_else(|| Some(String::new())); + } + + pub(super) fn on_parallel_completed(&mut self) { + self.parallel_parent = None; + } + + pub(super) fn on_parallel_branch_started(&mut self, renderer: &ProgressRenderer, branch: &str) { + let Some(parent_id) = self.parallel_parent.clone() else { + return; + }; + let Some(stage) = self.active_stages.get_mut(&parent_id) else { + return; + }; + + let bar = renderer.insert_after(stage.last_bar()); + bar.set_style(styles::style_subagent_info()); + bar.set_message( + renderer + .styles() + .dim + .apply_to(format!("\u{25b8} {branch}")) + .to_string(), + ); + stage.tool_calls.push_back(ToolCallEntry { + display_name: branch.to_string(), + tool_call_id: branch.to_string(), + status: ToolCallStatus::Running, + bar, + is_branch: true, + started_at: None, + }); + } + + pub(super) fn on_parallel_branch_completed( + &mut self, + renderer: &ProgressRenderer, + branch: &str, + duration_ms: u64, + status: &str, + ) { + let Some(parent_id) = self.parallel_parent.clone() else { + return; + }; + let Some(stage) = self.active_stages.get_mut(&parent_id) else { + return; + }; + + let Some(entry) = stage + .tool_calls + .iter_mut() + .find(|entry| entry.tool_call_id == branch) + else { + return; + }; + + let succeeded = matches!(status, "success" | "partial_success"); + entry.status = if succeeded { + ToolCallStatus::Succeeded + } else { + ToolCallStatus::Failed + }; + let glyph = if succeeded { + styles::green_check(renderer.styles()) + } else { + styles::red_cross(renderer.styles()) + }; + + if renderer.is_tty() { + entry.bar.set_style(styles::style_branch_done()); + set_duration_prefix(&entry.bar, Some(duration_ms)); + entry + .bar + .finish_with_message(format!("{glyph} {}", entry.display_name)); + } else { + renderer.print_line( + 8, + &format!("{glyph} {branch} {}", format_duration_ms(duration_ms)), + ); + } + } + + pub(super) fn on_assistant_message( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + model: &str, + ) { + if let Some(counts) = self.stage_counts.get_mut(stage_node_id) { + counts.0 += 1; + } + + if let Some(stage) = self.active_stages.get_mut(stage_node_id) { + if !stage.has_model { + stage.has_model = true; + let suffix = format!(" {}", renderer.styles().dim.apply_to(format!("[{model}]"))); + stage.display_name.push_str(&suffix); + stage.spinner.set_message(stage.display_name.clone()); + } + } + } + + pub(super) fn on_tool_call_started( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + tool_name: &str, + tool_call_id: &str, + arguments: &serde_json::Value, + timestamp: Option>, + ) { + let display_name = self.tool_display_name(renderer, tool_name, arguments); + let Some(stage) = self.active_stages.get_mut(stage_node_id) else { + return; + }; + + if !self.verbose && stage.tool_calls.len() >= MAX_TOOL_CALLS { + let evict_idx = stage + .tool_calls + .iter() + .position(|entry| !matches!(entry.status, ToolCallStatus::Running)) + .unwrap_or(0); + if let Some(evicted) = stage.tool_calls.remove(evict_idx) { + evicted.bar.finish_and_clear(); + } + } + + let bar = renderer.insert_after(stage.last_bar()); + bar.set_style(styles::style_tool_running()); + bar.set_message(display_name.clone()); + if renderer.is_tty() { + bar.enable_steady_tick(Duration::from_millis(100)); + } + stage.tool_calls.push_back(ToolCallEntry { + display_name, + tool_call_id: tool_call_id.to_string(), + status: ToolCallStatus::Running, + bar, + is_branch: false, + started_at: timestamp, + }); + } + + pub(super) fn on_tool_call_completed( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + tool_call_id: &str, + is_error: bool, + duration_ms: Option, + timestamp: Option>, + ) { + if let Some(counts) = self.stage_counts.get_mut(stage_node_id) { + counts.1 += 1; + } + + let Some(stage) = self.active_stages.get_mut(stage_node_id) else { + return; + }; + let Some(entry) = stage + .tool_calls + .iter_mut() + .find(|entry| entry.tool_call_id == tool_call_id) + else { + return; + }; + + let glyph = if is_error { + styles::red_cross(renderer.styles()) + } else { + styles::green_check(renderer.styles()) + }; + entry.status = if is_error { + ToolCallStatus::Failed + } else { + ToolCallStatus::Succeeded + }; + if renderer.is_tty() { + entry.bar.set_style(styles::style_tool_done()); + let computed_duration_ms = duration_ms.or_else(|| { + entry + .started_at + .zip(timestamp) + .and_then(|(started_at, completed_at)| { + u64::try_from( + completed_at + .signed_duration_since(started_at) + .num_milliseconds(), + ) + .ok() + }) + }); + set_duration_prefix(&entry.bar, computed_duration_ms); + entry + .bar + .finish_with_message(format!("{glyph} {}", entry.display_name)); + } + } + + pub(super) fn on_context_window_warning( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + usage_percent: u64, + ) { + if !self.verbose { + return; + } + + self.insert_info_line_for_stage( + renderer, + stage_node_id, + &format!( + "{} context window: {usage_percent}% used", + styles::warning_glyph(renderer.styles()) + ), + ); + } + + pub(super) fn on_compaction_started( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + ) { + if !renderer.is_tty() { + return; + } + let Some(stage) = self.active_stages.get_mut(stage_node_id) else { + return; + }; + + if let Some(old) = stage.compaction_bar.take() { + old.finish_and_clear(); + } + let bar = renderer.insert_after(stage.last_bar()); + bar.set_style(styles::style_tool_running()); + bar.set_message("\u{27f3} compacting context\u{2026}"); + bar.enable_steady_tick(Duration::from_millis(100)); + stage.compaction_bar = Some(bar); + } + + pub(super) fn on_compaction_completed( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + original_turn_count: u64, + preserved_turn_count: u64, + tracked_file_count: u64, + ) { + let message = format!( + "\u{27f3} compaction: {original_turn_count} \u{2192} {preserved_turn_count} turns, {tracked_file_count} files" + ); + + if renderer.is_tty() { + if let Some(bar) = self + .active_stages + .get_mut(stage_node_id) + .and_then(|stage| stage.compaction_bar.take()) + { + bar.set_style(styles::style_tool_done()); + bar.finish_with_message(message); + } else { + self.insert_info_line_for_stage(renderer, stage_node_id, &message); + } + } else { + renderer.print_line(6, &message); + } + } + + pub(super) fn on_llm_retry( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + model: &str, + attempt: u64, + delay_ms: u64, + error: &str, + ) { + if !self.verbose { + return; + } + + self.insert_info_line_for_stage( + renderer, + stage_node_id, + &format!( + "{} retry: {model} attempt {attempt} ({error}, delay {})", + styles::warning_glyph(renderer.styles()), + format_duration_ms(delay_ms) + ), + ); + } + + pub(super) fn on_subagent_spawned( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + agent_id: &str, + task: &str, + ) { + if !self.verbose { + return; + } + + self.insert_subagent_line_for_stage( + renderer, + stage_node_id, + &renderer + .styles() + .dim + .apply_to(format!( + "\u{25b8} subagent[{agent_id}] \"{}\"", + styles::truncate(task, 50) + )) + .to_string(), + ); + } + + pub(super) fn on_subagent_completed( + &mut self, + renderer: &ProgressRenderer, + stage_node_id: &str, + agent_id: &str, + success: bool, + turns_used: u64, + ) { + if !self.verbose { + return; + } + + let glyph = if success { + styles::green_check(renderer.styles()) + } else { + styles::red_cross(renderer.styles()) + }; + self.insert_subagent_line_for_stage( + renderer, + stage_node_id, + &format!("{glyph} subagent[{agent_id}] ({turns_used} turns)"), + ); + } + + pub(super) fn on_retro_started(&mut self, renderer: &ProgressRenderer) { + self.on_stage_started(renderer, "retro", "Retro", None); + } + + pub(super) fn on_retro_completed(&mut self, renderer: &ProgressRenderer, duration_ms: u64) { + self.finish_stage( + renderer, + "retro", + "Retro", + &styles::green_check(renderer.styles()), + &format_duration_ms(duration_ms), + ); + } + + pub(super) fn on_retro_failed(&mut self, renderer: &ProgressRenderer, duration_ms: u64) { + self.finish_stage( + renderer, + "retro", + "Retro", + &styles::red_cross(renderer.styles()), + &format_duration_ms(duration_ms), + ); + } + + fn finish_stage( + &mut self, + renderer: &ProgressRenderer, + node_id: &str, + name: &str, + glyph: &str, + prefix: &str, + ) { + let Some(stage) = self.active_stages.remove(node_id) else { + if !renderer.is_tty() { + Self::print_plain_stage_completion(renderer, name, glyph, prefix); + } + return; + }; + + if let Some(bar) = stage.compaction_bar { + bar.finish_and_clear(); + } + for entry in &stage.tool_calls { + if entry.is_branch || self.verbose { + entry.bar.abandon(); + } else { + entry.bar.finish_and_clear(); + } + } + + if renderer.is_tty() { + stage.spinner.set_style(styles::style_stage_done()); + stage.spinner.set_prefix(prefix.to_string()); + stage + .spinner + .finish_with_message(format!("{glyph} {}", stage.display_name)); + } else { + Self::print_plain_stage_completion(renderer, name, glyph, prefix); + } + } + + fn print_plain_stage_completion( + renderer: &ProgressRenderer, + name: &str, + glyph: &str, + prefix: &str, + ) { + if prefix.is_empty() { + renderer.print_line(4, &format!("{glyph} {name}")); + } else { + renderer.print_line(4, &format!("{glyph} {name} {prefix}")); + } + } + + fn insert_global_info_line(renderer: &ProgressRenderer, message: &str) { + if renderer.is_tty() { + let bar = renderer.add_spinner(); + bar.set_style(styles::style_static_dim()); + bar.finish_with_message(message.to_string()); + } else { + renderer.print_line(4, message); + } + } + + fn insert_info_line_for_stage( + &self, + renderer: &ProgressRenderer, + stage_node_id: &str, + message: &str, + ) { + if renderer.is_tty() { + let bar = if let Some(stage) = self.active_stages.get(stage_node_id) { + renderer.insert_after(stage.last_bar()) + } else { + renderer.add_spinner() + }; + bar.set_style(styles::style_tool_done()); + bar.finish_with_message(message.to_string()); + } else { + renderer.print_line(6, message); + } + } + + fn insert_subagent_line_for_stage( + &self, + renderer: &ProgressRenderer, + stage_node_id: &str, + message: &str, + ) { + if renderer.is_tty() { + let bar = if let Some(stage) = self.active_stages.get(stage_node_id) { + renderer.insert_after(stage.last_bar()) + } else { + renderer.add_spinner() + }; + bar.set_style(styles::style_subagent_info()); + bar.finish_with_message(message.to_string()); + } else { + renderer.print_line(8, message); + } + } + + fn tool_display_name( + &self, + renderer: &ProgressRenderer, + tool_name: &str, + arguments: &serde_json::Value, + ) -> String { + let arg = |key: &str| arguments.get(key).and_then(serde_json::Value::as_str); + let working_directory = self.working_directory.as_deref(); + let path_arg = || { + arg("path") + .or_else(|| arg("file_path")) + .map(|path| styles::truncate(&styles::shorten_path(path, working_directory), 60)) + }; + + let detail = match tool_name { + "bash" | "shell" | "execute_command" => { + arg("command").map(|command| styles::truncate(command, 60)) + } + "glob" => arg("pattern").map(String::from), + "grep" | "ripgrep" => arg("pattern").map(|pattern| styles::truncate(pattern, 40)), + "read_file" | "read" | "write_file" | "write" | "create_file" | "edit_file" + | "edit" | "list_dir" => path_arg(), + "web_search" => arg("query").map(|query| styles::truncate(query, 60)), + "web_fetch" => arg("url").map(|url| styles::truncate(url, 60)), + "spawn_agent" => arg("task").map(|task| styles::truncate(task, 60)), + "wait" | "send_input" | "close_agent" => arg("agent_id").map(String::from), + "use_skill" => arg("skill_name").map(String::from), + "apply_patch" => Some("...".to_string()), + "read_many_files" => arguments + .get("paths") + .and_then(serde_json::Value::as_array) + .map(|paths| format!("{} files", paths.len())), + _ => None, + }; + + match detail { + Some(detail) => format!( + "{tool_name}{}", + renderer.styles().dim.apply_to(format!("({detail})")) + ), + None => tool_name.to_string(), + } + } +} + +fn set_duration_prefix(bar: &ProgressBar, duration_ms: Option) { + let prefix = duration_ms.map_or_else( + || styles::format_duration_short(bar.elapsed()), + |duration_ms| styles::format_duration_short(Duration::from_millis(duration_ms)), + ); + bar.set_prefix(prefix); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::run::run_progress::renderer::ProgressRenderer; + + #[test] + fn tool_display_name_shortens_paths_relative_to_working_directory() { + let renderer = ProgressRenderer::new_plain(Box::new(std::io::sink()), false); + let mut stage = StageDisplay::new(false); + stage.set_working_directory("/workspace".into()); + + let display_name = stage.tool_display_name( + &renderer, + "read_file", + &serde_json::json!({"file_path": "/workspace/src/main.rs"}), + ); + + assert_eq!(display_name, "read_file(src/main.rs)"); + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/styles.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/styles.rs new file mode 100644 index 000000000..9a279aa51 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/styles.rs @@ -0,0 +1,116 @@ +use std::path::Path; +use std::sync::OnceLock; +use std::time::Duration; + +use fabro_util::terminal::Styles; +use indicatif::ProgressStyle; + +macro_rules! cached_style { + ($name:ident, $template:expr) => { + pub(super) fn $name() -> ProgressStyle { + static STYLE: OnceLock = OnceLock::new(); + STYLE + .get_or_init(|| ProgressStyle::with_template($template).expect("valid template")) + .clone() + } + }; +} + +cached_style!( + style_header_running, + " {spinner:.dim} {wide_msg} {elapsed:.dim}" +); +cached_style!(style_header_done, " {wide_msg:.dim} {prefix:.dim}"); +cached_style!( + style_stage_running, + " {spinner:.cyan} {wide_msg} {elapsed:.dim}" +); +cached_style!(style_stage_done, " {wide_msg} {prefix:.dim}"); +cached_style!( + style_tool_running, + " {spinner:.dim} {wide_msg} {elapsed:.dim}" +); +cached_style!(style_tool_done, " {wide_msg} {prefix:.dim}"); +cached_style!(style_subagent_info, " {wide_msg}"); +cached_style!(style_branch_done, " {wide_msg} {prefix:.dim}"); +cached_style!(style_static_dim, " {wide_msg:.dim}"); +cached_style!(style_sandbox_detail, " {wide_msg:.dim}"); +cached_style!(style_empty, " "); + +pub(super) fn green_check(styles: &Styles) -> String { + styles.green.apply_to("\u{2713}").to_string() +} + +pub(super) fn red_cross(styles: &Styles) -> String { + styles.red.apply_to("\u{2717}").to_string() +} + +pub(super) fn warning_glyph(styles: &Styles) -> String { + styles.yellow.apply_to("\u{26a0}").to_string() +} + +pub(crate) fn format_duration_short(d: Duration) -> String { + let secs = d.as_secs(); + if secs >= 60 { + format!("{}m{:02}s", secs / 60, secs % 60) + } else if d.as_millis() >= 1000 { + format!("{secs}s") + } else { + format!("{}ms", d.as_millis()) + } +} + +pub(super) fn terminal_hyperlink(url: &str, text: &str) -> String { + format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\") +} + +pub(super) fn format_number(n: f64) -> String { + if (n - n.round()).abs() < f64::EPSILON { + #[allow(clippy::cast_possible_truncation)] + let i = n as i64; + format!("{i}") + } else { + format!("{n:.1}") + } +} + +pub(super) fn truncate(s: &str, max: usize) -> String { + let single_line = s.split_whitespace().collect::>().join(" "); + if single_line.len() > max { + let mut truncated: String = single_line.chars().take(max - 3).collect(); + truncated.push_str("..."); + truncated + } else { + single_line + } +} + +pub(super) fn last_line_truncated(s: &str, max: usize) -> String { + let line = s + .trim() + .lines() + .rfind(|line| !line.trim().is_empty()) + .unwrap_or("") + .trim(); + if line.len() > max { + let mut truncated: String = line.chars().take(max - 3).collect(); + truncated.push_str("..."); + truncated + } else { + line.to_string() + } +} + +pub(super) fn shorten_path(path: &str, working_directory: Option<&str>) -> String { + if let Some(wd) = working_directory { + if let Ok(rel) = Path::new(path).strip_prefix(wd) { + return rel.display().to_string(); + } + } + if let Ok(cwd) = std::env::current_dir() { + if let Ok(rel) = Path::new(path).strip_prefix(&cwd) { + return rel.display().to_string(); + } + } + path.to_string() +} diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 9a72bd9de..52ffbe9d5 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -486,6 +486,7 @@ mod tests { fn event_payload(run_id: RunId, ts: &str, event: &str) -> EventPayload { EventPayload::new( serde_json::json!({ + "id": format!("evt-{run_id}-{event}"), "ts": ts, "run_id": run_id.to_string(), "event": event @@ -540,14 +541,14 @@ mod tests { run.append_event(&event_payload( run_id, "2026-03-27T12:00:00.000Z", - "WorkflowRunStarted", + "run.started", )) .await .unwrap(); run.append_event(&event_payload( run_id, "2026-03-27T12:00:01.000Z", - "StageCompleted", + "stage.completed", )) .await .unwrap(); diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index af5993e41..d1ad7e77a 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -11,7 +11,9 @@ mod user_config; use anyhow::Result; use args::{Commands, GlobalArgs, LONG_VERSION, RunCommands}; -use clap::Parser; +#[cfg(feature = "server")] +use args::{ServerCommand, ServerNamespace}; +use clap::{CommandFactory, Parser}; use fabro_telemetry::{git, panic as tel_panic, sanitize, sender}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -106,7 +108,10 @@ async fn main_inner() -> (String, Result<()>) { let (config_log_level, upgrade_check_enabled) = { #[cfg(feature = "server")] { - if let Commands::Serve(args) = command.as_ref() { + if let Commands::Server(ServerNamespace { + command: ServerCommand::Start(args), + }) = command.as_ref() + { match fabro_config::server::load_server_settings(args.config.as_deref()) { Ok(server_settings) => ( server_settings.log.as_ref().and_then(|l| l.level.clone()), @@ -136,8 +141,8 @@ async fn main_inner() -> (String, Result<()>) { } }; - let log_prefix = if command_name == "serve" { - "serve" + let log_prefix = if command_name == "server start" { + "server" } else { "cli" }; @@ -153,7 +158,6 @@ async fn main_inner() -> (String, Result<()>) { Commands::RunCmd(RunCommands::Run(_) | RunCommands::Create(_)) | Commands::Exec(_) | Commands::Repo(_) - | Commands::Init | Commands::Install { .. } ) { commands::upgrade::spawn_upgrade_check(globals.no_upgrade_check, upgrade_check_enabled) @@ -183,7 +187,8 @@ async fn main_inner() -> (String, Result<()>) { Commands::RunsCmd(cmd) => commands::runs::dispatch(cmd, &globals).await?, Commands::Model { command } => commands::model::execute(command, &globals).await?, #[cfg(feature = "server")] - Commands::Serve(args) => { + Commands::Server(ns) => { + let ServerCommand::Start(args) = ns.command; let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); fabro_server::serve::serve_command(args, styles, globals.storage_dir.clone()) .await?; @@ -201,10 +206,6 @@ async fn main_inner() -> (String, Result<()>) { open::that("https://docs.fabro.sh/")?; } Commands::Repo(ns) => commands::repo::dispatch(ns).await?, - Commands::Init => { - fabro_util::warn_user!("`fabro init` is deprecated, use `fabro repo init` instead"); - commands::repo::init::run_init().await?; - } Commands::Install { web_url } => { commands::install::run_install(&web_url).await?; } @@ -219,6 +220,27 @@ async fn main_inner() -> (String, Result<()>) { Commands::Provider(ns) => commands::provider::dispatch(ns).await?, Commands::Sandbox { command } => commands::sandbox::dispatch(command, &globals).await?, Commands::System(ns) => commands::system::dispatch(ns, &globals).await?, + Commands::Completion(args) => { + let mut cmd = Cli::command(); + let shell = args.shell; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut buf = Vec::new(); + clap_complete::generate(shell, &mut cmd, "fabro", &mut buf); + buf + })); + match result { + Ok(buf) => { + use std::io::Write; + std::io::stdout().write_all(&buf)?; + } + Err(_) => { + anyhow::bail!( + "Failed to generate completions for {shell}. \ + Try zsh, fish, elvish, or powershell instead." + ); + } + } + } Commands::SendAnalytics { path } => { let result = sender::upload(&path).await; let _ = std::fs::remove_file(&path); diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index f56baff1e..a2172cac8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use crate::support::{example_fixture, run_output_filters}; + use super::support::{output_stdout, write_sleep_workflow}; #[test] @@ -29,6 +31,68 @@ fn help() { "); } +#[test] +fn attach_requires_run_arg() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.arg("attach"); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 2 + ----- stdout ----- + ----- stderr ----- + error: the following required arguments were not provided: + + + Usage: fabro attach --no-upgrade-check --storage-dir + + For more information, try '--help'. + "); +} + +#[test] +fn attach_replays_completed_detached_run() { + let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAQ"; + + context + .command() + .args([ + "run", + "--dry-run", + "--auto-approve", + "--no-retro", + "--detach", + "--run-id", + run_id, + example_fixture("simple.fabro").to_str().unwrap(), + ]) + .assert() + .success(); + + context + .command() + .args(["wait", run_id]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + + let mut cmd = context.command(); + cmd.args(["attach", run_id]); + cmd.timeout(std::time::Duration::from_secs(10)); + fabro_snapshot!(run_output_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Sandbox: local (ready in [TIME]) + ✓ Start [TIME] + ✓ Run Tests [TIME] + ✓ Report [TIME] + ✓ Exit [TIME] + "); +} + #[test] fn attach_before_completion_streams_to_finished_state() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/config_show.rs b/lib/crates/fabro-cli/tests/it/cmd/completion.rs similarity index 56% rename from lib/crates/fabro-cli/tests/it/cmd/config_show.rs rename to lib/crates/fabro-cli/tests/it/cmd/completion.rs index ae2df16a7..adeb15708 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config_show.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/completion.rs @@ -3,18 +3,18 @@ use fabro_test::{fabro_snapshot, test_context}; #[test] fn help() { let context = test_context!(); - let mut cmd = context.settings(); - cmd.arg("--help"); + let mut cmd = context.command(); + cmd.args(["completion", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- - Inspect merged configuration + Generate shell completions - Usage: fabro settings [OPTIONS] [WORKFLOW] + Usage: fabro completion [OPTIONS] Arguments: - [WORKFLOW] Optional workflow name, .fabro path, or .toml run config to overlay + Shell to generate completions for [possible values: bash, elvish, fish, powershell, zsh] Options: --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] @@ -26,3 +26,19 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn generates_zsh_completions() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["completion", "zsh"]); + cmd.assert().success(); +} + +#[test] +fn generates_fish_completions() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["completion", "fish"]); + cmd.assert().success(); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index 4a5e4fbac..0016a23d2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -3,7 +3,9 @@ use serde_json::json; use fabro_test::{fabro_snapshot, test_context}; -use super::support::{fixture, output_stdout, read_json, resolve_run}; +use crate::support::{fabro_json_snapshot, read_json}; + +use super::support::{fixture, output_stdout, resolve_run}; #[test] fn help() { @@ -43,6 +45,118 @@ fn help() { "); } +#[test] +fn create_persists_directory_workflow_slug_and_cached_graph() { + let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAA"; + let workflow_path = context.temp_dir.join("sluggy/workflow.fabro"); + + context.write_temp( + "sluggy/workflow.fabro", + "\ +digraph BarBaz { + start [shape=Mdiamond, label=\"Start\"] + exit [shape=Msquare, label=\"Exit\"] + start -> exit +} +", + ); + + context + .command() + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + workflow_path.to_str().unwrap(), + ]) + .assert() + .success(); + + let run_dir = context.find_run_dir(run_id); + let run_record = read_json(run_dir.join("run.json")); + let cached_graph = std::fs::read_to_string(run_dir.join("workflow.fabro")).unwrap(); + fabro_json_snapshot!( + context, + serde_json::json!({ + "workflow_slug": run_record["workflow_slug"], + "graph_name": run_record["graph"]["name"], + "cached_graph_lines": cached_graph.lines().collect::>(), + }), + @r#" + { + "workflow_slug": "sluggy", + "graph_name": "BarBaz", + "cached_graph_lines": [ + "digraph BarBaz {", + " start [shape=Mdiamond, label=\"Start\"]", + " exit [shape=Msquare, label=\"Exit\"]", + " start -> exit", + "}" + ] + } + "# + ); +} + +#[test] +fn create_persists_file_stem_slug_for_standalone_file() { + let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAB"; + let workflow_path = context.temp_dir.join("alpha.fabro"); + + context.write_temp( + "alpha.fabro", + "\ +digraph FooWorkflow { + start [shape=Mdiamond, label=\"Start\"] + exit [shape=Msquare, label=\"Exit\"] + start -> exit +} +", + ); + + context + .command() + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + workflow_path.to_str().unwrap(), + ]) + .assert() + .success(); + + let run_dir = context.find_run_dir(run_id); + let run_record = read_json(run_dir.join("run.json")); + let cached_graph = std::fs::read_to_string(run_dir.join("workflow.fabro")).unwrap(); + fabro_json_snapshot!( + context, + serde_json::json!({ + "workflow_slug": run_record["workflow_slug"], + "graph_name": run_record["graph"]["name"], + "cached_graph_lines": cached_graph.lines().collect::>(), + }), + @r#" + { + "workflow_slug": "alpha", + "graph_name": "FooWorkflow", + "cached_graph_lines": [ + "digraph FooWorkflow {", + " start [shape=Mdiamond, label=\"Start\"]", + " exit [shape=Msquare, label=\"Exit\"]", + " start -> exit", + "}" + ] + } + "# + ); +} + #[test] fn create_persists_requested_overrides_into_run_json() { let context = test_context!(); @@ -85,7 +199,7 @@ fn create_persists_requested_overrides_into_run_json() { .expect("create should print a run ID") .to_string(); let run = resolve_run(&context, &run_id); - let run_json = read_json(&run.run_dir.join("run.json")); + let run_json = read_json(run.run_dir.join("run.json")); let labels = json!({ "env": run_json.pointer("/labels/env"), "team": run_json.pointer("/labels/team"), diff --git a/lib/crates/fabro-cli/tests/it/cmd/detached.rs b/lib/crates/fabro-cli/tests/it/cmd/detached.rs index eab78133b..0b622823b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/detached.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/detached.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use crate::support::{fabro_json_snapshot, read_json}; + #[test] fn help() { let context = test_context!(); @@ -26,3 +28,241 @@ fn help() { ----- stderr ----- "); } + +fn launcher_path(context: &fabro_test::TestContext, run_id: &str) -> std::path::PathBuf { + context + .storage_dir + .join("launchers") + .join(format!("{run_id}.json")) +} + +#[test] +fn detached_uses_cached_graph_after_source_deleted() { + let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAF"; + let workflow_path = context.temp_dir.join("workflow.fabro"); + + context.write_temp( + "workflow.fabro", + "\ +digraph CachedGraph { + start [shape=Mdiamond, label=\"Start\"] + exit [shape=Msquare, label=\"Exit\"] + start -> exit +} +", + ); + + context + .command() + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + workflow_path.to_str().unwrap(), + ]) + .assert() + .success(); + + let run_dir = context.find_run_dir(run_id); + std::fs::remove_file(&workflow_path).unwrap(); + + context + .command() + .args([ + "__detached", + "--run-dir", + run_dir.to_str().unwrap(), + "--launcher-path", + launcher_path(&context, run_id).to_str().unwrap(), + ]) + .timeout(std::time::Duration::from_secs(15)) + .assert() + .success(); + + let conclusion = read_json(run_dir.join("conclusion.json")); + fabro_json_snapshot!( + context, + serde_json::json!({ + "status": conclusion["status"], + }), + @r#" + { + "status": "success" + } + "# + ); +} + +#[test] +fn detached_uses_snapshotted_app_id_for_github_credentials() { + let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAG"; + let workflow_path = context.temp_dir.join("workflow.fabro"); + + context.write_home( + ".fabro/user.toml", + "\ +version = 1 + +[git] +app_id = \"snapshotted-app-id\" +", + ); + context.write_temp( + "workflow.fabro", + "\ +digraph GitHubApp { + start [shape=Mdiamond, label=\"Start\"] + exit [shape=Msquare, label=\"Exit\"] + start -> exit +} +", + ); + + context + .command() + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + workflow_path.to_str().unwrap(), + ]) + .assert() + .success(); + + let run_dir = context.find_run_dir(run_id); + let run_record = read_json(run_dir.join("run.json")); + fabro_json_snapshot!( + context, + serde_json::json!({ + "app_id": run_record["settings"]["git"]["app_id"], + }), + @r#" + { + "app_id": "snapshotted-app-id" + } + "# + ); + + context.write_home(".fabro/user.toml", "version = 1\n"); + + let mut cmd = context.command(); + cmd.env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%"); + cmd.args([ + "__detached", + "--run-dir", + run_dir.to_str().unwrap(), + "--launcher-path", + launcher_path(&context, run_id).to_str().unwrap(), + ]); + cmd.timeout(std::time::Duration::from_secs(10)); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: GITHUB_APP_PRIVATE_KEY is not valid PEM or base64: Invalid symbol 37, offset 0. + "); +} + +#[test] +fn detached_resume_rejects_completed_run_without_mutating_it() { + let context = test_context!(); + context.write_temp( + "workflow.fabro", + "\ +digraph Test { + start [shape=Mdiamond, label=\"Start\"] + exit [shape=Msquare, label=\"Exit\"] + start -> exit +} +", + ); + + let run = context + .command() + .current_dir(&context.temp_dir) + .args([ + "run", + "--dry-run", + "--auto-approve", + "--no-retro", + "--detach", + context.temp_dir.join("workflow.fabro").to_str().unwrap(), + ]) + .assert() + .success(); + let run_id = String::from_utf8(run.get_output().stdout.clone()) + .unwrap() + .trim() + .to_string(); + + context + .command() + .args(["wait", &run_id]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + + let inspect_before = context + .command() + .args(["inspect", &run_id]) + .assert() + .success(); + let before: serde_json::Value = + serde_json::from_slice(&inspect_before.get_output().stdout).unwrap(); + let before_summary = serde_json::json!({ + "run_dir": before[0]["run_dir"], + "start_time": before[0]["start_record"]["start_time"], + "conclusion_timestamp": before[0]["conclusion"]["timestamp"], + "conclusion_status": before[0]["conclusion"]["status"], + }); + let run_dir = before_summary["run_dir"].as_str().unwrap().to_string(); + fabro_json_snapshot!(context, &before_summary, @r#" + { + "run_dir": "[DRY_RUN_DIR]", + "start_time": "[TIMESTAMP]", + "conclusion_timestamp": "[TIMESTAMP]", + "conclusion_status": "success" + } + "#); + + let mut cmd = context.command(); + cmd.args([ + "__detached", + "--run-dir", + &run_dir, + "--launcher-path", + launcher_path(&context, &run_id).to_str().unwrap(), + "--resume", + ]); + cmd.timeout(std::time::Duration::from_secs(10)); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Precondition failed: run already finished successfully — nothing to resume + "); + + let inspect_after = context + .command() + .args(["inspect", &run_id]) + .assert() + .success(); + let after: serde_json::Value = + serde_json::from_slice(&inspect_after.get_output().stdout).unwrap(); + let after_summary = serde_json::json!({ + "run_dir": after[0]["run_dir"], + "start_time": after[0]["start_record"]["start_time"], + "conclusion_timestamp": after[0]["conclusion"]["timestamp"], + "conclusion_status": after[0]["conclusion"]["status"], + }); + + assert_eq!(after_summary, before_summary); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index 0d643c60c..82d873394 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -12,37 +12,38 @@ fn help() { Usage: fabro [OPTIONS] Commands: - run Launch a workflow run - create Create a workflow run (allocate run dir, persist spec) - start Start a created workflow run (spawn engine process) - attach Attach to a running or finished workflow run - logs View the event log of a workflow run - resume Resume an interrupted workflow run - rewind Rewind a workflow run to an earlier checkpoint - fork Fork a workflow run from an earlier checkpoint into a new run - wait Block until a workflow run completes - preflight Validate run configuration without executing - validate Validate a workflow - graph Render a workflow graph as SVG or PNG - asset Inspect and copy run assets (screenshots, reports, traces) - store Export store-backed run state for debugging - rm Remove one or more workflow runs - inspect Show detailed information about a workflow run - model List and test LLM models - doctor Check environment and integration health - install Set up the Fabro environment (LLMs, certs, GitHub) - pr Pull request operations - secret Manage secrets in ~/.fabro/.env - settings Inspect merged configuration - workflow Workflow operations - discord Open the Discord community in the browser - docs Open the docs website in the browser - upgrade Upgrade fabro to the latest version - repo Repository commands - provider Provider operations - sandbox Sandbox operations (cp, ssh, preview) - system System maintenance commands - help Print this message or the help of the given subcommand(s) + run Launch a workflow run + create Create a workflow run (allocate run dir, persist spec) + start Start a created workflow run (spawn engine process) + attach Attach to a running or finished workflow run + logs View the event log of a workflow run + resume Resume an interrupted workflow run + rewind Rewind a workflow run to an earlier checkpoint + fork Fork a workflow run from an earlier checkpoint into a new run + wait Block until a workflow run completes + preflight Validate run configuration without executing + validate Validate a workflow + graph Render a workflow graph as SVG or PNG + asset Inspect and copy run assets (screenshots, reports, traces) + store Export store-backed run state for debugging + rm Remove one or more workflow runs + inspect Show detailed information about a workflow run + model List and test LLM models + doctor Check environment and integration health + install Set up the Fabro environment (LLMs, certs, GitHub) + pr Pull request operations + secret Manage secrets in ~/.fabro/.env + settings Inspect merged configuration + workflow Workflow operations + discord Open the Discord community in the browser + docs Open the docs website in the browser + upgrade Upgrade fabro to the latest version + repo Repository commands + provider Provider operations + sandbox Sandbox operations (cp, ssh, preview) + completion Generate shell completions + system System maintenance commands + help Print this message or the help of the given subcommand(s) Options: --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] diff --git a/lib/crates/fabro-cli/tests/it/cmd/init.rs b/lib/crates/fabro-cli/tests/it/cmd/init.rs deleted file mode 100644 index 560047540..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/init.rs +++ /dev/null @@ -1,25 +0,0 @@ -use fabro_test::{fabro_snapshot, test_context}; - -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.init_cmd(); - cmd.arg("--help"); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Initialize a new project (deprecated: use `repo init`) - - Usage: fabro init [OPTIONS] - - Options: - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help - ----- stderr ----- - "); -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 6c961bf5e..03ad77555 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -2,9 +2,8 @@ mod asset; mod asset_cp; mod asset_list; mod attach; +mod completion; mod config; -mod config_show; -mod cp; mod create; mod detached; mod diff; @@ -15,7 +14,6 @@ mod exec; mod fabro; mod fork; mod graph; -mod init; mod inspect; mod install; mod llm; @@ -32,7 +30,6 @@ mod pr_list; mod pr_merge; mod pr_view; mod preflight; -mod preview; mod provider; mod provider_login; mod ps; @@ -43,6 +40,9 @@ mod resume; mod rewind; mod rm; mod run; +mod sandbox_cp; +mod sandbox_preview; +mod sandbox_ssh; mod secret; mod secret_get; mod secret_list; @@ -50,8 +50,7 @@ mod secret_rm; mod secret_set; mod send_analytics; mod send_panic; -mod serve; -mod ssh; +mod server; mod start; mod store; mod store_dump; diff --git a/lib/crates/fabro-cli/tests/it/cmd/resume.rs b/lib/crates/fabro-cli/tests/it/cmd/resume.rs index 42d58c224..ad56442d6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/resume.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/resume.rs @@ -30,6 +30,25 @@ fn help() { "); } +#[test] +fn resume_requires_run_arg() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["resume"]); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 2 + ----- stdout ----- + ----- stderr ----- + error: the following required arguments were not provided: + + + Usage: fabro resume --no-upgrade-check --storage-dir + + For more information, try '--help'. + "); +} + #[test] fn resume_rewound_run_succeeds() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index cec482d06..c0eb21da5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -1,286 +1,9 @@ -use std::collections::{BTreeSet, HashMap}; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; - -use chrono::TimeZone; -use fabro_config::FabroSettings; -use fabro_git_storage::branchstore::BranchStore; -use fabro_git_storage::gitobj::Store as GitStore; -use fabro_store::{NodeVisitRef, RuntimeState, SlateStore, Store as _}; use fabro_test::{fabro_snapshot, test_context}; -use fabro_types::{Checkpoint, Graph, RunRecord, StartRecord}; -use git2::{Repository, Signature}; -use object_store::local::LocalFileSystem; -use predicates::prelude::*; -use tokio::runtime::Runtime; -fn fixture(name: &str) -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../../test/{name}")) -} - -fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet { - let repo = Repository::discover(repo_dir).unwrap(); - repo.references() - .unwrap() - .flatten() - .filter_map(|reference| reference.name().map(ToOwned::to_owned)) - .filter_map(|name| { - name.strip_prefix("refs/heads/fabro/meta/") - .map(ToOwned::to_owned) - }) - .collect() -} - -fn seed_run_branch(repo_dir: &Path, run_id: &str, nodes: &[&str]) -> Vec { - let repo = Repository::discover(repo_dir).unwrap(); - let store = GitStore::new(repo); - let sig = Signature::now("Fabro", "noreply@fabro.sh").unwrap(); - let run_branch = format!("fabro/run/{run_id}"); - let empty_tree = store.write_empty_tree().unwrap(); - let mut shas = Vec::new(); - let mut parent = None; - - for node in nodes { - let parents = parent.into_iter().collect::>(); - let oid = store - .write_commit( - empty_tree, - &parents, - &format!("fabro({run_id}): {node} (completed)"), - &sig, - ) - .unwrap(); - store.update_ref(&run_branch, oid).unwrap(); - shas.push(oid.to_string()); - parent = Some(oid); - } - - shas -} - -fn checkpoint_record( - current_node: &str, - completed_nodes: &[&str], - node_visits: &[(&str, usize)], - git_commit_sha: Option<&str>, -) -> Checkpoint { - Checkpoint { - timestamp: chrono::Utc - .with_ymd_and_hms(2026, 1, 1, 0, 0, 0) - .single() - .unwrap(), - current_node: current_node.to_string(), - completed_nodes: completed_nodes - .iter() - .map(|node| (*node).to_string()) - .collect(), - node_retries: HashMap::new(), - context_values: HashMap::new(), - node_outcomes: HashMap::new(), - next_node_id: None, - git_commit_sha: git_commit_sha.map(ToOwned::to_owned), - loop_failure_signatures: HashMap::new(), - restart_failure_signatures: HashMap::new(), - node_visits: node_visits - .iter() - .map(|(node, visit)| ((*node).to_string(), *visit)) - .collect(), - } -} - -async fn seed_durable_run(storage_dir: &Path, repo_dir: &Path, run_id: &str) { - let store_path = storage_dir.join("store"); - std::fs::create_dir_all(&store_path).unwrap(); - let object_store = Arc::new(LocalFileSystem::new_with_prefix(&store_path).unwrap()); - let store = SlateStore::new(object_store, "", Duration::from_millis(5)); - let run_id: fabro_types::RunId = run_id.parse().unwrap(); - let created_at = chrono::Utc - .with_ymd_and_hms(2026, 1, 1, 0, 0, 0) - .single() - .unwrap(); - let run_store = store.create_run(&run_id, created_at, None).await.unwrap(); - - let run_record = RunRecord { - run_id, - created_at, - settings: FabroSettings::default(), - graph: Graph::default(), - workflow_slug: None, - working_directory: repo_dir.to_path_buf(), - host_repo_path: Some(repo_dir.to_string_lossy().into_owned()), - base_branch: None, - labels: HashMap::new(), - }; - run_store.put_run(&run_record).await.unwrap(); - run_store - .put_start(&StartRecord { - run_id, - start_time: created_at, - run_branch: Some(format!("fabro/run/{run_id}")), - base_sha: None, - }) - .await - .unwrap(); - - let start = NodeVisitRef { - node_id: "start", - visit: 1, - }; - run_store - .put_node_prompt(&start, "start prompt") - .await - .unwrap(); - let build = NodeVisitRef { - node_id: "build", - visit: 1, - }; - run_store - .put_node_prompt(&build, "build prompt") - .await - .unwrap(); - - run_store - .append_checkpoint(&checkpoint_record( - "start", - &["start"], - &[("start", 1)], - None, - )) - .await - .unwrap(); - run_store - .append_checkpoint(&checkpoint_record( - "build", - &["start", "build"], - &[("start", 1), ("build", 1)], - None, - )) - .await - .unwrap(); -} - -fn metadata_checkpoints(repo_dir: &Path, run_id: &str) -> Vec { - let repo = Repository::discover(repo_dir).unwrap(); - let store = GitStore::new(repo); - let sig = Signature::now("Fabro", "noreply@fabro.sh").unwrap(); - let branch = format!("fabro/meta/{run_id}"); - let bs = BranchStore::new(&store, &branch, &sig); - - bs.log(100) - .unwrap() - .iter() - .rev() - .filter(|commit| commit.message.starts_with("checkpoint")) - .map(|commit| { - serde_json::from_slice::( - &store - .read_blob_at(commit.oid, "checkpoint.json") - .unwrap() - .unwrap(), - ) - .unwrap() - }) - .collect() -} - -fn latest_metadata_checkpoint(repo_dir: &Path, run_id: &str) -> Checkpoint { - let repo = Repository::discover(repo_dir).unwrap(); - let store = GitStore::new(repo); - let tip = store - .resolve_ref(&format!("fabro/meta/{run_id}")) - .unwrap() - .unwrap(); - serde_json::from_slice(&store.read_blob_at(tip, "checkpoint.json").unwrap().unwrap()).unwrap() -} - -/// Helper: create a minimal run directory that `resolve_run` can find. -/// Sets up run.json, status.json, and progress.jsonl. -fn setup_run_dir( - storage_dir: &std::path::Path, - run_id: &str, - spec_overrides: serde_json::Value, - progress_lines: &[&str], -) -> std::path::PathBuf { - let run_dir = storage_dir.join("runs").join(run_id); - std::fs::create_dir_all(&run_dir).unwrap(); - - // Build defaults, then merge overrides - let overrides = spec_overrides; - let get_str = |key: &str, default: &str| -> serde_json::Value { - overrides - .get(key) - .and_then(|v| v.as_str()) - .map(|s| serde_json::json!(s)) - .unwrap_or_else(|| serde_json::json!(default)) - }; - let get_bool = |key: &str, default: bool| -> serde_json::Value { - overrides - .get(key) - .and_then(|v| v.as_bool()) - .map(|b| serde_json::json!(b)) - .unwrap_or_else(|| serde_json::json!(default)) - }; - - // run.json (RunRecord) for resolve_run and run_engine_entrypoint - let run_record = serde_json::json!({ - "run_id": run_id, - "created_at": "2026-01-01T00:00:00Z", - "settings": { - "goal": overrides.get("goal").and_then(|v| v.as_str()), - "llm": { - "model": get_str("model", "test-model"), - "provider": overrides.get("provider").and_then(|v| v.as_str()) - }, - "sandbox": { - "provider": get_str("sandbox_provider", "local"), - "preserve": get_bool("preserve_sandbox", false) - }, - "verbose": get_bool("verbose", false), - "dry_run": get_bool("dry_run", true), - "auto_approve": get_bool("auto_approve", true), - "no_retro": get_bool("no_retro", true) - }, - "graph": { - "name": "test", - "nodes": {}, - "edges": [], - "attrs": {} - }, - "working_directory": overrides.get("working_directory").and_then(|v| v.as_str()).unwrap_or("/tmp"), - "labels": overrides.get("labels").cloned().unwrap_or(serde_json::json!({})) - }); - std::fs::write( - run_dir.join("run.json"), - serde_json::to_string(&run_record).unwrap(), - ) - .unwrap(); - - // progress.jsonl - std::fs::write(run_dir.join("progress.jsonl"), progress_lines.join("\n")).unwrap(); - - run_dir -} - -fn find_run_dir(storage_dir: &std::path::Path, run_id: &str) -> std::path::PathBuf { - let runs_dir = storage_dir.join("runs"); - std::fs::read_dir(&runs_dir) - .unwrap() - .flatten() - .map(|entry| entry.path()) - .find(|path| { - path.is_dir() - && path - .file_name() - .is_some_and(|name| name.to_string_lossy().ends_with(run_id)) - }) - .unwrap_or_else(|| { - panic!( - "expected run directory for {run_id} under {}", - runs_dir.display() - ) - }) -} +use crate::support::{ + compact_progress_event, example_fixture, fabro_json_snapshot, read_json, read_jsonl, + run_output_filters, +}; #[test] fn help() { @@ -325,8 +48,8 @@ fn dry_run_simple() { let context = test_context!(); let mut cmd = context.run_cmd(); cmd.args(["--dry-run", "--auto-approve"]); - cmd.arg(fixture("simple.fabro")); - fabro_snapshot!(context.filters(), cmd, @" + cmd.arg(example_fixture("simple.fabro")); + fabro_snapshot!(run_output_filters(&context), cmd, @" success: true exit_code: 0 ----- stdout ----- @@ -336,10 +59,10 @@ fn dry_run_simple() { Goal: Run tests and report results Sandbox: local (ready in [TIME]) - ✓ Start 0ms - ✓ Run Tests 0ms - ✓ Report 0ms - ✓ Exit 0ms + ✓ Start [TIME] + ✓ Run Tests [TIME] + ✓ Report [TIME] + ✓ Exit [TIME] === Run Result === Run: [ULID] @@ -352,239 +75,10 @@ fn dry_run_simple() { "); } -#[test] -fn dry_run_branching() { - let context = test_context!(); - let mut cmd = context.run_cmd(); - cmd.args(["--dry-run", "--auto-approve"]); - cmd.arg(fixture("branching.fabro")); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - ----- stderr ----- - Workflow: Branch (6 nodes, 6 edges) - Graph: ../../../test/branching.fabro - Goal: Implement and validate a feature - - warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry) - Sandbox: local (ready in [TIME]) - ✓ Start 0ms - ✓ Plan 0ms - ✓ Implement 0ms - ✓ Validate 0ms - ✓ Tests passing? 0ms - ✓ Exit 0ms - - === Run Result === - Run: [ULID] - Status: SUCCESS - Duration: [DURATION] - Run: [DRY_RUN_DIR] - - === Output === - [Simulated] Response for stage: validate - "); -} - -#[test] -fn dry_run_conditions() { - let context = test_context!(); - let mut cmd = context.run_cmd(); - cmd.args(["--dry-run", "--auto-approve"]); - cmd.arg(fixture("conditions.fabro")); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - ----- stderr ----- - Workflow: Conditions (5 nodes, 5 edges) - Graph: ../../../test/conditions.fabro - Goal: Test condition evaluation with OR and parentheses - - Sandbox: local (ready in [TIME]) - ✓ start 0ms - ✓ Decide 0ms - ✓ Path B 0ms - ✓ exit 0ms - - === Run Result === - Run: [ULID] - Status: SUCCESS - Duration: [DURATION] - Run: [DRY_RUN_DIR] - - === Output === - [Simulated] Response for stage: path_b - "); -} - -#[test] -fn dry_run_parallel() { - let context = test_context!(); - let mut cmd = context.run_cmd(); - cmd.args(["--dry-run", "--auto-approve"]); - cmd.arg(fixture("parallel.fabro")); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - ----- stderr ----- - Workflow: Parallel (7 nodes, 7 edges) - Graph: ../../../test/parallel.fabro - Goal: Test parallel and fan-in execution - - Sandbox: local (ready in [TIME]) - ✓ start 0ms - ✓ Fork Work 0ms - ✓ Merge Results 0ms - ✓ Review 0ms - ✓ exit 0ms - - === Run Result === - Run: [ULID] - Status: SUCCESS - Duration: [DURATION] - Run: [DRY_RUN_DIR] - - === Output === - [Simulated] Response for stage: review - "); -} - -#[test] -fn dry_run_styled() { - let context = test_context!(); - let mut cmd = context.run_cmd(); - cmd.args(["--dry-run", "--auto-approve"]); - cmd.arg(fixture("styled.fabro")); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - ----- stderr ----- - Workflow: Styled (5 nodes, 4 edges) - Graph: ../../../test/styled.fabro - Goal: Build a styled pipeline - - Sandbox: local (ready in [TIME]) - ✓ start 0ms - ✓ Plan 0ms - ✓ Implement 0ms - ✓ Critical Review 0ms - ✓ exit 0ms - - === Run Result === - Run: [ULID] - Status: SUCCESS - Duration: [DURATION] - Run: [DRY_RUN_DIR] - - === Output === - [Simulated] Response for stage: critical_review - "); -} - -#[test] -fn dry_run_legacy_tool() { - let context = test_context!(); - let mut cmd = context.run_cmd(); - cmd.args(["--dry-run", "--auto-approve"]); - cmd.arg(fixture("legacy_tool.fabro")); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - ----- stderr ----- - Workflow: LegacyTool (3 nodes, 2 edges) - Graph: ../../../test/legacy_tool.fabro - Goal: Verify backwards compatibility with old tool naming - - Sandbox: local (ready in [TIME]) - ✓ Start 0ms - ✓ Echo 0ms - ✓ Exit 0ms - - === Run Result === - Run: [ULID] - Status: SUCCESS - Duration: [DURATION] - Run: [DRY_RUN_DIR] - "); -} - #[test] fn dry_run_writes_jsonl_and_live_json() { let context = test_context!(); - - context - .command() - .args([ - "run", - "--dry-run", - "--auto-approve", - "../../../test/simple.fabro", - ]) - .assert() - .success(); - - // Find the single run directory under storage_dir/runs/ - let runs_base = context.storage_dir.join("runs"); - assert!(runs_base.exists(), "runs/ directory should exist"); - let entries: Vec<_> = std::fs::read_dir(&runs_base) - .unwrap() - .filter_map(|e| e.ok()) - .collect(); - assert_eq!(entries.len(), 1, "should have exactly one run directory"); - let run_dir = entries[0].path(); - - // progress.jsonl must exist and contain valid JSON lines - let jsonl_path = run_dir.join("progress.jsonl"); - assert!(jsonl_path.exists(), "progress.jsonl should exist"); - let jsonl_content = std::fs::read_to_string(&jsonl_path).unwrap(); - let lines: Vec<&str> = jsonl_content.lines().collect(); - assert!( - !lines.is_empty(), - "progress.jsonl should have at least one line" - ); - - // Every line must be valid JSON with ts, run_id, and event keys - let first_line: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); - assert!(first_line.get("ts").is_some(), "line should have ts"); - assert!( - first_line.get("run_id").is_some(), - "line should have run_id" - ); - assert!(first_line.get("event").is_some(), "line should have event"); - - // Events should contain WorkflowRunStarted (may not be first due to exec env events) - let has_run_started = lines.iter().any(|line| { - let parsed: serde_json::Value = serde_json::from_str(line).unwrap(); - parsed["event"].as_str() == Some("WorkflowRunStarted") - }); - assert!(has_run_started, "events should contain WorkflowRunStarted"); - - // run_id should be non-empty after WorkflowRunStarted - let last_line: serde_json::Value = serde_json::from_str(lines[lines.len() - 1]).unwrap(); - let run_id = last_line["run_id"].as_str().unwrap(); - assert!(!run_id.is_empty(), "run_id should be non-empty"); - - // live.json must exist and contain valid JSON matching the last JSONL line - let live_path = run_dir.join("live.json"); - assert!(live_path.exists(), "live.json should exist"); - let live_content: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&live_path).unwrap()).unwrap(); - assert!(live_content.get("ts").is_some()); - assert!(live_content.get("run_id").is_some()); - assert!(live_content.get("event").is_some()); -} - -// == --run-id passthrough ===================================================== - -#[test] -fn run_id_passthrough_uses_provided_ulid() { - let my_ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; - let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB8"; context .command() @@ -593,402 +87,209 @@ fn run_id_passthrough_uses_provided_ulid() { "--dry-run", "--auto-approve", "--run-id", - my_ulid, + run_id, "../../../test/simple.fabro", ]) .assert() - .success() - .stderr(predicate::str::contains(my_ulid)); + .success(); + + let run_dir = context.find_run_dir(run_id); + let jsonl_path = run_dir.join("progress.jsonl"); + let progress = read_jsonl(&jsonl_path); + assert!( + !progress.is_empty(), + "progress.jsonl should have at least one line" + ); + let progress_summary: Vec<_> = progress.iter().map(compact_progress_event).collect(); + fabro_json_snapshot!(context, &progress_summary, @r#" + [ + { + "event": "sandbox.initializing", + "provider": "local" + }, + { + "event": "sandbox.ready", + "provider": "local" + }, + { + "event": "sandbox.initialized" + }, + { + "event": "run.started", + "name": "Simple", + "goal": "Run tests and report results" + }, + { + "event": "stage.started", + "node_id": "start", + "node_label": "Start", + "handler_type": "start", + "index": 0 + }, + { + "event": "stage.completed", + "node_id": "start", + "node_label": "Start", + "index": 0, + "status": "success" + }, + { + "event": "edge.selected", + "from_node": "start", + "to_node": "run_tests", + "reason": "unconditional" + }, + { + "event": "checkpoint.completed", + "node_id": "start", + "node_label": "start", + "status": "success" + }, + { + "event": "stage.started", + "node_id": "run_tests", + "node_label": "Run Tests", + "handler_type": "agent", + "index": 1 + }, + { + "event": "stage.completed", + "node_id": "run_tests", + "node_label": "Run Tests", + "index": 1, + "status": "success" + }, + { + "event": "edge.selected", + "from_node": "run_tests", + "to_node": "report", + "reason": "unconditional" + }, + { + "event": "checkpoint.completed", + "node_id": "run_tests", + "node_label": "run_tests", + "status": "success" + }, + { + "event": "stage.started", + "node_id": "report", + "node_label": "Report", + "handler_type": "agent", + "index": 2 + }, + { + "event": "stage.completed", + "node_id": "report", + "node_label": "Report", + "index": 2, + "status": "success" + }, + { + "event": "edge.selected", + "from_node": "report", + "to_node": "exit", + "reason": "unconditional" + }, + { + "event": "checkpoint.completed", + "node_id": "report", + "node_label": "report", + "status": "success" + }, + { + "event": "stage.started", + "node_id": "exit", + "node_label": "Exit", + "handler_type": "exit", + "index": 3 + }, + { + "event": "stage.completed", + "node_id": "exit", + "node_label": "Exit", + "index": 3, + "status": "success" + }, + { + "event": "run.completed", + "status": "success", + "artifact_count": 0 + }, + { + "event": "sandbox.cleanup.started", + "provider": "local" + }, + { + "event": "sandbox.cleanup.completed", + "provider": "local" + } + ] + "#); + + let live_path = run_dir.join("live.json"); + let live_content = read_json(&live_path); + let live_summary = compact_progress_event(&live_content); + fabro_json_snapshot!(context, &live_summary, @r#" + { + "event": "sandbox.cleanup.completed", + "provider": "local" + } + "#); + + assert_eq!(live_summary, progress_summary.last().cloned().unwrap()); } -// == --detach flag ============================================================= - #[test] -fn detach_flag_appears_in_help() { +fn run_id_passthrough_uses_provided_ulid() { let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + context .command() - .args(["run", "--help"]) + .args([ + "run", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + "../../../test/simple.fabro", + ]) .assert() - .success() - .stdout(predicate::str::contains("--detach")); + .success(); + + let run_dir = context.find_run_dir(run_id); + let run_record = read_json(run_dir.join("run.json")); + assert_eq!(run_record["run_id"].as_str(), Some(run_id)); } #[test] fn detach_prints_ulid_and_exits() { let context = test_context!(); - let output = context - .command() - .args([ - "run", - "--detach", - "--dry-run", - "--auto-approve", - "../../../test/simple.fabro", - ]) - .assert() - .success() - .get_output() - .stdout - .clone(); - - let stdout = String::from_utf8(output).unwrap(); - let ulid = stdout.trim(); - // ULID is 26 uppercase alphanumeric chars - assert_eq!(ulid.len(), 26, "expected 26-char ULID, got: {ulid:?}"); - assert!( - ulid.chars().all(|c| c.is_ascii_alphanumeric()), - "expected alphanumeric ULID, got: {ulid:?}" - ); + let mut cmd = context.run_cmd(); + cmd.args([ + "--detach", + "--dry-run", + "--auto-approve", + "../../../test/simple.fabro", + ]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + [ULID] + ----- stderr ----- + "); } #[test] fn detach_creates_run_dir_with_detach_log() { let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB9"; - let output = context - .command() + context + .run_cmd() .args([ - "run", - "--detach", - "--dry-run", - "--auto-approve", - "../../../test/simple.fabro", - ]) - .assert() - .success() - .get_output() - .stdout - .clone(); - - let ulid = String::from_utf8(output).unwrap(); - let ulid = ulid.trim(); - assert!(!ulid.is_empty(), "should print a ULID"); - - // Run dir should have been created under storage_dir/runs/ and the launcher - // log should live under storage_dir/launchers/. - let runs_base = context.storage_dir.join("runs"); - assert!(runs_base.exists(), "runs/ directory should exist"); - let entries: Vec<_> = std::fs::read_dir(&runs_base) - .unwrap() - .filter_map(|e| e.ok()) - .collect(); - assert_eq!(entries.len(), 1, "should have exactly one run directory"); - let run_dir = entries[0].path(); - assert!( - context - .storage_dir - .join("launchers") - .join(format!("{ulid}.log")) - .exists(), - "launcher log should exist under storage_dir/launchers" - ); - assert!(!run_dir.join("detach.log").exists()); -} - -// == Resume =================================================================== - -#[test] -fn resume_help_shows_expected_args() { - let context = test_context!(); - context - .command() - .args(["resume", "--help"]) - .assert() - .success() - .stdout(predicate::str::contains("--detach")) - .stdout(predicate::str::contains("--checkpoint").not()) - .stdout(predicate::str::contains("--workflow").not()); -} - -#[test] -fn resume_requires_run_arg() { - let context = test_context!(); - context.command().args(["resume"]).assert().failure(); -} - -#[test] -fn run_help_no_longer_shows_resume_or_run_branch() { - let context = test_context!(); - context - .command() - .args(["run", "--help"]) - .assert() - .success() - .stdout(predicate::str::contains("--resume").not()) - .stdout(predicate::str::contains("--run-branch").not()); -} - -#[test] -fn rewind_and_fork_recover_missing_metadata_from_store() { - let context = test_context!(); - let repo_dir = tempfile::tempdir().unwrap(); - Repository::init(repo_dir.path()).unwrap(); - - let source_run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAW"; - let expected_shas = seed_run_branch(repo_dir.path(), source_run_id, &["start", "build"]); - Runtime::new().unwrap().block_on(seed_durable_run( - &context.storage_dir, - repo_dir.path(), - source_run_id, - )); - - assert!( - list_metadata_run_ids(repo_dir.path()).is_empty(), - "metadata branch should start missing" - ); - - let rewind_list = context - .command() - .current_dir(repo_dir.path()) - .args(["rewind", source_run_id, "--list"]) - .timeout(Duration::from_secs(15)) - .assert() - .success() - .get_output() - .stderr - .clone(); - let rewind_list = String::from_utf8(rewind_list).unwrap(); - assert!( - rewind_list.contains("@1"), - "expected first checkpoint: {rewind_list}" - ); - assert!( - rewind_list.contains("@2"), - "expected second checkpoint: {rewind_list}" - ); - assert!( - !rewind_list.contains("no run commit"), - "rebuilt timeline should persist backfilled SHAs: {rewind_list}" - ); - - let rebuilt_checkpoints = metadata_checkpoints(repo_dir.path(), source_run_id); - assert_eq!(rebuilt_checkpoints.len(), 2); - assert_eq!( - rebuilt_checkpoints[0].git_commit_sha.as_deref(), - Some(expected_shas[0].as_str()) - ); - assert_eq!( - rebuilt_checkpoints[1].git_commit_sha.as_deref(), - Some(expected_shas[1].as_str()) - ); - - let before_child = list_metadata_run_ids(repo_dir.path()); - context - .command() - .current_dir(repo_dir.path()) - .args(["fork", source_run_id, "--no-push"]) - .timeout(Duration::from_secs(15)) - .assert() - .success(); - let after_child = list_metadata_run_ids(repo_dir.path()); - let child_run_ids: Vec<_> = after_child.difference(&before_child).cloned().collect(); - assert_eq!(child_run_ids.len(), 1, "expected one child run"); - let child_run_id = &child_run_ids[0]; - - let child_checkpoint = latest_metadata_checkpoint(repo_dir.path(), child_run_id); - assert_eq!( - child_checkpoint.git_commit_sha.as_deref(), - Some(expected_shas[1].as_str()) - ); - - let child_rewind = context - .command() - .current_dir(repo_dir.path()) - .args(["rewind", child_run_id, "@1", "--no-push"]) - .timeout(Duration::from_secs(15)) - .assert() - .success() - .get_output() - .stderr - .clone(); - let child_rewind = String::from_utf8(child_rewind).unwrap(); - assert!( - child_rewind.contains("Rewound run branch"), - "expected child rewind to move the run branch: {child_rewind}" - ); - assert!( - !child_rewind.contains("has no git_commit_sha"), - "child rewind should not lose git_commit_sha: {child_rewind}" - ); - - let before_grandchild = after_child; - context - .command() - .current_dir(repo_dir.path()) - .args(["fork", child_run_id, "--no-push"]) - .timeout(Duration::from_secs(15)) - .assert() - .success(); - let after_grandchild = list_metadata_run_ids(repo_dir.path()); - let grandchild_run_ids: Vec<_> = after_grandchild - .difference(&before_grandchild) - .cloned() - .collect(); - assert_eq!(grandchild_run_ids.len(), 1, "expected one grandchild run"); -} - -// == Bug regression: create/start/attach lifecycle ============================ - -#[test] -fn completed_run_preserves_workflow_slug_for_lookup() { - let context = test_context!(); - let project = tempfile::tempdir().unwrap(); - let workflow_dir = project.path().join("workflows").join("sluggy"); - std::fs::create_dir_all(&workflow_dir).unwrap(); - let workflow_path = workflow_dir.join("workflow.fabro"); - std::fs::write( - &workflow_path, - "\ -digraph BarBaz { - start [shape=Mdiamond, label=\"Start\"] - exit [shape=Msquare, label=\"Exit\"] - start -> exit -} -", - ) - .unwrap(); - - context - .command() - .current_dir(project.path()) - .args([ - "create", - "--dry-run", - "--auto-approve", - "--run-id", - "01ARZ3NDEKTSV4RRFFQ69G5FAX", - workflow_path.to_str().unwrap(), - ]) - .assert() - .success(); - - context - .command() - .current_dir(project.path()) - .args(["start", "sluggy"]) - .assert() - .success(); - - context - .command() - .current_dir(project.path()) - .args(["attach", "01ARZ3NDEKTSV4RRFFQ69G5FAX"]) - .timeout(std::time::Duration::from_secs(10)) - .assert() - .success(); - - context - .command() - .current_dir(project.path()) - .args(["attach", "sluggy"]) - .timeout(std::time::Duration::from_secs(10)) - .assert() - .success(); - - let run_dir = find_run_dir(&context.storage_dir, "01ARZ3NDEKTSV4RRFFQ69G5FAX"); - let run_record: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(run_dir.join("run.json")).unwrap()).unwrap(); - assert_eq!(run_record["graph"]["name"].as_str(), Some("BarBaz")); - assert_eq!(run_record["workflow_slug"].as_str(), Some("sluggy")); -} - -#[test] -fn standalone_file_run_uses_file_stem_slug_for_lookup() { - let context = test_context!(); - let workflow_dir = tempfile::tempdir().unwrap(); - let workflow_path = workflow_dir.path().join("alpha.fabro"); - std::fs::write( - &workflow_path, - "\ -digraph FooWorkflow { - start [shape=Mdiamond, label=\"Start\"] - exit [shape=Msquare, label=\"Exit\"] - start -> exit -} -", - ) - .unwrap(); - - context - .command() - .args([ - "create", - "--dry-run", - "--auto-approve", - "--run-id", - "01ARZ3NDEKTSV4RRFFQ69G5FAY", - workflow_path.to_str().unwrap(), - ]) - .assert() - .success(); - - context - .command() - .args(["start", "alpha"]) - .assert() - .success(); - - context - .command() - .args(["attach", "alpha"]) - .timeout(std::time::Duration::from_secs(10)) - .assert() - .success(); - - let run_dir = find_run_dir(&context.storage_dir, "01ARZ3NDEKTSV4RRFFQ69G5FAY"); - let run_record: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(run_dir.join("run.json")).unwrap()).unwrap(); - assert_eq!(run_record["graph"]["name"].as_str(), Some("FooWorkflow")); - assert_eq!(run_record["workflow_slug"].as_str(), Some("alpha")); -} - -#[test] -fn dry_run_create_start_attach_works_with_default_run_lookup() { - let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAZ"; - let context = test_context!(); - - context - .command() - .args([ - "create", - "--dry-run", - "--auto-approve", - "--run-id", - run_id, - "../../../test/simple.fabro", - ]) - .assert() - .success() - .stdout(predicate::str::contains(run_id)); - - let run_dir = find_run_dir(&context.storage_dir, run_id); - assert!( - run_dir.join("run.json").exists(), - "create should persist run.json so the run is discoverable" - ); - - context.command().args(["start", run_id]).assert().success(); - - context - .command() - .args(["attach", run_id]) - .timeout(std::time::Duration::from_secs(10)) - .assert() - .success(); - - assert!(run_dir.join("conclusion.json").exists()); -} - -#[test] -fn dry_run_detach_attach_works_with_default_run_lookup() { - let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB0"; - let context = test_context!(); - - context - .command() - .args([ - "run", "--detach", "--dry-run", "--auto-approve", @@ -997,511 +298,22 @@ fn dry_run_detach_attach_works_with_default_run_lookup() { "../../../test/simple.fabro", ]) .assert() - .success() - .stdout(predicate::str::contains(run_id)); - - context - .command() - .args(["attach", run_id]) - .timeout(std::time::Duration::from_secs(10)) - .assert() .success(); -} -#[test] -fn start_by_workflow_name_prefers_newly_created_submitted_run() { - let context = test_context!(); - let old_run_dir = context - .storage_dir - .join("runs") - .join("01ARZ3NDEKTSV4RRFFQ69G5FB1"); - std::fs::create_dir_all(&old_run_dir).unwrap(); - std::fs::write( - old_run_dir.join("run.json"), + let run_dir = context.find_run_dir(run_id); + fabro_json_snapshot!( + context, serde_json::json!({ - "run_id": "01ARZ3NDEKTSV4RRFFQ69G5FB1", - "created_at": "2026-01-01T00:00:00Z", - "settings": {}, - "graph": { - "name": "Smoke", - "nodes": {}, - "edges": [], - "attrs": {} - }, - "working_directory": "/tmp" - }) - .to_string(), - ) - .unwrap(); - std::fs::write( - old_run_dir.join("status.json"), - serde_json::json!({"status": "succeeded", "updated_at": "2026-01-01T00:00:00Z"}) - .to_string(), - ) - .unwrap(); - - let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB2"; - context - .command() - .args([ - "create", - "--dry-run", - "--auto-approve", - "--run-id", - run_id, - "smoke", - ]) - .assert() - .success() - .stdout(predicate::str::contains(run_id)); - - context - .command() - .args(["start", "smoke"]) - .assert() - .success(); - - context - .command() - .args(["attach", run_id]) - .timeout(std::time::Duration::from_secs(10)) - .assert() - .success(); - - let new_run_dir = find_run_dir(&context.storage_dir, run_id); - let status = std::fs::read_to_string(new_run_dir.join("status.json")).unwrap(); - assert!( - status.contains("\"status\": \"succeeded\""), - "expected the newly created Smoke run to be started and completed" - ); -} - -// Bug 2: __detached should use cached graph.fabro, not run.json working_directory. -// When the original workflow file is deleted between create and start, -// the engine should read the snapshot saved at create time. -#[test] -fn bug2_detached_uses_cached_graph_not_original_path() { - let context = test_context!(); - let run_dir = context - .storage_dir - .join("runs") - .join("01ARZ3NDEKTSV4RRFFQ69G5FB3"); - std::fs::create_dir_all(&run_dir).unwrap(); - - let dot = "\ -digraph G { - start [shape=Mdiamond, label=\"Start\"] - exit [shape=Msquare, label=\"Exit\"] - start -> exit -}"; - - // run.json: working_directory is valid but original workflow path no longer exists - let run_record = serde_json::json!({ - "run_id": "01ARZ3NDEKTSV4RRFFQ69G5FB3", - "created_at": "2026-01-01T00:00:00Z", - "settings": { - "dry_run": true, - "auto_approve": true, - "no_retro": true, - "llm": { - "model": "test-model" - }, - "sandbox": { - "provider": "local" - } - }, - "graph": { - "name": "G", - "nodes": {}, - "edges": [], - "attrs": {} - }, - "working_directory": run_dir.to_str().unwrap(), - }); - std::fs::write( - run_dir.join("run.json"), - serde_json::to_string(&run_record).unwrap(), - ) - .unwrap(); - - // The cached graph snapshot saved by `fabro create` - std::fs::write(run_dir.join("graph.fabro"), dot).unwrap(); - - // __detached should use graph.fabro and never reference the deleted file. - let output = context - .command() - .args([ - "__detached", - "--run-dir", - run_dir.to_str().unwrap(), - "--launcher-path", - context - .storage_dir - .join("launchers") - .join("01ARZ3NDEKTSV4RRFFQ69G5FB3.json") - .to_str() - .unwrap(), - ]) - .timeout(std::time::Duration::from_secs(15)) - .output() - .expect("process should start"); - - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - !stderr.contains("deleted-workflow.fabro"), - "bug2: engine should use cached graph.fabro, not the original \ - (deleted) workflow path.\nstderr: {stderr}" - ); -} - -#[test] -fn bug4_detached_resume_rejects_completed_run_without_mutating_it() { - let context = test_context!(); - context.write_temp( - "workflow.fabro", - "\ -digraph Test { - start [shape=Mdiamond, label=\"Start\"] - exit [shape=Msquare, label=\"Exit\"] - start -> exit -} -", - ); - - let run = context - .command() - .current_dir(&context.temp_dir) - .args([ - "run", - "--dry-run", - "--auto-approve", - "--no-retro", - "--detach", - context.temp_dir.join("workflow.fabro").to_str().unwrap(), - ]) - .assert() - .success(); - let run_id = String::from_utf8(run.get_output().stdout.clone()) - .unwrap() - .trim() - .to_string(); - - context - .command() - .args(["wait", &run_id]) - .timeout(std::time::Duration::from_secs(10)) - .assert() - .success(); - - let inspect_before = context - .command() - .args(["inspect", &run_id]) - .assert() - .success(); - let before: serde_json::Value = - serde_json::from_slice(&inspect_before.get_output().stdout).unwrap(); - let run_dir = before[0]["run_dir"].as_str().unwrap().to_string(); - let start_time_before = before[0]["start_record"]["start_time"] - .as_str() - .unwrap() - .to_string(); - let conclusion_ts_before = before[0]["conclusion"]["timestamp"] - .as_str() - .unwrap() - .to_string(); - - context - .command() - .args([ - "__detached", - "--run-dir", - &run_dir, - "--launcher-path", - context - .storage_dir - .join("launchers") - .join(format!("{run_id}.json")) - .to_str() - .unwrap(), - "--resume", - ]) - .timeout(std::time::Duration::from_secs(10)) - .assert() - .failure() - .stderr(predicate::str::contains("nothing to resume")); - - let inspect_after = context - .command() - .args(["inspect", &run_id]) - .assert() - .success(); - let after: serde_json::Value = - serde_json::from_slice(&inspect_after.get_output().stdout).unwrap(); - - assert_eq!( - after[0]["start_record"]["start_time"].as_str().unwrap(), - start_time_before - ); - assert_eq!( - after[0]["conclusion"]["timestamp"].as_str().unwrap(), - conclusion_ts_before - ); -} - -#[test] -fn bug5_detached_uses_snapshotted_app_id_for_github_credentials() { - let context = test_context!(); - let run_dir = context - .storage_dir - .join("runs") - .join("01ARZ3NDEKTSV4RRFFQ69G5FB4"); - std::fs::create_dir_all(&run_dir).unwrap(); - - let dot = "\ -digraph G { - start [shape=Mdiamond, label=\"Start\"] - exit [shape=Msquare, label=\"Exit\"] - start -> exit -}"; - - let run_record = serde_json::json!({ - "run_id": "01ARZ3NDEKTSV4RRFFQ69G5FB4", - "created_at": "2026-01-01T00:00:00Z", - "settings": { - "dry_run": true, - "auto_approve": true, - "no_retro": true, - "llm": { - "model": "test-model" - }, - "sandbox": { - "provider": "local" - }, - "git": { - "app_id": "snapshotted-app-id" - } - }, - "graph": { - "name": "G", - "nodes": {}, - "edges": [], - "attrs": {} - }, - "working_directory": run_dir.to_str().unwrap(), - }); - std::fs::write( - run_dir.join("run.json"), - serde_json::to_string(&run_record).unwrap(), - ) - .unwrap(); - std::fs::write(run_dir.join("graph.fabro"), dot).unwrap(); - - context - .command() - .env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%") - .args([ - "__detached", - "--run-dir", - run_dir.to_str().unwrap(), - "--launcher-path", - context - .storage_dir - .join("launchers") - .join("01ARZ3NDEKTSV4RRFFQ69G5FB4.json") - .to_str() - .unwrap(), - ]) - .timeout(std::time::Duration::from_secs(10)) - .assert() - .failure() - .stderr(predicate::str::contains( - "GITHUB_APP_PRIVATE_KEY is not valid PEM or base64", - )); -} - -// Bug 3: attach loop must leave interview_request.json in place until the -// engine consumes interview_response.json, so reattach remains safe. -#[test] -fn bug3_attach_leaves_interview_request_until_engine_consumes_response() { - let context = test_context!(); - - let run_dir = setup_run_dir( - &context.storage_dir, - "01ARZ3NDEKTSV4RRFFQ69G5FB5", - serde_json::json!({}), - &[ - r#"{"ts":"2026-01-01T00:00:01Z","run_id":"01ARZ3NDEKTSV4RRFFQ69G5FB5","event":"StageStarted","node_id":"gate","name":"Gate","index":0,"attempt":1,"max_attempts":1}"#, - ], - ); - - // Terminal status still allows attach to answer the interview once before exiting. - std::fs::write( - run_dir.join("status.json"), - serde_json::json!({"status": "succeeded", "updated_at": "2026-01-01T00:00:00Z"}) - .to_string(), - ) - .unwrap(); - - // interview_request.json — a question the engine wrote - let question = serde_json::json!({ - "text": "Approve?", - "question_type": "YesNo", - "options": [], - "allow_freeform": false, - "default": {"value": "Yes", "selected_option": null, "text": null}, - "timeout_seconds": 1.0, - "stage": "gate", - "metadata": {} - }); - let runtime_state = RuntimeState::new(&run_dir); - std::fs::create_dir_all(runtime_state.runtime_dir()).unwrap(); - std::fs::write( - runtime_state.interview_request_path(), - serde_json::to_string(&question).unwrap(), - ) - .unwrap(); - - // Pipe "y\n" so ConsoleInterviewer doesn't block on stdin - let _ = context - .command() - .args(["attach", "01ARZ3NDEKTSV4RRFFQ69G5FB5"]) - .write_stdin("y\n") - .timeout(std::time::Duration::from_secs(5)) - .output(); - - // The attach loop should leave the request durable until the engine consumes - // the response, so a crashed attach can be retried safely. - assert!( - runtime_state.interview_request_path().exists(), - "bug3: interview_request.json should stay present until the engine consumes the answer" - ); - assert!( - runtime_state.interview_response_path().exists(), - "bug3: attach should write interview_response.json after handling the prompt" - ); - let response = std::fs::read_to_string(runtime_state.interview_response_path()).unwrap(); - assert!(response.contains("\"value\": \"Yes\"")); -} - -#[test] -fn attach_closed_stdin_keeps_interview_pending() { - let context = test_context!(); - - let run_dir = setup_run_dir( - &context.storage_dir, - "01ARZ3NDEKTSV4RRFFQ69G5FB6", - serde_json::json!({}), - &[ - r#"{"ts":"2026-01-01T00:00:01Z","run_id":"01ARZ3NDEKTSV4RRFFQ69G5FB6","event":"StageStarted","node_id":"gate","name":"Gate","index":0,"attempt":1,"max_attempts":1}"#, - ], - ); - - std::fs::write( - run_dir.join("status.json"), - serde_json::json!({"status": "running", "updated_at": "2026-01-01T00:00:00Z"}).to_string(), - ) - .unwrap(); - - let question = serde_json::json!({ - "text": "Approve?", - "question_type": "YesNo", - "options": [], - "allow_freeform": false, - "default": null, - "timeout_seconds": null, - "stage": "gate", - "metadata": {} - }); - let runtime_state = RuntimeState::new(&run_dir); - std::fs::create_dir_all(runtime_state.runtime_dir()).unwrap(); - std::fs::write( - runtime_state.interview_request_path(), - serde_json::to_string(&question).unwrap(), - ) - .unwrap(); - - let assert = context - .command() - .args(["attach", "01ARZ3NDEKTSV4RRFFQ69G5FB6"]) - .timeout(std::time::Duration::from_secs(5)) - .assert() - .failure(); - - let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); - assert!( - stderr.contains("still waiting for input"), - "attach should explain that the run is still waiting for a human answer.\nstderr: {stderr}" - ); - assert!( - runtime_state.interview_request_path().exists(), - "attach with closed stdin must leave the request pending" - ); - assert!( - !runtime_state.interview_response_path().exists(), - "attach with closed stdin must not fabricate a response" - ); - assert!( - !runtime_state.interview_claim_path().exists(), - "attach with closed stdin must release the claim so a later attach can answer" - ); -} - -// Bug 4: attach should respect the verbose flag from run.json. -// Currently ProgressUI is created with verbose=false regardless of config. -#[test] -fn bug4_attach_respects_verbose_from_spec() { - let context = test_context!(); - - // Use pre-rename field names so handle_json_line can parse them - // (isolates this test from bug 1). With 2 turns and 1 tool call, - // verbose mode should display "(2 turns, 1 tools, ...)" in the output. - let run_dir = setup_run_dir( - &context.storage_dir, - "01ARZ3NDEKTSV4RRFFQ69G5FB7", - serde_json::json!({"verbose": true}), - &[ - r#"{"ts":"2026-01-01T12:00:00Z","run_id":"01ARZ3NDEKTSV4RRFFQ69G5FB7","event":"StageStarted","node_id":"code","name":"Code","index":0,"attempt":1,"max_attempts":1}"#, - r#"{"ts":"2026-01-01T12:00:01Z","run_id":"01ARZ3NDEKTSV4RRFFQ69G5FB7","event":"Agent.AssistantMessage","stage":"code","model":"claude-sonnet"}"#, - r#"{"ts":"2026-01-01T12:00:02Z","run_id":"01ARZ3NDEKTSV4RRFFQ69G5FB7","event":"Agent.AssistantMessage","stage":"code","model":"claude-sonnet"}"#, - r#"{"ts":"2026-01-01T12:00:03Z","run_id":"01ARZ3NDEKTSV4RRFFQ69G5FB7","event":"Agent.ToolCallStarted","stage":"code","tool_name":"read_file","tool_call_id":"tc1","arguments":{}}"#, - r#"{"ts":"2026-01-01T12:00:04Z","run_id":"01ARZ3NDEKTSV4RRFFQ69G5FB7","event":"Agent.ToolCallCompleted","stage":"code","tool_name":"read_file","tool_call_id":"tc1","is_error":false}"#, - r#"{"ts":"2026-01-01T12:00:10Z","run_id":"01ARZ3NDEKTSV4RRFFQ69G5FB7","event":"StageCompleted","node_id":"code","name":"Code","index":0,"duration_ms":10000,"status":"success","usage":{"input_tokens":1000,"output_tokens":500}}"#, - ], - ); - - // Succeeded status + conclusion so attach exits after reading events - std::fs::write( - run_dir.join("status.json"), - serde_json::json!({"status": "succeeded", "updated_at": "2026-01-01T12:00:10Z"}) - .to_string(), - ) - .unwrap(); - std::fs::write( - run_dir.join("conclusion.json"), - serde_json::json!({ - "timestamp": "2026-01-01T12:00:10Z", - "status": "success", - "duration_ms": 10000, - "stages": [], - "total_retries": 0 - }) - .to_string(), - ) - .unwrap(); - - let output = context - .command() - .args(["attach", "01ARZ3NDEKTSV4RRFFQ69G5FB7"]) - .timeout(std::time::Duration::from_secs(10)) - .output() - .expect("process should start"); - - let stderr = String::from_utf8(output.stderr).unwrap(); - - // Bug: verbose is hardcoded false, so stats are suppressed. - // Fix: load spec.verbose and pass it to ProgressUI. - assert!( - stderr.contains("turns") && stderr.contains("tools"), - "bug4: attach should show verbose stats when spec.verbose=true.\nstderr: {stderr}" + "run_dir": run_dir, + "launcher_log_exists": context.storage_dir.join("launchers").join(format!("{run_id}.log")).exists(), + "detach_log_exists": run_dir.join("detach.log").exists(), + }), + @r#" + { + "run_dir": "[DRY_RUN_DIR]", + "launcher_log_exists": true, + "detach_log_exists": false + } + "# ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/cp.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs similarity index 97% rename from lib/crates/fabro-cli/tests/it/cmd/cp.rs rename to lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs index 1d15b91f4..549f5299b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/cp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs @@ -5,8 +5,8 @@ use super::support::{read_text, setup_asset_sandbox_run, setup_created_dry_run, #[test] fn help() { let context = test_context!(); - let mut cmd = context.cp(); - cmd.arg("--help"); + let mut cmd = context.command(); + cmd.args(["sandbox", "cp", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 diff --git a/lib/crates/fabro-cli/tests/it/cmd/preview.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs similarity index 95% rename from lib/crates/fabro-cli/tests/it/cmd/preview.rs rename to lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs index 24b9f0c5f..4da8105ac 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/preview.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs @@ -5,8 +5,8 @@ use super::support::setup_asset_sandbox_run; #[test] fn help() { let context = test_context!(); - let mut cmd = context.preview(); - cmd.arg("--help"); + let mut cmd = context.command(); + cmd.args(["sandbox", "preview", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 diff --git a/lib/crates/fabro-cli/tests/it/cmd/ssh.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs similarity index 95% rename from lib/crates/fabro-cli/tests/it/cmd/ssh.rs rename to lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs index 20df52349..72b693308 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ssh.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs @@ -5,8 +5,8 @@ use super::support::setup_asset_sandbox_run; #[test] fn help() { let context = test_context!(); - let mut cmd = context.ssh(); - cmd.arg("--help"); + let mut cmd = context.command(); + cmd.args(["sandbox", "ssh", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 diff --git a/lib/crates/fabro-cli/tests/it/cmd/serve.rs b/lib/crates/fabro-cli/tests/it/cmd/server.rs similarity index 95% rename from lib/crates/fabro-cli/tests/it/cmd/serve.rs rename to lib/crates/fabro-cli/tests/it/cmd/server.rs index f24c904c6..aebf605b2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/serve.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server.rs @@ -5,14 +5,14 @@ fn help() { let context = test_context!(); let mut cmd = context.command(); - cmd.args(["serve", "--help"]); + cmd.args(["server", "start", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- Start the HTTP API server - Usage: fabro serve [OPTIONS] + Usage: fabro server start [OPTIONS] Options: --debug diff --git a/lib/crates/fabro-cli/tests/it/cmd/start.rs b/lib/crates/fabro-cli/tests/it/cmd/start.rs index 74e63e069..98dd80292 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/start.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use crate::support::{example_fixture, fabro_json_snapshot, read_json}; + use super::support::{output_stdout, resolve_run, wait_for_status, write_sleep_workflow}; #[test] @@ -29,6 +31,131 @@ fn help() { "); } +#[test] +fn start_by_run_id_starts_created_run() { + let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAC"; + + context + .command() + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + example_fixture("simple.fabro").to_str().unwrap(), + ]) + .assert() + .success(); + + context.command().args(["start", run_id]).assert().success(); + context + .command() + .args(["wait", run_id]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + + let run_dir = context.find_run_dir(run_id); + let status = read_json(run_dir.join("status.json")); + let conclusion = read_json(run_dir.join("conclusion.json")); + fabro_json_snapshot!( + context, + serde_json::json!({ + "status": status["status"], + "reason": status["reason"], + "conclusion_status": conclusion["status"], + }), + @r#" + { + "status": "succeeded", + "reason": "completed", + "conclusion_status": "success" + } + "# + ); +} + +#[test] +fn start_by_workflow_name_prefers_newly_created_submitted_run() { + let context = test_context!(); + let workflow_path = context.temp_dir.join("smoke/workflow.fabro"); + let old_run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAD"; + let new_run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAE"; + + context.write_temp( + "smoke/workflow.fabro", + "\ +digraph Smoke { + start [shape=Mdiamond, label=\"Start\"] + work [label=\"Work\", prompt=\"Do the work.\"] + exit [shape=Msquare, label=\"Exit\"] + start -> work -> exit +} +", + ); + + context + .command() + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + old_run_id, + workflow_path.to_str().unwrap(), + ]) + .assert() + .success(); + context + .command() + .args(["start", old_run_id]) + .assert() + .success(); + context + .command() + .args(["wait", old_run_id]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + + context + .command() + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + new_run_id, + workflow_path.to_str().unwrap(), + ]) + .assert() + .success(); + + context + .command() + .args(["start", "smoke"]) + .assert() + .success(); + context + .command() + .args(["attach", new_run_id]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + + let new_run_dir = context.find_run_dir(new_run_id); + let status = read_json(new_run_dir.join("status.json")); + fabro_json_snapshot!(context, &status, @r#" + { + "status": "succeeded", + "reason": "completed", + "updated_at": "[TIMESTAMP]" + } + "#); +} + #[test] fn start_rejects_already_active_or_completed_run() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/main.rs b/lib/crates/fabro-cli/tests/it/main.rs index a1ea332f3..f7f9dd07e 100644 --- a/lib/crates/fabro-cli/tests/it/main.rs +++ b/lib/crates/fabro-cli/tests/it/main.rs @@ -1,3 +1,4 @@ mod cmd; mod scenario; +mod support; mod workflow; diff --git a/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs index ab9c8c778..4bcf7684d 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs @@ -1,10 +1,8 @@ -use std::path::PathBuf; - -use fabro_store::RuntimeState; use fabro_test::test_context; use serde_json::Value; use super::{fixture, read_json, timeout_for}; +use crate::support::fabro_json_snapshot; #[test] #[ignore = "scenario: requires local sandbox"] @@ -61,12 +59,6 @@ fn local_run_lifecycle() { items[0]["conclusion"].is_object(), "inspect should include conclusion" ); - let run_dir = PathBuf::from( - items[0]["run_dir"] - .as_str() - .expect("inspect should include run_dir"), - ); - // 4. logs — non-empty, first line is valid JSONL with event field let logs_out = cmd(&["logs", &run_id]).success(); let logs_stdout = String::from_utf8(logs_out.get_output().stdout.clone()).unwrap(); @@ -87,95 +79,7 @@ fn local_run_lifecycle() { "asset list should report no assets: {asset_list_stdout}" ); - // 6. Seed a synthetic asset so asset list/cp have something to work with. - let asset_dir = RuntimeState::new(&run_dir).asset_stage_dir("step1", 1); - std::fs::create_dir_all(&asset_dir).unwrap(); - std::fs::write(asset_dir.join("output.txt"), "asset-content-42").unwrap(); - std::fs::write( - asset_dir.join("manifest.json"), - r#"{"files_copied":1,"total_bytes":16,"files_skipped":0,"download_errors":0,"hash_errors":0,"captured_assets":[{"path":"output.txt","mime":"text/plain","content_md5":"f02439728c0a94b7bfc465acb1201a1f","content_sha256":"0af9dea3e1c2dec968531c18c9331659b8268e8c9cf24b01cda7b8ce51d2ff00","bytes":16}]}"#, - ) - .unwrap(); - let retry_two_dir = RuntimeState::new(&run_dir).asset_stage_dir("step1", 2); - std::fs::create_dir_all(&retry_two_dir).unwrap(); - std::fs::write(retry_two_dir.join("output.txt"), "asset-content-84").unwrap(); - std::fs::write( - retry_two_dir.join("manifest.json"), - r#"{"files_copied":1,"total_bytes":16,"files_skipped":0,"download_errors":0,"hash_errors":0,"captured_assets":[{"path":"output.txt","mime":"text/plain","content_md5":"5b4e23e40a1630f9caa15a4cb6cfb79b","content_sha256":"1f71e0df61fc3b4e1ee3aba7ceac9ae391af22595b5b5630d97d34cf33d4d540","bytes":16}]}"#, - ) - .unwrap(); - - // 7. asset list — now shows the seeded assets - let asset_list_out2 = cmd(&["asset", "list", &run_id, "--json"]).success(); - let asset_list_stdout2 = - String::from_utf8(asset_list_out2.get_output().stdout.clone()).unwrap(); - let assets: Vec = serde_json::from_str(&asset_list_stdout2) - .expect("asset list --json should produce a JSON array"); - assert_eq!( - assets.len(), - 2, - "should have two assets: {asset_list_stdout2}" - ); - assert_eq!(assets[0]["relative_path"].as_str(), Some("output.txt")); - assert_eq!(assets[0]["node_slug"].as_str(), Some("step1")); - let retry_filtered_out = cmd(&["asset", "list", &run_id, "--retry", "1", "--json"]).success(); - let retry_filtered_stdout = - String::from_utf8(retry_filtered_out.get_output().stdout.clone()).unwrap(); - let retry_filtered_assets: Vec = serde_json::from_str(&retry_filtered_stdout) - .expect("asset list --json should produce a JSON array"); - assert_eq!(retry_filtered_assets.len(), 1); - assert_eq!(retry_filtered_assets[0]["retry"].as_u64(), Some(1)); - - // 8. asset cp — ambiguous without --retry when multiple retries captured the same path - let asset_dest = context.temp_dir.join("asset_copy"); - cmd(&[ - "asset", - "cp", - &format!("{run_id}:output.txt"), - asset_dest.to_str().unwrap(), - ]) - .failure(); - cmd(&[ - "asset", - "cp", - &format!("{run_id}:output.txt"), - asset_dest.to_str().unwrap(), - "--retry", - "1", - ]) - .success(); - let copied = std::fs::read_to_string(asset_dest.join("output.txt")).unwrap(); - assert_eq!( - copied, "asset-content-42", - "asset cp should copy file content" - ); - - // 9. cp — download a file from the local sandbox workdir - let sandbox_json: Value = read_json(&run_dir.join("sandbox.json")); - let workdir = sandbox_json["working_directory"] - .as_str() - .expect("sandbox.json should have working_directory"); - // Plant a file in the sandbox workdir so we can download it - std::fs::write( - PathBuf::from(workdir).join("cp_test.txt"), - "downloaded-via-cp", - ) - .unwrap(); - let cp_dest = context.temp_dir.join("cp_download.txt"); - cmd(&[ - "sandbox", - "cp", - &format!("{run_id}:cp_test.txt"), - cp_dest.to_str().unwrap(), - ]) - .success(); - let cp_content = std::fs::read_to_string(&cp_dest).unwrap(); - assert_eq!( - cp_content, "downloaded-via-cp", - "cp should download file from sandbox" - ); - - // 10. system df — mentions "Runs" + // 6. system df — mentions "Runs" let df_out = cmd(&["system", "df"]).success(); let df_stdout = String::from_utf8(df_out.get_output().stdout.clone()).unwrap(); assert!( @@ -183,10 +87,10 @@ fn local_run_lifecycle() { "system df should mention Runs: {df_stdout}" ); - // 11. rm — remove the run + // 7. rm — remove the run cmd(&["rm", &run_id]).success(); - // 12. ps -a --json — should be empty + // 8. ps -a --json — should be empty let ps_out2 = cmd(&["ps", "-a", "--json"]).success(); let ps_stdout2 = String::from_utf8(ps_out2.get_output().stdout.clone()).unwrap(); let runs2: Vec = @@ -196,3 +100,217 @@ fn local_run_lifecycle() { "runs should be empty after rm: {ps_stdout2}" ); } + +#[test] +fn dry_run_create_start_attach_works_with_default_run_lookup() { + let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAJ"; + + context + .command() + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + "../../../test/simple.fabro", + ]) + .assert() + .success(); + + context.command().args(["start", run_id]).assert().success(); + context + .command() + .args(["attach", run_id]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + + let run_dir = context.find_run_dir(run_id); + fabro_json_snapshot!( + context, + serde_json::json!({ + "run_json_exists": run_dir.join("run.json").exists(), + "conclusion_json_exists": run_dir.join("conclusion.json").exists(), + }), + @r#" + { + "run_json_exists": true, + "conclusion_json_exists": true + } + "# + ); +} + +#[test] +fn dry_run_detach_attach_works_with_default_run_lookup() { + let context = test_context!(); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAK"; + + context + .command() + .args([ + "run", + "--detach", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + "../../../test/simple.fabro", + ]) + .assert() + .success(); + + context + .command() + .args(["attach", run_id]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + + let run_dir = context.find_run_dir(run_id); + fabro_json_snapshot!( + context, + serde_json::json!({ + "run_dir": run_dir, + "conclusion_json_exists": run_dir.join("conclusion.json").exists(), + }), + @r#" + { + "run_dir": "[DRY_RUN_DIR]", + "conclusion_json_exists": true + } + "# + ); +} + +#[test] +fn completed_run_can_be_attached_by_workflow_slug() { + let context = test_context!(); + let project = tempfile::tempdir().unwrap(); + let workflow_dir = project.path().join("workflows").join("sluggy"); + let workflow_path = workflow_dir.join("workflow.fabro"); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAQ"; + + std::fs::create_dir_all(&workflow_dir).unwrap(); + std::fs::write( + &workflow_path, + "\ +digraph BarBaz { + start [shape=Mdiamond, label=\"Start\"] + exit [shape=Msquare, label=\"Exit\"] + start -> exit +} +", + ) + .unwrap(); + + context + .command() + .current_dir(project.path()) + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + workflow_path.to_str().unwrap(), + ]) + .assert() + .success(); + context + .command() + .current_dir(project.path()) + .args(["start", "sluggy"]) + .assert() + .success(); + context + .command() + .current_dir(project.path()) + .args(["attach", run_id]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + context + .command() + .current_dir(project.path()) + .args(["attach", "sluggy"]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + + let run_record = read_json(&context.find_run_dir(run_id).join("run.json")); + fabro_json_snapshot!( + context, + serde_json::json!({ + "graph_name": run_record["graph"]["name"], + "workflow_slug": run_record["workflow_slug"], + }), + @r#" + { + "graph_name": "BarBaz", + "workflow_slug": "sluggy" + } + "# + ); +} + +#[test] +fn completed_run_can_be_attached_by_file_stem() { + let context = test_context!(); + let workflow_dir = tempfile::tempdir().unwrap(); + let workflow_path = workflow_dir.path().join("alpha.fabro"); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAM"; + + std::fs::write( + &workflow_path, + "\ +digraph FooWorkflow { + start [shape=Mdiamond, label=\"Start\"] + exit [shape=Msquare, label=\"Exit\"] + start -> exit +} +", + ) + .unwrap(); + + context + .command() + .args([ + "create", + "--dry-run", + "--auto-approve", + "--run-id", + run_id, + workflow_path.to_str().unwrap(), + ]) + .assert() + .success(); + context + .command() + .args(["start", "alpha"]) + .assert() + .success(); + context + .command() + .args(["attach", "alpha"]) + .timeout(std::time::Duration::from_secs(10)) + .assert() + .success(); + + let run_record = read_json(&context.find_run_dir(run_id).join("run.json")); + fabro_json_snapshot!( + context, + serde_json::json!({ + "graph_name": run_record["graph"]["name"], + "workflow_slug": run_record["workflow_slug"], + }), + @r#" + { + "graph_name": "FooWorkflow", + "workflow_slug": "alpha" + } + "# + ); +} diff --git a/lib/crates/fabro-cli/tests/it/scenario/mod.rs b/lib/crates/fabro-cli/tests/it/scenario/mod.rs index fac730626..73218d1a2 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/mod.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/mod.rs @@ -1,5 +1,6 @@ mod exec; mod lifecycle; +mod recovery; use std::path::{Path, PathBuf}; use std::time::Duration; diff --git a/lib/crates/fabro-cli/tests/it/scenario/recovery.rs b/lib/crates/fabro-cli/tests/it/scenario/recovery.rs new file mode 100644 index 000000000..1104d30e8 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/scenario/recovery.rs @@ -0,0 +1,278 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use fabro_checkpoint::branch::BranchStore; +use fabro_checkpoint::git::Store as GitStore; +use fabro_test::{fabro_snapshot, test_context}; +use fabro_types::Checkpoint; +use git2::{Repository, Signature}; + +use crate::support::read_jsonl; + +fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet { + let repo = Repository::discover(repo_dir).unwrap(); + repo.references() + .unwrap() + .flatten() + .filter_map(|reference| reference.name().map(ToOwned::to_owned)) + .filter_map(|name| { + name.strip_prefix("refs/heads/fabro/meta/") + .map(ToOwned::to_owned) + }) + .collect() +} + +fn metadata_checkpoints(repo_dir: &Path, run_id: &str) -> Vec { + let repo = Repository::discover(repo_dir).unwrap(); + let store = GitStore::new(repo); + let sig = Signature::now("Fabro", "noreply@fabro.sh").unwrap(); + let branch = format!("fabro/meta/{run_id}"); + let bs = BranchStore::new(&store, &branch, &sig); + + bs.log(100) + .unwrap() + .iter() + .rev() + .filter(|commit| commit.message.starts_with("checkpoint")) + .map(|commit| { + serde_json::from_slice::( + &store + .read_blob_at(commit.oid, "checkpoint.json") + .unwrap() + .unwrap(), + ) + .unwrap() + }) + .collect() +} + +fn latest_metadata_checkpoint(repo_dir: &Path, run_id: &str) -> Checkpoint { + let repo = Repository::discover(repo_dir).unwrap(); + let store = GitStore::new(repo); + let tip = store + .resolve_ref(&format!("fabro/meta/{run_id}")) + .unwrap() + .unwrap(); + serde_json::from_slice(&store.read_blob_at(tip, "checkpoint.json").unwrap().unwrap()).unwrap() +} + +fn run_commit_shas_by_node(run_dir: &Path) -> serde_json::Map { + let mut shas_by_node = serde_json::Map::new(); + for event in read_jsonl(run_dir.join("progress.jsonl")) { + if !matches!(event["event"].as_str(), Some("git.commit" | "GitCommit")) { + continue; + } + + let Some(node_id) = event["node_id"].as_str() else { + continue; + }; + let Some(sha) = event + .get("properties") + .and_then(|properties| properties.get("sha")) + .and_then(serde_json::Value::as_str) + .or_else(|| event["sha"].as_str()) + else { + continue; + }; + + shas_by_node + .entry(node_id.to_string()) + .or_insert_with(|| serde_json::Value::Array(Vec::new())) + .as_array_mut() + .unwrap() + .push(serde_json::Value::String(sha.to_string())); + } + + shas_by_node +} + +fn init_repo_with_workflow(repo_dir: &Path) { + std::fs::write(repo_dir.join("README.md"), "recovery test\n").unwrap(); + std::fs::write( + repo_dir.join("workflow.fabro"), + "\ +digraph Recovery { + start [shape=Mdiamond, label=\"Start\"] + exit [shape=Msquare, label=\"Exit\"] + plan [label=\"Plan\", shape=parallelogram, script=\"echo plan\"] + build [label=\"Build\", shape=parallelogram, script=\"echo build\"] + start -> plan -> build -> exit +} +", + ) + .unwrap(); + + let init = std::process::Command::new("git") + .args(["init"]) + .current_dir(repo_dir) + .status() + .unwrap(); + assert!(init.success(), "git init should succeed"); + + let add = std::process::Command::new("git") + .args(["add", "README.md", "workflow.fabro"]) + .current_dir(repo_dir) + .status() + .unwrap(); + assert!(add.success(), "git add should succeed"); + + let commit = std::process::Command::new("git") + .args([ + "-c", + "user.name=Fabro", + "-c", + "user.email=noreply@fabro.sh", + "commit", + "-m", + "init", + ]) + .current_dir(repo_dir) + .status() + .unwrap(); + assert!(commit.success(), "git commit should succeed"); +} + +#[test] +fn rewind_and_fork_recover_missing_metadata_from_real_run_state() { + let context = test_context!(); + let repo_dir = tempfile::tempdir().unwrap(); + let source_run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAN"; + + init_repo_with_workflow(repo_dir.path()); + + context + .command() + .current_dir(repo_dir.path()) + .args([ + "run", + "--dry-run", + "--no-retro", + "--sandbox", + "local", + "--run-id", + source_run_id, + "workflow.fabro", + ]) + .assert() + .success(); + + let run_dir = context.find_run_dir(source_run_id); + let run_shas = run_commit_shas_by_node(&run_dir); + let plan_sha = run_shas["plan"][0].as_str().unwrap().to_string(); + let build_sha = run_shas["build"][0].as_str().unwrap().to_string(); + + let mut filters = Vec::new(); + for (idx, sha) in [plan_sha.as_str(), build_sha.as_str()].iter().enumerate() { + let replacement = format!("[SHA_{}]", idx + 1); + filters.push((regex::escape(sha), replacement.clone())); + filters.push((regex::escape(&sha[..8]), replacement.clone())); + filters.push((regex::escape(&sha[..7]), replacement)); + } + filters.extend(context.filters()); + + Repository::discover(repo_dir.path()) + .unwrap() + .find_reference(&format!("refs/heads/fabro/meta/{source_run_id}")) + .unwrap() + .delete() + .unwrap(); + + assert!( + list_metadata_run_ids(repo_dir.path()).is_empty(), + "metadata branch should start missing" + ); + + let mut rewind_list = context.command(); + rewind_list.current_dir(repo_dir.path()); + rewind_list.args(["rewind", source_run_id, "--list"]); + rewind_list.timeout(std::time::Duration::from_secs(15)); + fabro_snapshot!(filters.clone(), rewind_list, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + @ Node Details + @1 start (no run commit) + @2 plan + @3 build + "); + + let rebuilt_checkpoints = metadata_checkpoints(repo_dir.path(), source_run_id); + assert_eq!(rebuilt_checkpoints.len(), 3); + assert_eq!(rebuilt_checkpoints[0].git_commit_sha, None); + assert_eq!( + rebuilt_checkpoints[1].git_commit_sha.as_deref(), + Some(plan_sha.as_str()) + ); + assert_eq!( + rebuilt_checkpoints[2].git_commit_sha.as_deref(), + Some(build_sha.as_str()) + ); + + let before_child = list_metadata_run_ids(repo_dir.path()); + context + .command() + .current_dir(repo_dir.path()) + .args(["fork", source_run_id, "--no-push"]) + .timeout(std::time::Duration::from_secs(15)) + .assert() + .success(); + let after_child = list_metadata_run_ids(repo_dir.path()); + let child_run_ids: Vec<_> = after_child.difference(&before_child).cloned().collect(); + assert_eq!(child_run_ids.len(), 1, "expected one child run"); + let child_run_id = &child_run_ids[0]; + + let child_checkpoint = latest_metadata_checkpoint(repo_dir.path(), child_run_id); + assert_eq!( + child_checkpoint.git_commit_sha.as_deref(), + Some(build_sha.as_str()) + ); + + let mut rewind_filters = filters.clone(); + rewind_filters.push(( + regex::escape(&source_run_id[..8]), + "[RUN_PREFIX]".to_string(), + )); + + let mut source_rewind = context.command(); + source_rewind.current_dir(repo_dir.path()); + source_rewind.args(["rewind", source_run_id, "@2", "--no-push"]); + source_rewind.timeout(std::time::Duration::from_secs(15)); + fabro_snapshot!(rewind_filters, source_rewind, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Rewound metadata branch to @2 (plan) + Rewound run branch fabro/run/[ULID] to [SHA_1] + + To resume: fabro resume [RUN_PREFIX] + "); + + let rewound_child = latest_metadata_checkpoint(repo_dir.path(), source_run_id); + assert_eq!( + rewound_child.git_commit_sha.as_deref(), + Some(plan_sha.as_str()) + ); + + let before_grandchild = list_metadata_run_ids(repo_dir.path()); + context + .command() + .current_dir(repo_dir.path()) + .args(["fork", source_run_id, "--no-push"]) + .timeout(std::time::Duration::from_secs(15)) + .assert() + .success(); + let after_grandchild = list_metadata_run_ids(repo_dir.path()); + let grandchild_run_ids: Vec<_> = after_grandchild + .difference(&before_grandchild) + .cloned() + .collect(); + assert_eq!(grandchild_run_ids.len(), 1, "expected one grandchild run"); + + let grandchild_checkpoint = latest_metadata_checkpoint(repo_dir.path(), &grandchild_run_ids[0]); + assert_eq!( + grandchild_checkpoint.git_commit_sha.as_deref(), + Some(plan_sha.as_str()) + ); +} diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/crates/fabro-cli/tests/it/support/mod.rs new file mode 100644 index 000000000..b2b3bccad --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/support/mod.rs @@ -0,0 +1,82 @@ +use std::path::{Path, PathBuf}; + +use fabro_test::TestContext; +use serde_json::Value; + +macro_rules! fabro_json_snapshot { + ($context:expr, $value:expr, @$snapshot:literal) => {{ + let mut filters = $context.filters(); + filters.push(( + r"\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b".to_string(), + "[TIMESTAMP]".to_string(), + )); + let filters: Vec<(&str, &str)> = filters + .iter() + .map(|(pattern, replacement)| (pattern.as_str(), replacement.as_str())) + .collect(); + let rendered = serde_json::to_string_pretty(&$value).unwrap(); + insta::with_settings!({ filters => filters }, { + insta::assert_snapshot!(rendered, @$snapshot); + }); + }}; +} + +pub(crate) use fabro_json_snapshot; + +pub(crate) fn example_fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../../test/{name}")) +} + +pub(crate) fn read_json(path: impl AsRef) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +pub(crate) fn read_jsonl(path: impl AsRef) -> Vec { + std::fs::read_to_string(path) + .unwrap() + .lines() + .map(serde_json::from_str) + .collect::, _>>() + .unwrap() +} + +pub(crate) fn compact_progress_event(event: &Value) -> Value { + fn event_value<'a>(event: &'a Value, key: &str) -> Option<&'a Value> { + event + .get(key) + .or_else(|| { + event + .get("properties") + .and_then(|properties| properties.get(key)) + }) + .filter(|value| !value.is_null()) + } + + let mut compact = serde_json::Map::new(); + for key in [ + "event", + "provider", + "name", + "goal", + "node_id", + "node_label", + "handler_type", + "index", + "status", + "from_node", + "to_node", + "reason", + "artifact_count", + ] { + if let Some(value) = event_value(event, key) { + compact.insert(key.to_string(), value.clone()); + } + } + Value::Object(compact) +} + +pub(crate) fn run_output_filters(context: &TestContext) -> Vec<(String, String)> { + let mut filters = context.filters(); + filters.push((r"\b\d+ms\b".to_string(), "[TIME]".to_string())); + filters +} diff --git a/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs b/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs new file mode 100644 index 000000000..d3fa94488 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs @@ -0,0 +1,164 @@ +use fabro_test::{fabro_snapshot, test_context}; + +use crate::support::{example_fixture, run_output_filters}; + +#[test] +fn dry_run_branching() { + let context = test_context!(); + let mut cmd = context.run_cmd(); + cmd.args(["--dry-run", "--auto-approve"]); + cmd.arg(example_fixture("branching.fabro")); + fabro_snapshot!(run_output_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Workflow: Branch (6 nodes, 6 edges) + Graph: ../../../test/branching.fabro + Goal: Implement and validate a feature + + warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry) + Sandbox: local (ready in [TIME]) + ✓ Start [TIME] + ✓ Plan [TIME] + ✓ Implement [TIME] + ✓ Validate [TIME] + ✓ Tests passing? [TIME] + ✓ Exit [TIME] + + === Run Result === + Run: [ULID] + Status: SUCCESS + Duration: [DURATION] + Run: [DRY_RUN_DIR] + + === Output === + [Simulated] Response for stage: validate + "); +} + +#[test] +fn dry_run_conditions() { + let context = test_context!(); + let mut cmd = context.run_cmd(); + cmd.args(["--dry-run", "--auto-approve"]); + cmd.arg(example_fixture("conditions.fabro")); + fabro_snapshot!(run_output_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Workflow: Conditions (5 nodes, 5 edges) + Graph: ../../../test/conditions.fabro + Goal: Test condition evaluation with OR and parentheses + + Sandbox: local (ready in [TIME]) + ✓ start [TIME] + ✓ Decide [TIME] + ✓ Path B [TIME] + ✓ exit [TIME] + + === Run Result === + Run: [ULID] + Status: SUCCESS + Duration: [DURATION] + Run: [DRY_RUN_DIR] + + === Output === + [Simulated] Response for stage: path_b + "); +} + +#[test] +fn dry_run_parallel() { + let context = test_context!(); + let mut cmd = context.run_cmd(); + cmd.args(["--dry-run", "--auto-approve"]); + cmd.arg(example_fixture("parallel.fabro")); + fabro_snapshot!(run_output_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Workflow: Parallel (7 nodes, 7 edges) + Graph: ../../../test/parallel.fabro + Goal: Test parallel and fan-in execution + + Sandbox: local (ready in [TIME]) + ✓ start [TIME] + ✓ Fork Work [TIME] + ✓ Merge Results [TIME] + ✓ Review [TIME] + ✓ exit [TIME] + + === Run Result === + Run: [ULID] + Status: SUCCESS + Duration: [DURATION] + Run: [DRY_RUN_DIR] + + === Output === + [Simulated] Response for stage: review + "); +} + +#[test] +fn dry_run_styled() { + let context = test_context!(); + let mut cmd = context.run_cmd(); + cmd.args(["--dry-run", "--auto-approve"]); + cmd.arg(example_fixture("styled.fabro")); + fabro_snapshot!(run_output_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Workflow: Styled (5 nodes, 4 edges) + Graph: ../../../test/styled.fabro + Goal: Build a styled pipeline + + Sandbox: local (ready in [TIME]) + ✓ start [TIME] + ✓ Plan [TIME] + ✓ Implement [TIME] + ✓ Critical Review [TIME] + ✓ exit [TIME] + + === Run Result === + Run: [ULID] + Status: SUCCESS + Duration: [DURATION] + Run: [DRY_RUN_DIR] + + === Output === + [Simulated] Response for stage: critical_review + "); +} + +#[test] +fn dry_run_legacy_tool() { + let context = test_context!(); + let mut cmd = context.run_cmd(); + cmd.args(["--dry-run", "--auto-approve"]); + cmd.arg(example_fixture("legacy_tool.fabro")); + fabro_snapshot!(run_output_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Workflow: LegacyTool (3 nodes, 2 edges) + Graph: ../../../test/legacy_tool.fabro + Goal: Verify backwards compatibility with old tool naming + + Sandbox: local (ready in [TIME]) + ✓ Start [TIME] + ✓ Echo [TIME] + ✓ Exit [TIME] + + === Run Result === + Run: [ULID] + Status: SUCCESS + Duration: [DURATION] + Run: [DRY_RUN_DIR] + "); +} diff --git a/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs b/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs index 0f9af8405..50fae6256 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs @@ -51,12 +51,12 @@ fn scenario_full_stack(sandbox: &str) { // Progress events assert!( - has_event(&run_dir, "WorkflowRunStarted"), - "progress should contain WorkflowRunStarted" + has_event(&run_dir, "run.started"), + "progress should contain run.started" ); assert!( - has_event(&run_dir, "WorkflowRunCompleted"), - "progress should contain WorkflowRunCompleted" + has_event(&run_dir, "run.completed"), + "progress should contain run.completed" ); // All expected nodes completed diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 6b1e85ab3..3304b3105 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -2,6 +2,7 @@ mod agent_linear; mod command_agent_mixed; mod command_pipeline; mod conditional_branching; +mod dry_run_examples; mod full_stack; mod human_gate; diff --git a/lib/crates/fabro-git-storage/src/lib.rs b/lib/crates/fabro-git-storage/src/lib.rs deleted file mode 100644 index f2fc84016..000000000 --- a/lib/crates/fabro-git-storage/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod branchstore; -pub mod error; -pub mod gitobj; -pub mod snapshot; -pub mod trailerlink; - -pub use error::{Error, Result}; diff --git a/lib/crates/fabro-git-storage/src/snapshot.rs b/lib/crates/fabro-git-storage/src/snapshot.rs deleted file mode 100644 index 6bfe762b0..000000000 --- a/lib/crates/fabro-git-storage/src/snapshot.rs +++ /dev/null @@ -1,649 +0,0 @@ -use std::collections::BTreeMap; -use std::path::PathBuf; - -use git2::{Oid, Signature}; -use tracing::{debug, warn}; - -use crate::Result; -use crate::gitobj::{FileMode, Store, TreeEntries}; - -/// Options for writing a snapshot. -pub struct WriteOptions<'a> { - pub branch: String, - pub base_tree: Oid, - pub changes: FileChanges, - pub metadata: BTreeMap>, - pub metadata_from_disk: Option, - pub author: Signature<'a>, - pub message: String, - pub deduplicate: bool, -} - -/// File changes to apply from the working directory. -pub struct FileChanges { - pub modified: Vec, - pub new: Vec, - pub deleted: Vec, - pub repo_root: PathBuf, -} - -/// A directory on disk to walk and embed into the tree. -pub struct DiskDir { - pub disk_path: PathBuf, - pub tree_prefix: String, -} - -/// Result of a snapshot write. -pub struct WriteResult { - pub commit_oid: Oid, - pub tree_oid: Oid, - pub skipped: bool, -} - -/// Metadata about a snapshot commit. -#[derive(Debug)] -pub struct SnapshotInfo { - pub commit_oid: Oid, - pub tree_oid: Oid, - pub message: String, - pub time: git2::Time, -} - -/// Captures full repo-state on named branches. -pub struct SnapshotStore<'a> { - objects: &'a Store, -} - -impl<'a> SnapshotStore<'a> { - pub fn new(objects: &'a Store) -> Self { - Self { objects } - } - - /// Write a snapshot to a branch. - pub fn write(&self, opts: &WriteOptions<'_>) -> Result { - debug!(branch = %opts.branch, "Writing snapshot"); - // 1. Resolve existing branch tip or use base_tree - let (base_tree_oid, parent_oid) = match self.objects.resolve_ref(&opts.branch)? { - Some(commit_oid) => { - let commit = self.objects.repo().find_commit(commit_oid)?; - (commit.tree_id(), Some(commit_oid)) - } - None => (opts.base_tree, None), - }; - - // 2. Flatten base tree - let mut entries = self.objects.read_tree(base_tree_oid)?; - - // 3. Apply FileChanges - for path in &opts.changes.deleted { - entries.remove(path); - } - for path in opts.changes.modified.iter().chain(opts.changes.new.iter()) { - let full_path = opts.changes.repo_root.join(path); - match self.objects.write_blob_from_file(&full_path) { - Ok((oid, mode)) => { - entries.set(path.clone(), oid, mode); - } - Err(crate::Error::ReadFile { .. }) => { - // File disappeared since detection — treat as deleted - warn!(path = %path, "File disappeared since detection, treating as deleted"); - entries.remove(path); - } - Err(e) => return Err(e), - } - } - - // 4. Apply in-memory metadata - for (path, content) in &opts.metadata { - let oid = self.objects.write_blob(content)?; - entries.set(path.clone(), oid, FileMode::Blob); - } - - // 5. Walk metadata_from_disk - if let Some(disk_dir) = &opts.metadata_from_disk { - self.walk_disk_dir(&mut entries, disk_dir)?; - } - - // 6. Write tree - let new_tree_oid = self.objects.write_tree(&entries)?; - - // 7. Dedup check - if opts.deduplicate { - if let Some(parent) = parent_oid { - let parent_commit = self.objects.repo().find_commit(parent)?; - if parent_commit.tree_id() == new_tree_oid { - debug!(branch = %opts.branch, "Snapshot skipped (tree unchanged)"); - return Ok(WriteResult { - commit_oid: parent, - tree_oid: new_tree_oid, - skipped: true, - }); - } - } - } - - // 8. Create commit - let parents: Vec = parent_oid.into_iter().collect(); - let commit_oid = - self.objects - .write_commit(new_tree_oid, &parents, &opts.message, &opts.author)?; - - // 9. Update ref - self.objects.update_ref(&opts.branch, commit_oid)?; - debug!(branch = %opts.branch, commit = %commit_oid, "Snapshot written"); - - Ok(WriteResult { - commit_oid, - tree_oid: new_tree_oid, - skipped: false, - }) - } - - /// Tip commit of a snapshot branch. `None` if branch doesn't exist. - pub fn latest(&self, branch: &str) -> Result> { - let Some(commit_oid) = self.objects.resolve_ref(branch)? else { - return Ok(None); - }; - let commit = self.objects.repo().find_commit(commit_oid)?; - let tree_oid = commit.tree_id(); - let message = commit.message().unwrap_or("").to_string(); - let time = commit.author().when(); - Ok(Some(SnapshotInfo { - commit_oid, - tree_oid, - message, - time, - })) - } - - /// Read a single file from a snapshot commit's tree. - pub fn read_file(&self, commit_oid: Oid, path: &str) -> Result>> { - let commit = self.objects.repo().find_commit(commit_oid)?; - let tree = commit.tree()?; - match tree.get_path(std::path::Path::new(path)) { - Ok(entry) => { - let blob = self.objects.repo().find_blob(entry.id())?; - Ok(Some(blob.content().to_vec())) - } - Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Walk commits on a snapshot branch, newest first. - pub fn list_commits(&self, branch: &str, limit: usize) -> Result> { - let Some(commit_oid) = self.objects.resolve_ref(branch)? else { - return Ok(vec![]); - }; - let mut revwalk = self.objects.repo().revwalk()?; - revwalk.set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)?; - revwalk.push(commit_oid)?; - - let mut results = Vec::new(); - for oid_result in revwalk.take(limit) { - let oid = oid_result?; - let commit = self.objects.repo().find_commit(oid)?; - results.push(SnapshotInfo { - commit_oid: oid, - tree_oid: commit.tree_id(), - message: commit.message().unwrap_or("").to_string(), - time: commit.author().when(), - }); - } - Ok(results) - } - - /// Check if a snapshot branch exists. - pub fn exists(&self, branch: &str) -> Result { - Ok(self.objects.resolve_ref(branch)?.is_some()) - } - - /// Delete a snapshot branch. - pub fn delete(&self, branch: &str) -> Result<()> { - debug!(branch = %branch, "Deleting snapshot branch"); - self.objects.delete_ref(branch) - } - - /// Rename a snapshot branch. - pub fn rename(&self, old: &str, new: &str) -> Result<()> { - debug!(old = %old, new = %new, "Renaming snapshot branch"); - let oid = self - .objects - .resolve_ref(old)? - .ok_or_else(|| crate::Error::BranchNotFound { - branch: old.to_string(), - })?; - self.objects.update_ref(new, oid)?; - self.objects.delete_ref(old)?; - Ok(()) - } - - /// List snapshot branches matching a prefix. - pub fn list(&self, prefix: &str) -> Result> { - let full_prefix = format!("refs/heads/{prefix}"); - let mut branches = Vec::new(); - for reference in self - .objects - .repo() - .references_glob(&format!("{full_prefix}*"))? - { - let reference = reference?; - if let Some(name) = reference.name() { - if let Some(branch) = name.strip_prefix("refs/heads/") { - branches.push(branch.to_string()); - } - } - } - branches.sort(); - Ok(branches) - } - - /// Walk a directory on disk and add files to tree entries. - fn walk_disk_dir(&self, entries: &mut TreeEntries, disk_dir: &DiskDir) -> Result<()> { - let walker = walkdir::WalkDir::new(&disk_dir.disk_path) - .follow_links(false) - .into_iter() - .filter_map(std::result::Result::ok); - - for entry in walker { - // Skip symlinks - if entry.path_is_symlink() { - continue; - } - // Skip directories - if entry.file_type().is_dir() { - continue; - } - - let relative = entry - .path() - .strip_prefix(&disk_dir.disk_path) - .unwrap_or(entry.path()); - let tree_path = if disk_dir.tree_prefix.is_empty() { - relative.to_string_lossy().to_string() - } else { - format!( - "{}/{}", - disk_dir.tree_prefix.trim_end_matches('/'), - relative.to_string_lossy() - ) - }; - - let (oid, mode) = self.objects.write_blob_from_file(entry.path())?; - entries.set(tree_path, oid, mode); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use git2::Repository; - - fn temp_repo() -> (tempfile::TempDir, Store) { - let dir = tempfile::TempDir::new().unwrap(); - let repo = Repository::init(dir.path()).unwrap(); - (dir, Store::new(repo)) - } - - fn test_sig() -> Signature<'static> { - Signature::now("Test", "test@example.com").unwrap() - } - - fn empty_changes() -> FileChanges { - FileChanges { - modified: vec![], - new: vec![], - deleted: vec![], - repo_root: PathBuf::from("/tmp"), - } - } - - // -- write creates branch + commit from base tree -- - - #[test] - fn write_creates_branch_from_base_tree() { - let (_dir, store) = temp_repo(); - let sig = test_sig(); - let snap = SnapshotStore::new(&store); - - // Create a base tree with one file - let blob_oid = store.write_blob(b"base content").unwrap(); - let mut base_entries = TreeEntries::new(); - base_entries.set("existing.txt", blob_oid, FileMode::Blob); - let base_tree = store.write_tree(&base_entries).unwrap(); - - let result = snap - .write(&WriteOptions { - branch: "snap/test".to_string(), - base_tree, - changes: empty_changes(), - metadata: BTreeMap::new(), - metadata_from_disk: None, - author: sig, - message: "snapshot 1".to_string(), - deduplicate: false, - }) - .unwrap(); - - assert!(!result.skipped); - assert!(store.resolve_ref("snap/test").unwrap().is_some()); - - // Verify the file is in the snapshot - let content = snap.read_file(result.commit_oid, "existing.txt").unwrap(); - assert_eq!(content.unwrap(), b"base content"); - } - - // -- write applies file changes -- - - #[test] - fn write_applies_file_changes() { - let (dir, store) = temp_repo(); - let sig = test_sig(); - let snap = SnapshotStore::new(&store); - - // Create files on disk in the repo root - let repo_root = dir.path().to_path_buf(); - std::fs::write(repo_root.join("new_file.txt"), b"new content").unwrap(); - std::fs::write(repo_root.join("modified.txt"), b"modified content").unwrap(); - - // Create base tree with a file to delete and one to modify - let old_blob = store.write_blob(b"old content").unwrap(); - let delete_blob = store.write_blob(b"delete me").unwrap(); - let mut base_entries = TreeEntries::new(); - base_entries.set("modified.txt", old_blob, FileMode::Blob); - base_entries.set("to_delete.txt", delete_blob, FileMode::Blob); - let base_tree = store.write_tree(&base_entries).unwrap(); - - let result = snap - .write(&WriteOptions { - branch: "snap/changes".to_string(), - base_tree, - changes: FileChanges { - modified: vec!["modified.txt".to_string()], - new: vec!["new_file.txt".to_string()], - deleted: vec!["to_delete.txt".to_string()], - repo_root, - }, - metadata: BTreeMap::new(), - metadata_from_disk: None, - author: sig, - message: "apply changes".to_string(), - deduplicate: false, - }) - .unwrap(); - - assert_eq!( - snap.read_file(result.commit_oid, "modified.txt") - .unwrap() - .unwrap(), - b"modified content" - ); - assert_eq!( - snap.read_file(result.commit_oid, "new_file.txt") - .unwrap() - .unwrap(), - b"new content" - ); - assert!( - snap.read_file(result.commit_oid, "to_delete.txt") - .unwrap() - .is_none() - ); - } - - // -- write embeds in-memory metadata -- - - #[test] - fn write_embeds_metadata() { - let (_dir, store) = temp_repo(); - let sig = test_sig(); - let snap = SnapshotStore::new(&store); - let base_tree = store.write_empty_tree().unwrap(); - - let mut metadata = BTreeMap::new(); - metadata.insert( - ".meta/transcript.jsonl".to_string(), - b"line1\nline2".to_vec(), - ); - - let result = snap - .write(&WriteOptions { - branch: "snap/meta".to_string(), - base_tree, - changes: empty_changes(), - metadata, - metadata_from_disk: None, - author: sig, - message: "with metadata".to_string(), - deduplicate: false, - }) - .unwrap(); - - let content = snap - .read_file(result.commit_oid, ".meta/transcript.jsonl") - .unwrap() - .unwrap(); - assert_eq!(content, b"line1\nline2"); - } - - // -- write dedup skips when tree unchanged -- - - #[test] - fn write_dedup_skips_unchanged() { - let (_dir, store) = temp_repo(); - let snap = SnapshotStore::new(&store); - let base_tree = store.write_empty_tree().unwrap(); - let sig = test_sig(); - - // First write - let result1 = snap - .write(&WriteOptions { - branch: "snap/dedup".to_string(), - base_tree, - changes: empty_changes(), - metadata: BTreeMap::new(), - metadata_from_disk: None, - author: sig.clone(), - message: "first".to_string(), - deduplicate: true, - }) - .unwrap(); - assert!(!result1.skipped); - - // Second write with same content — should be skipped - let sig2 = test_sig(); - let result2 = snap - .write(&WriteOptions { - branch: "snap/dedup".to_string(), - base_tree, - changes: empty_changes(), - metadata: BTreeMap::new(), - metadata_from_disk: None, - author: sig2, - message: "second".to_string(), - deduplicate: true, - }) - .unwrap(); - assert!(result2.skipped); - assert_eq!(result2.commit_oid, result1.commit_oid); - } - - // -- latest / read_file / list_commits -- - - #[test] - fn latest_and_list_commits() { - let (_dir, store) = temp_repo(); - let snap = SnapshotStore::new(&store); - let base_tree = store.write_empty_tree().unwrap(); - - // Write two snapshots - let sig1 = test_sig(); - snap.write(&WriteOptions { - branch: "snap/history".to_string(), - base_tree, - changes: empty_changes(), - metadata: BTreeMap::from([("a.txt".to_string(), b"a".to_vec())]), - metadata_from_disk: None, - author: sig1, - message: "first".to_string(), - deduplicate: false, - }) - .unwrap(); - - let sig2 = test_sig(); - snap.write(&WriteOptions { - branch: "snap/history".to_string(), - base_tree, - changes: empty_changes(), - metadata: BTreeMap::from([("b.txt".to_string(), b"b".to_vec())]), - metadata_from_disk: None, - author: sig2, - message: "second".to_string(), - deduplicate: false, - }) - .unwrap(); - - let latest = snap.latest("snap/history").unwrap().unwrap(); - assert_eq!(latest.message, "second"); - - let commits = snap.list_commits("snap/history", 10).unwrap(); - assert_eq!(commits.len(), 2); - assert_eq!(commits[0].message, "second"); - assert_eq!(commits[1].message, "first"); - } - - #[test] - fn latest_nonexistent() { - let (_dir, store) = temp_repo(); - let snap = SnapshotStore::new(&store); - assert!(snap.latest("nonexistent").unwrap().is_none()); - } - - // -- exists / delete / rename / list -- - - #[test] - fn exists_and_delete() { - let (_dir, store) = temp_repo(); - let snap = SnapshotStore::new(&store); - let base_tree = store.write_empty_tree().unwrap(); - let sig = test_sig(); - - snap.write(&WriteOptions { - branch: "snap/del".to_string(), - base_tree, - changes: empty_changes(), - metadata: BTreeMap::new(), - metadata_from_disk: None, - author: sig, - message: "create".to_string(), - deduplicate: false, - }) - .unwrap(); - - assert!(snap.exists("snap/del").unwrap()); - snap.delete("snap/del").unwrap(); - assert!(!snap.exists("snap/del").unwrap()); - } - - #[test] - fn rename_branch() { - let (_dir, store) = temp_repo(); - let snap = SnapshotStore::new(&store); - let base_tree = store.write_empty_tree().unwrap(); - let sig = test_sig(); - - snap.write(&WriteOptions { - branch: "snap/old".to_string(), - base_tree, - changes: empty_changes(), - metadata: BTreeMap::from([("file.txt".to_string(), b"data".to_vec())]), - metadata_from_disk: None, - author: sig, - message: "create".to_string(), - deduplicate: false, - }) - .unwrap(); - - snap.rename("snap/old", "snap/new").unwrap(); - assert!(!snap.exists("snap/old").unwrap()); - assert!(snap.exists("snap/new").unwrap()); - - // Verify data is preserved - let info = snap.latest("snap/new").unwrap().unwrap(); - let content = snap.read_file(info.commit_oid, "file.txt").unwrap(); - assert_eq!(content.unwrap(), b"data"); - } - - #[test] - fn list_branches() { - let (_dir, store) = temp_repo(); - let snap = SnapshotStore::new(&store); - let base_tree = store.write_empty_tree().unwrap(); - - // Create several branches - for name in &["snap/a", "snap/b", "other/c"] { - let sig = test_sig(); - snap.write(&WriteOptions { - branch: name.to_string(), - base_tree, - changes: empty_changes(), - metadata: BTreeMap::new(), - metadata_from_disk: None, - author: sig, - message: "create".to_string(), - deduplicate: false, - }) - .unwrap(); - } - - let snap_branches = snap.list("snap/").unwrap(); - assert_eq!(snap_branches, vec!["snap/a", "snap/b"]); - } - - // -- metadata_from_disk -- - - #[test] - fn write_metadata_from_disk() { - let (_dir, store) = temp_repo(); - let snap = SnapshotStore::new(&store); - let base_tree = store.write_empty_tree().unwrap(); - - // Create a temp directory with files - let meta_dir = tempfile::TempDir::new().unwrap(); - std::fs::write(meta_dir.path().join("info.json"), b"{}").unwrap(); - std::fs::create_dir(meta_dir.path().join("sub")).unwrap(); - std::fs::write(meta_dir.path().join("sub/data.txt"), b"nested").unwrap(); - - let sig = test_sig(); - let result = snap - .write(&WriteOptions { - branch: "snap/disk".to_string(), - base_tree, - changes: empty_changes(), - metadata: BTreeMap::new(), - metadata_from_disk: Some(DiskDir { - disk_path: meta_dir.path().to_path_buf(), - tree_prefix: ".meta".to_string(), - }), - author: sig, - message: "from disk".to_string(), - deduplicate: false, - }) - .unwrap(); - - assert_eq!( - snap.read_file(result.commit_oid, ".meta/info.json") - .unwrap() - .unwrap(), - b"{}" - ); - assert_eq!( - snap.read_file(result.commit_oid, ".meta/sub/data.txt") - .unwrap() - .unwrap(), - b"nested" - ); - } -} diff --git a/lib/crates/fabro-retro/src/retro.rs b/lib/crates/fabro-retro/src/retro.rs index 6c07964b9..034085aa3 100644 --- a/lib/crates/fabro-retro/src/retro.rs +++ b/lib/crates/fabro-retro/src/retro.rs @@ -51,14 +51,16 @@ pub fn extract_stage_durations(run_dir: &Path) -> HashMap { let Ok(envelope) = serde_json::from_str::(line) else { continue; }; - if envelope.get("event").and_then(|v| v.as_str()) != Some("StageCompleted") { + if envelope.get("event").and_then(|v| v.as_str()) != Some("stage.completed") { continue; } let Some(name) = envelope.get("node_id").and_then(|v| v.as_str()) else { continue; }; let Some(duration_ms) = envelope - .get("duration_ms") + .get("properties") + .and_then(serde_json::Value::as_object) + .and_then(|properties| properties.get("duration_ms")) .and_then(serde_json::Value::as_u64) else { continue; diff --git a/lib/crates/fabro-retro/src/retro_agent.rs b/lib/crates/fabro-retro/src/retro_agent.rs index 84cbefb2b..10273c0f6 100644 --- a/lib/crates/fabro-retro/src/retro_agent.rs +++ b/lib/crates/fabro-retro/src/retro_agent.rs @@ -649,6 +649,7 @@ mod tests { event: AgentEvent::SessionStarted, timestamp: SystemTime::now(), session_id: "retro-test".into(), + parent_session_id: None, }) .unwrap(); diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 29f2d3e4e..4b9dbcefa 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -45,7 +45,7 @@ use crate::sessions::{SessionStore, new_session_store}; use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer}; use fabro_retro::RetroExt; use fabro_workflow::context::Context; -use fabro_workflow::event::{EventEmitter, WorkflowRunEvent}; +use fabro_workflow::event::{EventEmitter, RunEventEnvelope}; use fabro_workflow::operations::{self, CreateRunInput, WorkflowInput}; use fabro_workflow::pipeline::Persisted; use fabro_workflow::records::{Checkpoint, CheckpointExt}; @@ -92,7 +92,7 @@ struct ManagedRun { created_at: chrono::DateTime, // Populated when running: interviewer: Option>, - event_tx: Option>, + event_tx: Option>, context: Option, checkpoint: Option, cancel_tx: Option>, @@ -627,7 +627,7 @@ async fn execute_run(state: Arc, run_id: RunId) { // Create interviewer and event plumbing (this is the "provisioning" phase) let interviewer = Arc::new(WebInterviewer::new()); let context = Context::new(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::new(run_id); if let Some(tx_clone) = event_tx { emitter.on_event(move |event| { let _ = tx_clone.send(event.clone()); diff --git a/lib/crates/fabro-store/src/disk_projecting.rs b/lib/crates/fabro-store/src/disk_projecting.rs index f3ceaaea8..b81e0026a 100644 --- a/lib/crates/fabro-store/src/disk_projecting.rs +++ b/lib/crates/fabro-store/src/disk_projecting.rs @@ -512,6 +512,7 @@ mod tests { fn event_payload(run_id: &str, ts: &str, event: &str) -> EventPayload { EventPayload::new( serde_json::json!({ + "id": format!("evt-{run_id}-{event}"), "ts": ts, "run_id": test_run_id(run_id).to_string(), "event": event, diff --git a/lib/crates/fabro-store/src/memory.rs b/lib/crates/fabro-store/src/memory.rs index 950308bb1..a29357e12 100644 --- a/lib/crates/fabro-store/src/memory.rs +++ b/lib/crates/fabro-store/src/memory.rs @@ -986,6 +986,7 @@ mod tests { assert!(matches!(err, StoreError::InvalidEvent(_))); let invalid_run_id: EventPayload = serde_json::from_value(serde_json::json!({ + "id": "evt-invalid-run", "ts": "2026-03-27T12:00:00Z", "run_id": "other-run", "event": "StageStarted" @@ -1022,6 +1023,7 @@ mod tests { .unwrap(); let first = EventPayload::new( serde_json::json!({ + "id": "evt-1", "ts": "2026-03-27T12:00:00.000Z", "run_id": test_run_id("run-1").to_string(), "event": "WorkflowRunStarted" @@ -1031,6 +1033,7 @@ mod tests { .unwrap(); let second = EventPayload::new( serde_json::json!({ + "id": "evt-2", "ts": "2026-03-27T12:00:01.000Z", "run_id": test_run_id("run-1").to_string(), "event": "StageCompleted" diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 6ac67d6f6..ec6f78435 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -496,6 +496,7 @@ mod tests { fn event_payload(run_id: &str, ts: &str, event: &str) -> EventPayload { EventPayload::new( serde_json::json!({ + "id": format!("evt-{run_id}-{event}"), "ts": ts, "run_id": test_run_id(run_id).to_string(), "event": event diff --git a/lib/crates/fabro-store/src/types.rs b/lib/crates/fabro-store/src/types.rs index 9aa89df99..14dbe9097 100644 --- a/lib/crates/fabro-store/src/types.rs +++ b/lib/crates/fabro-store/src/types.rs @@ -81,7 +81,7 @@ impl EventPayload { StoreError::InvalidEvent("event payload must be a JSON object".into()) })?; - for field in ["ts", "run_id", "event"] { + for field in ["id", "ts", "run_id", "event"] { match obj.get(field) { Some(serde_json::Value::String(_)) => {} _ => { diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index 067713c88..47e990127 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -180,13 +180,6 @@ impl TestContext { cmd } - /// Build a `sandbox cp` subcommand. - pub fn cp(&self) -> Command { - let mut cmd = self.command(); - cmd.args(["sandbox", "cp"]); - cmd - } - /// Build an `init` subcommand. pub fn init_cmd(&self) -> Command { let mut cmd = self.command(); @@ -208,13 +201,6 @@ impl TestContext { cmd } - /// Build a `sandbox preview` subcommand. - pub fn preview(&self) -> Command { - let mut cmd = self.command(); - cmd.args(["sandbox", "preview"]); - cmd - } - /// Build a `repo` subcommand. pub fn repo(&self) -> Command { let mut cmd = self.command(); @@ -222,13 +208,6 @@ impl TestContext { cmd } - /// Build a `sandbox ssh` subcommand. - pub fn ssh(&self) -> Command { - let mut cmd = self.command(); - cmd.args(["sandbox", "ssh"]); - cmd - } - /// Build a `system` subcommand. pub fn system(&self) -> Command { let mut cmd = self.command(); @@ -277,6 +256,45 @@ impl TestContext { std::fs::write(&full, content).expect("failed to write file"); self } + + /// Find a run directory whose name ends with `run_id_suffix`. + pub fn find_run_dir(&self, run_id_suffix: &str) -> PathBuf { + let runs_dir = self.storage_dir.join("runs"); + std::fs::read_dir(&runs_dir) + .expect("runs directory should exist") + .flatten() + .map(|entry| entry.path()) + .find(|path| { + path.is_dir() + && path + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with(run_id_suffix)) + }) + .unwrap_or_else(|| { + panic!( + "expected run directory for {run_id_suffix} under {}", + runs_dir.display() + ) + }) + } + + /// Return the only run directory currently present under storage. + pub fn single_run_dir(&self) -> PathBuf { + let runs_dir = self.storage_dir.join("runs"); + let entries: Vec<_> = std::fs::read_dir(&runs_dir) + .expect("runs directory should exist") + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + assert_eq!( + entries.len(), + 1, + "expected exactly one run directory under {}", + runs_dir.display() + ); + entries.into_iter().next().unwrap() + } } /// Execute a command and format the output for snapshot testing. diff --git a/lib/crates/fabro-workflow/Cargo.toml b/lib/crates/fabro-workflow/Cargo.toml index f4df489a1..f70831eb9 100644 --- a/lib/crates/fabro-workflow/Cargo.toml +++ b/lib/crates/fabro-workflow/Cargo.toml @@ -30,7 +30,7 @@ fabro-mcp = { path = "../fabro-mcp" } fabro-github = { path = "../fabro-github" } fabro-interview = { path = "../fabro-interview" } fabro-util = { path = "../fabro-util" } -fabro-git-storage = { path = "../fabro-git-storage" } +fabro-checkpoint = { path = "../fabro-checkpoint" } fabro-llm = { path = "../fabro-llm" } fabro-model = { path = "../fabro-model" } fabro-retro = { path = "../fabro-retro" } diff --git a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs b/lib/crates/fabro-workflow/src/devcontainer_bridge.rs index 334a276a6..2c1cfeab9 100644 --- a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs +++ b/lib/crates/fabro-workflow/src/devcontainer_bridge.rs @@ -348,7 +348,7 @@ mod tests { #[tokio::test] async fn shell_command_executed() { let sandbox = TestSandbox::new(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())]; run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000) .await @@ -361,7 +361,7 @@ mod tests { #[tokio::test] async fn args_command_joins() { let sandbox = TestSandbox::new(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let commands = vec![fabro_devcontainer::Command::Args(vec![ "echo".to_string(), "hi".to_string(), @@ -380,8 +380,8 @@ mod tests { #[tokio::test] async fn emits_started_and_completed_events() { - let emitter = EventEmitter::new(); - let events = Arc::new(Mutex::new(Vec::new())); + let emitter = EventEmitter::default(); + let events = Arc::new(Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); emitter.on_event(move |event| { events_clone.lock().unwrap().push(event.clone()); @@ -392,28 +392,27 @@ mod tests { .await .unwrap(); let events = events.lock().unwrap(); - assert!(matches!( - &events[0], - WorkflowRunEvent::DevcontainerLifecycleStarted { phase, command_count } if phase == "on_create" && *command_count == 1 - )); - assert!(matches!( - &events[1], - WorkflowRunEvent::DevcontainerLifecycleCommandStarted { phase, index, .. } if phase == "on_create" && *index == 0 - )); - assert!(matches!( - &events[2], - WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { phase, index, exit_code, .. } if phase == "on_create" && *index == 0 && *exit_code == 0 - )); - assert!(matches!( - &events[3], - WorkflowRunEvent::DevcontainerLifecycleCompleted { phase, .. } if phase == "on_create" - )); + assert_eq!(events[0].event, "devcontainer.lifecycle.started"); + assert_eq!(events[0].properties["phase"], "on_create"); + assert_eq!(events[0].properties["command_count"], 1); + + assert_eq!(events[1].event, "devcontainer.lifecycle.command.started"); + assert_eq!(events[1].properties["phase"], "on_create"); + assert_eq!(events[1].properties["index"], 0); + + assert_eq!(events[2].event, "devcontainer.lifecycle.command.completed"); + assert_eq!(events[2].properties["phase"], "on_create"); + assert_eq!(events[2].properties["index"], 0); + assert_eq!(events[2].properties["exit_code"], 0); + + assert_eq!(events[3].event, "devcontainer.lifecycle.completed"); + assert_eq!(events[3].properties["phase"], "on_create"); } #[tokio::test] async fn failed_command_emits_failed_and_returns_error() { - let emitter = EventEmitter::new(); - let events = Arc::new(Mutex::new(Vec::new())); + let emitter = EventEmitter::default(); + let events = Arc::new(Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); emitter.on_event(move |event| { events_clone.lock().unwrap().push(event.clone()); @@ -424,15 +423,16 @@ mod tests { run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000).await; assert!(result.is_err()); let events = events.lock().unwrap(); - assert!(events.iter().any(|e| matches!( - e, - WorkflowRunEvent::DevcontainerLifecycleFailed { phase, exit_code, .. } if phase == "on_create" && *exit_code == 1 - ))); + assert!(events.iter().any(|event| { + event.event == "devcontainer.lifecycle.failed" + && event.properties["phase"] == "on_create" + && event.properties["exit_code"] == 1 + })); } #[tokio::test] async fn empty_commands_is_noop() { - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = Arc::new(Mutex::new(Vec::new())); let events_clone = Arc::clone(&events); emitter.on_event(move |event| { @@ -448,7 +448,7 @@ mod tests { #[tokio::test] async fn parallel_commands_run() { let sandbox = TestSandbox::new(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let mut map = HashMap::new(); map.insert("install".to_string(), "npm install".to_string()); map.insert("build".to_string(), "npm run build".to_string()); diff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/crates/fabro-workflow/src/error.rs index 5a8545d77..f4b51e2e8 100644 --- a/lib/crates/fabro-workflow/src/error.rs +++ b/lib/crates/fabro-workflow/src/error.rs @@ -343,12 +343,26 @@ impl From for FabroError { } } +impl From for FabroError { + fn from(err: fabro_checkpoint::MetadataError) -> Self { + let message = err.to_string(); + match err { + fabro_checkpoint::MetadataError::Deserialize { + entity: "checkpoint", + .. + } => Self::Checkpoint(message), + _ => Self::engine(message), + } + } +} + pub type Result = std::result::Result; #[cfg(test)] mod tests { use super::*; use crate::outcome::OutcomeExt; + use fabro_checkpoint::MetadataError; use fabro_llm::error::ProviderErrorDetail; #[test] @@ -419,6 +433,38 @@ mod tests { assert!(err.is_err()); } + #[test] + fn metadata_checkpoint_deserialize_error_preserves_source_detail() { + let source = serde_json::from_str::("not json").unwrap_err(); + let source_message = source.to_string(); + let fabro_error = FabroError::from(MetadataError::Deserialize { + entity: "checkpoint", + branch: "fabro/meta/run-1".to_string(), + source, + }); + + assert!(matches!(fabro_error, FabroError::Checkpoint(_))); + let message = fabro_error.to_string(); + assert!(message.contains("deserialize checkpoint on branch fabro/meta/run-1")); + assert!(message.contains(&source_message)); + } + + #[test] + fn metadata_non_checkpoint_deserialize_error_maps_to_engine_with_source_detail() { + let source = serde_json::from_str::("not json").unwrap_err(); + let source_message = source.to_string(); + let fabro_error = FabroError::from(MetadataError::Deserialize { + entity: "run record", + branch: "fabro/meta/run-1".to_string(), + source, + }); + + assert!(matches!(fabro_error, FabroError::Engine { .. })); + let message = fabro_error.to_string(); + assert!(message.contains("deserialize run record on branch fabro/meta/run-1")); + assert!(message.contains(&source_message)); + } + #[test] fn cancelled_error_display() { let err = FabroError::Cancelled; diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index d6f19bed7..ae29e606e 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -8,7 +8,9 @@ use chrono::{SecondsFormat, Utc}; use fabro_store::{EventPayload, RunStore}; use fabro_types::RunId; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use tokio::sync::{mpsc, oneshot}; +use uuid::Uuid; use crate::error::FabroError; use crate::outcome::{FailureDetail, StageUsage}; @@ -24,6 +26,23 @@ pub enum RunNoticeLevel { Error, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunEventEnvelope { + pub id: String, + pub ts: String, + pub run_id: String, + pub event: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_label: Option, + pub properties: serde_json::Value, +} + /// Events emitted during workflow run execution for observability. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum WorkflowRunEvent { @@ -206,6 +225,10 @@ pub enum WorkflowRunEvent { Agent { stage: String, event: AgentEvent, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parent_session_id: Option, }, SubgraphStarted { node_id: String, @@ -769,81 +792,376 @@ impl WorkflowRunEvent { } } -/// Flatten a `WorkflowRunEvent` into its event name and a map of top-level fields. -/// -/// Simple variants like `StageStarted` return `("StageStarted", {fields})`. -/// Wrapper variants use dot notation: -/// - `Agent { stage, event: ToolCallStarted { .. } }` → `"Agent.ToolCallStarted"` -/// - `Sandbox { event: Initializing { .. } }` → `"Sandbox.Initializing"` -/// - `Agent { stage, event: SubAgentEvent { event: inner, .. } }` → `"Agent.SubAgentEvent.{Inner}"` -/// with one level of flattening; deeper nesting stays as JSON. -pub fn flatten_event( - event: &WorkflowRunEvent, -) -> (String, serde_json::Map) { - let value = serde_json::to_value(event).expect("WorkflowRunEvent must serialize"); - let (event_name, mut fields) = match value { - serde_json::Value::Object(map) => { - // Externally-tagged enum: { "VariantName": { fields } } - let (variant_name, inner) = map.into_iter().next().expect("enum must have one key"); - match variant_name.as_str() { - "Agent" => flatten_agent(inner), - "Sandbox" => flatten_sandbox(inner), - _ => { - let fields = match inner { - serde_json::Value::Object(m) => m, - _ => serde_json::Map::new(), - }; - (variant_name, fields) +pub fn event_name(event: &WorkflowRunEvent) -> &'static str { + match event { + WorkflowRunEvent::WorkflowRunStarted { .. } => "run.started", + WorkflowRunEvent::WorkflowRunCompleted { .. } => "run.completed", + WorkflowRunEvent::WorkflowRunFailed { .. } => "run.failed", + WorkflowRunEvent::RunNotice { .. } => "run.notice", + WorkflowRunEvent::StageStarted { .. } => "stage.started", + WorkflowRunEvent::StageCompleted { .. } => "stage.completed", + WorkflowRunEvent::StageFailed { .. } => "stage.failed", + WorkflowRunEvent::StageRetrying { .. } => "stage.retrying", + WorkflowRunEvent::ParallelStarted { .. } => "parallel.started", + WorkflowRunEvent::ParallelBranchStarted { .. } => "parallel.branch.started", + WorkflowRunEvent::ParallelBranchCompleted { .. } => "parallel.branch.completed", + WorkflowRunEvent::ParallelCompleted { .. } => "parallel.completed", + WorkflowRunEvent::InterviewStarted { .. } => "interview.started", + WorkflowRunEvent::InterviewCompleted { .. } => "interview.completed", + WorkflowRunEvent::InterviewTimeout { .. } => "interview.timeout", + WorkflowRunEvent::CheckpointCompleted { .. } => "checkpoint.completed", + WorkflowRunEvent::CheckpointFailed { .. } => "checkpoint.failed", + WorkflowRunEvent::GitCommit { .. } => "git.commit", + WorkflowRunEvent::GitPush { .. } => "git.push", + WorkflowRunEvent::GitBranch { .. } => "git.branch", + WorkflowRunEvent::GitWorktreeAdd { .. } => "git.worktree.added", + WorkflowRunEvent::GitWorktreeRemove { .. } => "git.worktree.removed", + WorkflowRunEvent::GitFetch { .. } => "git.fetch", + WorkflowRunEvent::GitReset { .. } => "git.reset", + WorkflowRunEvent::EdgeSelected { .. } => "edge.selected", + WorkflowRunEvent::LoopRestart { .. } => "loop.restart", + WorkflowRunEvent::Prompt { .. } => "stage.prompt", + WorkflowRunEvent::Agent { event, .. } => match event { + AgentEvent::SessionStarted => "agent.session.started", + AgentEvent::SessionEnded => "agent.session.ended", + AgentEvent::ProcessingEnd => "agent.processing.end", + AgentEvent::UserInput { .. } => "agent.input", + AgentEvent::AssistantTextStart => "agent.output.start", + AgentEvent::AssistantOutputReplace { .. } => "agent.output.replace", + AgentEvent::AssistantMessage { .. } => "agent.message", + AgentEvent::TextDelta { .. } => "agent.text.delta", + AgentEvent::ReasoningDelta { .. } => "agent.reasoning.delta", + AgentEvent::ToolCallStarted { .. } => "agent.tool.started", + AgentEvent::ToolCallOutputDelta { .. } => "agent.tool.output.delta", + AgentEvent::ToolCallCompleted { .. } => "agent.tool.completed", + AgentEvent::Error { .. } => "agent.error", + AgentEvent::Warning { .. } => "agent.warning", + AgentEvent::LoopDetected => "agent.loop.detected", + AgentEvent::TurnLimitReached { .. } => "agent.turn.limit", + AgentEvent::SkillExpanded { .. } => "agent.skill.expanded", + AgentEvent::SteeringInjected { .. } => "agent.steering.injected", + AgentEvent::CompactionStarted { .. } => "agent.compaction.started", + AgentEvent::CompactionCompleted { .. } => "agent.compaction.completed", + AgentEvent::LlmRetry { .. } => "agent.llm.retry", + AgentEvent::SubAgentSpawned { .. } => "agent.sub.spawned", + AgentEvent::SubAgentCompleted { .. } => "agent.sub.completed", + AgentEvent::SubAgentFailed { .. } => "agent.sub.failed", + AgentEvent::SubAgentClosed { .. } => "agent.sub.closed", + AgentEvent::McpServerReady { .. } => "agent.mcp.ready", + AgentEvent::McpServerFailed { .. } => "agent.mcp.failed", + }, + WorkflowRunEvent::SubgraphStarted { .. } => "subgraph.started", + WorkflowRunEvent::SubgraphCompleted { .. } => "subgraph.completed", + WorkflowRunEvent::Sandbox { event } => match event { + SandboxEvent::Initializing { .. } => "sandbox.initializing", + SandboxEvent::Ready { .. } => "sandbox.ready", + SandboxEvent::InitializeFailed { .. } => "sandbox.failed", + SandboxEvent::CleanupStarted { .. } => "sandbox.cleanup.started", + SandboxEvent::CleanupCompleted { .. } => "sandbox.cleanup.completed", + SandboxEvent::CleanupFailed { .. } => "sandbox.cleanup.failed", + SandboxEvent::SnapshotPulling { .. } => "sandbox.snapshot.pulling", + SandboxEvent::SnapshotPulled { .. } => "sandbox.snapshot.pulled", + SandboxEvent::SnapshotEnsuring { .. } => "sandbox.snapshot.ensuring", + SandboxEvent::SnapshotCreating { .. } => "sandbox.snapshot.creating", + SandboxEvent::SnapshotReady { .. } => "sandbox.snapshot.ready", + SandboxEvent::SnapshotFailed { .. } => "sandbox.snapshot.failed", + SandboxEvent::GitCloneStarted { .. } => "sandbox.git.started", + SandboxEvent::GitCloneCompleted { .. } => "sandbox.git.completed", + SandboxEvent::GitCloneFailed { .. } => "sandbox.git.failed", + }, + WorkflowRunEvent::SandboxInitialized { .. } => "sandbox.initialized", + WorkflowRunEvent::SetupStarted { .. } => "setup.started", + WorkflowRunEvent::SetupCommandStarted { .. } => "setup.command.started", + WorkflowRunEvent::SetupCommandCompleted { .. } => "setup.command.completed", + WorkflowRunEvent::SetupCompleted { .. } => "setup.completed", + WorkflowRunEvent::SetupFailed { .. } => "setup.failed", + WorkflowRunEvent::StallWatchdogTimeout { .. } => "watchdog.timeout", + WorkflowRunEvent::AssetCaptured { .. } => "asset.captured", + WorkflowRunEvent::SshAccessReady { .. } => "ssh.ready", + WorkflowRunEvent::Failover { .. } => "agent.failover", + WorkflowRunEvent::CliEnsureStarted { .. } => "cli.ensure.started", + WorkflowRunEvent::CliEnsureCompleted { .. } => "cli.ensure.completed", + WorkflowRunEvent::CliEnsureFailed { .. } => "cli.ensure.failed", + WorkflowRunEvent::PullRequestCreated { .. } => "pull_request.created", + WorkflowRunEvent::PullRequestFailed { .. } => "pull_request.failed", + WorkflowRunEvent::DevcontainerResolved { .. } => "devcontainer.resolved", + WorkflowRunEvent::DevcontainerLifecycleStarted { .. } => "devcontainer.lifecycle.started", + WorkflowRunEvent::DevcontainerLifecycleCommandStarted { .. } => { + "devcontainer.lifecycle.command.started" + } + WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { .. } => { + "devcontainer.lifecycle.command.completed" + } + WorkflowRunEvent::DevcontainerLifecycleCompleted { .. } => { + "devcontainer.lifecycle.completed" + } + WorkflowRunEvent::DevcontainerLifecycleFailed { .. } => "devcontainer.lifecycle.failed", + WorkflowRunEvent::RetroStarted => "retro.started", + WorkflowRunEvent::RetroCompleted { .. } => "retro.completed", + WorkflowRunEvent::RetroFailed { .. } => "retro.failed", + } +} + +#[derive(Debug)] +struct EnvelopeFields { + session_id: Option, + parent_session_id: Option, + node_id: Option, + node_label: Option, + properties: Value, +} + +fn tagged_variant_fields(value: &T) -> Map { + tagged_variant_fields_from_value(serde_json::to_value(value).expect("serializable event")) +} + +fn tagged_variant_fields_from_value(value: Value) -> Map { + match value { + Value::Object(map) => { + let (_, inner) = map.into_iter().next().expect("enum must have one variant"); + match inner { + Value::Object(fields) => fields, + Value::String(_) | Value::Null => Map::new(), + other => { + let mut fields = Map::new(); + fields.insert("value".to_string(), other); + fields } } } - // Unit variants serialize as strings - serde_json::Value::String(name) => (name, serde_json::Map::new()), - _ => ("Unknown".to_string(), serde_json::Map::new()), - }; - rename_fields(&event_name, &mut fields); - (event_name, fields) -} - -pub fn build_event_envelope(event: &WorkflowRunEvent, run_id: &RunId) -> serde_json::Value { - let (event_name, event_fields) = flatten_event(event); - let mut envelope = serde_json::Map::new(); - envelope.insert( - "ts".to_string(), - serde_json::Value::String(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)), - ); - envelope.insert( - "run_id".to_string(), - serde_json::Value::String(run_id.to_string()), - ); - envelope.insert("event".to_string(), serde_json::Value::String(event_name)); - for (k, v) in event_fields { - if k != "ts" && k != "run_id" && k != "event" { - envelope.insert(k, v); + Value::String(_) | Value::Null => Map::new(), + other => { + let mut fields = Map::new(); + fields.insert("value".to_string(), other); + fields } } - serde_json::Value::Object(envelope) +} + +fn remove_string(fields: &mut Map, key: &str) -> Option { + match fields.remove(key) { + Some(Value::String(value)) => Some(value), + _ => None, + } +} + +fn flatten_failure_detail(fields: &mut Map) { + let Some(Value::Object(failure)) = fields.remove("failure") else { + return; + }; + if let Some(message) = failure.get("message").cloned() { + fields.insert("error".to_string(), message); + } + if let Some(failure_class) = failure.get("failure_class").cloned() { + fields.insert("failure_class".to_string(), failure_class); + } + if let Some(failure_signature) = failure.get("failure_signature").cloned() { + if !failure_signature.is_null() { + fields.insert("failure_signature".to_string(), failure_signature); + } + } +} + +fn default_node_label(node_id: &Option, node_label: Option) -> Option { + node_label.or_else(|| node_id.clone()) +} + +fn extract_envelope_fields(event: &WorkflowRunEvent) -> EnvelopeFields { + match event { + WorkflowRunEvent::WorkflowRunStarted { .. } => { + let mut fields = tagged_variant_fields(event); + fields.remove("run_id"); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id: None, + node_label: None, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::WorkflowRunFailed { error, .. } => { + let mut fields = tagged_variant_fields(event); + fields.insert("error".to_string(), Value::String(error.to_string())); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id: None, + node_label: None, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::StageCompleted { .. } | WorkflowRunEvent::StageFailed { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node_id"); + let node_label = default_node_label(&node_id, remove_string(&mut fields, "name")); + flatten_failure_detail(&mut fields); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::StageStarted { .. } | WorkflowRunEvent::StageRetrying { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node_id"); + let node_label = default_node_label(&node_id, remove_string(&mut fields, "name")); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::Agent { + session_id, + parent_session_id, + .. + } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "stage"); + let node_label = default_node_label(&node_id, None); + fields.remove("session_id"); + fields.remove("parent_session_id"); + let properties = fields.remove("event").map_or_else( + || Value::Object(Map::new()), + |value| Value::Object(tagged_variant_fields_from_value(value)), + ); + EnvelopeFields { + session_id: session_id.clone(), + parent_session_id: parent_session_id.clone(), + node_id, + node_label, + properties, + } + } + WorkflowRunEvent::Sandbox { .. } => { + let mut fields = tagged_variant_fields(event); + let properties = fields.remove("event").map_or_else( + || Value::Object(Map::new()), + |value| Value::Object(tagged_variant_fields_from_value(value)), + ); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id: None, + node_label: None, + properties, + } + } + WorkflowRunEvent::GitCommit { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node_id"); + let node_label = default_node_label(&node_id, None); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::ParallelBranchStarted { .. } + | WorkflowRunEvent::ParallelBranchCompleted { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "branch"); + let node_label = default_node_label(&node_id, None); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::Prompt { .. } + | WorkflowRunEvent::InterviewStarted { .. } + | WorkflowRunEvent::InterviewTimeout { .. } + | WorkflowRunEvent::Failover { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "stage"); + let node_label = default_node_label(&node_id, None); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::CheckpointCompleted { .. } + | WorkflowRunEvent::CheckpointFailed { .. } + | WorkflowRunEvent::SubgraphStarted { .. } + | WorkflowRunEvent::SubgraphCompleted { .. } + | WorkflowRunEvent::AssetCaptured { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node_id"); + let node_label = default_node_label(&node_id, remove_string(&mut fields, "name")); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + WorkflowRunEvent::StallWatchdogTimeout { .. } => { + let mut fields = tagged_variant_fields(event); + let node_id = remove_string(&mut fields, "node"); + let node_label = default_node_label(&node_id, None); + EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id, + node_label, + properties: Value::Object(fields), + } + } + _ => EnvelopeFields { + session_id: None, + parent_session_id: None, + node_id: None, + node_label: None, + properties: Value::Object(tagged_variant_fields(event)), + }, + } +} + +pub fn canonicalize_event(run_id: &RunId, event: &WorkflowRunEvent) -> RunEventEnvelope { + let fields = extract_envelope_fields(event); + RunEventEnvelope { + id: Uuid::now_v7().to_string(), + ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + run_id: run_id.to_string(), + event: event_name(event).to_string(), + session_id: fields.session_id, + parent_session_id: fields.parent_session_id, + node_id: fields.node_id, + node_label: fields.node_label, + properties: fields.properties, + } } pub fn build_redacted_event_payload( - event: &WorkflowRunEvent, + envelope: &RunEventEnvelope, run_id: &RunId, ) -> Result { - let envelope = build_event_envelope(event, run_id); - let line = serde_json::to_string(&envelope)?; - let line = redact_jsonl_line(&line); - let value = serde_json::from_str(&line).context("Failed to parse redacted event payload")?; - EventPayload::new(value, run_id).map_err(anyhow::Error::from) + let line = redacted_event_json(envelope)?; + event_payload_from_redacted_json(&line, run_id) } -pub fn append_progress_event( +pub fn append_progress_event(run_dir: &Path, envelope: &RunEventEnvelope) -> Result<()> { + let line = redacted_event_json(envelope)?; + append_progress_event_with_line(run_dir, envelope, &line) +} + +pub fn append_progress_event_with_line( run_dir: &Path, - run_id: &RunId, - event: &WorkflowRunEvent, + envelope: &RunEventEnvelope, + line: &str, ) -> Result<()> { - let envelope = build_event_envelope(event, run_id); - let line = serde_json::to_string(&envelope)?; - let line = redact_jsonl_line(&line); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) @@ -856,7 +1174,7 @@ pub fn append_progress_event( })?; writeln!(file, "{line}")?; - let pretty = serde_json::to_string_pretty(&envelope)?; + let pretty = serde_json::to_string_pretty(envelope)?; let pretty = redact_jsonl_line(&pretty); std::fs::write(run_dir.join("live.json"), pretty) .with_context(|| format!("Failed to write {}", run_dir.join("live.json").display()))?; @@ -864,32 +1182,32 @@ pub fn append_progress_event( Ok(()) } +pub fn redacted_event_json(envelope: &RunEventEnvelope) -> Result { + let line = serde_json::to_string(envelope)?; + Ok(redact_jsonl_line(&line)) +} + +pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result { + let value = serde_json::from_str(line).context("Failed to parse redacted event payload")?; + EventPayload::new(value, run_id).map_err(anyhow::Error::from) +} + pub struct ProgressLogger { run_dir: PathBuf, - run_id: RunId, } impl ProgressLogger { #[must_use] - pub fn new(run_dir: impl Into, run_id: RunId) -> Self { + pub fn new(run_dir: impl Into) -> Self { Self { run_dir: run_dir.into(), - run_id, } } pub fn register(self, emitter: &EventEmitter) { let run_dir = self.run_dir; - let run_id = Arc::new(std::sync::Mutex::new(self.run_id)); emitter.on_event(move |event| { - if let WorkflowRunEvent::WorkflowRunStarted { - run_id: started_run_id, - .. - } = event - { - (*run_id.lock().unwrap()).clone_from(started_run_id); - } - let _ = append_progress_event(&run_dir, &run_id.lock().unwrap(), event); + let _ = append_progress_event(&run_dir, event); }); } } @@ -902,12 +1220,11 @@ enum StoreProgressCommand { #[derive(Clone)] pub struct StoreProgressLogger { tx: mpsc::UnboundedSender, - run_id: Arc>, } impl StoreProgressLogger { #[must_use] - pub fn new(run_store: Arc, run_id: RunId) -> Self { + pub fn new(run_store: Arc) -> Self { let (tx, mut rx) = mpsc::unbounded_channel(); tokio::spawn(async move { @@ -925,25 +1242,16 @@ impl StoreProgressLogger { } }); - Self { - tx, - run_id: Arc::new(std::sync::Mutex::new(run_id)), - } + Self { tx } } pub fn register(&self, emitter: &EventEmitter) { let tx = self.tx.clone(); - let run_id = Arc::clone(&self.run_id); emitter.on_event(move |event| { - if let WorkflowRunEvent::WorkflowRunStarted { - run_id: started_run_id, - .. - } = event - { - (*run_id.lock().unwrap()).clone_from(started_run_id); - } - - let run_id = *run_id.lock().unwrap(); + let Ok(run_id) = event.run_id.parse::() else { + tracing::warn!(run_id = %event.run_id, "Invalid run id on event envelope"); + return; + }; match build_redacted_event_payload(event, &run_id) { Ok(payload) => { if tx.send(StoreProgressCommand::Event(payload)).is_err() { @@ -971,232 +1279,6 @@ impl StoreProgressLogger { } } -fn flatten_agent(inner: serde_json::Value) -> (String, serde_json::Map) { - let serde_json::Value::Object(mut agent_fields) = inner else { - return ("Agent".to_string(), serde_json::Map::new()); - }; - let stage = agent_fields.remove("stage"); - let agent_event = agent_fields - .remove("event") - .unwrap_or(serde_json::Value::Null); - - match agent_event { - serde_json::Value::Object(event_map) => { - let (inner_name, inner_value) = event_map - .into_iter() - .next() - .expect("agent event must have one key"); - if inner_name == "SubAgentEvent" { - flatten_sub_agent_event(stage, inner_value) - } else { - let mut fields = match inner_value { - serde_json::Value::Object(m) => m, - _ => serde_json::Map::new(), - }; - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - (format!("Agent.{inner_name}"), fields) - } - } - // Unit variant inside Agent (e.g. SessionStarted) - serde_json::Value::String(name) => { - let mut fields = serde_json::Map::new(); - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - (format!("Agent.{name}"), fields) - } - _ => { - let mut fields = serde_json::Map::new(); - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - ("Agent".to_string(), fields) - } - } -} - -fn flatten_sandbox( - inner: serde_json::Value, -) -> (String, serde_json::Map) { - let serde_json::Value::Object(mut sandbox_fields) = inner else { - return ("Sandbox".to_string(), serde_json::Map::new()); - }; - let sandbox_event = sandbox_fields - .remove("event") - .unwrap_or(serde_json::Value::Null); - - match sandbox_event { - serde_json::Value::Object(event_map) => { - let (inner_name, inner_value) = event_map - .into_iter() - .next() - .expect("sandbox event must have one key"); - let fields = match inner_value { - serde_json::Value::Object(m) => m, - _ => serde_json::Map::new(), - }; - (format!("Sandbox.{inner_name}"), fields) - } - serde_json::Value::String(name) => (format!("Sandbox.{name}"), serde_json::Map::new()), - _ => ("Sandbox".to_string(), serde_json::Map::new()), - } -} - -fn flatten_sub_agent_event( - stage: Option, - inner_value: serde_json::Value, -) -> (String, serde_json::Map) { - let serde_json::Value::Object(mut sub_fields) = inner_value else { - let mut fields = serde_json::Map::new(); - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - return ("Agent.SubAgentEvent".to_string(), fields); - }; - - // Extract the inner event name for dot notation, but keep full inner - // event as `nested_event` JSON to avoid field collisions when sub-agents - // are themselves nested (SubAgentEvent wrapping SubAgentEvent). - let nested_event = sub_fields - .remove("event") - .unwrap_or(serde_json::Value::Null); - let inner_name = match &nested_event { - serde_json::Value::Object(map) => map.keys().next().cloned(), - serde_json::Value::String(name) => Some(name.clone()), - _ => None, - }; - - let event_name = match &inner_name { - Some(name) => format!("Agent.SubAgentEvent.{name}"), - None => "Agent.SubAgentEvent".to_string(), - }; - - // Start with the SubAgentEvent's own fields (agent_id, depth) - let mut fields = sub_fields; - if let Some(s) = stage { - fields.insert("stage".to_string(), s); - } - fields.insert("nested_event".to_string(), nested_event); - - (event_name, fields) -} - -/// Rename flattened event fields for clarity in progress.jsonl output. -/// -/// Applied as a post-processing step after `flatten_event` serialization to -/// give fields self-describing names without changing the Rust enum. -fn rename_fields(event_name: &str, fields: &mut serde_json::Map) { - /// Move a key from `old` to `new` if present. - fn rename(fields: &mut serde_json::Map, old: &str, new: &str) { - if let Some(v) = fields.remove(old) { - fields.insert(new.to_string(), v); - } - } - - /// Insert `node_label` defaulting to the value of `node_id`, if not already present. - fn default_node_label(fields: &mut serde_json::Map) { - if !fields.contains_key("node_label") { - if let Some(id) = fields.get("node_id").cloned() { - fields.insert("node_label".to_string(), id); - } - } - } - - if event_name.starts_with("Stage") { - // name → node_label, index → stage_index, node_id stays - rename(fields, "name", "node_label"); - rename(fields, "index", "stage_index"); - // Flatten FailureDetail into top-level fields for backward compat - if let Some(serde_json::Value::Object(failure)) = fields.remove("failure") { - if let Some(msg) = failure.get("message") { - fields.insert("error".to_string(), msg.clone()); - fields.insert("failure_reason".to_string(), msg.clone()); - } - if let Some(fc) = failure.get("failure_class") { - fields.insert("failure_class".to_string(), fc.clone()); - } - if let Some(sig) = failure.get("failure_signature") { - if !sig.is_null() { - fields.insert("failure_signature".to_string(), sig.clone()); - } - } - } - // node_id already present from Rust enum - } else if event_name == "WorkflowRunFailed" { - // Flatten FabroError to a string for backward compat in progress.jsonl - if let Some(error_val) = fields.get("error") { - if error_val.is_object() { - // Extract the display message from the FabroError serde format - let display = error_val - .get("data") - .and_then(|d| { - // For struct variants (Handler/Engine): { "data": { "message": "..." } } - d.get("message").and_then(|m| m.as_str().map(String::from)) - }) - .or_else(|| { - // For newtype string variants: { "data": "..." } - error_val - .get("data") - .and_then(|d| d.as_str().map(String::from)) - }) - .unwrap_or_else(|| error_val.to_string()); - fields.insert("error".to_string(), serde_json::Value::String(display)); - } - } - } else if event_name == "WorkflowRunStarted" { - rename(fields, "name", "workflow_name"); - } else if event_name.starts_with("Agent.") || event_name == "Agent" { - rename(fields, "stage", "node_id"); - default_node_label(fields); - } else if event_name.starts_with("Sandbox.Snapshot") { - // Must check before generic Sandbox.* to catch Snapshot* first - rename(fields, "name", "snapshot_name"); - rename(fields, "provider", "sandbox_provider"); - } else if event_name.starts_with("Sandbox.") { - rename(fields, "provider", "sandbox_provider"); - } else if event_name.starts_with("ParallelBranch") { - rename(fields, "branch", "node_id"); - default_node_label(fields); - rename(fields, "index", "branch_index"); - } else if event_name.starts_with("SetupCommand") || event_name == "SetupFailed" { - rename(fields, "index", "command_index"); - } else if event_name == "EdgeSelected" || event_name == "LoopRestart" { - rename(fields, "from_node", "from_node_id"); - rename(fields, "to_node", "to_node_id"); - } else if event_name == "StallWatchdogTimeout" { - rename(fields, "node", "node_id"); - default_node_label(fields); - } else if event_name == "Prompt" { - rename(fields, "stage", "node_id"); - default_node_label(fields); - rename(fields, "text", "prompt_text"); - } else if event_name == "AssetCaptured" { - default_node_label(fields); - } else if event_name.starts_with("Interview") && event_name != "InterviewCompleted" { - // InterviewStarted, InterviewTimeout have `stage` - rename(fields, "stage", "node_id"); - default_node_label(fields); - } else if event_name == "SubgraphStarted" { - default_node_label(fields); - rename(fields, "start_node", "start_node_id"); - } else if event_name == "SubgraphCompleted" - || event_name == "CheckpointCompleted" - || event_name == "CheckpointFailed" - { - default_node_label(fields); - } else if event_name == "GitCommit" { - if fields.contains_key("node_id") { - default_node_label(fields); - } - } else if event_name.starts_with("DevcontainerLifecycleCommand") - || event_name == "DevcontainerLifecycleFailed" - { - rename(fields, "index", "command_index"); - } -} - /// Current time as epoch milliseconds. fn epoch_millis() -> i64 { let millis = std::time::SystemTime::now() @@ -1207,10 +1289,11 @@ fn epoch_millis() -> i64 { } /// Listener callback type for workflow run events. -type EventListener = Arc; +type EventListener = Arc; /// Callback-based event emitter for workflow run events. pub struct EventEmitter { + run_id: RunId, listeners: std::sync::Mutex>, /// Epoch milliseconds of the last `emit()` or `touch()` call. 0 until first event. last_event_at: AtomicI64, @@ -1220,6 +1303,7 @@ impl std::fmt::Debug for EventEmitter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let count = self.listeners.lock().map(|l| l.len()).unwrap_or(0); f.debug_struct("EventEmitter") + .field("run_id", &self.run_id) .field("listener_count", &count) .field("last_event_at", &self.last_event_at.load(Ordering::Relaxed)) .finish() @@ -1228,20 +1312,26 @@ impl std::fmt::Debug for EventEmitter { impl Default for EventEmitter { fn default() -> Self { - Self::new() + Self::new(RunId::new()) } } impl EventEmitter { #[must_use] - pub fn new() -> Self { + pub fn new(run_id: RunId) -> Self { Self { + run_id, listeners: std::sync::Mutex::new(Vec::new()), last_event_at: AtomicI64::new(0), } } - pub fn on_event(&self, listener: impl Fn(&WorkflowRunEvent) + Send + Sync + 'static) { + #[must_use] + pub fn run_id(&self) -> RunId { + self.run_id + } + + pub fn on_event(&self, listener: impl Fn(&RunEventEnvelope) + Send + Sync + 'static) { self.listeners .lock() .expect("listeners lock poisoned") @@ -1251,6 +1341,18 @@ impl EventEmitter { pub fn emit(&self, event: &WorkflowRunEvent) { self.last_event_at.store(epoch_millis(), Ordering::Relaxed); event.trace(); + if let WorkflowRunEvent::WorkflowRunStarted { run_id, .. } = event { + debug_assert_eq!( + *run_id, self.run_id, + "workflow run started event must match emitter run_id" + ); + } + let envelope = canonicalize_event(&self.run_id, event); + self.dispatch_envelope(&envelope); + } + + pub(crate) fn dispatch_envelope(&self, envelope: &RunEventEnvelope) { + self.last_event_at.store(epoch_millis(), Ordering::Relaxed); // Clone the listener list so we don't hold the lock during dispatch. // This prevents deadlocks if a listener calls emit() reentrantly. // Note: listeners added during this emit() won't receive the current event. @@ -1260,7 +1362,7 @@ impl EventEmitter { .expect("listeners lock poisoned") .clone(); for listener in &snapshot { - listener(event); + listener(&envelope); } } @@ -1295,27 +1397,22 @@ impl EventEmitter { #[cfg(test)] mod tests { use super::*; - use fabro_llm::types::Usage; use fabro_types::fixtures; use std::sync::{Arc, Mutex}; #[test] fn event_emitter_new_has_no_listeners() { - let emitter = EventEmitter::new(); + let emitter = EventEmitter::new(fixtures::RUN_1); assert_eq!(emitter.listeners.lock().unwrap().len(), 0); } #[test] - fn event_emitter_calls_listener() { - let emitter = EventEmitter::new(); + fn event_emitter_calls_listener_with_envelope() { + let emitter = EventEmitter::new(fixtures::RUN_1); let received = Arc::new(Mutex::new(Vec::new())); let received_clone = Arc::clone(&received); emitter.on_event(move |event| { - let name = match event { - WorkflowRunEvent::WorkflowRunStarted { name, .. } => name.clone(), - _ => "other".to_string(), - }; - received_clone.lock().unwrap().push(name); + received_clone.lock().unwrap().push(event.clone()); }); emitter.emit(&WorkflowRunEvent::WorkflowRunStarted { name: "test".to_string(), @@ -1328,39 +1425,9 @@ mod tests { }); let events = received.lock().unwrap(); assert_eq!(events.len(), 1); - assert_eq!(events[0], "test"); - } - - #[test] - fn workflow_run_event_serialization() { - let event = WorkflowRunEvent::StageStarted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - handler_type: Some("agent".to_string()), - script: None, - attempt: 1, - max_attempts: 3, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("StageStarted")); - assert!(json.contains("plan")); - assert!(json.contains("\"handler_type\":\"agent\"")); - assert!(json.contains("\"attempt\":1")); - assert!(json.contains("\"max_attempts\":3")); - - // None handler_type serializes as null - let event_none = WorkflowRunEvent::StageStarted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - handler_type: None, - script: None, - attempt: 1, - max_attempts: 1, - }; - let json_none = serde_json::to_string(&event_none).unwrap(); - assert!(json_none.contains("\"handler_type\":null")); + assert_eq!(events[0].event, "run.started"); + assert_eq!(events[0].run_id, fixtures::RUN_1.to_string()); + assert!(events[0].id.len() >= 32); } #[test] @@ -1370,1454 +1437,173 @@ mod tests { } #[test] - fn agent_event_wrapper_serialization() { - let event = WorkflowRunEvent::Agent { - stage: "plan".to_string(), - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".to_string(), - tool_call_id: "call_1".to_string(), - arguments: serde_json::json!({"path": "/tmp/test.txt"}), - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("Agent")); - assert!(json.contains("ToolCallStarted")); - assert!(json.contains("read_file")); - assert!(json.contains("plan")); - - // Verify round-trip - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Agent { stage, .. } if stage == "plan")); - } - - #[test] - fn agent_assistant_message_serialization() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::AssistantMessage { - text: "Here is the implementation".to_string(), - model: "claude-opus-4-6".to_string(), - usage: Usage { - input_tokens: 1000, - output_tokens: 500, - total_tokens: 1500, - cache_read_tokens: Some(800), - cache_write_tokens: Some(50), - reasoning_tokens: Some(100), - speed: None, - raw: None, - }, - tool_call_count: 3, - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("AssistantMessage")); - assert!(json.contains("claude-opus-4-6")); - assert!(json.contains("\"cache_read_tokens\":800")); - assert!(json.contains("\"reasoning_tokens\":100")); - - // Round-trip - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - match deserialized { - WorkflowRunEvent::Agent { - event: AgentEvent::AssistantMessage { usage, .. }, - .. - } => { - assert_eq!(usage.cache_read_tokens, Some(800)); - assert_eq!(usage.reasoning_tokens, Some(100)); - } - _ => panic!("expected Agent(AssistantMessage)"), - } - } - - #[test] - fn agent_assistant_message_without_cache_tokens_omits_them() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::AssistantMessage { - text: "response".to_string(), - model: "test-model".to_string(), - usage: Usage { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, - ..Default::default() - }, - tool_call_count: 0, - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(!json.contains("cache_read_tokens")); - assert!(!json.contains("reasoning_tokens")); - } - - #[test] - fn agent_assistant_output_replace_serialization() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("AssistantOutputReplace")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - match deserialized { - WorkflowRunEvent::Agent { - stage, - event: AgentEvent::AssistantOutputReplace { text, reasoning }, - } => { - assert_eq!(stage, "code"); - assert!(text.is_empty()); - assert_eq!(reasoning, None); - } - _ => panic!("expected Agent(AssistantOutputReplace)"), - } - } - - #[test] - fn stage_completed_event_serialization_with_new_fields() { - use crate::outcome::{FailureCategory, FailureDetail}; - - let event = WorkflowRunEvent::StageCompleted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - duration_ms: 1500, - status: "partial_success".to_string(), - preferred_label: None, - suggested_next_ids: vec![], - usage: None, - failure: Some(FailureDetail::new( - "lint errors remain", - FailureCategory::Deterministic, - )), - notes: Some("fixed 3 of 5 issues".to_string()), - files_touched: vec!["src/main.rs".to_string()], - attempt: 2, - max_attempts: 3, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("lint errors remain")); - assert!(json.contains("\"notes\":\"fixed 3 of 5 issues\"")); - assert!(json.contains("src/main.rs")); - assert!(json.contains("\"attempt\":2")); - assert!(json.contains("\"max_attempts\":3")); - - let event_none = WorkflowRunEvent::StageCompleted { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - duration_ms: 1500, - status: "success".to_string(), - preferred_label: None, - suggested_next_ids: vec![], - usage: None, - failure: None, - notes: None, - files_touched: vec![], - attempt: 1, - max_attempts: 1, - }; - let json_none = serde_json::to_string(&event_none).unwrap(); - assert!(json_none.contains("\"notes\":null")); - } - - #[test] - fn stage_failed_event_serialization() { - use crate::outcome::{FailureCategory, FailureDetail}; - - let event = WorkflowRunEvent::StageFailed { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - failure: FailureDetail { - message: "LLM request timed out".to_string(), - category: FailureCategory::TransientInfra, - signature: None, - }, - will_retry: true, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("LLM request timed out")); - assert!(json.contains("transient_infra")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::StageFailed { failure, .. } if failure.category == FailureCategory::TransientInfra - )); - - let event_terminal = WorkflowRunEvent::StageFailed { - node_id: "plan".to_string(), - name: "plan".to_string(), - index: 0, - failure: FailureDetail::new("timeout", FailureCategory::Deterministic), - will_retry: false, - }; - let json_terminal = serde_json::to_string(&event_terminal).unwrap(); - assert!(json_terminal.contains("deterministic")); - } - - #[test] - fn parallel_branch_completed_event_serialization() { - let event = WorkflowRunEvent::ParallelBranchCompleted { - branch: "branch_a".to_string(), - index: 0, - duration_ms: 1500, - status: "success".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"status\":\"success\"")); - assert!(!json.contains("\"success\":")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::ParallelBranchCompleted { status, .. } if status == "success") - ); - } - - #[test] - fn parallel_started_event_serialization() { - let event = WorkflowRunEvent::ParallelStarted { - branch_count: 3, - join_policy: "wait_all".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"join_policy\":\"wait_all\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::ParallelStarted { join_policy, .. } if join_policy == "wait_all") - ); - } - - #[test] - fn interview_started_event_serialization() { - let event = WorkflowRunEvent::InterviewStarted { - question: "Review changes?".to_string(), - stage: "gate".to_string(), - question_type: "multiple_choice".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"question_type\":\"multiple_choice\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::InterviewStarted { question_type, .. } if question_type == "multiple_choice") - ); - } - - #[test] - fn agent_compaction_event_serialization() { - let started = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::CompactionStarted { - estimated_tokens: 5000, - context_window_size: 8000, - }, - }; - let json = serde_json::to_string(&started).unwrap(); - assert!(json.contains("CompactionStarted")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Agent { stage, .. } if stage == "code")); - - let completed = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::CompactionCompleted { - original_turn_count: 20, - preserved_turn_count: 6, - summary_token_estimate: 500, - tracked_file_count: 3, - }, - }; - let json = serde_json::to_string(&completed).unwrap(); - assert!(json.contains("CompactionCompleted")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Agent { stage, .. } if stage == "code")); - } - - #[test] - fn edge_selected_event_serialization() { - let event = WorkflowRunEvent::EdgeSelected { - from_node: "plan".to_string(), - to_node: "code".to_string(), - label: Some("success".to_string()), - condition: Some("outcome == 'success'".to_string()), - reason: "condition".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "success".to_string(), - is_jump: false, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("EdgeSelected")); - assert!(json.contains("\"from_node\":\"plan\"")); - assert!(json.contains("\"to_node\":\"code\"")); - assert!(json.contains("\"label\":\"success\"")); - assert!(json.contains("\"condition\":\"outcome == 'success'\"")); - assert!(json.contains("\"reason\":\"condition\"")); - assert!(json.contains("\"stage_status\":\"success\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::EdgeSelected { from_node, to_node, .. } if from_node == "plan" && to_node == "code") - ); - - // None label/condition - let event_none = WorkflowRunEvent::EdgeSelected { - from_node: "a".to_string(), - to_node: "b".to_string(), - label: None, - condition: None, - reason: "unconditional".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "success".to_string(), - is_jump: false, - }; - let json_none = serde_json::to_string(&event_none).unwrap(); - assert!(json_none.contains("\"label\":null")); - assert!(json_none.contains("\"condition\":null")); - } - - #[test] - fn loop_restart_event_serialization() { - let event = WorkflowRunEvent::LoopRestart { - from_node: "review".to_string(), - to_node: "code".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("LoopRestart")); - assert!(json.contains("\"from_node\":\"review\"")); - assert!(json.contains("\"to_node\":\"code\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::LoopRestart { from_node, to_node } if from_node == "review" && to_node == "code") - ); - } - - #[test] - fn stage_retrying_event_serialization() { - let event = WorkflowRunEvent::StageRetrying { - node_id: "lint".to_string(), - name: "lint".to_string(), - index: 2, - attempt: 3, - max_attempts: 5, - delay_ms: 400, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("StageRetrying")); - assert!(json.contains("\"attempt\":3")); - assert!(json.contains("\"max_attempts\":5")); - assert!(json.contains("\"delay_ms\":400")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::StageRetrying { - max_attempts: 5, - .. - } - )); - } - - #[test] - fn agent_llm_retry_event_serialization() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::LlmRetry { - provider: "anthropic".to_string(), - model: "claude-opus-4-6".to_string(), - attempt: 2, - delay_secs: 1.5, - error: fabro_llm::error::SdkError::Network { - message: "rate limited".to_string(), - source: None, - }, - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("LlmRetry")); - assert!(json.contains("\"provider\":\"anthropic\"")); - assert!(json.contains("\"delay_secs\":1.5")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Agent { stage, .. } if stage == "code")); - } - - #[test] - fn subgraph_started_event_serialization() { - let event = WorkflowRunEvent::SubgraphStarted { - node_id: "sub_1".to_string(), - start_node: "start".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("SubgraphStarted")); - assert!(json.contains("\"node_id\":\"sub_1\"")); - assert!(json.contains("\"start_node\":\"start\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::SubgraphStarted { node_id, .. } if node_id == "sub_1") - ); - } - - #[test] - fn subgraph_completed_event_serialization() { - let event = WorkflowRunEvent::SubgraphCompleted { - node_id: "sub_1".to_string(), - steps_executed: 5, - status: "success".to_string(), - duration_ms: 3200, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("SubgraphCompleted")); - assert!(json.contains("\"steps_executed\":5")); - assert!(json.contains("\"duration_ms\":3200")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::SubgraphCompleted { - steps_executed: 5, - .. - } - )); - } - - #[test] - fn sandbox_event_wrapper_serialization() { - use fabro_agent::SandboxEvent; - - let event = WorkflowRunEvent::Sandbox { - event: SandboxEvent::Initializing { - provider: "docker".into(), - }, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("Sandbox")); - assert!(json.contains("Initializing")); - assert!(json.contains("docker")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::Sandbox { .. })); - } - - #[test] - fn emitter_last_event_at_initially_zero() { - let emitter = EventEmitter::new(); - assert_eq!(emitter.last_event_at(), 0); - } - - #[test] - fn emitter_last_event_at_updates_after_emit() { - let emitter = EventEmitter::new(); - assert_eq!(emitter.last_event_at(), 0); - emitter.emit(&WorkflowRunEvent::WorkflowRunStarted { - name: "test".to_string(), - run_id: fixtures::RUN_1, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: None, - }); - assert!(emitter.last_event_at() > 0); - } - - #[test] - fn emitter_touch_updates_last_event_at() { - let emitter = EventEmitter::new(); - assert_eq!(emitter.last_event_at(), 0); - emitter.touch(); - assert!(emitter.last_event_at() > 0); - } - - #[test] - fn stall_watchdog_timeout_serialization() { - let event = WorkflowRunEvent::StallWatchdogTimeout { - node: "work".to_string(), - idle_seconds: 600, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("StallWatchdogTimeout")); - assert!(json.contains("\"node\":\"work\"")); - assert!(json.contains("\"idle_seconds\":600")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::StallWatchdogTimeout { node, idle_seconds } if node == "work" && idle_seconds == 600) - ); - } - - #[test] - fn serde_round_trip_asset_captured() { - let event = WorkflowRunEvent::AssetCaptured { - node_id: "work".to_string(), - attempt: 2, - node_slug: "work-visit_3".to_string(), - path: "coverage/lcov.info".to_string(), - mime: "application/octet-stream".to_string(), - content_md5: "abc123".to_string(), - content_sha256: "def456".to_string(), - bytes: 512, - }; - let json = serde_json::to_string(&event).unwrap(); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::AssetCaptured { node_slug, bytes, .. } - if node_slug == "work-visit_3" && bytes == 512 - )); - } - - #[test] - fn flatten_event_simple_variant() { - let event = WorkflowRunEvent::StageStarted { - node_id: "plan".to_string(), - name: "Plan Stage".to_string(), - index: 0, - handler_type: Some("agent".to_string()), - script: None, - attempt: 1, - max_attempts: 3, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "StageStarted"); - assert_eq!(fields["node_id"], "plan"); - assert_eq!(fields["node_label"], "Plan Stage"); - assert_eq!(fields["stage_index"], 0); - assert_eq!(fields["handler_type"], "agent"); - assert_eq!(fields["attempt"], 1); - assert_eq!(fields["max_attempts"], 3); - // Old keys should not be present - assert!(!fields.contains_key("name")); - assert!(!fields.contains_key("index")); - } - - #[test] - fn flatten_event_agent_tool_call_started() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".to_string(), - tool_call_id: "call_1".to_string(), - arguments: serde_json::json!({"path": "/tmp/test.txt"}), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.ToolCallStarted"); - assert_eq!(fields["node_id"], "code"); - assert_eq!(fields["node_label"], "code"); - assert_eq!(fields["tool_name"], "read_file"); - assert_eq!(fields["tool_call_id"], "call_1"); - assert!(!fields.contains_key("stage")); - } - - #[test] - fn flatten_event_sandbox_initializing() { - let event = WorkflowRunEvent::Sandbox { - event: SandboxEvent::Initializing { - provider: "docker".into(), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Sandbox.Initializing"); - assert_eq!(fields["sandbox_provider"], "docker"); - assert!(!fields.contains_key("provider")); - } - - #[test] - fn flatten_event_agent_sub_agent_event() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::SubAgentEvent { - agent_id: "sub_1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "write_file".to_string(), - tool_call_id: "call_2".to_string(), - arguments: serde_json::json!({}), - }), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.SubAgentEvent.ToolCallStarted"); - assert_eq!(fields["node_id"], "code"); - assert_eq!(fields["node_label"], "code"); - assert_eq!(fields["agent_id"], "sub_1"); - assert_eq!(fields["depth"], 1); - assert!(!fields.contains_key("stage")); - // Inner event preserved as nested_event JSON (not flattened) - let nested = fields["nested_event"].as_object().unwrap(); - let tool_call = nested["ToolCallStarted"].as_object().unwrap(); - assert_eq!(tool_call["tool_name"], "write_file"); - } - - #[test] - fn flatten_event_doubly_nested_sub_agent_preserves_all_data() { - let event = WorkflowRunEvent::Agent { - stage: "code".to_string(), - event: AgentEvent::SubAgentEvent { - agent_id: "sub_1".to_string(), - depth: 1, - event: Box::new(AgentEvent::SubAgentEvent { - agent_id: "sub_2".to_string(), - depth: 2, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "read_file".to_string(), - tool_call_id: "call_3".to_string(), - arguments: serde_json::json!({}), - }), - }), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.SubAgentEvent.SubAgentEvent"); - // Outer SubAgentEvent fields at top level - assert_eq!(fields["agent_id"], "sub_1"); - assert_eq!(fields["depth"], 1); - assert_eq!(fields["node_id"], "code"); - assert_eq!(fields["node_label"], "code"); - assert!(!fields.contains_key("stage")); - // Inner SubAgentEvent preserved in nested_event with all data intact - let nested = fields["nested_event"].as_object().unwrap(); - let inner_sub = nested["SubAgentEvent"].as_object().unwrap(); - assert_eq!(inner_sub["agent_id"], "sub_2"); - assert_eq!(inner_sub["depth"], 2); - let inner_event = inner_sub["event"].as_object().unwrap(); - let tool_call = inner_event["ToolCallStarted"].as_object().unwrap(); - assert_eq!(tool_call["tool_name"], "read_file"); - } - - #[test] - fn flatten_event_agent_session_started() { - let event = WorkflowRunEvent::Agent { - stage: "plan".to_string(), - event: AgentEvent::SessionStarted, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.SessionStarted"); - assert_eq!(fields["node_id"], "plan"); - assert_eq!(fields["node_label"], "plan"); - assert!(!fields.contains_key("stage")); - } - - #[test] - fn flatten_event_agent_assistant_output_replace() { - let event = WorkflowRunEvent::Agent { - stage: "plan".to_string(), - event: AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Agent.AssistantOutputReplace"); - assert_eq!(fields["node_id"], "plan"); - assert_eq!(fields["node_label"], "plan"); - assert_eq!(fields["text"], ""); - assert!(!fields.contains_key("stage")); - } - - #[test] - fn rename_fields_workflow_run_started() { - let event = WorkflowRunEvent::WorkflowRunStarted { - name: "my_pipeline".to_string(), - run_id: fixtures::RUN_1, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: None, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "WorkflowRunStarted"); - assert_eq!(fields["workflow_name"], "my_pipeline"); - assert!(!fields.contains_key("name")); - } - - #[test] - fn rename_fields_parallel_branch_started() { - let event = WorkflowRunEvent::ParallelBranchStarted { - branch: "lint".to_string(), - index: 0, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "ParallelBranchStarted"); - assert_eq!(fields["node_id"], "lint"); - assert_eq!(fields["node_label"], "lint"); - assert_eq!(fields["branch_index"], 0); - assert!(!fields.contains_key("branch")); - assert!(!fields.contains_key("index")); - } - - #[test] - fn rename_fields_parallel_branch_completed() { - let event = WorkflowRunEvent::ParallelBranchCompleted { - branch: "lint".to_string(), - index: 0, - duration_ms: 1000, - status: "success".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "ParallelBranchCompleted"); - assert_eq!(fields["node_id"], "lint"); - assert_eq!(fields["node_label"], "lint"); - assert_eq!(fields["branch_index"], 0); - } - - #[test] - fn rename_fields_setup_command_started() { - let event = WorkflowRunEvent::SetupCommandStarted { - command: "npm install".to_string(), - index: 2, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "SetupCommandStarted"); - assert_eq!(fields["command_index"], 2); - assert!(!fields.contains_key("index")); - } - - #[test] - fn rename_fields_setup_failed() { - let event = WorkflowRunEvent::SetupFailed { - command: "npm test".to_string(), - index: 1, - exit_code: 1, - stderr: "fail".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "SetupFailed"); - assert_eq!(fields["command_index"], 1); - assert!(!fields.contains_key("index")); - } - - #[test] - fn rename_fields_edge_selected() { - let event = WorkflowRunEvent::EdgeSelected { - from_node: "plan".to_string(), - to_node: "code".to_string(), - label: Some("success".to_string()), - condition: None, - reason: "preferred_label".to_string(), - preferred_label: Some("success".to_string()), - suggested_next_ids: Vec::new(), - stage_status: "success".to_string(), - is_jump: false, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "EdgeSelected"); - assert_eq!(fields["from_node_id"], "plan"); - assert_eq!(fields["to_node_id"], "code"); - assert!(!fields.contains_key("from_node")); - assert!(!fields.contains_key("to_node")); - } - - #[test] - fn rename_fields_loop_restart() { - let event = WorkflowRunEvent::LoopRestart { - from_node: "review".to_string(), - to_node: "code".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "LoopRestart"); - assert_eq!(fields["from_node_id"], "review"); - assert_eq!(fields["to_node_id"], "code"); - } - - #[test] - fn rename_fields_stall_watchdog_timeout() { - let event = WorkflowRunEvent::StallWatchdogTimeout { - node: "work".to_string(), - idle_seconds: 600, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "StallWatchdogTimeout"); - assert_eq!(fields["node_id"], "work"); - assert_eq!(fields["node_label"], "work"); - assert!(!fields.contains_key("node")); - } - - #[test] - fn rename_fields_prompt() { - let event = WorkflowRunEvent::Prompt { - stage: "gate".to_string(), - text: "Approve?".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Prompt"); - assert_eq!(fields["node_id"], "gate"); - assert_eq!(fields["node_label"], "gate"); - assert_eq!(fields["prompt_text"], "Approve?"); - assert!(!fields.contains_key("stage")); - assert!(!fields.contains_key("text")); - } - - #[test] - fn rename_fields_asset_captured() { - let event = WorkflowRunEvent::AssetCaptured { - node_id: "test_stage".to_string(), - attempt: 1, - node_slug: "test_stage".to_string(), - path: "test-results/report.xml".to_string(), - mime: "text/xml".to_string(), - content_md5: "d41d8cd98f00b204e9800998ecf8427e".to_string(), - content_sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - .to_string(), - bytes: 1024, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "AssetCaptured"); - assert_eq!(fields["node_id"], "test_stage"); - assert_eq!(fields["node_label"], "test_stage"); - assert_eq!(fields["path"], "test-results/report.xml"); - assert_eq!(fields["bytes"], 1024); - } - - #[test] - fn rename_fields_interview_started() { - let event = WorkflowRunEvent::InterviewStarted { - question: "OK?".to_string(), - stage: "gate".to_string(), - question_type: "yes_no".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "InterviewStarted"); - assert_eq!(fields["node_id"], "gate"); - assert_eq!(fields["node_label"], "gate"); - assert!(!fields.contains_key("stage")); - } - - #[test] - fn rename_fields_subgraph_started() { - let event = WorkflowRunEvent::SubgraphStarted { - node_id: "sub_1".to_string(), - start_node: "start".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "SubgraphStarted"); - assert_eq!(fields["node_id"], "sub_1"); - assert_eq!(fields["node_label"], "sub_1"); - assert_eq!(fields["start_node_id"], "start"); - assert!(!fields.contains_key("start_node")); - } - - #[test] - fn rename_fields_checkpoint_completed() { - let event = WorkflowRunEvent::CheckpointCompleted { - node_id: "work".to_string(), - status: "success".to_string(), - git_commit_sha: Some("abc123".to_string()), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "CheckpointCompleted"); - assert_eq!(fields["node_id"], "work"); - assert_eq!(fields["node_label"], "work"); - - // Without git_commit_sha - let event_no_git = WorkflowRunEvent::CheckpointCompleted { - node_id: "plan".to_string(), - status: "success".to_string(), - git_commit_sha: None, - }; - let json = serde_json::to_string(&event_no_git).unwrap(); - assert!(!json.contains("git_commit_sha")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::CheckpointCompleted { - git_commit_sha: None, - .. - } - )); - } - - #[test] - fn rename_fields_checkpoint_failed() { - let event = WorkflowRunEvent::CheckpointFailed { - node_id: "fix_lints".to_string(), - error: "git add failed (exit 1): fatal: not a git repository".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "CheckpointFailed"); - assert_eq!(fields["node_id"], "fix_lints"); - assert_eq!(fields["node_label"], "fix_lints"); - assert_eq!( - fields["error"], - "git add failed (exit 1): fatal: not a git repository" - ); - } - - #[test] - fn rename_fields_git_commit_with_node_id() { - let event = WorkflowRunEvent::GitCommit { - node_id: Some("work".to_string()), - sha: "abc123".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "GitCommit"); - assert_eq!(fields["node_id"], "work"); - assert_eq!(fields["node_label"], "work"); - assert_eq!(fields["sha"], "abc123"); - } - - #[test] - fn rename_fields_git_commit_without_node_id() { - let event = WorkflowRunEvent::GitCommit { - node_id: None, - sha: "abc123".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "GitCommit"); - assert!(!fields.contains_key("node_label")); - assert_eq!(fields["sha"], "abc123"); - } - - #[test] - fn git_commit_serialization() { - let event = WorkflowRunEvent::GitCommit { - node_id: Some("work".to_string()), - sha: "abc123".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitCommit")); - assert!(json.contains("\"sha\":\"abc123\"")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::GitCommit { sha, .. } if sha == "abc123")); - - // node_id None is omitted - let event_none = WorkflowRunEvent::GitCommit { - node_id: None, - sha: "def456".to_string(), - }; - let json_none = serde_json::to_string(&event_none).unwrap(); - assert!(!json_none.contains("node_id")); - } - - #[test] - fn git_push_serialization() { - let event = WorkflowRunEvent::GitPush { - branch: "fabro/run/123".to_string(), - success: true, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitPush")); - assert!(json.contains("\"success\":true")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::GitPush { success: true, .. } - )); - } - - #[test] - fn git_branch_serialization() { - let event = WorkflowRunEvent::GitBranch { - branch: "fabro/run/123/work".to_string(), - sha: "abc123".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitBranch")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::GitBranch { branch, .. } if branch == "fabro/run/123/work") - ); - } - - #[test] - fn git_worktree_add_serialization() { - let event = WorkflowRunEvent::GitWorktreeAdd { - path: "/tmp/wt".to_string(), - branch: "work".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitWorktreeAdd")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::GitWorktreeAdd { path, .. } if path == "/tmp/wt") - ); - } - - #[test] - fn git_worktree_remove_serialization() { - let event = WorkflowRunEvent::GitWorktreeRemove { - path: "/tmp/wt".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitWorktreeRemove")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::GitWorktreeRemove { path } if path == "/tmp/wt") - ); - } - - #[test] - fn git_fetch_serialization() { - let event = WorkflowRunEvent::GitFetch { - branch: "main".to_string(), - success: false, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitFetch")); - assert!(json.contains("\"success\":false")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::GitFetch { success: false, .. } - )); - } - - #[test] - fn git_reset_serialization() { - let event = WorkflowRunEvent::GitReset { - sha: "abc123".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("GitReset")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::GitReset { sha } if sha == "abc123")); - } - - #[test] - fn rename_fields_sandbox_snapshot_pulling() { - let event = WorkflowRunEvent::Sandbox { - event: SandboxEvent::SnapshotPulling { - name: "base-image".into(), - }, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "Sandbox.SnapshotPulling"); - assert_eq!(fields["snapshot_name"], "base-image"); - assert!(!fields.contains_key("name")); - } - - #[test] - fn cli_ensure_events_serialization() { - let events = vec![ - WorkflowRunEvent::CliEnsureStarted { - cli_name: "claude".into(), - provider: "anthropic".into(), - }, - WorkflowRunEvent::CliEnsureCompleted { - cli_name: "claude".into(), - provider: "anthropic".into(), - already_installed: false, - node_installed: true, - duration_ms: 45000, - }, - WorkflowRunEvent::CliEnsureFailed { - cli_name: "codex".into(), - provider: "openai".into(), - error: "npm install failed".into(), - duration_ms: 30000, - }, - ]; - - for event in &events { - let json = serde_json::to_string(event).unwrap(); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - let json2 = serde_json::to_string(&deserialized).unwrap(); - assert_eq!(json, json2); - } - } - - #[test] - fn setup_events_serialization() { - let events = vec![ - WorkflowRunEvent::SetupStarted { command_count: 3 }, - WorkflowRunEvent::SetupCommandStarted { - command: "npm install".into(), + fn canonicalize_stage_completed_places_node_fields_in_envelope() { + let envelope = canonicalize_event( + &fixtures::RUN_2, + &WorkflowRunEvent::StageCompleted { + node_id: "plan".to_string(), + name: "Plan".to_string(), index: 0, - }, - WorkflowRunEvent::SetupCommandCompleted { - command: "npm install".into(), - index: 0, - exit_code: 0, duration_ms: 5000, + status: "success".to_string(), + preferred_label: None, + suggested_next_ids: Vec::new(), + usage: None, + failure: None, + notes: None, + files_touched: Vec::new(), + attempt: 1, + max_attempts: 1, }, - WorkflowRunEvent::SetupCompleted { duration_ms: 8000 }, - WorkflowRunEvent::SetupFailed { - command: "npm test".into(), - index: 1, - exit_code: 1, - stderr: "test failed".into(), - }, - ]; - - for event in &events { - let json = serde_json::to_string(event).unwrap(); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - let json2 = serde_json::to_string(&deserialized).unwrap(); - assert_eq!(json, json2); - } - } - - #[test] - fn pull_request_created_event_serialization() { - let event = WorkflowRunEvent::PullRequestCreated { - pr_url: "https://github.com/owner/repo/pull/42".to_string(), - pr_number: 42, - draft: true, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("PullRequestCreated")); - assert!(json.contains("\"pr_number\":42")); - assert!(json.contains("\"draft\":true")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::PullRequestCreated { - pr_number: 42, - draft: true, - .. - } - )); - } - - #[test] - fn pull_request_failed_event_serialization() { - let event = WorkflowRunEvent::PullRequestFailed { - error: "auth failed".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("PullRequestFailed")); - assert!(json.contains("\"error\":\"auth failed\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::PullRequestFailed { error } if error == "auth failed") ); + + assert_eq!(envelope.event, "stage.completed"); + assert_eq!(envelope.run_id, fixtures::RUN_2.to_string()); + assert_eq!(envelope.node_id.as_deref(), Some("plan")); + assert_eq!(envelope.node_label.as_deref(), Some("Plan")); + assert_eq!(envelope.properties["duration_ms"], 5000); + assert_eq!(envelope.properties["status"], "success"); + assert!(envelope.session_id.is_none()); } #[test] - fn run_notice_event_serialization() { - let event = WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Warn, - code: "sandbox_cleanup_failed".to_string(), - message: "sandbox cleanup failed: boom".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("RunNotice")); - assert!(json.contains("\"level\":\"warn\"")); + fn canonicalize_stage_failure_flattens_failure_detail() { + let envelope = canonicalize_event( + &fixtures::RUN_3, + &WorkflowRunEvent::StageFailed { + node_id: "code".to_string(), + name: "Code".to_string(), + index: 1, + failure: FailureDetail::new( + "lint failed", + crate::outcome::FailureCategory::Deterministic, + ), + will_retry: true, + }, + ); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::RunNotice { + assert_eq!(envelope.event, "stage.failed"); + assert_eq!(envelope.properties["error"], "lint failed"); + assert_eq!(envelope.properties["failure_class"], "deterministic"); + assert_eq!(envelope.properties["will_retry"], true); + assert!(envelope.properties.get("failure").is_none()); + } + + #[test] + fn canonicalize_agent_tool_started_moves_session_metadata_to_envelope() { + let envelope = canonicalize_event( + &fixtures::RUN_4, + &WorkflowRunEvent::Agent { + stage: "code".to_string(), + event: AgentEvent::ToolCallStarted { + tool_name: "read_file".to_string(), + tool_call_id: "call_1".to_string(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }, + session_id: Some("ses_child".to_string()), + parent_session_id: Some("ses_parent".to_string()), + }, + ); + + assert_eq!(envelope.event, "agent.tool.started"); + assert_eq!(envelope.node_id.as_deref(), Some("code")); + assert_eq!(envelope.node_label.as_deref(), Some("code")); + assert_eq!(envelope.session_id.as_deref(), Some("ses_child")); + assert_eq!(envelope.parent_session_id.as_deref(), Some("ses_parent")); + assert_eq!(envelope.properties["tool_name"], "read_file"); + assert_eq!(envelope.properties["tool_call_id"], "call_1"); + } + + #[test] + fn canonicalize_sandbox_event_keeps_properties_nested() { + let envelope = canonicalize_event( + &fixtures::RUN_5, + &WorkflowRunEvent::Sandbox { + event: SandboxEvent::Ready { + provider: "daytona".to_string(), + duration_ms: 2500, + name: Some("sandbox-1".to_string()), + cpu: Some(4.0), + memory: Some(8.0), + url: Some("https://example.test".to_string()), + }, + }, + ); + + assert_eq!(envelope.event, "sandbox.ready"); + assert!(envelope.node_id.is_none()); + assert_eq!(envelope.properties["provider"], "daytona"); + assert_eq!(envelope.properties["duration_ms"], 2500); + } + + #[test] + fn canonicalize_workflow_failure_flattens_error_display() { + let envelope = canonicalize_event( + &fixtures::RUN_6, + &WorkflowRunEvent::WorkflowRunFailed { + error: FabroError::handler("boom"), + duration_ms: 900, + git_commit_sha: Some("abc123".to_string()), + }, + ); + + assert_eq!(envelope.event, "run.failed"); + assert_eq!(envelope.properties["error"], "Handler error: boom"); + assert_eq!(envelope.properties["duration_ms"], 900); + } + + #[test] + fn append_progress_event_writes_envelope_shape() { + let dir = tempfile::tempdir().unwrap(); + let envelope = canonicalize_event( + &fixtures::RUN_7, + &WorkflowRunEvent::RunNotice { level: RunNoticeLevel::Warn, - code, - .. - } if code == "sandbox_cleanup_failed" - )); + code: "example".to_string(), + message: "notice".to_string(), + }, + ); + + append_progress_event(dir.path(), &envelope).unwrap(); + + let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap(); + let line: serde_json::Value = serde_json::from_str(progress.trim()).unwrap(); + assert!(line.get("id").is_some()); + assert_eq!(line["event"], "run.notice"); + assert_eq!(line["properties"]["code"], "example"); } #[test] - fn flatten_event_run_notice() { - let event = WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Error, - code: "bootstrap_failed".to_string(), - message: "working directory missing".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "RunNotice"); - assert_eq!(fields.get("level").and_then(|v| v.as_str()), Some("error")); + fn build_redacted_event_payload_requires_id() { + let envelope = canonicalize_event(&fixtures::RUN_8, &WorkflowRunEvent::RetroStarted); + + let payload = build_redacted_event_payload(&envelope, &fixtures::RUN_8).unwrap(); + assert_eq!(payload.as_value()["id"], envelope.id); + assert_eq!(payload.as_value()["event"], "retro.started"); + } + + #[test] + fn event_name_matches_new_dot_notation() { + assert_eq!(event_name(&WorkflowRunEvent::RetroStarted), "retro.started"); assert_eq!( - fields.get("code").and_then(|v| v.as_str()), - Some("bootstrap_failed") - ); - } - - #[test] - fn workflow_run_completed_serialization_with_status_and_usage() { - let event = WorkflowRunEvent::WorkflowRunCompleted { - duration_ms: 30000, - artifact_count: 2, - status: "success".to_string(), - total_cost: Some(1.23), - final_git_commit_sha: Some("abc123".to_string()), - usage: Some(Usage { - input_tokens: 5000, - output_tokens: 2000, - total_tokens: 7000, - cache_read_tokens: Some(3000), - cache_write_tokens: Some(500), - reasoning_tokens: Some(800), - speed: None, - raw: None, + event_name(&WorkflowRunEvent::ParallelBranchStarted { + branch: "fork".to_string(), + index: 0, }), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"status\":\"success\"")); - assert!(json.contains("\"total_tokens\":7000")); - assert!(json.contains("\"cache_read_tokens\":3000")); - assert!(json.contains("\"reasoning_tokens\":800")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::WorkflowRunCompleted { status, usage: Some(u), .. } - if status == "success" && u.total_tokens == 7000 - )); - } - - #[test] - fn workflow_run_completed_backward_compat_without_new_fields() { - // Old JSONL without status/usage should deserialize with defaults - let json = - r#"{"WorkflowRunCompleted":{"duration_ms":5000,"artifact_count":1,"total_cost":0.25}}"#; - let deserialized: WorkflowRunEvent = serde_json::from_str(json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::WorkflowRunCompleted { status, usage, .. } - if status.is_empty() && usage.is_none() - )); - } - - #[test] - fn devcontainer_resolved_serializes() { - let event = WorkflowRunEvent::DevcontainerResolved { - dockerfile_lines: 15, - environment_count: 3, - lifecycle_command_count: 5, - workspace_folder: "/workspaces/myrepo".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("DevcontainerResolved")); - assert!(json.contains("\"dockerfile_lines\":15")); - assert!(json.contains("\"workspace_folder\":\"/workspaces/myrepo\"")); - - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::DevcontainerResolved { - dockerfile_lines: 15, - .. - } - )); - } - - #[test] - fn devcontainer_lifecycle_events_serialization() { - let events = vec![ - WorkflowRunEvent::DevcontainerLifecycleStarted { - phase: "on_create".into(), - command_count: 2, - }, - WorkflowRunEvent::DevcontainerLifecycleCommandStarted { - phase: "on_create".into(), - command: "npm install".into(), - index: 0, - }, - WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { - phase: "on_create".into(), - command: "npm install".into(), - index: 0, - exit_code: 0, - duration_ms: 5000, - }, - WorkflowRunEvent::DevcontainerLifecycleCompleted { - phase: "on_create".into(), - duration_ms: 8000, - }, - WorkflowRunEvent::DevcontainerLifecycleFailed { - phase: "post_create".into(), - command: "npm test".into(), - index: 1, - exit_code: 1, - stderr: "test failed".into(), - }, - ]; - - for event in &events { - let json = serde_json::to_string(event).unwrap(); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - let json2 = serde_json::to_string(&deserialized).unwrap(); - assert_eq!(json, json2); - } - } - - #[test] - fn flatten_devcontainer_lifecycle_command_renames_index() { - let event = WorkflowRunEvent::DevcontainerLifecycleCommandStarted { - phase: "on_create".into(), - command: "npm install".into(), - index: 2, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "DevcontainerLifecycleCommandStarted"); - assert_eq!(fields["command_index"], 2); - assert!(!fields.contains_key("index")); - } - - #[test] - fn flatten_devcontainer_lifecycle_failed_renames_index() { - let event = WorkflowRunEvent::DevcontainerLifecycleFailed { - phase: "post_create".into(), - command: "npm test".into(), - index: 1, - exit_code: 1, - stderr: "fail".into(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "DevcontainerLifecycleFailed"); - assert_eq!(fields["command_index"], 1); - assert!(!fields.contains_key("index")); - } - - #[test] - fn workflow_run_started_with_goal_round_trip() { - let event = WorkflowRunEvent::WorkflowRunStarted { - name: "my_workflow".to_string(), - run_id: fixtures::RUN_42, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: Some("Fix the bug".to_string()), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"goal\":\"Fix the bug\"")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!( - matches!(deserialized, WorkflowRunEvent::WorkflowRunStarted { goal: Some(g), .. } if g == "Fix the bug") + "parallel.branch.started" ); - } - - #[test] - fn workflow_run_started_without_goal_backward_compat() { - // Old JSONL without `goal` field should deserialize to `goal: None` - let json = - r#"{"WorkflowRunStarted":{"name":"old_wf","run_id":"00000000000000000000000001"}}"#; - let deserialized: WorkflowRunEvent = serde_json::from_str(json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::WorkflowRunStarted { goal: None, .. } - )); - } - - #[test] - fn workflow_run_started_goal_none_omitted_from_json() { - let event = WorkflowRunEvent::WorkflowRunStarted { - name: "wf".to_string(), - run_id: fixtures::RUN_1, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: None, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!( - !json.contains("goal"), - "goal: None should be skipped, got: {json}" + assert_eq!( + event_name(&WorkflowRunEvent::Agent { + stage: "code".to_string(), + event: AgentEvent::SubAgentSpawned { + agent_id: "a1".to_string(), + depth: 1, + task: "do it".to_string(), + }, + session_id: None, + parent_session_id: None, + }), + "agent.sub.spawned" ); } - - #[test] - fn retro_started_event_serialization() { - let event = WorkflowRunEvent::RetroStarted; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("RetroStarted")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::RetroStarted)); - } - - #[test] - fn retro_completed_event_serialization() { - let event = WorkflowRunEvent::RetroCompleted { duration_ms: 5000 }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"duration_ms\":5000")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::RetroCompleted { duration_ms: 5000 } - )); - } - - #[test] - fn retro_failed_event_serialization() { - let event = WorkflowRunEvent::RetroFailed { - error: "LLM timeout".to_string(), - duration_ms: 3000, - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("LLM timeout")); - assert!(json.contains("\"duration_ms\":3000")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, WorkflowRunEvent::RetroFailed { .. })); - } - - #[test] - fn flatten_retro_started() { - let event = WorkflowRunEvent::RetroStarted; - let (name, _fields) = flatten_event(&event); - assert_eq!(name, "RetroStarted"); - } - - #[test] - fn flatten_retro_failed() { - let event = WorkflowRunEvent::RetroFailed { - error: "timeout".to_string(), - duration_ms: 1000, - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "RetroFailed"); - assert_eq!(fields["error"], "timeout"); - assert_eq!(fields["duration_ms"], 1000); - } - - #[test] - fn emitter_captures_retro_events() { - let emitter = EventEmitter::new(); - let received = Arc::new(Mutex::new(Vec::new())); - let r = Arc::clone(&received); - emitter.on_event(move |event| { - if let WorkflowRunEvent::RetroStarted = event { - r.lock().unwrap().push("started".to_string()); - } - if let WorkflowRunEvent::RetroCompleted { .. } = event { - r.lock().unwrap().push("completed".to_string()); - } - }); - emitter.emit(&WorkflowRunEvent::RetroStarted); - emitter.emit(&WorkflowRunEvent::RetroCompleted { duration_ms: 100 }); - let events = received.lock().unwrap(); - assert_eq!(events.len(), 2); - assert_eq!(events[0], "started"); - assert_eq!(events[1], "completed"); - } - - #[test] - fn sandbox_initialized_event_serialization() { - let event = WorkflowRunEvent::SandboxInitialized { - working_directory: "/workspace/project".to_string(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("SandboxInitialized")); - assert!(json.contains("/workspace/project")); - let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap(); - assert!(matches!( - deserialized, - WorkflowRunEvent::SandboxInitialized { - working_directory - } if working_directory == "/workspace/project" - )); - } - - #[test] - fn flatten_sandbox_initialized() { - let event = WorkflowRunEvent::SandboxInitialized { - working_directory: "/workspace".to_string(), - }; - let (name, fields) = flatten_event(&event); - assert_eq!(name, "SandboxInitialized"); - assert_eq!(fields["working_directory"], "/workspace"); - } } diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index 6d0134746..53d593e58 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -1,77 +1,20 @@ -use std::fmt::Write; use std::path::Path; use std::process::Command; +use fabro_checkpoint::git::Store; use fabro_config::FabroSettings; -use fabro_config::server::GitAuthorSettings; -use fabro_git_storage::branchstore::BranchStore; -use fabro_git_storage::gitobj::Store; -use git2::{Repository, Signature}; use crate::error::{FabroError, Result}; -use crate::records::{Checkpoint, RunRecord, StartRecord}; use tokio::task::{JoinError, spawn_blocking}; use tokio::time::timeout; +pub use fabro_checkpoint::META_BRANCH_PREFIX; +pub use fabro_checkpoint::author::GitAuthor; +pub use fabro_checkpoint::metadata::MetadataStore; + /// Branch prefix for workflow run branches (e.g. `fabro/run/{run_id}`). pub const RUN_BRANCH_PREFIX: &str = "fabro/run/"; -/// Branch prefix for metadata branches (e.g. `fabro/meta/{run_id}`). -pub const META_BRANCH_PREFIX: &str = "fabro/meta/"; - -/// Resolved git author identity for checkpoint commits. -#[derive(Debug, Clone, PartialEq)] -pub struct GitAuthor { - pub name: String, - pub email: String, -} - -impl Default for GitAuthor { - fn default() -> Self { - Self { - name: "Fabro".into(), - email: "noreply@fabro.sh".into(), - } - } -} - -impl GitAuthor { - /// Create a `GitAuthor` from optional name/email, falling back to defaults. - pub fn from_options(name: Option, email: Option) -> Self { - let defaults = Self::default(); - Self { - name: name.unwrap_or(defaults.name), - email: email.unwrap_or(defaults.email), - } - } - - /// Returns true when this identity matches the default Fabro identity. - pub fn is_default(&self) -> bool { - let defaults = Self::default(); - self.name == defaults.name && self.email == defaults.email - } - - /// Append the Fabro footer (and Co-Authored-By when the author is not the - /// default identity) to a commit message. - pub fn append_footer(&self, message: &mut String) { - message.push_str("\n\u{2692}\u{fe0f} Generated with [Fabro](https://fabro.sh)\n"); - if !self.is_default() { - let defaults = Self::default(); - let _ = write!( - message, - "\nCo-Authored-By: {} <{}>\n", - defaults.name, defaults.email - ); - } - } -} - -impl From<&GitAuthorSettings> for GitAuthor { - fn from(value: &GitAuthorSettings) -> Self { - Self::from_options(value.name.clone(), value.email.clone()) - } -} - pub fn git_author_from_settings(settings: &FabroSettings) -> GitAuthor { settings .git_author() @@ -409,160 +352,11 @@ pub fn scan_node_files(run_dir: &Path) -> Vec<(String, Vec)> { result } -/// Git-native metadata storage for pipeline runs. -/// -/// Stores checkpoint data, run records, and metadata on an orphan branch -/// (`fabro/meta/{run_id}`) so that runs can be resumed from git alone. -pub struct MetadataStore { - repo_path: std::path::PathBuf, - author: GitAuthor, -} - -impl MetadataStore { - pub fn new(repo_path: impl Into, author: &GitAuthor) -> Self { - Self { - repo_path: repo_path.into(), - author: author.clone(), - } - } - - /// Returns the branch name for a run: `fabro/meta/{run_id}`. - pub fn branch_name(run_id: &str) -> String { - format!("{META_BRANCH_PREFIX}{run_id}") - } - - /// Format a commit message with the standard Fabro footer appended. - fn commit_message(&self, subject: &str) -> String { - let mut msg = format!("{subject}\n"); - self.author.append_footer(&mut msg); - msg - } - - fn open_store(&self) -> Result<(Store, Signature<'static>)> { - let repo = Repository::discover(&self.repo_path) - .map_err(|e| git_error(format!("failed to open repo: {e}")))?; - let store = Store::new(repo); - let sig = Signature::now(&self.author.name, &self.author.email) - .map_err(|e| git_error(format!("failed to create signature: {e}")))?; - Ok((store, sig)) - } - - /// Initialize a run's metadata branch with the given files. - /// - /// Callers pass all files (run.json, start.json, sandbox.json, etc.) - /// via the `files` slice. - pub fn init_run(&self, run_id: &str, files: &[(&str, &[u8])]) -> Result<()> { - let (store, sig) = self.open_store()?; - let branch = Self::branch_name(run_id); - let bs = BranchStore::new(&store, &branch, &sig); - bs.ensure_branch() - .map_err(|e| git_error(format!("ensure_branch failed: {e}")))?; - let msg = self.commit_message("init run"); - bs.write_entries(files, &msg) - .map_err(|e| git_error(format!("write_entries failed: {e}")))?; - Ok(()) - } - - /// Write arbitrary files to the metadata branch without overwriting checkpoint.json. - pub fn write_files( - &self, - run_id: &str, - entries: &[(&str, &[u8])], - message: &str, - ) -> Result<()> { - let (store, sig) = self.open_store()?; - let branch = Self::branch_name(run_id); - let bs = BranchStore::new(&store, &branch, &sig); - let msg = self.commit_message(message); - bs.write_entries(entries, &msg) - .map_err(|e| git_error(format!("write_entries failed: {e}")))?; - Ok(()) - } - - /// Write checkpoint data (and optional artifacts) to the metadata branch. - /// Returns the SHA of the new commit on the shadow branch. - pub fn write_checkpoint( - &self, - run_id: &str, - checkpoint_json: &[u8], - artifacts: &[(&str, &[u8])], - ) -> Result { - let (store, sig) = self.open_store()?; - let branch = Self::branch_name(run_id); - let bs = BranchStore::new(&store, &branch, &sig); - let mut entries: Vec<(&str, &[u8])> = vec![("checkpoint.json", checkpoint_json)]; - entries.extend_from_slice(artifacts); - let msg = self.commit_message("checkpoint"); - let oid = bs - .write_entries(&entries, &msg) - .map_err(|e| git_error(format!("write_entries failed: {e}")))?; - Ok(oid.to_string()) - } - - /// Read a single file from the metadata branch. Returns `None` if branch or path doesn't exist. - fn read_file(repo_path: &Path, run_id: &str, path: &str) -> Result>> { - let Ok(repo) = Repository::discover(repo_path) else { - return Ok(None); - }; - let store = Store::new(repo); - let sig = Signature::now("Fabro", "noreply@fabro.sh") - .map_err(|e| git_error(format!("failed to create signature: {e}")))?; - let branch = Self::branch_name(run_id); - let bs = BranchStore::new(&store, &branch, &sig); - bs.read_entry(path) - .map_err(|e| git_error(format!("read_entry failed: {e}"))) - } - - /// Read a checkpoint from the metadata branch. Returns `None` if branch or file doesn't exist. - pub fn read_checkpoint(repo_path: &Path, run_id: &str) -> Result> { - match Self::read_file(repo_path, run_id, "checkpoint.json")? { - Some(bytes) => { - let cp: Checkpoint = serde_json::from_slice(&bytes) - .map_err(|e| FabroError::Checkpoint(format!("deserialize failed: {e}")))?; - Ok(Some(cp)) - } - None => Ok(None), - } - } - - /// Read the run record from the metadata branch. Returns `None` if not found. - pub fn read_run_record(repo_path: &Path, run_id: &str) -> Result> { - match Self::read_file(repo_path, run_id, "run.json")? { - Some(bytes) => { - let record: RunRecord = serde_json::from_slice(&bytes) - .map_err(|e| git_error(format!("run record deserialize failed: {e}")))?; - Ok(Some(record)) - } - None => Ok(None), - } - } - - /// Read the start record from the metadata branch. Returns `None` if not found. - pub fn read_start_record(repo_path: &Path, run_id: &str) -> Result> { - match Self::read_file(repo_path, run_id, "start.json")? { - Some(bytes) => { - let record: StartRecord = serde_json::from_slice(&bytes) - .map_err(|e| git_error(format!("start record deserialize failed: {e}")))?; - Ok(Some(record)) - } - None => Ok(None), - } - } - - /// Read an artifact from the metadata branch. Returns `None` if not found. - pub fn read_artifact(repo_path: &Path, run_id: &str, key: &str) -> Result>> { - Self::read_file(repo_path, run_id, &format!("artifacts/{key}.json")) - } -} - #[cfg(test)] mod tests { use super::*; - use fabro_types::fixtures; use std::fs; - use crate::records::{CheckpointExt, RunRecordExt}; - /// Create a temporary git repo with an initial commit. fn init_repo(dir: &Path) { Command::new("git") @@ -647,142 +441,6 @@ mod tests { assert!(!wt_path.exists()); } - // --- MetadataStore tests --- - - #[test] - fn metadata_store_init_run_and_read() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()); - - let store = MetadataStore::new(dir.path(), &GitAuthor::default()); - let run_id = fixtures::RUN_1.to_string(); - let run_record = format!( - r#"{{"run_id":"{run_id}","created_at":"2025-01-01T00:00:00Z","settings":{{}},"graph":{{"name":"test","nodes":{{}},"edges":[],"attrs":{{}}}},"working_directory":"/tmp"}}"# - ); - store - .init_run(&run_id, &[("run.json", run_record.as_bytes())]) - .unwrap(); - - let read_record = MetadataStore::read_run_record(dir.path(), &run_id) - .unwrap() - .unwrap(); - assert_eq!(read_record.run_id, fixtures::RUN_1); - assert_eq!(read_record.workflow_name(), "test"); - } - - #[test] - fn metadata_store_write_and_read_checkpoint() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()); - - let store = MetadataStore::new(dir.path(), &GitAuthor::default()); - store.init_run("RUN2", &[]).unwrap(); - - let ctx = crate::context::Context::new(); - ctx.set("goal", serde_json::json!("test")); - let cp = crate::records::Checkpoint::from_context( - &ctx, - "node_a", - vec!["start".to_string()], - std::collections::HashMap::new(), - std::collections::HashMap::new(), - Some("node_b".to_string()), - std::collections::HashMap::new(), - std::collections::HashMap::new(), - std::collections::HashMap::new(), - ); - let cp_json = serde_json::to_vec_pretty(&cp).unwrap(); - store.write_checkpoint("RUN2", &cp_json, &[]).unwrap(); - - let loaded = MetadataStore::read_checkpoint(dir.path(), "RUN2") - .unwrap() - .unwrap(); - assert_eq!(loaded.current_node, "node_a"); - assert_eq!(loaded.completed_nodes, vec!["start"]); - assert_eq!(loaded.next_node_id.as_deref(), Some("node_b")); - assert_eq!( - loaded.context_values.get("goal"), - Some(&serde_json::json!("test")) - ); - } - - #[test] - fn metadata_store_write_checkpoint_overwrites() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()); - - let store = MetadataStore::new(dir.path(), &GitAuthor::default()); - store.init_run("RUN3", &[]).unwrap(); - - let ctx = crate::context::Context::new(); - let cp1 = crate::records::Checkpoint::from_context( - &ctx, - "node_a", - vec!["start".to_string()], - std::collections::HashMap::new(), - std::collections::HashMap::new(), - None, - std::collections::HashMap::new(), - std::collections::HashMap::new(), - std::collections::HashMap::new(), - ); - let cp1_json = serde_json::to_vec_pretty(&cp1).unwrap(); - store.write_checkpoint("RUN3", &cp1_json, &[]).unwrap(); - - let cp2 = crate::records::Checkpoint::from_context( - &ctx, - "node_b", - vec!["start".to_string(), "node_a".to_string()], - std::collections::HashMap::new(), - std::collections::HashMap::new(), - Some("node_c".to_string()), - std::collections::HashMap::new(), - std::collections::HashMap::new(), - std::collections::HashMap::new(), - ); - let cp2_json = serde_json::to_vec_pretty(&cp2).unwrap(); - store.write_checkpoint("RUN3", &cp2_json, &[]).unwrap(); - - let loaded = MetadataStore::read_checkpoint(dir.path(), "RUN3") - .unwrap() - .unwrap(); - assert_eq!(loaded.current_node, "node_b"); - assert_eq!(loaded.completed_nodes.len(), 2); - } - - #[test] - fn metadata_store_read_checkpoint_missing_branch() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()); - - let result = MetadataStore::read_checkpoint(dir.path(), "NONEXISTENT").unwrap(); - assert!(result.is_none()); - } - - #[test] - fn metadata_store_artifact_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()); - - let store = MetadataStore::new(dir.path(), &GitAuthor::default()); - store.init_run("RUN4", &[]).unwrap(); - - let artifact_data = br#"{"large_output":"some data"}"#; - let cp_json = b"{}"; // minimal checkpoint for the test - store - .write_checkpoint( - "RUN4", - cp_json, - &[("artifacts/response.plan.json", artifact_data.as_slice())], - ) - .unwrap(); - - let read_back = MetadataStore::read_artifact(dir.path(), "RUN4", "response.plan") - .unwrap() - .unwrap(); - assert_eq!(read_back, artifact_data); - } - #[test] fn scan_node_files_picks_up_allowlisted() { let dir = tempfile::tempdir().unwrap(); @@ -834,56 +492,6 @@ mod tests { assert!(files.is_empty()); } - #[test] - fn metadata_store_write_files() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()); - - let store = MetadataStore::new(dir.path(), &GitAuthor::default()); - let run_id = fixtures::RUN_5.to_string(); - let run_record = format!( - r#"{{"run_id":"{run_id}","created_at":"2025-01-01T00:00:00Z","settings":{{}},"graph":{{"name":"test","nodes":{{}},"edges":[],"attrs":{{}}}},"working_directory":"/tmp"}}"# - ); - store - .init_run(&run_id, &[("run.json", run_record.as_bytes())]) - .unwrap(); - - store - .write_files( - &run_id, - &[("retro.json", b"{\"status\":\"ok\"}")], - "finalize", - ) - .unwrap(); - - let data = MetadataStore::read_file(dir.path(), &run_id, "retro.json") - .unwrap() - .unwrap(); - assert_eq!(data, b"{\"status\":\"ok\"}"); - - // Original files still present - let record = MetadataStore::read_run_record(dir.path(), &run_id) - .unwrap() - .unwrap(); - assert_eq!(record.run_id, fixtures::RUN_5); - } - - #[test] - fn metadata_store_init_run_with_extra_files() { - let dir = tempfile::tempdir().unwrap(); - init_repo(dir.path()); - - let store = MetadataStore::new(dir.path(), &GitAuthor::default()); - store - .init_run("RUN6", &[("sandbox.json", b"{\"type\":\"local\"}")]) - .unwrap(); - - let data = MetadataStore::read_file(dir.path(), "RUN6", "sandbox.json") - .unwrap() - .unwrap(); - assert_eq!(data, b"{\"type\":\"local\"}"); - } - #[test] fn sanitize_ref_component_lowercases() { assert_eq!(sanitize_ref_component("Hello"), "hello"); diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index d81328bb7..997231e10 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -48,8 +48,6 @@ struct FileTracking { last: Option, } -/// Recursively extract file-tracking events from agent events, including -/// those wrapped in one or more layers of `SubAgentEvent`. fn track_file_event(event: &AgentEvent, state: &mut FileTracking) { match event { AgentEvent::ToolCallStarted { @@ -75,9 +73,6 @@ fn track_file_event(event: &AgentEvent, state: &mut FileTracking) { } } } - AgentEvent::SubAgentEvent { event: inner, .. } => { - track_file_event(inner, state); - } _ => {} } } @@ -116,6 +111,8 @@ fn spawn_event_forwarder( emitter.emit(&WorkflowRunEvent::Agent { stage: node_id.clone(), event: event.event.clone(), + session_id: Some(event.session_id.clone()), + parent_session_id: event.parent_session_id.clone(), }); } } @@ -261,7 +258,7 @@ impl AgentApiBackend { manager_for_callback .lock() .await - .set_event_callback(session.event_callback()); + .set_event_callback(session.sub_agent_event_callback()); Ok(session) } @@ -730,7 +727,7 @@ mod tests { } #[test] - fn track_file_event_unwraps_sub_agent_edit() { + fn track_file_event_tracks_edit_file() { let mut state = new_file_tracking(); let mut args = serde_json::Map::new(); @@ -739,32 +736,22 @@ mod tests { serde_json::Value::String("/src/lib.rs".to_string()), ); - // ToolCallStarted wrapped in SubAgentEvent track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "edit_file".to_string(), - tool_call_id: "tc-sub".to_string(), - arguments: serde_json::Value::Object(args), - }), + &AgentEvent::ToolCallStarted { + tool_name: "edit_file".to_string(), + tool_call_id: "tc-sub".to_string(), + arguments: serde_json::Value::Object(args), }, &mut state, ); assert_eq!(state.pending.get("tc-sub").unwrap(), "/src/lib.rs"); - // ToolCallCompleted wrapped in SubAgentEvent track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallCompleted { - tool_call_id: "tc-sub".to_string(), - tool_name: "edit_file".to_string(), - is_error: false, - output: serde_json::Value::String("ok".to_string()), - }), + &AgentEvent::ToolCallCompleted { + tool_call_id: "tc-sub".to_string(), + tool_name: "edit_file".to_string(), + is_error: false, + output: serde_json::Value::String("ok".to_string()), }, &mut state, ); @@ -772,55 +759,6 @@ mod tests { assert_eq!(state.last.as_deref(), Some("/src/lib.rs")); } - #[test] - fn track_file_event_unwraps_nested_sub_sub_agent() { - let mut state = new_file_tracking(); - - let mut args = serde_json::Map::new(); - args.insert( - "file_path".to_string(), - serde_json::Value::String("/deep/file.rs".to_string()), - ); - - // Double-wrapped SubAgentEvent → SubAgentEvent → ToolCallStarted - track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-outer".to_string(), - depth: 1, - event: Box::new(AgentEvent::SubAgentEvent { - agent_id: "sub-inner".to_string(), - depth: 2, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "write_file".to_string(), - tool_call_id: "tc-deep".to_string(), - arguments: serde_json::Value::Object(args), - }), - }), - }, - &mut state, - ); - assert!(state.pending.contains_key("tc-deep")); - - track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-outer".to_string(), - depth: 1, - event: Box::new(AgentEvent::SubAgentEvent { - agent_id: "sub-inner".to_string(), - depth: 2, - event: Box::new(AgentEvent::ToolCallCompleted { - tool_call_id: "tc-deep".to_string(), - tool_name: "write_file".to_string(), - is_error: false, - output: serde_json::Value::String("ok".to_string()), - }), - }), - }, - &mut state, - ); - assert!(state.touched.contains("/deep/file.rs")); - } - #[test] fn track_file_event_error_removes_pending() { let mut state = new_file_tracking(); @@ -832,28 +770,20 @@ mod tests { ); track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallStarted { - tool_name: "edit_file".to_string(), - tool_call_id: "tc-err".to_string(), - arguments: serde_json::Value::Object(args), - }), + &AgentEvent::ToolCallStarted { + tool_name: "edit_file".to_string(), + tool_call_id: "tc-err".to_string(), + arguments: serde_json::Value::Object(args), }, &mut state, ); track_file_event( - &AgentEvent::SubAgentEvent { - agent_id: "sub-1".to_string(), - depth: 1, - event: Box::new(AgentEvent::ToolCallCompleted { - tool_call_id: "tc-err".to_string(), - tool_name: "edit_file".to_string(), - is_error: true, - output: serde_json::Value::String("failed".to_string()), - }), + &AgentEvent::ToolCallCompleted { + tool_call_id: "tc-err".to_string(), + tool_name: "edit_file".to_string(), + is_error: true, + output: serde_json::Value::String("failed".to_string()), }, &mut state, ); diff --git a/lib/crates/fabro-workflow/src/handler/llm/cli.rs b/lib/crates/fabro-workflow/src/handler/llm/cli.rs index 472207571..7b43ab6b6 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/cli.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/cli.rs @@ -969,7 +969,7 @@ mod tests { #[allow(unsafe_code)] async fn ensure_cli_skips_install_when_present() { let sandbox: Arc = Arc::new(CliMockSandbox::new(vec![ok_result()])); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await; assert!(result.is_ok()); @@ -988,7 +988,7 @@ mod tests { fail_result(127), // claude --version ok_result(), // combined node + npm install ])); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await; assert!(result.is_ok()); @@ -1005,7 +1005,7 @@ mod tests { fail_result(127), // claude --version fail_result(1), // combined install fails ])); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await; assert!(result.is_err()); diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index 29aba758f..ac015d5b2 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -4,12 +4,6 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use async_trait::async_trait; -use chrono::Utc; -use fabro_config::FabroSettings; -use fabro_store::{InMemoryStore, Store}; -use fabro_types::RunId; - use crate::condition::evaluate_condition; use crate::context::keys; use crate::context::{Context, WorkflowContext}; @@ -20,7 +14,11 @@ use crate::pipeline; use crate::pipeline::types::Initialized; use crate::run_dir::visit_from_context; use crate::run_options::RunOptions; +use async_trait::async_trait; +use chrono::Utc; +use fabro_config::FabroSettings; use fabro_graphviz::graph::{AttrValue, Graph, Node}; +use fabro_store::{InMemoryStore, Store}; use tokio::time::{sleep, timeout}; use super::{EngineServices, Handler}; @@ -166,7 +164,8 @@ impl Handler for SubWorkflowHandler { settings: fabro_config::FabroSettings::default(), run_dir: child_logs, cancel_token: Some(cancel_token), - run_id: RunId::new(), + // Child workflows are part of the parent run's event stream. + run_id: services.emitter.run_id(), labels: HashMap::new(), workflow_slug: None, github_app: None, diff --git a/lib/crates/fabro-workflow/src/handler/mod.rs b/lib/crates/fabro-workflow/src/handler/mod.rs index 7e8df9140..93f62de2d 100644 --- a/lib/crates/fabro-workflow/src/handler/mod.rs +++ b/lib/crates/fabro-workflow/src/handler/mod.rs @@ -71,7 +71,7 @@ impl EngineServices { pub fn test_default() -> Self { Self { registry: Arc::new(HandlerRegistry::new(Box::new(start::StartHandler))), - emitter: Arc::new(EventEmitter::new()), + emitter: Arc::new(EventEmitter::default()), sandbox: Arc::new(fabro_agent::LocalSandbox::new( std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), )), diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs index 50ff37577..53d91c91e 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/crates/fabro-workflow/src/lib.rs @@ -113,13 +113,18 @@ pub fn extract_stage_durations_from_events(events: &[EventEnvelope]) -> HashMap< let mut durations = HashMap::new(); for envelope in events { let value = envelope.payload.as_value(); - if value.get("event").and_then(serde_json::Value::as_str) != Some("StageCompleted") { + if value.get("event").and_then(serde_json::Value::as_str) != Some("stage.completed") { continue; } let Some(node_id) = value.get("node_id").and_then(serde_json::Value::as_str) else { continue; }; - let Some(duration_ms) = value.get("duration_ms").and_then(serde_json::Value::as_u64) else { + let Some(duration_ms) = value + .get("properties") + .and_then(serde_json::Value::as_object) + .and_then(|properties| properties.get("duration_ms")) + .and_then(serde_json::Value::as_u64) + else { continue; }; durations.insert(node_id.to_string(), duration_ms); diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index c4c325caa..0e47f5c30 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; -use fabro_git_storage::branchstore::BranchStore; -use fabro_git_storage::gitobj::Store; +use fabro_checkpoint::branch::BranchStore; +use fabro_checkpoint::git::Store; use fabro_types::RunId; use git2::{Oid, Signature}; diff --git a/lib/crates/fabro-workflow/src/operations/hydrate.rs b/lib/crates/fabro-workflow/src/operations/hydrate.rs index c37aba4de..8b3b40857 100644 --- a/lib/crates/fabro-workflow/src/operations/hydrate.rs +++ b/lib/crates/fabro-workflow/src/operations/hydrate.rs @@ -227,7 +227,7 @@ mod tests { use fabro_types::{Conclusion, RunStatus, RunStatusRecord, StageStatus, fixtures}; use super::open_or_hydrate_run; - use crate::event::{WorkflowRunEvent, append_progress_event}; + use crate::event::{WorkflowRunEvent, append_progress_event, canonicalize_event}; use crate::records::{Checkpoint, CheckpointExt, ConclusionExt, RunRecord, RunRecordExt}; use crate::run_status::RunStatusRecordExt; @@ -286,16 +286,15 @@ mod tests { has_pricing: false, }; conclusion.save(&run_dir.join("conclusion.json")).unwrap(); - append_progress_event( - run_dir, + let envelope = canonicalize_event( &test_run_id(), &WorkflowRunEvent::RunNotice { level: crate::event::RunNoticeLevel::Info, code: "hydrated".to_string(), message: "hello".to_string(), }, - ) - .unwrap(); + ); + append_progress_event(run_dir, &envelope).unwrap(); } #[tokio::test] diff --git a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs index 4e658f2b9..f45f3d839 100644 --- a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs +++ b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs @@ -3,8 +3,8 @@ use std::fmt::Write; use std::path::PathBuf; use anyhow::{Context, Result, bail}; -use fabro_git_storage::branchstore::BranchStore; -use fabro_git_storage::gitobj::Store as GitStore; +use fabro_checkpoint::branch::BranchStore; +use fabro_checkpoint::git::Store as GitStore; use fabro_store::{ ListRunsQuery, NodeVisitRef, RunStore as DurableRunStore, Store as DurableStore, }; diff --git a/lib/crates/fabro-workflow/src/operations/rewind.rs b/lib/crates/fabro-workflow/src/operations/rewind.rs index b12978cc5..5f8beb5fa 100644 --- a/lib/crates/fabro-workflow/src/operations/rewind.rs +++ b/lib/crates/fabro-workflow/src/operations/rewind.rs @@ -3,8 +3,8 @@ use std::fmt::Write; use std::str::FromStr; use anyhow::{Context, Result, bail}; -use fabro_git_storage::branchstore::{BranchStore, CommitInfo}; -use fabro_git_storage::gitobj::Store; +use fabro_checkpoint::branch::{BranchStore, CommitInfo}; +use fabro_checkpoint::git::Store; use fabro_types::RunId; use git2::{Oid, Repository, Signature}; diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 7d5617a31..ce3b80053 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -19,7 +19,8 @@ use crate::context::Context; use crate::error::FabroError; use crate::event::{ EventEmitter, RunNoticeLevel, StoreProgressLogger, WorkflowRunEvent, append_progress_event, - build_redacted_event_payload, + append_progress_event_with_line, canonicalize_event, event_payload_from_redacted_json, + redacted_event_json, }; use crate::git::MetadataStore; use crate::handler::HandlerRegistry; @@ -128,8 +129,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, @@ -146,6 +146,7 @@ pub(super) async fn execute_persisted_run( ), }, ); + let _ = append_progress_event(&projection_run_dir, &envelope); }), ), ); @@ -468,23 +469,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 { @@ -703,9 +719,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, @@ -713,7 +728,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() { @@ -733,13 +760,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; } @@ -814,22 +851,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"); @@ -956,7 +987,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::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); let injected = Arc::new(AtomicBool::new(false)); @@ -967,15 +998,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()), + }); } }); } @@ -1000,7 +1029,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::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); persisted_workflow(MINIMAL_DOT, &run_dir); @@ -1020,7 +1049,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::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); let visited = Arc::new(Mutex::new(Vec::new())); @@ -1049,7 +1078,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::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); persisted_workflow(MINIMAL_DOT, &run_dir); @@ -1086,7 +1115,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::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); persisted_workflow(MINIMAL_DOT, &run_dir); @@ -1108,7 +1137,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::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); persisted_workflow(MINIMAL_DOT, &run_dir); diff --git a/lib/crates/fabro-workflow/src/operations/test_support.rs b/lib/crates/fabro-workflow/src/operations/test_support.rs index f686bb8b9..79319fa7b 100644 --- a/lib/crates/fabro-workflow/src/operations/test_support.rs +++ b/lib/crates/fabro-workflow/src/operations/test_support.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use fabro_git_storage::gitobj::Store; +use fabro_checkpoint::git::Store; use git2::{Repository, Signature}; pub(super) fn temp_repo() -> (tempfile::TempDir, Store) { diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index 0d7890d8c..1bff3fd32 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -20,7 +20,7 @@ use fabro_types::{RunId, fixtures}; use super::*; use crate::context::{self, Context}; use crate::error::FabroError; -use crate::event::{EventEmitter, WorkflowRunEvent}; +use crate::event::{EventEmitter, RunEventEnvelope}; use crate::handler::start::StartHandler; use crate::handler::{Handler as HandlerTrait, HandlerRegistry}; use crate::outcome::{Outcome, OutcomeExt, StageStatus}; @@ -77,6 +77,14 @@ fn test_run_id(label: &str) -> RunId { } } +fn test_emitter(label: &str) -> EventEmitter { + EventEmitter::new(test_run_id(label)) +} + +fn test_emitter_arc(label: &str) -> Arc { + Arc::new(test_emitter(label)) +} + fn test_run_options(run_dir: &Path, run_id: &str) -> RunOptions { RunOptions { run_dir: run_dir.to_path_buf(), @@ -169,7 +177,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: test_emitter_arc("run-test"), sandbox: SandboxSpec::Local { working_directory: std::env::current_dir().unwrap(), }, @@ -426,7 +434,7 @@ async fn execute_runs_simple_workflow() { let dir = tempfile::tempdir().unwrap(); let outcome = run_graph( make_registry(), - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &simple_graph(), &test_run_options(dir.path(), "test-run"), @@ -441,7 +449,7 @@ async fn execute_saves_checkpoint() { let dir = tempfile::tempdir().unwrap(); run_graph( make_registry(), - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &simple_graph(), &test_run_options(dir.path(), "test-run"), @@ -456,7 +464,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 = test_emitter("test-run"); emitter.on_event(move |event| { events_clone.lock().unwrap().push(format!("{event:?}")); }); @@ -479,7 +487,7 @@ async fn execute_error_when_no_start_node() { let dir = tempfile::tempdir().unwrap(); let result = run_graph( make_registry(), - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &Graph::new("empty"), &test_run_options(dir.path(), "test-run"), @@ -493,7 +501,7 @@ async fn execute_mirrors_graph_goal_to_context() { let dir = tempfile::tempdir().unwrap(); run_graph( make_registry(), - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &simple_graph(), &test_run_options(dir.path(), "test-run"), @@ -542,7 +550,7 @@ async fn execute_conditional_routing_uses_unconditional_success_path() { run_graph( make_registry(), - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -567,7 +575,7 @@ async fn execute_writes_start_json_and_node_status() { run_graph( make_registry(), - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &simple_graph(), &run_options, @@ -629,7 +637,7 @@ async fn timeout_causes_fail_status_json() { registry.register("slow", Box::new(SlowHandler { sleep_ms: 500 })); run_graph( registry, - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -671,7 +679,7 @@ async fn execute_cancelled_mid_run() { let result = run_graph( registry, - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &g, &run_options, @@ -689,7 +697,7 @@ async fn max_node_visits_errors_on_cycle() { let result = run_graph( make_registry(), - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -724,7 +732,7 @@ async fn panic_handler_writes_panic_txt() { registry.register("panicker", Box::new(PanickingHandler)); let _ = run_graph( registry, - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -745,7 +753,7 @@ async fn loop_circuit_breaker_aborts_on_repeated_failure() { let result = run_graph( registry, - Arc::new(EventEmitter::new()), + test_emitter_arc("test-run"), local_env(), &looping_fail_graph(), &test_run_options(dir.path(), "test-run"), @@ -794,7 +802,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()), + test_emitter_arc("test-run"), local_env(), &g, &test_run_options(dir.path(), "test-run"), @@ -841,9 +849,9 @@ async fn retry_emits_stage_started_per_attempt() { g.edges.push(Edge::new("start", "work")); g.edges.push(Edge::new("work", "exit")); - let events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let events = Arc::new(std::sync::Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); - let emitter = EventEmitter::new(); + let emitter = test_emitter("retry-events-test"); emitter.on_event(move |event| { events_clone.lock().unwrap().push(event.clone()); }); @@ -870,11 +878,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 +891,13 @@ async fn run_with_lifecycle_emits_initialize_and_setup_events() { let dir = tempfile::tempdir().unwrap(); let events = Arc::new(std::sync::Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); - let emitter = EventEmitter::new(); + let emitter = test_emitter("order-test"); 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 +971,9 @@ async fn git_checkpoint_skips_start_node() { g.edges.push(Edge::new("start", "work")); g.edges.push(Edge::new("work", "exit")); - let events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let events = Arc::new(std::sync::Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); - let emitter = EventEmitter::new(); + let emitter = test_emitter("git-cp-test"); emitter.on_event(move |event| { events_clone.lock().unwrap().push(event.clone()); }); @@ -996,13 +1002,15 @@ async fn git_checkpoint_skips_start_node() { let collected = events.lock().unwrap(); let checkpoint_node_ids: Vec<&str> = collected .iter() - .filter_map(|e| match e { - WorkflowRunEvent::CheckpointCompleted { - node_id, - git_commit_sha: Some(_), - .. - } => Some(node_id.as_str()), - _ => None, + .filter_map(|event| { + (event.event == "checkpoint.completed" + && event + .properties + .get("git_commit_sha") + .and_then(|value| value.as_str()) + .is_some()) + .then(|| event.node_id.as_deref()) + .flatten() }) .collect(); assert!(!checkpoint_node_ids.contains(&"start")); diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index a37474e08..f9731637a 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -489,7 +489,7 @@ mod tests { run_options: test_run_options(&run_dir), run_store: Arc::clone(&run_store), hook_runner: None, - emitter: Arc::new(EventEmitter::new()), + emitter: Arc::new(EventEmitter::default()), sandbox: Arc::new(fabro_agent::LocalSandbox::new( std::env::current_dir().unwrap(), )), diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 139ee6f28..3dca93db9 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -753,7 +753,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, @@ -831,7 +831,7 @@ mod tests { std::fs::create_dir_all(&run_dir).unwrap(); let (graph, source) = simple_graph(); let persisted = test_persisted(graph, source, &run_dir); - let emitter = Arc::new(crate::event::EventEmitter::new()); + let emitter = Arc::new(crate::event::EventEmitter::default()); let initialized = initialize( persisted, diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index f4e13d365..297b5bd6b 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -80,6 +80,8 @@ pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option { emitter.emit(&WorkflowRunEvent::Agent { stage: "retro".to_string(), event: event.event.clone(), + session_id: Some(event.session_id.clone()), + parent_session_id: event.parent_session_id.clone(), }); } }) @@ -178,7 +180,7 @@ mod tests { use super::*; use crate::context::Context; - use crate::event::{EventEmitter, WorkflowRunEvent}; + use crate::event::EventEmitter; use crate::pipeline::types::Executed; use crate::records::{Checkpoint, CheckpointExt}; use crate::run_options::RunOptions; @@ -249,7 +251,7 @@ mod tests { std::fs::create_dir_all(&run_dir).unwrap(); let checkpoint = write_checkpoint(&run_dir); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let sandbox: Arc = Arc::new(fabro_agent::LocalSandbox::new( std::env::current_dir().unwrap(), )); @@ -299,11 +301,11 @@ mod tests { std::fs::create_dir_all(&run_dir).unwrap(); let checkpoint = write_checkpoint(&run_dir); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let seen = Arc::new(Mutex::new(Vec::new())); emitter.on_event({ let seen = Arc::clone(&seen); - move |event| seen.lock().unwrap().push(event.clone()) + move |event| seen.lock().unwrap().push(event.event.clone()) }); let retro = run_retro( @@ -330,13 +332,7 @@ mod tests { assert!(retro.is_some()); let seen = seen.lock().unwrap(); - assert!( - seen.iter() - .any(|event| matches!(event, WorkflowRunEvent::RetroStarted)) - ); - assert!( - seen.iter() - .any(|event| matches!(event, WorkflowRunEvent::RetroCompleted { .. })) - ); + assert!(seen.iter().any(|event| event == "retro.started")); + assert!(seen.iter().any(|event| event == "retro.completed")); } } diff --git a/lib/crates/fabro-workflow/src/sandbox_git.rs b/lib/crates/fabro-workflow/src/sandbox_git.rs index 2c39b205e..05762a4ef 100644 --- a/lib/crates/fabro-workflow/src/sandbox_git.rs +++ b/lib/crates/fabro-workflow/src/sandbox_git.rs @@ -1,7 +1,8 @@ use std::path::Path; use fabro_agent::Sandbox; -use fabro_git_storage::trailerlink::{self, Trailer}; +use fabro_checkpoint::trailer as trailerlink; +use fabro_checkpoint::trailer::Trailer; use fabro_types::RunId; use crate::asset_snapshot; diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index 9806f9352..1c7fa00d3 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -21,6 +21,13 @@ struct InitializedOptions { checkpoint: Option, } +fn bound_emitter(run_id: fabro_types::RunId, observer: Arc) -> Arc { + let emitter = Arc::new(EventEmitter::new(run_id)); + let observer_clone = Arc::clone(&observer); + emitter.on_event(move |event| observer_clone.dispatch_envelope(event)); + emitter +} + async fn initialized( registry: HandlerRegistry, emitter: Arc, @@ -42,6 +49,7 @@ async fn initialized( inner_store, run_options.run_dir.clone(), )); + let emitter = bound_emitter(run_options.run_id, emitter); Initialized { graph: graph.clone(), source: String::new(), diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 81dbd520b..a4988a26c 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -407,7 +407,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env.clone()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -584,7 +584,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { // Set up event collection let dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = Arc::new(std::sync::Mutex::new(Vec::new())); { let events_clone = Arc::clone(&events); @@ -627,16 +627,13 @@ async fn daytona_git_checkpoint_remote_emits_events() { let git_events: Vec<_> = events .iter() .filter_map(|e| { - if let fabro_workflow::event::WorkflowRunEvent::CheckpointCompleted { - node_id, - git_commit_sha: Some(sha), - .. - } = e - { - Some((node_id.clone(), sha.clone())) - } else { - None + if e.event != "checkpoint.completed" { + return None; } + Some(( + e.node_id.clone()?, + e.properties.get("git_commit_sha")?.as_str()?.to_string(), + )) }) .collect(); // Only the "work" node gets a checkpoint — start is skipped and exit breaks @@ -766,7 +763,7 @@ async fn daytona_parallel_git_branching_e2e() { graph.edges.push(Edge::new("fan_in", "exit")); let run_tmp = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = Arc::new(std::sync::Mutex::new(Vec::new())); { let events_clone = Arc::clone(&events); @@ -879,12 +876,7 @@ async fn daytona_parallel_git_branching_e2e() { let events = events.lock().unwrap(); let parallel_started: Vec<_> = events .iter() - .filter(|e| { - matches!( - e, - fabro_workflow::event::WorkflowRunEvent::ParallelStarted { .. } - ) - }) + .filter(|e| e.event == "parallel.started") .collect(); assert_eq!( parallel_started.len(), @@ -893,12 +885,7 @@ async fn daytona_parallel_git_branching_e2e() { ); let parallel_completed: Vec<_> = events .iter() - .filter(|e| { - matches!( - e, - fabro_workflow::event::WorkflowRunEvent::ParallelCompleted { .. } - ) - }) + .filter(|e| e.event == "parallel.completed") .collect(); assert_eq!( parallel_completed.len(), @@ -983,7 +970,7 @@ async fn run_daytona_cli_test(provider: Provider, model: &str, install_command: let backend = AgentCliBackend::new(model.to_string(), provider); let node = Node::new("daytona_cli_test"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -1157,7 +1144,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { registry.register("exit", Box::new(ExitHandler)); let meta_branch = MetadataStore::branch_name(&run_id.to_string()); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env.clone()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1263,7 +1250,7 @@ async fn daytona_asset_collection() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env.clone()); let mut graph = Graph::new("DaytonaAssetTest"); graph.attrs.insert( @@ -1546,7 +1533,7 @@ async fn daytona_git_push_run_branch_to_origin() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env.clone()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 034cbe879..16b221d56 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -30,7 +30,7 @@ use fabro_types::RunId; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; use fabro_workflow::error::{FabroError, FailureSignatureExt}; -use fabro_workflow::event::{EventEmitter, WorkflowRunEvent}; +use fabro_workflow::event::{EventEmitter, RunEventEnvelope, WorkflowRunEvent}; use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenResult}; use fabro_workflow::handler::command::CommandHandler; use fabro_workflow::handler::conditional::ConditionalHandler; @@ -213,7 +213,7 @@ async fn end_to_end_linear_pipeline() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -355,7 +355,7 @@ async fn end_to_end_branching_pipeline() { registry.register("agent", Box::new(AgentHandler::new(None))); registry.register("conditional", Box::new(ConditionalHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -474,7 +474,7 @@ async fn end_to_end_human_gate_pipeline() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -569,7 +569,7 @@ async fn human_gate_aborted_input_fails_closed_without_fail_route() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -679,7 +679,7 @@ async fn human_gate_aborted_input_routes_via_outcome_fail_condition() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -791,7 +791,7 @@ async fn goal_gate_routes_to_retry_target_on_failure() { registry.register("exit", Box::new(ExitHandler)); registry.register("always_fail", Box::new(AlwaysFailHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -911,7 +911,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1222,7 +1222,7 @@ async fn retry_on_failure_then_succeed() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1294,7 +1294,7 @@ async fn pipeline_with_many_nodes() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -1479,7 +1479,7 @@ impl Handler for ContextSetterHandler { } } -fn collect_events(emitter: &EventEmitter) -> Arc>> { +fn collect_events(emitter: &EventEmitter) -> Arc>> { let events = Arc::new(std::sync::Mutex::new(Vec::new())); let events_clone = Arc::clone(&events); emitter.on_event(move |event| { @@ -1617,7 +1617,7 @@ async fn smoke_test_with_mock_codergen_backend() { ); registry.register("conditional", Box::new(ConditionalHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1717,7 +1717,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1829,7 +1829,7 @@ async fn resume_from_checkpoint_completes_pipeline() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1927,7 +1927,7 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1967,7 +1967,7 @@ async fn graph_goal_in_context() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2002,7 +2002,7 @@ async fn event_streaming_lifecycle() { }"#; let graph = parse(input).expect("parse"); let dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new(make_linear_registry(), Arc::new(emitter), local_env()); let run_options = RunOptions { @@ -2021,50 +2021,32 @@ async fn event_streaming_lifecycle() { engine.run(&graph, &run_options).await.expect("run"); let collected = events.lock().unwrap(); + assert!(collected.iter().any(|e| e.event == "run.started")); assert!( collected .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })) + .any(|e| e.event == "stage.started" && e.node_id.as_deref() == Some("start")) ); assert!( collected .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageStarted { name, .. } if name == "start")) + .any(|e| e.event == "stage.completed" && e.node_id.as_deref() == Some("start")) ); assert!( collected .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageCompleted { name, .. } if name == "start")) + .any(|e| e.event == "stage.started" && e.node_id.as_deref() == Some("task")) ); assert!( collected .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageStarted { name, .. } if name == "task")) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageCompleted { name, .. } if name == "task")) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::CheckpointCompleted { .. })) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) + .any(|e| e.event == "stage.completed" && e.node_id.as_deref() == Some("task")) ); + assert!(collected.iter().any(|e| e.event == "checkpoint.completed")); + assert!(collected.iter().any(|e| e.event == "run.completed")); // WorkflowRunStarted first, WorkflowRunCompleted last - assert!(matches!( - collected.first().unwrap(), - WorkflowRunEvent::WorkflowRunStarted { .. } - )); - assert!(matches!( - collected.last().unwrap(), - WorkflowRunEvent::WorkflowRunCompleted { .. } - )); + assert_eq!(collected.first().unwrap().event, "run.started"); + assert_eq!(collected.last().unwrap().event, "run.completed"); } #[tokio::test] @@ -2095,7 +2077,7 @@ async fn context_flow_between_stages() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2147,7 +2129,7 @@ async fn tool_handler_e2e() { let interviewer = Arc::new(AutoApproveInterviewer); let engine = WorkflowRunner::new( make_full_registry(interviewer), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2218,7 +2200,7 @@ async fn auto_approve_interviewer_e2e() { let interviewer = Arc::new(AutoApproveInterviewer); let engine = WorkflowRunner::new( make_full_registry(interviewer), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2254,7 +2236,7 @@ async fn codergen_without_backend_simulated() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -2360,7 +2342,7 @@ async fn branching_loop_back_on_failure() { call_count: std::sync::atomic::AtomicU32::new(0), }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2442,7 +2424,7 @@ async fn human_gate_loops_back() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2493,7 +2475,7 @@ async fn scenario_ship_a_feature() { let interviewer = Arc::new(AutoApproveInterviewer); let dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new( make_full_registry(interviewer), @@ -2528,16 +2510,8 @@ async fn scenario_ship_a_feature() { assert!(cp.completed_nodes.contains(&"review".to_string())); let collected = events.lock().unwrap(); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) - ); + assert!(collected.iter().any(|e| e.event == "run.started")); + assert!(collected.iter().any(|e| e.event == "run.completed")); } #[tokio::test] @@ -2588,7 +2562,7 @@ async fn scenario_parallel_expert_review() { ); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2671,7 +2645,7 @@ async fn scenario_node_retries_on_retry_status() { call_count: std::sync::atomic::AtomicU32::new(0), }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2732,7 +2706,7 @@ async fn scenario_loop_restart_resets_context() { call_count: Arc::clone(&call_count), }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2799,7 +2773,7 @@ async fn scenario_bug_triage_router() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("conditional", Box::new(ConditionalHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2857,7 +2831,7 @@ async fn scenario_crash_recovery() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2965,7 +2939,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { registry.register("exit", Box::new(ExitHandler)); registry.register("done_setter", Box::new(DoneSetterHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3043,7 +3017,7 @@ async fn manager_loop_max_cycles_exceeded_e2e() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3180,7 +3154,7 @@ async fn conditional_branching_success_fail_paths() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("always_fail", Box::new(AlwaysFailHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3232,7 +3206,7 @@ async fn edge_selection_condition_match_wins_over_weight() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3278,7 +3252,7 @@ async fn edge_selection_weight_breaks_ties() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3316,7 +3290,7 @@ async fn edge_selection_lexical_tiebreak() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3373,7 +3347,7 @@ async fn context_updates_visible_across_nodes() { registry.register("exit", Box::new(ExitHandler)); registry.register("conditional", Box::new(ConditionalHandler)); registry.register("context_setter", Box::new(ContextSetterHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3414,7 +3388,7 @@ async fn stylesheet_applies_model_override() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -3471,7 +3445,7 @@ async fn custom_handler_registration_and_execution() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("my_custom", Box::new(CustomHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3535,7 +3509,7 @@ async fn integration_smoke_plan_implement_review_done() { // Run pipeline let interviewer = Arc::new(AutoApproveInterviewer); let dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new( make_full_registry(interviewer), @@ -3582,16 +3556,8 @@ async fn integration_smoke_plan_implement_review_done() { // Verify events let collected = events.lock().unwrap(); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })) - ); - assert!( - collected - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) - ); + assert!(collected.iter().any(|e| e.event == "run.started")); + assert!(collected.iter().any(|e| e.event == "run.completed")); } // =========================================================================== @@ -3650,7 +3616,7 @@ async fn manager_loop_runs_child_engine_e2e() { registry.register("exit", Box::new(ExitHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3783,7 +3749,7 @@ async fn manager_loop_context_flows_e2e() { registry.register("setter", Box::new(SetterHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3855,7 +3821,7 @@ async fn manager_loop_child_dotfile_e2e() { registry.register("exit", Box::new(ExitHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3954,7 +3920,7 @@ async fn import_e2e_through_engine() { let engine = WorkflowRunner::new( make_linear_registry(), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions { @@ -4109,7 +4075,7 @@ async fn fidelity_default_is_compact() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4165,7 +4131,7 @@ async fn fidelity_graph_default_applied() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4217,7 +4183,7 @@ async fn fidelity_node_overrides_graph_default() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4275,7 +4241,7 @@ async fn fidelity_edge_overrides_node_and_graph() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4323,7 +4289,7 @@ async fn fidelity_full_produces_empty_preamble() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4381,7 +4347,7 @@ async fn fidelity_truncate_preamble_minimal() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4452,7 +4418,7 @@ async fn fidelity_summary_low_mode() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4518,7 +4484,7 @@ async fn fidelity_summary_medium_mode() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4584,7 +4550,7 @@ async fn fidelity_summary_high_mode() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4643,7 +4609,7 @@ async fn fidelity_full_sets_thread_id_in_context() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4713,7 +4679,7 @@ async fn fidelity_full_nodes_share_thread_id() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4793,7 +4759,7 @@ async fn fidelity_resume_degrades_full_to_summary_high() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4889,7 +4855,7 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4972,7 +4938,7 @@ async fn fidelity_resume_no_degrade_when_not_full() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5013,7 +4979,7 @@ async fn fidelity_stored_in_checkpoint_context() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5098,7 +5064,7 @@ async fn fidelity_precedence_multi_node_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5165,7 +5131,7 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5239,7 +5205,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { captures: captures_low.clone(), }), ); - let engine_low = WorkflowRunner::new(registry_low, Arc::new(EventEmitter::new()), local_env()); + let engine_low = + WorkflowRunner::new(registry_low, Arc::new(EventEmitter::default()), local_env()); let run_options_low = RunOptions { settings: FabroSettings::default(), run_dir: dir_low.path().to_path_buf(), @@ -5305,7 +5272,8 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { captures: captures_med.clone(), }), ); - let engine_med = WorkflowRunner::new(registry_med, Arc::new(EventEmitter::new()), local_env()); + let engine_med = + WorkflowRunner::new(registry_med, Arc::new(EventEmitter::default()), local_env()); let run_options_med = RunOptions { settings: FabroSettings::default(), run_dir: dir_med.path().to_path_buf(), @@ -5375,7 +5343,7 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5428,7 +5396,7 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5484,7 +5452,7 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5541,7 +5509,7 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5608,7 +5576,7 @@ async fn fidelity_from_parsed_dot_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5655,7 +5623,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5724,7 +5692,7 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5810,7 +5778,7 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6024,7 +5992,7 @@ mod real_llm { )))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6137,7 +6105,7 @@ mod real_llm { Box::new(AgentHandler::new(Some(make_llm_backend(client)))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6275,7 +6243,7 @@ mod real_llm { ); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6381,7 +6349,7 @@ mod real_llm { ))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6476,7 +6444,7 @@ async fn human_gate_freeform_only_routes_text() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6605,7 +6573,7 @@ async fn human_gate_freeform_with_fixed_choice_match() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6719,7 +6687,7 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6846,7 +6814,7 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6953,7 +6921,7 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7210,23 +7178,23 @@ impl HookTestRunner { fn emitter_with_events() -> ( Arc, - Arc>>, + Arc>>, ) { - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); (Arc::new(emitter), events) } fn engine_with_hooks(hooks: Vec) -> HookTestRunner { HookTestRunner { - emitter: Arc::new(EventEmitter::new()), + emitter: Arc::new(EventEmitter::default()), hook_runner: hook_runner_from_defs(hooks), } } fn engine_with_hooks_and_events( hooks: Vec, -) -> (HookTestRunner, Arc>>) { +) -> (HookTestRunner, Arc>>) { let (emitter, events) = emitter_with_events(); ( HookTestRunner { @@ -7336,17 +7304,13 @@ async fn hook_run_start_block_prevents_run() { // WorkflowRunStarted should still have been emitted (it fires before the hook) let captured = events.lock().unwrap(); assert!( - captured - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })), + captured.iter().any(|e| e.event == "run.started"), "WorkflowRunStarted should be emitted before hook blocks" ); // But no StageStarted — the run never reached node execution assert!( - !captured - .iter() - .any(|e| matches!(e, WorkflowRunEvent::StageStarted { .. })), + !captured.iter().any(|e| e.event == "stage.started"), "No stage should start when RunStart hook blocks" ); } @@ -7427,8 +7391,13 @@ async fn hook_stage_start_skip_bypasses_node() { let stage_starts: Vec<_> = captured .iter() .filter(|e| { - matches!(e, WorkflowRunEvent::StageStarted { handler_type, .. } - if handler_type.as_deref() != Some("start") && handler_type.as_deref() != Some("exit")) + e.event == "stage.started" + && !matches!( + e.properties + .get("handler_type") + .and_then(|value| value.as_str()), + Some("start" | "exit") + ) }) .collect(); assert!( @@ -7750,9 +7719,10 @@ async fn hook_edge_selected_override_redirects_routing() { let captured = events.lock().unwrap(); let completed_nodes: Vec = captured .iter() - .filter_map(|e| match e { - WorkflowRunEvent::StageCompleted { node_id, .. } => Some(node_id.clone()), - _ => None, + .filter_map(|e| { + (e.event == "stage.completed") + .then(|| e.node_id.clone()) + .flatten() }) .collect(); assert!( @@ -8275,16 +8245,13 @@ async fn hooks_do_not_duplicate_workflow_events() { let captured = events.lock().unwrap(); // Count WorkflowRunStarted — should be exactly 1 - let run_started = captured - .iter() - .filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })) - .count(); + let run_started = captured.iter().filter(|e| e.event == "run.started").count(); assert_eq!(run_started, 1, "Should have exactly 1 WorkflowRunStarted"); // Count WorkflowRunCompleted — should be exactly 1 let run_completed = captured .iter() - .filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) + .filter(|e| e.event == "run.completed") .count(); assert_eq!( run_completed, 1, @@ -8292,10 +8259,7 @@ async fn hooks_do_not_duplicate_workflow_events() { ); // No WorkflowRunFailed - let run_failed = captured - .iter() - .filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunFailed { .. })) - .count(); + let run_failed = captured.iter().filter(|e| e.event == "run.failed").count(); assert_eq!(run_failed, 0, "Should have 0 WorkflowRunFailed"); } @@ -8342,7 +8306,7 @@ async fn arc_e2e_with_real_llm() { }); let run_dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: run_dir.path().to_path_buf(), @@ -8469,7 +8433,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -8665,7 +8629,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { @@ -8723,14 +8687,15 @@ async fn large_context_values_are_offloaded_to_artifact_store() { let evts = events.lock().unwrap(); let completed_event = evts .iter() - .find(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })) + .find(|e| e.event == "run.completed") .expect("should have WorkflowRunCompleted event"); - if let WorkflowRunEvent::WorkflowRunCompleted { artifact_count, .. } = completed_event { - assert!( - *artifact_count > 0, - "artifact_count should be > 0, got {artifact_count}" - ); - } + let artifact_count = completed_event.properties["artifact_count"] + .as_u64() + .expect("run.completed should include artifact_count"); + assert!( + artifact_count > 0, + "artifact_count should be > 0, got {artifact_count}" + ); } // --------------------------------------------------------------------------- @@ -8880,7 +8845,11 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { registry.register("exit", Box::new(ExitHandler)); let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), remote_env.clone()); + let engine = WorkflowRunner::new( + registry, + Arc::new(EventEmitter::default()), + remote_env.clone(), + ); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -9009,7 +8978,7 @@ async fn node_dir_uses_visit_count_on_revisit() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -9291,7 +9260,7 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() { let node = Node::new("fix_code"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -9365,7 +9334,7 @@ async fn cli_backend_run_detects_changed_files() { let node = Node::new("implement"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -9400,7 +9369,7 @@ async fn cli_backend_run_with_codex_provider() { let node = Node::new("implement"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -9566,7 +9535,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() { .with_poll_interval(Duration::from_millis(10)); let node = Node::new("step"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let _ = env; // unused, just for the above struct @@ -9607,7 +9576,7 @@ async fn cli_backend_run_fails_on_unparseable_output() { let node = Node::new("step"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -9650,7 +9619,7 @@ async fn cli_backend_run_uses_node_model_override() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); backend @@ -9701,7 +9670,7 @@ async fn cli_backend_run_uses_node_provider_override() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); backend @@ -9736,7 +9705,7 @@ async fn cli_backend_run_writes_provider_used_json() { let node = Node::new("step"); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); backend @@ -9789,7 +9758,7 @@ async fn backend_router_delegates_to_cli_for_cli_node() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = router @@ -9833,7 +9802,7 @@ async fn backend_router_delegates_to_api_for_normal_node() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = router @@ -9880,7 +9849,7 @@ async fn backend_router_delegates_to_cli_for_backend_attr() { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = router @@ -9982,7 +9951,7 @@ async fn full_pipeline_with_cli_backend_node() { ); let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -10112,7 +10081,7 @@ async fn stylesheet_backend_property_routes_to_cli() { ); let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), env); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -10156,7 +10125,7 @@ async fn run_real_cli_test(provider: Provider, model: &str) { ); let context = Context::new(); - let emitter = Arc::new(EventEmitter::new()); + let emitter = Arc::new(EventEmitter::default()); let dir = tempfile::tempdir().unwrap(); let result = backend @@ -10382,7 +10351,7 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { // 4. Set up event collection and engine let run_dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let env: Arc = @@ -10421,16 +10390,13 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { let git_events: Vec<_> = events .iter() .filter_map(|e| { - if let WorkflowRunEvent::CheckpointCompleted { - node_id, - git_commit_sha: Some(sha), - .. - } = e - { - Some((node_id.clone(), sha.clone())) - } else { - None + if e.event != "checkpoint.completed" { + return None; } + Some(( + e.node_id.clone()?, + e.properties.get("git_commit_sha")?.as_str()?.to_string(), + )) }) .collect(); // work node gets a checkpoint commit (start is skipped, exit is terminal) @@ -10584,7 +10550,7 @@ async fn git_checkpoint_host_writes_shadow_branch() { serde_json::to_string(&run_record_json).unwrap(), ) .unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let env: Arc = Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone())); @@ -10774,7 +10740,7 @@ async fn parallel_git_branching_host_e2e() { // 4. Set up engine with FileWriterHandler for branches let run_dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let env: Arc = @@ -10944,7 +10910,7 @@ async fn parallel_git_branching_host_e2e() { let events = events.lock().unwrap(); let parallel_started: Vec<_> = events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::ParallelStarted { .. })) + .filter(|e| e.event == "parallel.started") .collect(); assert_eq!( parallel_started.len(), @@ -10954,7 +10920,7 @@ async fn parallel_git_branching_host_e2e() { let parallel_completed: Vec<_> = events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::ParallelCompleted { .. })) + .filter(|e| e.event == "parallel.completed") .collect(); assert_eq!( parallel_completed.len(), @@ -11044,7 +11010,7 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { graph.edges.push(Edge::new("work", "exit")); let run_dir = tempfile::tempdir().unwrap(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let _events = collect_events(&emitter); let env: Arc = @@ -11434,7 +11400,7 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { )), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11480,7 +11446,7 @@ async fn e2e_circuit_breaker_custom_limit() { Box::new(DeterministicFailHandler::new("same error every time")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11519,7 +11485,7 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { registry.register("exit", Box::new(ExitHandler)); registry.register("test_handler", Box::new(TransientInfraFailHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11565,7 +11531,7 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11604,7 +11570,7 @@ async fn e2e_circuit_breaker_loop_restart() { Box::new(DeterministicFailHandler::new("verify step failed")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11665,7 +11631,7 @@ async fn e2e_failure_signature_persisted_in_context() { Box::new(DeterministicFailHandler::new("test assertion failed")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11728,7 +11694,7 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { registry.register("exit", Box::new(ExitHandler)); registry.register("hint_handler", Box::new(SignatureHintHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11783,7 +11749,7 @@ async fn e2e_signature_maps_persist_in_checkpoint() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11898,7 +11864,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { let dir = tempfile::tempdir().unwrap(); let graph = circuit_breaker_self_loop_graph(Some(3)); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let mut registry = HandlerRegistry::new(Box::new(StartHandler)); @@ -11928,9 +11894,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { let events = events.lock().unwrap(); // Should have at least WorkflowRunStarted and some StageFailed/StageCompleted events - let has_pipeline_started = events - .iter() - .any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })); + let has_pipeline_started = events.iter().any(|e| e.event == "run.started"); assert!( has_pipeline_started, "WorkflowRunStarted event should be emitted" @@ -11941,11 +11905,11 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { // the stage event for that iteration is emitted, so we see limit-1 events. let stage_failed_count = events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::StageFailed { name, .. } if name == "work")) + .filter(|e| e.event == "stage.failed" && e.node_id.as_deref() == Some("work")) .count(); let stage_completed_count = events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::StageCompleted { name, .. } if name == "work")) + .filter(|e| e.event == "stage.completed" && e.node_id.as_deref() == Some("work")) .count(); let total_work_events = stage_completed_count + stage_failed_count; // With limit=3, the breaker fires on the 3rd failure before its event is emitted. @@ -11975,7 +11939,7 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12070,7 +12034,7 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { )), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12166,7 +12130,7 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { Box::new(ClassifiedFailHandler::always("deterministic")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12205,7 +12169,7 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { Box::new(ClassifiedFailHandler::always("structural")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12244,7 +12208,7 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { Box::new(ClassifiedFailHandler::always("budget_exhausted")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12283,7 +12247,7 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { Box::new(ClassifiedFailHandler::always("canceled")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12319,7 +12283,7 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { Box::new(ClassifiedFailHandler::always("compilation_loop")), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12359,7 +12323,7 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { Box::new(ClassifiedFailHandler::succeed_on("transient_infra", 1)), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12457,7 +12421,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { let events = Arc::new(std::sync::Mutex::new(Vec::new())); let events_clone = events.clone(); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); emitter.on_event(move |event| { events_clone.lock().unwrap().push(format!("{event:?}")); }); @@ -12484,11 +12448,11 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { "expected error to contain 'stall watchdog', got: {err}" ); - // Verify StallWatchdogTimeout event was emitted + // Verify the canonical watchdog timeout envelope was emitted. let collected = events.lock().unwrap(); assert!( - collected.iter().any(|e| e.contains("StallWatchdogTimeout")), - "expected StallWatchdogTimeout event in: {collected:?}" + collected.iter().any(|e| e.contains("watchdog.timeout")), + "expected watchdog.timeout event in: {collected:?}" ); } @@ -12517,7 +12481,7 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12562,7 +12526,7 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { registry.register("exit", Box::new(ExitHandler)); registry.register("slow", Box::new(SlowTestHandler { sleep_ms: 50 })); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12626,7 +12590,7 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { registry.register("exit", Box::new(ExitHandler)); registry.register("hanging", Box::new(HangingHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), local_env()); let run_options = RunOptions { settings: FabroSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12720,7 +12684,7 @@ async fn asset_collection_local_sandbox_success() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let emitter = EventEmitter::new(); + let emitter = EventEmitter::default(); let events = collect_events(&emitter); let engine = WorkflowRunner::new(registry, Arc::new(emitter), sandbox.clone()); @@ -12806,31 +12770,33 @@ async fn asset_collection_local_sandbox_success() { // Check that AssetCaptured events were emitted let captured_events = events.lock().unwrap(); - let asset_events: Vec<&WorkflowRunEvent> = captured_events + let asset_events: Vec<&RunEventEnvelope> = captured_events .iter() - .filter(|e| matches!(e, WorkflowRunEvent::AssetCaptured { .. })) + .filter(|e| e.event == "asset.captured") .collect(); assert!( !asset_events.is_empty(), "should emit at least one AssetCaptured event" ); - if let WorkflowRunEvent::AssetCaptured { - path, - mime, - content_md5, - content_sha256, - bytes, - attempt, - .. - } = asset_events[0] - { - assert!(!path.is_empty()); - assert!(!mime.is_empty()); - assert_eq!(content_md5.len(), 32); - assert_eq!(content_sha256.len(), 64); - assert!(*bytes > 0); - assert_eq!(*attempt, 1); - } + let asset_event = asset_events[0]; + assert!(!asset_event.properties["path"].as_str().unwrap().is_empty()); + assert!(!asset_event.properties["mime"].as_str().unwrap().is_empty()); + assert_eq!( + asset_event.properties["content_md5"] + .as_str() + .unwrap() + .len(), + 32 + ); + assert_eq!( + asset_event.properties["content_sha256"] + .as_str() + .unwrap() + .len(), + 64 + ); + assert!(asset_event.properties["bytes"].as_u64().unwrap() > 0); + assert_eq!(asset_event.properties["attempt"].as_u64().unwrap(), 1); } /// Local sandbox: assets are still collected even when the handler fails. @@ -12848,7 +12814,7 @@ async fn asset_collection_local_sandbox_on_failure() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), sandbox.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), sandbox.clone()); let mut graph = Graph::new("AssetCollectionFailTest"); graph.attrs.insert( @@ -12939,7 +12905,7 @@ async fn asset_collection_docker_sandbox() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), sandbox.clone()); + let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::default()), sandbox.clone()); let mut graph = Graph::new("DockerAssetTest"); graph.attrs.insert( @@ -13038,7 +13004,7 @@ async fn wait_timer_e2e() { let interviewer = Arc::new(AutoApproveInterviewer); let engine = WorkflowRunner::new( make_full_registry(interviewer), - Arc::new(EventEmitter::new()), + Arc::new(EventEmitter::default()), local_env(), ); let run_options = RunOptions {