From ae5ccb5ce20acaaf30257ca4ef0eac27d6440514 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 2 May 2026 14:52:10 -0400 Subject: [PATCH] refactor(workflow): split event module by responsibility Keep fabro_workflow::event as the public facade while moving event conversion, names, redaction, sink, emitter, stored-field helpers, and StageScope into focused modules. Co-locate the existing event tests with the moved code and update the events strategy docs for the new module layout. --- docs/internal/events-strategy.md | 6 +- .../plans/2026-05-02-event-module-split.md | 196 + lib/crates/fabro-workflow/src/event.rs | 4072 +---------------- .../fabro-workflow/src/event/convert.rs | 1778 +++++++ .../fabro-workflow/src/event/emitter.rs | 193 + lib/crates/fabro-workflow/src/event/events.rs | 1281 ++++++ lib/crates/fabro-workflow/src/event/names.rs | 193 + .../fabro-workflow/src/event/redaction.rs | 81 + lib/crates/fabro-workflow/src/event/sink.rs | 339 ++ .../fabro-workflow/src/event/stored_fields.rs | 218 + lib/crates/fabro-workflow/src/lib.rs | 1 + lib/crates/fabro-workflow/src/stage_scope.rs | 67 + 12 files changed, 4368 insertions(+), 4057 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-02-event-module-split.md create mode 100644 lib/crates/fabro-workflow/src/event/convert.rs create mode 100644 lib/crates/fabro-workflow/src/event/emitter.rs create mode 100644 lib/crates/fabro-workflow/src/event/events.rs create mode 100644 lib/crates/fabro-workflow/src/event/names.rs create mode 100644 lib/crates/fabro-workflow/src/event/redaction.rs create mode 100644 lib/crates/fabro-workflow/src/event/sink.rs create mode 100644 lib/crates/fabro-workflow/src/event/stored_fields.rs create mode 100644 lib/crates/fabro-workflow/src/stage_scope.rs diff --git a/docs/internal/events-strategy.md b/docs/internal/events-strategy.md index 136c76cce..68b0e8aef 100644 --- a/docs/internal/events-strategy.md +++ b/docs/internal/events-strategy.md @@ -19,7 +19,7 @@ Engine/Handler -> Event -> Emitter::emit() `- CLI / tests / metrics listeners ``` -The canonical `RunEvent` is built exactly once in `fabro-workflow/src/event.rs`. +The canonical `RunEvent` is built exactly once in the `fabro-workflow::event` module. - `Event` (in `fabro-workflow`) is the internal typed event emitted by engine and handlers. - `Emitter` owns an immutable `run_id` and converts `Event` into `RunEvent` via `to_run_event_at()`. @@ -94,7 +94,7 @@ The external event name is lowercase dot notation, for example: - `sandbox.ready` - `parallel.branch.completed` -`event_name()` in `event.rs` is exhaustive. Do not use wildcard fallthroughs when adding new variants. +`event_name()` in the `fabro-workflow::event` module is exhaustive. Do not use wildcard fallthroughs when adding new variants. ## Node And Session Metadata @@ -138,7 +138,7 @@ Add a variant to `EventBody` in `fabro-types/src/run_event/mod.rs` with a corres ### 5. Map envelope fields and construct `EventBody` -Update `stored_event_fields()` and `event_body_from_event()` in `fabro-workflow/src/event.rs`: +Update `stored_event_fields()` and `event_body_from_event()` in the `fabro-workflow::event` module: - Move `node_id`, `node_label`, `session_id`, and `parent_session_id` into the envelope when appropriate. - Construct the `EventBody` variant directly from the `Event` fields. diff --git a/docs/superpowers/plans/2026-05-02-event-module-split.md b/docs/superpowers/plans/2026-05-02-event-module-split.md new file mode 100644 index 000000000..c0cc895a2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-event-module-split.md @@ -0,0 +1,196 @@ +# Mechanical Event Module Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split `fabro-workflow`'s oversized event module into focused child modules while preserving `fabro_workflow::event::{...}` as the public API. + +**Architecture:** Keep `src/event.rs` as a facade that declares child modules and re-exports the existing public symbols. Move code mechanically by responsibility, co-locate tests with the modules they cover, and move `StageScope` to a crate-root module while preserving `fabro_workflow::event::StageScope`. `event/events.rs` intentionally remains the largest file because it keeps the `Event` enum and its exhaustive tracing behavior together. + +**Tech Stack:** Rust, Tokio, serde/serde_json, chrono, uuid, fabro-types `RunEvent` / `EventBody`, fabro-store `EventPayload`, existing `cargo nextest` workflow tests. + +--- + +## Files + +- Modify: `lib/crates/fabro-workflow/src/event.rs` +- Create: `lib/crates/fabro-workflow/src/event/events.rs` +- Create: `lib/crates/fabro-workflow/src/event/names.rs` +- Create: `lib/crates/fabro-workflow/src/event/stored_fields.rs` +- Create: `lib/crates/fabro-workflow/src/event/convert.rs` +- Create: `lib/crates/fabro-workflow/src/event/redaction.rs` +- Create: `lib/crates/fabro-workflow/src/event/sink.rs` +- Create: `lib/crates/fabro-workflow/src/event/emitter.rs` +- Create: `lib/crates/fabro-workflow/src/stage_scope.rs` +- Modify: `lib/crates/fabro-workflow/src/lib.rs` +- Modify: `docs/internal/events-strategy.md` + +## Task 1: Confirm Private Helpers and Build the Facade + +- [x] Confirm these helpers are not used outside `event.rs` before moving them: + +```bash +rg -n "StoredEventFields|event_body_from_event|normalized_event_value|redacted_event_value|RunEventCommand|RunEventSinkFuture|RunEventSinkCallback|RunEventTransform|agent_tool_call_id|agent_actor_for_event|default_node_label|node_stored_fields|billed_token_counts_from_llm|stage_status_from_string|epoch_millis" . --glob '!target' --glob '!apps/fabro-web/dist/**' +``` + +Expected: production hits are limited to `lib/crates/fabro-workflow/src/event.rs`; plan and historical docs may mention the names. + +- [x] Replace `event.rs` with child module declarations and public re-exports: + +```rust +mod convert; +mod emitter; +mod events; +mod names; +mod redaction; +mod sink; +mod stored_fields; + +pub use fabro_types::{EventBody, RunNoticeLevel}; + +pub use self::convert::{to_run_event, to_run_event_at}; +pub use self::emitter::Emitter; +pub use self::events::Event; +pub use self::names::event_name; +pub use self::redaction::{ + build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json, +}; +pub use self::sink::{ + RunEventLogger, RunEventSink, StoreProgressLogger, append_event, append_event_to_sink, +}; +pub use crate::stage_scope::StageScope; +``` + +- [x] Add `mod stage_scope;` to `lib.rs`. Do not expose a new `fabro_workflow::stage_scope` public module in this pass; preserve the existing public path through `pub use crate::stage_scope::StageScope` in `event.rs`. + +## Task 2: Move Event, Names, Stage Scope, and Stored Fields + +- [x] Move the `Event` enum, `Event::pull_request_created`, and `Event::trace` into `event/events.rs`. Keep all derives, serde attributes, clippy allowances, variant fields, tracing levels, tracing fields, and `PullRequestRecord` behavior unchanged. + +- [x] Move `event_name` into `event/names.rs`. Keep the exhaustive match and all returned strings unchanged. + +- [x] Move `StageScope` into `stage_scope.rs`. Keep constructors and `stage_id()` unchanged, including use of `visit_from_context`. Preserve `fabro_workflow::event::StageScope` by re-exporting it from `event.rs`. + +- [x] In `stage_scope.rs`, import only the dependencies needed by `StageScope`: `fabro_types::{ParallelBranchId, StageId}`, `crate::context::{Context as WfContext, WorkflowContext}`, and `crate::run_dir::visit_from_context`. Do not import from `crate::event`. + +- [x] Move stored-field helpers into `event/stored_fields.rs`: + - `StoredEventFields` + - `default_node_label` + - `node_stored_fields` + - `stored_event_fields` + - `stored_event_fields_for_variant` + - `agent_tool_call_id` + - `agent_actor_for_event` + +- [x] Make both `StoredEventFields` and `stored_event_fields` `pub(super)` because `convert.rs` calls the function and reads fields from its return value. Keep `default_node_label`, `node_stored_fields`, `stored_event_fields_for_variant`, `agent_tool_call_id`, and `agent_actor_for_event` private to `stored_fields.rs`. + +## Task 3: Move Conversion and Redaction + +- [x] Move conversion helpers into `event/convert.rs`: + - `billed_token_counts_from_llm` + - `stage_status_from_string` + - `event_body_from_event` + - `to_run_event` + - `to_run_event_at` + +- [x] Keep `event_body_from_event` private to `convert.rs`. Import `stored_event_fields` from `event/stored_fields.rs`. Keep `to_run_event` and `to_run_event_at` public through the facade re-export. + +- [x] Move redaction helpers into `event/redaction.rs`: + - `build_redacted_event_payload` + - `redacted_event_json` + - `normalized_event_value` + - `redacted_event_value` + - `event_payload_from_redacted_json` + +- [x] Keep `normalized_event_value` and `redacted_event_value` private. Keep redaction behavior exactly as `RunEvent::to_value() -> normalize_json_value -> redact_json_value`. + +## Task 4: Move Sink, Logger, and Emitter Plumbing + +- [x] Move sink and logger code into `event/sink.rs`: + - `append_event` + - `append_event_to_sink` + - `RunEventSink` + - `RunEventSinkFuture` + - `RunEventSinkCallback` + - `RunEventTransform` + - `RunEventCommand` + - `RunEventLogger` + - `StoreProgressLogger` + +- [x] Keep `RunEventCommand` and callback type aliases private. Preserve the iterative `RunEventSink::write_run_event` stack logic and redacted JSONL output behavior. + +- [x] Move emitter code into `event/emitter.rs`: + - `epoch_millis` + - `EventListener` + - `Emitter` + - `Debug`, `Default`, and inherent impls + +- [x] Keep `dispatch_run_event` as `pub(crate)` and keep all public `Emitter` methods unchanged. + +## Task 5: Co-locate Tests and Update Docs + +- [x] Move each existing inline test into the module it characterizes. Keep test names, fixtures, assertions, and async test attributes unchanged. + +- [x] Use this test placement: + - `emitter.rs`: `event_emitter_*` + - `sink.rs`: `run_event_sink_*`, `run_event_logger_*`, `append_event_writes_store_event_shape` + - `redaction.rs`: `build_redacted_event_payload_*` + - `names.rs`: `event_name_matches_new_dot_notation`, `run_archived_event_name_matches_dot_notation` + - `stored_fields.rs`: only direct helper tests introduced during the move, if needed; it is acceptable for this module to have no tests + - `convert.rs`: all `run_event_*` tests, actor envelope tests, tool call id tests, parallel id tests, stage id tests, metadata snapshot mapping tests, and `stage_scope_populates_stage_id_on_non_stage_events` + - `stage_scope.rs`: direct `StageScope` constructor tests introduced during the move, if needed; it is acceptable for this module to have no tests + +- [x] Update `docs/internal/events-strategy.md` references that say the canonical conversion is in `fabro-workflow/src/event.rs` so they refer to the `fabro-workflow::event` module. Do not change event-strategy rules. + +- [x] Do not update production call sites outside the event module unless compilation requires an import fix. The intended result is that existing imports such as `crate::event::{Emitter, Event}` and `fabro_workflow::event::{Event, to_run_event}` continue to work. + +## Task 6: Verify the Mechanical Split + +- [x] Run focused event tests: + +```bash +cargo nextest run -p fabro-workflow event +``` + +Expected: all matching tests pass. + +- [x] Run the full workflow crate test suite: + +```bash +cargo nextest run -p fabro-workflow +``` + +Expected: all `fabro-workflow` tests pass. + +- [x] Run format check: + +```bash +cargo +nightly-2026-04-14 fmt --check --all +``` + +Expected: no formatting diffs. + +- [x] Run clippy: + +```bash +cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings +``` + +Expected: no warnings. + +## Acceptance Criteria + +- `lib/crates/fabro-workflow/src/event.rs` is a small facade module. +- Existing public API paths under `fabro_workflow::event::{...}` still compile. +- `StageScope` lives at crate root and remains available from `fabro_workflow::event::StageScope`. +- Tests are co-located with the module they cover; there is no catch-all `event/tests.rs`. +- Event wire names, envelope metadata, `EventBody` conversion, redaction, JSONL sink output, store payload shape, and emitter dispatch behavior are unchanged. +- No macro registry, generated event table, or domain-split `Event` enum is introduced in this pass. +- `docs/internal/events-strategy.md` remains accurate after the file split. + +## Assumptions + +- This is a strictly mechanical refactor; deeper cleanup like domain-specific event enums or shared event DTO extraction is out of scope. +- `event/events.rs` remains intentionally large because keeping `Event::trace` with `Event` avoids splitting a pure inherent `Event` behavior into a separate file. +- Keeping `convert.rs` focused on `Event -> EventBody` body conversion is acceptable even if it remains one of the larger event files. +- `RunEventSink`, `RunEventLogger`, and `StoreProgressLogger` remain together in `sink.rs` for this pass; split them later only if that file remains hard to navigate after tests are co-located. +- Existing tests are sufficient characterization coverage for this split; new behavior tests are not required unless a moved module exposes an accidental visibility issue. diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 378679f2d..63d1647a6 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -1,4057 +1,21 @@ -use std::collections::BTreeMap; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; -use std::sync::atomic::{AtomicI64, Ordering}; +mod convert; +mod emitter; +mod events; +mod names; +mod redaction; +mod sink; +mod stored_fields; -use ::fabro_types::{ - BilledTokenCounts, BlockedReason, CommandTermination, FailureReason, ForkSourceRef, GitContext, - ParallelBranchId, Principal, PullRequestRecord, RunBlobId, RunControlAction, RunEvent, RunId, - RunProvenance, StageId, StageOutcome, SuccessReason, SystemActorKind, run_event as fabro_types, -}; -use anyhow::{Context, Result}; -use chrono::Utc; -use fabro_agent::{AgentEvent, SandboxEvent, WorktreeEvent, WorktreeEventCallback}; -use fabro_llm::types::TokenCounts as LlmTokenCounts; -use fabro_redact::redact_json_value; -use fabro_store::{EventPayload, RunDatabase}; pub use fabro_types::{EventBody, RunNoticeLevel}; -use fabro_util::json::normalize_json_value; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tokio::io::{AsyncWrite, AsyncWriteExt}; -use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot}; -use uuid::Uuid; -use crate::context::{Context as WfContext, WorkflowContext}; -use crate::error::Error; -use crate::outcome::{BilledModelUsage, FailureDetail, Outcome}; -use crate::run_dir::visit_from_context; -use crate::runtime_store::RunStoreHandle; - -/// Events emitted during workflow run execution for observability. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow( - clippy::large_enum_variant, - reason = "Workflow events stay inline to match the serialized event stream." -)] -pub enum Event { - RunCreated { - run_id: RunId, - settings: serde_json::Value, - graph: serde_json::Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - workflow_source: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - workflow_config: Option, - labels: BTreeMap, - run_dir: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - source_directory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - workflow_slug: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - db_prefix: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provenance: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - manifest_blob: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - git: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - fork_source_ref: Option, - #[serde(default)] - in_place: bool, - }, - WorkflowRunStarted { - name: String, - run_id: RunId, - #[serde(default, skip_serializing_if = "Option::is_none")] - base_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - base_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - run_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - worktree_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - goal: Option, - }, - RunSubmitted { - #[serde(default, skip_serializing_if = "Option::is_none")] - definition_blob: Option, - }, - RunQueued, - RunStarting, - RunRunning, - RunBlocked { - blocked_reason: BlockedReason, - }, - RunUnblocked, - RunRemoving, - RunCancelRequested { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunPauseRequested { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunUnpauseRequested { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunPaused, - RunUnpaused, - RunSupersededBy { - new_run_id: RunId, - target_checkpoint_ordinal: usize, - target_node_id: String, - target_visit: usize, - }, - RunArchived { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - RunUnarchived { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - WorkflowRunCompleted { - duration_ms: u64, - artifact_count: usize, - #[serde(default)] - status: String, - reason: SuccessReason, - #[serde(default, skip_serializing_if = "Option::is_none")] - total_usd_micros: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - final_git_commit_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - final_patch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - billing: Option, - }, - WorkflowRunFailed { - error: Error, - duration_ms: u64, - reason: FailureReason, - #[serde(default, skip_serializing_if = "Option::is_none")] - git_commit_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - final_patch: Option, - }, - RunNotice { - level: RunNoticeLevel, - code: String, - message: String, - }, - MetadataSnapshotStarted { - phase: fabro_types::MetadataSnapshotPhase, - branch: String, - }, - MetadataSnapshotCompleted { - phase: fabro_types::MetadataSnapshotPhase, - branch: String, - duration_ms: u64, - entry_count: usize, - bytes: u64, - commit_sha: String, - }, - MetadataSnapshotFailed { - phase: fabro_types::MetadataSnapshotPhase, - branch: String, - duration_ms: u64, - failure_kind: fabro_types::MetadataSnapshotFailureKind, - error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - causes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - commit_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - entry_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - exec_output_tail: Option, - }, - StageStarted { - node_id: String, - name: String, - index: usize, - handler_type: String, - attempt: usize, - max_attempts: usize, - }, - StageCompleted { - node_id: String, - name: String, - index: usize, - duration_ms: u64, - status: String, - preferred_label: Option, - suggested_next_ids: Vec, - billing: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - failure: Option, - notes: Option, - files_touched: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - context_updates: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - jump_to_node: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - context_values: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - node_visits: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - loop_failure_signatures: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - restart_failure_signatures: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - response: Option, - attempt: usize, - max_attempts: usize, - }, - StageFailed { - node_id: String, - name: String, - index: usize, - failure: FailureDetail, - will_retry: bool, - duration_ms: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - }, - StageRetrying { - node_id: String, - name: String, - index: usize, - attempt: usize, - max_attempts: usize, - delay_ms: u64, - }, - ParallelStarted { - node_id: String, - visit: u32, - branch_count: usize, - join_policy: String, - }, - ParallelBranchStarted { - parallel_group_id: StageId, - parallel_branch_id: ParallelBranchId, - branch: String, - index: usize, - }, - ParallelBranchCompleted { - parallel_group_id: StageId, - parallel_branch_id: ParallelBranchId, - branch: String, - index: usize, - duration_ms: u64, - status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - head_sha: Option, - }, - ParallelCompleted { - node_id: String, - visit: u32, - duration_ms: u64, - success_count: usize, - failure_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - results: Vec, - }, - InterviewStarted { - question_id: String, - question: String, - stage: String, - question_type: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - options: Vec, - #[serde(default)] - allow_freeform: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - timeout_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - context_display: Option, - }, - InterviewCompleted { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - question_id: String, - question: String, - answer: String, - duration_ms: u64, - }, - InterviewTimeout { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - question_id: String, - question: String, - stage: String, - duration_ms: u64, - }, - InterviewInterrupted { - #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, - question_id: String, - question: String, - stage: String, - reason: String, - duration_ms: u64, - }, - CheckpointCompleted { - node_id: String, - status: String, - current_node: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - completed_nodes: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - node_retries: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - context_values: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - node_outcomes: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - next_node_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - git_commit_sha: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - loop_failure_signatures: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - restart_failure_signatures: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - node_visits: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - diff: Option, - }, - CheckpointFailed { - node_id: String, - error: String, - }, - GitCommit { - #[serde(default, skip_serializing_if = "Option::is_none")] - node_id: Option, - sha: String, - }, - GitPush { - branch: String, - success: bool, - }, - GitBranch { - branch: String, - sha: String, - }, - GitWorktreeAdd { - path: String, - branch: String, - }, - GitWorktreeRemove { - path: String, - }, - GitFetch { - branch: String, - success: bool, - }, - GitReset { - sha: String, - }, - EdgeSelected { - from_node: String, - to_node: String, - label: Option, - condition: Option, - /// Which selection step chose this edge (e.g. "condition", - /// "preferred_label", "jump"). - reason: String, - /// The stage's preferred label hint, if any. - #[serde(default, skip_serializing_if = "Option::is_none")] - preferred_label: Option, - /// The stage's suggested next node IDs, if any. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - suggested_next_ids: Vec, - /// The stage outcome status that influenced routing. - stage_status: String, - /// Whether this was a direct jump (bypassing normal edge selection). - is_jump: bool, - }, - LoopRestart { - from_node: String, - to_node: String, - }, - Prompt { - stage: String, - visit: u32, - text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, - }, - PromptCompleted { - node_id: String, - response: String, - model: String, - provider: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - billing: Option, - }, - /// Forwarded from an agent session, tagged with the workflow stage. - Agent { - stage: String, - visit: u32, - event: AgentEvent, - #[serde(default, skip_serializing_if = "Option::is_none")] - session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - parent_session_id: Option, - }, - SubgraphStarted { - node_id: String, - start_node: String, - }, - SubgraphCompleted { - node_id: String, - steps_executed: usize, - status: String, - duration_ms: u64, - }, - /// Forwarded from a sandbox lifecycle operation. - Sandbox { - event: SandboxEvent, - }, - /// Emitted after the sandbox has been initialized (by engine lifecycle). - SandboxInitialized { - working_directory: String, - provider: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - identifier: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - repo_cloned: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - clone_origin_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - clone_branch: Option, - }, - SetupStarted { - command_count: usize, - }, - SetupCommandStarted { - command: String, - index: usize, - }, - SetupCommandCompleted { - command: String, - index: usize, - exit_code: i32, - duration_ms: u64, - }, - SetupCompleted { - duration_ms: u64, - }, - SetupFailed { - command: String, - index: usize, - exit_code: i32, - stderr: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - exec_output_tail: Option, - }, - StallWatchdogTimeout { - node: String, - idle_seconds: u64, - }, - ArtifactCaptured { - node_id: String, - attempt: u32, - node_slug: String, - path: String, - mime: String, - content_md5: String, - content_sha256: String, - bytes: u64, - }, - SshAccessReady { - ssh_command: String, - }, - Failover { - stage: String, - from_provider: String, - from_model: String, - to_provider: String, - to_model: String, - error: String, - }, - CliEnsureStarted { - cli_name: String, - provider: String, - }, - CliEnsureCompleted { - cli_name: String, - provider: String, - already_installed: bool, - node_installed: bool, - duration_ms: u64, - }, - CliEnsureFailed { - cli_name: String, - provider: String, - error: String, - duration_ms: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - exec_output_tail: Option, - }, - CommandStarted { - node_id: String, - script: String, - command: String, - language: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - timeout_ms: Option, - }, - CommandCompleted { - node_id: String, - stdout: String, - stderr: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - exit_code: Option, - duration_ms: u64, - termination: CommandTermination, - stdout_bytes: u64, - stderr_bytes: u64, - streams_separated: bool, - live_streaming: bool, - }, - AgentCliStarted { - node_id: String, - visit: u32, - mode: String, - provider: String, - model: String, - command: String, - }, - AgentCliCompleted { - node_id: String, - stdout: String, - stderr: String, - exit_code: i32, - duration_ms: u64, - }, - PullRequestCreated { - pr_url: String, - pr_number: u64, - owner: String, - repo: String, - base_branch: String, - head_branch: String, - title: String, - draft: bool, - }, - PullRequestFailed { - error: String, - }, - DevcontainerResolved { - dockerfile_lines: usize, - environment_count: usize, - lifecycle_command_count: usize, - workspace_folder: String, - }, - DevcontainerLifecycleStarted { - phase: String, - command_count: usize, - }, - DevcontainerLifecycleCommandStarted { - phase: String, - command: String, - index: usize, - }, - DevcontainerLifecycleCommandCompleted { - phase: String, - command: String, - index: usize, - exit_code: i32, - duration_ms: u64, - }, - DevcontainerLifecycleCompleted { - phase: String, - duration_ms: u64, - }, - DevcontainerLifecycleFailed { - phase: String, - command: String, - index: usize, - exit_code: i32, - stderr: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - exec_output_tail: Option, - }, - RetroStarted { - #[serde(default, skip_serializing_if = "Option::is_none")] - prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, - }, - RetroCompleted { - duration_ms: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - response: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - retro: Option, - }, - RetroFailed { - error: String, - duration_ms: u64, - }, -} - -impl Event { - pub fn pull_request_created(record: &PullRequestRecord, draft: bool) -> Self { - Self::PullRequestCreated { - pr_url: record.html_url.clone(), - pr_number: record.number, - owner: record.owner.clone(), - repo: record.repo.clone(), - base_branch: record.base_branch.clone(), - head_branch: record.head_branch.clone(), - title: record.title.clone(), - draft, - } - } - - pub fn trace(&self) { - use tracing::{debug, error, info, warn}; - match self { - Self::RunCreated { - run_id, run_dir, .. - } => { - info!(run_id = %run_id, run_dir, "Run created"); - } - Self::WorkflowRunStarted { name, run_id, .. } => { - info!(workflow = name.as_str(), run_id = %run_id, "Workflow run started"); - } - Self::RunSubmitted { definition_blob } => { - info!(?definition_blob, "Run submitted"); - } - Self::RunQueued => { - info!("Run queued"); - } - Self::RunStarting => { - info!("Run starting"); - } - Self::RunRunning => { - info!("Run running"); - } - Self::RunBlocked { blocked_reason } => { - info!(?blocked_reason, "Run blocked"); - } - Self::RunUnblocked => { - info!("Run unblocked"); - } - Self::RunRemoving => { - info!("Run removing"); - } - Self::RunCancelRequested { .. } => { - info!("Run cancel requested"); - } - Self::RunPauseRequested { .. } => { - info!("Run pause requested"); - } - Self::RunUnpauseRequested { .. } => { - info!("Run unpause requested"); - } - Self::RunPaused => { - info!("Run paused"); - } - Self::RunUnpaused => { - info!("Run unpaused"); - } - Self::RunSupersededBy { - new_run_id, - target_checkpoint_ordinal, - target_node_id, - target_visit, - } => { - info!( - %new_run_id, - target_checkpoint_ordinal, - target_node_id, - target_visit, - "Run superseded by new run" - ); - } - Self::RunArchived { actor } => { - info!(?actor, "Run archived"); - } - Self::RunUnarchived { actor } => { - info!(?actor, "Run unarchived"); - } - Self::WorkflowRunCompleted { - duration_ms, - artifact_count, - status, - .. - } => { - info!( - duration_ms, - artifact_count, status, "Workflow run completed" - ); - } - Self::WorkflowRunFailed { - error, duration_ms, .. - } => { - error!( - error = %error, - causes = ?error.causes(), - duration_ms, - "Workflow run failed" - ); - } - Self::RunNotice { - level, - code, - message, - } => match level { - RunNoticeLevel::Info => { - info!(code, message, "Run notice"); - } - RunNoticeLevel::Warn => { - warn!(code, message, "Run notice"); - } - RunNoticeLevel::Error => { - error!(code, message, "Run notice"); - } - }, - Self::MetadataSnapshotStarted { phase, branch } => { - debug!(%phase, branch, "Metadata snapshot started"); - } - Self::MetadataSnapshotCompleted { - phase, - branch, - duration_ms, - .. - } => { - info!(%phase, branch, duration_ms, "Metadata snapshot completed"); - } - Self::MetadataSnapshotFailed { - phase, - branch, - duration_ms, - failure_kind, - error, - exec_output_tail, - .. - } => { - let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - warn!( - %phase, - branch, - duration_ms, - %failure_kind, - error, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Metadata snapshot failed" - ); - } - Self::StageStarted { - node_id, - name, - index, - handler_type, - attempt, - max_attempts, - .. - } => { - info!( - node_id, - stage = name.as_str(), - index, - handler_type, - attempt, - max_attempts, - "Stage started" - ); - } - Self::StageCompleted { - node_id, - name, - index, - duration_ms, - status, - attempt, - max_attempts, - .. - } => { - info!( - node_id, - stage = name.as_str(), - index, - duration_ms, - status, - attempt, - max_attempts, - "Stage completed" - ); - } - Self::StageFailed { - node_id, - name, - index, - failure, - will_retry, - .. - } => { - let error_msg = &failure.message; - if *will_retry { - warn!( - node_id, - stage = name.as_str(), - index, - error = error_msg.as_str(), - will_retry, - "Stage failed" - ); - } else { - error!( - node_id, - stage = name.as_str(), - index, - error = error_msg.as_str(), - will_retry, - "Stage failed" - ); - } - } - Self::StageRetrying { - node_id, - name, - index, - attempt, - max_attempts, - delay_ms, - .. - } => { - warn!( - node_id, - stage = name.as_str(), - index, - attempt, - max_attempts, - delay_ms, - "Stage retrying" - ); - } - Self::ParallelStarted { - branch_count, - join_policy, - .. - } => { - debug!(branch_count, join_policy, "Parallel execution started"); - } - Self::ParallelBranchStarted { branch, index, .. } => { - debug!(branch, index, "Parallel branch started"); - } - Self::ParallelBranchCompleted { - branch, - index, - duration_ms, - status, - .. - } => { - debug!( - branch, - index, duration_ms, status, "Parallel branch completed" - ); - } - Self::ParallelCompleted { - duration_ms, - success_count, - failure_count, - results, - .. - } => { - debug!( - duration_ms, - success_count, - failure_count, - result_count = results.len(), - "Parallel execution completed" - ); - } - Self::InterviewStarted { - stage, - question_type, - .. - } => { - debug!(stage, question_type, "Interview started"); - } - Self::InterviewCompleted { duration_ms, .. } => { - debug!(duration_ms, "Interview completed"); - } - Self::InterviewTimeout { - stage, duration_ms, .. - } => { - warn!(stage, duration_ms, "Interview timeout"); - } - Self::InterviewInterrupted { - stage, - reason, - duration_ms, - .. - } => { - warn!(stage, reason, duration_ms, "Interview interrupted"); - } - Self::CheckpointCompleted { - node_id, - status, - completed_nodes, - .. - } => { - info!( - node_id, - status, - completed_count = completed_nodes.len(), - "Checkpoint completed" - ); - } - Self::CheckpointFailed { node_id, error } => { - error!(node_id, error, "Checkpoint failed"); - } - Self::GitCommit { node_id, sha } => { - debug!( - node_id = node_id.as_deref().unwrap_or(""), - sha, "Git commit" - ); - } - Self::GitPush { branch, success } => { - if *success { - debug!(branch, "Git push succeeded"); - } else { - warn!(branch, "Git push failed"); - } - } - Self::GitBranch { branch, sha } => { - debug!(branch, sha, "Git branch created"); - } - Self::GitWorktreeAdd { path, branch } => { - debug!(path, branch, "Git worktree added"); - } - Self::GitWorktreeRemove { path } => { - debug!(path, "Git worktree removed"); - } - Self::GitFetch { branch, success } => { - if *success { - debug!(branch, "Git fetch succeeded"); - } else { - warn!(branch, "Git fetch failed"); - } - } - Self::GitReset { sha } => { - debug!(sha, "Git reset"); - } - Self::EdgeSelected { - from_node, - to_node, - label, - reason, - .. - } => { - info!( - from_node, - to_node, - label = label.as_deref().unwrap_or(""), - reason, - "Edge selected" - ); - } - Self::LoopRestart { from_node, to_node } => { - debug!(from_node, to_node, "Loop restart"); - } - Self::Prompt { - stage, - text, - mode, - provider, - model, - .. - } => { - debug!( - stage, - text_len = text.len(), - mode = mode.as_deref().unwrap_or(""), - provider = provider.as_deref().unwrap_or(""), - model = model.as_deref().unwrap_or(""), - "Prompt sent" - ); - } - Self::PromptCompleted { - node_id, - model, - provider, - .. - } => { - debug!(node_id, model, provider, "Prompt completed"); - } - Self::Agent { .. } | Self::Sandbox { .. } => {} - Self::SandboxInitialized { - working_directory, - provider, - identifier, - .. - } => { - info!( - working_directory, - provider, - identifier = identifier.as_deref().unwrap_or(""), - "Sandbox initialized" - ); - } - Self::SubgraphStarted { - node_id, - start_node, - } => { - debug!(node_id, start_node, "Subgraph started"); - } - Self::SubgraphCompleted { - node_id, - steps_executed, - status, - duration_ms, - } => { - debug!( - node_id, - steps_executed, status, duration_ms, "Subgraph completed" - ); - } - Self::SetupStarted { command_count } => { - info!(command_count, "Setup started"); - } - Self::SetupCommandStarted { command, index } => { - debug!(command, index, "Setup command started"); - } - Self::SetupCommandCompleted { - command, - index, - exit_code, - duration_ms, - } => { - debug!( - command, - index, exit_code, duration_ms, "Setup command completed" - ); - } - Self::SetupCompleted { duration_ms } => { - info!(duration_ms, "Setup completed"); - } - Self::SetupFailed { - command, - index, - exit_code, - exec_output_tail, - .. - } => { - let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - error!( - command, - index, - exit_code, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Setup command failed" - ); - } - Self::StallWatchdogTimeout { node, idle_seconds } => { - warn!(node, idle_seconds, "Stall watchdog timeout"); - } - Self::ArtifactCaptured { - node_id, - node_slug, - attempt, - path, - bytes, - .. - } => { - debug!( - node_id, - node_slug, attempt, path, bytes, "Artifact captured" - ); - } - Self::SshAccessReady { ssh_command } => { - info!(ssh_command, "SSH access ready"); - } - Self::Failover { - stage, - from_provider, - from_model, - to_provider, - to_model, - error, - } => { - warn!( - stage, - from_provider, - from_model, - to_provider, - to_model, - error, - "LLM provider failover" - ); - } - Self::CliEnsureStarted { - cli_name, provider, .. - } => { - debug!(cli_name, provider, "CLI ensure started"); - } - Self::CliEnsureCompleted { - cli_name, - provider, - already_installed, - node_installed, - duration_ms, - } => { - info!( - cli_name, - provider, - already_installed, - node_installed, - duration_ms, - "CLI ensure completed" - ); - } - Self::CliEnsureFailed { - cli_name, - provider, - error, - duration_ms, - exec_output_tail, - } => { - let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - error!( - cli_name, - provider, - error, - duration_ms, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "CLI ensure failed" - ); - } - Self::CommandStarted { - node_id, - language, - timeout_ms, - .. - } => { - debug!(node_id, language, timeout_ms, "Command started"); - } - Self::CommandCompleted { - node_id, - exit_code, - duration_ms, - termination, - stdout_bytes, - stderr_bytes, - .. - } => { - debug!( - node_id, - exit_code, - duration_ms, - termination = %termination, - stdout_bytes, - stderr_bytes, - "Command completed" - ); - } - Self::AgentCliStarted { - node_id, - provider, - model, - .. - } => { - debug!(node_id, provider, model, "Agent CLI started"); - } - Self::AgentCliCompleted { - node_id, - exit_code, - duration_ms, - .. - } => { - debug!(node_id, exit_code, duration_ms, "Agent CLI completed"); - } - Self::PullRequestCreated { - pr_url, - pr_number, - draft, - owner, - repo, - .. - } => { - info!(pr_url = %pr_url, pr_number, draft, owner, repo, "Pull request created"); - } - Self::PullRequestFailed { error, .. } => { - error!(error = %error, "Pull request creation failed"); - } - Self::DevcontainerResolved { - dockerfile_lines, - environment_count, - lifecycle_command_count, - workspace_folder, - } => { - info!( - dockerfile_lines, - environment_count, - lifecycle_command_count, - workspace_folder, - "Devcontainer resolved" - ); - } - Self::DevcontainerLifecycleStarted { - phase, - command_count, - } => { - info!(phase, command_count, "Devcontainer lifecycle started"); - } - Self::DevcontainerLifecycleCommandStarted { - phase, - command, - index, - } => { - debug!( - phase, - command, index, "Devcontainer lifecycle command started" - ); - } - Self::DevcontainerLifecycleCommandCompleted { - phase, - command, - index, - exit_code, - duration_ms, - } => { - debug!( - phase, - command, - index, - exit_code, - duration_ms, - "Devcontainer lifecycle command completed" - ); - } - Self::DevcontainerLifecycleCompleted { phase, duration_ms } => { - info!(phase, duration_ms, "Devcontainer lifecycle completed"); - } - Self::DevcontainerLifecycleFailed { - phase, - command, - index, - exit_code, - exec_output_tail, - .. - } => { - let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); - error!( - phase, - command, - index, - exit_code, - exec_output_tail_present = tail.present, - exec_stdout_tail_bytes = tail.stdout_bytes, - exec_stderr_tail_bytes = tail.stderr_bytes, - exec_stdout_truncated = tail.stdout_truncated, - exec_stderr_truncated = tail.stderr_truncated, - "Devcontainer lifecycle command failed" - ); - } - Self::RetroStarted { - prompt: _, - provider, - model, - } => { - info!( - provider = provider.as_deref().unwrap_or(""), - model = model.as_deref().unwrap_or(""), - "Retro started" - ); - } - Self::RetroCompleted { duration_ms, .. } => { - info!(duration_ms, "Retro completed"); - } - Self::RetroFailed { error, duration_ms } => { - error!(error = %error, duration_ms, "Retro failed"); - } - } - } -} - -#[must_use] -pub fn event_name(event: &Event) -> &'static str { - match event { - Event::RunCreated { .. } => "run.created", - Event::WorkflowRunStarted { .. } => "run.started", - Event::RunSubmitted { .. } => "run.submitted", - Event::RunQueued => "run.queued", - Event::RunStarting => "run.starting", - Event::RunRunning => "run.running", - Event::RunBlocked { .. } => "run.blocked", - Event::RunUnblocked => "run.unblocked", - Event::RunRemoving => "run.removing", - Event::RunCancelRequested { .. } => "run.cancel.requested", - Event::RunPauseRequested { .. } => "run.pause.requested", - Event::RunUnpauseRequested { .. } => "run.unpause.requested", - Event::RunPaused => "run.paused", - Event::RunUnpaused => "run.unpaused", - Event::RunSupersededBy { .. } => "run.superseded_by", - Event::RunArchived { .. } => "run.archived", - Event::RunUnarchived { .. } => "run.unarchived", - Event::WorkflowRunCompleted { .. } => "run.completed", - Event::WorkflowRunFailed { .. } => "run.failed", - Event::RunNotice { .. } => "run.notice", - Event::MetadataSnapshotStarted { .. } => "metadata.snapshot.started", - Event::MetadataSnapshotCompleted { .. } => "metadata.snapshot.completed", - Event::MetadataSnapshotFailed { .. } => "metadata.snapshot.failed", - Event::StageStarted { .. } => "stage.started", - Event::StageCompleted { .. } => "stage.completed", - Event::StageFailed { .. } => "stage.failed", - Event::StageRetrying { .. } => "stage.retrying", - Event::ParallelStarted { .. } => "parallel.started", - Event::ParallelBranchStarted { .. } => "parallel.branch.started", - Event::ParallelBranchCompleted { .. } => "parallel.branch.completed", - Event::ParallelCompleted { .. } => "parallel.completed", - Event::InterviewStarted { .. } => "interview.started", - Event::InterviewCompleted { .. } => "interview.completed", - Event::InterviewTimeout { .. } => "interview.timeout", - Event::InterviewInterrupted { .. } => "interview.interrupted", - Event::CheckpointCompleted { .. } => "checkpoint.completed", - Event::CheckpointFailed { .. } => "checkpoint.failed", - Event::GitCommit { .. } => "git.commit", - Event::GitPush { .. } => "git.push", - Event::GitBranch { .. } => "git.branch", - Event::GitWorktreeAdd { .. } => "git.worktree.added", - Event::GitWorktreeRemove { .. } => "git.worktree.removed", - Event::GitFetch { .. } => "git.fetch", - Event::GitReset { .. } => "git.reset", - Event::EdgeSelected { .. } => "edge.selected", - Event::LoopRestart { .. } => "loop.restart", - Event::Prompt { .. } => "stage.prompt", - Event::PromptCompleted { .. } => "prompt.completed", - Event::Agent { event, .. } => match event { - AgentEvent::SessionStarted { .. } => "agent.session.started", - AgentEvent::SessionEnded => "agent.session.ended", - AgentEvent::ProcessingEnd => "agent.processing.end", - AgentEvent::UserInput { .. } => "agent.input", - AgentEvent::AssistantTextStart => "agent.output.start", - AgentEvent::AssistantOutputReplace { .. } => "agent.output.replace", - AgentEvent::AssistantMessage { .. } => "agent.message", - AgentEvent::TextDelta { .. } => "agent.text.delta", - AgentEvent::ReasoningDelta { .. } => "agent.reasoning.delta", - AgentEvent::ToolCallStarted { .. } => "agent.tool.started", - AgentEvent::ToolCallOutputDelta { .. } => "agent.tool.output.delta", - AgentEvent::ToolCallCompleted { .. } => "agent.tool.completed", - AgentEvent::Error { .. } => "agent.error", - AgentEvent::Warning { .. } => "agent.warning", - AgentEvent::LoopDetected => "agent.loop.detected", - AgentEvent::TurnLimitReached { .. } => "agent.turn.limit", - AgentEvent::SkillExpanded { .. } => "agent.skill.expanded", - AgentEvent::SteeringInjected { .. } => "agent.steering.injected", - AgentEvent::CompactionStarted { .. } => "agent.compaction.started", - AgentEvent::CompactionCompleted { .. } => "agent.compaction.completed", - AgentEvent::LlmRetry { .. } => "agent.llm.retry", - AgentEvent::SubAgentSpawned { .. } => "agent.sub.spawned", - AgentEvent::SubAgentCompleted { .. } => "agent.sub.completed", - AgentEvent::SubAgentFailed { .. } => "agent.sub.failed", - AgentEvent::SubAgentClosed { .. } => "agent.sub.closed", - AgentEvent::McpServerReady { .. } => "agent.mcp.ready", - AgentEvent::McpServerFailed { .. } => "agent.mcp.failed", - }, - Event::SubgraphStarted { .. } => "subgraph.started", - Event::SubgraphCompleted { .. } => "subgraph.completed", - Event::Sandbox { event } => match event { - SandboxEvent::Initializing { .. } => "sandbox.initializing", - SandboxEvent::Ready { .. } => "sandbox.ready", - SandboxEvent::InitializeFailed { .. } => "sandbox.failed", - SandboxEvent::CleanupStarted { .. } => "sandbox.cleanup.started", - SandboxEvent::CleanupCompleted { .. } => "sandbox.cleanup.completed", - SandboxEvent::CleanupFailed { .. } => "sandbox.cleanup.failed", - SandboxEvent::SnapshotPulling { .. } => "sandbox.snapshot.pulling", - SandboxEvent::SnapshotPulled { .. } => "sandbox.snapshot.pulled", - SandboxEvent::SnapshotEnsuring { .. } => "sandbox.snapshot.ensuring", - SandboxEvent::SnapshotCreating { .. } => "sandbox.snapshot.creating", - SandboxEvent::SnapshotReady { .. } => "sandbox.snapshot.ready", - SandboxEvent::SnapshotFailed { .. } => "sandbox.snapshot.failed", - SandboxEvent::GitCloneStarted { .. } => "sandbox.git.started", - SandboxEvent::GitCloneCompleted { .. } => "sandbox.git.completed", - SandboxEvent::GitCloneFailed { .. } => "sandbox.git.failed", - }, - Event::SandboxInitialized { .. } => "sandbox.initialized", - Event::SetupStarted { .. } => "setup.started", - Event::SetupCommandStarted { .. } => "setup.command.started", - Event::SetupCommandCompleted { .. } => "setup.command.completed", - Event::SetupCompleted { .. } => "setup.completed", - Event::SetupFailed { .. } => "setup.failed", - Event::StallWatchdogTimeout { .. } => "watchdog.timeout", - Event::ArtifactCaptured { .. } => "artifact.captured", - Event::SshAccessReady { .. } => "ssh.ready", - Event::Failover { .. } => "agent.failover", - Event::CliEnsureStarted { .. } => "cli.ensure.started", - Event::CliEnsureCompleted { .. } => "cli.ensure.completed", - Event::CliEnsureFailed { .. } => "cli.ensure.failed", - Event::CommandStarted { .. } => "command.started", - Event::CommandCompleted { .. } => "command.completed", - Event::AgentCliStarted { .. } => "agent.cli.started", - Event::AgentCliCompleted { .. } => "agent.cli.completed", - Event::PullRequestCreated { .. } => "pull_request.created", - Event::PullRequestFailed { .. } => "pull_request.failed", - Event::DevcontainerResolved { .. } => "devcontainer.resolved", - Event::DevcontainerLifecycleStarted { .. } => "devcontainer.lifecycle.started", - Event::DevcontainerLifecycleCommandStarted { .. } => { - "devcontainer.lifecycle.command.started" - } - Event::DevcontainerLifecycleCommandCompleted { .. } => { - "devcontainer.lifecycle.command.completed" - } - Event::DevcontainerLifecycleCompleted { .. } => "devcontainer.lifecycle.completed", - Event::DevcontainerLifecycleFailed { .. } => "devcontainer.lifecycle.failed", - Event::RetroStarted { .. } => "retro.started", - Event::RetroCompleted { .. } => "retro.completed", - Event::RetroFailed { .. } => "retro.failed", - } -} - -#[derive(Debug, Default)] -struct StoredEventFields { - session_id: Option, - parent_session_id: Option, - node_id: Option, - node_label: Option, - stage_id: Option, - parallel_group_id: Option, - parallel_branch_id: Option, - tool_call_id: Option, - actor: Option, -} - -fn default_node_label(node_id: Option<&String>, node_label: Option) -> Option { - node_label.or_else(|| node_id.cloned()) -} - -fn node_stored_fields(node_id: Option) -> StoredEventFields { - let node_label = default_node_label(node_id.as_ref(), None); - StoredEventFields { - node_id, - node_label, - ..StoredEventFields::default() - } -} - -fn billed_token_counts_from_llm(usage: &LlmTokenCounts) -> BilledTokenCounts { - BilledTokenCounts { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - total_tokens: usage.total_tokens(), - reasoning_tokens: usage.reasoning_tokens, - cache_read_tokens: usage.cache_read_tokens, - cache_write_tokens: usage.cache_write_tokens, - total_usd_micros: None, - } -} - -fn stage_status_from_string(status: &str) -> StageOutcome { - status.parse().unwrap_or_else(|_| { - tracing::warn!( - status, - "unknown stage status in StageCompleted event; using Fail" - ); - StageOutcome::Failed { - retry_requested: false, - } - }) -} - -fn stored_event_fields(event: &Event, scope: Option<&StageScope>) -> StoredEventFields { - let mut fields = stored_event_fields_for_variant(event); - if let Some(scope) = scope { - if fields.node_id.is_none() { - fields.node_id = Some(scope.node_id.clone()); - fields.node_label = default_node_label(Some(&scope.node_id), fields.node_label); - } - if fields.stage_id.is_none() { - fields.stage_id = Some(StageId::new(scope.node_id.clone(), scope.visit)); - } - if fields.parallel_group_id.is_none() { - fields - .parallel_group_id - .clone_from(&scope.parallel_group_id); - } - if fields.parallel_branch_id.is_none() { - fields - .parallel_branch_id - .clone_from(&scope.parallel_branch_id); - } - } - fields -} - -fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { - match event { - Event::RunCreated { provenance, .. } => StoredEventFields { - actor: provenance.as_ref().and_then(|p| p.subject.clone()), - ..StoredEventFields::default() - }, - Event::RunCancelRequested { actor } - | Event::RunPauseRequested { actor } - | Event::RunUnpauseRequested { actor } - | Event::RunArchived { actor } - | Event::RunUnarchived { actor, .. } - | Event::InterviewCompleted { actor, .. } => StoredEventFields { - actor: actor.clone(), - ..StoredEventFields::default() - }, - Event::StageCompleted { node_id, name, .. } - | Event::StageStarted { node_id, name, .. } - | Event::StageRetrying { node_id, name, .. } => { - let node_id_str = node_id.clone(); - let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); - StoredEventFields { - node_id: Some(node_id_str), - node_label, - ..StoredEventFields::default() - } - } - Event::StageFailed { - node_id, - name, - actor, - .. - } => { - let node_id_str = node_id.clone(); - let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); - StoredEventFields { - node_id: Some(node_id_str), - node_label, - actor: actor.clone(), - ..StoredEventFields::default() - } - } - Event::ParallelStarted { node_id, visit, .. } - | Event::ParallelCompleted { node_id, visit, .. } => { - let node_id_str = node_id.clone(); - let node_label = default_node_label(Some(&node_id_str), None); - let parallel_group_id = Some(StageId::new(node_id_str.clone(), *visit)); - StoredEventFields { - node_id: Some(node_id_str), - node_label, - parallel_group_id, - ..StoredEventFields::default() - } - } - Event::CheckpointCompleted { node_id, .. } - | Event::CheckpointFailed { node_id, .. } - | Event::SubgraphStarted { node_id, .. } - | Event::SubgraphCompleted { node_id, .. } - | Event::ArtifactCaptured { node_id, .. } - | Event::PromptCompleted { node_id, .. } - | Event::CommandStarted { node_id, .. } - | Event::CommandCompleted { node_id, .. } - | Event::AgentCliStarted { node_id, .. } - | Event::AgentCliCompleted { node_id, .. } => node_stored_fields(Some(node_id.clone())), - Event::Agent { - stage, - visit, - event: agent_event, - session_id, - parent_session_id, - } => { - let node_id = Some(stage.clone()); - let node_label = default_node_label(node_id.as_ref(), None); - let stage_id = Some(StageId::new(stage.clone(), *visit)); - let tool_call_id = agent_tool_call_id(agent_event).map(str::to_string); - let actor = agent_actor_for_event( - agent_event, - session_id.as_deref(), - parent_session_id.as_deref(), - ); - StoredEventFields { - session_id: session_id.clone(), - parent_session_id: parent_session_id.clone(), - node_id, - node_label, - stage_id, - tool_call_id, - actor, - ..StoredEventFields::default() - } - } - Event::GitCommit { node_id, .. } => node_stored_fields(node_id.clone()), - Event::ParallelBranchStarted { - parallel_group_id, - parallel_branch_id, - branch, - .. - } - | Event::ParallelBranchCompleted { - parallel_group_id, - parallel_branch_id, - branch, - .. - } => { - let node_id = Some(branch.clone()); - let node_label = default_node_label(node_id.as_ref(), None); - StoredEventFields { - node_id, - node_label, - parallel_group_id: Some(parallel_group_id.clone()), - parallel_branch_id: Some(parallel_branch_id.clone()), - ..StoredEventFields::default() - } - } - Event::Prompt { stage, .. } - | Event::InterviewStarted { stage, .. } - | Event::Failover { stage, .. } => node_stored_fields(Some(stage.clone())), - Event::InterviewTimeout { actor, stage, .. } - | Event::InterviewInterrupted { actor, stage, .. } => { - let mut fields = node_stored_fields(Some(stage.clone())); - fields.actor.clone_from(actor); - fields - } - Event::StallWatchdogTimeout { node, .. } => { - let mut fields = node_stored_fields(Some(node.clone())); - fields.actor = Some(Principal::System { - system_kind: SystemActorKind::Watchdog, - }); - fields - } - _ => StoredEventFields::default(), - } -} - -fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> { - match event { - AgentEvent::ToolCallStarted { tool_call_id, .. } - | AgentEvent::ToolCallCompleted { tool_call_id, .. } => Some(tool_call_id.as_str()), - _ => None, - } -} - -fn agent_actor_for_event( - event: &AgentEvent, - session_id: Option<&str>, - parent_session_id: Option<&str>, -) -> Option { - match event { - AgentEvent::AssistantMessage { model, .. } => Some(Principal::Agent { - session_id: session_id.map(str::to_string), - parent_session_id: parent_session_id.map(str::to_string), - model: Some(model.clone()), - }), - AgentEvent::ToolCallStarted { .. } - | AgentEvent::ToolCallOutputDelta { .. } - | AgentEvent::ToolCallCompleted { .. } => Some(Principal::Agent { - session_id: session_id.map(str::to_string), - parent_session_id: parent_session_id.map(str::to_string), - model: None, - }), - _ => None, - } -} - -fn event_body_from_event(event: &Event) -> EventBody { - match event { - Event::RunCreated { - settings, - graph, - workflow_source, - workflow_config, - labels, - run_dir, - source_directory, - workflow_slug, - db_prefix, - provenance, - manifest_blob, - git, - fork_source_ref, - in_place, - .. - } => EventBody::RunCreated(fabro_types::RunCreatedProps { - settings: serde_json::from_value(settings.clone()) - .expect("run.created settings"), - graph: serde_json::from_value(graph.clone()).expect("run.created graph"), - workflow_source: workflow_source.clone(), - workflow_config: workflow_config.clone(), - labels: labels.clone(), - run_dir: run_dir.clone(), - source_directory: source_directory.clone(), - workflow_slug: workflow_slug.clone(), - db_prefix: db_prefix.clone(), - provenance: provenance.clone(), - manifest_blob: *manifest_blob, - git: git.clone(), - fork_source_ref: fork_source_ref.clone(), - in_place: *in_place, - }), - Event::WorkflowRunStarted { - name, - base_branch, - base_sha, - run_branch, - worktree_dir, - goal, - .. - } => EventBody::RunStarted(fabro_types::RunStartedProps { - name: name.clone(), - base_branch: base_branch.clone(), - base_sha: base_sha.clone(), - run_branch: run_branch.clone(), - worktree_dir: worktree_dir.clone(), - goal: goal.clone(), - }), - Event::RunSubmitted { definition_blob } => { - EventBody::RunSubmitted(fabro_types::RunSubmittedProps { - definition_blob: *definition_blob, - }) - } - Event::RunQueued => EventBody::RunQueued(fabro_types::RunStatusEffectProps::default()), - Event::RunStarting => { - EventBody::RunStarting(fabro_types::RunStatusTransitionProps::default()) - } - Event::RunRunning => { - EventBody::RunRunning(fabro_types::RunStatusTransitionProps::default()) - } - Event::RunBlocked { blocked_reason } => { - EventBody::RunBlocked(fabro_types::RunBlockedProps { - blocked_reason: *blocked_reason, - }) - } - Event::RunUnblocked => { - EventBody::RunUnblocked(fabro_types::RunStatusEffectProps::default()) - } - Event::RunRemoving => { - EventBody::RunRemoving(fabro_types::RunStatusTransitionProps::default()) - } - Event::RunCancelRequested { .. } => { - EventBody::RunCancelRequested(fabro_types::RunControlRequestedProps { - action: RunControlAction::Cancel, - }) - } - Event::RunPauseRequested { .. } => { - EventBody::RunPauseRequested(fabro_types::RunControlRequestedProps { - action: RunControlAction::Pause, - }) - } - Event::RunUnpauseRequested { .. } => { - EventBody::RunUnpauseRequested(fabro_types::RunControlRequestedProps { - action: RunControlAction::Unpause, - }) - } - Event::RunPaused => EventBody::RunPaused(fabro_types::RunControlEffectProps::default()), - Event::RunUnpaused => EventBody::RunUnpaused(fabro_types::RunControlEffectProps::default()), - Event::RunSupersededBy { - new_run_id, - target_checkpoint_ordinal, - target_node_id, - target_visit, - } => EventBody::RunSupersededBy(fabro_types::RunSupersededByProps { - new_run_id: *new_run_id, - target_checkpoint_ordinal: *target_checkpoint_ordinal, - target_node_id: target_node_id.clone(), - target_visit: *target_visit, - }), - Event::RunArchived { .. } => { - EventBody::RunArchived(fabro_types::RunArchivedProps::default()) - } - Event::RunUnarchived { .. } => { - EventBody::RunUnarchived(fabro_types::RunUnarchivedProps::default()) - } - Event::WorkflowRunCompleted { - duration_ms, - artifact_count, - status, - reason, - total_usd_micros, - final_git_commit_sha, - final_patch, - billing, - } => EventBody::RunCompleted(fabro_types::RunCompletedProps { - duration_ms: *duration_ms, - artifact_count: *artifact_count, - status: status.clone(), - reason: *reason, - total_usd_micros: *total_usd_micros, - final_git_commit_sha: final_git_commit_sha.clone(), - final_patch: final_patch.clone(), - billing: billing.clone(), - }), - Event::WorkflowRunFailed { - error, - duration_ms, - reason, - git_commit_sha, - final_patch, - } => EventBody::RunFailed(fabro_types::RunFailedProps { - error: error.to_string(), - causes: error.causes(), - duration_ms: *duration_ms, - reason: *reason, - git_commit_sha: git_commit_sha.clone(), - final_patch: final_patch.clone(), - }), - Event::RunNotice { - level, - code, - message, - } => EventBody::RunNotice(fabro_types::RunNoticeProps { - level: *level, - code: code.clone(), - message: message.clone(), - }), - Event::MetadataSnapshotStarted { phase, branch } => { - EventBody::MetadataSnapshotStarted(fabro_types::MetadataSnapshotStartedProps { - phase: *phase, - branch: branch.clone(), - }) - } - Event::MetadataSnapshotCompleted { - phase, - branch, - duration_ms, - entry_count, - bytes, - commit_sha, - } => EventBody::MetadataSnapshotCompleted(fabro_types::MetadataSnapshotCompletedProps { - phase: *phase, - branch: branch.clone(), - duration_ms: *duration_ms, - entry_count: *entry_count, - bytes: *bytes, - commit_sha: commit_sha.clone(), - }), - Event::MetadataSnapshotFailed { - phase, - branch, - duration_ms, - failure_kind, - error, - causes, - commit_sha, - entry_count, - bytes, - exec_output_tail, - } => EventBody::MetadataSnapshotFailed(fabro_types::MetadataSnapshotFailedProps { - phase: *phase, - branch: branch.clone(), - duration_ms: *duration_ms, - failure_kind: *failure_kind, - error: error.clone(), - causes: causes.clone(), - commit_sha: commit_sha.clone(), - entry_count: *entry_count, - bytes: *bytes, - exec_output_tail: exec_output_tail.clone(), - }), - Event::StageStarted { - index, - handler_type, - attempt, - max_attempts, - .. - } => EventBody::StageStarted(fabro_types::StageStartedProps { - index: *index, - handler_type: handler_type.clone(), - attempt: *attempt, - max_attempts: *max_attempts, - }), - Event::StageCompleted { - index, - duration_ms, - status, - preferred_label, - suggested_next_ids, - billing, - failure, - notes, - files_touched, - context_updates, - jump_to_node, - context_values, - node_visits, - loop_failure_signatures, - restart_failure_signatures, - response, - attempt, - max_attempts, - .. - } => EventBody::StageCompleted(fabro_types::StageCompletedProps { - index: *index, - duration_ms: *duration_ms, - status: stage_status_from_string(status), - preferred_label: preferred_label.clone(), - suggested_next_ids: suggested_next_ids.clone(), - billing: billing.clone(), - failure: failure.clone(), - notes: notes.clone(), - files_touched: files_touched.clone(), - context_updates: context_updates.clone(), - jump_to_node: jump_to_node.clone(), - context_values: context_values.clone(), - node_visits: node_visits.clone(), - loop_failure_signatures: loop_failure_signatures.clone(), - restart_failure_signatures: restart_failure_signatures.clone(), - response: response.clone(), - attempt: *attempt, - max_attempts: *max_attempts, - }), - Event::StageFailed { - index, - failure, - will_retry, - duration_ms, - .. - } => EventBody::StageFailed(fabro_types::StageFailedProps { - index: *index, - failure: Some(failure.clone()), - will_retry: *will_retry, - duration_ms: *duration_ms, - }), - Event::StageRetrying { - index, - attempt, - max_attempts, - delay_ms, - .. - } => EventBody::StageRetrying(fabro_types::StageRetryingProps { - index: *index, - attempt: *attempt, - max_attempts: *max_attempts, - delay_ms: *delay_ms, - }), - Event::ParallelStarted { - visit, - branch_count, - join_policy, - .. - } => EventBody::ParallelStarted(fabro_types::ParallelStartedProps { - visit: *visit, - branch_count: *branch_count, - join_policy: join_policy.clone(), - }), - Event::ParallelBranchStarted { index, .. } => { - EventBody::ParallelBranchStarted(fabro_types::ParallelBranchStartedProps { - index: *index, - }) - } - Event::ParallelBranchCompleted { - index, - duration_ms, - status, - head_sha, - .. - } => EventBody::ParallelBranchCompleted(fabro_types::ParallelBranchCompletedProps { - index: *index, - duration_ms: *duration_ms, - status: status.clone(), - head_sha: head_sha.clone(), - }), - Event::ParallelCompleted { - visit, - duration_ms, - success_count, - failure_count, - results, - .. - } => EventBody::ParallelCompleted(fabro_types::ParallelCompletedProps { - visit: *visit, - duration_ms: *duration_ms, - success_count: *success_count, - failure_count: *failure_count, - results: results.clone(), - }), - Event::InterviewStarted { - question_id, - question, - stage, - question_type, - options, - allow_freeform, - timeout_seconds, - context_display, - } => EventBody::InterviewStarted(fabro_types::InterviewStartedProps { - question_id: question_id.clone(), - question: question.clone(), - stage: stage.clone(), - question_type: question_type.clone(), - options: options.clone(), - allow_freeform: *allow_freeform, - timeout_seconds: *timeout_seconds, - context_display: context_display.clone(), - }), - Event::InterviewCompleted { - actor: _, - question_id, - question, - answer, - duration_ms, - } => EventBody::InterviewCompleted(fabro_types::InterviewCompletedProps { - question_id: question_id.clone(), - question: question.clone(), - answer: answer.clone(), - duration_ms: *duration_ms, - }), - Event::InterviewTimeout { - actor: _, - question_id, - question, - stage, - duration_ms, - } => EventBody::InterviewTimeout(fabro_types::InterviewTimeoutProps { - question_id: question_id.clone(), - question: question.clone(), - stage: stage.clone(), - duration_ms: *duration_ms, - }), - Event::InterviewInterrupted { - actor: _, - question_id, - question, - stage, - reason, - duration_ms, - } => EventBody::InterviewInterrupted(fabro_types::InterviewInterruptedProps { - question_id: question_id.clone(), - question: question.clone(), - stage: stage.clone(), - reason: reason.clone(), - duration_ms: *duration_ms, - }), - Event::CheckpointCompleted { - status, - current_node, - completed_nodes, - node_retries, - context_values, - node_outcomes, - next_node_id, - git_commit_sha, - loop_failure_signatures, - restart_failure_signatures, - node_visits, - diff, - .. - } => EventBody::CheckpointCompleted(fabro_types::CheckpointCompletedProps { - status: status.clone(), - current_node: current_node.clone(), - completed_nodes: completed_nodes.clone(), - node_retries: node_retries.clone(), - context_values: context_values.clone(), - node_outcomes: node_outcomes.clone(), - next_node_id: next_node_id.clone(), - git_commit_sha: git_commit_sha.clone(), - loop_failure_signatures: loop_failure_signatures.clone(), - restart_failure_signatures: restart_failure_signatures.clone(), - node_visits: node_visits.clone(), - diff: diff.clone(), - }), - Event::CheckpointFailed { error, .. } => { - EventBody::CheckpointFailed(fabro_types::CheckpointFailedProps { - error: error.clone(), - }) - } - Event::GitCommit { sha, .. } => { - EventBody::GitCommit(fabro_types::GitCommitProps { sha: sha.clone() }) - } - Event::GitPush { branch, success } => EventBody::GitPush(fabro_types::GitPushProps { - branch: branch.clone(), - success: *success, - }), - Event::GitBranch { branch, sha } => EventBody::GitBranch(fabro_types::GitBranchProps { - branch: branch.clone(), - sha: sha.clone(), - }), - Event::GitWorktreeAdd { path, branch } => { - EventBody::GitWorktreeAdd(fabro_types::GitWorktreeAddProps { - path: path.clone(), - branch: branch.clone(), - }) - } - Event::GitWorktreeRemove { path } => { - EventBody::GitWorktreeRemove(fabro_types::GitWorktreeRemoveProps { path: path.clone() }) - } - Event::GitFetch { branch, success } => EventBody::GitFetch(fabro_types::GitFetchProps { - branch: branch.clone(), - success: *success, - }), - Event::GitReset { sha } => { - EventBody::GitReset(fabro_types::GitResetProps { sha: sha.clone() }) - } - Event::EdgeSelected { - from_node, - to_node, - label, - condition, - reason, - preferred_label, - suggested_next_ids, - stage_status, - is_jump, - } => EventBody::EdgeSelected(fabro_types::EdgeSelectedProps { - from_node: from_node.clone(), - to_node: to_node.clone(), - label: label.clone(), - condition: condition.clone(), - reason: reason.clone(), - preferred_label: preferred_label.clone(), - suggested_next_ids: suggested_next_ids.clone(), - stage_status: stage_status.clone(), - is_jump: *is_jump, - }), - Event::LoopRestart { from_node, to_node } => { - EventBody::LoopRestart(fabro_types::LoopRestartProps { - from_node: from_node.clone(), - to_node: to_node.clone(), - }) - } - Event::Prompt { - visit, - text, - mode, - provider, - model, - .. - } => EventBody::StagePrompt(fabro_types::StagePromptProps { - visit: *visit, - text: text.clone(), - mode: mode.clone(), - provider: provider.clone(), - model: model.clone(), - }), - Event::PromptCompleted { - response, - model, - provider, - billing, - .. - } => EventBody::PromptCompleted(fabro_types::PromptCompletedProps { - response: response.clone(), - model: model.clone(), - provider: provider.clone(), - billing: billing.clone(), - }), - Event::Agent { visit, event, .. } => match event { - AgentEvent::SessionStarted { provider, model } => { - EventBody::AgentSessionStarted(fabro_types::AgentSessionStartedProps { - provider: provider.clone(), - model: model.clone(), - visit: *visit, - }) - } - AgentEvent::SessionEnded => { - EventBody::AgentSessionEnded(fabro_types::AgentSessionEndedProps { visit: *visit }) - } - AgentEvent::ProcessingEnd => { - EventBody::AgentProcessingEnd(fabro_types::AgentProcessingEndProps { - visit: *visit, - }) - } - AgentEvent::UserInput { text } => EventBody::AgentInput(fabro_types::AgentInputProps { - text: text.clone(), - visit: *visit, - }), - AgentEvent::AssistantMessage { - text, - model, - usage, - tool_call_count, - } => EventBody::AgentMessage(fabro_types::AgentMessageProps { - text: text.clone(), - model: model.clone(), - billing: billed_token_counts_from_llm(usage), - tool_call_count: *tool_call_count, - visit: *visit, - }), - AgentEvent::ToolCallStarted { - tool_name, - tool_call_id, - arguments, - } => EventBody::AgentToolStarted(fabro_types::AgentToolStartedProps { - tool_name: tool_name.clone(), - tool_call_id: tool_call_id.clone(), - arguments: arguments.clone(), - visit: *visit, - }), - AgentEvent::ToolCallCompleted { - tool_name, - tool_call_id, - output, - is_error, - } => EventBody::AgentToolCompleted(fabro_types::AgentToolCompletedProps { - tool_name: tool_name.clone(), - tool_call_id: tool_call_id.clone(), - output: output.clone(), - is_error: *is_error, - visit: *visit, - }), - AgentEvent::Error { error } => EventBody::AgentError(fabro_types::AgentErrorProps { - error: serde_json::to_value(error).expect("serializable agent error"), - visit: *visit, - }), - AgentEvent::Warning { - kind, - message, - details, - } => EventBody::AgentWarning(fabro_types::AgentWarningProps { - kind: kind.clone(), - message: message.clone(), - details: details.clone(), - visit: *visit, - }), - AgentEvent::LoopDetected => { - EventBody::AgentLoopDetected(fabro_types::AgentLoopDetectedProps { visit: *visit }) - } - AgentEvent::TurnLimitReached { max_turns } => { - EventBody::AgentTurnLimitReached(fabro_types::AgentTurnLimitReachedProps { - max_turns: *max_turns, - visit: *visit, - }) - } - AgentEvent::SteeringInjected { text } => { - EventBody::AgentSteeringInjected(fabro_types::AgentSteeringInjectedProps { - text: text.clone(), - visit: *visit, - }) - } - AgentEvent::CompactionStarted { - estimated_tokens, - context_window_size, - } => EventBody::AgentCompactionStarted(fabro_types::AgentCompactionStartedProps { - estimated_tokens: *estimated_tokens, - context_window_size: *context_window_size, - visit: *visit, - }), - AgentEvent::CompactionCompleted { - original_turn_count, - preserved_turn_count, - summary_token_estimate, - tracked_file_count, - } => EventBody::AgentCompactionCompleted(fabro_types::AgentCompactionCompletedProps { - original_turn_count: *original_turn_count, - preserved_turn_count: *preserved_turn_count, - summary_token_estimate: *summary_token_estimate, - tracked_file_count: *tracked_file_count, - visit: *visit, - }), - AgentEvent::LlmRetry { - provider, - model, - attempt, - delay_secs, - error, - } => EventBody::AgentLlmRetry(fabro_types::AgentLlmRetryProps { - provider: provider.clone(), - model: model.clone(), - attempt: *attempt, - delay_secs: *delay_secs, - error: serde_json::to_value(error).expect("serializable sdk error"), - visit: *visit, - }), - AgentEvent::SubAgentSpawned { - agent_id, - depth, - task, - } => EventBody::AgentSubSpawned(fabro_types::AgentSubSpawnedProps { - agent_id: agent_id.clone(), - depth: *depth, - task: task.clone(), - visit: *visit, - }), - AgentEvent::SubAgentCompleted { - agent_id, - depth, - success, - turns_used, - } => EventBody::AgentSubCompleted(fabro_types::AgentSubCompletedProps { - agent_id: agent_id.clone(), - depth: *depth, - success: *success, - turns_used: *turns_used, - visit: *visit, - }), - AgentEvent::SubAgentFailed { - agent_id, - depth, - error, - } => EventBody::AgentSubFailed(fabro_types::AgentSubFailedProps { - agent_id: agent_id.clone(), - depth: *depth, - error: serde_json::to_value(error).expect("serializable agent error"), - visit: *visit, - }), - AgentEvent::SubAgentClosed { agent_id, depth } => { - EventBody::AgentSubClosed(fabro_types::AgentSubClosedProps { - agent_id: agent_id.clone(), - depth: *depth, - visit: *visit, - }) - } - AgentEvent::McpServerReady { - server_name, - tool_count, - } => EventBody::AgentMcpReady(fabro_types::AgentMcpReadyProps { - server_name: server_name.clone(), - tool_count: *tool_count, - visit: *visit, - }), - AgentEvent::McpServerFailed { server_name, error } => { - EventBody::AgentMcpFailed(fabro_types::AgentMcpFailedProps { - server_name: server_name.clone(), - error: error.clone(), - visit: *visit, - }) - } - AgentEvent::AssistantTextStart - | AgentEvent::AssistantOutputReplace { .. } - | AgentEvent::TextDelta { .. } - | AgentEvent::ReasoningDelta { .. } - | AgentEvent::ToolCallOutputDelta { .. } - | AgentEvent::SkillExpanded { .. } => { - panic!("streaming-noise agent event should not be converted to RunEvent") - } - }, - Event::SubgraphStarted { start_node, .. } => { - EventBody::SubgraphStarted(fabro_types::SubgraphStartedProps { - start_node: start_node.clone(), - }) - } - Event::SubgraphCompleted { - steps_executed, - status, - duration_ms, - .. - } => EventBody::SubgraphCompleted(fabro_types::SubgraphCompletedProps { - steps_executed: *steps_executed, - status: status.clone(), - duration_ms: *duration_ms, - }), - Event::Sandbox { event } => match event { - SandboxEvent::Initializing { provider } => { - EventBody::SandboxInitializing(fabro_types::SandboxInitializingProps { - provider: provider.clone(), - }) - } - SandboxEvent::Ready { - provider, - duration_ms, - name, - cpu, - memory, - url, - } => EventBody::SandboxReady(fabro_types::SandboxReadyProps { - provider: provider.clone(), - duration_ms: *duration_ms, - name: name.clone(), - cpu: *cpu, - memory: *memory, - url: url.clone(), - }), - SandboxEvent::InitializeFailed { - provider, - error, - causes, - duration_ms, - } => EventBody::SandboxFailed(fabro_types::SandboxFailedProps { - provider: provider.clone(), - error: error.clone(), - causes: causes.clone(), - duration_ms: *duration_ms, - }), - SandboxEvent::CleanupStarted { provider } => { - EventBody::SandboxCleanupStarted(fabro_types::SandboxCleanupStartedProps { - provider: provider.clone(), - }) - } - SandboxEvent::CleanupCompleted { - provider, - duration_ms, - } => EventBody::SandboxCleanupCompleted(fabro_types::SandboxCleanupCompletedProps { - provider: provider.clone(), - duration_ms: *duration_ms, - }), - SandboxEvent::CleanupFailed { - provider, - error, - causes, - } => EventBody::SandboxCleanupFailed(fabro_types::SandboxCleanupFailedProps { - provider: provider.clone(), - error: error.clone(), - causes: causes.clone(), - }), - SandboxEvent::SnapshotPulling { name } => { - EventBody::SnapshotPulling(fabro_types::SnapshotNameProps { name: name.clone() }) - } - SandboxEvent::SnapshotPulled { name, duration_ms } => { - EventBody::SnapshotPulled(fabro_types::SnapshotCompletedProps { - name: name.clone(), - duration_ms: *duration_ms, - }) - } - SandboxEvent::SnapshotEnsuring { name } => { - EventBody::SnapshotEnsuring(fabro_types::SnapshotNameProps { name: name.clone() }) - } - SandboxEvent::SnapshotCreating { name } => { - EventBody::SnapshotCreating(fabro_types::SnapshotNameProps { name: name.clone() }) - } - SandboxEvent::SnapshotReady { name, duration_ms } => { - EventBody::SnapshotReady(fabro_types::SnapshotCompletedProps { - name: name.clone(), - duration_ms: *duration_ms, - }) - } - SandboxEvent::SnapshotFailed { - name, - error, - causes, - } => EventBody::SnapshotFailed(fabro_types::SnapshotFailedProps { - name: name.clone(), - error: error.clone(), - causes: causes.clone(), - }), - SandboxEvent::GitCloneStarted { url, branch } => { - EventBody::GitCloneStarted(fabro_types::GitCloneStartedProps { - url: url.clone(), - branch: branch.clone(), - }) - } - SandboxEvent::GitCloneCompleted { url, duration_ms } => { - EventBody::GitCloneCompleted(fabro_types::GitCloneCompletedProps { - url: url.clone(), - duration_ms: *duration_ms, - }) - } - SandboxEvent::GitCloneFailed { url, error, causes } => { - EventBody::GitCloneFailed(fabro_types::GitCloneFailedProps { - url: url.clone(), - error: error.clone(), - causes: causes.clone(), - }) - } - }, - Event::SandboxInitialized { - working_directory, - provider, - identifier, - repo_cloned, - clone_origin_url, - clone_branch, - } => EventBody::SandboxInitialized(fabro_types::SandboxInitializedProps { - working_directory: working_directory.clone(), - provider: provider.clone(), - identifier: identifier.clone(), - repo_cloned: *repo_cloned, - clone_origin_url: clone_origin_url.clone(), - clone_branch: clone_branch.clone(), - }), - Event::SetupStarted { command_count } => { - EventBody::SetupStarted(fabro_types::SetupStartedProps { - command_count: *command_count, - }) - } - Event::SetupCommandStarted { command, index } => { - EventBody::SetupCommandStarted(fabro_types::SetupCommandStartedProps { - command: command.clone(), - index: *index, - }) - } - Event::SetupCommandCompleted { - command, - index, - exit_code, - duration_ms, - } => EventBody::SetupCommandCompleted(fabro_types::SetupCommandCompletedProps { - command: command.clone(), - index: *index, - exit_code: *exit_code, - duration_ms: *duration_ms, - }), - Event::SetupCompleted { duration_ms } => { - EventBody::SetupCompleted(fabro_types::SetupCompletedProps { - duration_ms: *duration_ms, - }) - } - Event::SetupFailed { - command, - index, - exit_code, - stderr, - exec_output_tail, - } => EventBody::SetupFailed(fabro_types::SetupFailedProps { - command: command.clone(), - index: *index, - exit_code: *exit_code, - stderr: stderr.clone(), - exec_output_tail: exec_output_tail.clone(), - }), - Event::StallWatchdogTimeout { idle_seconds, .. } => { - EventBody::StallWatchdogTimeout(fabro_types::StallWatchdogTimeoutProps { - idle_seconds: *idle_seconds, - }) - } - Event::ArtifactCaptured { - attempt, - node_slug, - path, - mime, - content_md5, - content_sha256, - bytes, - .. - } => EventBody::ArtifactCaptured(fabro_types::ArtifactCapturedProps { - attempt: *attempt, - node_slug: node_slug.clone(), - path: path.clone(), - mime: mime.clone(), - content_md5: content_md5.clone(), - content_sha256: content_sha256.clone(), - bytes: *bytes, - }), - Event::SshAccessReady { ssh_command } => { - EventBody::SshAccessReady(fabro_types::SshAccessReadyProps { - ssh_command: ssh_command.clone(), - }) - } - Event::Failover { - from_provider, - from_model, - to_provider, - to_model, - error, - .. - } => EventBody::Failover(fabro_types::FailoverProps { - from_provider: from_provider.clone(), - from_model: from_model.clone(), - to_provider: to_provider.clone(), - to_model: to_model.clone(), - error: error.clone(), - }), - Event::CliEnsureStarted { cli_name, provider } => { - EventBody::CliEnsureStarted(fabro_types::CliEnsureStartedProps { - cli_name: cli_name.clone(), - provider: provider.clone(), - }) - } - Event::CliEnsureCompleted { - cli_name, - provider, - already_installed, - node_installed, - duration_ms, - } => EventBody::CliEnsureCompleted(fabro_types::CliEnsureCompletedProps { - cli_name: cli_name.clone(), - provider: provider.clone(), - already_installed: *already_installed, - node_installed: *node_installed, - duration_ms: *duration_ms, - }), - Event::CliEnsureFailed { - cli_name, - provider, - error, - duration_ms, - exec_output_tail, - } => EventBody::CliEnsureFailed(fabro_types::CliEnsureFailedProps { - cli_name: cli_name.clone(), - provider: provider.clone(), - error: error.clone(), - duration_ms: *duration_ms, - exec_output_tail: exec_output_tail.clone(), - }), - Event::CommandStarted { - script, - command, - language, - timeout_ms, - .. - } => EventBody::CommandStarted(fabro_types::CommandStartedProps { - script: script.clone(), - command: command.clone(), - language: language.clone(), - timeout_ms: *timeout_ms, - }), - Event::CommandCompleted { - stdout, - stderr, - exit_code, - duration_ms, - termination, - stdout_bytes, - stderr_bytes, - streams_separated, - live_streaming, - .. - } => EventBody::CommandCompleted(fabro_types::CommandCompletedProps { - stdout: stdout.clone(), - stderr: stderr.clone(), - exit_code: *exit_code, - duration_ms: *duration_ms, - termination: *termination, - stdout_bytes: *stdout_bytes, - stderr_bytes: *stderr_bytes, - streams_separated: *streams_separated, - live_streaming: *live_streaming, - }), - Event::AgentCliStarted { - visit, - mode, - provider, - model, - command, - .. - } => EventBody::AgentCliStarted(fabro_types::AgentCliStartedProps { - visit: *visit, - mode: mode.clone(), - provider: provider.clone(), - model: model.clone(), - command: command.clone(), - }), - Event::AgentCliCompleted { - stdout, - stderr, - exit_code, - duration_ms, - .. - } => EventBody::AgentCliCompleted(fabro_types::AgentCliCompletedProps { - stdout: stdout.clone(), - stderr: stderr.clone(), - exit_code: *exit_code, - duration_ms: *duration_ms, - }), - Event::PullRequestCreated { - pr_url, - pr_number, - owner, - repo, - base_branch, - head_branch, - title, - draft, - } => EventBody::PullRequestCreated(fabro_types::PullRequestCreatedProps { - pr_url: pr_url.clone(), - pr_number: *pr_number, - owner: owner.clone(), - repo: repo.clone(), - base_branch: base_branch.clone(), - head_branch: head_branch.clone(), - title: title.clone(), - draft: *draft, - }), - Event::PullRequestFailed { error } => { - EventBody::PullRequestFailed(fabro_types::PullRequestFailedProps { - error: error.clone(), - }) - } - Event::DevcontainerResolved { - dockerfile_lines, - environment_count, - lifecycle_command_count, - workspace_folder, - } => EventBody::DevcontainerResolved(fabro_types::DevcontainerResolvedProps { - dockerfile_lines: *dockerfile_lines, - environment_count: *environment_count, - lifecycle_command_count: *lifecycle_command_count, - workspace_folder: workspace_folder.clone(), - }), - Event::DevcontainerLifecycleStarted { - phase, - command_count, - } => EventBody::DevcontainerLifecycleStarted( - fabro_types::DevcontainerLifecycleStartedProps { - phase: phase.clone(), - command_count: *command_count, - }, - ), - Event::DevcontainerLifecycleCommandStarted { - phase, - command, - index, - } => EventBody::DevcontainerLifecycleCommandStarted( - fabro_types::DevcontainerLifecycleCommandStartedProps { - phase: phase.clone(), - command: command.clone(), - index: *index, - }, - ), - Event::DevcontainerLifecycleCommandCompleted { - phase, - command, - index, - exit_code, - duration_ms, - } => EventBody::DevcontainerLifecycleCommandCompleted( - fabro_types::DevcontainerLifecycleCommandCompletedProps { - phase: phase.clone(), - command: command.clone(), - index: *index, - exit_code: *exit_code, - duration_ms: *duration_ms, - }, - ), - Event::DevcontainerLifecycleCompleted { phase, duration_ms } => { - EventBody::DevcontainerLifecycleCompleted( - fabro_types::DevcontainerLifecycleCompletedProps { - phase: phase.clone(), - duration_ms: *duration_ms, - }, - ) - } - Event::DevcontainerLifecycleFailed { - phase, - command, - index, - exit_code, - stderr, - exec_output_tail, - } => { - EventBody::DevcontainerLifecycleFailed(fabro_types::DevcontainerLifecycleFailedProps { - phase: phase.clone(), - command: command.clone(), - index: *index, - exit_code: *exit_code, - stderr: stderr.clone(), - exec_output_tail: exec_output_tail.clone(), - }) - } - Event::RetroStarted { - prompt, - provider, - model, - } => EventBody::RetroStarted(fabro_types::RetroStartedProps { - prompt: prompt.clone(), - provider: provider.clone(), - model: model.clone(), - }), - Event::RetroCompleted { - duration_ms, - response, - retro, - } => EventBody::RetroCompleted(fabro_types::RetroCompletedProps { - duration_ms: *duration_ms, - response: response.clone(), - retro: retro.clone(), - }), - Event::RetroFailed { error, duration_ms } => { - EventBody::RetroFailed(fabro_types::RetroFailedProps { - error: error.clone(), - duration_ms: *duration_ms, - }) - } - } -} - -/// Stage-level scope threaded through event emission to populate -/// `stage_id` / `parallel_group_id` / `parallel_branch_id` on events -/// that happen inside a concrete stage execution. -#[derive(Clone, Debug)] -pub struct StageScope { - pub node_id: String, - pub visit: u32, - pub parallel_group_id: Option, - pub parallel_branch_id: Option, -} - -impl StageScope { - /// Build a scope from the given node id, sourcing visit count and parallel - /// ids from the current context. - pub fn from_context(context: &WfContext, node_id: impl Into) -> Self { - Self { - node_id: node_id.into(), - visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), - parallel_group_id: context.parallel_group_id(), - parallel_branch_id: context.parallel_branch_id(), - } - } - - /// Build scope for a handler invocation. Prefers the `current_stage_scope` - /// seeded by the fidelity lifecycle `before_node` hook, and falls back to - /// synthesizing one from `node_id` for direct-handler call sites (tests, - /// etc.) that don't go through the full lifecycle. - pub fn for_handler(context: &WfContext, node_id: impl Into) -> Self { - context - .current_stage_scope() - .unwrap_or_else(|| Self::from_context(context, node_id)) - } - - /// Build scope for the branch-lifecycle events emitted by the parallel - /// handler (`ParallelBranchStarted`, `ParallelBranchCompleted`, and the - /// pre-dispatch `GitCommit` for the branch worktree). - /// - /// `target_visit` is the visit count of `target_node_id` for this - /// particular branch dispatch. The parallel handler currently passes - /// `1` because branches haven't been re-entered yet at the point of - /// scope construction; a future change that loops a parallel node - /// must pass the actual visit so envelope `stage_id`s stay accurate. - #[must_use] - pub fn for_parallel_branch( - target_node_id: impl Into, - target_visit: u32, - parallel_group_id: StageId, - parallel_branch_id: ParallelBranchId, - ) -> Self { - Self { - node_id: target_node_id.into(), - visit: target_visit, - parallel_group_id: Some(parallel_group_id), - parallel_branch_id: Some(parallel_branch_id), - } - } - - #[must_use] - pub fn stage_id(&self) -> StageId { - StageId::new(self.node_id.clone(), self.visit) - } -} - -#[must_use] -pub fn to_run_event(run_id: &RunId, event: &Event) -> RunEvent { - to_run_event_at(run_id, event, Utc::now(), None) -} - -#[must_use] -pub fn to_run_event_at( - run_id: &RunId, - event: &Event, - ts: chrono::DateTime, - scope: Option<&StageScope>, -) -> RunEvent { - let fields = stored_event_fields(event, scope); - let body = event_body_from_event(event); - RunEvent { - id: Uuid::now_v7().to_string(), - ts, - run_id: *run_id, - node_id: fields.node_id, - node_label: fields.node_label, - stage_id: fields.stage_id, - parallel_group_id: fields.parallel_group_id, - parallel_branch_id: fields.parallel_branch_id, - session_id: fields.session_id, - parent_session_id: fields.parent_session_id, - tool_call_id: fields.tool_call_id, - actor: fields.actor, - body, - } -} - -pub fn build_redacted_event_payload(event: &RunEvent, run_id: &RunId) -> Result { - let value = redacted_event_value(event)?; - EventPayload::new(value, run_id).map_err(anyhow::Error::from) -} - -pub fn redacted_event_json(event: &RunEvent) -> Result { - serde_json::to_string(&redacted_event_value(event)?).map_err(anyhow::Error::from) -} - -fn normalized_event_value(event: &RunEvent) -> Result { - let value = event.to_value()?; - Ok(normalize_json_value(value)) -} - -fn redacted_event_value(event: &RunEvent) -> Result { - Ok(redact_json_value(normalized_event_value(event)?)) -} - -pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result { - let value = serde_json::from_str(line).context("Failed to parse redacted event payload")?; - EventPayload::new(value, run_id).map_err(anyhow::Error::from) -} - -pub async fn append_event(run_store: &RunDatabase, run_id: &RunId, event: &Event) -> Result<()> { - let stored = to_run_event(run_id, event); - let payload = build_redacted_event_payload(&stored, run_id)?; - run_store - .append_event(&payload) - .await - .map(|_| ()) - .map_err(anyhow::Error::from) -} - -pub async fn append_event_to_sink( - sink: &RunEventSink, - run_id: &RunId, - event: &Event, -) -> Result<()> { - let stored = to_run_event(run_id, event); - sink.write_run_event(&stored).await -} - -#[derive(Clone)] -pub enum RunEventSink { - Store(RunStoreHandle), - JsonLines(Arc>>>), - Callback(Arc), - Map { - transform: Arc, - inner: Box, - }, - Composite(Vec), -} - -type RunEventSinkFuture = Pin> + Send + 'static>>; -type RunEventSinkCallback = dyn Fn(RunEvent) -> RunEventSinkFuture + Send + Sync + 'static; -type RunEventTransform = dyn Fn(RunEvent) -> RunEvent + Send + Sync + 'static; - -impl RunEventSink { - #[must_use] - pub fn store(run_store: RunDatabase) -> Self { - Self::Store(RunStoreHandle::local(run_store)) - } - - #[must_use] - pub fn backend(run_store: RunStoreHandle) -> Self { - Self::Store(run_store) - } - - #[must_use] - pub fn json_lines(writer: W) -> Self - where - W: AsyncWrite + Send + 'static, - { - Self::JsonLines(Arc::new(AsyncMutex::new(Box::pin(writer)))) - } - - #[must_use] - pub fn callback(callback: F) -> Self - where - F: Fn(RunEvent) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - { - Self::Callback(Arc::new(move |event| Box::pin(callback(event)))) - } - - #[must_use] - pub fn fanout(sinks: Vec) -> Self { - let mut flattened = Vec::new(); - for sink in sinks { - match sink { - Self::Composite(inner) => flattened.extend(inner), - other => flattened.push(other), - } - } - Self::Composite(flattened) - } - - #[must_use] - pub fn map(transform: F, inner: Self) -> Self - where - F: Fn(RunEvent) -> RunEvent + Send + Sync + 'static, - { - Self::Map { - transform: Arc::new(transform), - inner: Box::new(inner), - } - } - - pub async fn write_run_event(&self, event: &RunEvent) -> Result<()> { - let mut pending = vec![(self, event.clone())]; - while let Some((sink, event)) = pending.pop() { - match sink { - Self::Store(run_store) => { - run_store.append_run_event(&event).await?; - } - Self::JsonLines(writer) => { - let line = redacted_event_json(&event)?; - let mut writer = writer.lock().await; - writer.write_all(line.as_bytes()).await?; - writer.write_all(b"\n").await?; - writer.flush().await?; - } - Self::Callback(callback) => callback(event).await?, - Self::Map { transform, inner } => { - pending.push((inner.as_ref(), transform(event))); - } - Self::Composite(sinks) => { - for sink in sinks.iter().rev() { - pending.push((sink, event.clone())); - } - } - } - } - Ok(()) - } -} - -#[allow( - clippy::large_enum_variant, - reason = "Logger queue messages stay inline to avoid boxing hot-path payloads." -)] -enum RunEventCommand { - Event(RunEvent), - Flush(oneshot::Sender<()>), -} - -#[derive(Clone)] -pub struct RunEventLogger { - tx: mpsc::UnboundedSender, -} - -impl RunEventLogger { - #[must_use] - pub fn new(sink: RunEventSink) -> Self { - let (tx, mut rx) = mpsc::unbounded_channel(); - - tokio::spawn(async move { - while let Some(command) = rx.recv().await { - match command { - RunEventCommand::Event(event) => { - if let Err(err) = sink.write_run_event(&event).await { - tracing::warn!(error = %err, "Failed to write run event"); - } - } - RunEventCommand::Flush(tx) => { - let _ = tx.send(()); - } - } - } - }); - - Self { tx } - } - - pub fn register(&self, emitter: &Emitter) { - let tx = self.tx.clone(); - emitter.on_event(move |event| { - if tx.send(RunEventCommand::Event(event.clone())).is_err() { - tracing::warn!("Run event logger channel closed while forwarding event"); - } - }); - } - - pub async fn flush(&self) { - let (tx, rx) = oneshot::channel(); - if self.tx.send(RunEventCommand::Flush(tx)).is_err() { - tracing::warn!("Run event logger channel closed before flush"); - return; - } - if rx.await.is_err() { - tracing::warn!("Run event logger flush dropped before completion"); - } - } -} - -#[derive(Clone)] -pub struct StoreProgressLogger { - inner: RunEventLogger, -} - -impl StoreProgressLogger { - #[must_use] - pub fn new(run_store: impl Into) -> Self { - Self { - inner: RunEventLogger::new(RunEventSink::backend(run_store.into())), - } - } - - pub fn register(&self, emitter: &Emitter) { - self.inner.register(emitter); - } - - pub async fn flush(&self) { - self.inner.flush().await; - } -} - -/// Current time as epoch milliseconds. -fn epoch_millis() -> i64 { - let millis = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - i64::try_from(millis).unwrap_or(i64::MAX) -} - -/// Listener callback type for workflow run events. -type EventListener = Arc; - -/// Callback-based event emitter for workflow run events. -pub struct Emitter { - run_id: RunId, - listeners: std::sync::Mutex>, - /// Epoch milliseconds of the last `emit()` or `touch()` call. 0 until first - /// event. - last_event_at: AtomicI64, -} - -impl std::fmt::Debug for Emitter { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let count = self.listeners.lock().map_or(0, |l| l.len()); - f.debug_struct("Emitter") - .field("run_id", &self.run_id) - .field("listener_count", &count) - .field("last_event_at", &self.last_event_at.load(Ordering::Relaxed)) - .finish() - } -} - -impl Default for Emitter { - fn default() -> Self { - Self::new(RunId::new()) - } -} - -impl Emitter { - #[must_use] - pub fn new(run_id: RunId) -> Self { - Self { - run_id, - listeners: std::sync::Mutex::new(Vec::new()), - last_event_at: AtomicI64::new(0), - } - } - - #[must_use] - pub fn run_id(&self) -> RunId { - self.run_id - } - - pub fn on_event(&self, listener: impl Fn(&RunEvent) + Send + Sync + 'static) { - self.listeners - .lock() - .expect("listeners lock poisoned") - .push(Arc::new(listener)); - } - - pub fn emit(&self, event: &Event) { - self.emit_with_scope(event, None); - } - - pub fn emit_scoped(&self, event: &Event, scope: &StageScope) { - self.emit_with_scope(event, Some(scope)); - } - - pub fn notice( - &self, - level: RunNoticeLevel, - code: impl Into, - message: impl Into, - ) { - self.emit(&Event::RunNotice { - level, - code: code.into(), - message: message.into(), - }); - } - - fn emit_with_scope(&self, event: &Event, scope: Option<&StageScope>) { - self.last_event_at.store(epoch_millis(), Ordering::Relaxed); - event.trace(); - if let Event::WorkflowRunStarted { run_id, .. } = event { - debug_assert_eq!( - *run_id, self.run_id, - "workflow run started event must match emitter run_id" - ); - } - let stored = to_run_event_at(&self.run_id, event, Utc::now(), scope); - self.dispatch_run_event(&stored); - } - - pub(crate) fn dispatch_run_event(&self, event: &RunEvent) { - self.last_event_at.store(epoch_millis(), Ordering::Relaxed); - // Clone the listener list so we don't hold the lock during dispatch. - // This prevents deadlocks if a listener calls emit() reentrantly. - // Note: listeners added during this emit() won't receive the current event. - let snapshot: Vec = self - .listeners - .lock() - .expect("listeners lock poisoned") - .clone(); - for listener in &snapshot { - listener(event); - } - } - - /// Returns the epoch milliseconds of the last `emit()` or `touch()` call. - /// Returns 0 if neither has been called. - pub fn last_event_at(&self) -> i64 { - self.last_event_at.load(Ordering::Relaxed) - } - - /// Manually update the last-event timestamp (e.g. to seed the watchdog at - /// workflow run start). - pub fn touch(&self) { - self.last_event_at.store(epoch_millis(), Ordering::Relaxed); - } - - /// Build a [`WorktreeEventCallback`] that forwards worktree lifecycle - /// events as [`Event`]s on this emitter. - pub fn worktree_callback(self: Arc) -> WorktreeEventCallback { - Arc::new(move |event| match event { - WorktreeEvent::BranchCreated { branch, sha } => { - self.emit(&Event::GitBranch { branch, sha }); - } - WorktreeEvent::WorktreeAdded { path, branch } => { - self.emit(&Event::GitWorktreeAdd { path, branch }); - } - WorktreeEvent::WorktreeRemoved { path } => { - self.emit(&Event::GitWorktreeRemove { path }); - } - }) - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - - use ::fabro_types::{AuthMethod, IdpIdentity, fixtures}; - - use super::*; - - fn user_principal(login: &str) -> Principal { - Principal::user( - IdpIdentity::new("https://github.com", "12345").unwrap(), - login.to_string(), - AuthMethod::Github, - ) - } - - #[test] - fn event_emitter_new_has_no_listeners() { - let emitter = Emitter::new(fixtures::RUN_1); - assert_eq!(emitter.listeners.lock().unwrap().len(), 0); - } - - #[test] - fn event_emitter_calls_listener_with_envelope() { - let emitter = Emitter::new(fixtures::RUN_1); - let received = Arc::new(Mutex::new(Vec::new())); - let received_clone = Arc::clone(&received); - emitter.on_event(move |event| { - received_clone.lock().unwrap().push(event.clone()); - }); - emitter.emit(&Event::WorkflowRunStarted { - name: "test".to_string(), - run_id: fixtures::RUN_1, - base_branch: None, - base_sha: None, - run_branch: None, - worktree_dir: None, - goal: None, - }); - let events = received.lock().unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_name(), "run.started"); - assert_eq!(events[0].run_id, fixtures::RUN_1); - assert!(events[0].id.len() >= 32); - } - - #[test] - fn event_emitter_default() { - let emitter = Emitter::default(); - assert_eq!(emitter.listeners.lock().unwrap().len(), 0); - } - - #[test] - fn run_event_stage_completed_places_node_fields_in_header() { - let stored = to_run_event_at( - &fixtures::RUN_2, - &Event::StageCompleted { - node_id: "plan".to_string(), - name: "Plan".to_string(), - index: 0, - duration_ms: 5000, - status: "succeeded".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - billing: None, - failure: None, - notes: None, - files_touched: Vec::new(), - 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, - }, - Utc::now(), - Some(&StageScope { - node_id: "plan".to_string(), - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, - }), - ); - - assert_eq!(stored.event_name(), "stage.completed"); - assert_eq!(stored.run_id, fixtures::RUN_2); - assert_eq!(stored.node_id.as_deref(), Some("plan")); - assert_eq!(stored.node_label.as_deref(), Some("Plan")); - assert_eq!(stored.stage_id, Some(StageId::new("plan", 1))); - let properties = stored.properties().unwrap(); - assert_eq!(properties["duration_ms"], 5000); - assert_eq!(properties["status"], "succeeded"); - assert!(stored.session_id.is_none()); - } - - #[test] - fn run_event_stage_completed_keeps_response_and_signature_snapshots() { - let stored = to_run_event(&fixtures::RUN_2, &Event::StageCompleted { - node_id: "plan".to_string(), - name: "Plan".to_string(), - index: 0, - duration_ms: 5000, - status: "succeeded".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - billing: None, - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: Some(BTreeMap::from([("sig-a".to_string(), 2usize)])), - restart_failure_signatures: Some(BTreeMap::from([("sig-b".to_string(), 1usize)])), - response: Some("done".to_string()), - attempt: 1, - max_attempts: 1, - }); - - let properties = stored.properties().unwrap(); - assert_eq!(properties["response"], "done"); - assert_eq!(properties["loop_failure_signatures"]["sig-a"], 2); - assert_eq!(properties["restart_failure_signatures"]["sig-b"], 1); - } - - #[test] - fn run_event_stage_failure_keeps_failure_detail() { - let stored = to_run_event(&fixtures::RUN_3, &Event::StageFailed { - node_id: "code".to_string(), - name: "Code".to_string(), - index: 1, - failure: FailureDetail::new( - "lint failed", - crate::outcome::FailureCategory::Deterministic, - ), - will_retry: true, - duration_ms: 5000, - actor: None, - }); - - assert_eq!(stored.event_name(), "stage.failed"); - let properties = stored.properties().unwrap(); - assert_eq!(properties["failure"]["message"], "lint failed"); - assert_eq!(properties["failure"]["failure_class"], "deterministic"); - assert_eq!(properties["will_retry"], true); - } - - #[test] - fn run_event_agent_tool_started_moves_session_metadata_to_header() { - let stored = to_run_event(&fixtures::RUN_4, &Event::Agent { - stage: "code".to_string(), - visit: 2, - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".to_string(), - tool_call_id: "call_1".to_string(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }, - session_id: Some("ses_child".to_string()), - parent_session_id: Some("ses_parent".to_string()), - }); - - assert_eq!(stored.event_name(), "agent.tool.started"); - assert_eq!(stored.node_id.as_deref(), Some("code")); - assert_eq!(stored.node_label.as_deref(), Some("code")); - assert_eq!(stored.session_id.as_deref(), Some("ses_child")); - assert_eq!(stored.parent_session_id.as_deref(), Some("ses_parent")); - let properties = stored.properties().unwrap(); - assert_eq!(properties["tool_name"], "read_file"); - assert_eq!(properties["tool_call_id"], "call_1"); - assert_eq!(properties["visit"], 2); - } - - #[test] - fn run_event_sandbox_event_keeps_properties_nested() { - let stored = to_run_event(&fixtures::RUN_5, &Event::Sandbox { - event: SandboxEvent::Ready { - provider: "daytona".to_string(), - duration_ms: 2500, - name: Some("sandbox-1".to_string()), - cpu: Some(4.0), - memory: Some(8.0), - url: Some("https://example.test".to_string()), - }, - }); - - assert_eq!(stored.event_name(), "sandbox.ready"); - assert!(stored.node_id.is_none()); - let properties = stored.properties().unwrap(); - assert_eq!(properties["provider"], "daytona"); - assert_eq!(properties["duration_ms"], 2500); - } - - #[test] - fn run_event_sandbox_failure_serializes_causes() { - let stored = to_run_event(&fixtures::RUN_5, &Event::Sandbox { - event: SandboxEvent::InitializeFailed { - provider: "docker".to_string(), - error: "Failed to pull Docker image buildpack-deps:noble".to_string(), - causes: vec!["connection refused".to_string()], - duration_ms: 42, - }, - }); - - assert_eq!(stored.event_name(), "sandbox.failed"); - let properties = stored.properties().unwrap(); - assert_eq!(properties["provider"], "docker"); - assert_eq!( - properties["error"], - "Failed to pull Docker image buildpack-deps:noble" - ); - assert_eq!( - properties["causes"], - serde_json::json!(["connection refused"]) - ); - } - - #[test] - fn run_event_workflow_failure_uses_display_error() { - let stored = to_run_event(&fixtures::RUN_6, &Event::WorkflowRunFailed { - error: Error::handler("boom"), - duration_ms: 900, - reason: FailureReason::WorkflowError, - git_commit_sha: Some("abc123".to_string()), - final_patch: None, - }); - - assert_eq!(stored.event_name(), "run.failed"); - let properties = stored.properties().unwrap(); - assert_eq!(properties["error"], "Handler error: boom"); - assert_eq!(properties["duration_ms"], 900); - } - - #[derive(Debug)] - struct EventTestCause; - - impl std::fmt::Display for EventTestCause { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("connection refused") - } - } - - impl std::error::Error for EventTestCause {} - - #[test] - fn run_event_workflow_failure_serializes_causes() { - let source = EventTestCause; - let stored = to_run_event(&fixtures::RUN_6, &Event::WorkflowRunFailed { - error: Error::engine_with_source("Failed to initialize sandbox", &source), - duration_ms: 900, - reason: FailureReason::WorkflowError, - git_commit_sha: None, - final_patch: None, - }); - - let properties = stored.properties().unwrap(); - assert_eq!( - properties["error"], - "Engine error: Failed to initialize sandbox" - ); - assert_eq!( - properties["causes"], - serde_json::json!(["connection refused"]) - ); - } - - #[tokio::test] - async fn append_event_writes_store_event_shape() { - let store = fabro_store::Database::new( - std::sync::Arc::new(object_store::memory::InMemory::new()), - "", - std::time::Duration::from_millis(1), - None, - ); - let run_store = store.create_run(&fixtures::RUN_7).await.unwrap(); - let stored = to_run_event(&fixtures::RUN_7, &Event::RunNotice { - level: RunNoticeLevel::Warn, - code: "example".to_string(), - message: "notice".to_string(), - }); - 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(); - let line = events - .into_iter() - .next() - .map(|event| event.event.to_value().unwrap()) - .unwrap(); - assert!(line.get("id").is_some()); - assert_eq!(line["event"], "run.notice"); - assert_eq!(line["properties"]["code"], "example"); - } - - #[tokio::test] - async fn run_event_sink_json_lines_writes_canonical_event_lines() { - use tokio::io::{AsyncBufReadExt, BufReader}; - - let (writer, reader) = tokio::io::duplex(4096); - let sink = RunEventSink::json_lines(writer); - let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None }); - - sink.write_run_event(&event).await.unwrap(); - - let mut reader = BufReader::new(reader); - let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); - - let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_7).unwrap(); - assert_eq!(payload.as_value()["event"], "run.pause.requested"); - assert_eq!(payload.as_value()["properties"]["action"], "pause"); - } - - #[tokio::test] - async fn run_event_sink_map_applies_transform_before_fanout() { - let first = Arc::new(AsyncMutex::new(Vec::new())); - let second = Arc::new(AsyncMutex::new(Vec::new())); - let first_events = Arc::clone(&first); - let second_events = Arc::clone(&second); - let sink = RunEventSink::map( - |mut event| { - event.actor = Some(user_principal("alice")); - event - }, - RunEventSink::fanout(vec![ - RunEventSink::callback(move |event| { - let first_events = Arc::clone(&first_events); - async move { - first_events.lock().await.push(event); - Ok(()) - } - }), - RunEventSink::callback(move |event| { - let second_events = Arc::clone(&second_events); - async move { - second_events.lock().await.push(event); - Ok(()) - } - }), - ]), - ); - let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None }); - - sink.write_run_event(&event).await.unwrap(); - - let first = first.lock().await; - let second = second.lock().await; - assert_eq!(first.len(), 1); - assert_eq!(second.len(), 1); - assert_eq!(first[0].actor, Some(user_principal("alice"))); - assert_eq!(second[0].actor, Some(user_principal("alice"))); - } - - #[tokio::test] - async fn run_event_logger_registers_emitter_events_to_json_lines() { - use tokio::io::{AsyncBufReadExt, BufReader}; - - let (writer, reader) = tokio::io::duplex(4096); - let sink = RunEventSink::json_lines(writer); - let logger = RunEventLogger::new(sink); - let emitter = Emitter::new(fixtures::RUN_8); - logger.register(&emitter); - - emitter.emit(&Event::RunPaused); - logger.flush().await; - - let mut reader = BufReader::new(reader); - let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); - - let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_8).unwrap(); - assert_eq!(payload.as_value()["event"], "run.paused"); - } - - #[test] - fn build_redacted_event_payload_requires_id() { - let stored = to_run_event(&fixtures::RUN_8, &Event::RetroStarted { - prompt: Some("Analyze the run".to_string()), - provider: None, - model: None, - }); - let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); - assert_eq!(payload.as_value()["id"], stored.id); - assert_eq!(payload.as_value()["event"], "retro.started"); - assert_eq!( - payload.as_value()["properties"]["prompt"], - "Analyze the run" - ); - } - - #[test] - fn build_redacted_event_payload_redacts_exec_output_tail_values() { - let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; - let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed { - command: "setup".to_string(), - index: 0, - exit_code: 1, - stderr: "compat stderr".to_string(), - exec_output_tail: Some(fabro_types::ExecOutputTail { - stdout: Some(format!("stdout {secret}")), - stderr: Some("plain stderr".to_string()), - stdout_truncated: false, - stderr_truncated: false, - }), - }); - - let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); - let payload_text = serde_json::to_string(payload.as_value()).unwrap(); - - assert!(!payload_text.contains(secret)); - assert!(payload_text.contains("REDACTED")); - assert_eq!(payload.as_value()["event"], "setup.failed"); - assert_eq!( - payload.as_value()["properties"]["exec_output_tail"]["stderr"], - "plain stderr" - ); - } - - #[test] - fn event_name_matches_new_dot_notation() { - assert_eq!( - event_name(&Event::RetroStarted { - prompt: None, - provider: None, - model: None, - }), - "retro.started" - ); - assert_eq!( - event_name(&Event::ParallelBranchStarted { - parallel_group_id: StageId::new("plan", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("plan", 1), 0), - branch: "fork".to_string(), - index: 0, - }), - "parallel.branch.started" - ); - assert_eq!( - event_name(&Event::Agent { - stage: "code".to_string(), - visit: 1, - event: AgentEvent::SubAgentSpawned { - agent_id: "a1".to_string(), - depth: 1, - task: "do it".to_string(), - }, - session_id: None, - parent_session_id: None, - }), - "agent.sub.spawned" - ); - } - - #[test] - fn stage_started_populates_parallel_ids_when_present() { - let stored = to_run_event_at( - &fixtures::RUN_1, - &Event::StageStarted { - node_id: "review".to_string(), - name: "review".to_string(), - index: 1, - handler_type: "agent".to_string(), - attempt: 1, - max_attempts: 1, - }, - Utc::now(), - Some(&StageScope { - node_id: "review".to_string(), - visit: 1, - parallel_group_id: Some(StageId::new("fanout", 2)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)), - }), - ); - assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); - assert_eq!( - stored.parallel_branch_id, - Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) - ); - } - - #[test] - fn parallel_started_populates_parallel_group_id() { - let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelStarted { - node_id: "fanout".to_string(), - visit: 2, - branch_count: 3, - join_policy: "wait_all".to_string(), - }); - assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); - assert!(stored.parallel_branch_id.is_none()); - } - - #[test] - fn parallel_branch_started_populates_group_and_branch_ids() { - let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelBranchStarted { - parallel_group_id: StageId::new("fanout", 2), - parallel_branch_id: ParallelBranchId::new(StageId::new("fanout", 2), 1), - branch: "review".to_string(), - index: 1, - }); - assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); - assert_eq!( - stored.parallel_branch_id, - Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) - ); - } - - #[test] - fn agent_tool_started_populates_tool_call_id_and_stage_id() { - let stored = to_run_event_at( - &fixtures::RUN_1, - &Event::Agent { - stage: "code".to_string(), - visit: 3, - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".to_string(), - tool_call_id: "call_abc".to_string(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }, - session_id: Some("ses_1".to_string()), - parent_session_id: None, - }, - Utc::now(), - Some(&StageScope { - node_id: "code".to_string(), - visit: 3, - parallel_group_id: Some(StageId::new("fanout", 2)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 0)), - }), - ); - assert_eq!(stored.stage_id, Some(StageId::new("code", 3))); - assert_eq!(stored.tool_call_id.as_deref(), Some("call_abc")); - assert_eq!( - stored.actor, - Some(Principal::Agent { - session_id: Some("ses_1".to_string()), - parent_session_id: None, - model: None, - }) - ); - assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); - assert_eq!( - stored.parallel_branch_id, - Some(ParallelBranchId::new(StageId::new("fanout", 2), 0)) - ); - } - - #[test] - fn stage_scope_populates_stage_id_on_non_stage_events() { - // Events tied to a concrete stage execution but lacking scope in their - // own variant fields (CheckpointCompleted, CommandStarted, PromptCompleted, - // Prompt, InterviewStarted, Failover, GitCommit) should pick up stage_id - // / parallel_group_id / parallel_branch_id from the scope argument. - let scope = StageScope { - node_id: "build".to_string(), - visit: 2, - parallel_group_id: Some(StageId::new("fanout", 1)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 1), 0)), - }; - - let command_started = to_run_event_at( - &fixtures::RUN_1, - &Event::CommandStarted { - node_id: "build".to_string(), - script: "echo".to_string(), - command: "echo".to_string(), - language: "shell".to_string(), - timeout_ms: None, - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(command_started.stage_id, Some(StageId::new("build", 2))); - assert_eq!(command_started.parallel_group_id, scope.parallel_group_id); - assert_eq!(command_started.parallel_branch_id, scope.parallel_branch_id); - - let prompt = to_run_event_at( - &fixtures::RUN_1, - &Event::Prompt { - stage: "build".to_string(), - visit: 2, - text: "do it".to_string(), - mode: None, - provider: None, - model: None, - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(prompt.stage_id, Some(StageId::new("build", 2))); - - let git_commit = to_run_event_at( - &fixtures::RUN_1, - &Event::GitCommit { - node_id: Some("build".to_string()), - sha: "deadbeef".to_string(), - }, - Utc::now(), - Some(&scope), - ); - assert_eq!(git_commit.stage_id, Some(StageId::new("build", 2))); - } - - #[test] - fn run_level_events_without_scope_leave_stage_id_absent() { - let stored = to_run_event(&fixtures::RUN_1, &Event::RunRunning); - assert!(stored.stage_id.is_none()); - assert!(stored.parallel_group_id.is_none()); - assert!(stored.parallel_branch_id.is_none()); - } - - #[test] - fn control_action_events_carry_actor_in_envelope() { - let actor = user_principal("alice"); - - let cancel = to_run_event(&fixtures::RUN_1, &Event::RunCancelRequested { - actor: Some(actor.clone()), - }); - assert_eq!(cancel.event_name(), "run.cancel.requested"); - assert_eq!(cancel.actor.as_ref().expect("actor set"), &actor); - - let pause = to_run_event(&fixtures::RUN_1, &Event::RunPauseRequested { - actor: Some(actor.clone()), - }); - assert_eq!(pause.actor.as_ref().expect("actor set"), &actor); - - let unpause = to_run_event(&fixtures::RUN_1, &Event::RunUnpauseRequested { - actor: None, - }); - assert!(unpause.actor.is_none()); - } - - #[test] - fn run_archived_event_name_matches_dot_notation() { - assert_eq!( - event_name(&Event::RunArchived { actor: None }), - "run.archived" - ); - assert_eq!( - event_name(&Event::RunUnarchived { actor: None }), - "run.unarchived" - ); - } - - #[test] - fn run_archived_round_trips_actor_in_envelope() { - let actor = user_principal("alice"); - - let archived = to_run_event(&fixtures::RUN_1, &Event::RunArchived { - actor: Some(actor.clone()), - }); - assert_eq!(archived.event_name(), "run.archived"); - assert_eq!(archived.actor.as_ref().expect("actor set"), &actor); - assert!(matches!(archived.body, EventBody::RunArchived(_))); - } - - #[test] - fn run_unarchived_round_trips_actor_in_envelope() { - let actor = user_principal("bob"); - - let unarchived = to_run_event(&fixtures::RUN_1, &Event::RunUnarchived { - actor: Some(actor.clone()), - }); - assert_eq!(unarchived.event_name(), "run.unarchived"); - assert_eq!(unarchived.actor.as_ref().expect("actor set"), &actor); - match &unarchived.body { - EventBody::RunUnarchived(_) => {} - other => panic!("expected RunUnarchived body, got {other:?}"), - } - } - - #[test] - fn metadata_snapshot_events_map_to_typed_bodies() { - let started = to_run_event(&fixtures::RUN_1, &Event::MetadataSnapshotStarted { - phase: fabro_types::MetadataSnapshotPhase::Init, - branch: "fabro/metadata/run".to_string(), - }); - - assert_eq!(started.event_name(), "metadata.snapshot.started"); - assert!(started.node_id.is_none()); - assert!(started.stage_id.is_none()); - match started.body { - EventBody::MetadataSnapshotStarted(props) => { - assert_eq!(props.phase, fabro_types::MetadataSnapshotPhase::Init); - assert_eq!(props.branch, "fabro/metadata/run"); - } - other => panic!("expected MetadataSnapshotStarted body, got {other:?}"), - } - - let completed = to_run_event(&fixtures::RUN_1, &Event::MetadataSnapshotCompleted { - phase: fabro_types::MetadataSnapshotPhase::Finalize, - branch: "fabro/metadata/run".to_string(), - duration_ms: 2400, - entry_count: 4, - bytes: 512, - commit_sha: "abc123".to_string(), - }); - - assert_eq!(completed.event_name(), "metadata.snapshot.completed"); - match completed.body { - EventBody::MetadataSnapshotCompleted(props) => { - assert_eq!(props.phase, fabro_types::MetadataSnapshotPhase::Finalize); - assert_eq!(props.duration_ms, 2400); - assert_eq!(props.entry_count, 4); - assert_eq!(props.bytes, 512); - assert_eq!(props.commit_sha, "abc123"); - } - other => panic!("expected MetadataSnapshotCompleted body, got {other:?}"), - } - - let failed = to_run_event(&fixtures::RUN_1, &Event::MetadataSnapshotFailed { - phase: fabro_types::MetadataSnapshotPhase::Checkpoint, - branch: "fabro/metadata/run".to_string(), - duration_ms: 120, - failure_kind: fabro_types::MetadataSnapshotFailureKind::Push, - error: "push rejected".to_string(), - causes: vec!["permission denied".to_string()], - commit_sha: Some("def456".to_string()), - entry_count: Some(4), - bytes: Some(512), - exec_output_tail: Some(fabro_types::ExecOutputTail { - stdout: Some("last stdout line".to_string()), - stderr: Some("last stderr line".to_string()), - stdout_truncated: false, - stderr_truncated: true, - }), - }); - - assert_eq!(failed.event_name(), "metadata.snapshot.failed"); - match failed.body { - EventBody::MetadataSnapshotFailed(props) => { - assert_eq!( - props.failure_kind, - fabro_types::MetadataSnapshotFailureKind::Push - ); - assert_eq!(props.commit_sha.as_deref(), Some("def456")); - assert_eq!(props.entry_count, Some(4)); - assert_eq!(props.bytes, Some(512)); - let tail = props.exec_output_tail.expect("exec output tail"); - assert_eq!(tail.stdout.as_deref(), Some("last stdout line")); - assert_eq!(tail.stderr.as_deref(), Some("last stderr line")); - assert!(tail.stderr_truncated); - assert!(!tail.stdout_truncated); - } - other => panic!("expected MetadataSnapshotFailed body, got {other:?}"), - } - } - - #[test] - fn checkpoint_metadata_snapshot_events_can_be_stage_scoped() { - let scope = StageScope { - node_id: "build".to_string(), - visit: 2, - parallel_group_id: Some(StageId::new("fanout", 1)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 1), 0)), - }; - let stored = to_run_event_at( - &fixtures::RUN_1, - &Event::MetadataSnapshotStarted { - phase: fabro_types::MetadataSnapshotPhase::Checkpoint, - branch: "fabro/metadata/run".to_string(), - }, - Utc::now(), - Some(&scope), - ); - - assert_eq!(stored.node_id.as_deref(), Some("build")); - assert_eq!(stored.node_label.as_deref(), Some("build")); - assert_eq!(stored.stage_id, Some(StageId::new("build", 2))); - assert_eq!(stored.parallel_group_id, scope.parallel_group_id); - assert_eq!(stored.parallel_branch_id, scope.parallel_branch_id); - } - - #[test] - fn agent_assistant_message_populates_agent_actor() { - let stored = to_run_event(&fixtures::RUN_1, &Event::Agent { - stage: "code".to_string(), - visit: 1, - event: AgentEvent::AssistantMessage { - text: "ok".to_string(), - model: "claude-sonnet".to_string(), - usage: LlmTokenCounts::default(), - tool_call_count: 0, - }, - session_id: Some("ses_agent".to_string()), - parent_session_id: None, - }); - let actor = stored.actor.as_ref().expect("actor set"); - assert_eq!(actor, &Principal::Agent { - session_id: Some("ses_agent".to_string()), - parent_session_id: None, - model: Some("claude-sonnet".to_string()), - }); - } - - #[test] - fn stall_watchdog_timeout_populates_watchdog_actor() { - let stored = to_run_event(&fixtures::RUN_1, &Event::StallWatchdogTimeout { - node: "code".to_string(), - idle_seconds: 60, - }); - - assert_eq!(stored.event_name(), "watchdog.timeout"); - assert_eq!(stored.node_id.as_deref(), Some("code")); - assert_eq!( - stored.actor, - Some(Principal::System { - system_kind: SystemActorKind::Watchdog, - }) - ); - } - - #[test] - fn run_created_populates_user_actor_from_provenance() { - use ::fabro_types::{Graph, WorkflowSettings, fixtures}; - - let provenance = RunProvenance { - server: None, - client: None, - subject: Some(user_principal("alice")), - }; - - let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated { - run_id: fixtures::RUN_1, - settings: serde_json::to_value(WorkflowSettings::default()).unwrap(), - graph: serde_json::to_value(Graph::new("test")).unwrap(), - workflow_source: None, - workflow_config: None, - labels: BTreeMap::default(), - run_dir: "/tmp/run".to_string(), - source_directory: Some("/tmp/run".to_string()), - workflow_slug: None, - db_prefix: None, - provenance: Some(provenance), - manifest_blob: None, - git: None, - fork_source_ref: None, - in_place: false, - }); - let actor = stored.actor.as_ref().expect("actor set"); - assert_eq!(actor, &user_principal("alice")); - } -} +pub use self::convert::{to_run_event, to_run_event_at}; +pub use self::emitter::Emitter; +pub use self::events::Event; +pub use self::names::event_name; +pub use self::redaction::{ + build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json, +}; +pub use self::sink::{ + RunEventLogger, RunEventSink, StoreProgressLogger, append_event, append_event_to_sink, +}; +pub use crate::stage_scope::StageScope; diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs new file mode 100644 index 000000000..5c8802ffd --- /dev/null +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -0,0 +1,1778 @@ +use ::fabro_types::{ + BilledTokenCounts, EventBody, RunControlAction, RunEvent, RunId, StageOutcome, + run_event as fabro_types, +}; +use chrono::Utc; +use fabro_agent::{AgentEvent, SandboxEvent}; +use fabro_llm::types::TokenCounts as LlmTokenCounts; +use uuid::Uuid; + +use super::Event; +use super::stored_fields::stored_event_fields; +use crate::stage_scope::StageScope; + +fn billed_token_counts_from_llm(usage: &LlmTokenCounts) -> BilledTokenCounts { + BilledTokenCounts { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + total_tokens: usage.total_tokens(), + reasoning_tokens: usage.reasoning_tokens, + cache_read_tokens: usage.cache_read_tokens, + cache_write_tokens: usage.cache_write_tokens, + total_usd_micros: None, + } +} + +fn stage_status_from_string(status: &str) -> StageOutcome { + status.parse().unwrap_or_else(|_| { + tracing::warn!( + status, + "unknown stage status in StageCompleted event; using Fail" + ); + StageOutcome::Failed { + retry_requested: false, + } + }) +} + +fn event_body_from_event(event: &Event) -> EventBody { + match event { + Event::RunCreated { + settings, + graph, + workflow_source, + workflow_config, + labels, + run_dir, + source_directory, + workflow_slug, + db_prefix, + provenance, + manifest_blob, + git, + fork_source_ref, + in_place, + .. + } => EventBody::RunCreated(fabro_types::RunCreatedProps { + settings: serde_json::from_value(settings.clone()) + .expect("run.created settings"), + graph: serde_json::from_value(graph.clone()).expect("run.created graph"), + workflow_source: workflow_source.clone(), + workflow_config: workflow_config.clone(), + labels: labels.clone(), + run_dir: run_dir.clone(), + source_directory: source_directory.clone(), + workflow_slug: workflow_slug.clone(), + db_prefix: db_prefix.clone(), + provenance: provenance.clone(), + manifest_blob: *manifest_blob, + git: git.clone(), + fork_source_ref: fork_source_ref.clone(), + in_place: *in_place, + }), + Event::WorkflowRunStarted { + name, + base_branch, + base_sha, + run_branch, + worktree_dir, + goal, + .. + } => EventBody::RunStarted(fabro_types::RunStartedProps { + name: name.clone(), + base_branch: base_branch.clone(), + base_sha: base_sha.clone(), + run_branch: run_branch.clone(), + worktree_dir: worktree_dir.clone(), + goal: goal.clone(), + }), + Event::RunSubmitted { definition_blob } => { + EventBody::RunSubmitted(fabro_types::RunSubmittedProps { + definition_blob: *definition_blob, + }) + } + Event::RunQueued => EventBody::RunQueued(fabro_types::RunStatusEffectProps::default()), + Event::RunStarting => { + EventBody::RunStarting(fabro_types::RunStatusTransitionProps::default()) + } + Event::RunRunning => { + EventBody::RunRunning(fabro_types::RunStatusTransitionProps::default()) + } + Event::RunBlocked { blocked_reason } => { + EventBody::RunBlocked(fabro_types::RunBlockedProps { + blocked_reason: *blocked_reason, + }) + } + Event::RunUnblocked => { + EventBody::RunUnblocked(fabro_types::RunStatusEffectProps::default()) + } + Event::RunRemoving => { + EventBody::RunRemoving(fabro_types::RunStatusTransitionProps::default()) + } + Event::RunCancelRequested { .. } => { + EventBody::RunCancelRequested(fabro_types::RunControlRequestedProps { + action: RunControlAction::Cancel, + }) + } + Event::RunPauseRequested { .. } => { + EventBody::RunPauseRequested(fabro_types::RunControlRequestedProps { + action: RunControlAction::Pause, + }) + } + Event::RunUnpauseRequested { .. } => { + EventBody::RunUnpauseRequested(fabro_types::RunControlRequestedProps { + action: RunControlAction::Unpause, + }) + } + Event::RunPaused => EventBody::RunPaused(fabro_types::RunControlEffectProps::default()), + Event::RunUnpaused => EventBody::RunUnpaused(fabro_types::RunControlEffectProps::default()), + Event::RunSupersededBy { + new_run_id, + target_checkpoint_ordinal, + target_node_id, + target_visit, + } => EventBody::RunSupersededBy(fabro_types::RunSupersededByProps { + new_run_id: *new_run_id, + target_checkpoint_ordinal: *target_checkpoint_ordinal, + target_node_id: target_node_id.clone(), + target_visit: *target_visit, + }), + Event::RunArchived { .. } => { + EventBody::RunArchived(fabro_types::RunArchivedProps::default()) + } + Event::RunUnarchived { .. } => { + EventBody::RunUnarchived(fabro_types::RunUnarchivedProps::default()) + } + Event::WorkflowRunCompleted { + duration_ms, + artifact_count, + status, + reason, + total_usd_micros, + final_git_commit_sha, + final_patch, + billing, + } => EventBody::RunCompleted(fabro_types::RunCompletedProps { + duration_ms: *duration_ms, + artifact_count: *artifact_count, + status: status.clone(), + reason: *reason, + total_usd_micros: *total_usd_micros, + final_git_commit_sha: final_git_commit_sha.clone(), + final_patch: final_patch.clone(), + billing: billing.clone(), + }), + Event::WorkflowRunFailed { + error, + duration_ms, + reason, + git_commit_sha, + final_patch, + } => EventBody::RunFailed(fabro_types::RunFailedProps { + error: error.to_string(), + causes: error.causes(), + duration_ms: *duration_ms, + reason: *reason, + git_commit_sha: git_commit_sha.clone(), + final_patch: final_patch.clone(), + }), + Event::RunNotice { + level, + code, + message, + } => EventBody::RunNotice(fabro_types::RunNoticeProps { + level: *level, + code: code.clone(), + message: message.clone(), + }), + Event::MetadataSnapshotStarted { phase, branch } => { + EventBody::MetadataSnapshotStarted(fabro_types::MetadataSnapshotStartedProps { + phase: *phase, + branch: branch.clone(), + }) + } + Event::MetadataSnapshotCompleted { + phase, + branch, + duration_ms, + entry_count, + bytes, + commit_sha, + } => EventBody::MetadataSnapshotCompleted(fabro_types::MetadataSnapshotCompletedProps { + phase: *phase, + branch: branch.clone(), + duration_ms: *duration_ms, + entry_count: *entry_count, + bytes: *bytes, + commit_sha: commit_sha.clone(), + }), + Event::MetadataSnapshotFailed { + phase, + branch, + duration_ms, + failure_kind, + error, + causes, + commit_sha, + entry_count, + bytes, + exec_output_tail, + } => EventBody::MetadataSnapshotFailed(fabro_types::MetadataSnapshotFailedProps { + phase: *phase, + branch: branch.clone(), + duration_ms: *duration_ms, + failure_kind: *failure_kind, + error: error.clone(), + causes: causes.clone(), + commit_sha: commit_sha.clone(), + entry_count: *entry_count, + bytes: *bytes, + exec_output_tail: exec_output_tail.clone(), + }), + Event::StageStarted { + index, + handler_type, + attempt, + max_attempts, + .. + } => EventBody::StageStarted(fabro_types::StageStartedProps { + index: *index, + handler_type: handler_type.clone(), + attempt: *attempt, + max_attempts: *max_attempts, + }), + Event::StageCompleted { + index, + duration_ms, + status, + preferred_label, + suggested_next_ids, + billing, + failure, + notes, + files_touched, + context_updates, + jump_to_node, + context_values, + node_visits, + loop_failure_signatures, + restart_failure_signatures, + response, + attempt, + max_attempts, + .. + } => EventBody::StageCompleted(fabro_types::StageCompletedProps { + index: *index, + duration_ms: *duration_ms, + status: stage_status_from_string(status), + preferred_label: preferred_label.clone(), + suggested_next_ids: suggested_next_ids.clone(), + billing: billing.clone(), + failure: failure.clone(), + notes: notes.clone(), + files_touched: files_touched.clone(), + context_updates: context_updates.clone(), + jump_to_node: jump_to_node.clone(), + context_values: context_values.clone(), + node_visits: node_visits.clone(), + loop_failure_signatures: loop_failure_signatures.clone(), + restart_failure_signatures: restart_failure_signatures.clone(), + response: response.clone(), + attempt: *attempt, + max_attempts: *max_attempts, + }), + Event::StageFailed { + index, + failure, + will_retry, + duration_ms, + .. + } => EventBody::StageFailed(fabro_types::StageFailedProps { + index: *index, + failure: Some(failure.clone()), + will_retry: *will_retry, + duration_ms: *duration_ms, + }), + Event::StageRetrying { + index, + attempt, + max_attempts, + delay_ms, + .. + } => EventBody::StageRetrying(fabro_types::StageRetryingProps { + index: *index, + attempt: *attempt, + max_attempts: *max_attempts, + delay_ms: *delay_ms, + }), + Event::ParallelStarted { + visit, + branch_count, + join_policy, + .. + } => EventBody::ParallelStarted(fabro_types::ParallelStartedProps { + visit: *visit, + branch_count: *branch_count, + join_policy: join_policy.clone(), + }), + Event::ParallelBranchStarted { index, .. } => { + EventBody::ParallelBranchStarted(fabro_types::ParallelBranchStartedProps { + index: *index, + }) + } + Event::ParallelBranchCompleted { + index, + duration_ms, + status, + head_sha, + .. + } => EventBody::ParallelBranchCompleted(fabro_types::ParallelBranchCompletedProps { + index: *index, + duration_ms: *duration_ms, + status: status.clone(), + head_sha: head_sha.clone(), + }), + Event::ParallelCompleted { + visit, + duration_ms, + success_count, + failure_count, + results, + .. + } => EventBody::ParallelCompleted(fabro_types::ParallelCompletedProps { + visit: *visit, + duration_ms: *duration_ms, + success_count: *success_count, + failure_count: *failure_count, + results: results.clone(), + }), + Event::InterviewStarted { + question_id, + question, + stage, + question_type, + options, + allow_freeform, + timeout_seconds, + context_display, + } => EventBody::InterviewStarted(fabro_types::InterviewStartedProps { + question_id: question_id.clone(), + question: question.clone(), + stage: stage.clone(), + question_type: question_type.clone(), + options: options.clone(), + allow_freeform: *allow_freeform, + timeout_seconds: *timeout_seconds, + context_display: context_display.clone(), + }), + Event::InterviewCompleted { + actor: _, + question_id, + question, + answer, + duration_ms, + } => EventBody::InterviewCompleted(fabro_types::InterviewCompletedProps { + question_id: question_id.clone(), + question: question.clone(), + answer: answer.clone(), + duration_ms: *duration_ms, + }), + Event::InterviewTimeout { + actor: _, + question_id, + question, + stage, + duration_ms, + } => EventBody::InterviewTimeout(fabro_types::InterviewTimeoutProps { + question_id: question_id.clone(), + question: question.clone(), + stage: stage.clone(), + duration_ms: *duration_ms, + }), + Event::InterviewInterrupted { + actor: _, + question_id, + question, + stage, + reason, + duration_ms, + } => EventBody::InterviewInterrupted(fabro_types::InterviewInterruptedProps { + question_id: question_id.clone(), + question: question.clone(), + stage: stage.clone(), + reason: reason.clone(), + duration_ms: *duration_ms, + }), + Event::CheckpointCompleted { + status, + current_node, + completed_nodes, + node_retries, + context_values, + node_outcomes, + next_node_id, + git_commit_sha, + loop_failure_signatures, + restart_failure_signatures, + node_visits, + diff, + .. + } => EventBody::CheckpointCompleted(fabro_types::CheckpointCompletedProps { + status: status.clone(), + current_node: current_node.clone(), + completed_nodes: completed_nodes.clone(), + node_retries: node_retries.clone(), + context_values: context_values.clone(), + node_outcomes: node_outcomes.clone(), + next_node_id: next_node_id.clone(), + git_commit_sha: git_commit_sha.clone(), + loop_failure_signatures: loop_failure_signatures.clone(), + restart_failure_signatures: restart_failure_signatures.clone(), + node_visits: node_visits.clone(), + diff: diff.clone(), + }), + Event::CheckpointFailed { error, .. } => { + EventBody::CheckpointFailed(fabro_types::CheckpointFailedProps { + error: error.clone(), + }) + } + Event::GitCommit { sha, .. } => { + EventBody::GitCommit(fabro_types::GitCommitProps { sha: sha.clone() }) + } + Event::GitPush { branch, success } => EventBody::GitPush(fabro_types::GitPushProps { + branch: branch.clone(), + success: *success, + }), + Event::GitBranch { branch, sha } => EventBody::GitBranch(fabro_types::GitBranchProps { + branch: branch.clone(), + sha: sha.clone(), + }), + Event::GitWorktreeAdd { path, branch } => { + EventBody::GitWorktreeAdd(fabro_types::GitWorktreeAddProps { + path: path.clone(), + branch: branch.clone(), + }) + } + Event::GitWorktreeRemove { path } => { + EventBody::GitWorktreeRemove(fabro_types::GitWorktreeRemoveProps { path: path.clone() }) + } + Event::GitFetch { branch, success } => EventBody::GitFetch(fabro_types::GitFetchProps { + branch: branch.clone(), + success: *success, + }), + Event::GitReset { sha } => { + EventBody::GitReset(fabro_types::GitResetProps { sha: sha.clone() }) + } + Event::EdgeSelected { + from_node, + to_node, + label, + condition, + reason, + preferred_label, + suggested_next_ids, + stage_status, + is_jump, + } => EventBody::EdgeSelected(fabro_types::EdgeSelectedProps { + from_node: from_node.clone(), + to_node: to_node.clone(), + label: label.clone(), + condition: condition.clone(), + reason: reason.clone(), + preferred_label: preferred_label.clone(), + suggested_next_ids: suggested_next_ids.clone(), + stage_status: stage_status.clone(), + is_jump: *is_jump, + }), + Event::LoopRestart { from_node, to_node } => { + EventBody::LoopRestart(fabro_types::LoopRestartProps { + from_node: from_node.clone(), + to_node: to_node.clone(), + }) + } + Event::Prompt { + visit, + text, + mode, + provider, + model, + .. + } => EventBody::StagePrompt(fabro_types::StagePromptProps { + visit: *visit, + text: text.clone(), + mode: mode.clone(), + provider: provider.clone(), + model: model.clone(), + }), + Event::PromptCompleted { + response, + model, + provider, + billing, + .. + } => EventBody::PromptCompleted(fabro_types::PromptCompletedProps { + response: response.clone(), + model: model.clone(), + provider: provider.clone(), + billing: billing.clone(), + }), + Event::Agent { visit, event, .. } => match event { + AgentEvent::SessionStarted { provider, model } => { + EventBody::AgentSessionStarted(fabro_types::AgentSessionStartedProps { + provider: provider.clone(), + model: model.clone(), + visit: *visit, + }) + } + AgentEvent::SessionEnded => { + EventBody::AgentSessionEnded(fabro_types::AgentSessionEndedProps { visit: *visit }) + } + AgentEvent::ProcessingEnd => { + EventBody::AgentProcessingEnd(fabro_types::AgentProcessingEndProps { + visit: *visit, + }) + } + AgentEvent::UserInput { text } => EventBody::AgentInput(fabro_types::AgentInputProps { + text: text.clone(), + visit: *visit, + }), + AgentEvent::AssistantMessage { + text, + model, + usage, + tool_call_count, + } => EventBody::AgentMessage(fabro_types::AgentMessageProps { + text: text.clone(), + model: model.clone(), + billing: billed_token_counts_from_llm(usage), + tool_call_count: *tool_call_count, + visit: *visit, + }), + AgentEvent::ToolCallStarted { + tool_name, + tool_call_id, + arguments, + } => EventBody::AgentToolStarted(fabro_types::AgentToolStartedProps { + tool_name: tool_name.clone(), + tool_call_id: tool_call_id.clone(), + arguments: arguments.clone(), + visit: *visit, + }), + AgentEvent::ToolCallCompleted { + tool_name, + tool_call_id, + output, + is_error, + } => EventBody::AgentToolCompleted(fabro_types::AgentToolCompletedProps { + tool_name: tool_name.clone(), + tool_call_id: tool_call_id.clone(), + output: output.clone(), + is_error: *is_error, + visit: *visit, + }), + AgentEvent::Error { error } => EventBody::AgentError(fabro_types::AgentErrorProps { + error: serde_json::to_value(error).expect("serializable agent error"), + visit: *visit, + }), + AgentEvent::Warning { + kind, + message, + details, + } => EventBody::AgentWarning(fabro_types::AgentWarningProps { + kind: kind.clone(), + message: message.clone(), + details: details.clone(), + visit: *visit, + }), + AgentEvent::LoopDetected => { + EventBody::AgentLoopDetected(fabro_types::AgentLoopDetectedProps { visit: *visit }) + } + AgentEvent::TurnLimitReached { max_turns } => { + EventBody::AgentTurnLimitReached(fabro_types::AgentTurnLimitReachedProps { + max_turns: *max_turns, + visit: *visit, + }) + } + AgentEvent::SteeringInjected { text } => { + EventBody::AgentSteeringInjected(fabro_types::AgentSteeringInjectedProps { + text: text.clone(), + visit: *visit, + }) + } + AgentEvent::CompactionStarted { + estimated_tokens, + context_window_size, + } => EventBody::AgentCompactionStarted(fabro_types::AgentCompactionStartedProps { + estimated_tokens: *estimated_tokens, + context_window_size: *context_window_size, + visit: *visit, + }), + AgentEvent::CompactionCompleted { + original_turn_count, + preserved_turn_count, + summary_token_estimate, + tracked_file_count, + } => EventBody::AgentCompactionCompleted(fabro_types::AgentCompactionCompletedProps { + original_turn_count: *original_turn_count, + preserved_turn_count: *preserved_turn_count, + summary_token_estimate: *summary_token_estimate, + tracked_file_count: *tracked_file_count, + visit: *visit, + }), + AgentEvent::LlmRetry { + provider, + model, + attempt, + delay_secs, + error, + } => EventBody::AgentLlmRetry(fabro_types::AgentLlmRetryProps { + provider: provider.clone(), + model: model.clone(), + attempt: *attempt, + delay_secs: *delay_secs, + error: serde_json::to_value(error).expect("serializable sdk error"), + visit: *visit, + }), + AgentEvent::SubAgentSpawned { + agent_id, + depth, + task, + } => EventBody::AgentSubSpawned(fabro_types::AgentSubSpawnedProps { + agent_id: agent_id.clone(), + depth: *depth, + task: task.clone(), + visit: *visit, + }), + AgentEvent::SubAgentCompleted { + agent_id, + depth, + success, + turns_used, + } => EventBody::AgentSubCompleted(fabro_types::AgentSubCompletedProps { + agent_id: agent_id.clone(), + depth: *depth, + success: *success, + turns_used: *turns_used, + visit: *visit, + }), + AgentEvent::SubAgentFailed { + agent_id, + depth, + error, + } => EventBody::AgentSubFailed(fabro_types::AgentSubFailedProps { + agent_id: agent_id.clone(), + depth: *depth, + error: serde_json::to_value(error).expect("serializable agent error"), + visit: *visit, + }), + AgentEvent::SubAgentClosed { agent_id, depth } => { + EventBody::AgentSubClosed(fabro_types::AgentSubClosedProps { + agent_id: agent_id.clone(), + depth: *depth, + visit: *visit, + }) + } + AgentEvent::McpServerReady { + server_name, + tool_count, + } => EventBody::AgentMcpReady(fabro_types::AgentMcpReadyProps { + server_name: server_name.clone(), + tool_count: *tool_count, + visit: *visit, + }), + AgentEvent::McpServerFailed { server_name, error } => { + EventBody::AgentMcpFailed(fabro_types::AgentMcpFailedProps { + server_name: server_name.clone(), + error: error.clone(), + visit: *visit, + }) + } + AgentEvent::AssistantTextStart + | AgentEvent::AssistantOutputReplace { .. } + | AgentEvent::TextDelta { .. } + | AgentEvent::ReasoningDelta { .. } + | AgentEvent::ToolCallOutputDelta { .. } + | AgentEvent::SkillExpanded { .. } => { + panic!("streaming-noise agent event should not be converted to RunEvent") + } + }, + Event::SubgraphStarted { start_node, .. } => { + EventBody::SubgraphStarted(fabro_types::SubgraphStartedProps { + start_node: start_node.clone(), + }) + } + Event::SubgraphCompleted { + steps_executed, + status, + duration_ms, + .. + } => EventBody::SubgraphCompleted(fabro_types::SubgraphCompletedProps { + steps_executed: *steps_executed, + status: status.clone(), + duration_ms: *duration_ms, + }), + Event::Sandbox { event } => match event { + SandboxEvent::Initializing { provider } => { + EventBody::SandboxInitializing(fabro_types::SandboxInitializingProps { + provider: provider.clone(), + }) + } + SandboxEvent::Ready { + provider, + duration_ms, + name, + cpu, + memory, + url, + } => EventBody::SandboxReady(fabro_types::SandboxReadyProps { + provider: provider.clone(), + duration_ms: *duration_ms, + name: name.clone(), + cpu: *cpu, + memory: *memory, + url: url.clone(), + }), + SandboxEvent::InitializeFailed { + provider, + error, + causes, + duration_ms, + } => EventBody::SandboxFailed(fabro_types::SandboxFailedProps { + provider: provider.clone(), + error: error.clone(), + causes: causes.clone(), + duration_ms: *duration_ms, + }), + SandboxEvent::CleanupStarted { provider } => { + EventBody::SandboxCleanupStarted(fabro_types::SandboxCleanupStartedProps { + provider: provider.clone(), + }) + } + SandboxEvent::CleanupCompleted { + provider, + duration_ms, + } => EventBody::SandboxCleanupCompleted(fabro_types::SandboxCleanupCompletedProps { + provider: provider.clone(), + duration_ms: *duration_ms, + }), + SandboxEvent::CleanupFailed { + provider, + error, + causes, + } => EventBody::SandboxCleanupFailed(fabro_types::SandboxCleanupFailedProps { + provider: provider.clone(), + error: error.clone(), + causes: causes.clone(), + }), + SandboxEvent::SnapshotPulling { name } => { + EventBody::SnapshotPulling(fabro_types::SnapshotNameProps { name: name.clone() }) + } + SandboxEvent::SnapshotPulled { name, duration_ms } => { + EventBody::SnapshotPulled(fabro_types::SnapshotCompletedProps { + name: name.clone(), + duration_ms: *duration_ms, + }) + } + SandboxEvent::SnapshotEnsuring { name } => { + EventBody::SnapshotEnsuring(fabro_types::SnapshotNameProps { name: name.clone() }) + } + SandboxEvent::SnapshotCreating { name } => { + EventBody::SnapshotCreating(fabro_types::SnapshotNameProps { name: name.clone() }) + } + SandboxEvent::SnapshotReady { name, duration_ms } => { + EventBody::SnapshotReady(fabro_types::SnapshotCompletedProps { + name: name.clone(), + duration_ms: *duration_ms, + }) + } + SandboxEvent::SnapshotFailed { + name, + error, + causes, + } => EventBody::SnapshotFailed(fabro_types::SnapshotFailedProps { + name: name.clone(), + error: error.clone(), + causes: causes.clone(), + }), + SandboxEvent::GitCloneStarted { url, branch } => { + EventBody::GitCloneStarted(fabro_types::GitCloneStartedProps { + url: url.clone(), + branch: branch.clone(), + }) + } + SandboxEvent::GitCloneCompleted { url, duration_ms } => { + EventBody::GitCloneCompleted(fabro_types::GitCloneCompletedProps { + url: url.clone(), + duration_ms: *duration_ms, + }) + } + SandboxEvent::GitCloneFailed { url, error, causes } => { + EventBody::GitCloneFailed(fabro_types::GitCloneFailedProps { + url: url.clone(), + error: error.clone(), + causes: causes.clone(), + }) + } + }, + Event::SandboxInitialized { + working_directory, + provider, + identifier, + repo_cloned, + clone_origin_url, + clone_branch, + } => EventBody::SandboxInitialized(fabro_types::SandboxInitializedProps { + working_directory: working_directory.clone(), + provider: provider.clone(), + identifier: identifier.clone(), + repo_cloned: *repo_cloned, + clone_origin_url: clone_origin_url.clone(), + clone_branch: clone_branch.clone(), + }), + Event::SetupStarted { command_count } => { + EventBody::SetupStarted(fabro_types::SetupStartedProps { + command_count: *command_count, + }) + } + Event::SetupCommandStarted { command, index } => { + EventBody::SetupCommandStarted(fabro_types::SetupCommandStartedProps { + command: command.clone(), + index: *index, + }) + } + Event::SetupCommandCompleted { + command, + index, + exit_code, + duration_ms, + } => EventBody::SetupCommandCompleted(fabro_types::SetupCommandCompletedProps { + command: command.clone(), + index: *index, + exit_code: *exit_code, + duration_ms: *duration_ms, + }), + Event::SetupCompleted { duration_ms } => { + EventBody::SetupCompleted(fabro_types::SetupCompletedProps { + duration_ms: *duration_ms, + }) + } + Event::SetupFailed { + command, + index, + exit_code, + stderr, + exec_output_tail, + } => EventBody::SetupFailed(fabro_types::SetupFailedProps { + command: command.clone(), + index: *index, + exit_code: *exit_code, + stderr: stderr.clone(), + exec_output_tail: exec_output_tail.clone(), + }), + Event::StallWatchdogTimeout { idle_seconds, .. } => { + EventBody::StallWatchdogTimeout(fabro_types::StallWatchdogTimeoutProps { + idle_seconds: *idle_seconds, + }) + } + Event::ArtifactCaptured { + attempt, + node_slug, + path, + mime, + content_md5, + content_sha256, + bytes, + .. + } => EventBody::ArtifactCaptured(fabro_types::ArtifactCapturedProps { + attempt: *attempt, + node_slug: node_slug.clone(), + path: path.clone(), + mime: mime.clone(), + content_md5: content_md5.clone(), + content_sha256: content_sha256.clone(), + bytes: *bytes, + }), + Event::SshAccessReady { ssh_command } => { + EventBody::SshAccessReady(fabro_types::SshAccessReadyProps { + ssh_command: ssh_command.clone(), + }) + } + Event::Failover { + from_provider, + from_model, + to_provider, + to_model, + error, + .. + } => EventBody::Failover(fabro_types::FailoverProps { + from_provider: from_provider.clone(), + from_model: from_model.clone(), + to_provider: to_provider.clone(), + to_model: to_model.clone(), + error: error.clone(), + }), + Event::CliEnsureStarted { cli_name, provider } => { + EventBody::CliEnsureStarted(fabro_types::CliEnsureStartedProps { + cli_name: cli_name.clone(), + provider: provider.clone(), + }) + } + Event::CliEnsureCompleted { + cli_name, + provider, + already_installed, + node_installed, + duration_ms, + } => EventBody::CliEnsureCompleted(fabro_types::CliEnsureCompletedProps { + cli_name: cli_name.clone(), + provider: provider.clone(), + already_installed: *already_installed, + node_installed: *node_installed, + duration_ms: *duration_ms, + }), + Event::CliEnsureFailed { + cli_name, + provider, + error, + duration_ms, + exec_output_tail, + } => EventBody::CliEnsureFailed(fabro_types::CliEnsureFailedProps { + cli_name: cli_name.clone(), + provider: provider.clone(), + error: error.clone(), + duration_ms: *duration_ms, + exec_output_tail: exec_output_tail.clone(), + }), + Event::CommandStarted { + script, + command, + language, + timeout_ms, + .. + } => EventBody::CommandStarted(fabro_types::CommandStartedProps { + script: script.clone(), + command: command.clone(), + language: language.clone(), + timeout_ms: *timeout_ms, + }), + Event::CommandCompleted { + stdout, + stderr, + exit_code, + duration_ms, + termination, + stdout_bytes, + stderr_bytes, + streams_separated, + live_streaming, + .. + } => EventBody::CommandCompleted(fabro_types::CommandCompletedProps { + stdout: stdout.clone(), + stderr: stderr.clone(), + exit_code: *exit_code, + duration_ms: *duration_ms, + termination: *termination, + stdout_bytes: *stdout_bytes, + stderr_bytes: *stderr_bytes, + streams_separated: *streams_separated, + live_streaming: *live_streaming, + }), + Event::AgentCliStarted { + visit, + mode, + provider, + model, + command, + .. + } => EventBody::AgentCliStarted(fabro_types::AgentCliStartedProps { + visit: *visit, + mode: mode.clone(), + provider: provider.clone(), + model: model.clone(), + command: command.clone(), + }), + Event::AgentCliCompleted { + stdout, + stderr, + exit_code, + duration_ms, + .. + } => EventBody::AgentCliCompleted(fabro_types::AgentCliCompletedProps { + stdout: stdout.clone(), + stderr: stderr.clone(), + exit_code: *exit_code, + duration_ms: *duration_ms, + }), + Event::PullRequestCreated { + pr_url, + pr_number, + owner, + repo, + base_branch, + head_branch, + title, + draft, + } => EventBody::PullRequestCreated(fabro_types::PullRequestCreatedProps { + pr_url: pr_url.clone(), + pr_number: *pr_number, + owner: owner.clone(), + repo: repo.clone(), + base_branch: base_branch.clone(), + head_branch: head_branch.clone(), + title: title.clone(), + draft: *draft, + }), + Event::PullRequestFailed { error } => { + EventBody::PullRequestFailed(fabro_types::PullRequestFailedProps { + error: error.clone(), + }) + } + Event::DevcontainerResolved { + dockerfile_lines, + environment_count, + lifecycle_command_count, + workspace_folder, + } => EventBody::DevcontainerResolved(fabro_types::DevcontainerResolvedProps { + dockerfile_lines: *dockerfile_lines, + environment_count: *environment_count, + lifecycle_command_count: *lifecycle_command_count, + workspace_folder: workspace_folder.clone(), + }), + Event::DevcontainerLifecycleStarted { + phase, + command_count, + } => EventBody::DevcontainerLifecycleStarted( + fabro_types::DevcontainerLifecycleStartedProps { + phase: phase.clone(), + command_count: *command_count, + }, + ), + Event::DevcontainerLifecycleCommandStarted { + phase, + command, + index, + } => EventBody::DevcontainerLifecycleCommandStarted( + fabro_types::DevcontainerLifecycleCommandStartedProps { + phase: phase.clone(), + command: command.clone(), + index: *index, + }, + ), + Event::DevcontainerLifecycleCommandCompleted { + phase, + command, + index, + exit_code, + duration_ms, + } => EventBody::DevcontainerLifecycleCommandCompleted( + fabro_types::DevcontainerLifecycleCommandCompletedProps { + phase: phase.clone(), + command: command.clone(), + index: *index, + exit_code: *exit_code, + duration_ms: *duration_ms, + }, + ), + Event::DevcontainerLifecycleCompleted { phase, duration_ms } => { + EventBody::DevcontainerLifecycleCompleted( + fabro_types::DevcontainerLifecycleCompletedProps { + phase: phase.clone(), + duration_ms: *duration_ms, + }, + ) + } + Event::DevcontainerLifecycleFailed { + phase, + command, + index, + exit_code, + stderr, + exec_output_tail, + } => { + EventBody::DevcontainerLifecycleFailed(fabro_types::DevcontainerLifecycleFailedProps { + phase: phase.clone(), + command: command.clone(), + index: *index, + exit_code: *exit_code, + stderr: stderr.clone(), + exec_output_tail: exec_output_tail.clone(), + }) + } + Event::RetroStarted { + prompt, + provider, + model, + } => EventBody::RetroStarted(fabro_types::RetroStartedProps { + prompt: prompt.clone(), + provider: provider.clone(), + model: model.clone(), + }), + Event::RetroCompleted { + duration_ms, + response, + retro, + } => EventBody::RetroCompleted(fabro_types::RetroCompletedProps { + duration_ms: *duration_ms, + response: response.clone(), + retro: retro.clone(), + }), + Event::RetroFailed { error, duration_ms } => { + EventBody::RetroFailed(fabro_types::RetroFailedProps { + error: error.clone(), + duration_ms: *duration_ms, + }) + } + } +} + +#[must_use] +pub fn to_run_event(run_id: &RunId, event: &Event) -> RunEvent { + to_run_event_at(run_id, event, Utc::now(), None) +} + +#[must_use] +pub fn to_run_event_at( + run_id: &RunId, + event: &Event, + ts: chrono::DateTime, + scope: Option<&StageScope>, +) -> RunEvent { + let fields = stored_event_fields(event, scope); + let body = event_body_from_event(event); + RunEvent { + id: Uuid::now_v7().to_string(), + ts, + run_id: *run_id, + node_id: fields.node_id, + node_label: fields.node_label, + stage_id: fields.stage_id, + parallel_group_id: fields.parallel_group_id, + parallel_branch_id: fields.parallel_branch_id, + session_id: fields.session_id, + parent_session_id: fields.parent_session_id, + tool_call_id: fields.tool_call_id, + actor: fields.actor, + body, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use ::fabro_types::{ + AuthMethod, EventBody, FailureReason, IdpIdentity, ParallelBranchId, Principal, + RunProvenance, StageId, SystemActorKind, fixtures, run_event as fabro_types, + }; + use chrono::Utc; + use fabro_agent::{AgentEvent, SandboxEvent}; + use fabro_llm::types::TokenCounts as LlmTokenCounts; + + use super::*; + use crate::error::Error; + use crate::event::{Event, StageScope}; + use crate::outcome::FailureDetail; + + fn user_principal(login: &str) -> Principal { + Principal::user( + IdpIdentity::new("https://github.com", "12345").unwrap(), + login.to_string(), + AuthMethod::Github, + ) + } + + #[derive(Debug)] + struct EventTestCause; + + impl std::fmt::Display for EventTestCause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("connection refused") + } + } + + impl std::error::Error for EventTestCause {} + + #[test] + fn run_event_stage_completed_places_node_fields_in_header() { + let stored = to_run_event_at( + &fixtures::RUN_2, + &Event::StageCompleted { + node_id: "plan".to_string(), + name: "Plan".to_string(), + index: 0, + duration_ms: 5000, + status: "succeeded".to_string(), + preferred_label: None, + suggested_next_ids: Vec::new(), + billing: None, + failure: None, + notes: None, + files_touched: Vec::new(), + 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, + }, + Utc::now(), + Some(&StageScope { + node_id: "plan".to_string(), + visit: 1, + parallel_group_id: None, + parallel_branch_id: None, + }), + ); + + assert_eq!(stored.event_name(), "stage.completed"); + assert_eq!(stored.run_id, fixtures::RUN_2); + assert_eq!(stored.node_id.as_deref(), Some("plan")); + assert_eq!(stored.node_label.as_deref(), Some("Plan")); + assert_eq!(stored.stage_id, Some(StageId::new("plan", 1))); + let properties = stored.properties().unwrap(); + assert_eq!(properties["duration_ms"], 5000); + assert_eq!(properties["status"], "succeeded"); + assert!(stored.session_id.is_none()); + } + + #[test] + fn run_event_stage_completed_keeps_response_and_signature_snapshots() { + let stored = to_run_event(&fixtures::RUN_2, &Event::StageCompleted { + node_id: "plan".to_string(), + name: "Plan".to_string(), + index: 0, + duration_ms: 5000, + status: "succeeded".to_string(), + preferred_label: None, + suggested_next_ids: Vec::new(), + billing: None, + failure: None, + notes: None, + files_touched: Vec::new(), + context_updates: None, + jump_to_node: None, + context_values: None, + node_visits: None, + loop_failure_signatures: Some(BTreeMap::from([("sig-a".to_string(), 2usize)])), + restart_failure_signatures: Some(BTreeMap::from([("sig-b".to_string(), 1usize)])), + response: Some("done".to_string()), + attempt: 1, + max_attempts: 1, + }); + + let properties = stored.properties().unwrap(); + assert_eq!(properties["response"], "done"); + assert_eq!(properties["loop_failure_signatures"]["sig-a"], 2); + assert_eq!(properties["restart_failure_signatures"]["sig-b"], 1); + } + + #[test] + fn run_event_stage_failure_keeps_failure_detail() { + let stored = to_run_event(&fixtures::RUN_3, &Event::StageFailed { + node_id: "code".to_string(), + name: "Code".to_string(), + index: 1, + failure: FailureDetail::new( + "lint failed", + crate::outcome::FailureCategory::Deterministic, + ), + will_retry: true, + duration_ms: 5000, + actor: None, + }); + + assert_eq!(stored.event_name(), "stage.failed"); + let properties = stored.properties().unwrap(); + assert_eq!(properties["failure"]["message"], "lint failed"); + assert_eq!(properties["failure"]["failure_class"], "deterministic"); + assert_eq!(properties["will_retry"], true); + } + + #[test] + fn run_event_agent_tool_started_moves_session_metadata_to_header() { + let stored = to_run_event(&fixtures::RUN_4, &Event::Agent { + stage: "code".to_string(), + visit: 2, + event: AgentEvent::ToolCallStarted { + tool_name: "read_file".to_string(), + tool_call_id: "call_1".to_string(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }, + session_id: Some("ses_child".to_string()), + parent_session_id: Some("ses_parent".to_string()), + }); + + assert_eq!(stored.event_name(), "agent.tool.started"); + assert_eq!(stored.node_id.as_deref(), Some("code")); + assert_eq!(stored.node_label.as_deref(), Some("code")); + assert_eq!(stored.session_id.as_deref(), Some("ses_child")); + assert_eq!(stored.parent_session_id.as_deref(), Some("ses_parent")); + let properties = stored.properties().unwrap(); + assert_eq!(properties["tool_name"], "read_file"); + assert_eq!(properties["tool_call_id"], "call_1"); + assert_eq!(properties["visit"], 2); + } + + #[test] + fn run_event_sandbox_event_keeps_properties_nested() { + let stored = to_run_event(&fixtures::RUN_5, &Event::Sandbox { + event: SandboxEvent::Ready { + provider: "daytona".to_string(), + duration_ms: 2500, + name: Some("sandbox-1".to_string()), + cpu: Some(4.0), + memory: Some(8.0), + url: Some("https://example.test".to_string()), + }, + }); + + assert_eq!(stored.event_name(), "sandbox.ready"); + assert!(stored.node_id.is_none()); + let properties = stored.properties().unwrap(); + assert_eq!(properties["provider"], "daytona"); + assert_eq!(properties["duration_ms"], 2500); + } + + #[test] + fn run_event_sandbox_failure_serializes_causes() { + let stored = to_run_event(&fixtures::RUN_5, &Event::Sandbox { + event: SandboxEvent::InitializeFailed { + provider: "docker".to_string(), + error: "Failed to pull Docker image buildpack-deps:noble".to_string(), + causes: vec!["connection refused".to_string()], + duration_ms: 42, + }, + }); + + assert_eq!(stored.event_name(), "sandbox.failed"); + let properties = stored.properties().unwrap(); + assert_eq!(properties["provider"], "docker"); + assert_eq!( + properties["error"], + "Failed to pull Docker image buildpack-deps:noble" + ); + assert_eq!( + properties["causes"], + serde_json::json!(["connection refused"]) + ); + } + + #[test] + fn run_event_workflow_failure_uses_display_error() { + let stored = to_run_event(&fixtures::RUN_6, &Event::WorkflowRunFailed { + error: Error::handler("boom"), + duration_ms: 900, + reason: FailureReason::WorkflowError, + git_commit_sha: Some("abc123".to_string()), + final_patch: None, + }); + + assert_eq!(stored.event_name(), "run.failed"); + let properties = stored.properties().unwrap(); + assert_eq!(properties["error"], "Handler error: boom"); + assert_eq!(properties["duration_ms"], 900); + } + + #[test] + fn run_event_workflow_failure_serializes_causes() { + let source = EventTestCause; + let stored = to_run_event(&fixtures::RUN_6, &Event::WorkflowRunFailed { + error: Error::engine_with_source("Failed to initialize sandbox", &source), + duration_ms: 900, + reason: FailureReason::WorkflowError, + git_commit_sha: None, + final_patch: None, + }); + + let properties = stored.properties().unwrap(); + assert_eq!( + properties["error"], + "Engine error: Failed to initialize sandbox" + ); + assert_eq!( + properties["causes"], + serde_json::json!(["connection refused"]) + ); + } + + #[test] + fn stage_started_populates_parallel_ids_when_present() { + let stored = to_run_event_at( + &fixtures::RUN_1, + &Event::StageStarted { + node_id: "review".to_string(), + name: "review".to_string(), + index: 1, + handler_type: "agent".to_string(), + attempt: 1, + max_attempts: 1, + }, + Utc::now(), + Some(&StageScope { + node_id: "review".to_string(), + visit: 1, + parallel_group_id: Some(StageId::new("fanout", 2)), + parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)), + }), + ); + assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); + assert_eq!( + stored.parallel_branch_id, + Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) + ); + } + + #[test] + fn parallel_started_populates_parallel_group_id() { + let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelStarted { + node_id: "fanout".to_string(), + visit: 2, + branch_count: 3, + join_policy: "wait_all".to_string(), + }); + assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); + assert!(stored.parallel_branch_id.is_none()); + } + + #[test] + fn parallel_branch_started_populates_group_and_branch_ids() { + let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelBranchStarted { + parallel_group_id: StageId::new("fanout", 2), + parallel_branch_id: ParallelBranchId::new(StageId::new("fanout", 2), 1), + branch: "review".to_string(), + index: 1, + }); + assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); + assert_eq!( + stored.parallel_branch_id, + Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) + ); + } + + #[test] + fn agent_tool_started_populates_tool_call_id_and_stage_id() { + let stored = to_run_event_at( + &fixtures::RUN_1, + &Event::Agent { + stage: "code".to_string(), + visit: 3, + event: AgentEvent::ToolCallStarted { + tool_name: "read_file".to_string(), + tool_call_id: "call_abc".to_string(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }, + session_id: Some("ses_1".to_string()), + parent_session_id: None, + }, + Utc::now(), + Some(&StageScope { + node_id: "code".to_string(), + visit: 3, + parallel_group_id: Some(StageId::new("fanout", 2)), + parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 0)), + }), + ); + assert_eq!(stored.stage_id, Some(StageId::new("code", 3))); + assert_eq!(stored.tool_call_id.as_deref(), Some("call_abc")); + assert_eq!( + stored.actor, + Some(Principal::Agent { + session_id: Some("ses_1".to_string()), + parent_session_id: None, + model: None, + }) + ); + assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); + assert_eq!( + stored.parallel_branch_id, + Some(ParallelBranchId::new(StageId::new("fanout", 2), 0)) + ); + } + + #[test] + fn stage_scope_populates_stage_id_on_non_stage_events() { + // Events tied to a concrete stage execution but lacking scope in their + // own variant fields (CheckpointCompleted, CommandStarted, PromptCompleted, + // Prompt, InterviewStarted, Failover, GitCommit) should pick up stage_id + // / parallel_group_id / parallel_branch_id from the scope argument. + let scope = StageScope { + node_id: "build".to_string(), + visit: 2, + parallel_group_id: Some(StageId::new("fanout", 1)), + parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 1), 0)), + }; + + let command_started = to_run_event_at( + &fixtures::RUN_1, + &Event::CommandStarted { + node_id: "build".to_string(), + script: "echo".to_string(), + command: "echo".to_string(), + language: "shell".to_string(), + timeout_ms: None, + }, + Utc::now(), + Some(&scope), + ); + assert_eq!(command_started.stage_id, Some(StageId::new("build", 2))); + assert_eq!(command_started.parallel_group_id, scope.parallel_group_id); + assert_eq!(command_started.parallel_branch_id, scope.parallel_branch_id); + + let prompt = to_run_event_at( + &fixtures::RUN_1, + &Event::Prompt { + stage: "build".to_string(), + visit: 2, + text: "do it".to_string(), + mode: None, + provider: None, + model: None, + }, + Utc::now(), + Some(&scope), + ); + assert_eq!(prompt.stage_id, Some(StageId::new("build", 2))); + + let git_commit = to_run_event_at( + &fixtures::RUN_1, + &Event::GitCommit { + node_id: Some("build".to_string()), + sha: "deadbeef".to_string(), + }, + Utc::now(), + Some(&scope), + ); + assert_eq!(git_commit.stage_id, Some(StageId::new("build", 2))); + } + + #[test] + fn run_level_events_without_scope_leave_stage_id_absent() { + let stored = to_run_event(&fixtures::RUN_1, &Event::RunRunning); + assert!(stored.stage_id.is_none()); + assert!(stored.parallel_group_id.is_none()); + assert!(stored.parallel_branch_id.is_none()); + } + + #[test] + fn control_action_events_carry_actor_in_envelope() { + let actor = user_principal("alice"); + + let cancel = to_run_event(&fixtures::RUN_1, &Event::RunCancelRequested { + actor: Some(actor.clone()), + }); + assert_eq!(cancel.event_name(), "run.cancel.requested"); + assert_eq!(cancel.actor.as_ref().expect("actor set"), &actor); + + let pause = to_run_event(&fixtures::RUN_1, &Event::RunPauseRequested { + actor: Some(actor.clone()), + }); + assert_eq!(pause.actor.as_ref().expect("actor set"), &actor); + + let unpause = to_run_event(&fixtures::RUN_1, &Event::RunUnpauseRequested { + actor: None, + }); + assert!(unpause.actor.is_none()); + } + + #[test] + fn run_archived_round_trips_actor_in_envelope() { + let actor = user_principal("alice"); + + let archived = to_run_event(&fixtures::RUN_1, &Event::RunArchived { + actor: Some(actor.clone()), + }); + assert_eq!(archived.event_name(), "run.archived"); + assert_eq!(archived.actor.as_ref().expect("actor set"), &actor); + assert!(matches!(archived.body, EventBody::RunArchived(_))); + } + + #[test] + fn run_unarchived_round_trips_actor_in_envelope() { + let actor = user_principal("bob"); + + let unarchived = to_run_event(&fixtures::RUN_1, &Event::RunUnarchived { + actor: Some(actor.clone()), + }); + assert_eq!(unarchived.event_name(), "run.unarchived"); + assert_eq!(unarchived.actor.as_ref().expect("actor set"), &actor); + match &unarchived.body { + EventBody::RunUnarchived(_) => {} + other => panic!("expected RunUnarchived body, got {other:?}"), + } + } + + #[test] + fn metadata_snapshot_events_map_to_typed_bodies() { + let started = to_run_event(&fixtures::RUN_1, &Event::MetadataSnapshotStarted { + phase: fabro_types::MetadataSnapshotPhase::Init, + branch: "fabro/metadata/run".to_string(), + }); + + assert_eq!(started.event_name(), "metadata.snapshot.started"); + assert!(started.node_id.is_none()); + assert!(started.stage_id.is_none()); + match started.body { + EventBody::MetadataSnapshotStarted(props) => { + assert_eq!(props.phase, fabro_types::MetadataSnapshotPhase::Init); + assert_eq!(props.branch, "fabro/metadata/run"); + } + other => panic!("expected MetadataSnapshotStarted body, got {other:?}"), + } + + let completed = to_run_event(&fixtures::RUN_1, &Event::MetadataSnapshotCompleted { + phase: fabro_types::MetadataSnapshotPhase::Finalize, + branch: "fabro/metadata/run".to_string(), + duration_ms: 2400, + entry_count: 4, + bytes: 512, + commit_sha: "abc123".to_string(), + }); + + assert_eq!(completed.event_name(), "metadata.snapshot.completed"); + match completed.body { + EventBody::MetadataSnapshotCompleted(props) => { + assert_eq!(props.phase, fabro_types::MetadataSnapshotPhase::Finalize); + assert_eq!(props.duration_ms, 2400); + assert_eq!(props.entry_count, 4); + assert_eq!(props.bytes, 512); + assert_eq!(props.commit_sha, "abc123"); + } + other => panic!("expected MetadataSnapshotCompleted body, got {other:?}"), + } + + let failed = to_run_event(&fixtures::RUN_1, &Event::MetadataSnapshotFailed { + phase: fabro_types::MetadataSnapshotPhase::Checkpoint, + branch: "fabro/metadata/run".to_string(), + duration_ms: 120, + failure_kind: fabro_types::MetadataSnapshotFailureKind::Push, + error: "push rejected".to_string(), + causes: vec!["permission denied".to_string()], + commit_sha: Some("def456".to_string()), + entry_count: Some(4), + bytes: Some(512), + exec_output_tail: Some(fabro_types::ExecOutputTail { + stdout: Some("last stdout line".to_string()), + stderr: Some("last stderr line".to_string()), + stdout_truncated: false, + stderr_truncated: true, + }), + }); + + assert_eq!(failed.event_name(), "metadata.snapshot.failed"); + match failed.body { + EventBody::MetadataSnapshotFailed(props) => { + assert_eq!( + props.failure_kind, + fabro_types::MetadataSnapshotFailureKind::Push + ); + assert_eq!(props.commit_sha.as_deref(), Some("def456")); + assert_eq!(props.entry_count, Some(4)); + assert_eq!(props.bytes, Some(512)); + let tail = props.exec_output_tail.expect("exec output tail"); + assert_eq!(tail.stdout.as_deref(), Some("last stdout line")); + assert_eq!(tail.stderr.as_deref(), Some("last stderr line")); + assert!(tail.stderr_truncated); + assert!(!tail.stdout_truncated); + } + other => panic!("expected MetadataSnapshotFailed body, got {other:?}"), + } + } + + #[test] + fn checkpoint_metadata_snapshot_events_can_be_stage_scoped() { + let scope = StageScope { + node_id: "build".to_string(), + visit: 2, + parallel_group_id: Some(StageId::new("fanout", 1)), + parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 1), 0)), + }; + let stored = to_run_event_at( + &fixtures::RUN_1, + &Event::MetadataSnapshotStarted { + phase: fabro_types::MetadataSnapshotPhase::Checkpoint, + branch: "fabro/metadata/run".to_string(), + }, + Utc::now(), + Some(&scope), + ); + + assert_eq!(stored.node_id.as_deref(), Some("build")); + assert_eq!(stored.node_label.as_deref(), Some("build")); + assert_eq!(stored.stage_id, Some(StageId::new("build", 2))); + assert_eq!(stored.parallel_group_id, scope.parallel_group_id); + assert_eq!(stored.parallel_branch_id, scope.parallel_branch_id); + } + + #[test] + fn agent_assistant_message_populates_agent_actor() { + let stored = to_run_event(&fixtures::RUN_1, &Event::Agent { + stage: "code".to_string(), + visit: 1, + event: AgentEvent::AssistantMessage { + text: "ok".to_string(), + model: "claude-sonnet".to_string(), + usage: LlmTokenCounts::default(), + tool_call_count: 0, + }, + session_id: Some("ses_agent".to_string()), + parent_session_id: None, + }); + let actor = stored.actor.as_ref().expect("actor set"); + assert_eq!(actor, &Principal::Agent { + session_id: Some("ses_agent".to_string()), + parent_session_id: None, + model: Some("claude-sonnet".to_string()), + }); + } + + #[test] + fn stall_watchdog_timeout_populates_watchdog_actor() { + let stored = to_run_event(&fixtures::RUN_1, &Event::StallWatchdogTimeout { + node: "code".to_string(), + idle_seconds: 60, + }); + + assert_eq!(stored.event_name(), "watchdog.timeout"); + assert_eq!(stored.node_id.as_deref(), Some("code")); + assert_eq!( + stored.actor, + Some(Principal::System { + system_kind: SystemActorKind::Watchdog, + }) + ); + } + + #[test] + fn run_created_populates_user_actor_from_provenance() { + use ::fabro_types::{Graph, WorkflowSettings, fixtures}; + + let provenance = RunProvenance { + server: None, + client: None, + subject: Some(user_principal("alice")), + }; + + let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated { + run_id: fixtures::RUN_1, + settings: serde_json::to_value(WorkflowSettings::default()).unwrap(), + graph: serde_json::to_value(Graph::new("test")).unwrap(), + workflow_source: None, + workflow_config: None, + labels: BTreeMap::default(), + run_dir: "/tmp/run".to_string(), + source_directory: Some("/tmp/run".to_string()), + workflow_slug: None, + db_prefix: None, + provenance: Some(provenance), + manifest_blob: None, + git: None, + fork_source_ref: None, + in_place: false, + }); + let actor = stored.actor.as_ref().expect("actor set"); + assert_eq!(actor, &user_principal("alice")); + } +} diff --git a/lib/crates/fabro-workflow/src/event/emitter.rs b/lib/crates/fabro-workflow/src/event/emitter.rs new file mode 100644 index 000000000..4d54a0b40 --- /dev/null +++ b/lib/crates/fabro-workflow/src/event/emitter.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; + +use ::fabro_types::{RunEvent, RunId, RunNoticeLevel}; +use chrono::Utc; +use fabro_agent::{WorktreeEvent, WorktreeEventCallback}; + +use super::Event; +use super::convert::to_run_event_at; +use crate::stage_scope::StageScope; + +fn epoch_millis() -> i64 { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + i64::try_from(millis).unwrap_or(i64::MAX) +} + +/// Listener callback type for workflow run events. +type EventListener = Arc; + +/// Callback-based event emitter for workflow run events. +pub struct Emitter { + run_id: RunId, + listeners: std::sync::Mutex>, + /// Epoch milliseconds of the last `emit()` or `touch()` call. 0 until first + /// event. + last_event_at: AtomicI64, +} + +impl std::fmt::Debug for Emitter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let count = self.listeners.lock().map_or(0, |l| l.len()); + f.debug_struct("Emitter") + .field("run_id", &self.run_id) + .field("listener_count", &count) + .field("last_event_at", &self.last_event_at.load(Ordering::Relaxed)) + .finish() + } +} + +impl Default for Emitter { + fn default() -> Self { + Self::new(RunId::new()) + } +} + +impl Emitter { + #[must_use] + pub fn new(run_id: RunId) -> Self { + Self { + run_id, + listeners: std::sync::Mutex::new(Vec::new()), + last_event_at: AtomicI64::new(0), + } + } + + #[must_use] + pub fn run_id(&self) -> RunId { + self.run_id + } + + pub fn on_event(&self, listener: impl Fn(&RunEvent) + Send + Sync + 'static) { + self.listeners + .lock() + .expect("listeners lock poisoned") + .push(Arc::new(listener)); + } + + pub fn emit(&self, event: &Event) { + self.emit_with_scope(event, None); + } + + pub fn emit_scoped(&self, event: &Event, scope: &StageScope) { + self.emit_with_scope(event, Some(scope)); + } + + pub fn notice( + &self, + level: RunNoticeLevel, + code: impl Into, + message: impl Into, + ) { + self.emit(&Event::RunNotice { + level, + code: code.into(), + message: message.into(), + }); + } + + fn emit_with_scope(&self, event: &Event, scope: Option<&StageScope>) { + self.last_event_at.store(epoch_millis(), Ordering::Relaxed); + event.trace(); + if let Event::WorkflowRunStarted { run_id, .. } = event { + debug_assert_eq!( + *run_id, self.run_id, + "workflow run started event must match emitter run_id" + ); + } + let stored = to_run_event_at(&self.run_id, event, Utc::now(), scope); + self.dispatch_run_event(&stored); + } + + pub(crate) fn dispatch_run_event(&self, event: &RunEvent) { + self.last_event_at.store(epoch_millis(), Ordering::Relaxed); + // Clone the listener list so we don't hold the lock during dispatch. + // This prevents deadlocks if a listener calls emit() reentrantly. + // Note: listeners added during this emit() won't receive the current event. + let snapshot: Vec = self + .listeners + .lock() + .expect("listeners lock poisoned") + .clone(); + for listener in &snapshot { + listener(event); + } + } + + /// Returns the epoch milliseconds of the last `emit()` or `touch()` call. + /// Returns 0 if neither has been called. + pub fn last_event_at(&self) -> i64 { + self.last_event_at.load(Ordering::Relaxed) + } + + /// Manually update the last-event timestamp (e.g. to seed the watchdog at + /// workflow run start). + pub fn touch(&self) { + self.last_event_at.store(epoch_millis(), Ordering::Relaxed); + } + + /// Build a [`WorktreeEventCallback`] that forwards worktree lifecycle + /// events as [`Event`]s on this emitter. + pub fn worktree_callback(self: Arc) -> WorktreeEventCallback { + Arc::new(move |event| match event { + WorktreeEvent::BranchCreated { branch, sha } => { + self.emit(&Event::GitBranch { branch, sha }); + } + WorktreeEvent::WorktreeAdded { path, branch } => { + self.emit(&Event::GitWorktreeAdd { path, branch }); + } + WorktreeEvent::WorktreeRemoved { path } => { + self.emit(&Event::GitWorktreeRemove { path }); + } + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use ::fabro_types::fixtures; + + use super::*; + use crate::event::Event; + + #[test] + fn event_emitter_new_has_no_listeners() { + let emitter = Emitter::new(fixtures::RUN_1); + assert_eq!(emitter.listeners.lock().unwrap().len(), 0); + } + + #[test] + fn event_emitter_calls_listener_with_envelope() { + let emitter = Emitter::new(fixtures::RUN_1); + let received = Arc::new(Mutex::new(Vec::new())); + let received_clone = Arc::clone(&received); + emitter.on_event(move |event| { + received_clone.lock().unwrap().push(event.clone()); + }); + emitter.emit(&Event::WorkflowRunStarted { + name: "test".to_string(), + run_id: fixtures::RUN_1, + base_branch: None, + base_sha: None, + run_branch: None, + worktree_dir: None, + goal: None, + }); + let events = received.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_name(), "run.started"); + assert_eq!(events[0].run_id, fixtures::RUN_1); + assert!(events[0].id.len() >= 32); + } + + #[test] + fn event_emitter_default() { + let emitter = Emitter::default(); + assert_eq!(emitter.listeners.lock().unwrap().len(), 0); + } +} diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs new file mode 100644 index 000000000..12f94f81e --- /dev/null +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -0,0 +1,1281 @@ +use std::collections::BTreeMap; + +use ::fabro_types::{ + BilledTokenCounts, BlockedReason, CommandTermination, FailureReason, ForkSourceRef, GitContext, + ParallelBranchId, Principal, PullRequestRecord, RunBlobId, RunId, RunNoticeLevel, + RunProvenance, StageId, SuccessReason, run_event as fabro_types, +}; +use fabro_agent::{AgentEvent, SandboxEvent}; +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::outcome::{BilledModelUsage, FailureDetail, Outcome}; + +/// Events emitted during workflow run execution for observability. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow( + clippy::large_enum_variant, + reason = "Workflow events stay inline to match the serialized event stream." +)] +pub enum Event { + RunCreated { + run_id: RunId, + settings: serde_json::Value, + graph: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + workflow_source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + workflow_config: Option, + labels: BTreeMap, + run_dir: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_directory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + workflow_slug: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + db_prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provenance: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + manifest_blob: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fork_source_ref: Option, + #[serde(default)] + in_place: bool, + }, + WorkflowRunStarted { + name: String, + run_id: RunId, + #[serde(default, skip_serializing_if = "Option::is_none")] + base_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + base_sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + run_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + worktree_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + goal: Option, + }, + RunSubmitted { + #[serde(default, skip_serializing_if = "Option::is_none")] + definition_blob: Option, + }, + RunQueued, + RunStarting, + RunRunning, + RunBlocked { + blocked_reason: BlockedReason, + }, + RunUnblocked, + RunRemoving, + RunCancelRequested { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + RunPauseRequested { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + RunUnpauseRequested { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + RunPaused, + RunUnpaused, + RunSupersededBy { + new_run_id: RunId, + target_checkpoint_ordinal: usize, + target_node_id: String, + target_visit: usize, + }, + RunArchived { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + RunUnarchived { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + WorkflowRunCompleted { + duration_ms: u64, + artifact_count: usize, + #[serde(default)] + status: String, + reason: SuccessReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + total_usd_micros: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + final_git_commit_sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + final_patch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + billing: Option, + }, + WorkflowRunFailed { + error: Error, + duration_ms: u64, + reason: FailureReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + git_commit_sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + final_patch: Option, + }, + RunNotice { + level: RunNoticeLevel, + code: String, + message: String, + }, + MetadataSnapshotStarted { + phase: fabro_types::MetadataSnapshotPhase, + branch: String, + }, + MetadataSnapshotCompleted { + phase: fabro_types::MetadataSnapshotPhase, + branch: String, + duration_ms: u64, + entry_count: usize, + bytes: u64, + commit_sha: String, + }, + MetadataSnapshotFailed { + phase: fabro_types::MetadataSnapshotPhase, + branch: String, + duration_ms: u64, + failure_kind: fabro_types::MetadataSnapshotFailureKind, + error: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + causes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + commit_sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + entry_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + exec_output_tail: Option, + }, + StageStarted { + node_id: String, + name: String, + index: usize, + handler_type: String, + attempt: usize, + max_attempts: usize, + }, + StageCompleted { + node_id: String, + name: String, + index: usize, + duration_ms: u64, + status: String, + preferred_label: Option, + suggested_next_ids: Vec, + billing: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + failure: Option, + notes: Option, + files_touched: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + context_updates: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + jump_to_node: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + context_values: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + node_visits: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + loop_failure_signatures: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + restart_failure_signatures: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + response: Option, + attempt: usize, + max_attempts: usize, + }, + StageFailed { + node_id: String, + name: String, + index: usize, + failure: FailureDetail, + will_retry: bool, + duration_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + StageRetrying { + node_id: String, + name: String, + index: usize, + attempt: usize, + max_attempts: usize, + delay_ms: u64, + }, + ParallelStarted { + node_id: String, + visit: u32, + branch_count: usize, + join_policy: String, + }, + ParallelBranchStarted { + parallel_group_id: StageId, + parallel_branch_id: ParallelBranchId, + branch: String, + index: usize, + }, + ParallelBranchCompleted { + parallel_group_id: StageId, + parallel_branch_id: ParallelBranchId, + branch: String, + index: usize, + duration_ms: u64, + status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + head_sha: Option, + }, + ParallelCompleted { + node_id: String, + visit: u32, + duration_ms: u64, + success_count: usize, + failure_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + results: Vec, + }, + InterviewStarted { + question_id: String, + question: String, + stage: String, + question_type: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + options: Vec, + #[serde(default)] + allow_freeform: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + context_display: Option, + }, + InterviewCompleted { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + question_id: String, + question: String, + answer: String, + duration_ms: u64, + }, + InterviewTimeout { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + question_id: String, + question: String, + stage: String, + duration_ms: u64, + }, + InterviewInterrupted { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + question_id: String, + question: String, + stage: String, + reason: String, + duration_ms: u64, + }, + CheckpointCompleted { + node_id: String, + status: String, + current_node: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + completed_nodes: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + node_retries: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + context_values: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + node_outcomes: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + next_node_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + git_commit_sha: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + loop_failure_signatures: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + restart_failure_signatures: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + node_visits: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + diff: Option, + }, + CheckpointFailed { + node_id: String, + error: String, + }, + GitCommit { + #[serde(default, skip_serializing_if = "Option::is_none")] + node_id: Option, + sha: String, + }, + GitPush { + branch: String, + success: bool, + }, + GitBranch { + branch: String, + sha: String, + }, + GitWorktreeAdd { + path: String, + branch: String, + }, + GitWorktreeRemove { + path: String, + }, + GitFetch { + branch: String, + success: bool, + }, + GitReset { + sha: String, + }, + EdgeSelected { + from_node: String, + to_node: String, + label: Option, + condition: Option, + /// Which selection step chose this edge (e.g. "condition", + /// "preferred_label", "jump"). + reason: String, + /// The stage's preferred label hint, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + preferred_label: Option, + /// The stage's suggested next node IDs, if any. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + suggested_next_ids: Vec, + /// The stage outcome status that influenced routing. + stage_status: String, + /// Whether this was a direct jump (bypassing normal edge selection). + is_jump: bool, + }, + LoopRestart { + from_node: String, + to_node: String, + }, + Prompt { + stage: String, + visit: u32, + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + }, + PromptCompleted { + node_id: String, + response: String, + model: String, + provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + billing: Option, + }, + /// Forwarded from an agent session, tagged with the workflow stage. + Agent { + stage: String, + visit: u32, + event: AgentEvent, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parent_session_id: Option, + }, + SubgraphStarted { + node_id: String, + start_node: String, + }, + SubgraphCompleted { + node_id: String, + steps_executed: usize, + status: String, + duration_ms: u64, + }, + /// Forwarded from a sandbox lifecycle operation. + Sandbox { + event: SandboxEvent, + }, + /// Emitted after the sandbox has been initialized (by engine lifecycle). + SandboxInitialized { + working_directory: String, + provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + identifier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + repo_cloned: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + clone_origin_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + clone_branch: Option, + }, + SetupStarted { + command_count: usize, + }, + SetupCommandStarted { + command: String, + index: usize, + }, + SetupCommandCompleted { + command: String, + index: usize, + exit_code: i32, + duration_ms: u64, + }, + SetupCompleted { + duration_ms: u64, + }, + SetupFailed { + command: String, + index: usize, + exit_code: i32, + stderr: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + exec_output_tail: Option, + }, + StallWatchdogTimeout { + node: String, + idle_seconds: u64, + }, + ArtifactCaptured { + node_id: String, + attempt: u32, + node_slug: String, + path: String, + mime: String, + content_md5: String, + content_sha256: String, + bytes: u64, + }, + SshAccessReady { + ssh_command: String, + }, + Failover { + stage: String, + from_provider: String, + from_model: String, + to_provider: String, + to_model: String, + error: String, + }, + CliEnsureStarted { + cli_name: String, + provider: String, + }, + CliEnsureCompleted { + cli_name: String, + provider: String, + already_installed: bool, + node_installed: bool, + duration_ms: u64, + }, + CliEnsureFailed { + cli_name: String, + provider: String, + error: String, + duration_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + exec_output_tail: Option, + }, + CommandStarted { + node_id: String, + script: String, + command: String, + language: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout_ms: Option, + }, + CommandCompleted { + node_id: String, + stdout: String, + stderr: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + exit_code: Option, + duration_ms: u64, + termination: CommandTermination, + stdout_bytes: u64, + stderr_bytes: u64, + streams_separated: bool, + live_streaming: bool, + }, + AgentCliStarted { + node_id: String, + visit: u32, + mode: String, + provider: String, + model: String, + command: String, + }, + AgentCliCompleted { + node_id: String, + stdout: String, + stderr: String, + exit_code: i32, + duration_ms: u64, + }, + PullRequestCreated { + pr_url: String, + pr_number: u64, + owner: String, + repo: String, + base_branch: String, + head_branch: String, + title: String, + draft: bool, + }, + PullRequestFailed { + error: String, + }, + DevcontainerResolved { + dockerfile_lines: usize, + environment_count: usize, + lifecycle_command_count: usize, + workspace_folder: String, + }, + DevcontainerLifecycleStarted { + phase: String, + command_count: usize, + }, + DevcontainerLifecycleCommandStarted { + phase: String, + command: String, + index: usize, + }, + DevcontainerLifecycleCommandCompleted { + phase: String, + command: String, + index: usize, + exit_code: i32, + duration_ms: u64, + }, + DevcontainerLifecycleCompleted { + phase: String, + duration_ms: u64, + }, + DevcontainerLifecycleFailed { + phase: String, + command: String, + index: usize, + exit_code: i32, + stderr: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + exec_output_tail: Option, + }, + RetroStarted { + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + }, + RetroCompleted { + duration_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + response: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + retro: Option, + }, + RetroFailed { + error: String, + duration_ms: u64, + }, +} + +impl Event { + pub fn pull_request_created(record: &PullRequestRecord, draft: bool) -> Self { + Self::PullRequestCreated { + pr_url: record.html_url.clone(), + pr_number: record.number, + owner: record.owner.clone(), + repo: record.repo.clone(), + base_branch: record.base_branch.clone(), + head_branch: record.head_branch.clone(), + title: record.title.clone(), + draft, + } + } + + pub fn trace(&self) { + use tracing::{debug, error, info, warn}; + match self { + Self::RunCreated { + run_id, run_dir, .. + } => { + info!(run_id = %run_id, run_dir, "Run created"); + } + Self::WorkflowRunStarted { name, run_id, .. } => { + info!(workflow = name.as_str(), run_id = %run_id, "Workflow run started"); + } + Self::RunSubmitted { definition_blob } => { + info!(?definition_blob, "Run submitted"); + } + Self::RunQueued => { + info!("Run queued"); + } + Self::RunStarting => { + info!("Run starting"); + } + Self::RunRunning => { + info!("Run running"); + } + Self::RunBlocked { blocked_reason } => { + info!(?blocked_reason, "Run blocked"); + } + Self::RunUnblocked => { + info!("Run unblocked"); + } + Self::RunRemoving => { + info!("Run removing"); + } + Self::RunCancelRequested { .. } => { + info!("Run cancel requested"); + } + Self::RunPauseRequested { .. } => { + info!("Run pause requested"); + } + Self::RunUnpauseRequested { .. } => { + info!("Run unpause requested"); + } + Self::RunPaused => { + info!("Run paused"); + } + Self::RunUnpaused => { + info!("Run unpaused"); + } + Self::RunSupersededBy { + new_run_id, + target_checkpoint_ordinal, + target_node_id, + target_visit, + } => { + info!( + %new_run_id, + target_checkpoint_ordinal, + target_node_id, + target_visit, + "Run superseded by new run" + ); + } + Self::RunArchived { actor } => { + info!(?actor, "Run archived"); + } + Self::RunUnarchived { actor } => { + info!(?actor, "Run unarchived"); + } + Self::WorkflowRunCompleted { + duration_ms, + artifact_count, + status, + .. + } => { + info!( + duration_ms, + artifact_count, status, "Workflow run completed" + ); + } + Self::WorkflowRunFailed { + error, duration_ms, .. + } => { + error!( + error = %error, + causes = ?error.causes(), + duration_ms, + "Workflow run failed" + ); + } + Self::RunNotice { + level, + code, + message, + } => match level { + RunNoticeLevel::Info => { + info!(code, message, "Run notice"); + } + RunNoticeLevel::Warn => { + warn!(code, message, "Run notice"); + } + RunNoticeLevel::Error => { + error!(code, message, "Run notice"); + } + }, + Self::MetadataSnapshotStarted { phase, branch } => { + debug!(%phase, branch, "Metadata snapshot started"); + } + Self::MetadataSnapshotCompleted { + phase, + branch, + duration_ms, + .. + } => { + info!(%phase, branch, duration_ms, "Metadata snapshot completed"); + } + Self::MetadataSnapshotFailed { + phase, + branch, + duration_ms, + failure_kind, + error, + exec_output_tail, + .. + } => { + let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); + warn!( + %phase, + branch, + duration_ms, + %failure_kind, + error, + exec_output_tail_present = tail.present, + exec_stdout_tail_bytes = tail.stdout_bytes, + exec_stderr_tail_bytes = tail.stderr_bytes, + exec_stdout_truncated = tail.stdout_truncated, + exec_stderr_truncated = tail.stderr_truncated, + "Metadata snapshot failed" + ); + } + Self::StageStarted { + node_id, + name, + index, + handler_type, + attempt, + max_attempts, + .. + } => { + info!( + node_id, + stage = name.as_str(), + index, + handler_type, + attempt, + max_attempts, + "Stage started" + ); + } + Self::StageCompleted { + node_id, + name, + index, + duration_ms, + status, + attempt, + max_attempts, + .. + } => { + info!( + node_id, + stage = name.as_str(), + index, + duration_ms, + status, + attempt, + max_attempts, + "Stage completed" + ); + } + Self::StageFailed { + node_id, + name, + index, + failure, + will_retry, + .. + } => { + let error_msg = &failure.message; + if *will_retry { + warn!( + node_id, + stage = name.as_str(), + index, + error = error_msg.as_str(), + will_retry, + "Stage failed" + ); + } else { + error!( + node_id, + stage = name.as_str(), + index, + error = error_msg.as_str(), + will_retry, + "Stage failed" + ); + } + } + Self::StageRetrying { + node_id, + name, + index, + attempt, + max_attempts, + delay_ms, + .. + } => { + warn!( + node_id, + stage = name.as_str(), + index, + attempt, + max_attempts, + delay_ms, + "Stage retrying" + ); + } + Self::ParallelStarted { + branch_count, + join_policy, + .. + } => { + debug!(branch_count, join_policy, "Parallel execution started"); + } + Self::ParallelBranchStarted { branch, index, .. } => { + debug!(branch, index, "Parallel branch started"); + } + Self::ParallelBranchCompleted { + branch, + index, + duration_ms, + status, + .. + } => { + debug!( + branch, + index, duration_ms, status, "Parallel branch completed" + ); + } + Self::ParallelCompleted { + duration_ms, + success_count, + failure_count, + results, + .. + } => { + debug!( + duration_ms, + success_count, + failure_count, + result_count = results.len(), + "Parallel execution completed" + ); + } + Self::InterviewStarted { + stage, + question_type, + .. + } => { + debug!(stage, question_type, "Interview started"); + } + Self::InterviewCompleted { duration_ms, .. } => { + debug!(duration_ms, "Interview completed"); + } + Self::InterviewTimeout { + stage, duration_ms, .. + } => { + warn!(stage, duration_ms, "Interview timeout"); + } + Self::InterviewInterrupted { + stage, + reason, + duration_ms, + .. + } => { + warn!(stage, reason, duration_ms, "Interview interrupted"); + } + Self::CheckpointCompleted { + node_id, + status, + completed_nodes, + .. + } => { + info!( + node_id, + status, + completed_count = completed_nodes.len(), + "Checkpoint completed" + ); + } + Self::CheckpointFailed { node_id, error } => { + error!(node_id, error, "Checkpoint failed"); + } + Self::GitCommit { node_id, sha } => { + debug!( + node_id = node_id.as_deref().unwrap_or(""), + sha, "Git commit" + ); + } + Self::GitPush { branch, success } => { + if *success { + debug!(branch, "Git push succeeded"); + } else { + warn!(branch, "Git push failed"); + } + } + Self::GitBranch { branch, sha } => { + debug!(branch, sha, "Git branch created"); + } + Self::GitWorktreeAdd { path, branch } => { + debug!(path, branch, "Git worktree added"); + } + Self::GitWorktreeRemove { path } => { + debug!(path, "Git worktree removed"); + } + Self::GitFetch { branch, success } => { + if *success { + debug!(branch, "Git fetch succeeded"); + } else { + warn!(branch, "Git fetch failed"); + } + } + Self::GitReset { sha } => { + debug!(sha, "Git reset"); + } + Self::EdgeSelected { + from_node, + to_node, + label, + reason, + .. + } => { + info!( + from_node, + to_node, + label = label.as_deref().unwrap_or(""), + reason, + "Edge selected" + ); + } + Self::LoopRestart { from_node, to_node } => { + debug!(from_node, to_node, "Loop restart"); + } + Self::Prompt { + stage, + text, + mode, + provider, + model, + .. + } => { + debug!( + stage, + text_len = text.len(), + mode = mode.as_deref().unwrap_or(""), + provider = provider.as_deref().unwrap_or(""), + model = model.as_deref().unwrap_or(""), + "Prompt sent" + ); + } + Self::PromptCompleted { + node_id, + model, + provider, + .. + } => { + debug!(node_id, model, provider, "Prompt completed"); + } + Self::Agent { .. } | Self::Sandbox { .. } => {} + Self::SandboxInitialized { + working_directory, + provider, + identifier, + .. + } => { + info!( + working_directory, + provider, + identifier = identifier.as_deref().unwrap_or(""), + "Sandbox initialized" + ); + } + Self::SubgraphStarted { + node_id, + start_node, + } => { + debug!(node_id, start_node, "Subgraph started"); + } + Self::SubgraphCompleted { + node_id, + steps_executed, + status, + duration_ms, + } => { + debug!( + node_id, + steps_executed, status, duration_ms, "Subgraph completed" + ); + } + Self::SetupStarted { command_count } => { + info!(command_count, "Setup started"); + } + Self::SetupCommandStarted { command, index } => { + debug!(command, index, "Setup command started"); + } + Self::SetupCommandCompleted { + command, + index, + exit_code, + duration_ms, + } => { + debug!( + command, + index, exit_code, duration_ms, "Setup command completed" + ); + } + Self::SetupCompleted { duration_ms } => { + info!(duration_ms, "Setup completed"); + } + Self::SetupFailed { + command, + index, + exit_code, + exec_output_tail, + .. + } => { + let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); + error!( + command, + index, + exit_code, + exec_output_tail_present = tail.present, + exec_stdout_tail_bytes = tail.stdout_bytes, + exec_stderr_tail_bytes = tail.stderr_bytes, + exec_stdout_truncated = tail.stdout_truncated, + exec_stderr_truncated = tail.stderr_truncated, + "Setup command failed" + ); + } + Self::StallWatchdogTimeout { node, idle_seconds } => { + warn!(node, idle_seconds, "Stall watchdog timeout"); + } + Self::ArtifactCaptured { + node_id, + node_slug, + attempt, + path, + bytes, + .. + } => { + debug!( + node_id, + node_slug, attempt, path, bytes, "Artifact captured" + ); + } + Self::SshAccessReady { ssh_command } => { + info!(ssh_command, "SSH access ready"); + } + Self::Failover { + stage, + from_provider, + from_model, + to_provider, + to_model, + error, + } => { + warn!( + stage, + from_provider, + from_model, + to_provider, + to_model, + error, + "LLM provider failover" + ); + } + Self::CliEnsureStarted { + cli_name, provider, .. + } => { + debug!(cli_name, provider, "CLI ensure started"); + } + Self::CliEnsureCompleted { + cli_name, + provider, + already_installed, + node_installed, + duration_ms, + } => { + info!( + cli_name, + provider, + already_installed, + node_installed, + duration_ms, + "CLI ensure completed" + ); + } + Self::CliEnsureFailed { + cli_name, + provider, + error, + duration_ms, + exec_output_tail, + } => { + let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); + error!( + cli_name, + provider, + error, + duration_ms, + exec_output_tail_present = tail.present, + exec_stdout_tail_bytes = tail.stdout_bytes, + exec_stderr_tail_bytes = tail.stderr_bytes, + exec_stdout_truncated = tail.stdout_truncated, + exec_stderr_truncated = tail.stderr_truncated, + "CLI ensure failed" + ); + } + Self::CommandStarted { + node_id, + language, + timeout_ms, + .. + } => { + debug!(node_id, language, timeout_ms, "Command started"); + } + Self::CommandCompleted { + node_id, + exit_code, + duration_ms, + termination, + stdout_bytes, + stderr_bytes, + .. + } => { + debug!( + node_id, + exit_code, + duration_ms, + termination = %termination, + stdout_bytes, + stderr_bytes, + "Command completed" + ); + } + Self::AgentCliStarted { + node_id, + provider, + model, + .. + } => { + debug!(node_id, provider, model, "Agent CLI started"); + } + Self::AgentCliCompleted { + node_id, + exit_code, + duration_ms, + .. + } => { + debug!(node_id, exit_code, duration_ms, "Agent CLI completed"); + } + Self::PullRequestCreated { + pr_url, + pr_number, + draft, + owner, + repo, + .. + } => { + info!(pr_url = %pr_url, pr_number, draft, owner, repo, "Pull request created"); + } + Self::PullRequestFailed { error, .. } => { + error!(error = %error, "Pull request creation failed"); + } + Self::DevcontainerResolved { + dockerfile_lines, + environment_count, + lifecycle_command_count, + workspace_folder, + } => { + info!( + dockerfile_lines, + environment_count, + lifecycle_command_count, + workspace_folder, + "Devcontainer resolved" + ); + } + Self::DevcontainerLifecycleStarted { + phase, + command_count, + } => { + info!(phase, command_count, "Devcontainer lifecycle started"); + } + Self::DevcontainerLifecycleCommandStarted { + phase, + command, + index, + } => { + debug!( + phase, + command, index, "Devcontainer lifecycle command started" + ); + } + Self::DevcontainerLifecycleCommandCompleted { + phase, + command, + index, + exit_code, + duration_ms, + } => { + debug!( + phase, + command, + index, + exit_code, + duration_ms, + "Devcontainer lifecycle command completed" + ); + } + Self::DevcontainerLifecycleCompleted { phase, duration_ms } => { + info!(phase, duration_ms, "Devcontainer lifecycle completed"); + } + Self::DevcontainerLifecycleFailed { + phase, + command, + index, + exit_code, + exec_output_tail, + .. + } => { + let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); + error!( + phase, + command, + index, + exit_code, + exec_output_tail_present = tail.present, + exec_stdout_tail_bytes = tail.stdout_bytes, + exec_stderr_tail_bytes = tail.stderr_bytes, + exec_stdout_truncated = tail.stdout_truncated, + exec_stderr_truncated = tail.stderr_truncated, + "Devcontainer lifecycle command failed" + ); + } + Self::RetroStarted { + prompt: _, + provider, + model, + } => { + info!( + provider = provider.as_deref().unwrap_or(""), + model = model.as_deref().unwrap_or(""), + "Retro started" + ); + } + Self::RetroCompleted { duration_ms, .. } => { + info!(duration_ms, "Retro completed"); + } + Self::RetroFailed { error, duration_ms } => { + error!(error = %error, duration_ms, "Retro failed"); + } + } + } +} diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs new file mode 100644 index 000000000..76d5dc98f --- /dev/null +++ b/lib/crates/fabro-workflow/src/event/names.rs @@ -0,0 +1,193 @@ +use fabro_agent::{AgentEvent, SandboxEvent}; + +use super::Event; + +#[must_use] +pub fn event_name(event: &Event) -> &'static str { + match event { + Event::RunCreated { .. } => "run.created", + Event::WorkflowRunStarted { .. } => "run.started", + Event::RunSubmitted { .. } => "run.submitted", + Event::RunQueued => "run.queued", + Event::RunStarting => "run.starting", + Event::RunRunning => "run.running", + Event::RunBlocked { .. } => "run.blocked", + Event::RunUnblocked => "run.unblocked", + Event::RunRemoving => "run.removing", + Event::RunCancelRequested { .. } => "run.cancel.requested", + Event::RunPauseRequested { .. } => "run.pause.requested", + Event::RunUnpauseRequested { .. } => "run.unpause.requested", + Event::RunPaused => "run.paused", + Event::RunUnpaused => "run.unpaused", + Event::RunSupersededBy { .. } => "run.superseded_by", + Event::RunArchived { .. } => "run.archived", + Event::RunUnarchived { .. } => "run.unarchived", + Event::WorkflowRunCompleted { .. } => "run.completed", + Event::WorkflowRunFailed { .. } => "run.failed", + Event::RunNotice { .. } => "run.notice", + Event::MetadataSnapshotStarted { .. } => "metadata.snapshot.started", + Event::MetadataSnapshotCompleted { .. } => "metadata.snapshot.completed", + Event::MetadataSnapshotFailed { .. } => "metadata.snapshot.failed", + Event::StageStarted { .. } => "stage.started", + Event::StageCompleted { .. } => "stage.completed", + Event::StageFailed { .. } => "stage.failed", + Event::StageRetrying { .. } => "stage.retrying", + Event::ParallelStarted { .. } => "parallel.started", + Event::ParallelBranchStarted { .. } => "parallel.branch.started", + Event::ParallelBranchCompleted { .. } => "parallel.branch.completed", + Event::ParallelCompleted { .. } => "parallel.completed", + Event::InterviewStarted { .. } => "interview.started", + Event::InterviewCompleted { .. } => "interview.completed", + Event::InterviewTimeout { .. } => "interview.timeout", + Event::InterviewInterrupted { .. } => "interview.interrupted", + Event::CheckpointCompleted { .. } => "checkpoint.completed", + Event::CheckpointFailed { .. } => "checkpoint.failed", + Event::GitCommit { .. } => "git.commit", + Event::GitPush { .. } => "git.push", + Event::GitBranch { .. } => "git.branch", + Event::GitWorktreeAdd { .. } => "git.worktree.added", + Event::GitWorktreeRemove { .. } => "git.worktree.removed", + Event::GitFetch { .. } => "git.fetch", + Event::GitReset { .. } => "git.reset", + Event::EdgeSelected { .. } => "edge.selected", + Event::LoopRestart { .. } => "loop.restart", + Event::Prompt { .. } => "stage.prompt", + Event::PromptCompleted { .. } => "prompt.completed", + Event::Agent { event, .. } => match event { + AgentEvent::SessionStarted { .. } => "agent.session.started", + AgentEvent::SessionEnded => "agent.session.ended", + AgentEvent::ProcessingEnd => "agent.processing.end", + AgentEvent::UserInput { .. } => "agent.input", + AgentEvent::AssistantTextStart => "agent.output.start", + AgentEvent::AssistantOutputReplace { .. } => "agent.output.replace", + AgentEvent::AssistantMessage { .. } => "agent.message", + AgentEvent::TextDelta { .. } => "agent.text.delta", + AgentEvent::ReasoningDelta { .. } => "agent.reasoning.delta", + AgentEvent::ToolCallStarted { .. } => "agent.tool.started", + AgentEvent::ToolCallOutputDelta { .. } => "agent.tool.output.delta", + AgentEvent::ToolCallCompleted { .. } => "agent.tool.completed", + AgentEvent::Error { .. } => "agent.error", + AgentEvent::Warning { .. } => "agent.warning", + AgentEvent::LoopDetected => "agent.loop.detected", + AgentEvent::TurnLimitReached { .. } => "agent.turn.limit", + AgentEvent::SkillExpanded { .. } => "agent.skill.expanded", + AgentEvent::SteeringInjected { .. } => "agent.steering.injected", + AgentEvent::CompactionStarted { .. } => "agent.compaction.started", + AgentEvent::CompactionCompleted { .. } => "agent.compaction.completed", + AgentEvent::LlmRetry { .. } => "agent.llm.retry", + AgentEvent::SubAgentSpawned { .. } => "agent.sub.spawned", + AgentEvent::SubAgentCompleted { .. } => "agent.sub.completed", + AgentEvent::SubAgentFailed { .. } => "agent.sub.failed", + AgentEvent::SubAgentClosed { .. } => "agent.sub.closed", + AgentEvent::McpServerReady { .. } => "agent.mcp.ready", + AgentEvent::McpServerFailed { .. } => "agent.mcp.failed", + }, + Event::SubgraphStarted { .. } => "subgraph.started", + Event::SubgraphCompleted { .. } => "subgraph.completed", + Event::Sandbox { event } => match event { + SandboxEvent::Initializing { .. } => "sandbox.initializing", + SandboxEvent::Ready { .. } => "sandbox.ready", + SandboxEvent::InitializeFailed { .. } => "sandbox.failed", + SandboxEvent::CleanupStarted { .. } => "sandbox.cleanup.started", + SandboxEvent::CleanupCompleted { .. } => "sandbox.cleanup.completed", + SandboxEvent::CleanupFailed { .. } => "sandbox.cleanup.failed", + SandboxEvent::SnapshotPulling { .. } => "sandbox.snapshot.pulling", + SandboxEvent::SnapshotPulled { .. } => "sandbox.snapshot.pulled", + SandboxEvent::SnapshotEnsuring { .. } => "sandbox.snapshot.ensuring", + SandboxEvent::SnapshotCreating { .. } => "sandbox.snapshot.creating", + SandboxEvent::SnapshotReady { .. } => "sandbox.snapshot.ready", + SandboxEvent::SnapshotFailed { .. } => "sandbox.snapshot.failed", + SandboxEvent::GitCloneStarted { .. } => "sandbox.git.started", + SandboxEvent::GitCloneCompleted { .. } => "sandbox.git.completed", + SandboxEvent::GitCloneFailed { .. } => "sandbox.git.failed", + }, + Event::SandboxInitialized { .. } => "sandbox.initialized", + Event::SetupStarted { .. } => "setup.started", + Event::SetupCommandStarted { .. } => "setup.command.started", + Event::SetupCommandCompleted { .. } => "setup.command.completed", + Event::SetupCompleted { .. } => "setup.completed", + Event::SetupFailed { .. } => "setup.failed", + Event::StallWatchdogTimeout { .. } => "watchdog.timeout", + Event::ArtifactCaptured { .. } => "artifact.captured", + Event::SshAccessReady { .. } => "ssh.ready", + Event::Failover { .. } => "agent.failover", + Event::CliEnsureStarted { .. } => "cli.ensure.started", + Event::CliEnsureCompleted { .. } => "cli.ensure.completed", + Event::CliEnsureFailed { .. } => "cli.ensure.failed", + Event::CommandStarted { .. } => "command.started", + Event::CommandCompleted { .. } => "command.completed", + Event::AgentCliStarted { .. } => "agent.cli.started", + Event::AgentCliCompleted { .. } => "agent.cli.completed", + Event::PullRequestCreated { .. } => "pull_request.created", + Event::PullRequestFailed { .. } => "pull_request.failed", + Event::DevcontainerResolved { .. } => "devcontainer.resolved", + Event::DevcontainerLifecycleStarted { .. } => "devcontainer.lifecycle.started", + Event::DevcontainerLifecycleCommandStarted { .. } => { + "devcontainer.lifecycle.command.started" + } + Event::DevcontainerLifecycleCommandCompleted { .. } => { + "devcontainer.lifecycle.command.completed" + } + Event::DevcontainerLifecycleCompleted { .. } => "devcontainer.lifecycle.completed", + Event::DevcontainerLifecycleFailed { .. } => "devcontainer.lifecycle.failed", + Event::RetroStarted { .. } => "retro.started", + Event::RetroCompleted { .. } => "retro.completed", + Event::RetroFailed { .. } => "retro.failed", + } +} + +#[cfg(test)] +mod tests { + use ::fabro_types::{ParallelBranchId, StageId}; + use fabro_agent::AgentEvent; + + use super::*; + use crate::event::Event; + + #[test] + fn event_name_matches_new_dot_notation() { + assert_eq!( + event_name(&Event::RetroStarted { + prompt: None, + provider: None, + model: None, + }), + "retro.started" + ); + assert_eq!( + event_name(&Event::ParallelBranchStarted { + parallel_group_id: StageId::new("plan", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("plan", 1), 0), + branch: "fork".to_string(), + index: 0, + }), + "parallel.branch.started" + ); + assert_eq!( + event_name(&Event::Agent { + stage: "code".to_string(), + visit: 1, + event: AgentEvent::SubAgentSpawned { + agent_id: "a1".to_string(), + depth: 1, + task: "do it".to_string(), + }, + session_id: None, + parent_session_id: None, + }), + "agent.sub.spawned" + ); + } + + #[test] + fn run_archived_event_name_matches_dot_notation() { + assert_eq!( + event_name(&Event::RunArchived { actor: None }), + "run.archived" + ); + assert_eq!( + event_name(&Event::RunUnarchived { actor: None }), + "run.unarchived" + ); + } +} diff --git a/lib/crates/fabro-workflow/src/event/redaction.rs b/lib/crates/fabro-workflow/src/event/redaction.rs new file mode 100644 index 000000000..cc738ee27 --- /dev/null +++ b/lib/crates/fabro-workflow/src/event/redaction.rs @@ -0,0 +1,81 @@ +use ::fabro_types::{RunEvent, RunId}; +use anyhow::{Context, Result}; +use fabro_redact::redact_json_value; +use fabro_store::EventPayload; +use fabro_util::json::normalize_json_value; +use serde_json::Value; + +pub fn build_redacted_event_payload(event: &RunEvent, run_id: &RunId) -> Result { + let value = redacted_event_value(event)?; + EventPayload::new(value, run_id).map_err(anyhow::Error::from) +} + +pub fn redacted_event_json(event: &RunEvent) -> Result { + serde_json::to_string(&redacted_event_value(event)?).map_err(anyhow::Error::from) +} + +fn normalized_event_value(event: &RunEvent) -> Result { + let value = event.to_value()?; + Ok(normalize_json_value(value)) +} + +fn redacted_event_value(event: &RunEvent) -> Result { + Ok(redact_json_value(normalized_event_value(event)?)) +} + +pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result { + let value = serde_json::from_str(line).context("Failed to parse redacted event payload")?; + EventPayload::new(value, run_id).map_err(anyhow::Error::from) +} + +#[cfg(test)] +mod tests { + use ::fabro_types::{fixtures, run_event as fabro_types}; + + use super::*; + use crate::event::{Event, to_run_event}; + + #[test] + fn build_redacted_event_payload_requires_id() { + let stored = to_run_event(&fixtures::RUN_8, &Event::RetroStarted { + prompt: Some("Analyze the run".to_string()), + provider: None, + model: None, + }); + let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); + assert_eq!(payload.as_value()["id"], stored.id); + assert_eq!(payload.as_value()["event"], "retro.started"); + assert_eq!( + payload.as_value()["properties"]["prompt"], + "Analyze the run" + ); + } + + #[test] + fn build_redacted_event_payload_redacts_exec_output_tail_values() { + let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"; + let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed { + command: "setup".to_string(), + index: 0, + exit_code: 1, + stderr: "compat stderr".to_string(), + exec_output_tail: Some(fabro_types::ExecOutputTail { + stdout: Some(format!("stdout {secret}")), + stderr: Some("plain stderr".to_string()), + stdout_truncated: false, + stderr_truncated: false, + }), + }); + + let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap(); + let payload_text = serde_json::to_string(payload.as_value()).unwrap(); + + assert!(!payload_text.contains(secret)); + assert!(payload_text.contains("REDACTED")); + assert_eq!(payload.as_value()["event"], "setup.failed"); + assert_eq!( + payload.as_value()["properties"]["exec_output_tail"]["stderr"], + "plain stderr" + ); + } +} diff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs new file mode 100644 index 000000000..e551a8533 --- /dev/null +++ b/lib/crates/fabro-workflow/src/event/sink.rs @@ -0,0 +1,339 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use ::fabro_types::{RunEvent, RunId}; +use anyhow::Result; +use fabro_store::RunDatabase; +use tokio::io::{AsyncWrite, AsyncWriteExt}; +use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot}; + +use super::emitter::Emitter; +use super::redaction::{build_redacted_event_payload, redacted_event_json}; +use super::{Event, to_run_event}; +use crate::runtime_store::RunStoreHandle; + +pub async fn append_event(run_store: &RunDatabase, run_id: &RunId, event: &Event) -> Result<()> { + let stored = to_run_event(run_id, event); + let payload = build_redacted_event_payload(&stored, run_id)?; + run_store + .append_event(&payload) + .await + .map(|_| ()) + .map_err(anyhow::Error::from) +} + +pub async fn append_event_to_sink( + sink: &RunEventSink, + run_id: &RunId, + event: &Event, +) -> Result<()> { + let stored = to_run_event(run_id, event); + sink.write_run_event(&stored).await +} + +#[derive(Clone)] +pub enum RunEventSink { + Store(RunStoreHandle), + JsonLines(Arc>>>), + Callback(Arc), + Map { + transform: Arc, + inner: Box, + }, + Composite(Vec), +} + +type RunEventSinkFuture = Pin> + Send + 'static>>; +type RunEventSinkCallback = dyn Fn(RunEvent) -> RunEventSinkFuture + Send + Sync + 'static; +type RunEventTransform = dyn Fn(RunEvent) -> RunEvent + Send + Sync + 'static; + +impl RunEventSink { + #[must_use] + pub fn store(run_store: RunDatabase) -> Self { + Self::Store(RunStoreHandle::local(run_store)) + } + + #[must_use] + pub fn backend(run_store: RunStoreHandle) -> Self { + Self::Store(run_store) + } + + #[must_use] + pub fn json_lines(writer: W) -> Self + where + W: AsyncWrite + Send + 'static, + { + Self::JsonLines(Arc::new(AsyncMutex::new(Box::pin(writer)))) + } + + #[must_use] + pub fn callback(callback: F) -> Self + where + F: Fn(RunEvent) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + Self::Callback(Arc::new(move |event| Box::pin(callback(event)))) + } + + #[must_use] + pub fn fanout(sinks: Vec) -> Self { + let mut flattened = Vec::new(); + for sink in sinks { + match sink { + Self::Composite(inner) => flattened.extend(inner), + other => flattened.push(other), + } + } + Self::Composite(flattened) + } + + #[must_use] + pub fn map(transform: F, inner: Self) -> Self + where + F: Fn(RunEvent) -> RunEvent + Send + Sync + 'static, + { + Self::Map { + transform: Arc::new(transform), + inner: Box::new(inner), + } + } + + pub async fn write_run_event(&self, event: &RunEvent) -> Result<()> { + let mut pending = vec![(self, event.clone())]; + while let Some((sink, event)) = pending.pop() { + match sink { + Self::Store(run_store) => { + run_store.append_run_event(&event).await?; + } + Self::JsonLines(writer) => { + let line = redacted_event_json(&event)?; + let mut writer = writer.lock().await; + writer.write_all(line.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + } + Self::Callback(callback) => callback(event).await?, + Self::Map { transform, inner } => { + pending.push((inner.as_ref(), transform(event))); + } + Self::Composite(sinks) => { + for sink in sinks.iter().rev() { + pending.push((sink, event.clone())); + } + } + } + } + Ok(()) + } +} + +#[allow( + clippy::large_enum_variant, + reason = "Logger queue messages stay inline to avoid boxing hot-path payloads." +)] +enum RunEventCommand { + Event(RunEvent), + Flush(oneshot::Sender<()>), +} + +#[derive(Clone)] +pub struct RunEventLogger { + tx: mpsc::UnboundedSender, +} + +impl RunEventLogger { + #[must_use] + pub fn new(sink: RunEventSink) -> Self { + let (tx, mut rx) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + while let Some(command) = rx.recv().await { + match command { + RunEventCommand::Event(event) => { + if let Err(err) = sink.write_run_event(&event).await { + tracing::warn!(error = %err, "Failed to write run event"); + } + } + RunEventCommand::Flush(tx) => { + let _ = tx.send(()); + } + } + } + }); + + Self { tx } + } + + pub fn register(&self, emitter: &Emitter) { + let tx = self.tx.clone(); + emitter.on_event(move |event| { + if tx.send(RunEventCommand::Event(event.clone())).is_err() { + tracing::warn!("Run event logger channel closed while forwarding event"); + } + }); + } + + pub async fn flush(&self) { + let (tx, rx) = oneshot::channel(); + if self.tx.send(RunEventCommand::Flush(tx)).is_err() { + tracing::warn!("Run event logger channel closed before flush"); + return; + } + if rx.await.is_err() { + tracing::warn!("Run event logger flush dropped before completion"); + } + } +} + +#[derive(Clone)] +pub struct StoreProgressLogger { + inner: RunEventLogger, +} + +impl StoreProgressLogger { + #[must_use] + pub fn new(run_store: impl Into) -> Self { + Self { + inner: RunEventLogger::new(RunEventSink::backend(run_store.into())), + } + } + + pub fn register(&self, emitter: &Emitter) { + self.inner.register(emitter); + } + + pub async fn flush(&self) { + self.inner.flush().await; + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use ::fabro_types::{AuthMethod, IdpIdentity, Principal, RunNoticeLevel, fixtures}; + use tokio::sync::Mutex as AsyncMutex; + + use super::*; + use crate::event::{ + Emitter, Event, build_redacted_event_payload, event_payload_from_redacted_json, + to_run_event, + }; + + fn user_principal(login: &str) -> Principal { + Principal::user( + IdpIdentity::new("https://github.com", "12345").unwrap(), + login.to_string(), + AuthMethod::Github, + ) + } + + #[tokio::test] + async fn append_event_writes_store_event_shape() { + let store = fabro_store::Database::new( + std::sync::Arc::new(object_store::memory::InMemory::new()), + "", + std::time::Duration::from_millis(1), + None, + ); + let run_store = store.create_run(&fixtures::RUN_7).await.unwrap(); + let stored = to_run_event(&fixtures::RUN_7, &Event::RunNotice { + level: RunNoticeLevel::Warn, + code: "example".to_string(), + message: "notice".to_string(), + }); + 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(); + let line = events + .into_iter() + .next() + .map(|event| event.event.to_value().unwrap()) + .unwrap(); + assert!(line.get("id").is_some()); + assert_eq!(line["event"], "run.notice"); + assert_eq!(line["properties"]["code"], "example"); + } + + #[tokio::test] + async fn run_event_sink_json_lines_writes_canonical_event_lines() { + use tokio::io::{AsyncBufReadExt, BufReader}; + + let (writer, reader) = tokio::io::duplex(4096); + let sink = RunEventSink::json_lines(writer); + let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None }); + + sink.write_run_event(&event).await.unwrap(); + + let mut reader = BufReader::new(reader); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + + let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_7).unwrap(); + assert_eq!(payload.as_value()["event"], "run.pause.requested"); + assert_eq!(payload.as_value()["properties"]["action"], "pause"); + } + + #[tokio::test] + async fn run_event_sink_map_applies_transform_before_fanout() { + let first = Arc::new(AsyncMutex::new(Vec::new())); + let second = Arc::new(AsyncMutex::new(Vec::new())); + let first_events = Arc::clone(&first); + let second_events = Arc::clone(&second); + let sink = RunEventSink::map( + |mut event| { + event.actor = Some(user_principal("alice")); + event + }, + RunEventSink::fanout(vec![ + RunEventSink::callback(move |event| { + let first_events = Arc::clone(&first_events); + async move { + first_events.lock().await.push(event); + Ok(()) + } + }), + RunEventSink::callback(move |event| { + let second_events = Arc::clone(&second_events); + async move { + second_events.lock().await.push(event); + Ok(()) + } + }), + ]), + ); + let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None }); + + sink.write_run_event(&event).await.unwrap(); + + let first = first.lock().await; + let second = second.lock().await; + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_eq!(first[0].actor, Some(user_principal("alice"))); + assert_eq!(second[0].actor, Some(user_principal("alice"))); + } + + #[tokio::test] + async fn run_event_logger_registers_emitter_events_to_json_lines() { + use tokio::io::{AsyncBufReadExt, BufReader}; + + let (writer, reader) = tokio::io::duplex(4096); + let sink = RunEventSink::json_lines(writer); + let logger = RunEventLogger::new(sink); + let emitter = Emitter::new(fixtures::RUN_8); + logger.register(&emitter); + + emitter.emit(&Event::RunPaused); + logger.flush().await; + + let mut reader = BufReader::new(reader); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + + let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_8).unwrap(); + assert_eq!(payload.as_value()["event"], "run.paused"); + } +} diff --git a/lib/crates/fabro-workflow/src/event/stored_fields.rs b/lib/crates/fabro-workflow/src/event/stored_fields.rs new file mode 100644 index 000000000..3c9fd5872 --- /dev/null +++ b/lib/crates/fabro-workflow/src/event/stored_fields.rs @@ -0,0 +1,218 @@ +use ::fabro_types::{ParallelBranchId, Principal, StageId, SystemActorKind}; +use fabro_agent::AgentEvent; + +use super::Event; +use crate::stage_scope::StageScope; + +#[derive(Debug, Default)] +pub(super) struct StoredEventFields { + pub(super) session_id: Option, + pub(super) parent_session_id: Option, + pub(super) node_id: Option, + pub(super) node_label: Option, + pub(super) stage_id: Option, + pub(super) parallel_group_id: Option, + pub(super) parallel_branch_id: Option, + pub(super) tool_call_id: Option, + pub(super) actor: Option, +} + +fn default_node_label(node_id: Option<&String>, node_label: Option) -> Option { + node_label.or_else(|| node_id.cloned()) +} + +fn node_stored_fields(node_id: Option) -> StoredEventFields { + let node_label = default_node_label(node_id.as_ref(), None); + StoredEventFields { + node_id, + node_label, + ..StoredEventFields::default() + } +} + +pub(super) fn stored_event_fields(event: &Event, scope: Option<&StageScope>) -> StoredEventFields { + let mut fields = stored_event_fields_for_variant(event); + if let Some(scope) = scope { + if fields.node_id.is_none() { + fields.node_id = Some(scope.node_id.clone()); + fields.node_label = default_node_label(Some(&scope.node_id), fields.node_label); + } + if fields.stage_id.is_none() { + fields.stage_id = Some(StageId::new(scope.node_id.clone(), scope.visit)); + } + if fields.parallel_group_id.is_none() { + fields + .parallel_group_id + .clone_from(&scope.parallel_group_id); + } + if fields.parallel_branch_id.is_none() { + fields + .parallel_branch_id + .clone_from(&scope.parallel_branch_id); + } + } + fields +} + +fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { + match event { + Event::RunCreated { provenance, .. } => StoredEventFields { + actor: provenance.as_ref().and_then(|p| p.subject.clone()), + ..StoredEventFields::default() + }, + Event::RunCancelRequested { actor } + | Event::RunPauseRequested { actor } + | Event::RunUnpauseRequested { actor } + | Event::RunArchived { actor } + | Event::RunUnarchived { actor, .. } + | Event::InterviewCompleted { actor, .. } => StoredEventFields { + actor: actor.clone(), + ..StoredEventFields::default() + }, + Event::StageCompleted { node_id, name, .. } + | Event::StageStarted { node_id, name, .. } + | Event::StageRetrying { node_id, name, .. } => { + let node_id_str = node_id.clone(); + let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); + StoredEventFields { + node_id: Some(node_id_str), + node_label, + ..StoredEventFields::default() + } + } + Event::StageFailed { + node_id, + name, + actor, + .. + } => { + let node_id_str = node_id.clone(); + let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); + StoredEventFields { + node_id: Some(node_id_str), + node_label, + actor: actor.clone(), + ..StoredEventFields::default() + } + } + Event::ParallelStarted { node_id, visit, .. } + | Event::ParallelCompleted { node_id, visit, .. } => { + let node_id_str = node_id.clone(); + let node_label = default_node_label(Some(&node_id_str), None); + let parallel_group_id = Some(StageId::new(node_id_str.clone(), *visit)); + StoredEventFields { + node_id: Some(node_id_str), + node_label, + parallel_group_id, + ..StoredEventFields::default() + } + } + Event::CheckpointCompleted { node_id, .. } + | Event::CheckpointFailed { node_id, .. } + | Event::SubgraphStarted { node_id, .. } + | Event::SubgraphCompleted { node_id, .. } + | Event::ArtifactCaptured { node_id, .. } + | Event::PromptCompleted { node_id, .. } + | Event::CommandStarted { node_id, .. } + | Event::CommandCompleted { node_id, .. } + | Event::AgentCliStarted { node_id, .. } + | Event::AgentCliCompleted { node_id, .. } => node_stored_fields(Some(node_id.clone())), + Event::Agent { + stage, + visit, + event: agent_event, + session_id, + parent_session_id, + } => { + let node_id = Some(stage.clone()); + let node_label = default_node_label(node_id.as_ref(), None); + let stage_id = Some(StageId::new(stage.clone(), *visit)); + let tool_call_id = agent_tool_call_id(agent_event).map(str::to_string); + let actor = agent_actor_for_event( + agent_event, + session_id.as_deref(), + parent_session_id.as_deref(), + ); + StoredEventFields { + session_id: session_id.clone(), + parent_session_id: parent_session_id.clone(), + node_id, + node_label, + stage_id, + tool_call_id, + actor, + ..StoredEventFields::default() + } + } + Event::GitCommit { node_id, .. } => node_stored_fields(node_id.clone()), + Event::ParallelBranchStarted { + parallel_group_id, + parallel_branch_id, + branch, + .. + } + | Event::ParallelBranchCompleted { + parallel_group_id, + parallel_branch_id, + branch, + .. + } => { + let node_id = Some(branch.clone()); + let node_label = default_node_label(node_id.as_ref(), None); + StoredEventFields { + node_id, + node_label, + parallel_group_id: Some(parallel_group_id.clone()), + parallel_branch_id: Some(parallel_branch_id.clone()), + ..StoredEventFields::default() + } + } + Event::Prompt { stage, .. } + | Event::InterviewStarted { stage, .. } + | Event::Failover { stage, .. } => node_stored_fields(Some(stage.clone())), + Event::InterviewTimeout { actor, stage, .. } + | Event::InterviewInterrupted { actor, stage, .. } => { + let mut fields = node_stored_fields(Some(stage.clone())); + fields.actor.clone_from(actor); + fields + } + Event::StallWatchdogTimeout { node, .. } => { + let mut fields = node_stored_fields(Some(node.clone())); + fields.actor = Some(Principal::System { + system_kind: SystemActorKind::Watchdog, + }); + fields + } + _ => StoredEventFields::default(), + } +} + +fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> { + match event { + AgentEvent::ToolCallStarted { tool_call_id, .. } + | AgentEvent::ToolCallCompleted { tool_call_id, .. } => Some(tool_call_id.as_str()), + _ => None, + } +} + +fn agent_actor_for_event( + event: &AgentEvent, + session_id: Option<&str>, + parent_session_id: Option<&str>, +) -> Option { + match event { + AgentEvent::AssistantMessage { model, .. } => Some(Principal::Agent { + session_id: session_id.map(str::to_string), + parent_session_id: parent_session_id.map(str::to_string), + model: Some(model.clone()), + }), + AgentEvent::ToolCallStarted { .. } + | AgentEvent::ToolCallOutputDelta { .. } + | AgentEvent::ToolCallCompleted { .. } => Some(Principal::Agent { + session_id: session_id.map(str::to_string), + parent_session_id: parent_session_id.map(str::to_string), + model: None, + }), + _ => None, + } +} diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs index 8543c60d4..9af074d97 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/crates/fabro-workflow/src/lib.rs @@ -152,6 +152,7 @@ pub mod runtime_store; pub mod sandbox_git; pub(crate) mod sandbox_git_runtime; pub mod services; +mod stage_scope; #[doc(hidden)] pub mod test_support; #[doc(hidden)] diff --git a/lib/crates/fabro-workflow/src/stage_scope.rs b/lib/crates/fabro-workflow/src/stage_scope.rs new file mode 100644 index 000000000..fa309044b --- /dev/null +++ b/lib/crates/fabro-workflow/src/stage_scope.rs @@ -0,0 +1,67 @@ +use fabro_types::{ParallelBranchId, StageId}; + +use crate::context::{Context as WfContext, WorkflowContext}; +use crate::run_dir::visit_from_context; + +/// Stage-level scope threaded through event emission to populate +/// `stage_id` / `parallel_group_id` / `parallel_branch_id` on events +/// that happen inside a concrete stage execution. +#[derive(Clone, Debug)] +pub struct StageScope { + pub node_id: String, + pub visit: u32, + pub parallel_group_id: Option, + pub parallel_branch_id: Option, +} + +impl StageScope { + /// Build a scope from the given node id, sourcing visit count and parallel + /// ids from the current context. + pub fn from_context(context: &WfContext, node_id: impl Into) -> Self { + Self { + node_id: node_id.into(), + visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), + parallel_group_id: context.parallel_group_id(), + parallel_branch_id: context.parallel_branch_id(), + } + } + + /// Build scope for a handler invocation. Prefers the `current_stage_scope` + /// seeded by the fidelity lifecycle `before_node` hook, and falls back to + /// synthesizing one from `node_id` for direct-handler call sites (tests, + /// etc.) that don't go through the full lifecycle. + pub fn for_handler(context: &WfContext, node_id: impl Into) -> Self { + context + .current_stage_scope() + .unwrap_or_else(|| Self::from_context(context, node_id)) + } + + /// Build scope for the branch-lifecycle events emitted by the parallel + /// handler (`ParallelBranchStarted`, `ParallelBranchCompleted`, and the + /// pre-dispatch `GitCommit` for the branch worktree). + /// + /// `target_visit` is the visit count of `target_node_id` for this + /// particular branch dispatch. The parallel handler currently passes + /// `1` because branches haven't been re-entered yet at the point of + /// scope construction; a future change that loops a parallel node + /// must pass the actual visit so envelope `stage_id`s stay accurate. + #[must_use] + pub fn for_parallel_branch( + target_node_id: impl Into, + target_visit: u32, + parallel_group_id: StageId, + parallel_branch_id: ParallelBranchId, + ) -> Self { + Self { + node_id: target_node_id.into(), + visit: target_visit, + parallel_group_id: Some(parallel_group_id), + parallel_branch_id: Some(parallel_branch_id), + } + } + + #[must_use] + pub fn stage_id(&self) -> StageId { + StageId::new(self.node_id.clone(), self.visit) + } +}