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.
This commit is contained in:
Bryan Helmkamp 2026-04-04 10:38:44 -04:00
parent 14516f3e9c
commit 871bc500e4
32 changed files with 2019 additions and 543 deletions

View file

@ -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<String, Value>,
) -> Option<ProgressEvent> {
fn from_envelope_fields(event_name: &str, fields: &Map<String, Value>) -> Option<ProgressEvent> {
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<ProgressEvent> {
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<ProgressEvent> {
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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<chrono::Utc>,
// Populated when running:
interviewer: Option<Arc<WebInterviewer>>,
event_tx: Option<broadcast::Sender<RunEventEnvelope>>,
event_tx: Option<broadcast::Sender<StoredEvent>>,
context: Option<Context>,
checkpoint: Option<Checkpoint>,
cancel_tx: Option<oneshot::Sender<()>>,

View file

@ -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::<fabro_types::Settings>(&properties, "settings")?;
let graph = required_json::<fabro_types::Graph>(&properties, "graph")?;
let working_directory =
required_string(&properties, "working_directory").map(PathBuf::from)?;
let labels = optional_json::<BTreeMap<String, String>>(&properties, "labels")?
.unwrap_or_default()
.into_iter()
.collect::<HashMap<_, _>>();
match &stored.body {
EventBody::RunCreated(props) => {
let working_directory = PathBuf::from(&props.working_directory);
let labels = props.labels.clone().into_iter().collect::<HashMap<_, _>>();
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::<Retro>(&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::<fabro_types::FailureDetail>(&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<DateTime<Utc>> {
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<RunId> {
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<String, Value>, key: &str) -> Result<String> {
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<String, Value>, key: &str) -> Option<String> {
properties
.get(key)
.and_then(Value::as_str)
.map(ToString::to_string)
}
fn required_u64(properties: &serde_json::Map<String, Value>, key: &str) -> Result<u64> {
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<String, Value>, key: &str) -> Result<u32> {
u32::try_from(required_u64(properties, key)?)
.map_err(|_| StoreError::InvalidEvent(format!("property {key} does not fit in u32")))
}
fn required_json<T: DeserializeOwned>(
properties: &serde_json::Map<String, Value>,
key: &str,
) -> Result<T> {
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<T: DeserializeOwned>(
properties: &serde_json::Map<String, Value>,
key: &str,
) -> Result<Option<T>> {
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<String, Value>) -> Result<Option<StatusReason>> {
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<String, Value>,
reason: Option<StatusReason>,
updated_at: DateTime<Utc>,
) -> Result<RunStatusRecord> {
Ok(RunStatusRecord {
) -> RunStatusRecord {
RunStatusRecord {
status,
reason: parse_reason(properties)?,
reason,
updated_at,
})
}
}
fn checkpoint_from_properties(
properties: &serde_json::Map<String, Value>,
fn checkpoint_from_props(
props: &fabro_types::stored_event::CheckpointCompletedProps,
timestamp: DateTime<Utc>,
) -> Result<Checkpoint> {
let loop_failure_signatures =
optional_json::<HashMap<String, usize>>(properties, "loop_failure_signatures")?
.unwrap_or_default()
.into_iter()
.map(|(key, value)| (FailureSignature(key), value))
.collect();
let restart_failure_signatures =
optional_json::<HashMap<String, usize>>(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<String, Value>,
props: &RunCompletedProps,
timestamp: DateTime<Utc>,
) -> Result<Conclusion> {
let usage = optional_json::<RunUsage>(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<String, Value>,
timestamp: DateTime<Utc>,
) -> Conclusion {
fn conclusion_from_failed(props: &RunFailedProps, timestamp: DateTime<Utc>) -> 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<String, Value>,
node_visits: Option<&BTreeMap<String, usize>>,
state: &RunProjection,
) -> Option<u32> {
properties
.get("node_visits")
.and_then(|value| serde_json::from_value::<HashMap<String, usize>>(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<String, Value>,
) -> Result<Outcome<Option<StageUsage>>> {
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<Option<StageUsage>> {
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<String, Value>) -> Option<Value> {
fn provider_used_from_prompt(props: &fabro_types::stored_event::StagePromptProps) -> Option<Value> {
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<String, Value>,
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,
}
}

View file

@ -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<Utc>) -> StartRecord {
StartRecord {
run_id: test_run_id(run_id),

View file

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

View file

@ -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<String, AttrValue>,
@ -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<String, Node>,

View file

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

View file

@ -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<M: OutcomeMeta = ()> {
pub status: StageStatus,

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
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,
}

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[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<String>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_working_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_mount_point: Option<String>,
}
#[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,
}

View file

@ -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<String>,
}
#[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<Value>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<String>,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggested_next_ids: Vec<String>,
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<u64>,
}
#[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<i32>,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RetroCompletedProps {
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retro: Option<Value>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
}
impl From<TokenUsage> 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,
}
}
}

View file

@ -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<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StoredEvent {
pub id: String,
pub ts: DateTime<Utc>,
pub run_id: RunId,
pub event: String,
pub node_id: Option<String>,
pub node_label: Option<String>,
pub session_id: Option<String>,
pub parent_session_id: Option<String>,
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<Utc>,
run_id: RunId,
#[serde(default)]
node_id: Option<String>,
#[serde(default)]
node_label: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
parent_session_id: Option<String>,
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<Self> {
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> {
Self::from_value(serde_json::from_str(line)?)
}
pub fn to_value(&self) -> serde_json::Result<Value> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.to_value()
.map_err(serde::ser::Error::custom)?
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for StoredEvent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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(_)));
}
}

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_config: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
pub run_dir: String,
pub working_directory: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub db_prefix: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStartedProps {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStatusTransitionProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<StatusReason>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_commit_sha: Option<String>,
}
#[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<StatusReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
}
#[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<StatusReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_commit_sha: Option<String>,
}
#[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<chrono::Utc>,
pub run_id: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_session_id: Option<String>,
}

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggested_next_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<StageUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<FailureDetail>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_updates: Option<BTreeMap<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jump_to_node: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_values: Option<BTreeMap<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_visits: Option<BTreeMap<String, usize>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loop_failure_signatures: Option<BTreeMap<String, usize>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_failure_signatures: Option<BTreeMap<String, usize>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<String>,
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<FailureDetail>,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[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<StageUsage>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub node_retries: BTreeMap<String, u32>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub context_values: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub node_outcomes: BTreeMap<String, Outcome<Option<StageUsage>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub loop_failure_signatures: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub restart_failure_signatures: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub node_visits: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CheckpointFailedProps {
pub error: String,
}

View file

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

View file

@ -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::<crate::event::RunEventEnvelope>::new()));
let events = Arc::new(Mutex::new(Vec::<fabro_types::StoredEvent>::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::<crate::event::RunEventEnvelope>::new()));
let events = Arc::new(Mutex::new(Vec::<fabro_types::StoredEvent>::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
}));
}

View file

@ -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<EventPayload> {
let line = redacted_event_json(envelope)?;
event: &WorkflowRunEvent,
ts: chrono::DateTime<Utc>,
) -> 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<EventPayload> {
let line = redacted_event_json(event)?;
event_payload_from_redacted_json(&line, run_id)
}
pub fn redacted_event_json(envelope: &RunEventEnvelope) -> Result<String> {
let line = serde_json::to_string(&normalized_envelope_value(envelope)?)?;
pub fn redacted_event_json(event: &StoredEvent) -> Result<String> {
let line = serde_json::to_string(&normalized_event_value(event)?)?;
Ok(redact_jsonl_line(&line))
}
fn normalized_envelope_value(envelope: &RunEventEnvelope) -> Result<Value> {
let value = serde_json::to_value(envelope)?;
fn normalized_event_value(event: &StoredEvent) -> Result<Value> {
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::<RunId>() 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<dyn Fn(&RunEventEnvelope) + Send + Sync>;
type EventListener = Arc<dyn Fn(&StoredEvent) + Send + Sync>;
/// 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!(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,7 +31,7 @@ struct InitializedState {
fn bound_emitter(run_id: fabro_types::RunId, observer: &Arc<EventEmitter>) -> Arc<EventEmitter> {
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
}

View file

@ -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<std::sync::Mutex<Vec<RunEventEnvelope>>> {
fn collect_events(emitter: &EventEmitter) -> Arc<std::sync::Mutex<Vec<StoredEvent>>> {
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<EventEmitter>,
Arc<std::sync::Mutex<Vec<RunEventEnvelope>>>,
) {
fn emitter_with_events() -> (Arc<EventEmitter>, Arc<std::sync::Mutex<Vec<StoredEvent>>>) {
let emitter = EventEmitter::default();
let events = collect_events(&emitter);
(Arc::new(emitter), events)
@ -7334,7 +7331,7 @@ fn engine_with_hooks(hooks: Vec<fabro_hooks::HookDefinition>) -> HookTestRunner
fn engine_with_hooks_and_events(
hooks: Vec<fabro_hooks::HookDefinition>,
) -> (HookTestRunner, Arc<std::sync::Mutex<Vec<RunEventEnvelope>>>) {
) -> (HookTestRunner, Arc<std::sync::Mutex<Vec<StoredEvent>>>) {
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();