From 871bc500e4e9bb20f38e2e77845954605dd01783 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 4 Apr 2026 10:38:44 -0400 Subject: [PATCH] refactor: unify workflow stored event model Add a shared StoredEvent schema in fabro-types and switch workflow, store, CLI, and server event handling to use it directly. This removes the writer/reader mismatch around flattened failure data, updates affected projections and progress rendering, and refreshes the fixture/snapshot coverage around the canonical event shape. --- .../src/commands/run/run_progress/event.rs | 26 +- .../src/commands/run/run_progress/mod.rs | 33 +- .../fabro-cli/src/commands/store/dump.rs | 17 +- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 15 +- lib/crates/fabro-cli/tests/it/cmd/logs.rs | 36 +- lib/crates/fabro-cli/tests/it/cmd/ps.rs | 6 +- lib/crates/fabro-cli/tests/it/cmd/run.rs | 38 +- .../fabro-cli/tests/it/cmd/system_prune.rs | 2 +- .../tests/it/workflow/dry_run_examples.rs | 10 +- lib/crates/fabro-server/src/server.rs | 6 +- lib/crates/fabro-store/src/run_state.rs | 512 ++++++++---------- lib/crates/fabro-store/src/slate/mod.rs | 113 ++++ lib/crates/fabro-store/src/types.rs | 11 +- lib/crates/fabro-types/src/graph.rs | 6 +- lib/crates/fabro-types/src/lib.rs | 2 + lib/crates/fabro-types/src/outcome.rs | 4 +- .../fabro-types/src/stored_event/agent.rs | 158 ++++++ .../fabro-types/src/stored_event/infra.rs | 192 +++++++ .../fabro-types/src/stored_event/misc.rs | 259 +++++++++ .../fabro-types/src/stored_event/mod.rs | 494 +++++++++++++++++ .../fabro-types/src/stored_event/run.rs | 110 ++++ .../fabro-types/src/stored_event/stage.rs | 117 ++++ lib/crates/fabro-types/src/usage.rs | 2 +- .../fabro-workflow/src/devcontainer_bridge.rs | 40 +- lib/crates/fabro-workflow/src/event.rs | 94 ++-- lib/crates/fabro-workflow/src/git.rs | 147 +++-- .../fabro-workflow/src/operations/start.rs | 56 +- .../src/pipeline/execute/tests.rs | 18 +- .../fabro-workflow/src/pipeline/initialize.rs | 2 +- .../fabro-workflow/src/pipeline/retro.rs | 19 +- lib/crates/fabro-workflow/src/test_support.rs | 2 +- .../fabro-workflow/tests/it/integration.rs | 15 +- 32 files changed, 2019 insertions(+), 543 deletions(-) create mode 100644 lib/crates/fabro-types/src/stored_event/agent.rs create mode 100644 lib/crates/fabro-types/src/stored_event/infra.rs create mode 100644 lib/crates/fabro-types/src/stored_event/misc.rs create mode 100644 lib/crates/fabro-types/src/stored_event/mod.rs create mode 100644 lib/crates/fabro-types/src/stored_event/run.rs create mode 100644 lib/crates/fabro-types/src/stored_event/stage.rs diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs index 245bf72a3..1172612f7 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs @@ -1,6 +1,7 @@ use std::convert::TryFrom; use chrono::{DateTime, Utc}; +use fabro_types::StoredEvent; use fabro_workflow::event::RunNoticeLevel; use fabro_workflow::outcome::{StageUsage, compute_stage_cost}; use serde_json::{Map, Value}; @@ -240,10 +241,7 @@ pub(super) enum ProgressEvent { } #[allow(clippy::needless_pass_by_value)] -pub(super) fn from_envelope_fields( - event_name: &str, - fields: &Map, -) -> Option { +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"), @@ -457,6 +455,26 @@ pub(super) fn from_envelope_fields( } } +pub(super) fn from_stored_event(stored: &StoredEvent) -> Option { + let Value::Object(fields) = stored.to_value().ok()? else { + return None; + }; + let event_name = fields.get("event")?.as_str()?; + from_envelope_fields(event_name, &fields) +} + +pub(super) fn from_json_line(line: &str) -> Option { + if let Ok(stored) = StoredEvent::from_json_str(line) { + return from_stored_event(&stored); + } + + let Value::Object(fields) = serde_json::from_str(line).ok()? else { + return None; + }; + let event_name = fields.get("event")?.as_str()?; + from_envelope_fields(event_name, &fields) +} + fn parse_run_notice_level(level: Option<&str>) -> RunNoticeLevel { match level.unwrap_or("info") { "warn" => RunNoticeLevel::Warn, diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 98cb6ee73..be40ac95b 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -1,6 +1,4 @@ -use serde_json::Value; - -use fabro_workflow::event::RunEventEnvelope; +use fabro_types::StoredEvent; mod event; mod info_display; @@ -9,7 +7,7 @@ mod setup_display; mod stage_display; mod styles; -use event::{ProgressEvent, from_envelope_fields}; +use event::{ProgressEvent, from_json_line, from_stored_event}; use info_display::InfoDisplay; use renderer::ProgressRenderer; use setup_display::SetupDisplay; @@ -68,23 +66,14 @@ impl ProgressUI { } #[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) { + pub(crate) fn handle_event(&mut self, event: &StoredEvent) { + if let Some(progress_event) = from_stored_event(event) { 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) { + if let Some(progress_event) = from_json_line(line) { self.dispatch(progress_event); } } @@ -428,7 +417,9 @@ mod tests { 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::event::{ + RunNoticeLevel, WorkflowRunEvent, canonicalize_event, to_stored_event, + }; use fabro_workflow::outcome::StageUsage; use super::*; @@ -470,13 +461,13 @@ mod tests { } fn emit(ui: &mut ProgressUI, event: WorkflowRunEvent) { - let envelope = canonicalize_event(&fixtures::RUN_1, &event); - ui.handle_event(&envelope); + let stored = to_stored_event(&fixtures::RUN_1, &event); + ui.handle_event(&stored); } fn emit_ref(ui: &mut ProgressUI, event: &WorkflowRunEvent) { - let envelope = canonicalize_event(&fixtures::RUN_1, event); - ui.handle_event(&envelope); + let stored = to_stored_event(&fixtures::RUN_1, event); + ui.handle_event(&stored); } fn agent_event(stage: &str, event: AgentEvent) -> WorkflowRunEvent { diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index f7dc741b6..f4cc1bda8 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -513,7 +513,20 @@ mod tests { "id": format!("evt-{run_id}-stage-completed"), "ts": "2026-03-27T12:00:01.000Z", "run_id": run_id.to_string(), - "event": "stage.completed" + "event": "stage.completed", + "node_id": "code", + "node_label": "Code", + "properties": { + "index": 1, + "duration_ms": 1, + "status": "success", + "response": "Implemented", + "notes": "all good", + "files_touched": ["src/lib.rs"], + "node_visits": {"code": 2}, + "attempt": 1, + "max_attempts": 1 + } }), &run_id, ) @@ -562,7 +575,7 @@ mod tests { ); let node_status: NodeStatusRecord = read_json(&output.path().join("nodes/code/visit-2/status.json")); - assert_eq!(node_status.status, StageStatus::PartialSuccess); + assert_eq!(node_status.status, StageStatus::Success); assert_eq!( std::fs::read_to_string(output.path().join("nodes/code/visit-2/stdout.log")).unwrap(), "stdout line" diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 504b68593..64566dec2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -453,12 +453,8 @@ fn attach_json_errors_without_prompting_for_human_input() { "event": "sandbox.ready", "id": "[EVENT_ID]", "properties": { - "cpu": null, "duration_ms": "[DURATION_MS]", - "memory": null, - "name": null, - "provider": "local", - "url": null + "provider": "local" }, "run_id": "[ULID]", "ts": "[TIMESTAMP]" @@ -521,17 +517,12 @@ fn attach_json_errors_without_prompting_for_human_input() { "internal.thread_id": null }, "duration_ms": "[DURATION_MS]", - "files_touched": [], "index": 0, "max_attempts": 1, "node_visits": { "start": 1 }, - "notes": null, - "preferred_label": null, - "status": "success", - "suggested_next_ids": [], - "usage": null + "status": "success" }, "run_id": "[ULID]", "ts": "[TIMESTAMP]" @@ -540,10 +531,8 @@ fn attach_json_errors_without_prompting_for_human_input() { "event": "edge.selected", "id": "[EVENT_ID]", "properties": { - "condition": null, "from_node": "start", "is_jump": false, - "label": null, "reason": "unconditional", "stage_status": "success", "to_node": "approve" diff --git a/lib/crates/fabro-cli/tests/it/cmd/logs.rs b/lib/crates/fabro-cli/tests/it/cmd/logs.rs index 03b7c0166..837d534c8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/logs.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/logs.rs @@ -63,28 +63,28 @@ fn logs_completed_run_outputs_raw_ndjson() { success: true exit_code: 0 ----- stdout ----- - {"event":"run.created","id":"[EVENT_ID]","properties":{"graph":{"attrs":{"goal":{"String":"Run tests and report results"},"rankdir":{"String":"LR"}},"edges":[{"attrs":{},"from":"start","to":"run_tests"},{"attrs":{},"from":"run_tests","to":"report"},{"attrs":{},"from":"report","to":"exit"}],"name":"Simple","nodes":{"exit":{"attrs":{"label":{"String":"Exit"},"shape":{"String":"Msquare"}},"id":"exit"},"report":{"attrs":{"label":{"String":"Report"},"prompt":{"String":"Summarize the test results"}},"id":"report"},"run_tests":{"attrs":{"label":{"String":"Run Tests"},"prompt":{"String":"Run the test suite and report results"}},"id":"run_tests"},"start":{"attrs":{"label":{"String":"Start"},"shape":{"String":"Mdiamond"}},"id":"start"}}},"host_repo_path":"[TEMP_DIR]","labels":{},"run_dir":"[STORAGE_DIR]/runs/20260403-[ULID]","settings":{"auto_approve":true,"dry_run":true,"fabro":{"root":"fabro/"},"features":{"retros":false,"session_sandboxes":false},"goal":"Run tests and report results","hooks":[{"blocking":true,"command":"cargo fmt","event":"post_tool_use","matcher":"write_file|edit_file|apply_patch","name":"cargo-fmt","sandbox":null,"timeout_ms":null}],"llm":{"fallbacks":null,"model":"claude-sonnet-4-6","provider":"anthropic"},"mode":"standalone","no_retro":true,"pull_request":{"auto_merge":false,"draft":false,"enabled":true,"merge_strategy":"squash"},"sandbox":{"daytona":{"auto_stop_interval":30,"labels":{"repo":"fabro-sh/fabro"},"network":null,"skip_clone":false,"snapshot":{"cpu":4,"disk":20,"dockerfile":"FROM ubuntu:24.04/n/nRUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 && rm -rf /var/lib/apt/lists/*/n/n# GitHub CLI/nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && apt-get update && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/*/n/n# Rust/nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y/nENV PATH=\"/root/.cargo/bin:${PATH}\"/nRUN cargo install cargo-nextest --locked/nENV CARGO_INCREMENTAL=0/n/n# Bun/nRUN curl -fsSL https://bun.sh/install | bash/nENV PATH=\"/root/.bun/bin:${PATH}\"/n/nWORKDIR /root/n","memory":8,"name":"fabro-v6"}},"devcontainer":null,"env":null,"local":null,"preserve":null,"provider":"local"},"storage_dir":"[STORAGE_DIR]","version":1},"workflow_slug":"simple","workflow_source":"digraph Simple {/n graph [goal=\"Run tests and report results\"]/n rankdir=LR/n/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n/n run_tests [label=\"Run Tests\", prompt=\"Run the test suite and report results\"]/n report [label=\"Report\", prompt=\"Summarize the test results\"]/n/n start -> run_tests -> report -> exit/n}/n","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"run.created","id":"[EVENT_ID]","properties":{"graph":{"attrs":{"goal":{"String":"Run tests and report results"},"rankdir":{"String":"LR"}},"edges":[{"attrs":{},"from":"start","to":"run_tests"},{"attrs":{},"from":"run_tests","to":"report"},{"attrs":{},"from":"report","to":"exit"}],"name":"Simple","nodes":{"exit":{"attrs":{"label":{"String":"Exit"},"shape":{"String":"Msquare"}},"id":"exit"},"report":{"attrs":{"label":{"String":"Report"},"prompt":{"String":"Summarize the test results"}},"id":"report"},"run_tests":{"attrs":{"label":{"String":"Run Tests"},"prompt":{"String":"Run the test suite and report results"}},"id":"run_tests"},"start":{"attrs":{"label":{"String":"Start"},"shape":{"String":"Mdiamond"}},"id":"start"}}},"host_repo_path":"[TEMP_DIR]","labels":{},"run_dir":"[STORAGE_DIR]/runs/20260404-[ULID]","settings":{"auto_approve":true,"dry_run":true,"fabro":{"root":"fabro/"},"features":{"retros":false,"session_sandboxes":false},"goal":"Run tests and report results","hooks":[{"blocking":true,"command":"cargo fmt","event":"post_tool_use","matcher":"write_file|edit_file|apply_patch","name":"cargo-fmt","sandbox":null,"timeout_ms":null}],"llm":{"fallbacks":null,"model":"claude-sonnet-4-6","provider":"anthropic"},"mode":"standalone","no_retro":true,"pull_request":{"auto_merge":false,"draft":false,"enabled":true,"merge_strategy":"squash"},"sandbox":{"daytona":{"auto_stop_interval":30,"labels":{"repo":"fabro-sh/fabro"},"network":null,"skip_clone":false,"snapshot":{"cpu":4,"disk":20,"dockerfile":"FROM ubuntu:24.04/n/nRUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 && rm -rf /var/lib/apt/lists/*/n/n# GitHub CLI/nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && apt-get update && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/*/n/n# Rust/nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y/nENV PATH=\"/root/.cargo/bin:${PATH}\"/nRUN cargo install cargo-nextest --locked/nENV CARGO_INCREMENTAL=0/n/n# Bun/nRUN curl -fsSL https://bun.sh/install | bash/nENV PATH=\"/root/.bun/bin:${PATH}\"/n/nWORKDIR /root/n","memory":8,"name":"fabro-v6"}},"devcontainer":null,"env":null,"local":null,"preserve":null,"provider":"local"},"storage_dir":"[STORAGE_DIR]","version":1},"workflow_slug":"simple","workflow_source":"digraph Simple {/n graph [goal=\"Run tests and report results\"]/n rankdir=LR/n/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n/n run_tests [label=\"Run Tests\", prompt=\"Run the test suite and report results\"]/n report [label=\"Report\", prompt=\"Summarize the test results\"]/n/n start -> run_tests -> report -> exit/n}/n","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.submitted","id":"[EVENT_ID]","properties":{},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.starting","id":"[EVENT_ID]","properties":{"reason":"sandbox_initializing"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"sandbox.initializing","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"sandbox.ready","id":"[EVENT_ID]","properties":{"cpu":null,"duration_ms": [DURATION_MS],"memory":null,"name":null,"provider":"local","url":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"sandbox.ready","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"sandbox.initialized","id":"[EVENT_ID]","properties":{"provider":"local","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.started","id":"[EVENT_ID]","properties":{"goal":"Run tests and report results","name":"Simple"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.running","id":"[EVENT_ID]","properties":{},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"stage.started","id":"[EVENT_ID]","node_id":"start","node_label":"Start","properties":{"attempt":1,"handler_type":"start","index":0,"max_attempts":1},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"stage.completed","id":"[EVENT_ID]","node_id":"start","node_label":"Start","properties":{"attempt":1,"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"start","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.run_id":"[ULID]","internal.thread_id":null},"duration_ms": [DURATION_MS],"files_touched":[],"index":0,"max_attempts":1,"node_visits":{"start":1},"notes":"[Simulated] start","preferred_label":null,"status":"success","suggested_next_ids":[],"usage":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"edge.selected","id":"[EVENT_ID]","properties":{"condition":null,"from_node":"start","is_jump":false,"label":null,"reason":"unconditional","stage_status":"success","to_node":"run_tests"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"stage.completed","id":"[EVENT_ID]","node_id":"start","node_label":"Start","properties":{"attempt":1,"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"start","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.run_id":"[ULID]","internal.thread_id":null},"duration_ms": [DURATION_MS],"index":0,"max_attempts":1,"node_visits":{"start":1},"notes":"[Simulated] start","status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"edge.selected","id":"[EVENT_ID]","properties":{"from_node":"start","is_jump":false,"reason":"unconditional","stage_status":"success","to_node":"run_tests"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"checkpoint.completed","id":"[EVENT_ID]","node_id":"start","node_label":"start","properties":{"completed_nodes":["start"],"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"start","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":null,"outcome":"success"},"current_node":"start","next_node_id":"run_tests","node_outcomes":{"start":{"notes":"[Simulated] start","status":"success","usage":null}},"node_visits":{"start":1},"status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"stage.started","id":"[EVENT_ID]","node_id":"run_tests","node_label":"Run Tests","properties":{"attempt":1,"handler_type":"agent","index":1,"max_attempts":1},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"stage.completed","id":"[EVENT_ID]","node_id":"run_tests","node_label":"Run Tests","properties":{"attempt":1,"context_updates":{"last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","response.run_tests":"[Simulated] Response for stage: run_tests"},"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"run_tests","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"start","outcome":"success","thread.start.current_node":"run_tests"},"duration_ms": [DURATION_MS],"files_touched":[],"index":1,"max_attempts":1,"node_visits":{"run_tests":1,"start":1},"notes":"[Simulated] run_tests","preferred_label":null,"response":"[Simulated] Response for stage: run_tests","status":"success","suggested_next_ids":[],"usage":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"edge.selected","id":"[EVENT_ID]","properties":{"condition":null,"from_node":"run_tests","is_jump":false,"label":null,"reason":"unconditional","stage_status":"success","to_node":"report"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"stage.completed","id":"[EVENT_ID]","node_id":"run_tests","node_label":"Run Tests","properties":{"attempt":1,"context_updates":{"last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","response.run_tests":"[Simulated] Response for stage: run_tests"},"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"run_tests","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"start","outcome":"success","thread.start.current_node":"run_tests"},"duration_ms": [DURATION_MS],"index":1,"max_attempts":1,"node_visits":{"run_tests":1,"start":1},"notes":"[Simulated] run_tests","response":"[Simulated] Response for stage: run_tests","status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"edge.selected","id":"[EVENT_ID]","properties":{"from_node":"run_tests","is_jump":false,"reason":"unconditional","stage_status":"success","to_node":"report"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"checkpoint.completed","id":"[EVENT_ID]","node_id":"run_tests","node_label":"run_tests","properties":{"completed_nodes":["start","run_tests"],"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"run_tests","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.run_tests":0,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"start","last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","outcome":"success","response.run_tests":"[Simulated] Response for stage: run_tests","thread.start.current_node":"run_tests"},"current_node":"run_tests","next_node_id":"report","node_outcomes":{"run_tests":{"context_updates":{"last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","response.run_tests":"[Simulated] Response for stage: run_tests"},"notes":"[Simulated] run_tests","status":"success","usage":null},"start":{"notes":"[Simulated] start","status":"success","usage":null}},"node_visits":{"run_tests":1,"start":1},"status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"stage.started","id":"[EVENT_ID]","node_id":"report","node_label":"Report","properties":{"attempt":1,"handler_type":"agent","index":2,"max_attempts":1},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"stage.completed","id":"[EVENT_ID]","node_id":"report","node_label":"Report","properties":{"attempt":1,"context_updates":{"last_response":"[Simulated] Response for stage: report","last_stage":"report","response.report":"[Simulated] Response for stage: report"},"context_values":{"current.preamble":"Goal: Run tests and report results/n/n## Completed stages/n- **run_tests**: success/n","current_node":"report","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.run_tests":0,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"run_tests","last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","outcome":"success","response.run_tests":"[Simulated] Response for stage: run_tests","thread.run_tests.current_node":"report","thread.start.current_node":"run_tests"},"duration_ms": [DURATION_MS],"files_touched":[],"index":2,"max_attempts":1,"node_visits":{"report":1,"run_tests":1,"start":1},"notes":"[Simulated] report","preferred_label":null,"response":"[Simulated] Response for stage: report","status":"success","suggested_next_ids":[],"usage":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"edge.selected","id":"[EVENT_ID]","properties":{"condition":null,"from_node":"report","is_jump":false,"label":null,"reason":"unconditional","stage_status":"success","to_node":"exit"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"stage.completed","id":"[EVENT_ID]","node_id":"report","node_label":"Report","properties":{"attempt":1,"context_updates":{"last_response":"[Simulated] Response for stage: report","last_stage":"report","response.report":"[Simulated] Response for stage: report"},"context_values":{"current.preamble":"Goal: Run tests and report results/n/n## Completed stages/n- **run_tests**: success/n","current_node":"report","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.run_tests":0,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"run_tests","last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","outcome":"success","response.run_tests":"[Simulated] Response for stage: run_tests","thread.run_tests.current_node":"report","thread.start.current_node":"run_tests"},"duration_ms": [DURATION_MS],"index":2,"max_attempts":1,"node_visits":{"report":1,"run_tests":1,"start":1},"notes":"[Simulated] report","response":"[Simulated] Response for stage: report","status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"edge.selected","id":"[EVENT_ID]","properties":{"from_node":"report","is_jump":false,"reason":"unconditional","stage_status":"success","to_node":"exit"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"checkpoint.completed","id":"[EVENT_ID]","node_id":"report","node_label":"report","properties":{"completed_nodes":["start","run_tests","report"],"context_values":{"current.preamble":"Goal: Run tests and report results/n/n## Completed stages/n- **run_tests**: success/n","current_node":"report","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.report":0,"internal.retry_count.run_tests":0,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"run_tests","last_response":"[Simulated] Response for stage: report","last_stage":"report","outcome":"success","response.report":"[Simulated] Response for stage: report","response.run_tests":"[Simulated] Response for stage: run_tests","thread.run_tests.current_node":"report","thread.start.current_node":"run_tests"},"current_node":"report","next_node_id":"exit","node_outcomes":{"report":{"context_updates":{"last_response":"[Simulated] Response for stage: report","last_stage":"report","response.report":"[Simulated] Response for stage: report"},"notes":"[Simulated] report","status":"success","usage":null},"run_tests":{"context_updates":{"last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","response.run_tests":"[Simulated] Response for stage: run_tests"},"notes":"[Simulated] run_tests","status":"success","usage":null},"start":{"notes":"[Simulated] start","status":"success","usage":null}},"node_visits":{"report":1,"run_tests":1,"start":1},"status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"stage.started","id":"[EVENT_ID]","node_id":"exit","node_label":"Exit","properties":{"attempt":1,"handler_type":"exit","index":3,"max_attempts":1},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"stage.completed","id":"[EVENT_ID]","node_id":"exit","node_label":"Exit","properties":{"attempt":1,"duration_ms": [DURATION_MS],"files_touched":[],"index":3,"max_attempts":1,"notes":null,"preferred_label":null,"status":"success","suggested_next_ids":[],"usage":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"stage.completed","id":"[EVENT_ID]","node_id":"exit","node_label":"Exit","properties":{"attempt":1,"duration_ms": [DURATION_MS],"index":3,"max_attempts":1,"status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.completed","id":"[EVENT_ID]","properties":{"artifact_count":0,"duration_ms": [DURATION_MS],"reason":"completed","status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} @@ -228,28 +228,28 @@ fn logs_follow_detached_run_streams_until_completion() { success: true exit_code: 0 ----- stdout ----- - {"event":"run.created","id":"[EVENT_ID]","properties":{"graph":{"attrs":{"goal":{"String":"Run tests and report results"},"rankdir":{"String":"LR"}},"edges":[{"attrs":{},"from":"start","to":"run_tests"},{"attrs":{},"from":"run_tests","to":"report"},{"attrs":{},"from":"report","to":"exit"}],"name":"Simple","nodes":{"exit":{"attrs":{"label":{"String":"Exit"},"shape":{"String":"Msquare"}},"id":"exit"},"report":{"attrs":{"label":{"String":"Report"},"prompt":{"String":"Summarize the test results"}},"id":"report"},"run_tests":{"attrs":{"label":{"String":"Run Tests"},"prompt":{"String":"Run the test suite and report results"}},"id":"run_tests"},"start":{"attrs":{"label":{"String":"Start"},"shape":{"String":"Mdiamond"}},"id":"start"}}},"host_repo_path":"[TEMP_DIR]","labels":{},"run_dir":"[STORAGE_DIR]/runs/20260403-[ULID]","settings":{"auto_approve":true,"dry_run":true,"fabro":{"root":"fabro/"},"features":{"retros":false,"session_sandboxes":false},"goal":"Run tests and report results","hooks":[{"blocking":true,"command":"cargo fmt","event":"post_tool_use","matcher":"write_file|edit_file|apply_patch","name":"cargo-fmt","sandbox":null,"timeout_ms":null}],"llm":{"fallbacks":null,"model":"claude-sonnet-4-6","provider":"anthropic"},"mode":"standalone","no_retro":true,"pull_request":{"auto_merge":false,"draft":false,"enabled":true,"merge_strategy":"squash"},"sandbox":{"daytona":{"auto_stop_interval":30,"labels":{"repo":"fabro-sh/fabro"},"network":null,"skip_clone":false,"snapshot":{"cpu":4,"disk":20,"dockerfile":"FROM ubuntu:24.04/n/nRUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 && rm -rf /var/lib/apt/lists/*/n/n# GitHub CLI/nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && apt-get update && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/*/n/n# Rust/nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y/nENV PATH=\"/root/.cargo/bin:${PATH}\"/nRUN cargo install cargo-nextest --locked/nENV CARGO_INCREMENTAL=0/n/n# Bun/nRUN curl -fsSL https://bun.sh/install | bash/nENV PATH=\"/root/.bun/bin:${PATH}\"/n/nWORKDIR /root/n","memory":8,"name":"fabro-v6"}},"devcontainer":null,"env":null,"local":null,"preserve":null,"provider":"local"},"storage_dir":"[STORAGE_DIR]","version":1},"workflow_slug":"simple","workflow_source":"digraph Simple {/n graph [goal=\"Run tests and report results\"]/n rankdir=LR/n/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n/n run_tests [label=\"Run Tests\", prompt=\"Run the test suite and report results\"]/n report [label=\"Report\", prompt=\"Summarize the test results\"]/n/n start -> run_tests -> report -> exit/n}/n","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"run.created","id":"[EVENT_ID]","properties":{"graph":{"attrs":{"goal":{"String":"Run tests and report results"},"rankdir":{"String":"LR"}},"edges":[{"attrs":{},"from":"start","to":"run_tests"},{"attrs":{},"from":"run_tests","to":"report"},{"attrs":{},"from":"report","to":"exit"}],"name":"Simple","nodes":{"exit":{"attrs":{"label":{"String":"Exit"},"shape":{"String":"Msquare"}},"id":"exit"},"report":{"attrs":{"label":{"String":"Report"},"prompt":{"String":"Summarize the test results"}},"id":"report"},"run_tests":{"attrs":{"label":{"String":"Run Tests"},"prompt":{"String":"Run the test suite and report results"}},"id":"run_tests"},"start":{"attrs":{"label":{"String":"Start"},"shape":{"String":"Mdiamond"}},"id":"start"}}},"host_repo_path":"[TEMP_DIR]","labels":{},"run_dir":"[STORAGE_DIR]/runs/20260404-[ULID]","settings":{"auto_approve":true,"dry_run":true,"fabro":{"root":"fabro/"},"features":{"retros":false,"session_sandboxes":false},"goal":"Run tests and report results","hooks":[{"blocking":true,"command":"cargo fmt","event":"post_tool_use","matcher":"write_file|edit_file|apply_patch","name":"cargo-fmt","sandbox":null,"timeout_ms":null}],"llm":{"fallbacks":null,"model":"claude-sonnet-4-6","provider":"anthropic"},"mode":"standalone","no_retro":true,"pull_request":{"auto_merge":false,"draft":false,"enabled":true,"merge_strategy":"squash"},"sandbox":{"daytona":{"auto_stop_interval":30,"labels":{"repo":"fabro-sh/fabro"},"network":null,"skip_clone":false,"snapshot":{"cpu":4,"disk":20,"dockerfile":"FROM ubuntu:24.04/n/nRUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 && rm -rf /var/lib/apt/lists/*/n/n# GitHub CLI/nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && apt-get update && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/*/n/n# Rust/nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y/nENV PATH=\"/root/.cargo/bin:${PATH}\"/nRUN cargo install cargo-nextest --locked/nENV CARGO_INCREMENTAL=0/n/n# Bun/nRUN curl -fsSL https://bun.sh/install | bash/nENV PATH=\"/root/.bun/bin:${PATH}\"/n/nWORKDIR /root/n","memory":8,"name":"fabro-v6"}},"devcontainer":null,"env":null,"local":null,"preserve":null,"provider":"local"},"storage_dir":"[STORAGE_DIR]","version":1},"workflow_slug":"simple","workflow_source":"digraph Simple {/n graph [goal=\"Run tests and report results\"]/n rankdir=LR/n/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n/n run_tests [label=\"Run Tests\", prompt=\"Run the test suite and report results\"]/n report [label=\"Report\", prompt=\"Summarize the test results\"]/n/n start -> run_tests -> report -> exit/n}/n","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.submitted","id":"[EVENT_ID]","properties":{},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.starting","id":"[EVENT_ID]","properties":{"reason":"sandbox_initializing"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"sandbox.initializing","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"sandbox.ready","id":"[EVENT_ID]","properties":{"cpu":null,"duration_ms": [DURATION_MS],"memory":null,"name":null,"provider":"local","url":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"sandbox.ready","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"sandbox.initialized","id":"[EVENT_ID]","properties":{"provider":"local","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.started","id":"[EVENT_ID]","properties":{"goal":"Run tests and report results","name":"Simple"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.running","id":"[EVENT_ID]","properties":{},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"stage.started","id":"[EVENT_ID]","node_id":"start","node_label":"Start","properties":{"attempt":1,"handler_type":"start","index":0,"max_attempts":1},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"stage.completed","id":"[EVENT_ID]","node_id":"start","node_label":"Start","properties":{"attempt":1,"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"start","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.run_id":"[ULID]","internal.thread_id":null},"duration_ms": [DURATION_MS],"files_touched":[],"index":0,"max_attempts":1,"node_visits":{"start":1},"notes":"[Simulated] start","preferred_label":null,"status":"success","suggested_next_ids":[],"usage":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"edge.selected","id":"[EVENT_ID]","properties":{"condition":null,"from_node":"start","is_jump":false,"label":null,"reason":"unconditional","stage_status":"success","to_node":"run_tests"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"stage.completed","id":"[EVENT_ID]","node_id":"start","node_label":"Start","properties":{"attempt":1,"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"start","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.run_id":"[ULID]","internal.thread_id":null},"duration_ms": [DURATION_MS],"index":0,"max_attempts":1,"node_visits":{"start":1},"notes":"[Simulated] start","status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"edge.selected","id":"[EVENT_ID]","properties":{"from_node":"start","is_jump":false,"reason":"unconditional","stage_status":"success","to_node":"run_tests"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"checkpoint.completed","id":"[EVENT_ID]","node_id":"start","node_label":"start","properties":{"completed_nodes":["start"],"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"start","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":null,"outcome":"success"},"current_node":"start","next_node_id":"run_tests","node_outcomes":{"start":{"notes":"[Simulated] start","status":"success","usage":null}},"node_visits":{"start":1},"status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"stage.started","id":"[EVENT_ID]","node_id":"run_tests","node_label":"Run Tests","properties":{"attempt":1,"handler_type":"agent","index":1,"max_attempts":1},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"stage.completed","id":"[EVENT_ID]","node_id":"run_tests","node_label":"Run Tests","properties":{"attempt":1,"context_updates":{"last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","response.run_tests":"[Simulated] Response for stage: run_tests"},"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"run_tests","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"start","outcome":"success","thread.start.current_node":"run_tests"},"duration_ms": [DURATION_MS],"files_touched":[],"index":1,"max_attempts":1,"node_visits":{"run_tests":1,"start":1},"notes":"[Simulated] run_tests","preferred_label":null,"response":"[Simulated] Response for stage: run_tests","status":"success","suggested_next_ids":[],"usage":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"edge.selected","id":"[EVENT_ID]","properties":{"condition":null,"from_node":"run_tests","is_jump":false,"label":null,"reason":"unconditional","stage_status":"success","to_node":"report"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"stage.completed","id":"[EVENT_ID]","node_id":"run_tests","node_label":"Run Tests","properties":{"attempt":1,"context_updates":{"last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","response.run_tests":"[Simulated] Response for stage: run_tests"},"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"run_tests","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"start","outcome":"success","thread.start.current_node":"run_tests"},"duration_ms": [DURATION_MS],"index":1,"max_attempts":1,"node_visits":{"run_tests":1,"start":1},"notes":"[Simulated] run_tests","response":"[Simulated] Response for stage: run_tests","status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"edge.selected","id":"[EVENT_ID]","properties":{"from_node":"run_tests","is_jump":false,"reason":"unconditional","stage_status":"success","to_node":"report"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"checkpoint.completed","id":"[EVENT_ID]","node_id":"run_tests","node_label":"run_tests","properties":{"completed_nodes":["start","run_tests"],"context_values":{"current.preamble":"Goal: Run tests and report results/n","current_node":"run_tests","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.run_tests":0,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"start","last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","outcome":"success","response.run_tests":"[Simulated] Response for stage: run_tests","thread.start.current_node":"run_tests"},"current_node":"run_tests","next_node_id":"report","node_outcomes":{"run_tests":{"context_updates":{"last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","response.run_tests":"[Simulated] Response for stage: run_tests"},"notes":"[Simulated] run_tests","status":"success","usage":null},"start":{"notes":"[Simulated] start","status":"success","usage":null}},"node_visits":{"run_tests":1,"start":1},"status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"stage.started","id":"[EVENT_ID]","node_id":"report","node_label":"Report","properties":{"attempt":1,"handler_type":"agent","index":2,"max_attempts":1},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"stage.completed","id":"[EVENT_ID]","node_id":"report","node_label":"Report","properties":{"attempt":1,"context_updates":{"last_response":"[Simulated] Response for stage: report","last_stage":"report","response.report":"[Simulated] Response for stage: report"},"context_values":{"current.preamble":"Goal: Run tests and report results/n/n## Completed stages/n- **run_tests**: success/n","current_node":"report","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.run_tests":0,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"run_tests","last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","outcome":"success","response.run_tests":"[Simulated] Response for stage: run_tests","thread.run_tests.current_node":"report","thread.start.current_node":"run_tests"},"duration_ms": [DURATION_MS],"files_touched":[],"index":2,"max_attempts":1,"node_visits":{"report":1,"run_tests":1,"start":1},"notes":"[Simulated] report","preferred_label":null,"response":"[Simulated] Response for stage: report","status":"success","suggested_next_ids":[],"usage":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"edge.selected","id":"[EVENT_ID]","properties":{"condition":null,"from_node":"report","is_jump":false,"label":null,"reason":"unconditional","stage_status":"success","to_node":"exit"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"stage.completed","id":"[EVENT_ID]","node_id":"report","node_label":"Report","properties":{"attempt":1,"context_updates":{"last_response":"[Simulated] Response for stage: report","last_stage":"report","response.report":"[Simulated] Response for stage: report"},"context_values":{"current.preamble":"Goal: Run tests and report results/n/n## Completed stages/n- **run_tests**: success/n","current_node":"report","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.run_tests":0,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"run_tests","last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","outcome":"success","response.run_tests":"[Simulated] Response for stage: run_tests","thread.run_tests.current_node":"report","thread.start.current_node":"run_tests"},"duration_ms": [DURATION_MS],"index":2,"max_attempts":1,"node_visits":{"report":1,"run_tests":1,"start":1},"notes":"[Simulated] report","response":"[Simulated] Response for stage: report","status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"edge.selected","id":"[EVENT_ID]","properties":{"from_node":"report","is_jump":false,"reason":"unconditional","stage_status":"success","to_node":"exit"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"checkpoint.completed","id":"[EVENT_ID]","node_id":"report","node_label":"report","properties":{"completed_nodes":["start","run_tests","report"],"context_values":{"current.preamble":"Goal: Run tests and report results/n/n## Completed stages/n- **run_tests**: success/n","current_node":"report","failure_class":"","failure_signature":"","graph.goal":"Run tests and report results","graph.rankdir":"LR","internal.fidelity":"compact","internal.node_visit_count":1,"internal.retry_count.report":0,"internal.retry_count.run_tests":0,"internal.retry_count.start":0,"internal.run_id":"[ULID]","internal.thread_id":"run_tests","last_response":"[Simulated] Response for stage: report","last_stage":"report","outcome":"success","response.report":"[Simulated] Response for stage: report","response.run_tests":"[Simulated] Response for stage: run_tests","thread.run_tests.current_node":"report","thread.start.current_node":"run_tests"},"current_node":"report","next_node_id":"exit","node_outcomes":{"report":{"context_updates":{"last_response":"[Simulated] Response for stage: report","last_stage":"report","response.report":"[Simulated] Response for stage: report"},"notes":"[Simulated] report","status":"success","usage":null},"run_tests":{"context_updates":{"last_response":"[Simulated] Response for stage: run_tests","last_stage":"run_tests","response.run_tests":"[Simulated] Response for stage: run_tests"},"notes":"[Simulated] run_tests","status":"success","usage":null},"start":{"notes":"[Simulated] start","status":"success","usage":null}},"node_visits":{"report":1,"run_tests":1,"start":1},"status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"stage.started","id":"[EVENT_ID]","node_id":"exit","node_label":"Exit","properties":{"attempt":1,"handler_type":"exit","index":3,"max_attempts":1},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"stage.completed","id":"[EVENT_ID]","node_id":"exit","node_label":"Exit","properties":{"attempt":1,"duration_ms": [DURATION_MS],"files_touched":[],"index":3,"max_attempts":1,"notes":null,"preferred_label":null,"status":"success","suggested_next_ids":[],"usage":null},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"event":"stage.completed","id":"[EVENT_ID]","node_id":"exit","node_label":"Exit","properties":{"attempt":1,"duration_ms": [DURATION_MS],"index":3,"max_attempts":1,"status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"run.completed","id":"[EVENT_ID]","properties":{"artifact_count":0,"duration_ms": [DURATION_MS],"reason":"completed","status":"success"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} {"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index 260609588..965b9f54f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -73,7 +73,7 @@ fn ps_all_json_lists_created_and_completed_runs() { [ { "run_id": "[ULID]", - "dir_name": "20260403-[ULID]", + "dir_name": "20260404-[ULID]", "workflow_name": "Simple", "workflow_slug": "simple", "status": "submitted", @@ -87,7 +87,7 @@ fn ps_all_json_lists_created_and_completed_runs() { }, { "run_id": "[ULID]", - "dir_name": "20260403-[ULID]", + "dir_name": "20260404-[ULID]", "workflow_name": "Simple", "workflow_slug": "simple", "status": "succeeded", @@ -180,7 +180,7 @@ fn ps_filters_by_workflow_and_label() { [ { "run_id": "[ULID]", - "dir_name": "20260403-[ULID]", + "dir_name": "20260404-[ULID]", "workflow_name": "Simple", "workflow_slug": "simple", "status": "succeeded", diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index d333ade5f..c4828cc62 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -68,7 +68,7 @@ fn dry_run_simple() { Run: [ULID] Status: SUCCESS Duration: [DURATION] - Run: [STORAGE_DIR]/runs/20260403-[ULID] + Run: [STORAGE_DIR]/runs/20260404-[ULID] === Output === [Simulated] Response for stage: report @@ -400,12 +400,8 @@ fn json_run_implies_auto_approve_for_human_gates() { "event": "sandbox.ready", "id": "[EVENT_ID]", "properties": { - "cpu": null, "duration_ms": "[DURATION_MS]", - "memory": null, - "name": null, - "provider": "local", - "url": null + "provider": "local" }, "run_id": "[ULID]", "ts": "[TIMESTAMP]" @@ -468,17 +464,12 @@ fn json_run_implies_auto_approve_for_human_gates() { "internal.thread_id": null }, "duration_ms": "[DURATION_MS]", - "files_touched": [], "index": 0, "max_attempts": 1, "node_visits": { "start": 1 }, - "notes": null, - "preferred_label": null, - "status": "success", - "suggested_next_ids": [], - "usage": null + "status": "success" }, "run_id": "[ULID]", "ts": "[TIMESTAMP]" @@ -487,10 +478,8 @@ fn json_run_implies_auto_approve_for_human_gates() { "event": "edge.selected", "id": "[EVENT_ID]", "properties": { - "condition": null, "from_node": "start", "is_jump": false, - "label": null, "reason": "unconditional", "stage_status": "success", "to_node": "approve" @@ -576,20 +565,17 @@ fn json_run_implies_auto_approve_for_human_gates() { "thread.start.current_node": "approve" }, "duration_ms": "[DURATION_MS]", - "files_touched": [], "index": 1, "max_attempts": 1, "node_visits": { "approve": 1, "start": 1 }, - "notes": null, "preferred_label": "[A] Approve", "status": "success", "suggested_next_ids": [ "ship" - ], - "usage": null + ] }, "run_id": "[ULID]", "ts": "[TIMESTAMP]" @@ -598,7 +584,6 @@ fn json_run_implies_auto_approve_for_human_gates() { "event": "edge.selected", "id": "[EVENT_ID]", "properties": { - "condition": null, "from_node": "approve", "is_jump": false, "label": "[A] Approve", @@ -743,7 +728,6 @@ fn json_run_implies_auto_approve_for_human_gates() { "thread.start.current_node": "approve" }, "duration_ms": "[DURATION_MS]", - "files_touched": [], "index": 2, "max_attempts": 1, "node_visits": { @@ -752,10 +736,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "start": 1 }, "notes": "Script completed: echo shipped", - "preferred_label": null, - "status": "success", - "suggested_next_ids": [], - "usage": null + "status": "success" }, "run_id": "[ULID]", "ts": "[TIMESTAMP]" @@ -764,10 +745,8 @@ fn json_run_implies_auto_approve_for_human_gates() { "event": "edge.selected", "id": "[EVENT_ID]", "properties": { - "condition": null, "from_node": "ship", "is_jump": false, - "label": null, "reason": "unconditional", "stage_status": "success", "to_node": "exit" @@ -869,14 +848,9 @@ fn json_run_implies_auto_approve_for_human_gates() { "properties": { "attempt": 1, "duration_ms": "[DURATION_MS]", - "files_touched": [], "index": 3, "max_attempts": 1, - "notes": null, - "preferred_label": null, - "status": "success", - "suggested_next_ids": [], - "usage": null + "status": "success" }, "run_id": "[ULID]", "ts": "[TIMESTAMP]" diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs b/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs index d25ac6cd0..e8c4ad9b8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs @@ -51,7 +51,7 @@ fn system_prune_dry_run_lists_matching_runs_without_deleting() { success: true exit_code: 0 ----- stdout ----- - would delete: 20260403-[ULID] (Simple) + would delete: 20260404-[ULID] (Simple) ----- stderr ----- 1 run(s) would be deleted ([SIZE] freed). Pass --yes to confirm. 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 index a7b65be28..a241a9bd3 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs @@ -30,7 +30,7 @@ fn dry_run_branching() { Run: [ULID] Status: SUCCESS Duration: [DURATION] - Run: [STORAGE_DIR]/runs/20260403-[ULID] + Run: [STORAGE_DIR]/runs/20260404-[ULID] === Output === [Simulated] Response for stage: validate @@ -62,7 +62,7 @@ fn dry_run_conditions() { Run: [ULID] Status: SUCCESS Duration: [DURATION] - Run: [STORAGE_DIR]/runs/20260403-[ULID] + Run: [STORAGE_DIR]/runs/20260404-[ULID] === Output === [Simulated] Response for stage: path_b @@ -95,7 +95,7 @@ fn dry_run_parallel() { Run: [ULID] Status: SUCCESS Duration: [DURATION] - Run: [STORAGE_DIR]/runs/20260403-[ULID] + Run: [STORAGE_DIR]/runs/20260404-[ULID] === Output === [Simulated] Response for stage: review @@ -128,7 +128,7 @@ fn dry_run_styled() { Run: [ULID] Status: SUCCESS Duration: [DURATION] - Run: [STORAGE_DIR]/runs/20260403-[ULID] + Run: [STORAGE_DIR]/runs/20260404-[ULID] === Output === [Simulated] Response for stage: critical_review @@ -159,6 +159,6 @@ fn dry_run_legacy_tool() { Run: [ULID] Status: SUCCESS Duration: [DURATION] - Run: [STORAGE_DIR]/runs/20260403-[ULID] + Run: [STORAGE_DIR]/runs/20260404-[ULID] "); } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index e7ce5b48f..edc9cdf21 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -20,7 +20,7 @@ use fabro_llm::types::{ Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage, }; use fabro_store::StoreHandle; -use fabro_types::{RunId, Settings}; +use fabro_types::{RunId, Settings, StoredEvent}; use fabro_util::redact::redact_jsonl_line; use fabro_workflow::error::FabroError; use fabro_workflow::handler::HandlerRegistry; @@ -47,7 +47,7 @@ use crate::static_files; use crate::web_auth; use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer}; use fabro_workflow::context::Context; -use fabro_workflow::event::{EventEmitter, RunEventEnvelope}; +use fabro_workflow::event::EventEmitter; use fabro_workflow::operations::{self, CreateRunInput, WorkflowInput}; use fabro_workflow::pipeline::Persisted; use fabro_workflow::records::Checkpoint; @@ -97,7 +97,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>, diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 044ecb718..ff513b60f 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -3,14 +3,14 @@ use std::path::PathBuf; use std::str::FromStr; use chrono::{DateTime, Utc}; -use serde::de::DeserializeOwned; use serde_json::Value; use crate::{EventEnvelope, Result, RunSummary, StageId, StoreError}; +use fabro_types::stored_event::{RunCompletedProps, RunFailedProps, StageCompletedProps}; use fabro_types::{ - Checkpoint, Conclusion, FailureSignature, NodeStatusRecord, Outcome, PullRequestRecord, Retro, - RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, StageStatus, StageUsage, - StartRecord, StatusReason, + Checkpoint, Conclusion, EventBody, FailureSignature, NodeStatusRecord, Outcome, + PullRequestRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, + StageStatus, StageUsage, StartRecord, StatusReason, StoredEvent, TokenUsage, }; #[derive(Debug, Clone, Default)] @@ -75,190 +75,220 @@ impl RunProjection { } pub(crate) fn apply_event(&mut self, event: &EventEnvelope) -> Result<()> { - let value = event.payload.as_value(); - let ts = parse_ts(value)?; - let event_name = value - .get("event") - .and_then(Value::as_str) - .ok_or_else(|| StoreError::InvalidEvent("event payload missing event name".into()))?; - let run_id = parse_run_id(value)?; - let properties = value - .get("properties") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); + let stored = StoredEvent::from_value(event.payload.as_value().clone()) + .map_err(|err| StoreError::InvalidEvent(format!("invalid stored event: {err}")))?; + let ts = stored.ts; + let run_id = stored.run_id; - match event_name { - "run.created" => { - let settings = required_json::(&properties, "settings")?; - let graph = required_json::(&properties, "graph")?; - let working_directory = - required_string(&properties, "working_directory").map(PathBuf::from)?; - let labels = optional_json::>(&properties, "labels")? - .unwrap_or_default() - .into_iter() - .collect::>(); + match &stored.body { + EventBody::RunCreated(props) => { + let working_directory = PathBuf::from(&props.working_directory); + let labels = props.labels.clone().into_iter().collect::>(); self.run = Some(RunRecord { run_id, - settings, - graph, - workflow_slug: optional_string(&properties, "workflow_slug"), + settings: props.settings.clone(), + graph: props.graph.clone(), + workflow_slug: props.workflow_slug.clone(), working_directory, - host_repo_path: optional_string(&properties, "host_repo_path"), - base_branch: optional_string(&properties, "base_branch"), + host_repo_path: props.host_repo_path.clone(), + base_branch: props.base_branch.clone(), labels, }); - self.graph_source = optional_string(&properties, "workflow_source"); + self.graph_source = props.workflow_source.clone(); } - "run.started" => { + EventBody::RunStarted(props) => { self.start = Some(StartRecord { run_id, start_time: ts, - run_branch: optional_string(&properties, "run_branch"), - base_sha: optional_string(&properties, "base_sha"), + run_branch: props.run_branch.clone(), + base_sha: props.base_sha.clone(), }); } - "run.submitted" => { - self.status = Some(run_status_record(RunStatus::Submitted, &properties, ts)?); + EventBody::RunSubmitted(props) => { + self.status = Some(run_status_record( + RunStatus::Submitted, + props.reason.clone(), + ts, + )); } - "run.starting" => { - self.status = Some(run_status_record(RunStatus::Starting, &properties, ts)?); + EventBody::RunStarting(props) => { + self.status = Some(run_status_record( + RunStatus::Starting, + props.reason.clone(), + ts, + )); } - "run.running" => { - self.status = Some(run_status_record(RunStatus::Running, &properties, ts)?); + EventBody::RunRunning(props) => { + self.status = Some(run_status_record( + RunStatus::Running, + props.reason.clone(), + ts, + )); } - "run.removing" => { - self.status = Some(run_status_record(RunStatus::Removing, &properties, ts)?); + EventBody::RunRemoving(props) => { + self.status = Some(run_status_record( + RunStatus::Removing, + props.reason.clone(), + ts, + )); } - "run.completed" => { - self.status = Some(run_status_record(RunStatus::Succeeded, &properties, ts)?); - self.conclusion = Some(conclusion_from_completed(&properties, ts)?); - self.final_patch = optional_string(&properties, "final_patch"); + EventBody::RunCompleted(props) => { + self.status = Some(run_status_record( + RunStatus::Succeeded, + props.reason.clone(), + ts, + )); + self.conclusion = Some(conclusion_from_completed(props, ts)?); + self.final_patch = props.final_patch.clone(); } - "run.failed" => { - self.status = Some(run_status_record(RunStatus::Failed, &properties, ts)?); - self.conclusion = Some(conclusion_from_failed(&properties, ts)); + EventBody::RunFailed(props) => { + self.status = Some(run_status_record( + RunStatus::Failed, + props.reason.clone(), + ts, + )); + self.conclusion = Some(conclusion_from_failed(props, ts)); } - "run.rewound" => { + EventBody::RunRewound(_) => { self.reset_for_rewind(); } - "checkpoint.completed" => { - let checkpoint = checkpoint_from_properties(&properties, ts)?; - if let Some(node_id) = value.get("node_id").and_then(Value::as_str) { + EventBody::CheckpointCompleted(props) => { + let checkpoint = checkpoint_from_props(props, ts); + if let Some(node_id) = stored.node_id.as_deref() { let visit = checkpoint .node_visits .get(node_id) .and_then(|visit| u32::try_from(*visit).ok()) .unwrap_or(1); - if let Some(diff) = optional_string(&properties, "diff") { + if let Some(diff) = props.diff.clone() { self.node_mut(node_id, visit).diff = Some(diff); } } self.checkpoint = Some(checkpoint.clone()); self.checkpoints.push((event.seq, checkpoint)); } - "sandbox.initialized" => { + EventBody::SandboxInitialized(props) => { self.sandbox = Some(SandboxRecord { - provider: required_string(&properties, "provider")?, - working_directory: required_string(&properties, "working_directory")?, - identifier: optional_string(&properties, "identifier"), - host_working_directory: optional_string(&properties, "host_working_directory"), - container_mount_point: optional_string(&properties, "container_mount_point"), + provider: props.provider.clone(), + working_directory: props.working_directory.clone(), + identifier: props.identifier.clone(), + host_working_directory: props.host_working_directory.clone(), + container_mount_point: props.container_mount_point.clone(), }); } - "retro.started" => { - self.retro_prompt = optional_string(&properties, "prompt"); + EventBody::RetroStarted(props) => { + self.retro_prompt = props.prompt.clone(); } - "retro.completed" => { - self.retro_response = optional_string(&properties, "response"); - self.retro = optional_json::(&properties, "retro")?; + EventBody::RetroCompleted(props) => { + self.retro_response = props.response.clone(); + self.retro = props + .retro + .clone() + .map(serde_json::from_value) + .transpose() + .map_err(|err| { + StoreError::InvalidEvent(format!("invalid retro payload: {err}")) + })?; } - "pull_request.created" => { + EventBody::PullRequestCreated(props) => { self.pull_request = Some(PullRequestRecord { - html_url: required_string(&properties, "pr_url")?, - number: required_u64(&properties, "pr_number")?, - owner: required_string(&properties, "owner")?, - repo: required_string(&properties, "repo")?, - base_branch: required_string(&properties, "base_branch")?, - head_branch: required_string(&properties, "head_branch")?, - title: required_string(&properties, "title")?, + html_url: props.pr_url.clone(), + number: props.pr_number, + owner: props.owner.clone(), + repo: props.repo.clone(), + base_branch: props.base_branch.clone(), + head_branch: props.head_branch.clone(), + title: props.title.clone(), }); } - "stage.prompt" => { - let Some(node_id) = value.get("node_id").and_then(Value::as_str) else { + EventBody::StagePrompt(props) => { + let Some(node_id) = stored.node_id.as_deref() else { return Ok(()); }; - let visit = required_u32(&properties, "visit")?; - self.node_mut(node_id, visit).prompt = optional_string(&properties, "text"); - self.node_mut(node_id, visit).provider_used = - provider_used_from_prompt(&properties); + let visit = props.visit; + self.node_mut(node_id, visit).prompt = Some(props.text.clone()); + self.node_mut(node_id, visit).provider_used = provider_used_from_prompt(props); } - "prompt.completed" => { - let Some(node_id) = value.get("node_id").and_then(Value::as_str) else { + EventBody::PromptCompleted(props) => { + let Some(node_id) = stored.node_id.as_deref() else { return Ok(()); }; let visit = self.current_visit_for(node_id).unwrap_or(1); - self.node_mut(node_id, visit).response = optional_string(&properties, "response"); + self.node_mut(node_id, visit).response = Some(props.response.clone()); } - "stage.completed" => { - let Some(node_id) = value.get("node_id").and_then(Value::as_str) else { + EventBody::StageCompleted(props) => { + let Some(node_id) = stored.node_id.as_deref() else { return Ok(()); }; - let visit = stage_visit(node_id, &properties, self).unwrap_or(1); - let response = optional_string(&properties, "response"); - let outcome = stage_outcome_from_properties(&properties)?; + let visit = stage_visit(node_id, props.node_visits.as_ref(), self).unwrap_or(1); + let response = props.response.clone(); + let outcome = stage_outcome_from_props(props); let status = node_status_from_outcome(&outcome, ts); let node = self.node_mut(node_id, visit); node.response = response; node.status = Some(status); } - "stage.failed" => { - let Some(node_id) = value.get("node_id").and_then(Value::as_str) else { + EventBody::StageFailed(props) => { + let Some(node_id) = stored.node_id.as_deref() else { return Ok(()); }; let visit = self.current_visit_for(node_id).unwrap_or(1); - let failure = optional_json::(&properties, "failure")?; - let failure_reason = failure.as_ref().map(|detail| detail.message.clone()); + let failure_reason = props.failure.as_ref().map(|detail| detail.message.clone()); let node = self.node_mut(node_id, visit); node.status = Some(NodeStatusRecord { status: StageStatus::Fail, notes: None, - failure_reason: failure_reason.clone(), + failure_reason, timestamp: ts, }); } - "agent.session.started" | "agent.cli.started" => { - let Some(node_id) = value.get("node_id").and_then(Value::as_str) else { + EventBody::AgentSessionStarted(props) => { + let Some(node_id) = stored.node_id.as_deref() else { return Ok(()); }; - let visit = required_u32(&properties, "visit")?; - self.node_mut(node_id, visit).provider_used = - Some(provider_used_from_agent_event(event_name, &properties)); + self.node_mut(node_id, props.visit).provider_used = + Some(provider_used_from_agent_session_started(props)); } - "command.started" => { - let Some(node_id) = value.get("node_id").and_then(Value::as_str) else { + EventBody::AgentCliStarted(props) => { + let Some(node_id) = stored.node_id.as_deref() else { + return Ok(()); + }; + self.node_mut(node_id, props.visit).provider_used = + Some(provider_used_from_agent_cli_started(props)); + } + EventBody::CommandStarted(props) => { + let Some(node_id) = stored.node_id.as_deref() else { return Ok(()); }; let visit = self.current_visit_for(node_id).unwrap_or(1); self.node_mut(node_id, visit).script_invocation = - Some(Value::Object(properties.clone())); + Some(serde_json::to_value(props).map_err(|err| { + StoreError::InvalidEvent(format!("invalid command.started payload: {err}")) + })?); } - "command.completed" => { - let Some(node_id) = value.get("node_id").and_then(Value::as_str) else { + EventBody::CommandCompleted(props) => { + let Some(node_id) = stored.node_id.as_deref() else { return Ok(()); }; let visit = self.current_visit_for(node_id).unwrap_or(1); let node = self.node_mut(node_id, visit); - node.stdout = optional_string(&properties, "stdout"); - node.stderr = optional_string(&properties, "stderr"); - node.script_timing = Some(Value::Object(properties.clone())); + node.stdout = Some(props.stdout.clone()); + node.stderr = Some(props.stderr.clone()); + node.script_timing = Some(serde_json::to_value(props).map_err(|err| { + StoreError::InvalidEvent(format!("invalid command.completed payload: {err}")) + })?); } - "parallel.completed" => { - let Some(node_id) = value.get("node_id").and_then(Value::as_str) else { + EventBody::ParallelCompleted(props) => { + let Some(node_id) = stored.node_id.as_deref() else { return Ok(()); }; let visit = self.current_visit_for(node_id).unwrap_or(1); - self.node_mut(node_id, visit).parallel_results = properties.get("results").cloned(); + self.node_mut(node_id, visit).parallel_results = + Some(serde_json::to_value(&props.results).map_err(|err| { + StoreError::InvalidEvent(format!( + "invalid parallel.completed payload: {err}" + )) + })?); } _ => {} } @@ -358,148 +388,65 @@ impl RunProjection { } } -fn parse_ts(value: &Value) -> Result> { - let ts = value - .get("ts") - .and_then(Value::as_str) - .ok_or_else(|| StoreError::InvalidEvent("event payload missing ts".into()))?; - chrono::DateTime::parse_from_rfc3339(ts) - .map(|ts| ts.with_timezone(&Utc)) - .map_err(|err| StoreError::InvalidEvent(format!("invalid event ts: {err}"))) -} - -fn parse_run_id(value: &Value) -> Result { - let run_id = value - .get("run_id") - .and_then(Value::as_str) - .ok_or_else(|| StoreError::InvalidEvent("event payload missing run_id".into()))?; - run_id - .parse() - .map_err(|err| StoreError::InvalidEvent(format!("invalid run_id: {err}"))) -} - -fn required_string(properties: &serde_json::Map, key: &str) -> Result { - properties - .get(key) - .and_then(Value::as_str) - .map(ToString::to_string) - .ok_or_else(|| StoreError::InvalidEvent(format!("event missing string property {key}"))) -} - -fn optional_string(properties: &serde_json::Map, key: &str) -> Option { - properties - .get(key) - .and_then(Value::as_str) - .map(ToString::to_string) -} - -fn required_u64(properties: &serde_json::Map, key: &str) -> Result { - properties - .get(key) - .and_then(Value::as_u64) - .ok_or_else(|| StoreError::InvalidEvent(format!("event missing integer property {key}"))) -} - -fn required_u32(properties: &serde_json::Map, key: &str) -> Result { - u32::try_from(required_u64(properties, key)?) - .map_err(|_| StoreError::InvalidEvent(format!("property {key} does not fit in u32"))) -} - -fn required_json( - properties: &serde_json::Map, - key: &str, -) -> Result { - let value = properties - .get(key) - .cloned() - .ok_or_else(|| StoreError::InvalidEvent(format!("event missing property {key}")))?; - serde_json::from_value(value) - .map_err(|err| StoreError::InvalidEvent(format!("invalid property {key}: {err}"))) -} - -fn optional_json( - properties: &serde_json::Map, - key: &str, -) -> Result> { - properties - .get(key) - .filter(|value| !value.is_null()) - .cloned() - .map(|value| { - serde_json::from_value(value) - .map_err(|err| StoreError::InvalidEvent(format!("invalid property {key}: {err}"))) - }) - .transpose() -} - -fn parse_reason(properties: &serde_json::Map) -> Result> { - optional_string(properties, "reason") - .map(|reason| { - serde_json::from_value(Value::String(reason)) - .map_err(|err| StoreError::InvalidEvent(format!("invalid status reason: {err}"))) - }) - .transpose() -} - fn run_status_record( status: RunStatus, - properties: &serde_json::Map, + reason: Option, updated_at: DateTime, -) -> Result { - Ok(RunStatusRecord { +) -> RunStatusRecord { + RunStatusRecord { status, - reason: parse_reason(properties)?, + reason, updated_at, - }) + } } -fn checkpoint_from_properties( - properties: &serde_json::Map, +fn checkpoint_from_props( + props: &fabro_types::stored_event::CheckpointCompletedProps, timestamp: DateTime, -) -> Result { - let loop_failure_signatures = - optional_json::>(properties, "loop_failure_signatures")? - .unwrap_or_default() - .into_iter() - .map(|(key, value)| (FailureSignature(key), value)) - .collect(); - let restart_failure_signatures = - optional_json::>(properties, "restart_failure_signatures")? - .unwrap_or_default() - .into_iter() - .map(|(key, value)| (FailureSignature(key), value)) - .collect(); +) -> Checkpoint { + let loop_failure_signatures = props + .loop_failure_signatures + .clone() + .into_iter() + .map(|(key, value)| (FailureSignature(key), value)) + .collect(); + let restart_failure_signatures = props + .restart_failure_signatures + .clone() + .into_iter() + .map(|(key, value)| (FailureSignature(key), value)) + .collect(); - Ok(Checkpoint { + Checkpoint { timestamp, - current_node: required_string(properties, "current_node")?, - completed_nodes: optional_json(properties, "completed_nodes")?.unwrap_or_default(), - node_retries: optional_json(properties, "node_retries")?.unwrap_or_default(), - context_values: optional_json(properties, "context_values")?.unwrap_or_default(), - node_outcomes: optional_json(properties, "node_outcomes")?.unwrap_or_default(), - next_node_id: optional_string(properties, "next_node_id"), - git_commit_sha: optional_string(properties, "git_commit_sha"), + current_node: props.current_node.clone(), + completed_nodes: props.completed_nodes.clone(), + node_retries: props.node_retries.clone().into_iter().collect(), + context_values: props.context_values.clone().into_iter().collect(), + node_outcomes: props.node_outcomes.clone().into_iter().collect(), + next_node_id: props.next_node_id.clone(), + git_commit_sha: props.git_commit_sha.clone(), loop_failure_signatures, restart_failure_signatures, - node_visits: optional_json(properties, "node_visits")?.unwrap_or_default(), - }) + node_visits: props.node_visits.clone().into_iter().collect(), + } } fn conclusion_from_completed( - properties: &serde_json::Map, + props: &RunCompletedProps, timestamp: DateTime, ) -> Result { - let usage = optional_json::(properties, "usage")?; + let usage = props.usage.as_ref().map(run_usage_from_token_usage); Ok(Conclusion { timestamp, - status: StageStatus::from_str(&required_string(properties, "status")?).map_err(|err| { + status: StageStatus::from_str(&props.status).map_err(|err| { StoreError::InvalidEvent(format!("invalid completed stage status: {err}")) })?, - duration_ms: required_u64(properties, "duration_ms")?, + duration_ms: props.duration_ms, failure_reason: None, - final_git_commit_sha: optional_string(properties, "final_git_commit_sha"), + final_git_commit_sha: props.final_git_commit_sha.clone(), stages: Vec::new(), - total_cost: properties.get("total_cost").and_then(Value::as_f64), + total_cost: props.total_cost, total_retries: 0, total_input_tokens: usage.as_ref().map_or(0, |usage| usage.input_tokens), total_output_tokens: usage.as_ref().map_or(0, |usage| usage.output_tokens), @@ -519,19 +466,13 @@ fn conclusion_from_completed( }) } -fn conclusion_from_failed( - properties: &serde_json::Map, - timestamp: DateTime, -) -> Conclusion { +fn conclusion_from_failed(props: &RunFailedProps, timestamp: DateTime) -> Conclusion { Conclusion { timestamp, status: StageStatus::Fail, - duration_ms: properties - .get("duration_ms") - .and_then(Value::as_u64) - .unwrap_or_default(), - failure_reason: optional_string(properties, "error"), - final_git_commit_sha: optional_string(properties, "git_commit_sha"), + duration_ms: props.duration_ms, + failure_reason: Some(props.error.clone()), + final_git_commit_sha: props.git_commit_sha.clone(), stages: Vec::new(), total_cost: None, total_retries: 0, @@ -546,34 +487,33 @@ fn conclusion_from_failed( fn stage_visit( node_id: &str, - properties: &serde_json::Map, + node_visits: Option<&BTreeMap>, state: &RunProjection, ) -> Option { - properties - .get("node_visits") - .and_then(|value| serde_json::from_value::>(value.clone()).ok()) + node_visits .and_then(|visits| visits.get(node_id).copied()) .and_then(|visit| u32::try_from(visit).ok()) .or_else(|| state.current_visit_for(node_id)) } -fn stage_outcome_from_properties( - properties: &serde_json::Map, -) -> Result>> { - let status = StageStatus::from_str(&required_string(properties, "status")?) - .map_err(|err| StoreError::InvalidEvent(format!("invalid stage status: {err}")))?; - Ok(Outcome { - status, - preferred_label: optional_string(properties, "preferred_label"), - suggested_next_ids: optional_json(properties, "suggested_next_ids")?.unwrap_or_default(), - context_updates: optional_json(properties, "context_updates")?.unwrap_or_default(), - jump_to_node: optional_string(properties, "jump_to_node"), - notes: optional_string(properties, "notes"), - failure: optional_json(properties, "failure")?, - usage: optional_json(properties, "usage")?, - files_touched: optional_json(properties, "files_touched")?.unwrap_or_default(), - duration_ms: properties.get("duration_ms").and_then(Value::as_u64), - }) +fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome> { + Outcome { + status: props.status.clone(), + preferred_label: props.preferred_label.clone(), + suggested_next_ids: props.suggested_next_ids.clone(), + context_updates: props + .context_updates + .clone() + .unwrap_or_default() + .into_iter() + .collect(), + jump_to_node: props.jump_to_node.clone(), + notes: props.notes.clone(), + failure: props.failure.clone(), + usage: props.usage.clone(), + files_touched: props.files_touched.clone(), + duration_ms: Some(props.duration_ms), + } } fn node_status_from_outcome( @@ -591,41 +531,55 @@ fn node_status_from_outcome( } } -fn provider_used_from_prompt(properties: &serde_json::Map) -> Option { +fn provider_used_from_prompt(props: &fabro_types::stored_event::StagePromptProps) -> Option { let mut provider_used = serde_json::Map::new(); - if let Some(mode) = optional_string(properties, "mode") { + if let Some(mode) = props.mode.clone() { provider_used.insert("mode".to_string(), Value::String(mode)); } - if let Some(provider) = optional_string(properties, "provider") { + if let Some(provider) = props.provider.clone() { provider_used.insert("provider".to_string(), Value::String(provider)); } - if let Some(model) = optional_string(properties, "model") { + if let Some(model) = props.model.clone() { provider_used.insert("model".to_string(), Value::String(model)); } (!provider_used.is_empty()).then_some(Value::Object(provider_used)) } -fn provider_used_from_agent_event( - event_name: &str, - properties: &serde_json::Map, +fn provider_used_from_agent_session_started( + props: &fabro_types::stored_event::AgentSessionStartedProps, ) -> Value { let mut provider_used = serde_json::Map::new(); - provider_used.insert( - "mode".to_string(), - Value::String(if event_name == "agent.cli.started" { - "cli".to_string() - } else { - "agent".to_string() - }), - ); - if let Some(provider) = optional_string(properties, "provider") { + provider_used.insert("mode".to_string(), Value::String("agent".to_string())); + if let Some(provider) = props.provider.clone() { provider_used.insert("provider".to_string(), Value::String(provider)); } - if let Some(model) = optional_string(properties, "model") { + if let Some(model) = props.model.clone() { provider_used.insert("model".to_string(), Value::String(model)); } - if let Some(command) = optional_string(properties, "command") { - provider_used.insert("command".to_string(), Value::String(command)); - } Value::Object(provider_used) } + +fn provider_used_from_agent_cli_started( + props: &fabro_types::stored_event::AgentCliStartedProps, +) -> Value { + let mut provider_used = serde_json::Map::new(); + provider_used.insert("mode".to_string(), Value::String("cli".to_string())); + provider_used.insert( + "provider".to_string(), + Value::String(props.provider.clone()), + ); + provider_used.insert("model".to_string(), Value::String(props.model.clone())); + provider_used.insert("command".to_string(), Value::String(props.command.clone())); + Value::Object(provider_used) +} + +fn run_usage_from_token_usage(usage: &TokenUsage) -> RunUsage { + RunUsage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + reasoning_tokens: usage.reasoning_tokens, + cache_read_tokens: usage.cache_read_tokens, + cache_write_tokens: usage.cache_write_tokens, + cost: None, + } +} diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 4c8eb0c49..85350a4ff 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -421,6 +421,7 @@ mod tests { node_id: Option<&str>, properties: serde_json::Value, ) -> EventPayload { + let properties = normalize_test_event_properties(run_id, event, properties); let mut value = serde_json::json!({ "id": format!("evt-{run_id}-{event}"), "ts": ts, @@ -434,6 +435,118 @@ mod tests { EventPayload::new(value, &test_run_id(run_id)).unwrap() } + fn normalize_test_event_properties( + run_id: &str, + event: &str, + properties: serde_json::Value, + ) -> serde_json::Value { + let serde_json::Value::Object(mut props) = properties else { + return properties; + }; + + match event { + "run.created" => { + props + .entry("run_dir") + .or_insert_with(|| serde_json::Value::String(format!("/tmp/{run_id}"))); + } + "run.started" => { + props + .entry("name") + .or_insert_with(|| serde_json::Value::String("night-sky".to_string())); + } + "sandbox.initialized" => { + props + .entry("provider") + .or_insert_with(|| serde_json::Value::String("local".to_string())); + } + "checkpoint.completed" => { + props + .entry("completed_nodes") + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + props + .entry("node_retries") + .or_insert_with(|| serde_json::json!({})); + props + .entry("context_values") + .or_insert_with(|| serde_json::json!({})); + props + .entry("node_outcomes") + .or_insert_with(|| serde_json::json!({})); + props + .entry("node_visits") + .or_insert_with(|| serde_json::json!({})); + } + "parallel.completed" => { + props + .entry("visit") + .or_insert_with(|| serde_json::Value::from(1)); + props + .entry("duration_ms") + .or_insert_with(|| serde_json::Value::from(0)); + props + .entry("success_count") + .or_insert_with(|| serde_json::Value::from(0)); + props + .entry("failure_count") + .or_insert_with(|| serde_json::Value::from(0)); + } + "stage.completed" => { + if let Some(visit) = props.get("visit").cloned() { + props.entry("index").or_insert(visit); + } + props + .entry("index") + .or_insert_with(|| serde_json::Value::from(0)); + props + .entry("duration_ms") + .or_insert_with(|| serde_json::Value::from(0)); + props + .entry("attempt") + .or_insert_with(|| serde_json::Value::from(1)); + props + .entry("max_attempts") + .or_insert_with(|| serde_json::Value::from(1)); + } + "command.started" => { + let default_script = props + .get("command") + .cloned() + .unwrap_or_else(|| serde_json::Value::String(String::new())); + props.entry("script").or_insert(default_script); + props + .entry("language") + .or_insert_with(|| serde_json::Value::String("shell".to_string())); + } + "command.completed" => { + props + .entry("duration_ms") + .or_insert_with(|| serde_json::Value::from(0)); + props + .entry("timed_out") + .or_insert_with(|| serde_json::Value::Bool(false)); + } + "run.completed" => { + props + .entry("artifact_count") + .or_insert_with(|| serde_json::Value::from(0)); + } + "pull_request.created" => { + props + .entry("draft") + .or_insert_with(|| serde_json::Value::Bool(false)); + } + "retro.completed" => { + props + .entry("duration_ms") + .or_insert_with(|| serde_json::Value::from(0)); + } + _ => {} + } + + serde_json::Value::Object(props) + } + fn sample_start_record(run_id: &str, created_at: DateTime) -> StartRecord { StartRecord { run_id: test_run_id(run_id), diff --git a/lib/crates/fabro-store/src/types.rs b/lib/crates/fabro-store/src/types.rs index dd0a3688a..d43c63e1d 100644 --- a/lib/crates/fabro-store/src/types.rs +++ b/lib/crates/fabro-store/src/types.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{Result, StoreError}; -use fabro_types::{RunId, RunStatus, StatusReason}; +use fabro_types::{RunId, RunStatus, StatusReason, StoredEvent}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunSummary { @@ -70,6 +70,15 @@ impl EventPayload { } } +impl TryFrom<&EventPayload> for StoredEvent { + type Error = StoreError; + + fn try_from(value: &EventPayload) -> Result { + StoredEvent::from_value(value.as_value().clone()) + .map_err(|err| StoreError::InvalidEvent(format!("invalid stored event: {err}"))) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EventEnvelope { pub seq: u32, diff --git a/lib/crates/fabro-types/src/graph.rs b/lib/crates/fabro-types/src/graph.rs index f3864a01f..9b5a31f0d 100644 --- a/lib/crates/fabro-types/src/graph.rs +++ b/lib/crates/fabro-types/src/graph.rs @@ -96,7 +96,7 @@ pub fn shape_to_handler_type(shape: &str) -> Option<&'static str> { } /// A node in the workflow graph. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Node { pub id: String, pub attrs: HashMap, @@ -256,7 +256,7 @@ impl Node { } /// An edge connecting two nodes in the workflow graph. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Edge { pub from: String, pub to: String, @@ -321,7 +321,7 @@ impl Edge { } /// The parsed workflow graph containing nodes, edges, and graph-level attributes. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct Graph { pub name: String, pub nodes: HashMap, diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 6170db70e..c3ba9a854 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -17,6 +17,7 @@ pub mod settings; pub mod stage_id; pub mod start; pub mod status; +pub mod stored_event; pub mod usage; pub use checkpoint::Checkpoint; @@ -41,6 +42,7 @@ pub use start::StartRecord; pub use status::{ InvalidTransition, ParseRunStatusError, RunStatus, RunStatusRecord, StatusReason, }; +pub use stored_event::{EventBody, RunNoticeLevel, StoredEvent, TokenUsage}; pub use usage::StageUsage; pub use fabro_macros::Combine; diff --git a/lib/crates/fabro-types/src/outcome.rs b/lib/crates/fabro-types/src/outcome.rs index 3a59e1c04..5660f6aa2 100644 --- a/lib/crates/fabro-types/src/outcome.rs +++ b/lib/crates/fabro-types/src/outcome.rs @@ -117,7 +117,7 @@ impl FailureCategory { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FailureDetail { pub message: String, #[serde(rename = "failure_class")] @@ -140,7 +140,7 @@ impl FailureDetail { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(bound = "M: OutcomeMeta")] pub struct Outcome { pub status: StageStatus, diff --git a/lib/crates/fabro-types/src/stored_event/agent.rs b/lib/crates/fabro-types/src/stored_event/agent.rs new file mode 100644 index 000000000..a58fd30a1 --- /dev/null +++ b/lib/crates/fabro-types/src/stored_event/agent.rs @@ -0,0 +1,158 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::TokenUsage; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentSessionStartedProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentSessionEndedProps { + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentProcessingEndProps { + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentInputProps { + pub text: String, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentMessageProps { + pub text: String, + pub model: String, + pub usage: TokenUsage, + pub tool_call_count: usize, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentToolStartedProps { + pub tool_name: String, + pub tool_call_id: String, + pub arguments: Value, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentToolCompletedProps { + pub tool_name: String, + pub tool_call_id: String, + pub output: Value, + pub is_error: bool, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentErrorProps { + pub error: Value, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentWarningProps { + pub kind: String, + pub message: String, + pub details: Value, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentLoopDetectedProps { + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentTurnLimitReachedProps { + pub max_turns: usize, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentSteeringInjectedProps { + pub text: String, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentCompactionStartedProps { + pub estimated_tokens: usize, + pub context_window_size: usize, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentCompactionCompletedProps { + pub original_turn_count: usize, + pub preserved_turn_count: usize, + pub summary_token_estimate: usize, + pub tracked_file_count: usize, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentLlmRetryProps { + pub provider: String, + pub model: String, + pub attempt: usize, + pub delay_secs: f64, + pub error: Value, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentSubSpawnedProps { + pub agent_id: String, + pub depth: usize, + pub task: String, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentSubCompletedProps { + pub agent_id: String, + pub depth: usize, + pub success: bool, + pub turns_used: usize, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentSubFailedProps { + pub agent_id: String, + pub depth: usize, + pub error: Value, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentSubClosedProps { + pub agent_id: String, + pub depth: usize, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentMcpReadyProps { + pub server_name: String, + pub tool_count: usize, + pub visit: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentMcpFailedProps { + pub server_name: String, + pub error: String, + pub visit: u32, +} diff --git a/lib/crates/fabro-types/src/stored_event/infra.rs b/lib/crates/fabro-types/src/stored_event/infra.rs new file mode 100644 index 000000000..5d46e4035 --- /dev/null +++ b/lib/crates/fabro-types/src/stored_event/infra.rs @@ -0,0 +1,192 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxInitializingProps { + pub provider: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxReadyProps { + pub provider: String, + pub duration_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxFailedProps { + pub provider: String, + pub error: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxCleanupStartedProps { + pub provider: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxCleanupCompletedProps { + pub provider: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxCleanupFailedProps { + pub provider: String, + pub error: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SnapshotNameProps { + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SnapshotCompletedProps { + pub name: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SnapshotFailedProps { + pub name: String, + pub error: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitCloneStartedProps { + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitCloneCompletedProps { + pub url: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitCloneFailedProps { + pub url: String, + pub error: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxInitializedProps { + pub working_directory: String, + pub provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identifier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_working_directory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub container_mount_point: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SetupStartedProps { + pub command_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SetupCommandStartedProps { + pub command: String, + pub index: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SetupCommandCompletedProps { + pub command: String, + pub index: usize, + pub exit_code: i32, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SetupCompletedProps { + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SetupFailedProps { + pub command: String, + pub index: usize, + pub exit_code: i32, + pub stderr: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CliEnsureStartedProps { + pub cli_name: String, + pub provider: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CliEnsureCompletedProps { + pub cli_name: String, + pub provider: String, + pub already_installed: bool, + pub node_installed: bool, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CliEnsureFailedProps { + pub cli_name: String, + pub provider: String, + pub error: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevcontainerResolvedProps { + pub dockerfile_lines: usize, + pub environment_count: usize, + pub lifecycle_command_count: usize, + pub workspace_folder: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevcontainerLifecycleStartedProps { + pub phase: String, + pub command_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevcontainerLifecycleCommandStartedProps { + pub phase: String, + pub command: String, + pub index: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevcontainerLifecycleCommandCompletedProps { + pub phase: String, + pub command: String, + pub index: usize, + pub exit_code: i32, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevcontainerLifecycleCompletedProps { + pub phase: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevcontainerLifecycleFailedProps { + pub phase: String, + pub command: String, + pub index: usize, + pub exit_code: i32, + pub stderr: String, +} diff --git a/lib/crates/fabro-types/src/stored_event/misc.rs b/lib/crates/fabro-types/src/stored_event/misc.rs new file mode 100644 index 000000000..8a148d9d0 --- /dev/null +++ b/lib/crates/fabro-types/src/stored_event/misc.rs @@ -0,0 +1,259 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::TokenUsage; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ParallelStartedProps { + pub visit: u32, + pub branch_count: usize, + pub join_policy: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ParallelBranchStartedProps { + pub index: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ParallelBranchCompletedProps { + pub index: usize, + pub duration_ms: u64, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub head_sha: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ParallelCompletedProps { + pub visit: u32, + pub duration_ms: u64, + pub success_count: usize, + pub failure_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InterviewStartedProps { + pub question: String, + pub question_type: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InterviewCompletedProps { + pub question: String, + pub answer: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InterviewTimeoutProps { + pub question: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitCommitProps { + pub sha: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitPushProps { + pub branch: String, + pub success: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitBranchProps { + pub branch: String, + pub sha: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitWorktreeAddProps { + pub path: String, + pub branch: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitWorktreeRemoveProps { + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitFetchProps { + pub branch: String, + pub success: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GitResetProps { + pub sha: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EdgeSelectedProps { + pub from_node: String, + pub to_node: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub condition: Option, + pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_label: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub suggested_next_ids: Vec, + pub stage_status: String, + pub is_jump: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LoopRestartProps { + pub from_node: String, + pub to_node: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SubgraphStartedProps { + pub start_node: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SubgraphCompletedProps { + pub steps_executed: usize, + pub status: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StallWatchdogTimeoutProps { + pub idle_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AssetCapturedProps { + pub attempt: u32, + pub node_slug: String, + pub path: String, + pub mime: String, + pub content_md5: String, + pub content_sha256: String, + pub bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SshAccessReadyProps { + pub ssh_command: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FailoverProps { + pub from_provider: String, + pub from_model: String, + pub to_provider: String, + pub to_model: String, + pub error: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommandStartedProps { + pub script: String, + pub command: String, + pub language: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommandCompletedProps { + pub stdout: String, + pub stderr: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + pub duration_ms: u64, + pub timed_out: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentCliStartedProps { + pub visit: u32, + pub mode: String, + pub provider: String, + pub model: String, + pub command: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentCliCompletedProps { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PullRequestCreatedProps { + pub pr_url: String, + pub pr_number: u64, + pub owner: String, + pub repo: String, + pub base_branch: String, + pub head_branch: String, + pub title: String, + pub draft: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PullRequestFailedProps { + pub error: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RetroStartedProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RetroCompletedProps { + pub duration_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retro: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RetroFailedProps { + pub error: String, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AssistantUsageProps { + pub model: String, + pub input_tokens: u64, + pub output_tokens: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, +} + +impl From for AssistantUsageProps { + fn from(value: TokenUsage) -> Self { + Self { + model: String::new(), + input_tokens: u64::try_from(value.input_tokens).unwrap_or_default(), + output_tokens: u64::try_from(value.output_tokens).unwrap_or_default(), + speed: value.speed, + cost: None, + } + } +} diff --git a/lib/crates/fabro-types/src/stored_event/mod.rs b/lib/crates/fabro-types/src/stored_event/mod.rs new file mode 100644 index 000000000..9ca4ea640 --- /dev/null +++ b/lib/crates/fabro-types/src/stored_event/mod.rs @@ -0,0 +1,494 @@ +pub mod agent; +pub mod infra; +pub mod misc; +pub mod run; +pub mod stage; + +use chrono::{DateTime, Utc}; +use serde::de::Error as DeError; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Map, Value, json}; + +use crate::RunId; + +pub use agent::*; +pub use infra::*; +pub use misc::*; +pub use run::*; +pub use stage::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunNoticeLevel { + Info, + Warn, + Error, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TokenUsage { + pub input_tokens: i64, + pub output_tokens: i64, + pub total_tokens: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_read_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StoredEvent { + pub id: String, + pub ts: DateTime, + pub run_id: RunId, + pub event: String, + pub node_id: Option, + pub node_label: Option, + pub session_id: Option, + pub parent_session_id: Option, + pub properties: Value, + pub body: EventBody, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(tag = "event", content = "properties")] +pub enum EventBody { + #[serde(rename = "run.created")] + RunCreated(RunCreatedProps), + #[serde(rename = "run.started")] + RunStarted(RunStartedProps), + #[serde(rename = "run.submitted")] + RunSubmitted(RunStatusTransitionProps), + #[serde(rename = "run.starting")] + RunStarting(RunStatusTransitionProps), + #[serde(rename = "run.running")] + RunRunning(RunStatusTransitionProps), + #[serde(rename = "run.removing")] + RunRemoving(RunStatusTransitionProps), + #[serde(rename = "run.rewound")] + RunRewound(RunRewoundProps), + #[serde(rename = "run.completed")] + RunCompleted(RunCompletedProps), + #[serde(rename = "run.failed")] + RunFailed(RunFailedProps), + #[serde(rename = "run.notice")] + RunNotice(RunNoticeProps), + #[serde(rename = "stage.started")] + StageStarted(StageStartedProps), + #[serde(rename = "stage.completed")] + StageCompleted(StageCompletedProps), + #[serde(rename = "stage.failed")] + StageFailed(StageFailedProps), + #[serde(rename = "stage.retrying")] + StageRetrying(StageRetryingProps), + #[serde(rename = "parallel.started")] + ParallelStarted(ParallelStartedProps), + #[serde(rename = "parallel.branch.started")] + ParallelBranchStarted(ParallelBranchStartedProps), + #[serde(rename = "parallel.branch.completed")] + ParallelBranchCompleted(ParallelBranchCompletedProps), + #[serde(rename = "parallel.completed")] + ParallelCompleted(ParallelCompletedProps), + #[serde(rename = "interview.started")] + InterviewStarted(InterviewStartedProps), + #[serde(rename = "interview.completed")] + InterviewCompleted(InterviewCompletedProps), + #[serde(rename = "interview.timeout")] + InterviewTimeout(InterviewTimeoutProps), + #[serde(rename = "checkpoint.completed")] + CheckpointCompleted(CheckpointCompletedProps), + #[serde(rename = "checkpoint.failed")] + CheckpointFailed(CheckpointFailedProps), + #[serde(rename = "git.commit")] + GitCommit(GitCommitProps), + #[serde(rename = "git.push")] + GitPush(GitPushProps), + #[serde(rename = "git.branch")] + GitBranch(GitBranchProps), + #[serde(rename = "git.worktree.added")] + GitWorktreeAdd(GitWorktreeAddProps), + #[serde(rename = "git.worktree.removed")] + GitWorktreeRemove(GitWorktreeRemoveProps), + #[serde(rename = "git.fetch")] + GitFetch(GitFetchProps), + #[serde(rename = "git.reset")] + GitReset(GitResetProps), + #[serde(rename = "edge.selected")] + EdgeSelected(EdgeSelectedProps), + #[serde(rename = "loop.restart")] + LoopRestart(LoopRestartProps), + #[serde(rename = "stage.prompt")] + StagePrompt(StagePromptProps), + #[serde(rename = "prompt.completed")] + PromptCompleted(PromptCompletedProps), + #[serde(rename = "agent.session.started")] + AgentSessionStarted(AgentSessionStartedProps), + #[serde(rename = "agent.session.ended")] + AgentSessionEnded(AgentSessionEndedProps), + #[serde(rename = "agent.processing.end")] + AgentProcessingEnd(AgentProcessingEndProps), + #[serde(rename = "agent.input")] + AgentInput(AgentInputProps), + #[serde(rename = "agent.message")] + AgentMessage(AgentMessageProps), + #[serde(rename = "agent.tool.started")] + AgentToolStarted(AgentToolStartedProps), + #[serde(rename = "agent.tool.completed")] + AgentToolCompleted(AgentToolCompletedProps), + #[serde(rename = "agent.error")] + AgentError(AgentErrorProps), + #[serde(rename = "agent.warning")] + AgentWarning(AgentWarningProps), + #[serde(rename = "agent.loop.detected")] + AgentLoopDetected(AgentLoopDetectedProps), + #[serde(rename = "agent.turn.limit")] + AgentTurnLimitReached(AgentTurnLimitReachedProps), + #[serde(rename = "agent.steering.injected")] + AgentSteeringInjected(AgentSteeringInjectedProps), + #[serde(rename = "agent.compaction.started")] + AgentCompactionStarted(AgentCompactionStartedProps), + #[serde(rename = "agent.compaction.completed")] + AgentCompactionCompleted(AgentCompactionCompletedProps), + #[serde(rename = "agent.llm.retry")] + AgentLlmRetry(AgentLlmRetryProps), + #[serde(rename = "agent.sub.spawned")] + AgentSubSpawned(AgentSubSpawnedProps), + #[serde(rename = "agent.sub.completed")] + AgentSubCompleted(AgentSubCompletedProps), + #[serde(rename = "agent.sub.failed")] + AgentSubFailed(AgentSubFailedProps), + #[serde(rename = "agent.sub.closed")] + AgentSubClosed(AgentSubClosedProps), + #[serde(rename = "agent.mcp.ready")] + AgentMcpReady(AgentMcpReadyProps), + #[serde(rename = "agent.mcp.failed")] + AgentMcpFailed(AgentMcpFailedProps), + #[serde(rename = "subgraph.started")] + SubgraphStarted(SubgraphStartedProps), + #[serde(rename = "subgraph.completed")] + SubgraphCompleted(SubgraphCompletedProps), + #[serde(rename = "sandbox.initializing")] + SandboxInitializing(SandboxInitializingProps), + #[serde(rename = "sandbox.ready")] + SandboxReady(SandboxReadyProps), + #[serde(rename = "sandbox.failed")] + SandboxFailed(SandboxFailedProps), + #[serde(rename = "sandbox.cleanup.started")] + SandboxCleanupStarted(SandboxCleanupStartedProps), + #[serde(rename = "sandbox.cleanup.completed")] + SandboxCleanupCompleted(SandboxCleanupCompletedProps), + #[serde(rename = "sandbox.cleanup.failed")] + SandboxCleanupFailed(SandboxCleanupFailedProps), + #[serde(rename = "sandbox.snapshot.pulling")] + SnapshotPulling(SnapshotNameProps), + #[serde(rename = "sandbox.snapshot.pulled")] + SnapshotPulled(SnapshotCompletedProps), + #[serde(rename = "sandbox.snapshot.ensuring")] + SnapshotEnsuring(SnapshotNameProps), + #[serde(rename = "sandbox.snapshot.creating")] + SnapshotCreating(SnapshotNameProps), + #[serde(rename = "sandbox.snapshot.ready")] + SnapshotReady(SnapshotCompletedProps), + #[serde(rename = "sandbox.snapshot.failed")] + SnapshotFailed(SnapshotFailedProps), + #[serde(rename = "sandbox.git.started")] + GitCloneStarted(GitCloneStartedProps), + #[serde(rename = "sandbox.git.completed")] + GitCloneCompleted(GitCloneCompletedProps), + #[serde(rename = "sandbox.git.failed")] + GitCloneFailed(GitCloneFailedProps), + #[serde(rename = "sandbox.initialized")] + SandboxInitialized(SandboxInitializedProps), + #[serde(rename = "setup.started")] + SetupStarted(SetupStartedProps), + #[serde(rename = "setup.command.started")] + SetupCommandStarted(SetupCommandStartedProps), + #[serde(rename = "setup.command.completed")] + SetupCommandCompleted(SetupCommandCompletedProps), + #[serde(rename = "setup.completed")] + SetupCompleted(SetupCompletedProps), + #[serde(rename = "setup.failed")] + SetupFailed(SetupFailedProps), + #[serde(rename = "watchdog.timeout")] + StallWatchdogTimeout(StallWatchdogTimeoutProps), + #[serde(rename = "asset.captured")] + AssetCaptured(AssetCapturedProps), + #[serde(rename = "ssh.ready")] + SshAccessReady(SshAccessReadyProps), + #[serde(rename = "agent.failover")] + Failover(FailoverProps), + #[serde(rename = "cli.ensure.started")] + CliEnsureStarted(CliEnsureStartedProps), + #[serde(rename = "cli.ensure.completed")] + CliEnsureCompleted(CliEnsureCompletedProps), + #[serde(rename = "cli.ensure.failed")] + CliEnsureFailed(CliEnsureFailedProps), + #[serde(rename = "command.started")] + CommandStarted(CommandStartedProps), + #[serde(rename = "command.completed")] + CommandCompleted(CommandCompletedProps), + #[serde(rename = "agent.cli.started")] + AgentCliStarted(AgentCliStartedProps), + #[serde(rename = "agent.cli.completed")] + AgentCliCompleted(AgentCliCompletedProps), + #[serde(rename = "pull_request.created")] + PullRequestCreated(PullRequestCreatedProps), + #[serde(rename = "pull_request.failed")] + PullRequestFailed(PullRequestFailedProps), + #[serde(rename = "devcontainer.resolved")] + DevcontainerResolved(DevcontainerResolvedProps), + #[serde(rename = "devcontainer.lifecycle.started")] + DevcontainerLifecycleStarted(DevcontainerLifecycleStartedProps), + #[serde(rename = "devcontainer.lifecycle.command.started")] + DevcontainerLifecycleCommandStarted(DevcontainerLifecycleCommandStartedProps), + #[serde(rename = "devcontainer.lifecycle.command.completed")] + DevcontainerLifecycleCommandCompleted(DevcontainerLifecycleCommandCompletedProps), + #[serde(rename = "devcontainer.lifecycle.completed")] + DevcontainerLifecycleCompleted(DevcontainerLifecycleCompletedProps), + #[serde(rename = "devcontainer.lifecycle.failed")] + DevcontainerLifecycleFailed(DevcontainerLifecycleFailedProps), + #[serde(rename = "retro.started")] + RetroStarted(RetroStartedProps), + #[serde(rename = "retro.completed")] + RetroCompleted(RetroCompletedProps), + #[serde(rename = "retro.failed")] + RetroFailed(RetroFailedProps), + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Deserialize)] +struct StoredEventRaw { + id: String, + ts: DateTime, + run_id: RunId, + #[serde(default)] + node_id: Option, + #[serde(default)] + node_label: Option, + #[serde(default)] + session_id: Option, + #[serde(default)] + parent_session_id: Option, + event: String, + #[serde(default = "default_properties")] + properties: Value, +} + +fn default_properties() -> Value { + Value::Object(Map::new()) +} + +impl StoredEvent { + pub fn from_value(value: Value) -> serde_json::Result { + let raw: StoredEventRaw = serde_json::from_value(value)?; + let body = serde_json::from_value(json!({ + "event": raw.event, + "properties": raw.properties, + }))?; + Ok(Self { + id: raw.id, + ts: raw.ts, + run_id: raw.run_id, + event: event_name_from_body(&body), + node_id: raw.node_id, + node_label: raw.node_label, + session_id: raw.session_id, + parent_session_id: raw.parent_session_id, + properties: raw.properties, + body, + }) + } + + pub fn from_json_str(line: &str) -> serde_json::Result { + Self::from_value(serde_json::from_str(line)?) + } + + pub fn to_value(&self) -> serde_json::Result { + let mut map = Map::new(); + map.insert("id".to_string(), serde_json::to_value(&self.id)?); + map.insert("ts".to_string(), serde_json::to_value(self.ts)?); + map.insert("run_id".to_string(), serde_json::to_value(self.run_id)?); + map.insert( + "event".to_string(), + Value::String(event_name_from_body(&self.body)), + ); + if let Some(value) = &self.session_id { + map.insert("session_id".to_string(), Value::String(value.clone())); + } + if let Some(value) = &self.parent_session_id { + map.insert( + "parent_session_id".to_string(), + Value::String(value.clone()), + ); + } + if let Some(value) = &self.node_id { + map.insert("node_id".to_string(), Value::String(value.clone())); + } + if let Some(value) = &self.node_label { + map.insert("node_label".to_string(), Value::String(value.clone())); + } + map.insert("properties".to_string(), properties_from_body(&self.body)); + Ok(Value::Object(map)) + } + + pub fn event_name(&self) -> String { + self.event.clone() + } + + pub fn properties(&self) -> Value { + self.properties.clone() + } + + pub fn refresh_cache(&mut self) { + self.event = event_name_from_body(&self.body); + self.properties = properties_from_body(&self.body); + } +} + +fn event_name_from_body(body: &EventBody) -> String { + serde_json::to_value(body) + .ok() + .and_then(|value| { + value + .get("event") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn properties_from_body(body: &EventBody) -> Value { + serde_json::to_value(body) + .ok() + .and_then(|value| value.get("properties").cloned()) + .unwrap_or_else(default_properties) +} + +impl Serialize for StoredEvent { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.to_value() + .map_err(serde::ser::Error::custom)? + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for StoredEvent { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + Self::from_value(value).map_err(D::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::json; + + use crate::{Edge, Graph, Node, Settings, fixtures}; + + use super::*; + + #[test] + fn stored_event_round_trips_json() { + let event = StoredEvent { + id: "evt_1".to_string(), + ts: DateTime::parse_from_rfc3339("2026-04-04T12:00:00.000Z") + .unwrap() + .with_timezone(&Utc), + run_id: fixtures::RUN_1, + event: "stage.completed".to_string(), + node_id: Some("build".to_string()), + node_label: Some("Build".to_string()), + session_id: None, + parent_session_id: None, + properties: json!({ + "index": 1, + "duration_ms": 1234, + "status": "success", + "suggested_next_ids": ["next"], + "notes": "done", + "files_touched": ["src/main.rs"], + "attempt": 1, + "max_attempts": 1 + }), + body: EventBody::StageCompleted(StageCompletedProps { + index: 1, + duration_ms: 1234, + status: crate::StageStatus::Success, + preferred_label: None, + suggested_next_ids: vec!["next".to_string()], + usage: None, + failure: None, + notes: Some("done".to_string()), + files_touched: vec!["src/main.rs".to_string()], + context_updates: None, + jump_to_node: None, + context_values: None, + node_visits: None, + loop_failure_signatures: None, + restart_failure_signatures: None, + response: None, + attempt: 1, + max_attempts: 1, + }), + }; + + let value = event.to_value().unwrap(); + let parsed = StoredEvent::from_value(value).unwrap(); + + assert_eq!(parsed, event); + } + + #[test] + fn stored_event_deserializes_adjacent_layout() { + let settings = Settings::default(); + let graph = Graph { + name: "test".to_string(), + nodes: HashMap::from([( + "start".to_string(), + Node { + id: "start".to_string(), + attrs: HashMap::new(), + classes: Vec::new(), + }, + )]), + edges: vec![Edge { + from: "start".to_string(), + to: "done".to_string(), + attrs: HashMap::new(), + }], + attrs: HashMap::new(), + }; + + let line = json!({ + "id": "evt_2", + "ts": "2026-04-04T12:00:00.000Z", + "run_id": fixtures::RUN_1, + "event": "run.created", + "properties": { + "settings": settings, + "graph": graph, + "labels": {}, + "run_dir": "/tmp/run", + "working_directory": "/tmp/run" + } + }); + + let parsed = StoredEvent::from_value(line).unwrap(); + assert!(matches!(parsed.body, EventBody::RunCreated(_))); + } +} diff --git a/lib/crates/fabro-types/src/stored_event/run.rs b/lib/crates/fabro-types/src/stored_event/run.rs new file mode 100644 index 000000000..509f1e9d8 --- /dev/null +++ b/lib/crates/fabro-types/src/stored_event/run.rs @@ -0,0 +1,110 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::{Graph, RunId, Settings, StatusReason}; + +use super::{RunNoticeLevel, TokenUsage}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunCreatedProps { + pub settings: Settings, + pub graph: Graph, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_config: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub labels: BTreeMap, + pub run_dir: String, + pub working_directory: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_repo_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_slug: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub db_prefix: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunStartedProps { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunStatusTransitionProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunRewoundProps { + pub target_checkpoint_ordinal: usize, + pub target_node_id: String, + pub target_visit: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_commit_sha: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunCompletedProps { + pub duration_ms: u64, + pub artifact_count: usize, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_git_commit_sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_patch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunFailedProps { + pub error: String, + pub duration_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_commit_sha: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunNoticeProps { + pub level: RunNoticeLevel, + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StoredEventHeader { + pub id: String, + pub ts: chrono::DateTime, + pub run_id: RunId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, +} diff --git a/lib/crates/fabro-types/src/stored_event/stage.rs b/lib/crates/fabro-types/src/stored_event/stage.rs new file mode 100644 index 000000000..5e5a75785 --- /dev/null +++ b/lib/crates/fabro-types/src/stored_event/stage.rs @@ -0,0 +1,117 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{FailureDetail, Outcome, StageStatus, StageUsage}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StageStartedProps { + pub index: usize, + pub handler_type: String, + pub attempt: usize, + pub max_attempts: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StageCompletedProps { + pub index: usize, + pub duration_ms: u64, + pub status: StageStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_label: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub suggested_next_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub files_touched: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_updates: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jump_to_node: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_values: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_visits: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub loop_failure_signatures: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub restart_failure_signatures: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, + pub attempt: usize, + pub max_attempts: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StageFailedProps { + pub index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, + pub will_retry: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StageRetryingProps { + pub index: usize, + pub attempt: usize, + pub max_attempts: usize, + pub delay_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StagePromptProps { + pub visit: u32, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PromptCompletedProps { + pub response: String, + pub model: String, + pub provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CheckpointCompletedProps { + pub status: String, + pub current_node: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub completed_nodes: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub node_retries: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub context_values: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub node_outcomes: BTreeMap>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_node_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_commit_sha: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub loop_failure_signatures: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub restart_failure_signatures: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub node_visits: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diff: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CheckpointFailedProps { + pub error: String, +} diff --git a/lib/crates/fabro-types/src/usage.rs b/lib/crates/fabro-types/src/usage.rs index f3b5d85ba..61647b93f 100644 --- a/lib/crates/fabro-types/src/usage.rs +++ b/lib/crates/fabro-types/src/usage.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StageUsage { pub model: String, pub input_tokens: i64, diff --git a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs b/lib/crates/fabro-workflow/src/devcontainer_bridge.rs index a76d655f7..2b41334ce 100644 --- a/lib/crates/fabro-workflow/src/devcontainer_bridge.rs +++ b/lib/crates/fabro-workflow/src/devcontainer_bridge.rs @@ -381,7 +381,7 @@ mod tests { #[tokio::test] async fn emits_started_and_completed_events() { let emitter = EventEmitter::default(); - let events = Arc::new(Mutex::new(Vec::::new())); + 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,27 +392,33 @@ mod tests { .await .unwrap(); let events = events.lock().unwrap(); - 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[0].event_name(), "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[1].event_name(), + "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[2].event_name(), + "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"); + assert_eq!(events[3].event_name(), "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::default(); - let events = Arc::new(Mutex::new(Vec::::new())); + 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,9 +430,9 @@ mod tests { assert!(result.is_err()); let events = events.lock().unwrap(); assert!(events.iter().any(|event| { - event.event == "devcontainer.lifecycle.failed" - && event.properties["phase"] == "on_create" - && event.properties["exit_code"] == 1 + event.event_name() == "devcontainer.lifecycle.failed" + && event.properties()["phase"] == "on_create" + && event.properties()["exit_code"] == 1 })); } diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 8ca733485..3374f7f8d 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicI64, Ordering}; use anyhow::{Context, Result}; use chrono::{SecondsFormat, Utc}; use fabro_store::{EventPayload, SlateRunStore}; -use fabro_types::RunId; +use fabro_types::{RunId, StoredEvent}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::BTreeMap; @@ -18,13 +18,7 @@ use fabro_llm::types::Usage as LlmUsage; use fabro_types::StatusReason; use fabro_util::redact::redact_jsonl_line; -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum RunNoticeLevel { - Info, - Warn, - Error, -} +pub use fabro_types::{EventBody, RunNoticeLevel}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunEventEnvelope { @@ -43,6 +37,13 @@ pub struct RunEventEnvelope { pub properties: serde_json::Value, } +impl From<&RunEventEnvelope> for StoredEvent { + fn from(value: &RunEventEnvelope) -> Self { + StoredEvent::from_value(serde_json::to_value(value).expect("event envelope serializes")) + .expect("event envelope converts to stored event") + } +} + /// Events emitted during workflow run execution for observability. #[derive(Debug, Clone, Serialize, Deserialize)] #[allow(clippy::large_enum_variant)] @@ -1452,21 +1453,44 @@ pub fn canonicalize_event_at( } } -pub fn build_redacted_event_payload( - envelope: &RunEventEnvelope, +pub fn to_stored_event(run_id: &RunId, event: &WorkflowRunEvent) -> StoredEvent { + to_stored_event_at(run_id, event, Utc::now()) +} + +pub fn to_stored_event_at( run_id: &RunId, -) -> Result { - let line = redacted_event_json(envelope)?; + event: &WorkflowRunEvent, + ts: chrono::DateTime, +) -> StoredEvent { + let envelope = canonicalize_event_at(run_id, event, ts); + let mut stored = StoredEvent::from(&envelope); + + match (event, &mut stored.body) { + (WorkflowRunEvent::StageCompleted { failure, .. }, EventBody::StageCompleted(props)) => { + props.failure = failure.clone(); + } + (WorkflowRunEvent::StageFailed { failure, .. }, EventBody::StageFailed(props)) => { + props.failure = Some(failure.clone()); + } + _ => {} + } + + stored.refresh_cache(); + stored +} + +pub fn build_redacted_event_payload(event: &StoredEvent, run_id: &RunId) -> Result { + let line = redacted_event_json(event)?; event_payload_from_redacted_json(&line, run_id) } -pub fn redacted_event_json(envelope: &RunEventEnvelope) -> Result { - let line = serde_json::to_string(&normalized_envelope_value(envelope)?)?; +pub fn redacted_event_json(event: &StoredEvent) -> Result { + let line = serde_json::to_string(&normalized_event_value(event)?)?; Ok(redact_jsonl_line(&line)) } -fn normalized_envelope_value(envelope: &RunEventEnvelope) -> Result { - let value = serde_json::to_value(envelope)?; +fn normalized_event_value(event: &StoredEvent) -> Result { + let value = event.to_value()?; Ok(normalize_json_value(value)) } @@ -1498,8 +1522,8 @@ pub async fn append_workflow_event( run_id: &RunId, event: &WorkflowRunEvent, ) -> Result<()> { - let envelope = canonicalize_event(run_id, event); - let payload = build_redacted_event_payload(&envelope, run_id)?; + let stored = to_stored_event(run_id, event); + let payload = build_redacted_event_payload(&stored, run_id)?; run_store .append_event(&payload) .await @@ -1542,12 +1566,8 @@ impl StoreProgressLogger { pub fn register(&self, emitter: &EventEmitter) { let tx = self.tx.clone(); - emitter.on_event(move |event| { - 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) { + emitter.on_event( + move |event| match build_redacted_event_payload(event, &event.run_id) { Ok(payload) => { if tx.send(StoreProgressCommand::Event(payload)).is_err() { tracing::warn!( @@ -1558,8 +1578,8 @@ impl StoreProgressLogger { Err(err) => { tracing::warn!(error = %err, "Failed to build store event payload"); } - } - }); + }, + ); } pub async fn flush(&self) { @@ -1584,7 +1604,7 @@ 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 { @@ -1626,7 +1646,7 @@ impl EventEmitter { self.run_id } - pub fn on_event(&self, listener: impl Fn(&RunEventEnvelope) + Send + Sync + 'static) { + pub fn on_event(&self, listener: impl Fn(&StoredEvent) + Send + Sync + 'static) { self.listeners .lock() .expect("listeners lock poisoned") @@ -1642,11 +1662,11 @@ impl EventEmitter { "workflow run started event must match emitter run_id" ); } - let envelope = canonicalize_event(&self.run_id, event); - self.dispatch_envelope(&envelope); + let stored = to_stored_event(&self.run_id, event); + self.dispatch_stored_event(&stored); } - pub(crate) fn dispatch_envelope(&self, envelope: &RunEventEnvelope) { + pub(crate) fn dispatch_stored_event(&self, event: &StoredEvent) { 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. @@ -1657,7 +1677,7 @@ impl EventEmitter { .expect("listeners lock poisoned") .clone(); for listener in &snapshot { - listener(envelope); + listener(event); } } @@ -1720,8 +1740,8 @@ mod tests { }); let events = received.lock().unwrap(); assert_eq!(events.len(), 1); - assert_eq!(events[0].event, "run.started"); - assert_eq!(events[0].run_id, fixtures::RUN_1.to_string()); + assert_eq!(events[0].event_name(), "run.started"); + assert_eq!(events[0].run_id, fixtures::RUN_1); assert!(events[0].id.len() >= 32); } @@ -1910,7 +1930,8 @@ mod tests { }, ); - let payload = build_redacted_event_payload(&envelope, &fixtures::RUN_7).unwrap(); + let stored = StoredEvent::from(&envelope); + let payload = build_redacted_event_payload(&stored, &fixtures::RUN_7).unwrap(); run_store.append_event(&payload).await.unwrap(); let events = run_store.list_events().await.unwrap(); @@ -1935,7 +1956,8 @@ mod tests { }, ); - let payload = build_redacted_event_payload(&envelope, &fixtures::RUN_8).unwrap(); + let stored = StoredEvent::from(&envelope); + let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); assert_eq!(payload.as_value()["id"], envelope.id); assert_eq!(payload.as_value()["event"], "retro.started"); assert_eq!( diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index a6b175292..f0569e7ce 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -411,64 +411,125 @@ mod tests { #[tokio::test] async fn scan_node_files_from_state_reconstructs_allowlisted_entries() { - use fabro_store::EventPayload; + use crate::event::{WorkflowRunEvent, append_workflow_event}; let store = test_store(); let run = store.create_run(&fixtures::RUN_1).await.unwrap(); - let run_id_str = fixtures::RUN_1.to_string(); - - let event = |event_name: &str, props: serde_json::Value| -> EventPayload { - let value = serde_json::json!({ - "id": format!("evt-{event_name}"), - "ts": "2026-03-27T12:01:00Z", - "run_id": run_id_str, - "event": event_name, - "node_id": "work", - "properties": props, - }); - EventPayload::new(value, &fixtures::RUN_1).unwrap() - }; - - run.append_event(&event( - "stage.prompt", - serde_json::json!({"text": "hello", "visit": 2, "mode": "prompt", "provider": "openai"}), - )) + append_workflow_event( + &run, + &fixtures::RUN_1, + &WorkflowRunEvent::Prompt { + stage: "work".into(), + visit: 2, + text: "hello".into(), + mode: Some("prompt".into()), + provider: Some("openai".into()), + model: Some("gpt-5.4".into()), + }, + ) .await .unwrap(); - run.append_event(&event( - "prompt.completed", - serde_json::json!({"response": "world"}), - )) + append_workflow_event( + &run, + &fixtures::RUN_1, + &WorkflowRunEvent::PromptCompleted { + node_id: "work".into(), + response: "world".into(), + model: "gpt-5.4".into(), + provider: "openai".into(), + usage: None, + }, + ) .await .unwrap(); - run.append_event(&event( - "stage.completed", - serde_json::json!({"response": "world", "status": "success", "visit": 2}), - )) + append_workflow_event( + &run, + &fixtures::RUN_1, + &WorkflowRunEvent::StageCompleted { + node_id: "work".into(), + name: "Work".into(), + index: 2, + duration_ms: 100, + status: "success".into(), + preferred_label: None, + suggested_next_ids: Vec::new(), + usage: None, + failure: None, + notes: None, + files_touched: Vec::new(), + context_updates: None, + jump_to_node: None, + context_values: None, + node_visits: Some(std::collections::BTreeMap::from([("work".into(), 2)])), + loop_failure_signatures: None, + restart_failure_signatures: None, + response: Some("world".into()), + attempt: 1, + max_attempts: 1, + }, + ) .await .unwrap(); - run.append_event(&event( - "command.started", - serde_json::json!({"command": "echo hi"}), - )) + append_workflow_event( + &run, + &fixtures::RUN_1, + &WorkflowRunEvent::CommandStarted { + node_id: "work".into(), + script: "echo hi".into(), + command: "echo hi".into(), + language: "shell".into(), + timeout_ms: None, + }, + ) .await .unwrap(); - run.append_event(&event( - "command.completed", - serde_json::json!({"stdout": "hi\n", "stderr": "", "exit_code": 0}), - )) + append_workflow_event( + &run, + &fixtures::RUN_1, + &WorkflowRunEvent::CommandCompleted { + node_id: "work".into(), + stdout: "hi\n".into(), + stderr: String::new(), + exit_code: Some(0), + duration_ms: 10, + timed_out: false, + }, + ) .await .unwrap(); - run.append_event(&event( - "parallel.completed", - serde_json::json!({"results": [{"id": "a"}], "duration_ms": 100, "success_count": 1, "failure_count": 0}), - )) + append_workflow_event( + &run, + &fixtures::RUN_1, + &WorkflowRunEvent::ParallelCompleted { + node_id: "work".into(), + visit: 2, + duration_ms: 100, + success_count: 1, + failure_count: 0, + results: vec![serde_json::json!({"id": "a"})], + }, + ) .await .unwrap(); - run.append_event(&event( - "checkpoint.completed", - serde_json::json!({"diff": "diff --git a/story.txt b/story.txt", "ordinal": 1, "current_node": "work", "node_visits": {"work": 2}}), - )) + append_workflow_event( + &run, + &fixtures::RUN_1, + &WorkflowRunEvent::CheckpointCompleted { + node_id: "work".into(), + status: "success".into(), + current_node: "work".into(), + completed_nodes: Vec::new(), + node_retries: std::collections::BTreeMap::new(), + context_values: std::collections::BTreeMap::new(), + node_outcomes: std::collections::BTreeMap::new(), + next_node_id: None, + git_commit_sha: None, + loop_failure_signatures: std::collections::BTreeMap::new(), + restart_failure_signatures: std::collections::BTreeMap::new(), + node_visits: std::collections::BTreeMap::from([("work".into(), 2)]), + diff: Some("diff --git a/story.txt b/story.txt".into()), + }, + ) .await .unwrap(); diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 07d856b51..f307397c6 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -15,8 +15,8 @@ use fabro_types::{RunId, Settings}; use crate::context::Context; use crate::error::FabroError; use crate::event::{ - EventEmitter, RunNoticeLevel, StoreProgressLogger, WorkflowRunEvent, append_workflow_event, - canonicalize_event, event_payload_from_redacted_json, redacted_event_json, + EventBody, EventEmitter, RunNoticeLevel, StoreProgressLogger, WorkflowRunEvent, + append_workflow_event, event_payload_from_redacted_json, redacted_event_json, to_stored_event, }; use crate::git::MetadataStore; use crate::handler::HandlerRegistry; @@ -453,31 +453,23 @@ impl RunSession { { let sha_clone = Arc::clone(&last_git_sha); self.emitter.on_event(move |event| match event { - 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()); + event if matches!(&event.body, EventBody::CheckpointCompleted(_)) => { + if let EventBody::CheckpointCompleted(props) = &event.body { + if let Some(sha) = props.git_commit_sha.as_ref() { + *sha_clone.lock().unwrap() = Some(sha.clone()); + } } } - 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()); + event if matches!(&event.body, EventBody::RunCompleted(_)) => { + if let EventBody::RunCompleted(props) = &event.body { + if let Some(sha) = props.final_git_commit_sha.as_ref() { + *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()); + event if matches!(&event.body, EventBody::GitCommit(_)) => { + if let EventBody::GitCommit(props) = &event.body { + *sha_clone.lock().unwrap() = Some(props.sha.to_string()); } } _ => {} @@ -695,7 +687,7 @@ impl Drop for DetachedRunCompletionGuard { }; let serialized_notice = { - let envelope = canonicalize_event( + let stored = to_stored_event( &self.run_id, &WorkflowRunEvent::RunNotice { level: RunNoticeLevel::Error, @@ -703,7 +695,7 @@ impl Drop for DetachedRunCompletionGuard { message: message.to_string(), }, ); - let line = match redacted_event_json(&envelope) { + let line = match redacted_event_json(&stored) { Ok(line) => line, Err(err) => { tracing::warn!(error = %err, "Failed to serialize post-run abort event"); @@ -732,7 +724,7 @@ impl Drop for DetachedRunCompletionGuard { ) .await; if let Some((run_id, line)) = serialized_notice.or_else(|| { - let envelope = canonicalize_event( + let stored = to_stored_event( &run_id, &WorkflowRunEvent::RunNotice { level: RunNoticeLevel::Error, @@ -740,9 +732,7 @@ impl Drop for DetachedRunCompletionGuard { message: message.to_string(), }, ); - redacted_event_json(&envelope) - .ok() - .map(|line| (run_id, line)) + redacted_event_json(&stored).ok().map(|line| (run_id, line)) }) { match event_payload_from_redacted_json(&line, &run_id) { Ok(payload) => { @@ -791,8 +781,8 @@ async fn persist_detached_failure( code: format!("{phase}_failed"), message: message.clone(), }; - let envelope = canonicalize_event(&run_id, &event); - let line = redacted_event_json(&envelope).map_err(|err| FabroError::Io(err.to_string()))?; + let stored = to_stored_event(&run_id, &event); + let line = redacted_event_json(&stored).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 { @@ -911,7 +901,9 @@ mod tests { if injected.load(Ordering::SeqCst) { return; } - if event.event == "stage.started" && event.node_id.as_deref() == Some("start") { + if matches!(&event.body, EventBody::StageStarted(_)) + && event.node_id.as_deref() == Some("start") + { injected.store(true, Ordering::SeqCst); emitter_for_injection.emit(&WorkflowRunEvent::CheckpointCompleted { node_id: "start".to_string(), diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index d5ea8befa..b65154d28 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -19,7 +19,7 @@ use object_store::memory::InMemory; use super::*; use crate::context::{self, Context}; use crate::error::FabroError; -use crate::event::{EventEmitter, RunEventEnvelope, StoreProgressLogger}; +use crate::event::{EventEmitter, StoreProgressLogger}; use crate::handler::start::StartHandler; use crate::handler::{Handler as HandlerTrait, HandlerRegistry}; use crate::outcome::{Outcome, OutcomeExt, StageStatus}; @@ -894,7 +894,7 @@ 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 = test_emitter("retry-events-test"); emitter.on_event(move |event| { @@ -923,8 +923,10 @@ async fn retry_emits_stage_started_per_attempt() { let collected = events.lock().unwrap(); let work_started: Vec<_> = collected .iter() - .filter(|event| event.event == "stage.started" && event.node_id.as_deref() == Some("work")) - .map(|event| event.properties["attempt"].as_u64().unwrap()) + .filter(|event| { + event.event_name() == "stage.started" && event.node_id.as_deref() == Some("work") + }) + .map(|event| event.properties()["attempt"].as_u64().unwrap()) .collect(); assert_eq!(work_started, vec![1, 2]); } @@ -936,7 +938,7 @@ async fn run_with_lifecycle_emits_initialize_and_setup_events() { let events_clone = Arc::clone(&events); let emitter = test_emitter("order-test"); emitter.on_event(move |event| { - let name = match event.event.as_str() { + let name = match event.event_name().as_str() { "sandbox.initialized" => "SandboxInitialized", "setup.started" => "SetupStarted", "setup.completed" => "SetupCompleted", @@ -1017,7 +1019,7 @@ 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 = test_emitter("git-cp-test"); emitter.on_event(move |event| { @@ -1049,9 +1051,9 @@ async fn git_checkpoint_skips_start_node() { let checkpoint_node_ids: Vec<&str> = collected .iter() .filter(|event| { - event.event == "checkpoint.completed" + event.event_name() == "checkpoint.completed" && event - .properties + .properties() .get("git_commit_sha") .and_then(|value| value.as_str()) .is_some() diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index dd51131d7..513c5b085 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -835,7 +835,7 @@ mod tests { let seen = Arc::new(std::sync::Mutex::new(Vec::new())); emitter.on_event({ let seen = Arc::clone(&seen); - move |event| seen.lock().unwrap().push(event.event.clone()) + move |event| seen.lock().unwrap().push(event.event_name()) }); store_logger.register(&emitter); diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index c7a7c1bef..3127035e4 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -390,22 +390,25 @@ mod tests { let seen = seen.lock().unwrap(); let retro_started = seen .iter() - .find(|event| event.event == "retro.started") + .find(|event| event.event_name() == "retro.started") .unwrap(); - assert_eq!(retro_started.properties["provider"], "anthropic"); - assert_eq!(retro_started.properties["model"], "test-model"); + assert_eq!(retro_started.properties()["provider"], "anthropic"); + assert_eq!(retro_started.properties()["model"], "test-model"); assert!( - retro_started.properties["prompt"] + retro_started.properties()["prompt"] .as_str() .is_some_and(|prompt| prompt.contains("/tmp/retro_data/progress.jsonl")) ); let retro_completed = seen .iter() - .find(|event| event.event == "retro.completed") + .find(|event| event.event_name() == "retro.completed") .unwrap(); - assert_eq!(retro_completed.properties["response"], ""); - assert!(retro_completed.properties.get("retro").is_some()); - assert_eq!(retro_completed.properties["retro"]["smoothness"], "smooth"); + assert_eq!(retro_completed.properties()["response"], ""); + assert!(retro_completed.properties().get("retro").is_some()); + assert_eq!( + retro_completed.properties()["retro"]["smoothness"], + "smooth" + ); } } diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index e9aa2feb4..6c720cf2c 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -31,7 +31,7 @@ struct InitializedState { 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.on_event(move |event| observer_clone.dispatch_stored_event(event)); emitter } diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 08215c595..392e3ac05 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -25,11 +25,11 @@ use fabro_interview::{ }; use fabro_llm::provider::Provider; use fabro_store::{RuntimeState, SlateStore}; -use fabro_types::{RunId, Settings}; +use fabro_types::{RunId, Settings, StoredEvent}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; use fabro_workflow::error::{FabroError, FailureSignatureExt}; -use fabro_workflow::event::{EventEmitter, RunEventEnvelope, WorkflowRunEvent}; +use fabro_workflow::event::{EventEmitter, WorkflowRunEvent}; use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenResult}; use fabro_workflow::handler::command::CommandHandler; use fabro_workflow::handler::conditional::ConditionalHandler; @@ -1545,7 +1545,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| { @@ -7316,10 +7316,7 @@ impl HookTestRunner { } } -fn emitter_with_events() -> ( - Arc, - Arc>>, -) { +fn emitter_with_events() -> (Arc, Arc>>) { let emitter = EventEmitter::default(); let events = collect_events(&emitter); (Arc::new(emitter), events) @@ -7334,7 +7331,7 @@ fn engine_with_hooks(hooks: Vec) -> HookTestRunner fn engine_with_hooks_and_events( hooks: Vec, -) -> (HookTestRunner, Arc>>) { +) -> (HookTestRunner, Arc>>) { let (emitter, events) = emitter_with_events(); ( HookTestRunner { @@ -12446,7 +12443,7 @@ async fn asset_collection_local_sandbox_success() { // Check that AssetCaptured events were emitted let captured_events = events.lock().unwrap(); - let asset_events: Vec<&RunEventEnvelope> = captured_events + let asset_events: Vec<&StoredEvent> = captured_events .iter() .filter(|e| e.event == "asset.captured") .collect();