mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
Merge origin/main into main
Brings in the events schema v2 work (RunEvent envelope fields, ActorRef, parallel branch ids, flattened EventEnvelope wire JSON) on top of the local Stage 6 settings TOML redesign. Conflict resolutions: - fabro-types/src/lib.rs: keep new ParallelBranchId re-export from origin; drop the legacy Settings/ArtifactStorage* re-exports (the flat Settings struct was deleted in Stage 6.3b). - fabro-server/src/server.rs: keep new ActorRef import from origin; drop the unused legacy Settings import that came along with it. - fabro-api-client/src/models/web-settings.ts: keep our deletion. The remote modification was an incidental TS-client regeneration that Stage 6.6 already invalidated by collapsing settings DTOs to a freeform v2 shape. - fabro-workflow/src/event.rs: rewrite the run_created actor test to use SettingsFile::default() instead of the deleted Settings type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f79ca80591
47 changed files with 3391 additions and 571 deletions
376
docs-internal/event-schema-competitive-analysis.md
Normal file
376
docs-internal/event-schema-competitive-analysis.md
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# Event Schema Competitive Analysis
|
||||
|
||||
Date: 2026-04-08
|
||||
|
||||
This report compares the event schemas used by:
|
||||
|
||||
- Claude Sessions API
|
||||
- Claude Code
|
||||
- Goose
|
||||
- OpenAI Codex
|
||||
- OpenCode
|
||||
- pi-mono
|
||||
|
||||
Goal: identify patterns Fabro should copy, avoid, or formalize more clearly.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Fabro's current event model is already ahead of most of the field on one important point: it has a canonical envelope with stable metadata (`id`, `ts`, `run_id`, `event`, optional `session_id`, `parent_session_id`, `node_id`, `node_label`) and a typed internal-to-external mapping.
|
||||
|
||||
The biggest improvement opportunities are not "more events." They are:
|
||||
|
||||
1. Keep transport concerns separate from domain events, but document them as part of the contract.
|
||||
2. Make every streamed event part of one explicit public schema. Avoid opaque blobs and server-injected fields that the schema does not admit.
|
||||
3. Add more first-class correlation fields where the UI or downstream systems need them, especially `turn_id`, `message_id`, `tool_call_id`, `request_id`, and retry/attempt IDs.
|
||||
4. Make retry, stop, idle, and requires-action states machine-readable unions instead of loose strings.
|
||||
5. Be explicit about delta vs snapshot semantics and replay behavior.
|
||||
|
||||
## Fabro Baseline
|
||||
|
||||
Fabro's current strategy is documented in `docs-internal/events-strategy.md`. The canonical external shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuidv7",
|
||||
"ts": "2026-03-30T12:00:01.000Z",
|
||||
"run_id": "01JQ...",
|
||||
"event": "agent.tool.started",
|
||||
"session_id": "ses_child",
|
||||
"parent_session_id": "ses_parent",
|
||||
"node_id": "code",
|
||||
"node_label": "Code",
|
||||
"properties": { "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
That envelope is stronger than most comparator systems. It gives Fabro stable top-level metadata, keeps event-specific data inside `properties`, and avoids flattening arbitrary fields into the root.
|
||||
|
||||
Relevant current Fabro sources:
|
||||
|
||||
- `docs-internal/events-strategy.md`
|
||||
- `lib/crates/fabro-workflow/src/event.rs`
|
||||
- `lib/crates/fabro-types/src/run_event/mod.rs`
|
||||
- `lib/crates/fabro-agent/src/types.rs`
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| System | Public event surface | Discriminator | Universal envelope fields on every event | Replay / ordering story | Main strength | Main weakness |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Claude Sessions | 20-event public union | `type` | `id`, `processed_at` | Event IDs exist; replay semantics are not part of the event payload | Very explicit, stable union with typed nested states | Less transport detail and fewer workflow-specific events |
|
||||
| Claude Code | 24 core SDK messages, 31 stdout/control variants | `type`, often `subtype` | Usually `uuid`, `session_id`; no universal timestamp | Streaming is batched/coalesced; control and domain share the same channel | Rich task, hook, status, and tool progress | Nested stream payload is opaque at runtime; control and data are mixed |
|
||||
| Goose | 7 `MessageEvent` variants | `type` | None in JSON payload; SSE `id` is outside payload | Strong SSE replay via monotonic seq + `Last-Event-ID` + replay buffer | Reattach/replay semantics are clear | Transport fields are injected outside schema; payload typing is shallow |
|
||||
| Codex SDK | 8 thread events | `type` | No universal ID/timestamp | Ordered stream, no replay contract in payload | Very simple client model | Too minimal for rich UIs and analytics |
|
||||
| Codex app-server | 49 notification methods | `method` + `params` | No universal ID/timestamp in params | Ordered notifications, no seq/replay field | Richest low-level protocol in the set | Fragmented event story; transport shape leaks into the schema |
|
||||
| OpenCode | 45 generated event variants | `type` + `properties` | No universal ID/timestamp | Plain SSE; client supports SSE IDs but server does not emit them | Broadest app/runtime event coverage | Wire/schema drift and no universal envelope |
|
||||
| pi-mono | 12 assistant stream events, 10 agent events, 14 session events | `type` | Session header only; not per event | JSONL stream, no replay contract | Excellent streaming lifecycle grammar | No durable universal envelope for downstream consumers |
|
||||
|
||||
## System Notes
|
||||
|
||||
### Claude Sessions
|
||||
|
||||
What it does well:
|
||||
|
||||
- One explicit public union.
|
||||
- Every event has `id`, `type`, and `processed_at`.
|
||||
- Tool confirmation, custom tool results, MCP tool use, session errors, session status, and model-span events are all first-class.
|
||||
- Terminal and waiting states are structured. `session.status_idle.stop_reason` is not a loose string; it is a small union.
|
||||
- Error reporting is structured. `session.error.error` is a tagged union, not just a message.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- This is the cleanest example of a public agent-session event API that is still small enough to understand.
|
||||
- The main idea to copy is not the exact event list. It is the discipline: explicit tagged unions for stop reasons, errors, and status transitions.
|
||||
|
||||
Sources:
|
||||
|
||||
- `https://platform.claude.com/docs/en/api/beta/sessions/events/stream`
|
||||
- `https://platform.claude.com/docs/specs/merged.53db30dfcc06f431.json.gz`
|
||||
|
||||
### Claude Code
|
||||
|
||||
Schema shape:
|
||||
|
||||
- `SDKMessageSchema` contains 24 core message variants.
|
||||
- `StdoutMessageSchema` expands the stdout protocol to 31 variants once control messages and keep-alives are included.
|
||||
- Many events use `type: "system"` plus a `subtype`, for example `init`, `status`, `api_retry`, `hook_started`, `task_progress`, and `session_state_changed`.
|
||||
- Streaming assistant output is wrapped as `type: "stream_event"`.
|
||||
|
||||
What it does well:
|
||||
|
||||
- It covers more than just model output: task lifecycle, hook lifecycle, compaction boundaries, retries, authentication state, file persistence, tool progress, prompt suggestions.
|
||||
- It carries `uuid` and `session_id` widely, which is useful for correlation.
|
||||
- It includes explicit session-state transitions (`idle`, `running`, `requires_action`).
|
||||
|
||||
What is weak:
|
||||
|
||||
- The nested streaming payload is not explicitly validated at runtime. `RawMessageStreamEventPlaceholder` is `z.unknown()`.
|
||||
- Control protocol messages live in the same stream as domain messages.
|
||||
- `type: "system"` plus `subtype` is workable, but less ergonomic than a flatter public union.
|
||||
- There is no universal top-level timestamp on every event.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- Copy the breadth, not the shape.
|
||||
- Avoid opaque inner payloads in public schemas.
|
||||
- Avoid mixing keep-alive/control/config traffic into the same schema that product consumers use for analytics and UI rendering.
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/coreSchemas.ts`
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/controlSchemas.ts`
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/remote/sdkMessageAdapter.ts`
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/cli/transports/ccrClient.ts`
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/utils/sdkEventQueue.ts`
|
||||
|
||||
### Goose
|
||||
|
||||
Schema shape:
|
||||
|
||||
- One small SSE payload union: `Message`, `Error`, `Finish`, `Notification`, `UpdateConversation`, `ActiveRequests`, `Ping`.
|
||||
- SSE `id:` carries a monotonic sequence number.
|
||||
- Session replay uses `Last-Event-ID` plus a replay buffer.
|
||||
- `request_id` and `chat_request_id` are injected at the SSE framing layer, not modeled in the payload type.
|
||||
|
||||
What it does well:
|
||||
|
||||
- Clear reattach story.
|
||||
- Monotonic sequence numbers are transport-level, not payload-level.
|
||||
- `ActiveRequests` lets the client discover in-flight work when reconnecting.
|
||||
|
||||
What is weak:
|
||||
|
||||
- The public event payload omits fields the client actually consumes.
|
||||
- `Notification.message` is effectively an untyped object.
|
||||
- The session stream also emits comment heartbeats outside the schema, and there is a separate `Ping` payload variant in the shared enum. That split is easy to drift.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- Goose is the best example here for replay and reconnect semantics.
|
||||
- The lesson is not "put sequence numbers in the payload." The lesson is "formalize replay outside the payload, and do not rely on undocumented injected fields."
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/reply.rs`
|
||||
- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/session_events.rs`
|
||||
- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/session_event_bus.rs`
|
||||
- `/Users/bhelmkamp/p/block/goose/ui/desktop/openapi.json`
|
||||
- `/Users/bhelmkamp/p/block/goose/ui/desktop/src/hooks/useSessionEvents.ts`
|
||||
|
||||
### OpenAI Codex
|
||||
|
||||
There are really two event systems:
|
||||
|
||||
1. The TypeScript SDK `ThreadEvent` surface.
|
||||
2. The app-server `ServerNotification` protocol.
|
||||
|
||||
SDK shape:
|
||||
|
||||
- 8 high-level events: thread started, turn started/completed/failed, item started/updated/completed, fatal stream error.
|
||||
- Rich detail is pushed down into `ThreadItem`, which includes `agent_message`, `reasoning`, `command_execution`, `file_change`, `mcp_tool_call`, `web_search`, `todo_list`, and `error`.
|
||||
|
||||
App-server shape:
|
||||
|
||||
- 49 server notification methods.
|
||||
- Notifications are discriminated by `method`, with a typed `params` object.
|
||||
- Coverage includes thread lifecycle, turn lifecycle, item lifecycle, deltas, token usage, command output, MCP progress, model reroutes, config warnings, and experimental realtime notifications.
|
||||
|
||||
What it does well:
|
||||
|
||||
- Good separation of a simple developer-facing SDK from a richer system protocol.
|
||||
- The low-level protocol is broad and explicit.
|
||||
- Experimental notifications are clearly labeled as experimental.
|
||||
|
||||
What is weak:
|
||||
|
||||
- The event story is fragmented. "Which schema should I build against?" depends on which integration layer you pick.
|
||||
- There is no universal timestamp or universal event ID in the event bodies.
|
||||
- `method` + `params` is transport-shaped. It works well for JSON-RPC, but it is not as clean as a transport-agnostic event envelope.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- If Fabro needs both a high-level SDK and a low-level protocol, document the layering explicitly.
|
||||
- If Fabro only needs one event stream, a single canonical envelope is simpler than method-shaped notifications.
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/events.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/items.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/thread.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/src/protocol/common.rs`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/AgentMessageDeltaNotification.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts`
|
||||
|
||||
### OpenCode
|
||||
|
||||
Schema shape:
|
||||
|
||||
- One generated `Event` union with 45 variants.
|
||||
- `GlobalEvent` adds `directory` plus `payload: Event`.
|
||||
- Events use `type` plus a `properties` object.
|
||||
- Coverage includes questions, permissions, messages, message parts, session status/idle/compacted/error/diff, workspace readiness, PTYs, worktrees, VCS, file edits, MCP, TUI commands, and more.
|
||||
|
||||
What it does well:
|
||||
|
||||
- Broad coverage.
|
||||
- Generated API types from the server surface.
|
||||
- Clear split between session-scoped events and global events.
|
||||
- Status is partly structured. `SessionStatus` is a union of `idle`, `retry`, and `busy`.
|
||||
|
||||
What is weak:
|
||||
|
||||
- No universal event ID.
|
||||
- No universal timestamp.
|
||||
- No replay cursor or sequence field.
|
||||
- The wire stream emits `server.heartbeat`, but the generated `Event` union does not include it.
|
||||
- The global event schema says `directory` is present, but the initial global `server.connected` and heartbeat frames omit it.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- OpenCode shows how far a generated event surface can go.
|
||||
- It also shows the cost of not having a canonical envelope: clients must reconstruct correlation from nested `sessionID`, `messageID`, `partID`, and route-specific wrappers.
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/types.gen.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/server.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/routes/global.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/event.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/web/src/content/docs/server.mdx`
|
||||
|
||||
### pi-mono
|
||||
|
||||
Schema shape:
|
||||
|
||||
- `AssistantMessageEvent` has 12 streaming variants: `start`, block start/delta/end for text, thinking, and tool calls, then `done` or `error`.
|
||||
- `AgentEvent` has 10 lifecycle variants across agent, turn, message, and tool execution.
|
||||
- `AgentSessionEvent` extends `AgentEvent` with 4 session-only retry/compaction events.
|
||||
- JSON mode starts with a session header, then emits JSONL events.
|
||||
|
||||
What it does well:
|
||||
|
||||
- Excellent streaming lifecycle grammar.
|
||||
- Strong layering:
|
||||
- low-level assistant stream events
|
||||
- mid-level agent lifecycle events
|
||||
- high-level session events
|
||||
- The proxy mode has a bandwidth-optimized streaming shape that intentionally strips partial message snapshots and reconstructs them client-side.
|
||||
|
||||
What is weak:
|
||||
|
||||
- There is no universal per-event envelope.
|
||||
- There are no event IDs or replay semantics.
|
||||
- Timestamping is inconsistent. The session header has a timestamp, and some embedded message objects have timestamps, but not every event line does.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- pi-mono is the best example here for event layering and start/delta/end/done grammar.
|
||||
- Fabro should borrow that lifecycle discipline if it expands live agent streaming, but keep Fabro's stronger envelope.
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/ai/src/types.ts`
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/types.ts`
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/proxy.ts`
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/src/core/agent-session.ts`
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/docs/json.md`
|
||||
|
||||
## Cross-System Patterns
|
||||
|
||||
### Patterns worth copying
|
||||
|
||||
- One obvious discriminator per public event.
|
||||
- Explicit unions for error state, stop reason, retry state, and requires-action state.
|
||||
- Stable correlation IDs for session/thread/turn/message/tool levels.
|
||||
- A documented replay story for long-running streams.
|
||||
- Generated public schemas from one source of truth.
|
||||
- A clear distinction between snapshot events and delta events.
|
||||
|
||||
### Patterns worth avoiding
|
||||
|
||||
- Opaque `unknown` payloads inside otherwise typed events.
|
||||
- Server-injected fields that the public schema does not model.
|
||||
- Mixing keep-alives, control RPCs, and domain events in one event contract.
|
||||
- Event systems that only make sense in the context of one transport, for example JSON-RPC `method`/`params`, when the real need is a transport-agnostic event log.
|
||||
- No universal ID or timestamp on durable events.
|
||||
|
||||
## Recommendations For Fabro
|
||||
|
||||
### 1. Keep the canonical envelope
|
||||
|
||||
Fabro should keep `id`, `ts`, `run_id`, `event`, `session_id`, `parent_session_id`, `node_id`, and `node_label` exactly as the backbone of the public schema. That is already better than every comparator except Claude Sessions on consistency.
|
||||
|
||||
### 2. Do not let transport metadata leak informally
|
||||
|
||||
If Fabro supports SSE replay or live reattach, define transport rules explicitly:
|
||||
|
||||
- SSE `id`
|
||||
- replay cursor semantics
|
||||
- comment heartbeat vs payload heartbeat
|
||||
- reconnect guarantees
|
||||
|
||||
Do not make clients depend on extra fields injected by one server path that are absent from the formal schema.
|
||||
|
||||
### 3. Add deeper correlation IDs where the product needs them
|
||||
|
||||
Fabro already has run/session/node metadata. The next likely additions are:
|
||||
|
||||
- `turn_id`
|
||||
- `message_id`
|
||||
- `tool_call_id`
|
||||
- `request_id`
|
||||
- `attempt`
|
||||
|
||||
Those should be explicit schema fields, not encoded into ad hoc strings.
|
||||
|
||||
### 4. Prefer unions over stringly terminal state
|
||||
|
||||
If Fabro expands live session or agent events, model terminal and waiting states like Claude Sessions does:
|
||||
|
||||
- `stop_reason`
|
||||
- `retry_status`
|
||||
- `requires_action`
|
||||
- `error_kind`
|
||||
|
||||
Avoid free-form strings when a small tagged union will do.
|
||||
|
||||
### 5. Standardize lifecycle families
|
||||
|
||||
If Fabro emits live agent output, choose one lifecycle grammar and document it:
|
||||
|
||||
- `.started`
|
||||
- `.delta`
|
||||
- `.completed`
|
||||
- `.failed`
|
||||
|
||||
If snapshot replacement events also exist, mark them clearly and document when clients should treat them as authoritative replacement vs append-only updates.
|
||||
|
||||
### 6. Keep domain events separate from control and keep-alive traffic
|
||||
|
||||
Claude Code shows the downside of multiplexing control requests, control responses, keep-alives, and domain messages in one stream contract. Fabro's durable run events should stay product-facing and analyzable.
|
||||
|
||||
### 7. Add schema-drift tests for streamed events
|
||||
|
||||
OpenCode and Goose both show how easy it is for the wire stream to diverge from the published schema. Fabro should keep tests that validate:
|
||||
|
||||
- every emitted streamed payload is representable by the public schema
|
||||
- no consumer-visible fields are injected outside the schema
|
||||
- replay/heartbeat frames are documented and tested separately
|
||||
|
||||
## Bottom Line
|
||||
|
||||
Fabro does not need to copy any one competitor's schema wholesale.
|
||||
|
||||
The best composite design is:
|
||||
|
||||
- Claude Sessions' explicit unions for status and errors
|
||||
- Goose's replay semantics
|
||||
- Codex's separation between a simple high-level client view and a richer low-level view, if Fabro ever needs both
|
||||
- OpenCode's breadth of runtime events
|
||||
- pi-mono's streaming lifecycle grammar
|
||||
- Fabro's existing canonical envelope as the foundation
|
||||
|
||||
That combination would produce an event model that is both durable and ergonomic: good for live UI streaming, replay, analytics, tests, and long-term compatibility.
|
||||
484
docs-internal/fabro-event-schema-v2-concrete-shape.md
Normal file
484
docs-internal/fabro-event-schema-v2-concrete-shape.md
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
# Fabro Event Schema V2: Concrete Shape
|
||||
|
||||
Date: 2026-04-09
|
||||
|
||||
Status: implemented
|
||||
|
||||
This document turns the settled design decisions from the event-schema discussion into a concrete wire-contract proposal.
|
||||
|
||||
It intentionally supersedes the earlier framing in [fabro-event-schema-v2-proposal.md](/Users/bhelmkamp/p/fabro-sh/fabro/docs-internal/fabro-event-schema-v2-proposal.md) for:
|
||||
|
||||
- proposal 1: one canonical persisted log, not two truths
|
||||
- proposal 2: formalize and generalize the existing `since_seq` replay contract, rather than inventing replay from scratch
|
||||
|
||||
## Design Decisions Carried Forward
|
||||
|
||||
- one canonical persisted event log
|
||||
- plain hand-coded Rust structs are the authoritative source of truth for the event contract
|
||||
- `RunEvent` remains the canonical semantic event type
|
||||
- `seq` remains outside `RunEvent`, in the store/API envelope
|
||||
- replay stays built around ordered `since_seq` cursors
|
||||
- typed Rust consumers matching on `EventBody` remain the primary consumer model
|
||||
- the envelope widens only modestly for execution topology and tool-call correlation: `stage_id`, `parallel_group_id`, `parallel_branch_id`, `tool_call_id`
|
||||
- existing durable event families stay broadly intact
|
||||
- live token/delta noise does not become part of the durable persisted Rust event contract
|
||||
- snapshots are out of scope for both the durable event contract and the attach API
|
||||
|
||||
## Contract Source Of Truth
|
||||
|
||||
V2 does not adopt schema generation or a registry-first workflow.
|
||||
|
||||
The authoritative source of truth for the event contract should be plain, hand-coded Rust structs and enums that model the public wire shape directly.
|
||||
|
||||
Implications:
|
||||
|
||||
- the Rust event types are the canonical contract
|
||||
- this document describes that contract and should stay aligned with the Rust types
|
||||
- any TypeScript types, JSON Schema, or OpenAPI fragments are secondary artifacts, not the source of truth
|
||||
- codegen is explicitly out of scope for the initial V2 implementation
|
||||
|
||||
## Why Evolve The Current Model
|
||||
|
||||
V2 should evolve Fabro's existing event architecture rather than replace it with a generic event platform.
|
||||
|
||||
Earlier drafts of this document proposed a generic reducer contract, a larger ontology-first envelope, and a narrower replacement event catalog. V2 walks that back. The current code's boundary between internal workflow events, `RunEvent`, and `EventEnvelope` is stronger and simpler than it first appeared, so evolving that model is cheaper and clearer than replacing it.
|
||||
|
||||
The current code already has a strong separation of concerns:
|
||||
|
||||
- internal workflow/runtime events in `fabro-workflow`
|
||||
- one canonical semantic `RunEvent`
|
||||
- a store/API envelope that carries `seq` outside the event payload
|
||||
|
||||
That separation is worth preserving. The main V2 changes should be:
|
||||
|
||||
- modest envelope widening for execution topology
|
||||
- cleanup and clarification of event-family boundaries
|
||||
- keeping the durable event catalog semantic and typed
|
||||
|
||||
V2 should not introduce:
|
||||
|
||||
- a generic reducer contract based on `entity_type` / `event_role`
|
||||
- canonical persisted token deltas
|
||||
- snapshot events as a second truth layer
|
||||
|
||||
## Capability Coverage Decisions
|
||||
|
||||
V2 is evolutionary over the current `RunEvent` surface. It keeps the existing durable event families broadly intact rather than replacing them with a new ontology.
|
||||
|
||||
The main additions are:
|
||||
|
||||
- `stage_id` in the envelope for concrete stage execution identity
|
||||
- `parallel_group_id` in the envelope for one execution of a parallel node
|
||||
- `parallel_branch_id` in the envelope for one branch inside a parallel execution
|
||||
- `tool_call_id` in the envelope for agent tool lifecycle events that need a stable cross-family join key
|
||||
|
||||
Everything else should remain in typed `EventBody` props unless there is a strong cross-family reason to promote it. `session_id` already exists in the envelope today and stays as-is. `tool_call_id` is promoted now because `agent.tool.*` events already carry a stable tool-call identity that other durable families can reference when needed. `turn_id` is deferred because Fabro does not yet have a durable turn identity that spans the families that would need to join on it.
|
||||
|
||||
## Exact Delta From Current Code
|
||||
|
||||
This is the implementation delta from the current Rust codebase, not the full history of how the design was reached.
|
||||
|
||||
### Add
|
||||
|
||||
- add `stage_id: Option<String>` to `RunEvent`
|
||||
- add `parallel_group_id: Option<String>` to `RunEvent`
|
||||
- add `parallel_branch_id: Option<String>` to `RunEvent`
|
||||
- add `tool_call_id: Option<String>` to `RunEvent`
|
||||
- add `actor: Option<ActorRef>` to `RunEvent`
|
||||
- extend envelope extraction in `stored_event_fields()` to populate the new execution-topology fields when known
|
||||
- extend envelope extraction in `stored_event_fields()` to populate `tool_call_id` on tool-lifecycle events when known
|
||||
- update `RunEvent` serialization and parsing so the new optional envelope fields round-trip cleanly
|
||||
|
||||
### Keep As-Is
|
||||
|
||||
- `RunEvent` remains the canonical semantic event type
|
||||
- `EventBody` remains the typed tagged union of durable event families
|
||||
- `EventBody::Unknown` remains the compatibility valve for unknown event names on read
|
||||
- `EventEnvelope` remains the ordered outer wrapper with `seq` outside the event payload
|
||||
- `EventEnvelope.payload` remains `EventPayload`, not `RunEvent`
|
||||
- the internal/store `EventEnvelope` Rust type stays wrapped as `{ seq, payload }`
|
||||
- attach/replay remains exact ordered replay from `since_seq`, followed by live tailing
|
||||
- current durable event families stay broadly intact
|
||||
- live token/delta noise remains outside the durable persisted contract
|
||||
- snapshots remain out of scope
|
||||
|
||||
### Do Not Do
|
||||
|
||||
- do not inline `seq` into `RunEvent`
|
||||
- do not introduce `entity_type`, `entity_id`, or `event_role`
|
||||
- do not replace typed Rust consumers with a generic reducer model
|
||||
- do not redesign the store envelope
|
||||
- do not add snapshot events or attach-time synthetic snapshots
|
||||
- do not persist token deltas or other live UI noise as durable `RunEvent`s
|
||||
|
||||
## Canonical Rust Shapes
|
||||
|
||||
V2 should model the public contract directly as hand-coded Rust types, following the existing architecture.
|
||||
|
||||
```rust
|
||||
pub struct RunEvent {
|
||||
pub id: String,
|
||||
pub ts: DateTime<Utc>,
|
||||
pub run_id: RunId,
|
||||
pub node_id: Option<String>,
|
||||
pub node_label: Option<String>,
|
||||
pub stage_id: Option<String>,
|
||||
pub parallel_group_id: Option<String>,
|
||||
pub parallel_branch_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub parent_session_id: Option<String>,
|
||||
pub tool_call_id: Option<String>,
|
||||
pub actor: Option<ActorRef>,
|
||||
pub body: EventBody,
|
||||
}
|
||||
|
||||
pub struct EventEnvelope {
|
||||
pub seq: u32,
|
||||
pub payload: EventPayload,
|
||||
}
|
||||
|
||||
pub struct ActorRef {
|
||||
pub kind: ActorKind,
|
||||
pub id: Option<String>,
|
||||
pub display: Option<String>,
|
||||
}
|
||||
|
||||
pub enum ActorKind {
|
||||
User,
|
||||
Agent,
|
||||
System,
|
||||
}
|
||||
```
|
||||
|
||||
`RunEvent` remains the semantic product event. `EventEnvelope` remains the ordered store/API wrapper. The store continues to persist validated JSON `EventPayload`, not typed `RunEvent` structs.
|
||||
|
||||
For wire JSON, `EventEnvelope` should serialize in flattened form so clients see:
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": 4861,
|
||||
"id": "...",
|
||||
"ts": "...",
|
||||
"run_id": "...",
|
||||
"event": "...",
|
||||
"properties": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
That flattening is a wire concern only. It does not move `seq` into `RunEvent`, and it does not change the internal/store Rust shape of `EventEnvelope`.
|
||||
|
||||
`EventBody` remains a hand-coded tagged enum serialized as:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "stage.completed",
|
||||
"properties": { "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
V2 should also preserve the current unknown-event fallback shape:
|
||||
|
||||
```rust
|
||||
EventBody::Unknown {
|
||||
name: String,
|
||||
properties: serde_json::Value,
|
||||
}
|
||||
```
|
||||
|
||||
This fallback already exists in the current code and should be kept.
|
||||
|
||||
### Envelope Rules
|
||||
|
||||
- `id`, `ts`, `run_id`, and `event` are always present on the serialized `RunEvent`.
|
||||
- `seq` is not part of `RunEvent`. It stays in the outer `EventEnvelope`.
|
||||
- Optional envelope fields are omitted, never serialized as `null`.
|
||||
- The existing top-level envelope fields remain:
|
||||
- `node_id`
|
||||
- `node_label`
|
||||
- `session_id`
|
||||
- `parent_session_id`
|
||||
- V2 adds only these new optional envelope fields:
|
||||
- `stage_id`
|
||||
- `parallel_group_id`
|
||||
- `parallel_branch_id`
|
||||
- `tool_call_id`
|
||||
- Other relationship identifiers stay inside typed `properties`.
|
||||
- `turn_id` remains in typed `properties`; see the deferral decision in `Capability Coverage Decisions`.
|
||||
- `actor` is optional. When present, it identifies the primary actor for the event.
|
||||
- Set `actor` on human- or agent-initiated events where that identity matters to consumers. Example: `run.cancel.requested` should identify the user who initiated the cancel.
|
||||
- Set `actor` on durable agent output when the producing session identity matters. Example: `agent.message` should identify the agent session.
|
||||
- Omit `actor` for routine runtime events with no meaningful primary actor. Example: `stage.started`.
|
||||
|
||||
### ID Format Conventions
|
||||
|
||||
- `run_id` keeps Fabro's current format: an unprefixed ULID string.
|
||||
- `stage_id` keeps Fabro's current format: `"{node_id}@{visit}"`.
|
||||
- `node_id` is the stable graph node identifier from the workflow definition.
|
||||
- `parallel_group_id` should be the durable identity of one execution of a parallel node. The default format should be `"{node_id}@{visit}"`.
|
||||
- `parallel_branch_id` should be the durable identity of one branch within a parallel execution. The default format should be `"{parallel_group_id}:{index}"`.
|
||||
- Consumers should otherwise treat IDs as opaque strings.
|
||||
|
||||
### Presence Expectations
|
||||
|
||||
- `stage_id` is present on events tied to a concrete stage execution.
|
||||
- `parallel_group_id` is present on `parallel.*` events and on events emitted inside a parallel execution when that scope is known.
|
||||
- `parallel_branch_id` is present on `parallel.branch.*` events and on nested events emitted inside a specific branch when that scope is known.
|
||||
- `session_id` and `parent_session_id` keep their current meaning for forwarded agent/session activity.
|
||||
- `tool_call_id` is present on `agent.tool.*` events and on other durable events that directly describe the same tool call.
|
||||
- `node_label` remains in the envelope for display-oriented consumers.
|
||||
- `actor` is expected on control actions and durable agent output when there is a meaningful user or agent identity to expose. It is usually omitted on routine runtime lifecycle events.
|
||||
|
||||
## Consumer Model
|
||||
|
||||
Rust consumers should keep matching on `RunEvent.body` using typed `EventBody` variants.
|
||||
|
||||
This document does not adopt:
|
||||
|
||||
- `entity_type`
|
||||
- `entity_id`
|
||||
- `event_role`
|
||||
- a generic reducer contract
|
||||
|
||||
External JSON consumers should continue to:
|
||||
|
||||
- match on `"event"`
|
||||
- read event-specific values from `"properties"`
|
||||
- read `"seq"` from the flattened outer event envelope on API/SSE responses
|
||||
- use envelope metadata only for cross-cutting context such as stage, session, execution topology, and tool-call correlation
|
||||
|
||||
## Replay Contract
|
||||
|
||||
Fabro keeps the current replay model:
|
||||
|
||||
- ordered events are stored as `EventEnvelope { seq, payload }`
|
||||
- API/SSE serialization of `EventEnvelope` should flatten `seq` into the top-level JSON object returned to clients
|
||||
- attach starts from `since_seq`
|
||||
- the server replays exact persisted envelopes and then tails live envelopes while the run is active
|
||||
- SSE keepalive comments are transport frames, not events
|
||||
|
||||
V2 does not introduce:
|
||||
|
||||
- `run.snapshot`
|
||||
- `session.snapshot`
|
||||
- API-level attach snapshots
|
||||
- persisted snapshot events of any kind
|
||||
|
||||
The durable model remains simple: replay ordered events, no duplicate truth layer.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
An engineer implementing this proposal should make only these structural changes unless a later section explicitly says otherwise.
|
||||
|
||||
1. Update [`RunEvent`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) to add:
|
||||
- `stage_id`
|
||||
- `parallel_group_id`
|
||||
- `parallel_branch_id`
|
||||
- `tool_call_id`
|
||||
- `actor`
|
||||
2. Update `RunEvent::to_value()` and `RunEvent` parsing in [`run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) so the new envelope fields serialize and deserialize.
|
||||
3. Extend `StoredEventFields` and `stored_event_fields()` in [`event.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-workflow/src/event.rs) to populate:
|
||||
- `stage_id`
|
||||
- `parallel_group_id`
|
||||
- `parallel_branch_id`
|
||||
- `tool_call_id` on tool-lifecycle events
|
||||
- `actor` when there is a clear primary actor
|
||||
These values should come from the emitter's current execution context for stage and parallel scope, and from event-specific payloads for `tool_call_id`.
|
||||
4. Leave [`EventEnvelope`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-store/src/types.rs) structurally unchanged:
|
||||
- `seq: u32`
|
||||
- `payload: EventPayload`
|
||||
5. Update API/SSE envelope serialization so wire JSON is flattened:
|
||||
- top-level `seq`
|
||||
- then the `RunEvent` payload fields alongside it
|
||||
- no `"payload": { ... }` wrapper in JSON responses
|
||||
6. Leave the replay/attach flow unchanged in behavior:
|
||||
- persisted replay from `since_seq`
|
||||
- live tail after replay
|
||||
- no snapshots
|
||||
7. Keep the current `EventBody` family surface unless there is an explicit product reason to change a specific family.
|
||||
8. Keep streaming-noise agent events out of durable `RunEvent` conversion.
|
||||
9. Update the HTTP/API schema docs to reflect both:
|
||||
- new `RunEvent` envelope fields
|
||||
- flattened JSON serialization of `EventEnvelope`
|
||||
|
||||
## EventBody And Property Model
|
||||
|
||||
V2 should keep the current hand-coded domain split for prop structs:
|
||||
|
||||
- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/run.rs)
|
||||
- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/stage.rs)
|
||||
- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/agent.rs)
|
||||
- infra/setup/devcontainer props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/infra.rs)
|
||||
- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/misc.rs)
|
||||
|
||||
That split is part of the design quality. V2 should keep adding hand-coded prop structs, not collapse everything into generic maps.
|
||||
|
||||
## Durable Event Surface
|
||||
|
||||
V2 keeps the current durable family surface broadly intact.
|
||||
|
||||
### Run
|
||||
|
||||
- `run.created`
|
||||
- `run.started`
|
||||
- `run.submitted`
|
||||
- `run.starting`
|
||||
- `run.running`
|
||||
- `run.removing`
|
||||
- `run.cancel.requested`
|
||||
- `run.pause.requested`
|
||||
- `run.unpause.requested`
|
||||
- `run.paused`
|
||||
- `run.unpaused`
|
||||
- `run.rewound`
|
||||
- `run.completed`
|
||||
- `run.failed`
|
||||
- `run.notice`
|
||||
|
||||
### Stage And Prompt
|
||||
|
||||
- `stage.started`
|
||||
- `stage.completed`
|
||||
- `stage.failed`
|
||||
- `stage.retrying`
|
||||
- `stage.prompt`
|
||||
- `prompt.completed`
|
||||
|
||||
### Parallel
|
||||
|
||||
- `parallel.started`
|
||||
- `parallel.branch.started`
|
||||
- `parallel.branch.completed`
|
||||
- `parallel.completed`
|
||||
|
||||
### Interview / Human Input
|
||||
|
||||
- `interview.started`
|
||||
- `interview.completed`
|
||||
- `interview.timeout`
|
||||
- `interview.interrupted`
|
||||
|
||||
### Checkpoint
|
||||
|
||||
- `checkpoint.completed`
|
||||
- `checkpoint.failed`
|
||||
|
||||
### Agent Durable Events
|
||||
|
||||
- `agent.session.started`
|
||||
- `agent.session.ended`
|
||||
- `agent.processing.end`
|
||||
- `agent.input`
|
||||
- `agent.message`
|
||||
- `agent.tool.started`
|
||||
- `agent.tool.completed`
|
||||
- `agent.error`
|
||||
- `agent.warning`
|
||||
- `agent.loop.detected`
|
||||
- `agent.turn.limit`
|
||||
- `agent.steering.injected`
|
||||
- `agent.compaction.started`
|
||||
- `agent.compaction.completed`
|
||||
- `agent.llm.retry`
|
||||
- `agent.sub.spawned`
|
||||
- `agent.sub.completed`
|
||||
- `agent.sub.failed`
|
||||
- `agent.sub.closed`
|
||||
- `agent.mcp.ready`
|
||||
- `agent.mcp.failed`
|
||||
- `agent.failover`
|
||||
|
||||
### Git
|
||||
|
||||
- `git.commit`
|
||||
- `git.push`
|
||||
- `git.branch`
|
||||
- `git.worktree.added`
|
||||
- `git.worktree.removed`
|
||||
- `git.fetch`
|
||||
- `git.reset`
|
||||
|
||||
### Infra And Execution
|
||||
|
||||
- `sandbox.*`
|
||||
- `setup.*`
|
||||
- `cli.ensure.*`
|
||||
- `command.*`
|
||||
- `agent.cli.*`
|
||||
- `devcontainer.*`
|
||||
- `pull_request.*`
|
||||
- `artifact.captured`
|
||||
- `ssh.ready`
|
||||
- `subgraph.*`
|
||||
- `edge.selected`
|
||||
- `loop.restart`
|
||||
- `retro.*`
|
||||
|
||||
## Explicitly Non-Durable Streaming Noise
|
||||
|
||||
The current boundary that keeps live token/delta noise out of `RunEvent` should remain in place.
|
||||
|
||||
These stay outside the durable persisted contract:
|
||||
|
||||
- `agent.output.start`
|
||||
- `agent.output.replace`
|
||||
- `agent.text.delta`
|
||||
- `agent.reasoning.delta`
|
||||
- `agent.tool.output.delta`
|
||||
- `agent.skill.expanded`
|
||||
|
||||
`agent.skill.expanded` stays in this non-durable bucket because it is display-oriented expansion metadata, not a durable workflow fact.
|
||||
|
||||
If Fabro needs those for UI, they belong in a separate transient stream, not in the canonical persisted Rust event contract.
|
||||
|
||||
## Example Shapes
|
||||
|
||||
### Flattened Wire JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": 4861,
|
||||
"id": "evt_01JSE1N7RJD1NW2JSDT3W0YQ92",
|
||||
"ts": "2026-04-08T16:21:11.106Z",
|
||||
"run_id": "01JSE1M0Q0P8P6KQW9Q6D58Q0E",
|
||||
"event": "agent.tool.completed",
|
||||
"stage_id": "code@1",
|
||||
"node_id": "code",
|
||||
"node_label": "Code",
|
||||
"session_id": "ses_child",
|
||||
"tool_call_id": "call_1",
|
||||
"parent_session_id": "ses_parent",
|
||||
"properties": {
|
||||
"tool_name": "read_file",
|
||||
"output": {
|
||||
"summary": "Read docs-internal/events-strategy.md"
|
||||
},
|
||||
"is_error": false,
|
||||
"visit": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In Rust, `EventEnvelope` still remains `{ seq, payload: EventPayload }`. The example above is only the flattened API/SSE JSON form of that envelope.
|
||||
|
||||
## Practical Guidance
|
||||
|
||||
- Preserve the current one-time canonicalization boundary from internal `Event` to external `RunEvent`.
|
||||
- Keep `RunEvent` semantic and typed. Do not turn it into a generic reducer envelope.
|
||||
- Keep `seq` outside the event payload.
|
||||
- Widen the envelope only modestly: `stage_id`, `parallel_group_id`, `parallel_branch_id`, and `tool_call_id`.
|
||||
- Keep `session_id` as the existing top-level session field.
|
||||
- Keep event-specific detail inside typed props.
|
||||
- Preserve `EventBody::Unknown` as the compatibility valve for unknown event names on read.
|
||||
- Do not store token deltas or other live UI noise as durable `RunEvent`s.
|
||||
- Do not add snapshot events or attach-time synthetic snapshots.
|
||||
- When adding a new durable event, update the current Rust boundary cleanly:
|
||||
- internal `Event`
|
||||
- `event_name()`
|
||||
- envelope extraction
|
||||
- `EventBody`
|
||||
- typed props
|
||||
- affected consumers
|
||||
|
||||
## Open Follow-Up
|
||||
|
||||
- `correlation_id`-style cross-entity grouping remains deferred until Fabro has a concrete consumer and explicit propagation rules
|
||||
633
docs-internal/fabro-event-schema-v2-proposal.md
Normal file
633
docs-internal/fabro-event-schema-v2-proposal.md
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
# Fabro Event Schema V2 Proposal
|
||||
|
||||
Date: 2026-04-08
|
||||
|
||||
Status: proposal
|
||||
|
||||
Assumptions:
|
||||
|
||||
- greenfield redesign
|
||||
- no production deployments
|
||||
- no backward-compatibility constraints
|
||||
- optimize for the best long-term public event contract
|
||||
|
||||
This proposal turns the earlier ideation into concrete schema changes.
|
||||
|
||||
## Design Goal
|
||||
|
||||
Fabro should expose:
|
||||
|
||||
1. a durable, append-only event log for audit, storage, replay, and projections
|
||||
2. a separate live stream for UI-oriented snapshots, deltas, and fast progress
|
||||
|
||||
They should share IDs and correlation fields, but they should not be the same contract.
|
||||
|
||||
## Top 10 Concrete Improvements
|
||||
|
||||
### 1. Split the single event story into two concrete public APIs
|
||||
|
||||
#### Proposal
|
||||
|
||||
Introduce two top-level event contracts:
|
||||
|
||||
- `DurableEvent`
|
||||
- `LiveEvent`
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `GET /runs/:run_id/events`
|
||||
- append-only durable events
|
||||
- replayable
|
||||
- no keep-alive payload events
|
||||
- `GET /runs/:run_id/live`
|
||||
- live UI stream
|
||||
- snapshots + deltas + keep-alives
|
||||
- resumable with cursor
|
||||
|
||||
#### Durable event shape
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "durable",
|
||||
"id": "evt_01960d0c...",
|
||||
"seq": 182,
|
||||
"ts": "2026-04-08T15:01:02.123Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"event": "agent.tool.started",
|
||||
"session_id": "ses_123",
|
||||
"node_id": "code",
|
||||
"properties": {
|
||||
"tool_call_id": "tool_abc",
|
||||
"tool_name": "read_file",
|
||||
"arguments": { "path": "src/main.rs" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Live event shape
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "live",
|
||||
"id": "levt_01960d0d...",
|
||||
"seq": 991,
|
||||
"ts": "2026-04-08T15:01:03.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"event": "message.delta",
|
||||
"session_id": "ses_123",
|
||||
"message_id": "msg_456",
|
||||
"part_id": "part_1",
|
||||
"properties": {
|
||||
"block_type": "text",
|
||||
"delta": "Let me check that file..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Durable events stay stable and analyzable.
|
||||
- Live events can be noisy and UI-oriented without polluting projections.
|
||||
- Keeps Fabro from repeating the Claude Code / OpenCode problem of mixing control, transport, and product semantics.
|
||||
|
||||
### 2. Add explicit stream ordering, replay, and recovery semantics
|
||||
|
||||
#### Proposal
|
||||
|
||||
Every durable and live stream event gets:
|
||||
|
||||
- `seq: u64`
|
||||
- SSE `id:` = `seq`
|
||||
- replay semantics based on `Last-Event-ID`
|
||||
|
||||
Server rules:
|
||||
|
||||
- if `Last-Event-ID` is present and still buffered, replay `seq > cursor`
|
||||
- if cursor is too old, return a structured reset event in live streams and `409 replay_reset_required` in durable streams
|
||||
- durable streams never emit synthetic snapshots
|
||||
- live streams may start with a `*.snapshot` event after reconnect
|
||||
|
||||
#### New live-only events
|
||||
|
||||
- `stream.heartbeat`
|
||||
- `run.snapshot`
|
||||
- `session.snapshot`
|
||||
- `node.snapshot`
|
||||
- `stream.reset_required`
|
||||
|
||||
#### Example `stream.reset_required`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "live",
|
||||
"id": "levt_01960d0e...",
|
||||
"seq": 1200,
|
||||
"ts": "2026-04-08T15:02:00.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"event": "stream.reset_required",
|
||||
"properties": {
|
||||
"reason": "cursor_too_old",
|
||||
"expected_from_seq": 1170
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Reattach behavior becomes deterministic.
|
||||
- Clients no longer guess whether they missed data.
|
||||
- Replay is part of the contract, not an implementation detail.
|
||||
|
||||
### 3. Expand the envelope into a first-class correlation model
|
||||
|
||||
#### Proposal
|
||||
|
||||
Extend the shared envelope with these optional fields:
|
||||
|
||||
- `workflow_id`
|
||||
- `stage_id`
|
||||
- `branch_id`
|
||||
- `checkpoint_id`
|
||||
- `session_id`
|
||||
- `parent_session_id`
|
||||
- `turn_id`
|
||||
- `message_id`
|
||||
- `part_id`
|
||||
- `tool_call_id`
|
||||
- `request_id`
|
||||
- `causation_id`
|
||||
- `correlation_id`
|
||||
|
||||
Rules:
|
||||
|
||||
- `id` is the event's own identity
|
||||
- `causation_id` points to the immediate triggering event, if any
|
||||
- `correlation_id` groups a whole logical operation, for example one user request or one retry attempt tree
|
||||
- `request_id` is transport/API request scoped, not workflow scoped
|
||||
|
||||
#### Concrete change
|
||||
|
||||
Move these IDs out of ad hoc `properties` payloads when they are structural identifiers.
|
||||
|
||||
Good:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "agent.tool.completed",
|
||||
"tool_call_id": "tool_abc",
|
||||
"message_id": "msg_456",
|
||||
"properties": {
|
||||
"tool_name": "read_file",
|
||||
"is_error": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "agent.tool.completed",
|
||||
"properties": {
|
||||
"tool_call_id": "tool_abc",
|
||||
"message_id": "msg_456",
|
||||
"tool_name": "read_file"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Correlation becomes universal instead of event-family-specific.
|
||||
- UI and analytics consumers can join without parsing `properties`.
|
||||
- Parent/child agent and retry trees become much easier to reason about.
|
||||
|
||||
### 4. Replace stringly state with concrete tagged unions
|
||||
|
||||
#### Proposal
|
||||
|
||||
Define explicit union types for stateful fields.
|
||||
|
||||
Examples:
|
||||
|
||||
```ts
|
||||
type StopReason =
|
||||
| { type: "completed" }
|
||||
| { type: "requires_input"; request_id: string }
|
||||
| { type: "interrupted"; interrupt_reason: InterruptReason }
|
||||
| { type: "failed"; error_kind: ErrorKind }
|
||||
| { type: "retries_exhausted"; attempts: number };
|
||||
|
||||
type RetryStatus =
|
||||
| { type: "not_retrying" }
|
||||
| { type: "retry_scheduled"; attempt: number; next_retry_at: string }
|
||||
| { type: "retrying"; attempt: number }
|
||||
| { type: "retries_exhausted"; attempts: number };
|
||||
|
||||
type ApprovalStatus =
|
||||
| { type: "not_required" }
|
||||
| { type: "requested"; approval_id: string }
|
||||
| { type: "approved"; approval_id: string; actor: string }
|
||||
| { type: "denied"; approval_id: string; actor: string; reason?: string };
|
||||
```
|
||||
|
||||
#### Concrete fields to replace
|
||||
|
||||
- `status`
|
||||
- `reason`
|
||||
- `failure_class`
|
||||
- `interrupt_reason`
|
||||
- `stop_reason`
|
||||
- `approval_status`
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Eliminates string drift.
|
||||
- Makes reducers and policy engines much safer.
|
||||
- Makes test fixtures much more stable.
|
||||
|
||||
### 5. Standardize event family grammar across the entire product
|
||||
|
||||
#### Proposal
|
||||
|
||||
Use one lifecycle vocabulary:
|
||||
|
||||
- `.created`
|
||||
- `.started`
|
||||
- `.snapshot`
|
||||
- `.delta`
|
||||
- `.updated`
|
||||
- `.completed`
|
||||
- `.failed`
|
||||
- `.cancelled`
|
||||
- `.interrupted`
|
||||
- `.deleted`
|
||||
|
||||
Apply it consistently to the same kinds of things:
|
||||
|
||||
- `run.*`
|
||||
- `stage.*`
|
||||
- `session.*`
|
||||
- `turn.*`
|
||||
- `message.*`
|
||||
- `message.part.*`
|
||||
- `tool.*`
|
||||
- `command.*`
|
||||
- `checkpoint.*`
|
||||
- `parallel.branch.*`
|
||||
- `retro.*`
|
||||
|
||||
#### Concrete renames
|
||||
|
||||
Current style is already decent, but V2 should be stricter.
|
||||
|
||||
Examples:
|
||||
|
||||
- `agent.output.start` -> `message.part.started`
|
||||
- `agent.text.delta` -> `message.part.delta`
|
||||
- `agent.tool.output.delta` -> `tool.output.delta`
|
||||
- `agent.processing.end` -> `turn.completed` or `session.idle`, depending on actual semantics
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Consumers can infer behavior from naming alone.
|
||||
- Reduces one-off event families that encode bespoke lifecycle semantics.
|
||||
|
||||
### 6. Introduce typed content blocks and block-level deltas
|
||||
|
||||
#### Proposal
|
||||
|
||||
Represent streamable content as typed message parts.
|
||||
|
||||
Base union:
|
||||
|
||||
```ts
|
||||
type MessagePart =
|
||||
| { type: "text"; part_id: string; text: string }
|
||||
| { type: "reasoning"; part_id: string; text: string }
|
||||
| { type: "tool_call"; part_id: string; tool_call_id: string; tool_name: string; input: unknown }
|
||||
| { type: "tool_result"; part_id: string; tool_call_id: string; output: unknown; is_error: boolean }
|
||||
| { type: "patch"; part_id: string; patch_ref: string }
|
||||
| { type: "file_ref"; part_id: string; file_id: string; path: string }
|
||||
| { type: "artifact_ref"; part_id: string; artifact_id: string; label: string }
|
||||
| { type: "plan"; part_id: string; items: PlanItem[] }
|
||||
| { type: "todo"; part_id: string; items: TodoItem[] }
|
||||
| { type: "command_output"; part_id: string; command_id: string; stream: "stdout" | "stderr"; text: string };
|
||||
```
|
||||
|
||||
Live delta event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "message.part.delta",
|
||||
"message_id": "msg_456",
|
||||
"part_id": "part_1",
|
||||
"properties": {
|
||||
"part_type": "text",
|
||||
"delta": "checking src/main.rs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Durable completion event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "message.completed",
|
||||
"message_id": "msg_456",
|
||||
"properties": {
|
||||
"parts": [
|
||||
{ "type": "text", "part_id": "part_1", "text": "checking src/main.rs" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Supports rich UI without reparsing free-form text.
|
||||
- Supports structured summarization, compaction, and retro generation.
|
||||
- Aligns Fabro with the best parts of Claude Sessions and pi-mono.
|
||||
|
||||
### 7. Make approvals, questions, and operator interventions first-class durable events
|
||||
|
||||
#### Proposal
|
||||
|
||||
Add explicit event families:
|
||||
|
||||
- `approval.requested`
|
||||
- `approval.responded`
|
||||
- `question.asked`
|
||||
- `question.answered`
|
||||
- `interrupt.requested`
|
||||
- `interrupt.applied`
|
||||
- `resume.required`
|
||||
- `resume.applied`
|
||||
|
||||
#### Example `approval.requested`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "durable",
|
||||
"id": "evt_01960d0f...",
|
||||
"seq": 201,
|
||||
"ts": "2026-04-08T15:03:00.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"session_id": "ses_123",
|
||||
"tool_call_id": "tool_abc",
|
||||
"event": "approval.requested",
|
||||
"properties": {
|
||||
"approval_id": "apr_1",
|
||||
"scope": "tool_call",
|
||||
"tool_name": "exec_command",
|
||||
"request": {
|
||||
"cmd": "git push origin branch"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Example `approval.responded`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "durable",
|
||||
"id": "evt_01960d10...",
|
||||
"seq": 202,
|
||||
"ts": "2026-04-08T15:03:10.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"event": "approval.responded",
|
||||
"properties": {
|
||||
"approval_id": "apr_1",
|
||||
"result": {
|
||||
"type": "approved",
|
||||
"actor": "user"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Human-in-loop behavior becomes queryable and replayable.
|
||||
- Workflow interruption is no longer hidden in transport or UI state.
|
||||
|
||||
### 8. Add real snapshot events instead of relying on ad hoc reconstruction
|
||||
|
||||
#### Proposal
|
||||
|
||||
Define explicit snapshot events for live attach and projection recovery:
|
||||
|
||||
- `run.snapshot`
|
||||
- `session.snapshot`
|
||||
- `node.snapshot`
|
||||
- `checkpoint.saved`
|
||||
|
||||
#### Example `session.snapshot`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "live",
|
||||
"id": "levt_01960d11...",
|
||||
"seq": 1500,
|
||||
"ts": "2026-04-08T15:04:00.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"session_id": "ses_123",
|
||||
"event": "session.snapshot",
|
||||
"properties": {
|
||||
"state": { "type": "running" },
|
||||
"turn_id": "turn_9",
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "msg_456",
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{ "type": "text", "part_id": "part_1", "text": "checking src/main.rs" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"active_tool_calls": [
|
||||
{
|
||||
"tool_call_id": "tool_abc",
|
||||
"tool_name": "read_file",
|
||||
"status": "running"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Concrete rule
|
||||
|
||||
- snapshots are authoritative replacement state for live consumers
|
||||
- snapshots are optional in durable streams
|
||||
- checkpoints are durable domain snapshots, not just UI snapshots
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Fast attach becomes trivial.
|
||||
- Projections can self-heal from snapshots.
|
||||
- Checkpoint semantics become explicit rather than emergent.
|
||||
|
||||
### 9. Make model, tool, command, and MCP work first-class span families
|
||||
|
||||
#### Proposal
|
||||
|
||||
Create event families with shared semantics:
|
||||
|
||||
- `model.request.started`
|
||||
- `model.request.completed`
|
||||
- `model.request.failed`
|
||||
- `tool.started`
|
||||
- `tool.output.delta`
|
||||
- `tool.completed`
|
||||
- `tool.failed`
|
||||
- `command.started`
|
||||
- `command.output.delta`
|
||||
- `command.completed`
|
||||
- `command.failed`
|
||||
- `mcp.call.started`
|
||||
- `mcp.call.progress`
|
||||
- `mcp.call.completed`
|
||||
- `mcp.call.failed`
|
||||
|
||||
#### Example `model.request.completed`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "durable",
|
||||
"id": "evt_01960d12...",
|
||||
"seq": 220,
|
||||
"ts": "2026-04-08T15:05:00.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"session_id": "ses_123",
|
||||
"turn_id": "turn_9",
|
||||
"request_id": "req_llm_1",
|
||||
"event": "model.request.completed",
|
||||
"properties": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4",
|
||||
"latency_ms": 1834,
|
||||
"usage": {
|
||||
"input_tokens": 1400,
|
||||
"output_tokens": 380,
|
||||
"reasoning_tokens": 120,
|
||||
"cache_read_tokens": 900,
|
||||
"cache_write_tokens": 0
|
||||
},
|
||||
"retry_status": { "type": "not_retrying" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Cost and latency analysis become first-class.
|
||||
- Policy engines can reason about real operations, not just stage summaries.
|
||||
- Cross-provider comparison gets much easier.
|
||||
|
||||
### 10. Generate and enforce the public schema, docs, and examples from one registry
|
||||
|
||||
#### Proposal
|
||||
|
||||
Build a single `event_schema_registry` source that defines:
|
||||
|
||||
- envelope fields
|
||||
- event families
|
||||
- payload types
|
||||
- union types
|
||||
- versioning
|
||||
- example payloads
|
||||
|
||||
Artifacts generated from it:
|
||||
|
||||
- Rust types
|
||||
- TypeScript types
|
||||
- JSON Schema
|
||||
- OpenAPI / SSE docs
|
||||
- sample event fixtures
|
||||
- validation tests
|
||||
|
||||
#### Concrete rules
|
||||
|
||||
- every public event must have:
|
||||
- one schema definition
|
||||
- one example payload
|
||||
- one validation test
|
||||
- no endpoint may inject extra consumer-visible fields outside the schema
|
||||
- keep-alive frames are documented separately from payload events
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Prevents the OpenCode and Goose class of drift.
|
||||
- Makes Fabro's event API publishable and stable from day one.
|
||||
|
||||
## Recommended V2 Event Families
|
||||
|
||||
If Fabro were starting from scratch, I would structure the public families like this:
|
||||
|
||||
- `run.*`
|
||||
- `stage.*`
|
||||
- `checkpoint.*`
|
||||
- `parallel.branch.*`
|
||||
- `session.*`
|
||||
- `turn.*`
|
||||
- `message.*`
|
||||
- `message.part.*`
|
||||
- `model.request.*`
|
||||
- `tool.*`
|
||||
- `command.*`
|
||||
- `mcp.call.*`
|
||||
- `approval.*`
|
||||
- `question.*`
|
||||
- `interrupt.*`
|
||||
- `resume.*`
|
||||
- `compaction.*`
|
||||
- `retro.*`
|
||||
- `artifact.*`
|
||||
- `stream.*` (live only)
|
||||
|
||||
## Recommended Field Placement Rules
|
||||
|
||||
Top-level envelope:
|
||||
|
||||
- identity and correlation
|
||||
- ordering
|
||||
- timestamps
|
||||
- scope
|
||||
|
||||
`properties`:
|
||||
|
||||
- event-family-specific payload
|
||||
- business data
|
||||
- structured state payloads
|
||||
|
||||
Never in `properties` if they are structural:
|
||||
|
||||
- `run_id`
|
||||
- `seq`
|
||||
- `event`
|
||||
- `session_id`
|
||||
- `message_id`
|
||||
- `tool_call_id`
|
||||
- `request_id`
|
||||
- `causation_id`
|
||||
- `correlation_id`
|
||||
|
||||
## Bottom Line
|
||||
|
||||
The best greenfield version of Fabro is not "the current schema plus more events."
|
||||
|
||||
It is:
|
||||
|
||||
- separate durable and live contracts
|
||||
- replayable ordered streams
|
||||
- a richer envelope
|
||||
- typed state unions
|
||||
- typed content blocks
|
||||
- explicit snapshots
|
||||
- first-class HITL events
|
||||
- first-class span families
|
||||
- generated schema/docs/tests from one registry
|
||||
|
||||
That would give Fabro a better event platform than any of the compared systems.
|
||||
|
|
@ -2620,6 +2620,32 @@ components:
|
|||
items:
|
||||
$ref: "#/components/schemas/ErrorResponseEntry"
|
||||
|
||||
ActorKind:
|
||||
description: High-level category of an event actor.
|
||||
type: string
|
||||
enum:
|
||||
- user
|
||||
- agent
|
||||
- system
|
||||
|
||||
ActorRef:
|
||||
description: >
|
||||
Optional primary actor associated with a run event. Present on control
|
||||
actions and durable agent output where a stable user or agent identity
|
||||
matters; omitted on routine runtime lifecycle events.
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
kind:
|
||||
$ref: "#/components/schemas/ActorKind"
|
||||
id:
|
||||
type: string
|
||||
description: Stable actor identifier when available.
|
||||
display:
|
||||
type: string
|
||||
description: Display-friendly label for the actor.
|
||||
|
||||
RunEvent:
|
||||
description: >
|
||||
Internal RunEvent-compatible JSON payload. The server validates this
|
||||
|
|
@ -2644,12 +2670,39 @@ components:
|
|||
node_label:
|
||||
type: string
|
||||
nullable: true
|
||||
stage_id:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Stage execution identity, formatted as "{node_id}@{visit}".
|
||||
parallel_group_id:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >
|
||||
Durable identity of one execution of a parallel node, formatted as
|
||||
"{node_id}@{visit}".
|
||||
parallel_branch_id:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >
|
||||
Durable identity of one branch within a parallel execution,
|
||||
formatted as "{parallel_group_id}:{index}".
|
||||
session_id:
|
||||
type: string
|
||||
nullable: true
|
||||
parent_session_id:
|
||||
type: string
|
||||
nullable: true
|
||||
tool_call_id:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >
|
||||
Stable identifier for a tool call, present on agent.tool.* events
|
||||
and other durable events that directly describe the same tool
|
||||
call.
|
||||
actor:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ActorRef"
|
||||
nullable: true
|
||||
event:
|
||||
type: string
|
||||
description: Event type discriminator.
|
||||
|
|
@ -2659,19 +2712,25 @@ components:
|
|||
additionalProperties: true
|
||||
additionalProperties: true
|
||||
|
||||
EventEnvelope:
|
||||
description: Stored event envelope with assigned sequence number.
|
||||
EventSeq:
|
||||
description: Assigned sequence number component of a stored event envelope.
|
||||
type: object
|
||||
required:
|
||||
- seq
|
||||
- payload
|
||||
properties:
|
||||
seq:
|
||||
type: integer
|
||||
description: Assigned event sequence number.
|
||||
example: 42
|
||||
payload:
|
||||
$ref: "#/components/schemas/RunEvent"
|
||||
|
||||
EventEnvelope:
|
||||
description: >
|
||||
Stored event envelope with assigned sequence number. On the wire the
|
||||
envelope is flattened: seq sits alongside the RunEvent payload fields
|
||||
at the top level of the JSON object.
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/EventSeq"
|
||||
- $ref: "#/components/schemas/RunEvent"
|
||||
|
||||
PaginatedEventList:
|
||||
description: Paginated list of stored run events.
|
||||
|
|
|
|||
134
docs/ideation/2026-04-08-fabro-event-schema-ideation.md
Normal file
134
docs/ideation/2026-04-08-fabro-event-schema-ideation.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
---
|
||||
date: 2026-04-08
|
||||
topic: fabro-event-schema
|
||||
focus: greenfield redesign of Fabro's event schemas and streaming contract
|
||||
---
|
||||
|
||||
# Ideation: Fabro Event Schema Redesign
|
||||
|
||||
Assumption: greenfield reset. No production deployments, no backward-compatibility constraints, optimize for the best long-term event model rather than incremental migration cost.
|
||||
|
||||
These are not ten unrelated features. They are the ten strongest changes to make as one coherent event-platform redesign.
|
||||
|
||||
## Codebase Context
|
||||
|
||||
- Fabro already has the strongest core envelope in the comparison set: `id`, `ts`, `run_id`, `event`, optional `session_id`, `parent_session_id`, `node_id`, `node_label`, plus `properties`
|
||||
- `docs-internal/events-strategy.md` already treats events as the durable audit trail that powers storage, SSE, CLI progress, retro analysis, and JSONL sinks
|
||||
- `RunEvent` and `EventBody` already give Fabro a typed internal model, but the public contract is still more convention-driven than schema-first
|
||||
- Recent internal plans already push Fabro toward events as source of truth, checkpoint derivation, and simplified `RunEvent`, so the repo is directionally aligned with a stronger event platform
|
||||
- The biggest remaining gaps are cross-cutting ones: replay, correlation depth, public schema generation, live-stream semantics, and structured status/error/approval states
|
||||
|
||||
## Ranked Ideas
|
||||
|
||||
### 1. Split Fabro into two event products: a durable event log and a live UI stream
|
||||
**Description:** Define two first-class streams instead of one overloaded one. The durable log contains immutable domain facts suitable for audit, replay, storage, and projections. The live stream contains UI-oriented deltas, snapshots, keep-alives, and fast-changing progress. They share IDs and correlation fields, but they are different contracts.
|
||||
**Rationale:** This is the highest-leverage fix. Most competitors get into trouble by mixing audit events, high-frequency streaming deltas, control frames, and reconnect machinery into one schema. Fabro should not. Durable events should be boring and trustworthy. Live events should be optimized for interactivity.
|
||||
**Downsides:** Two contracts are more work than one. Emitters must decide whether an event is durable, live-only, or both.
|
||||
**Confidence:** 98%
|
||||
**Complexity:** High
|
||||
**Status:** Recommended
|
||||
|
||||
### 2. Add a formal replay and resume contract with ordered cursors and snapshot handshakes
|
||||
**Description:** Every run and session stream gets a monotonic `seq` plus a documented resume protocol: subscribe from cursor, receive the latest snapshot if needed, then apply deltas after `seq > cursor`. Define SSE `id` semantics, dedupe rules, replay buffer guarantees, and failure behavior when a client falls too far behind.
|
||||
**Rationale:** Goose is the clearest proof that reconnect semantics need to be part of the design, not a side effect. Greenfield Fabro should make "reattach to a long-running workflow" a first-class use case.
|
||||
**Downsides:** The server needs replay buffers or snapshot storage, and clients need to implement cursor logic correctly.
|
||||
**Confidence:** 96%
|
||||
**Complexity:** High
|
||||
**Status:** Recommended
|
||||
|
||||
### 3. Expand the envelope into a real correlation graph
|
||||
**Description:** Keep the existing strong envelope and add the IDs Fabro will actually want long term: `workflow_id`, `branch_id`, `checkpoint_id`, `turn_id`, `message_id`, `tool_call_id`, `request_id`, `causation_id`, and `correlation_id`. Not every event sets every field, but the contract makes those slots explicit.
|
||||
**Rationale:** Current Fabro is strong at run/session/node identity, but still thin below that. The next generation of debugging, UI, projection, and analytics work will want to join events by turn, message, tool call, branch, checkpoint, and request without reconstructing those edges from payloads.
|
||||
**Downsides:** Emitters and handlers must be stricter about ID ownership and propagation.
|
||||
**Confidence:** 95%
|
||||
**Complexity:** Medium
|
||||
**Status:** Recommended
|
||||
|
||||
### 4. Make the public event contract schema-first and generated from one registry
|
||||
**Description:** Keep Fabro's `event` + event-specific payload shape, but stop treating the public wire contract as implicit. Generate JSON Schema, TypeScript types, Rust validators, docs, and streaming examples from one source of truth. Every public event family gets an explicit schema version from day one.
|
||||
**Rationale:** This is the cleanest fix for drift. Claude Sessions benefits from having a clear public union. OpenCode shows how useful generated event types are. Fabro should combine both while preserving its stronger envelope.
|
||||
**Downsides:** Codegen and schema governance add process overhead. Some engineers will fight the discipline.
|
||||
**Confidence:** 94%
|
||||
**Complexity:** High
|
||||
**Status:** Recommended
|
||||
|
||||
### 5. Normalize lifecycle grammar across all streamable entities
|
||||
**Description:** Standardize event families around a small lifecycle vocabulary: `.started`, `.delta`, `.snapshot`, `.completed`, `.failed`, `.cancelled`, `.interrupted`. Apply it consistently to runs, stages, sessions, turns, messages, tool calls, commands, checkpoints, parallel branches, and retro work where relevant.
|
||||
**Rationale:** pi-mono is strongest here. Clients get dramatically simpler when every streamable thing follows the same lifecycle rules instead of bespoke one-off semantics.
|
||||
**Downsides:** Some event families will feel slightly unnatural if forced into the same lifecycle vocabulary. Discipline matters.
|
||||
**Confidence:** 93%
|
||||
**Complexity:** Medium
|
||||
**Status:** Recommended
|
||||
|
||||
### 6. Replace stringly status and failure fields with explicit tagged unions
|
||||
**Description:** Stop representing terminal and waiting states as loosely-typed strings where possible. Add typed unions for `stop_reason`, `retry_status`, `approval_status`, `wait_reason`, `error_kind`, `failure_class`, and `interrupt_reason`. Preserve display strings separately when useful.
|
||||
**Rationale:** Claude Sessions gets this exactly right. This is a major quality jump for policy engines, UIs, analytics, and test fixtures. It also reduces accidental schema drift where one code path emits `"timed_out"` and another emits `"timeout"`.
|
||||
**Downsides:** More up-front schema design. Adding new states later requires more care.
|
||||
**Confidence:** 96%
|
||||
**Complexity:** Medium
|
||||
**Status:** Recommended
|
||||
|
||||
### 7. Introduce typed content blocks and block-level deltas for agent output
|
||||
**Description:** Model agent-facing content as typed blocks instead of mostly strings: `text`, `reasoning`, `tool_call`, `tool_result`, `patch`, `file_ref`, `artifact_ref`, `plan`, `todo`, `command_output`, and `summary`. Live deltas target block IDs rather than appending ambiguous raw text.
|
||||
**Rationale:** This is the difference between a transcript that only humans can read and one that both humans and tools can reason over. It unlocks richer UIs, selective re-rendering, better retro analysis, and much cleaner summarization and compaction.
|
||||
**Downsides:** The model is more complex than "event name plus string payload." Poorly chosen block boundaries can make clients awkward.
|
||||
**Confidence:** 92%
|
||||
**Complexity:** High
|
||||
**Status:** Recommended
|
||||
|
||||
### 8. Make human-in-the-loop and control-plane semantics first-class events
|
||||
**Description:** Promote approvals, questions, interrupts, resumes, compactions, and operator interventions into explicit event families: `approval.requested`, `approval.responded`, `question.asked`, `question.answered`, `interrupt.requested`, `interrupt.applied`, `resume.required`, `compaction.started`, `compaction.completed`, `compaction.failed`.
|
||||
**Rationale:** This is one of the clearest wins from Claude Sessions and OpenCode. Human-in-loop behavior is not edge-case control traffic. It is core workflow state and deserves durable, typed representation.
|
||||
**Downsides:** It increases surface area. Some flows that are currently implicit must become explicit state machines.
|
||||
**Confidence:** 94%
|
||||
**Complexity:** Medium
|
||||
**Status:** Recommended
|
||||
|
||||
### 9. Add first-class snapshot events for fast attach and projection repair
|
||||
**Description:** Introduce self-contained snapshot events such as `run.snapshot`, `session.snapshot`, and `checkpoint.saved` that intentionally duplicate enough state to let clients and projectors reattach without replaying the full history. Treat these as part of the contract, not ad hoc recovery hacks.
|
||||
**Rationale:** This complements replay. Durable facts remain append-only, but long-running workflows need efficient recovery points. Greenfield Fabro can design snapshots deliberately instead of letting checkpoint semantics and UI recovery drift apart.
|
||||
**Downsides:** Snapshot compaction and retention rules must be explicit or the event system becomes harder to reason about.
|
||||
**Confidence:** 90%
|
||||
**Complexity:** High
|
||||
**Status:** Recommended
|
||||
|
||||
### 10. Promote model, tool, and command work into first-class span-style event families
|
||||
**Description:** Treat model requests, tool execution, MCP calls, shell commands, and patch application as first-class event families with started/completed/error plus usage, latency, retries, provider, model, routing, approval outcome, and output references. Do not hide these behind generic stage completion summaries.
|
||||
**Rationale:** Fabro is an AI workflow product. The event model should expose the actual unit economics and failure surfaces of AI work. This gives better debugging, cost analysis, policy enforcement, and product telemetry than aggregating everything back into stage summaries.
|
||||
**Downsides:** More event volume. Care is needed to keep live deltas separate from durable summaries.
|
||||
**Confidence:** 93%
|
||||
**Complexity:** Medium
|
||||
**Status:** Recommended
|
||||
|
||||
## What This Adds Up To
|
||||
|
||||
If Fabro adopted all ten, the resulting idealized model would look like this:
|
||||
|
||||
- one durable event log for facts
|
||||
- one live stream for interactive state
|
||||
- one shared envelope with strong correlation IDs
|
||||
- one generated public schema registry
|
||||
- one consistent lifecycle grammar
|
||||
- one explicit replay/snapshot story
|
||||
- typed blocks and typed states instead of strings and ad hoc payloads
|
||||
|
||||
That is materially better than any single comparator repo.
|
||||
|
||||
## Rejection Summary
|
||||
|
||||
| # | Idea | Reason Rejected |
|
||||
|---|------|-----------------|
|
||||
| 1 | Keep one stream and just document it better | Not enough — durable and live concerns have different optimization goals |
|
||||
| 2 | Flatten all event-specific fields into the top level | Root churn would make the schema worse, not better |
|
||||
| 3 | Switch to JSON-RPC-style `method`/`params` notifications | Too transport-shaped for Fabro's broader event-log use case |
|
||||
| 4 | Use timestamps alone for replay ordering | Weak contract; reconnect needs explicit sequence semantics |
|
||||
| 5 | Eventize binary artifacts and all large blobs directly | Expensive and noisy; use refs/metadata instead |
|
||||
| 6 | Keep string errors but standardize message text | Still not machine-readable enough |
|
||||
| 7 | Make snapshots the only source of truth | Loses auditability and event-sourced advantages |
|
||||
| 8 | Encode every UI concern in the durable log | Durable logs should stay trustworthy and projection-friendly |
|
||||
| 9 | Preserve current schema and only add more event names | Misses the deeper contract problems |
|
||||
| 10 | Treat approvals and questions as transport-level control traffic | These are product-level workflow semantics and belong in the event model |
|
||||
|
||||
## Session Log
|
||||
|
||||
- 2026-04-08: Grounded ideation from current Fabro event docs and code, plus comparison against Claude Sessions, Claude Code, Goose, OpenAI Codex, OpenCode, and pi-mono. Survivors intentionally optimized for greenfield quality rather than migration ease.
|
||||
|
|
@ -198,8 +198,13 @@ fn run_event(run_id: fabro_types::RunId, node_id: Option<String>, body: EventBod
|
|||
run_id,
|
||||
node_id,
|
||||
node_label: None,
|
||||
stage_id: None,
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor: None,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ mod tests {
|
|||
use fabro_agent::{AgentEvent, SandboxEvent};
|
||||
use fabro_llm::types::TokenCounts;
|
||||
use fabro_model::Provider;
|
||||
use fabro_types::fixtures;
|
||||
use fabro_types::{ParallelBranchId, StageId, fixtures};
|
||||
use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
|
||||
use fabro_workflow::outcome::billed_model_usage_from_llm;
|
||||
|
||||
|
|
@ -561,6 +561,8 @@ mod tests {
|
|||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchStarted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
},
|
||||
|
|
@ -576,6 +578,8 @@ mod tests {
|
|||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchCompleted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
duration_ms: 2000,
|
||||
|
|
@ -607,6 +611,8 @@ mod tests {
|
|||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchStarted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
},
|
||||
|
|
@ -1108,6 +1114,8 @@ mod tests {
|
|||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchStarted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
},
|
||||
|
|
@ -1115,6 +1123,8 @@ mod tests {
|
|||
emit(
|
||||
&mut ui,
|
||||
Event::ParallelBranchCompleted {
|
||||
parallel_group_id: StageId::new("fork1", 1),
|
||||
parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0),
|
||||
branch: "security".into(),
|
||||
index: 0,
|
||||
duration_ms: 500,
|
||||
|
|
@ -1149,6 +1159,7 @@ mod tests {
|
|||
max_attempts: 1,
|
||||
},
|
||||
started_ts,
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let tool_started = serde_json::to_string(&to_run_event_at(
|
||||
|
|
@ -1162,6 +1173,7 @@ mod tests {
|
|||
},
|
||||
),
|
||||
started_ts,
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let tool_completed = serde_json::to_string(&to_run_event_at(
|
||||
|
|
@ -1176,6 +1188,7 @@ mod tests {
|
|||
},
|
||||
),
|
||||
completed_ts,
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ impl RunAttachEventStream {
|
|||
|
||||
fn buffer_sse_events(&mut self, finalize: bool) -> Result<()> {
|
||||
for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) {
|
||||
let event: types::EventEnvelope = serde_json::from_str(&payload)?;
|
||||
self.buffered_events.push_back(convert_type(event)?);
|
||||
self.buffered_events
|
||||
.push_back(serde_json::from_str(&payload)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -410,7 +410,7 @@ impl ServerStoreClient {
|
|||
let page_events = parsed
|
||||
.data
|
||||
.into_iter()
|
||||
.map(convert_type)
|
||||
.map(convert_type::<_, EventEnvelope>)
|
||||
.collect::<Result<Vec<EventEnvelope>>>()?;
|
||||
let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1));
|
||||
all_events.extend(page_events);
|
||||
|
|
|
|||
|
|
@ -671,6 +671,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"max_attempts": 1
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "start@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -698,6 +699,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"status": "success"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "start@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -748,6 +750,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"status": "success"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "start@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -762,6 +765,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"max_attempts": 1
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "approve@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -787,6 +791,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"stage": "approve"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "approve@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -90,8 +90,13 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() {
|
|||
run_id,
|
||||
node_id: None,
|
||||
node_label: None,
|
||||
stage_id: None,
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor: None,
|
||||
body: EventBody::PullRequestCreated(PullRequestCreatedProps {
|
||||
pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
|
||||
pr_number: 123,
|
||||
|
|
|
|||
|
|
@ -74,16 +74,14 @@ fn remote_run_state_response() -> serde_json::Value {
|
|||
fn run_completed_event(run_id: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"seq": 1,
|
||||
"payload": {
|
||||
"event": "run.completed",
|
||||
"id": "evt-run-completed",
|
||||
"run_id": run_id,
|
||||
"ts": "2026-04-05T12:00:01Z",
|
||||
"properties": {
|
||||
"duration_ms": 12,
|
||||
"artifact_count": 0,
|
||||
"status": "success"
|
||||
}
|
||||
"event": "run.completed",
|
||||
"id": "evt-run-completed",
|
||||
"run_id": run_id,
|
||||
"ts": "2026-04-05T12:00:01Z",
|
||||
"properties": {
|
||||
"duration_ms": 12,
|
||||
"artifact_count": 0,
|
||||
"status": "success"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -91,13 +89,11 @@ fn run_completed_event(run_id: &str) -> serde_json::Value {
|
|||
fn run_running_event(run_id: &str, seq: u32) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"seq": seq,
|
||||
"payload": {
|
||||
"event": "run.running",
|
||||
"id": format!("evt-run-running-{seq}"),
|
||||
"run_id": run_id,
|
||||
"ts": "2026-04-05T12:00:00Z",
|
||||
"properties": {}
|
||||
}
|
||||
"event": "run.running",
|
||||
"id": format!("evt-run-running-{seq}"),
|
||||
"run_id": run_id,
|
||||
"ts": "2026-04-05T12:00:00Z",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1040,6 +1036,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"max_attempts": 1
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "start@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1067,6 +1064,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"status": "success"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "start@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1117,6 +1115,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"status": "success"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "start@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1131,6 +1130,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"max_attempts": 1
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "approve@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1156,11 +1156,14 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"stage": "approve"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "approve@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"event": "interview.completed",
|
||||
"id": "[EVENT_ID]",
|
||||
"node_id": "approve",
|
||||
"node_label": "approve",
|
||||
"properties": {
|
||||
"answer": "A",
|
||||
"duration_ms": "[DURATION_MS]",
|
||||
|
|
@ -1168,6 +1171,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"question_id": "[ULID]"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "approve@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1209,6 +1213,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
]
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "approve@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1283,6 +1288,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"status": "success"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "approve@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1297,6 +1303,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"max_attempts": 1
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "ship@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1310,6 +1317,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"script": "echo shipped"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "ship@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1325,6 +1333,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"timed_out": false
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "ship@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1369,6 +1378,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"status": "success"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "ship@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1453,6 +1463,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"status": "success"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "ship@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1467,6 +1478,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"max_attempts": 1
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "exit@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
@ -1482,6 +1494,7 @@ fn json_run_implies_auto_approve_for_human_gates() {
|
|||
"status": "success"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"stage_id": "exit@1",
|
||||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -673,7 +673,7 @@ pub(crate) fn run_events(run_dir: &Path) -> Vec<EventEnvelope> {
|
|||
run_dir,
|
||||
&format!("/api/v1/runs/{run_id}/events"),
|
||||
));
|
||||
serde_json::from_value(response["data"].clone()).expect("event list should parse")
|
||||
crate::support::parse_event_envelopes(&response)
|
||||
}
|
||||
|
||||
pub(crate) fn wait_for_event_names(run_dir: &Path, expected: &[&str]) {
|
||||
|
|
|
|||
|
|
@ -29,16 +29,14 @@ fn live_run_state_response() -> serde_json::Value {
|
|||
fn run_sse_body(run_id: &str) -> String {
|
||||
let completed = serde_json::json!({
|
||||
"seq": 2,
|
||||
"payload": {
|
||||
"event": "run.completed",
|
||||
"id": "evt-run-completed",
|
||||
"run_id": run_id,
|
||||
"ts": "2026-04-05T12:00:01Z",
|
||||
"properties": {
|
||||
"duration_ms": 12,
|
||||
"artifact_count": 0,
|
||||
"status": "success"
|
||||
}
|
||||
"event": "run.completed",
|
||||
"id": "evt-run-completed",
|
||||
"run_id": run_id,
|
||||
"ts": "2026-04-05T12:00:01Z",
|
||||
"properties": {
|
||||
"duration_ms": 12,
|
||||
"artifact_count": 0,
|
||||
"status": "success"
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -267,13 +265,11 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
|
|||
serde_json::json!({
|
||||
"data": [{
|
||||
"seq": 1,
|
||||
"payload": {
|
||||
"event": "run.running",
|
||||
"id": "evt-run-running",
|
||||
"run_id": success_run_id,
|
||||
"ts": "2026-04-05T12:00:00Z",
|
||||
"properties": {}
|
||||
}
|
||||
"event": "run.running",
|
||||
"id": "evt-run-running",
|
||||
"run_id": success_run_id,
|
||||
"ts": "2026-04-05T12:00:00Z",
|
||||
"properties": {}
|
||||
}],
|
||||
"meta": { "has_more": false }
|
||||
})
|
||||
|
|
@ -367,13 +363,11 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
|
|||
serde_json::json!({
|
||||
"data": [{
|
||||
"seq": 1,
|
||||
"payload": {
|
||||
"event": "run.running",
|
||||
"id": "evt-run-running",
|
||||
"run_id": eof_run_id,
|
||||
"ts": "2026-04-05T12:00:00Z",
|
||||
"properties": {}
|
||||
}
|
||||
"event": "run.running",
|
||||
"id": "evt-run-running",
|
||||
"run_id": eof_run_id,
|
||||
"ts": "2026-04-05T12:00:00Z",
|
||||
"properties": {}
|
||||
}],
|
||||
"meta": { "has_more": false }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assert_cmd::Command;
|
||||
use fabro_store::EventEnvelope;
|
||||
use fabro_test::TestContext;
|
||||
use fabro_types::RunId;
|
||||
macro_rules! fabro_json_snapshot {
|
||||
|
|
@ -60,6 +61,17 @@ pub(crate) fn unique_run_id() -> String {
|
|||
RunId::new().to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_event_envelopes(response: &serde_json::Value) -> Vec<EventEnvelope> {
|
||||
response["data"]
|
||||
.as_array()
|
||||
.expect("event list response should contain a data array")
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(serde_json::from_value)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("wire event envelope list should parse")
|
||||
}
|
||||
|
||||
pub(crate) struct LightweightCli {
|
||||
home_dir: tempfile::TempDir,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ fn run_events(run_dir: &Path) -> Vec<EventEnvelope> {
|
|||
storage_dir,
|
||||
&format!("/api/v1/runs/{run_id}/events"),
|
||||
));
|
||||
serde_json::from_value(response["data"].clone()).expect("event list should parse")
|
||||
crate::support::parse_event_envelopes(&response)
|
||||
}
|
||||
|
||||
macro_rules! sandbox_tests {
|
||||
|
|
|
|||
|
|
@ -240,16 +240,14 @@ pub(crate) async fn run_events_stub(
|
|||
Event::default().data(
|
||||
json!({
|
||||
"seq": 2,
|
||||
"payload": {
|
||||
"id": "evt_demo_attach_completed",
|
||||
"ts": "2026-04-06T15:00:02Z",
|
||||
"run_id": "01JQ0000000000000000000001",
|
||||
"event": "run.completed",
|
||||
"properties": {
|
||||
"duration_ms": 42,
|
||||
"artifact_count": 0,
|
||||
"status": "success"
|
||||
}
|
||||
"id": "evt_demo_attach_completed",
|
||||
"ts": "2026-04-06T15:00:02Z",
|
||||
"run_id": "01JQ0000000000000000000001",
|
||||
"event": "run.completed",
|
||||
"properties": {
|
||||
"duration_ms": 42,
|
||||
"artifact_count": 0,
|
||||
"status": "success"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
|
|
@ -507,12 +505,10 @@ pub(crate) async fn attach_events_stub(
|
|||
Event::default().data(
|
||||
json!({
|
||||
"seq": 1,
|
||||
"payload": {
|
||||
"id": "evt_demo_1",
|
||||
"ts": "2026-04-06T15:00:00Z",
|
||||
"run_id": "01JQ0000000000000000000001",
|
||||
"event": "run.started"
|
||||
}
|
||||
"id": "evt_demo_1",
|
||||
"ts": "2026-04-06T15:00:00Z",
|
||||
"run_id": "01JQ0000000000000000000001",
|
||||
"event": "run.started"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
|
|
@ -521,12 +517,10 @@ pub(crate) async fn attach_events_stub(
|
|||
Event::default().data(
|
||||
json!({
|
||||
"seq": 2,
|
||||
"payload": {
|
||||
"id": "evt_demo_2",
|
||||
"ts": "2026-04-06T15:00:01Z",
|
||||
"run_id": "01JQ0000000000000000000001",
|
||||
"event": "stage.started"
|
||||
}
|
||||
"id": "evt_demo_2",
|
||||
"ts": "2026-04-06T15:00:01Z",
|
||||
"run_id": "01JQ0000000000000000000001",
|
||||
"event": "stage.started"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -35,8 +35,9 @@ use fabro_store::{
|
|||
};
|
||||
use fabro_types::settings::{InterpString, SettingsFile};
|
||||
use fabro_types::{
|
||||
EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance,
|
||||
RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, RunSubjectProvenance,
|
||||
ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId,
|
||||
RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance,
|
||||
RunSubjectProvenance,
|
||||
};
|
||||
use fabro_util::redact::redact_jsonl_line;
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
|
|
@ -112,10 +113,10 @@ pub use fabro_api::types::{
|
|||
QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphFormat,
|
||||
RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse, RunBilling,
|
||||
RunBillingStage, RunBillingTotals, RunControlAction as ApiRunControlAction, RunError,
|
||||
RunEvent as ApiRunEvent, RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry,
|
||||
SandboxFileListResponse, ServerSettings, SetSecretRequest, SshAccessRequest, SshAccessResponse,
|
||||
StartRunRequest, StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse,
|
||||
SystemRunCounts, WriteBlobResponse,
|
||||
RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse,
|
||||
ServerSettings, SetSecretRequest, SshAccessRequest, SshAccessResponse, StartRunRequest,
|
||||
StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse, SystemRunCounts,
|
||||
WriteBlobResponse,
|
||||
};
|
||||
use fabro_graphviz::render::GraphFormat;
|
||||
|
||||
|
|
@ -1477,8 +1478,7 @@ fn event_matches_run_filter(event: &EventEnvelope, run_filter: Option<&HashSet<R
|
|||
}
|
||||
|
||||
fn sse_event_from_store(event: &EventEnvelope) -> Option<Event> {
|
||||
let event = api_event_envelope_from_store(event).ok()?;
|
||||
let data = serde_json::to_string(&event).ok()?;
|
||||
let data = serde_json::to_string(event).ok()?;
|
||||
let data = redact_jsonl_line(&data);
|
||||
Some(Event::default().data(data))
|
||||
}
|
||||
|
|
@ -2373,23 +2373,17 @@ fn octet_stream_response(bytes: Bytes) -> Response {
|
|||
.into_response()
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn api_run_event_from_store(payload: &EventPayload) -> Result<ApiRunEvent, Response> {
|
||||
serde_json::from_value(payload.as_value().clone()).map_err(|err| {
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to serialize stored event: {err}"),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn api_event_envelope_from_store(event: &EventEnvelope) -> Result<ApiEventEnvelope, Response> {
|
||||
Ok(ApiEventEnvelope {
|
||||
payload: api_run_event_from_store(&event.payload)?,
|
||||
seq: i64::from(event.seq),
|
||||
})
|
||||
serde_json::to_value(event)
|
||||
.and_then(serde_json::from_value)
|
||||
.map_err(|err| {
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to serialize stored event: {err}"),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_live_run_state(run: &mut ManagedRun) {
|
||||
|
|
@ -5191,16 +5185,21 @@ async fn append_control_request(
|
|||
state: &AppState,
|
||||
run_id: RunId,
|
||||
action: RunControlAction,
|
||||
actor: Option<ActorRef>,
|
||||
) -> anyhow::Result<()> {
|
||||
let run_store = state.store.open_run(&run_id).await?;
|
||||
let event = match action {
|
||||
RunControlAction::Cancel => workflow_event::Event::RunCancelRequested,
|
||||
RunControlAction::Pause => workflow_event::Event::RunPauseRequested,
|
||||
RunControlAction::Unpause => workflow_event::Event::RunUnpauseRequested,
|
||||
RunControlAction::Cancel => workflow_event::Event::RunCancelRequested { actor },
|
||||
RunControlAction::Pause => workflow_event::Event::RunPauseRequested { actor },
|
||||
RunControlAction::Unpause => workflow_event::Event::RunUnpauseRequested { actor },
|
||||
};
|
||||
workflow_event::append_event(&run_store, &run_id, &event).await
|
||||
}
|
||||
|
||||
fn actor_from_subject(subject: &AuthenticatedSubject) -> Option<ActorRef> {
|
||||
subject.login.clone().map(ActorRef::user)
|
||||
}
|
||||
|
||||
fn schedule_worker_kill(state: Arc<AppState>, run_id: RunId, worker_pid: u32) {
|
||||
tokio::spawn(async move {
|
||||
sleep(WORKER_CANCEL_GRACE).await;
|
||||
|
|
@ -5216,7 +5215,7 @@ fn schedule_worker_kill(state: Arc<AppState>, run_id: RunId, worker_pid: u32) {
|
|||
}
|
||||
|
||||
async fn cancel_run(
|
||||
_auth: AuthenticatedService,
|
||||
subject: AuthenticatedSubject,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
|
|
@ -5282,7 +5281,13 @@ async fn cancel_run(
|
|||
};
|
||||
|
||||
if pending_control != Some(RunControlAction::Cancel) {
|
||||
if let Err(err) = append_control_request(state.as_ref(), id, RunControlAction::Cancel).await
|
||||
if let Err(err) = append_control_request(
|
||||
state.as_ref(),
|
||||
id,
|
||||
RunControlAction::Cancel,
|
||||
actor_from_subject(&subject),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
|
|
@ -5334,7 +5339,7 @@ async fn cancel_run(
|
|||
}
|
||||
|
||||
async fn pause_run(
|
||||
_auth: AuthenticatedService,
|
||||
subject: AuthenticatedSubject,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
|
|
@ -5372,7 +5377,14 @@ async fn pause_run(
|
|||
let Some(worker_pid) = worker_pid else {
|
||||
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.").into_response();
|
||||
};
|
||||
if let Err(err) = append_control_request(state.as_ref(), id, RunControlAction::Pause).await {
|
||||
if let Err(err) = append_control_request(
|
||||
state.as_ref(),
|
||||
id,
|
||||
RunControlAction::Pause,
|
||||
actor_from_subject(&subject),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
|
|
@ -5395,7 +5407,7 @@ async fn pause_run(
|
|||
}
|
||||
|
||||
async fn unpause_run(
|
||||
_auth: AuthenticatedService,
|
||||
subject: AuthenticatedSubject,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
|
|
@ -5433,7 +5445,14 @@ async fn unpause_run(
|
|||
let Some(worker_pid) = worker_pid else {
|
||||
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.").into_response();
|
||||
};
|
||||
if let Err(err) = append_control_request(state.as_ref(), id, RunControlAction::Unpause).await {
|
||||
if let Err(err) = append_control_request(
|
||||
state.as_ref(),
|
||||
id,
|
||||
RunControlAction::Unpause,
|
||||
actor_from_subject(&subject),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
|
|
@ -7512,7 +7531,7 @@ level = "debug"
|
|||
managed_run.status = RunStatus::Running;
|
||||
managed_run.worker_pid = Some(u32::MAX);
|
||||
}
|
||||
append_control_request(state.as_ref(), run_id, RunControlAction::Pause)
|
||||
append_control_request(state.as_ref(), run_id, RunControlAction::Pause, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -7543,7 +7562,7 @@ level = "debug"
|
|||
managed_run.status = RunStatus::Running;
|
||||
managed_run.worker_pid = Some(u32::MAX);
|
||||
}
|
||||
append_control_request(state.as_ref(), run_id, RunControlAction::Cancel)
|
||||
append_control_request(state.as_ref(), run_id, RunControlAction::Cancel, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -7677,7 +7696,7 @@ level = "debug"
|
|||
workflow_event::Event::RunStarting { reason: None },
|
||||
workflow_event::Event::RunRunning { reason: None },
|
||||
workflow_event::Event::RunPaused,
|
||||
workflow_event::Event::RunCancelRequested,
|
||||
workflow_event::Event::RunCancelRequested { actor: None },
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -270,14 +270,12 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() {
|
|||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
(event["payload"]["event"] == "run.failed").then(|| {
|
||||
(event["event"] == "run.failed").then(|| {
|
||||
(
|
||||
event["payload"]["properties"]["reason"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned),
|
||||
event["payload"]["properties"]["error"]
|
||||
event["properties"]["reason"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned),
|
||||
event["properties"]["error"].as_str().map(ToOwned::to_owned),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ async fn attach_run_events_replays_terminal_event_after_completion() {
|
|||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data:"))
|
||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line.trim()).ok())
|
||||
.filter_map(|event| event["payload"]["event"].as_str().map(ToString::to_string))
|
||||
.filter_map(|event| event["event"].as_str().map(ToString::to_string))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ async fn sse_stream_contains_expected_event_types() {
|
|||
if let Some(json_str) = line.strip_prefix("data:") {
|
||||
let json_str = json_str.trim();
|
||||
if let Ok(event) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||||
if let Some(event_name) = event["payload"]["event"].as_str() {
|
||||
if let Some(event_name) = event["event"].as_str() {
|
||||
event_types.push(event_name.to_string());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -612,8 +612,13 @@ mod tests {
|
|||
run_id: fixtures::RUN_1,
|
||||
node_id: node_id.map(ToOwned::to_owned),
|
||||
node_label: None,
|
||||
stage_id: None,
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor: None,
|
||||
body,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -83,5 +83,54 @@ impl TryFrom<&EventPayload> for RunEvent {
|
|||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EventEnvelope {
|
||||
pub seq: u32,
|
||||
#[serde(flatten)]
|
||||
pub payload: EventPayload,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
use fabro_types::{EventBody, RunEvent, StageId, fixtures, run_event::RunCompletedProps};
|
||||
|
||||
use super::{EventEnvelope, EventPayload};
|
||||
|
||||
#[test]
|
||||
fn wire_event_envelope_round_trips() {
|
||||
let event = RunEvent {
|
||||
id: "evt_1".to_string(),
|
||||
ts: Utc.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap(),
|
||||
run_id: fixtures::RUN_1,
|
||||
node_id: Some("code".to_string()),
|
||||
node_label: Some("Code".to_string()),
|
||||
stage_id: Some(StageId::new("code", 1)),
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor: None,
|
||||
body: EventBody::RunCompleted(RunCompletedProps {
|
||||
duration_ms: 42,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
}),
|
||||
};
|
||||
let payload = EventPayload::new(event.to_value().unwrap(), &fixtures::RUN_1).unwrap();
|
||||
let envelope = EventEnvelope { seq: 7, payload };
|
||||
|
||||
let wire = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(wire["seq"], 7);
|
||||
assert_eq!(wire["id"], "evt_1");
|
||||
assert_eq!(wire["event"], "run.completed");
|
||||
assert!(wire.get("payload").is_none(), "wire shape must be flat");
|
||||
|
||||
let parsed: EventEnvelope = serde_json::from_value(wire).unwrap();
|
||||
assert_eq!(parsed, envelope);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@ pub use run::{
|
|||
RunSubjectProvenance,
|
||||
};
|
||||
pub use run_blob_id::RunBlobId;
|
||||
pub use run_event::{EventBody, RunEvent, RunNoticeLevel};
|
||||
pub use run_event::{ActorKind, ActorRef, EventBody, RunEvent, RunNoticeLevel};
|
||||
pub use run_id::RunId;
|
||||
pub use run_id::fixtures;
|
||||
pub use sandbox_record::SandboxRecord;
|
||||
pub use stage_id::StageId;
|
||||
pub use stage_id::{ParallelBranchId, StageId};
|
||||
pub use start::StartRecord;
|
||||
pub use status::{
|
||||
InvalidTransition, ParseRunStatusError, RunControlAction, RunStatus, RunStatusRecord,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use serde::ser::Error as SerError;
|
|||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::RunId;
|
||||
use crate::{ParallelBranchId, RunId, StageId};
|
||||
|
||||
pub use agent::*;
|
||||
pub use infra::*;
|
||||
|
|
@ -27,6 +27,34 @@ pub enum RunNoticeLevel {
|
|||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ActorKind {
|
||||
User,
|
||||
Agent,
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActorRef {
|
||||
pub kind: ActorKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display: Option<String>,
|
||||
}
|
||||
|
||||
impl ActorRef {
|
||||
#[must_use]
|
||||
pub fn user(login: String) -> Self {
|
||||
Self {
|
||||
kind: ActorKind::User,
|
||||
id: Some(login.clone()),
|
||||
display: Some(login),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RunEvent {
|
||||
pub id: String,
|
||||
|
|
@ -34,8 +62,13 @@ pub struct RunEvent {
|
|||
pub run_id: RunId,
|
||||
pub node_id: Option<String>,
|
||||
pub node_label: Option<String>,
|
||||
pub stage_id: Option<StageId>,
|
||||
pub parallel_group_id: Option<StageId>,
|
||||
pub parallel_branch_id: Option<ParallelBranchId>,
|
||||
pub session_id: Option<String>,
|
||||
pub parent_session_id: Option<String>,
|
||||
pub tool_call_id: Option<String>,
|
||||
pub actor: Option<ActorRef>,
|
||||
pub body: EventBody,
|
||||
}
|
||||
|
||||
|
|
@ -271,9 +304,19 @@ struct RunEventRaw {
|
|||
#[serde(default)]
|
||||
node_label: Option<String>,
|
||||
#[serde(default)]
|
||||
stage_id: Option<StageId>,
|
||||
#[serde(default)]
|
||||
parallel_group_id: Option<StageId>,
|
||||
#[serde(default)]
|
||||
parallel_branch_id: Option<ParallelBranchId>,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
parent_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(default)]
|
||||
actor: Option<ActorRef>,
|
||||
event: String,
|
||||
#[serde(default = "default_properties")]
|
||||
properties: Value,
|
||||
|
|
@ -283,6 +326,23 @@ fn default_properties() -> Value {
|
|||
Value::Object(Map::new())
|
||||
}
|
||||
|
||||
struct RunEventParts<'a> {
|
||||
id: String,
|
||||
ts: DateTime<Utc>,
|
||||
run_id: RunId,
|
||||
node_id: Option<String>,
|
||||
node_label: Option<String>,
|
||||
stage_id: Option<StageId>,
|
||||
parallel_group_id: Option<StageId>,
|
||||
parallel_branch_id: Option<ParallelBranchId>,
|
||||
session_id: Option<String>,
|
||||
parent_session_id: Option<String>,
|
||||
tool_call_id: Option<String>,
|
||||
actor: Option<ActorRef>,
|
||||
event: &'a str,
|
||||
properties: &'a Value,
|
||||
}
|
||||
|
||||
impl EventBody {
|
||||
pub fn event_name(&self) -> &str {
|
||||
match self {
|
||||
|
|
@ -524,23 +584,39 @@ fn is_known_event_name(event: &str) -> bool {
|
|||
impl RunEvent {
|
||||
pub fn from_value(value: Value) -> serde_json::Result<Self> {
|
||||
let raw: RunEventRaw = serde_json::from_value(value)?;
|
||||
Self::from_parts(
|
||||
raw.id,
|
||||
raw.ts,
|
||||
raw.run_id,
|
||||
raw.node_id,
|
||||
raw.node_label,
|
||||
raw.session_id,
|
||||
raw.parent_session_id,
|
||||
&raw.event,
|
||||
&raw.properties,
|
||||
)
|
||||
Self::from_parts(RunEventParts {
|
||||
id: raw.id,
|
||||
ts: raw.ts,
|
||||
run_id: raw.run_id,
|
||||
node_id: raw.node_id,
|
||||
node_label: raw.node_label,
|
||||
stage_id: raw.stage_id,
|
||||
parallel_group_id: raw.parallel_group_id,
|
||||
parallel_branch_id: raw.parallel_branch_id,
|
||||
session_id: raw.session_id,
|
||||
parent_session_id: raw.parent_session_id,
|
||||
tool_call_id: raw.tool_call_id,
|
||||
actor: raw.actor,
|
||||
event: &raw.event,
|
||||
properties: &raw.properties,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_ref(value: &Value) -> serde_json::Result<Self> {
|
||||
fn opt_field<T: for<'a> Deserialize<'a>>(
|
||||
obj: &Map<String, Value>,
|
||||
key: &str,
|
||||
) -> serde_json::Result<Option<T>> {
|
||||
match obj.get(key) {
|
||||
Some(value) if !value.is_null() => Ok(Some(T::deserialize(value)?)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
let obj = value.as_object().ok_or_else(|| {
|
||||
<serde_json::Error as DeError>::custom("run event must be a JSON object")
|
||||
})?;
|
||||
let opt_str = |key: &str| obj.get(key).and_then(Value::as_str).map(str::to_string);
|
||||
let id = obj.get("id").and_then(Value::as_str).ok_or_else(|| {
|
||||
<serde_json::Error as DeError>::custom("missing or non-string field: id")
|
||||
})?;
|
||||
|
|
@ -559,58 +635,50 @@ impl RunEvent {
|
|||
.get("properties")
|
||||
.cloned()
|
||||
.unwrap_or_else(default_properties);
|
||||
Self::from_parts(
|
||||
id.to_string(),
|
||||
Self::from_parts(RunEventParts {
|
||||
id: id.to_string(),
|
||||
ts,
|
||||
run_id,
|
||||
obj.get("node_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
obj.get("node_label")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
obj.get("session_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
obj.get("parent_session_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
node_id: opt_str("node_id"),
|
||||
node_label: opt_str("node_label"),
|
||||
stage_id: opt_field(obj, "stage_id")?,
|
||||
parallel_group_id: opt_field(obj, "parallel_group_id")?,
|
||||
parallel_branch_id: opt_field(obj, "parallel_branch_id")?,
|
||||
session_id: opt_str("session_id"),
|
||||
parent_session_id: opt_str("parent_session_id"),
|
||||
tool_call_id: opt_str("tool_call_id"),
|
||||
actor: opt_field(obj, "actor")?,
|
||||
event,
|
||||
&properties,
|
||||
)
|
||||
properties: &properties,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_parts(
|
||||
id: String,
|
||||
ts: DateTime<Utc>,
|
||||
run_id: RunId,
|
||||
node_id: Option<String>,
|
||||
node_label: Option<String>,
|
||||
session_id: Option<String>,
|
||||
parent_session_id: Option<String>,
|
||||
event: &str,
|
||||
properties: &Value,
|
||||
) -> serde_json::Result<Self> {
|
||||
fn from_parts(parts: RunEventParts<'_>) -> serde_json::Result<Self> {
|
||||
let body_payload = json!({
|
||||
"event": event,
|
||||
"properties": properties,
|
||||
"event": parts.event,
|
||||
"properties": parts.properties,
|
||||
});
|
||||
let body: EventBody = match serde_json::from_value(body_payload) {
|
||||
Ok(body) => body,
|
||||
Err(err) if is_known_event_name(event) => return Err(err),
|
||||
Err(err) if is_known_event_name(parts.event) => return Err(err),
|
||||
Err(_) => EventBody::Unknown {
|
||||
name: event.to_string(),
|
||||
properties: properties.clone(),
|
||||
name: parts.event.to_string(),
|
||||
properties: parts.properties.clone(),
|
||||
},
|
||||
};
|
||||
Ok(Self {
|
||||
id,
|
||||
ts,
|
||||
run_id,
|
||||
node_id,
|
||||
node_label,
|
||||
session_id,
|
||||
parent_session_id,
|
||||
id: parts.id,
|
||||
ts: parts.ts,
|
||||
run_id: parts.run_id,
|
||||
node_id: parts.node_id,
|
||||
node_label: parts.node_label,
|
||||
stage_id: parts.stage_id,
|
||||
parallel_group_id: parts.parallel_group_id,
|
||||
parallel_branch_id: parts.parallel_branch_id,
|
||||
session_id: parts.session_id,
|
||||
parent_session_id: parts.parent_session_id,
|
||||
tool_call_id: parts.tool_call_id,
|
||||
actor: parts.actor,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
|
@ -620,29 +688,46 @@ impl RunEvent {
|
|||
}
|
||||
|
||||
pub fn to_value(&self) -> serde_json::Result<Value> {
|
||||
fn insert_opt<T: Serialize>(
|
||||
map: &mut Map<String, Value>,
|
||||
key: &str,
|
||||
value: Option<&T>,
|
||||
) -> serde_json::Result<()> {
|
||||
if let Some(v) = value {
|
||||
map.insert(key.to_string(), serde_json::to_value(v)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let mut map = Map::new();
|
||||
map.insert("id".to_string(), serde_json::to_value(&self.id)?);
|
||||
map.insert("id".to_string(), Value::String(self.id.clone()));
|
||||
map.insert("ts".to_string(), serde_json::to_value(self.ts)?);
|
||||
map.insert("run_id".to_string(), serde_json::to_value(self.run_id)?);
|
||||
map.insert(
|
||||
"event".to_string(),
|
||||
Value::String(self.body.event_name().to_string()),
|
||||
);
|
||||
if let Some(value) = &self.session_id {
|
||||
map.insert("session_id".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
if let Some(value) = &self.parent_session_id {
|
||||
map.insert(
|
||||
"parent_session_id".to_string(),
|
||||
Value::String(value.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(value) = &self.node_id {
|
||||
map.insert("node_id".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
if let Some(value) = &self.node_label {
|
||||
map.insert("node_label".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
insert_opt(&mut map, "session_id", self.session_id.as_ref())?;
|
||||
insert_opt(
|
||||
&mut map,
|
||||
"parent_session_id",
|
||||
self.parent_session_id.as_ref(),
|
||||
)?;
|
||||
insert_opt(&mut map, "node_id", self.node_id.as_ref())?;
|
||||
insert_opt(&mut map, "node_label", self.node_label.as_ref())?;
|
||||
insert_opt(&mut map, "stage_id", self.stage_id.as_ref())?;
|
||||
insert_opt(
|
||||
&mut map,
|
||||
"parallel_group_id",
|
||||
self.parallel_group_id.as_ref(),
|
||||
)?;
|
||||
insert_opt(
|
||||
&mut map,
|
||||
"parallel_branch_id",
|
||||
self.parallel_branch_id.as_ref(),
|
||||
)?;
|
||||
insert_opt(&mut map, "tool_call_id", self.tool_call_id.as_ref())?;
|
||||
insert_opt(&mut map, "actor", self.actor.as_ref())?;
|
||||
map.insert("properties".to_string(), self.body.properties_value()?);
|
||||
Ok(Value::Object(map))
|
||||
}
|
||||
|
|
@ -698,8 +783,13 @@ mod tests {
|
|||
run_id: fixtures::RUN_1,
|
||||
node_id: Some("build".to_string()),
|
||||
node_label: Some("Build".to_string()),
|
||||
stage_id: None,
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor: None,
|
||||
body: EventBody::StageCompleted(StageCompletedProps {
|
||||
index: 1,
|
||||
duration_ms: 1234,
|
||||
|
|
@ -874,4 +964,93 @@ mod tests {
|
|||
assert_eq!(serialized["event"], value["event"]);
|
||||
assert_eq!(serialized["properties"], value["properties"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_event_round_trips_new_envelope_fields() {
|
||||
let value = json!({
|
||||
"id": "evt_envelope",
|
||||
"ts": "2026-04-08T16:21:11.106Z",
|
||||
"run_id": fixtures::RUN_1,
|
||||
"event": "agent.tool.completed",
|
||||
"stage_id": "code@1",
|
||||
"node_id": "code",
|
||||
"node_label": "Code",
|
||||
"parallel_group_id": "code@1",
|
||||
"parallel_branch_id": "code@1:0",
|
||||
"session_id": "ses_child",
|
||||
"parent_session_id": "ses_parent",
|
||||
"tool_call_id": "call_1",
|
||||
"actor": {
|
||||
"kind": "agent",
|
||||
"id": "ses_child",
|
||||
"display": "claude-sonnet"
|
||||
},
|
||||
"properties": {
|
||||
"tool_name": "read_file",
|
||||
"tool_call_id": "call_1",
|
||||
"output": {"summary": "read"},
|
||||
"is_error": false,
|
||||
"visit": 1
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = RunEvent::from_value(value.clone()).unwrap();
|
||||
assert_eq!(parsed.stage_id, Some(StageId::new("code", 1)));
|
||||
assert_eq!(parsed.parallel_group_id, Some(StageId::new("code", 1)));
|
||||
assert_eq!(
|
||||
parsed.parallel_branch_id,
|
||||
Some(ParallelBranchId::new(StageId::new("code", 1), 0))
|
||||
);
|
||||
assert_eq!(parsed.tool_call_id.as_deref(), Some("call_1"));
|
||||
let actor = parsed.actor.as_ref().expect("actor present");
|
||||
assert_eq!(actor.kind, ActorKind::Agent);
|
||||
assert_eq!(actor.id.as_deref(), Some("ses_child"));
|
||||
assert_eq!(actor.display.as_deref(), Some("claude-sonnet"));
|
||||
|
||||
let serialized = parsed.to_value().unwrap();
|
||||
assert_eq!(serialized["stage_id"], value["stage_id"]);
|
||||
assert_eq!(serialized["parallel_group_id"], value["parallel_group_id"]);
|
||||
assert_eq!(
|
||||
serialized["parallel_branch_id"],
|
||||
value["parallel_branch_id"]
|
||||
);
|
||||
assert_eq!(serialized["tool_call_id"], value["tool_call_id"]);
|
||||
assert_eq!(serialized["actor"], value["actor"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_event_omits_absent_envelope_fields() {
|
||||
let event = RunEvent {
|
||||
id: "evt_bare".to_string(),
|
||||
ts: DateTime::parse_from_rfc3339("2026-04-04T12:00:00.000Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
run_id: fixtures::RUN_1,
|
||||
node_id: None,
|
||||
node_label: None,
|
||||
stage_id: None,
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor: None,
|
||||
body: EventBody::RunStarted(RunStartedProps {
|
||||
name: "demo".to_string(),
|
||||
base_branch: None,
|
||||
base_sha: None,
|
||||
run_branch: None,
|
||||
worktree_dir: None,
|
||||
goal: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let serialized = event.to_value().unwrap();
|
||||
let obj = serialized.as_object().unwrap();
|
||||
assert!(!obj.contains_key("stage_id"));
|
||||
assert!(!obj.contains_key("parallel_group_id"));
|
||||
assert!(!obj.contains_key("parallel_branch_id"));
|
||||
assert!(!obj.contains_key("tool_call_id"));
|
||||
assert!(!obj.contains_key("actor"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,9 +90,90 @@ impl<'de> Deserialize<'de> for StageId {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ParallelBranchId {
|
||||
group: StageId,
|
||||
index: u32,
|
||||
}
|
||||
|
||||
impl ParallelBranchId {
|
||||
#[must_use]
|
||||
pub fn new(group: StageId, index: u32) -> Self {
|
||||
Self { group, index }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn group(&self) -> &StageId {
|
||||
&self.group
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn index(&self) -> u32 {
|
||||
self.index
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ParallelBranchId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}:{}", self.group, self.index)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParseParallelBranchIdError(String);
|
||||
|
||||
impl fmt::Display for ParseParallelBranchIdError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseParallelBranchIdError {}
|
||||
|
||||
impl FromStr for ParallelBranchId {
|
||||
type Err = ParseParallelBranchIdError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let (group, index) = s.rsplit_once(':').ok_or_else(|| {
|
||||
ParseParallelBranchIdError("parallel branch id must contain ':'".to_string())
|
||||
})?;
|
||||
let group = group.parse::<StageId>().map_err(|err| {
|
||||
ParseParallelBranchIdError(format!("invalid parallel group id: {err}"))
|
||||
})?;
|
||||
if index.is_empty() {
|
||||
return Err(ParseParallelBranchIdError(
|
||||
"parallel branch id index must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let index = index.parse().map_err(|err| {
|
||||
ParseParallelBranchIdError(format!("invalid parallel branch index: {err}"))
|
||||
})?;
|
||||
Ok(Self::new(group, index))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ParallelBranchId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ParallelBranchId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::StageId;
|
||||
use super::{ParallelBranchId, StageId};
|
||||
|
||||
#[test]
|
||||
fn display_and_parse_round_trip() {
|
||||
|
|
@ -151,4 +232,41 @@ mod tests {
|
|||
let err = "@3".parse::<StageId>().unwrap_err();
|
||||
assert_eq!(err.to_string(), "stage id node_id must not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_branch_id_display_and_parse_round_trip() {
|
||||
let branch = ParallelBranchId::new(StageId::new("fanout", 2), 3);
|
||||
assert_eq!(branch.to_string(), "fanout@2:3");
|
||||
assert_eq!("fanout@2:3".parse::<ParallelBranchId>().unwrap(), branch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_branch_id_serde_round_trip_uses_string_form() {
|
||||
let branch = ParallelBranchId::new(StageId::new("fanout", 2), 0);
|
||||
let value = serde_json::to_value(&branch).unwrap();
|
||||
assert_eq!(value, serde_json::json!("fanout@2:0"));
|
||||
let decoded: ParallelBranchId = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(decoded, branch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_branch_id_rejects_missing_colon() {
|
||||
let err = "fanout@2".parse::<ParallelBranchId>().unwrap_err();
|
||||
assert_eq!(err.to_string(), "parallel branch id must contain ':'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_branch_id_rejects_bad_group() {
|
||||
let err = "fanout:0".parse::<ParallelBranchId>().unwrap_err();
|
||||
assert!(err.to_string().starts_with("invalid parallel group id:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_branch_id_rejects_non_numeric_index() {
|
||||
let err = "fanout@2:zero".parse::<ParallelBranchId>().unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.starts_with("invalid parallel branch index:")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ pub mod keys {
|
|||
pub const INTERNAL_THREAD_ID: &str = "internal.thread_id";
|
||||
pub const INTERNAL_NODE_VISIT_COUNT: &str = "internal.node_visit_count";
|
||||
pub const INTERNAL_PARENT_PREAMBLE: &str = "internal.parent_preamble";
|
||||
pub const INTERNAL_PARALLEL_GROUP_ID: &str = "internal.parallel_group_id";
|
||||
pub const INTERNAL_PARALLEL_BRANCH_ID: &str = "internal.parallel_branch_id";
|
||||
|
||||
// --- current.* keys ---
|
||||
pub const CURRENT_PREAMBLE: &str = "current.preamble";
|
||||
|
|
@ -133,7 +135,9 @@ pub mod keys {
|
|||
|
||||
pub use fabro_core::Context;
|
||||
|
||||
use crate::event::StageScope;
|
||||
use fabro_graphviz::Fidelity;
|
||||
use fabro_types::{ParallelBranchId, StageId};
|
||||
|
||||
/// Domain-specific typed accessors for workflow context values.
|
||||
pub trait WorkflowContext {
|
||||
|
|
@ -141,6 +145,12 @@ pub trait WorkflowContext {
|
|||
fn thread_id(&self) -> Option<String>;
|
||||
fn preamble(&self) -> String;
|
||||
fn run_id(&self) -> String;
|
||||
fn parallel_group_id(&self) -> Option<StageId>;
|
||||
fn parallel_branch_id(&self) -> Option<ParallelBranchId>;
|
||||
/// Build the stage-level emit scope from the currently-executing node and its
|
||||
/// accumulated visit count. Returns `None` for run-level emissions where no
|
||||
/// stage is active (i.e., `CURRENT_NODE` is unset).
|
||||
fn current_stage_scope(&self) -> Option<StageScope>;
|
||||
}
|
||||
|
||||
impl WorkflowContext for Context {
|
||||
|
|
@ -162,6 +172,23 @@ impl WorkflowContext for Context {
|
|||
fn run_id(&self) -> String {
|
||||
self.get_string(keys::INTERNAL_RUN_ID, "unknown")
|
||||
}
|
||||
|
||||
fn parallel_group_id(&self) -> Option<StageId> {
|
||||
self.get(keys::INTERNAL_PARALLEL_GROUP_ID)
|
||||
.and_then(|value| serde_json::from_value(value).ok())
|
||||
}
|
||||
|
||||
fn parallel_branch_id(&self) -> Option<ParallelBranchId> {
|
||||
self.get(keys::INTERNAL_PARALLEL_BRANCH_ID)
|
||||
.and_then(|value| serde_json::from_value(value).ok())
|
||||
}
|
||||
|
||||
fn current_stage_scope(&self) -> Option<StageScope> {
|
||||
let node_id = self
|
||||
.get(keys::CURRENT_NODE)
|
||||
.and_then(|value| value.as_str().map(String::from))?;
|
||||
Some(StageScope::from_context(self, node_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -309,6 +336,31 @@ mod tests {
|
|||
assert_eq!(ctx.thread_id(), Some("main".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_ids_default() {
|
||||
let ctx = Context::new();
|
||||
assert_eq!(ctx.parallel_group_id(), None);
|
||||
assert_eq!(ctx.parallel_branch_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_ids_set() {
|
||||
let ctx = Context::new();
|
||||
ctx.set(
|
||||
keys::INTERNAL_PARALLEL_GROUP_ID,
|
||||
serde_json::json!("fanout@2"),
|
||||
);
|
||||
ctx.set(
|
||||
keys::INTERNAL_PARALLEL_BRANCH_ID,
|
||||
serde_json::json!("fanout@2:1"),
|
||||
);
|
||||
assert_eq!(ctx.parallel_group_id(), Some(StageId::new("fanout", 2)));
|
||||
assert_eq!(
|
||||
ctx.parallel_branch_id(),
|
||||
Some(ParallelBranchId::new(StageId::new("fanout", 2), 1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_visit_count_default() {
|
||||
let ctx = Context::new();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use std::sync::atomic::{AtomicI64, Ordering};
|
|||
|
||||
use ::fabro_types::run_event as fabro_types;
|
||||
use ::fabro_types::{
|
||||
BilledTokenCounts, RunBlobId, RunControlAction, RunEvent, RunId, StageStatus, StatusReason,
|
||||
ActorKind, ActorRef, BilledTokenCounts, ParallelBranchId, RunBlobId, RunControlAction,
|
||||
RunEvent, RunId, RunProvenance, StageId, StageStatus, StatusReason,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
|
|
@ -18,8 +19,10 @@ 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::FabroError;
|
||||
use crate::outcome::{BilledModelUsage, FailureDetail, Outcome};
|
||||
use crate::run_dir::visit_from_context;
|
||||
use fabro_agent::{AgentEvent, SandboxEvent, WorktreeEvent, WorktreeEventCallback};
|
||||
use fabro_llm::types::TokenCounts as LlmTokenCounts;
|
||||
use fabro_util::redact::redact_json_value;
|
||||
|
|
@ -54,7 +57,7 @@ pub enum Event {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
db_prefix: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
provenance: Option<::fabro_types::RunProvenance>,
|
||||
provenance: Option<RunProvenance>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
manifest_blob: Option<RunBlobId>,
|
||||
},
|
||||
|
|
@ -90,9 +93,18 @@ pub enum Event {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<StatusReason>,
|
||||
},
|
||||
RunCancelRequested,
|
||||
RunPauseRequested,
|
||||
RunUnpauseRequested,
|
||||
RunCancelRequested {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
actor: Option<ActorRef>,
|
||||
},
|
||||
RunPauseRequested {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
actor: Option<ActorRef>,
|
||||
},
|
||||
RunUnpauseRequested {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
actor: Option<ActorRef>,
|
||||
},
|
||||
RunPaused,
|
||||
RunUnpaused,
|
||||
RunRewound {
|
||||
|
|
@ -193,10 +205,14 @@ pub enum Event {
|
|||
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,
|
||||
|
|
@ -568,13 +584,13 @@ impl Event {
|
|||
Self::RunRemoving { reason } => {
|
||||
info!(?reason, "Run removing");
|
||||
}
|
||||
Self::RunCancelRequested => {
|
||||
Self::RunCancelRequested { .. } => {
|
||||
info!("Run cancel requested");
|
||||
}
|
||||
Self::RunPauseRequested => {
|
||||
Self::RunPauseRequested { .. } => {
|
||||
info!("Run pause requested");
|
||||
}
|
||||
Self::RunUnpauseRequested => {
|
||||
Self::RunUnpauseRequested { .. } => {
|
||||
info!("Run unpause requested");
|
||||
}
|
||||
Self::RunPaused => {
|
||||
|
|
@ -676,6 +692,7 @@ impl Event {
|
|||
index,
|
||||
failure,
|
||||
will_retry,
|
||||
..
|
||||
} => {
|
||||
let error_msg = &failure.message;
|
||||
if *will_retry {
|
||||
|
|
@ -705,6 +722,7 @@ impl Event {
|
|||
attempt,
|
||||
max_attempts,
|
||||
delay_ms,
|
||||
..
|
||||
} => {
|
||||
warn!(
|
||||
node_id,
|
||||
|
|
@ -723,7 +741,7 @@ impl Event {
|
|||
} => {
|
||||
debug!(branch_count, join_policy, "Parallel execution started");
|
||||
}
|
||||
Self::ParallelBranchStarted { branch, index } => {
|
||||
Self::ParallelBranchStarted { branch, index, .. } => {
|
||||
debug!(branch, index, "Parallel branch started");
|
||||
}
|
||||
Self::ParallelBranchCompleted {
|
||||
|
|
@ -1131,9 +1149,9 @@ pub fn event_name(event: &Event) -> &'static str {
|
|||
Event::RunStarting { .. } => "run.starting",
|
||||
Event::RunRunning { .. } => "run.running",
|
||||
Event::RunRemoving { .. } => "run.removing",
|
||||
Event::RunCancelRequested => "run.cancel.requested",
|
||||
Event::RunPauseRequested => "run.pause.requested",
|
||||
Event::RunUnpauseRequested => "run.unpause.requested",
|
||||
Event::RunCancelRequested { .. } => "run.cancel.requested",
|
||||
Event::RunPauseRequested { .. } => "run.pause.requested",
|
||||
Event::RunUnpauseRequested { .. } => "run.unpause.requested",
|
||||
Event::RunPaused => "run.paused",
|
||||
Event::RunUnpaused => "run.unpaused",
|
||||
Event::RunRewound { .. } => "run.rewound",
|
||||
|
|
@ -1248,18 +1266,32 @@ pub fn event_name(event: &Event) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Default)]
|
||||
struct StoredEventFields {
|
||||
session_id: Option<String>,
|
||||
parent_session_id: Option<String>,
|
||||
node_id: Option<String>,
|
||||
node_label: Option<String>,
|
||||
stage_id: Option<StageId>,
|
||||
parallel_group_id: Option<StageId>,
|
||||
parallel_branch_id: Option<ParallelBranchId>,
|
||||
tool_call_id: Option<String>,
|
||||
actor: Option<ActorRef>,
|
||||
}
|
||||
|
||||
fn default_node_label(node_id: Option<&String>, node_label: Option<String>) -> Option<String> {
|
||||
node_label.or_else(|| node_id.cloned())
|
||||
}
|
||||
|
||||
fn node_stored_fields(node_id: Option<String>) -> 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,
|
||||
|
|
@ -1276,19 +1308,64 @@ fn stage_status_from_string(status: &str) -> StageStatus {
|
|||
serde_json::from_value(Value::String(status.to_string())).expect("valid stage status")
|
||||
}
|
||||
|
||||
fn stored_event_fields(event: &Event) -> StoredEventFields {
|
||||
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(actor_from_provenance),
|
||||
..StoredEventFields::default()
|
||||
},
|
||||
Event::RunCancelRequested { actor }
|
||||
| Event::RunPauseRequested { actor }
|
||||
| Event::RunUnpauseRequested { actor } => StoredEventFields {
|
||||
actor: actor.clone(),
|
||||
..StoredEventFields::default()
|
||||
},
|
||||
Event::StageCompleted { node_id, name, .. }
|
||||
| Event::StageFailed { node_id, name, .. }
|
||||
| Event::StageStarted { node_id, name, .. }
|
||||
| Event::StageRetrying { node_id, name, .. } => {
|
||||
let node_id = Some(node_id.clone());
|
||||
let node_label = default_node_label(node_id.as_ref(), Some(name.clone()));
|
||||
let node_id_str = node_id.clone();
|
||||
let node_label = default_node_label(Some(&node_id_str), Some(name.clone()));
|
||||
StoredEventFields {
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
node_id,
|
||||
node_id: Some(node_id_str),
|
||||
node_label,
|
||||
..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, .. }
|
||||
|
|
@ -1297,86 +1374,91 @@ fn stored_event_fields(event: &Event) -> StoredEventFields {
|
|||
| Event::SubgraphCompleted { node_id, .. }
|
||||
| Event::ArtifactCaptured { node_id, .. }
|
||||
| Event::PromptCompleted { node_id, .. }
|
||||
| Event::ParallelStarted { node_id, .. }
|
||||
| Event::ParallelCompleted { node_id, .. }
|
||||
| Event::CommandStarted { node_id, .. }
|
||||
| Event::CommandCompleted { node_id, .. }
|
||||
| Event::AgentCliStarted { node_id, .. }
|
||||
| Event::AgentCliCompleted { node_id, .. } => {
|
||||
let node_id = Some(node_id.clone());
|
||||
let node_label = default_node_label(node_id.as_ref(), None);
|
||||
StoredEventFields {
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
node_id,
|
||||
node_label,
|
||||
}
|
||||
}
|
||||
| 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());
|
||||
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, .. } => {
|
||||
let node_label = default_node_label(node_id.as_ref(), None);
|
||||
StoredEventFields {
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
node_id: node_id.clone(),
|
||||
node_label,
|
||||
}
|
||||
Event::GitCommit { node_id, .. } => node_stored_fields(node_id.clone()),
|
||||
Event::ParallelBranchStarted {
|
||||
parallel_group_id,
|
||||
parallel_branch_id,
|
||||
branch,
|
||||
..
|
||||
}
|
||||
Event::ParallelBranchStarted { branch, .. }
|
||||
| Event::ParallelBranchCompleted { 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 {
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
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::InterviewTimeout { stage, .. }
|
||||
| Event::InterviewInterrupted { stage, .. }
|
||||
| Event::Failover { stage, .. } => {
|
||||
let node_id = Some(stage.clone());
|
||||
let node_label = default_node_label(node_id.as_ref(), None);
|
||||
StoredEventFields {
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
node_id,
|
||||
node_label,
|
||||
}
|
||||
}
|
||||
Event::StallWatchdogTimeout { node, .. } => {
|
||||
let node_id = Some(node.clone());
|
||||
let node_label = default_node_label(node_id.as_ref(), None);
|
||||
StoredEventFields {
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
node_id,
|
||||
node_label,
|
||||
}
|
||||
}
|
||||
_ => StoredEventFields {
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
node_id: None,
|
||||
node_label: None,
|
||||
},
|
||||
| Event::Failover { stage, .. } => node_stored_fields(Some(stage.clone())),
|
||||
Event::StallWatchdogTimeout { node, .. } => node_stored_fields(Some(node.clone())),
|
||||
_ => StoredEventFields::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn actor_from_provenance(provenance: &RunProvenance) -> Option<ActorRef> {
|
||||
provenance
|
||||
.subject
|
||||
.as_ref()?
|
||||
.login
|
||||
.clone()
|
||||
.map(ActorRef::user)
|
||||
}
|
||||
|
||||
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>) -> Option<ActorRef> {
|
||||
match event {
|
||||
AgentEvent::AssistantMessage { model, .. } => Some(ActorRef {
|
||||
kind: ActorKind::Agent,
|
||||
id: session_id.map(str::to_string),
|
||||
display: Some(model.clone()),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1446,17 +1528,17 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
Event::RunRemoving { reason } => {
|
||||
EventBody::RunRemoving(fabro_types::RunStatusTransitionProps { reason: *reason })
|
||||
}
|
||||
Event::RunCancelRequested => {
|
||||
Event::RunCancelRequested { .. } => {
|
||||
EventBody::RunCancelRequested(fabro_types::RunControlRequestedProps {
|
||||
action: RunControlAction::Cancel,
|
||||
})
|
||||
}
|
||||
Event::RunPauseRequested => {
|
||||
Event::RunPauseRequested { .. } => {
|
||||
EventBody::RunPauseRequested(fabro_types::RunControlRequestedProps {
|
||||
action: RunControlAction::Pause,
|
||||
})
|
||||
}
|
||||
Event::RunUnpauseRequested => {
|
||||
Event::RunUnpauseRequested { .. } => {
|
||||
EventBody::RunUnpauseRequested(fabro_types::RunControlRequestedProps {
|
||||
action: RunControlAction::Unpause,
|
||||
})
|
||||
|
|
@ -2381,12 +2463,51 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn to_run_event(run_id: &RunId, event: &Event) -> RunEvent {
|
||||
to_run_event_at(run_id, event, Utc::now())
|
||||
/// 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<StageId>,
|
||||
pub parallel_branch_id: Option<ParallelBranchId>,
|
||||
}
|
||||
|
||||
pub fn to_run_event_at(run_id: &RunId, event: &Event, ts: chrono::DateTime<Utc>) -> RunEvent {
|
||||
let fields = stored_event_fields(event);
|
||||
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<String>) -> 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_attempt 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<String>) -> Self {
|
||||
context
|
||||
.current_stage_scope()
|
||||
.unwrap_or_else(|| Self::from_context(context, node_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_run_event(run_id: &RunId, event: &Event) -> RunEvent {
|
||||
to_run_event_at(run_id, event, Utc::now(), None)
|
||||
}
|
||||
|
||||
pub fn to_run_event_at(
|
||||
run_id: &RunId,
|
||||
event: &Event,
|
||||
ts: chrono::DateTime<Utc>,
|
||||
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(),
|
||||
|
|
@ -2394,8 +2515,13 @@ pub fn to_run_event_at(run_id: &RunId, event: &Event, ts: chrono::DateTime<Utc>)
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -2654,6 +2780,14 @@ impl Emitter {
|
|||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -2662,7 +2796,7 @@ impl Emitter {
|
|||
"workflow run started event must match emitter run_id"
|
||||
);
|
||||
}
|
||||
let stored = to_run_event(&self.run_id, event);
|
||||
let stored = to_run_event_at(&self.run_id, event, Utc::now(), scope);
|
||||
self.dispatch_run_event(&stored);
|
||||
}
|
||||
|
||||
|
|
@ -2753,7 +2887,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn run_event_stage_completed_places_node_fields_in_header() {
|
||||
let stored = to_run_event(
|
||||
let stored = to_run_event_at(
|
||||
&fixtures::RUN_2,
|
||||
&Event::StageCompleted {
|
||||
node_id: "plan".to_string(),
|
||||
|
|
@ -2777,12 +2911,20 @@ mod tests {
|
|||
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"], "success");
|
||||
|
|
@ -2951,7 +3093,7 @@ mod tests {
|
|||
|
||||
let (writer, reader) = tokio::io::duplex(4096);
|
||||
let sink = RunEventSink::json_lines(writer);
|
||||
let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested);
|
||||
let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None });
|
||||
|
||||
sink.write_run_event(&event).await.unwrap();
|
||||
|
||||
|
|
@ -3016,6 +3158,8 @@ mod tests {
|
|||
);
|
||||
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,
|
||||
}),
|
||||
|
|
@ -3036,4 +3180,255 @@ mod tests {
|
|||
"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.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 { reason: None });
|
||||
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 = ActorRef {
|
||||
kind: ActorKind::User,
|
||||
id: Some("alice".to_string()),
|
||||
display: Some("alice".to_string()),
|
||||
};
|
||||
|
||||
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 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.kind, ActorKind::Agent);
|
||||
assert_eq!(actor.id.as_deref(), Some("ses_agent"));
|
||||
assert_eq!(actor.display.as_deref(), Some("claude-sonnet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_created_populates_user_actor_from_provenance() {
|
||||
use ::fabro_types::settings::SettingsFile;
|
||||
use ::fabro_types::{Graph, RunAuthMethod, RunSubjectProvenance, fixtures};
|
||||
|
||||
let provenance = RunProvenance {
|
||||
server: None,
|
||||
client: None,
|
||||
subject: Some(RunSubjectProvenance {
|
||||
login: Some("alice".to_string()),
|
||||
auth_method: RunAuthMethod::Cookie,
|
||||
}),
|
||||
};
|
||||
|
||||
let stored = to_run_event(
|
||||
&fixtures::RUN_1,
|
||||
&Event::RunCreated {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(SettingsFile::default()).unwrap(),
|
||||
graph: serde_json::to_value(Graph::new("test")).unwrap(),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: Default::default(),
|
||||
run_dir: "/tmp/run".to_string(),
|
||||
working_directory: "/tmp/run".to_string(),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
workflow_slug: None,
|
||||
db_prefix: None,
|
||||
provenance: Some(provenance),
|
||||
manifest_blob: None,
|
||||
},
|
||||
);
|
||||
let actor = stored.actor.as_ref().expect("actor set");
|
||||
assert_eq!(actor.kind, ActorKind::User);
|
||||
assert_eq!(actor.id.as_deref(), Some("alice"));
|
||||
assert_eq!(actor.display.as_deref(), Some("alice"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use fabro_types::RunId;
|
|||
use crate::context::keys;
|
||||
use crate::context::{Context, WorkflowContext};
|
||||
use crate::error::FabroError;
|
||||
use crate::event::{Emitter, Event};
|
||||
use crate::event::{Emitter, Event, StageScope};
|
||||
use crate::outcome::{
|
||||
BilledModelUsage, FailureCategory, FailureDetail, Outcome, OutcomeExt, StageStatus,
|
||||
};
|
||||
|
|
@ -256,14 +256,18 @@ impl Handler for AgentHandler {
|
|||
.map(String::from)
|
||||
.or_else(|| Some(Provider::default_from_env().as_str().to_string()));
|
||||
let prompt_model = node.model().map(String::from);
|
||||
services.emitter.emit(&Event::Prompt {
|
||||
stage: node.id.clone(),
|
||||
visit,
|
||||
text: prompt.clone(),
|
||||
mode: Some("agent".to_string()),
|
||||
provider: prompt_provider,
|
||||
model: prompt_model,
|
||||
});
|
||||
let stage_scope = StageScope::for_handler(context, &node.id);
|
||||
services.emitter.emit_scoped(
|
||||
&Event::Prompt {
|
||||
stage: node.id.clone(),
|
||||
visit,
|
||||
text: prompt.clone(),
|
||||
mode: Some("agent".to_string()),
|
||||
provider: prompt_provider,
|
||||
model: prompt_model,
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
// 3. Call LLM backend (agent loop)
|
||||
let thread_id = context.thread_id();
|
||||
|
|
@ -329,13 +333,16 @@ impl Handler for AgentHandler {
|
|||
.map(String::from)
|
||||
.or_else(|| Some(Provider::default_from_env().as_str().to_string()))
|
||||
.unwrap_or_default();
|
||||
services.emitter.emit(&Event::PromptCompleted {
|
||||
node_id: node.id.clone(),
|
||||
response: response_text.clone(),
|
||||
model: response_model,
|
||||
provider: response_provider,
|
||||
billing: stage_usage.clone(),
|
||||
});
|
||||
services.emitter.emit_scoped(
|
||||
&Event::PromptCompleted {
|
||||
node_id: node.id.clone(),
|
||||
response: response_text.clone(),
|
||||
model: response_model,
|
||||
provider: response_provider,
|
||||
billing: stage_usage.clone(),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
// Build and write status
|
||||
let mut outcome = Outcome::success();
|
||||
|
|
@ -709,17 +716,21 @@ mod tests {
|
|||
_sandbox: &Arc<dyn fabro_agent::Sandbox>,
|
||||
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
|
||||
) -> Result<CodergenResult, FabroError> {
|
||||
emitter.emit(&crate::event::Event::Agent {
|
||||
stage: node.id.clone(),
|
||||
visit: u32::try_from(crate::run_dir::visit_from_context(context))
|
||||
.unwrap_or(u32::MAX),
|
||||
event: fabro_agent::AgentEvent::SessionStarted {
|
||||
provider: Some("openai".to_string()),
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
let scope = StageScope::for_handler(context, &node.id);
|
||||
emitter.emit_scoped(
|
||||
&crate::event::Event::Agent {
|
||||
stage: node.id.clone(),
|
||||
visit: u32::try_from(crate::run_dir::visit_from_context(context))
|
||||
.unwrap_or(u32::MAX),
|
||||
event: fabro_agent::AgentEvent::SessionStarted {
|
||||
provider: Some("openai".to_string()),
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
},
|
||||
session_id: Some("session_123".to_string()),
|
||||
parent_session_id: None,
|
||||
},
|
||||
session_id: Some("session_123".to_string()),
|
||||
parent_session_id: None,
|
||||
});
|
||||
&scope,
|
||||
);
|
||||
Ok(CodergenResult::Text {
|
||||
text: "done".to_string(),
|
||||
usage: None,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use crate::context::Context;
|
|||
use crate::context::keys;
|
||||
use crate::error::FabroError;
|
||||
use crate::event::Event;
|
||||
use crate::event::StageScope;
|
||||
use crate::outcome::{Outcome, OutcomeExt};
|
||||
use async_trait::async_trait;
|
||||
use fabro_graphviz::graph::{Graph, Node};
|
||||
|
|
@ -57,7 +58,7 @@ impl Handler for CommandHandler {
|
|||
async fn execute(
|
||||
&self,
|
||||
node: &Node,
|
||||
_context: &Context,
|
||||
context: &Context,
|
||||
_graph: &Graph,
|
||||
_run_dir: &Path,
|
||||
services: &EngineServices,
|
||||
|
|
@ -90,13 +91,17 @@ impl Handler for CommandHandler {
|
|||
} else {
|
||||
script.to_string()
|
||||
};
|
||||
services.emitter.emit(&Event::CommandStarted {
|
||||
node_id: node.id.clone(),
|
||||
script: script.to_string(),
|
||||
command: command.clone(),
|
||||
language: language.to_string(),
|
||||
timeout_ms: timeout_ms(node),
|
||||
});
|
||||
let stage_scope = StageScope::for_handler(context, &node.id);
|
||||
services.emitter.emit_scoped(
|
||||
&Event::CommandStarted {
|
||||
node_id: node.id.clone(),
|
||||
script: script.to_string(),
|
||||
command: command.clone(),
|
||||
language: language.to_string(),
|
||||
timeout_ms: timeout_ms(node),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
let timeout_ms = node
|
||||
.timeout()
|
||||
|
|
@ -118,14 +123,17 @@ impl Handler for CommandHandler {
|
|||
let result =
|
||||
result.map_err(|e| FabroError::handler(format!("Failed to spawn script: {e}")))?;
|
||||
|
||||
services.emitter.emit(&Event::CommandCompleted {
|
||||
node_id: node.id.clone(),
|
||||
stdout: result.stdout.clone(),
|
||||
stderr: result.stderr.clone(),
|
||||
exit_code: (!result.timed_out).then_some(result.exit_code),
|
||||
duration_ms: result.duration_ms,
|
||||
timed_out: result.timed_out,
|
||||
});
|
||||
services.emitter.emit_scoped(
|
||||
&Event::CommandCompleted {
|
||||
node_id: node.id.clone(),
|
||||
stdout: result.stdout.clone(),
|
||||
stderr: result.stderr.clone(),
|
||||
exit_code: (!result.timed_out).then_some(result.exit_code),
|
||||
duration_ms: result.duration_ms,
|
||||
timed_out: result.timed_out,
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
if result.timed_out {
|
||||
return Err(FabroError::handler(format!(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||
use crate::context::Context;
|
||||
use crate::context::keys;
|
||||
use crate::error::FabroError;
|
||||
use crate::event::{Emitter, Event};
|
||||
use crate::event::{Emitter, Event, StageScope};
|
||||
use crate::outcome::{Outcome, OutcomeExt};
|
||||
use crate::run_dir::visit_from_context;
|
||||
use crate::sandbox_git::git_merge_ff_only;
|
||||
|
|
@ -232,15 +232,19 @@ async fn llm_evaluate(
|
|||
);
|
||||
|
||||
let visit_u32 = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX);
|
||||
let stage_scope = StageScope::for_handler(context, node_id);
|
||||
|
||||
emitter.emit(&Event::Prompt {
|
||||
stage: node_id.to_string(),
|
||||
visit: visit_u32,
|
||||
text: full_prompt.clone(),
|
||||
mode: Some("fan_in".to_string()),
|
||||
provider: None,
|
||||
model: None,
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::Prompt {
|
||||
stage: node_id.to_string(),
|
||||
visit: visit_u32,
|
||||
text: full_prompt.clone(),
|
||||
mode: Some("fan_in".to_string()),
|
||||
provider: None,
|
||||
model: None,
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
// Build a synthetic node for the backend call
|
||||
let eval_node = Node::new("fan_in_eval");
|
||||
|
|
@ -269,13 +273,16 @@ async fn llm_evaluate(
|
|||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let response_text =
|
||||
serde_json::to_string_pretty(&outcome).unwrap_or_else(|_| "{}".to_string());
|
||||
emitter.emit(&Event::PromptCompleted {
|
||||
node_id: node_id.to_string(),
|
||||
response: response_text.clone(),
|
||||
model: String::new(),
|
||||
provider: String::new(),
|
||||
billing: None,
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::PromptCompleted {
|
||||
node_id: node_id.to_string(),
|
||||
response: response_text.clone(),
|
||||
model: String::new(),
|
||||
provider: String::new(),
|
||||
billing: None,
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
Ok(Candidate {
|
||||
id: best_id,
|
||||
status: outcome.status.to_string(),
|
||||
|
|
@ -283,13 +290,16 @@ async fn llm_evaluate(
|
|||
})
|
||||
}
|
||||
Ok(CodergenResult::Text { text, .. }) => {
|
||||
emitter.emit(&Event::PromptCompleted {
|
||||
node_id: node_id.to_string(),
|
||||
response: text.clone(),
|
||||
model: String::new(),
|
||||
provider: String::new(),
|
||||
billing: None,
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::PromptCompleted {
|
||||
node_id: node_id.to_string(),
|
||||
response: text.clone(),
|
||||
model: String::new(),
|
||||
provider: String::new(),
|
||||
billing: None,
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
// The LLM responded with text; try to find a matching candidate ID
|
||||
let text = text.trim().to_string();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use async_trait::async_trait;
|
|||
use crate::context::Context;
|
||||
use crate::context::keys;
|
||||
use crate::error::FabroError;
|
||||
use crate::event::{Emitter, Event};
|
||||
use crate::event::{Emitter, Event, StageScope};
|
||||
use crate::millis_u64;
|
||||
use crate::outcome::{Outcome, OutcomeExt};
|
||||
use fabro_graphviz::graph::{Graph, Node};
|
||||
|
|
@ -88,10 +88,10 @@ impl HumanHandler {
|
|||
self
|
||||
}
|
||||
|
||||
fn emit(&self, default_emitter: &Arc<Emitter>, event: &Event) {
|
||||
fn emit(&self, default_emitter: &Arc<Emitter>, event: &Event, scope: &StageScope) {
|
||||
match &self.emitter {
|
||||
Some(emitter) => emitter.emit(event),
|
||||
None => default_emitter.emit(event),
|
||||
Some(emitter) => emitter.emit_scoped(event, scope),
|
||||
None => default_emitter.emit_scoped(event, scope),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -209,6 +209,7 @@ impl Handler for HumanHandler {
|
|||
// 3. Present to interviewer
|
||||
let question_text = node.label().to_string();
|
||||
let question_id = question.id.clone();
|
||||
let stage_scope = StageScope::for_handler(context, &node.id);
|
||||
self.emit(
|
||||
&services.emitter,
|
||||
&Event::InterviewStarted {
|
||||
|
|
@ -228,6 +229,7 @@ impl Handler for HumanHandler {
|
|||
timeout_seconds: question.timeout_seconds,
|
||||
context_display: question.context_display.clone(),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
let interview_start = Instant::now();
|
||||
let answer = self.interviewer.ask(question).await;
|
||||
|
|
@ -242,6 +244,7 @@ impl Handler for HumanHandler {
|
|||
stage: node.id.clone(),
|
||||
duration_ms: millis_u64(interview_start.elapsed()),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
let default_choice = node
|
||||
.attrs
|
||||
|
|
@ -279,6 +282,7 @@ impl Handler for HumanHandler {
|
|||
reason: "interrupted".to_string(),
|
||||
duration_ms: millis_u64(interview_start.elapsed()),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
return Ok(unanswered_human_gate(
|
||||
"human interaction interrupted before an answer was provided",
|
||||
|
|
@ -293,6 +297,7 @@ impl Handler for HumanHandler {
|
|||
answer: answer_text(&answer),
|
||||
duration_ms: millis_u64(interview_start.elapsed()),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
return Ok(unanswered_human_gate("human skipped interaction"));
|
||||
}
|
||||
|
|
@ -306,6 +311,7 @@ impl Handler for HumanHandler {
|
|||
answer: answer_text(&answer),
|
||||
duration_ms: millis_u64(interview_start.elapsed()),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
// 6. Try fixed-choice match
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex};
|
|||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::event::StageScope;
|
||||
use fabro_agent::{
|
||||
AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, OpenAiProfile, Sandbox, Session,
|
||||
SessionOptions, Turn,
|
||||
|
|
@ -21,7 +22,6 @@ use crate::context::{Context, WorkflowContext};
|
|||
use crate::error::FabroError;
|
||||
use crate::event::{Emitter, Event};
|
||||
use crate::outcome::billed_model_usage_from_llm;
|
||||
use crate::run_dir::visit_from_context;
|
||||
use fabro_graphviz::graph::Node;
|
||||
|
||||
fn build_profile(model: &str, provider: Provider) -> Box<dyn AgentProfile> {
|
||||
|
|
@ -37,10 +37,6 @@ fn build_profile(model: &str, provider: Provider) -> Box<dyn AgentProfile> {
|
|||
}
|
||||
}
|
||||
|
||||
fn current_visit(context: &Context) -> u32 {
|
||||
u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// Shared state for tracking file modifications from agent tool calls.
|
||||
struct FileTracking {
|
||||
/// Maps tool_call_id → file_path for in-flight write/edit calls.
|
||||
|
|
@ -86,7 +82,7 @@ fn track_file_event(event: &AgentEvent, state: &mut FileTracking) {
|
|||
fn spawn_event_forwarder(
|
||||
session: &Session,
|
||||
node_id: String,
|
||||
visit: u32,
|
||||
scope: StageScope,
|
||||
emitter: Arc<Emitter>,
|
||||
file_tracking: Arc<Mutex<FileTracking>>,
|
||||
) {
|
||||
|
|
@ -103,13 +99,16 @@ fn spawn_event_forwarder(
|
|||
if !event.event.is_streaming_noise()
|
||||
&& !matches!(&event.event, AgentEvent::ProcessingEnd)
|
||||
{
|
||||
emitter.emit(&Event::Agent {
|
||||
stage: node_id.clone(),
|
||||
visit,
|
||||
event: event.event.clone(),
|
||||
session_id: Some(event.session_id.clone()),
|
||||
parent_session_id: event.parent_session_id.clone(),
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::Agent {
|
||||
stage: node_id.clone(),
|
||||
visit: scope.visit,
|
||||
event: event.event.clone(),
|
||||
session_id: Some(event.session_id.clone()),
|
||||
parent_session_id: event.parent_session_id.clone(),
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -455,12 +454,13 @@ impl CodergenBackend for AgentApiBackend {
|
|||
touched: HashSet::new(),
|
||||
last: None,
|
||||
}));
|
||||
let event_scope = StageScope::for_handler(context, &node.id);
|
||||
|
||||
// Subscribe to session events: forward to pipeline emitter + track files.
|
||||
spawn_event_forwarder(
|
||||
&session,
|
||||
node.id.clone(),
|
||||
current_visit(context),
|
||||
event_scope.clone(),
|
||||
Arc::clone(emitter),
|
||||
Arc::clone(&file_tracking),
|
||||
);
|
||||
|
|
@ -488,14 +488,17 @@ impl CodergenBackend for AgentApiBackend {
|
|||
let mut succeeded = false;
|
||||
|
||||
for target in &self.fallback_chain {
|
||||
emitter.emit(&Event::Failover {
|
||||
stage: node.id.clone(),
|
||||
from_provider: from_provider.clone(),
|
||||
from_model: from_model.clone(),
|
||||
to_provider: target.provider.clone(),
|
||||
to_model: target.model.clone(),
|
||||
error: error_msg.clone(),
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::Failover {
|
||||
stage: node.id.clone(),
|
||||
from_provider: from_provider.clone(),
|
||||
from_model: from_model.clone(),
|
||||
to_provider: target.provider.clone(),
|
||||
to_model: target.model.clone(),
|
||||
error: error_msg.clone(),
|
||||
},
|
||||
&event_scope,
|
||||
);
|
||||
|
||||
let target_provider: Provider = match target.provider.parse() {
|
||||
Ok(p) => p,
|
||||
|
|
@ -525,7 +528,7 @@ impl CodergenBackend for AgentApiBackend {
|
|||
spawn_event_forwarder(
|
||||
&session,
|
||||
node.id.clone(),
|
||||
current_visit(context),
|
||||
event_scope.clone(),
|
||||
Arc::clone(emitter),
|
||||
Arc::clone(&file_tracking),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use tokio::time::sleep;
|
|||
use super::super::agent::{CodergenBackend, CodergenResult};
|
||||
use crate::context::Context;
|
||||
use crate::error::FabroError;
|
||||
use crate::event::{Emitter, Event};
|
||||
use crate::event::{Emitter, Event, StageScope};
|
||||
use crate::outcome::billed_model_usage_from_llm;
|
||||
use crate::run_dir::visit_from_context;
|
||||
use fabro_graphviz::graph::Node;
|
||||
|
|
@ -496,14 +496,18 @@ impl CodergenBackend for AgentCliBackend {
|
|||
ensure_cli(cli, provider, sandbox, emitter).await?;
|
||||
|
||||
let command = cli_command_for_provider(provider, model, &prompt_path);
|
||||
emitter.emit(&Event::AgentCliStarted {
|
||||
node_id: node.id.clone(),
|
||||
visit: current_visit(_context),
|
||||
mode: "cli".to_string(),
|
||||
provider: provider.as_str().to_string(),
|
||||
model: model.to_string(),
|
||||
command: command.clone(),
|
||||
});
|
||||
let stage_scope = StageScope::for_handler(_context, &node.id);
|
||||
emitter.emit_scoped(
|
||||
&Event::AgentCliStarted {
|
||||
node_id: node.id.clone(),
|
||||
visit: current_visit(_context),
|
||||
mode: "cli".to_string(),
|
||||
provider: provider.as_str().to_string(),
|
||||
model: model.to_string(),
|
||||
command: command.clone(),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
// Forward provider API key and custom env vars so the CLI tool can authenticate.
|
||||
// Build a HashMap to pass via exec_command's env_vars parameter — this
|
||||
|
|
@ -620,13 +624,16 @@ impl CodergenBackend for AgentCliBackend {
|
|||
timed_out: false,
|
||||
duration_ms,
|
||||
};
|
||||
emitter.emit(&Event::AgentCliCompleted {
|
||||
node_id: node.id.clone(),
|
||||
stdout: result.stdout.clone(),
|
||||
stderr: result.stderr.clone(),
|
||||
exit_code: result.exit_code,
|
||||
duration_ms: result.duration_ms,
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::AgentCliCompleted {
|
||||
node_id: node.id.clone(),
|
||||
stdout: result.stdout.clone(),
|
||||
stderr: result.stderr.clone(),
|
||||
exit_code: result.exit_code,
|
||||
duration_ms: result.duration_ms,
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
// 3e. Cleanup temp files
|
||||
let _ = sandbox
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ use std::time::Instant;
|
|||
|
||||
use async_trait::async_trait;
|
||||
use fabro_agent::{Sandbox, WorktreeOptions, WorktreeSandbox};
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::{ParallelBranchId, RunId, StageId};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::context::keys;
|
||||
use crate::context::{Context, WorkflowContext};
|
||||
use crate::error::FabroError;
|
||||
use crate::event::Event;
|
||||
use crate::event::{Event, StageScope};
|
||||
use crate::git::sanitize_ref_component;
|
||||
use crate::hook_context::set_hook_node;
|
||||
use crate::millis_u64;
|
||||
|
|
@ -132,6 +132,7 @@ impl Handler for ParallelHandler {
|
|||
struct BranchSetup {
|
||||
target_id: String,
|
||||
branch_index: usize,
|
||||
parallel_branch_id: ParallelBranchId,
|
||||
branch_context: Context,
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
worktree_path: Option<PathBuf>,
|
||||
|
|
@ -150,9 +151,12 @@ impl Handler for ParallelHandler {
|
|||
.unwrap_or("wait_all"),
|
||||
);
|
||||
|
||||
let parallel_visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX);
|
||||
let parallel_group_id = StageId::new(node.id.clone(), parallel_visit);
|
||||
|
||||
services.emitter.emit(&Event::ParallelStarted {
|
||||
node_id: node.id.clone(),
|
||||
visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX),
|
||||
visit: parallel_visit,
|
||||
branch_count: branches.len(),
|
||||
join_policy: join_policy.to_string(),
|
||||
});
|
||||
|
|
@ -204,6 +208,18 @@ impl Handler for ParallelHandler {
|
|||
for (branch_index, edge) in branches.iter().enumerate() {
|
||||
let target_id = edge.to.clone();
|
||||
let branch_context = context.fork();
|
||||
let parallel_branch_id = ParallelBranchId::new(
|
||||
parallel_group_id.clone(),
|
||||
u32::try_from(branch_index).unwrap_or(u32::MAX),
|
||||
);
|
||||
branch_context.set(
|
||||
keys::INTERNAL_PARALLEL_GROUP_ID,
|
||||
serde_json::Value::String(parallel_group_id.to_string()),
|
||||
);
|
||||
branch_context.set(
|
||||
keys::INTERNAL_PARALLEL_BRANCH_ID,
|
||||
serde_json::Value::String(parallel_branch_id.to_string()),
|
||||
);
|
||||
|
||||
let (branch_sandbox, worktree_path): (Arc<dyn Sandbox>, Option<PathBuf>) = if let (
|
||||
Some(ref gs),
|
||||
|
|
@ -256,6 +272,7 @@ impl Handler for ParallelHandler {
|
|||
branch_setups.push(BranchSetup {
|
||||
target_id,
|
||||
branch_index,
|
||||
parallel_branch_id,
|
||||
branch_context,
|
||||
sandbox: branch_sandbox,
|
||||
worktree_path,
|
||||
|
|
@ -283,6 +300,13 @@ impl Handler for ParallelHandler {
|
|||
.as_ref()
|
||||
.map(|gs| gs.git_author.clone())
|
||||
.unwrap_or_default();
|
||||
let group_id = parallel_group_id.clone();
|
||||
let branch_scope = StageScope {
|
||||
node_id: setup.target_id.clone(),
|
||||
visit: 1,
|
||||
parallel_group_id: Some(group_id.clone()),
|
||||
parallel_branch_id: Some(setup.parallel_branch_id.clone()),
|
||||
};
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _permit = sem
|
||||
|
|
@ -290,10 +314,15 @@ impl Handler for ParallelHandler {
|
|||
.await
|
||||
.map_err(|e| FabroError::handler(format!("semaphore error: {e}")))?;
|
||||
|
||||
emitter.emit(&Event::ParallelBranchStarted {
|
||||
branch: setup.target_id.clone(),
|
||||
index: setup.branch_index,
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::ParallelBranchStarted {
|
||||
parallel_group_id: group_id.clone(),
|
||||
parallel_branch_id: setup.parallel_branch_id.clone(),
|
||||
branch: setup.target_id.clone(),
|
||||
index: setup.branch_index,
|
||||
},
|
||||
&branch_scope,
|
||||
);
|
||||
let branch_start = Instant::now();
|
||||
|
||||
let Some(target_node) = graph.nodes.get(&setup.target_id) else {
|
||||
|
|
@ -301,13 +330,18 @@ impl Handler for ParallelHandler {
|
|||
"branch target node not found: {}",
|
||||
setup.target_id
|
||||
));
|
||||
emitter.emit(&Event::ParallelBranchCompleted {
|
||||
branch: setup.target_id.clone(),
|
||||
index: setup.branch_index,
|
||||
duration_ms: millis_u64(branch_start.elapsed()),
|
||||
status: "fail".to_string(),
|
||||
head_sha: None,
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::ParallelBranchCompleted {
|
||||
parallel_group_id: group_id.clone(),
|
||||
parallel_branch_id: setup.parallel_branch_id.clone(),
|
||||
branch: setup.target_id.clone(),
|
||||
index: setup.branch_index,
|
||||
duration_ms: millis_u64(branch_start.elapsed()),
|
||||
status: "fail".to_string(),
|
||||
head_sha: None,
|
||||
},
|
||||
&branch_scope,
|
||||
);
|
||||
return Ok(BranchResult {
|
||||
id: setup.target_id.clone(),
|
||||
outcome,
|
||||
|
|
@ -373,10 +407,13 @@ impl Handler for ParallelHandler {
|
|||
match sha_result {
|
||||
Ok(r) if r.exit_code == 0 => {
|
||||
let sha = r.stdout.trim().to_string();
|
||||
emitter.emit(&Event::GitCommit {
|
||||
node_id: Some(setup.target_id.clone()),
|
||||
sha: sha.clone(),
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::GitCommit {
|
||||
node_id: Some(setup.target_id.clone()),
|
||||
sha: sha.clone(),
|
||||
},
|
||||
&branch_scope,
|
||||
);
|
||||
Some(sha)
|
||||
}
|
||||
_ => None,
|
||||
|
|
@ -385,13 +422,18 @@ impl Handler for ParallelHandler {
|
|||
None
|
||||
};
|
||||
|
||||
emitter.emit(&Event::ParallelBranchCompleted {
|
||||
branch: setup.target_id.clone(),
|
||||
index: setup.branch_index,
|
||||
duration_ms: millis_u64(branch_start.elapsed()),
|
||||
status: outcome.status.to_string(),
|
||||
head_sha: head_sha.clone(),
|
||||
});
|
||||
emitter.emit_scoped(
|
||||
&Event::ParallelBranchCompleted {
|
||||
parallel_group_id: group_id.clone(),
|
||||
parallel_branch_id: setup.parallel_branch_id.clone(),
|
||||
branch: setup.target_id.clone(),
|
||||
index: setup.branch_index,
|
||||
duration_ms: millis_u64(branch_start.elapsed()),
|
||||
status: outcome.status.to_string(),
|
||||
head_sha: head_sha.clone(),
|
||||
},
|
||||
&branch_scope,
|
||||
);
|
||||
|
||||
Ok::<BranchResult, FabroError>(BranchResult {
|
||||
id: setup.target_id,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::path::Path;
|
|||
use crate::context::keys;
|
||||
use crate::context::{Context, WorkflowContext};
|
||||
use crate::error::FabroError;
|
||||
use crate::event::Event;
|
||||
use crate::event::{Event, StageScope};
|
||||
use crate::outcome::Outcome;
|
||||
use crate::run_dir::visit_from_context;
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -91,14 +91,18 @@ impl Handler for PromptHandler {
|
|||
.map(String::from)
|
||||
.or_else(|| Some(Provider::default_from_env().as_str().to_string()));
|
||||
let prompt_model = node.model().map(String::from);
|
||||
services.emitter.emit(&Event::Prompt {
|
||||
stage: node.id.clone(),
|
||||
visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX),
|
||||
text: prompt.clone(),
|
||||
mode: Some("prompt".to_string()),
|
||||
provider: prompt_provider.clone(),
|
||||
model: prompt_model.clone(),
|
||||
});
|
||||
let stage_scope = StageScope::for_handler(context, &node.id);
|
||||
services.emitter.emit_scoped(
|
||||
&Event::Prompt {
|
||||
stage: node.id.clone(),
|
||||
visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX),
|
||||
text: prompt.clone(),
|
||||
mode: Some("prompt".to_string()),
|
||||
provider: prompt_provider.clone(),
|
||||
model: prompt_model.clone(),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
// 3. Call LLM backend (one_shot)
|
||||
let (response_text, stage_usage, backend_files_touched) =
|
||||
|
|
@ -140,13 +144,16 @@ impl Handler for PromptHandler {
|
|||
.or_else(|| Some(Provider::default_from_env().as_str().to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
services.emitter.emit(&Event::PromptCompleted {
|
||||
node_id: node.id.clone(),
|
||||
response: response_text.clone(),
|
||||
model: response_model,
|
||||
provider: response_provider,
|
||||
billing: stage_usage.clone(),
|
||||
});
|
||||
services.emitter.emit_scoped(
|
||||
&Event::PromptCompleted {
|
||||
node_id: node.id.clone(),
|
||||
response: response_text.clone(),
|
||||
model: response_model,
|
||||
provider: response_provider,
|
||||
billing: stage_usage.clone(),
|
||||
},
|
||||
&stage_scope,
|
||||
);
|
||||
|
||||
// 4. Build and write status
|
||||
let mut outcome = Outcome::success();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use crate::artifact_upload::ArtifactSink;
|
|||
use crate::event::{Emitter, Event, RunNoticeLevel};
|
||||
use crate::graph::WorkflowGraph;
|
||||
use crate::graph::WorkflowNode;
|
||||
use crate::lifecycle::event::stage_scope_for;
|
||||
use crate::outcome::BilledModelUsage;
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
use fabro_core::lifecycle::NodeDecision;
|
||||
|
|
@ -136,18 +137,22 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
|
|||
});
|
||||
return Ok(());
|
||||
}
|
||||
let scope = stage_scope_for(state, node_id);
|
||||
for asset in &summary.captured_assets {
|
||||
self.captured_artifact_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.emitter.emit(&Event::ArtifactCaptured {
|
||||
node_id: node_id.to_string(),
|
||||
attempt: ctx.attempt,
|
||||
node_slug: node_slug.clone(),
|
||||
path: asset.path.clone(),
|
||||
mime: asset.mime.clone(),
|
||||
content_md5: asset.content_md5.clone(),
|
||||
content_sha256: asset.content_sha256.clone(),
|
||||
bytes: asset.bytes,
|
||||
});
|
||||
self.emitter.emit_scoped(
|
||||
&Event::ArtifactCaptured {
|
||||
node_id: node_id.to_string(),
|
||||
attempt: ctx.attempt,
|
||||
node_slug: node_slug.clone(),
|
||||
path: asset.path.clone(),
|
||||
mime: asset.mime.clone(),
|
||||
content_md5: asset.content_md5.clone(),
|
||||
content_sha256: asset.content_sha256.clone(),
|
||||
bytes: asset.bytes,
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // no files collected
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ use super::circuit_breaker::CircuitBreakerLifecycle;
|
|||
use super::git::GitCheckpointResult;
|
||||
use crate::artifact;
|
||||
use crate::context;
|
||||
use crate::context::WorkflowContext;
|
||||
use crate::error::FabroError;
|
||||
use crate::event::{Emitter, Event};
|
||||
use crate::event::{Emitter, Event, StageScope};
|
||||
use crate::graph::WorkflowGraph;
|
||||
use crate::graph::WorkflowNode;
|
||||
use crate::outcome::{BilledModelUsage, FailureCategory, FailureDetail, Outcome, StageStatus};
|
||||
|
|
@ -79,6 +80,20 @@ fn response_from_outcome(node_id: &str, outcome: &Outcome) -> Option<String> {
|
|||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||
}
|
||||
|
||||
fn stage_visit(state: &WfRunState, node_id: &str) -> u32 {
|
||||
let visits = state.node_visits.get(node_id).copied().unwrap_or(1);
|
||||
u32::try_from(visits.max(1)).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
pub(crate) fn stage_scope_for(state: &WfRunState, node_id: &str) -> StageScope {
|
||||
StageScope {
|
||||
node_id: node_id.to_string(),
|
||||
visit: stage_visit(state, node_id),
|
||||
parallel_group_id: state.context.parallel_group_id(),
|
||||
parallel_branch_id: state.context.parallel_branch_id(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
||||
async fn on_run_start(&self, _graph: &WorkflowGraph, _state: &WfRunState) -> CoreResult<()> {
|
||||
|
|
@ -120,41 +135,48 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
|||
}
|
||||
let gv = node.inner();
|
||||
let stage_index = state.stage_index;
|
||||
let scope = stage_scope_for(state, &gv.id);
|
||||
let (loop_failure_signatures, restart_failure_signatures) =
|
||||
snapshot_failure_signatures(&self.circuit_breaker);
|
||||
self.emitter.emit(&Event::StageStarted {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
handler_type: gv.handler_type().unwrap_or_default().to_string(),
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
});
|
||||
self.emitter.emit(&Event::StageCompleted {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
duration_ms: 0,
|
||||
status: StageStatus::Success.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,
|
||||
restart_failure_signatures,
|
||||
response: state
|
||||
.context
|
||||
.get(&context::keys::response_key(&gv.id))
|
||||
.and_then(|value| value.as_str().map(ToOwned::to_owned)),
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
});
|
||||
self.emitter.emit_scoped(
|
||||
&Event::StageStarted {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
handler_type: gv.handler_type().unwrap_or_default().to_string(),
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
self.emitter.emit_scoped(
|
||||
&Event::StageCompleted {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
duration_ms: 0,
|
||||
status: StageStatus::Success.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,
|
||||
restart_failure_signatures,
|
||||
response: state
|
||||
.context
|
||||
.get(&context::keys::response_key(&gv.id))
|
||||
.and_then(|value| value.as_str().map(ToOwned::to_owned)),
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
}
|
||||
|
||||
async fn before_attempt(
|
||||
|
|
@ -163,14 +185,18 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
|||
state: &WfRunState,
|
||||
) -> CoreResult<NodeDecision<Option<BilledModelUsage>>> {
|
||||
let gv = ctx.node.inner();
|
||||
self.emitter.emit(&Event::StageStarted {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: state.stage_index,
|
||||
handler_type: gv.handler_type().unwrap_or_default().to_string(),
|
||||
attempt: ctx.attempt as usize,
|
||||
max_attempts: ctx.max_attempts as usize,
|
||||
});
|
||||
let scope = stage_scope_for(state, &gv.id);
|
||||
self.emitter.emit_scoped(
|
||||
&Event::StageStarted {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: state.stage_index,
|
||||
handler_type: gv.handler_type().unwrap_or_default().to_string(),
|
||||
attempt: ctx.attempt as usize,
|
||||
max_attempts: ctx.max_attempts as usize,
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
Ok(NodeDecision::Continue)
|
||||
}
|
||||
|
||||
|
|
@ -183,27 +209,34 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
|||
let gv = ctx.node.inner();
|
||||
let outcome = &ctx.result.outcome;
|
||||
let stage_index = state.stage_index;
|
||||
let scope = stage_scope_for(state, &gv.id);
|
||||
|
||||
self.emitter.emit(&Event::StageFailed {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
failure: outcome.failure.clone().unwrap_or_else(|| {
|
||||
FailureDetail::new("handler failed", FailureCategory::TransientInfra)
|
||||
}),
|
||||
will_retry: true,
|
||||
});
|
||||
self.emitter.emit_scoped(
|
||||
&Event::StageFailed {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
failure: outcome.failure.clone().unwrap_or_else(|| {
|
||||
FailureDetail::new("handler failed", FailureCategory::TransientInfra)
|
||||
}),
|
||||
will_retry: true,
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
|
||||
self.emitter.emit(&Event::StageRetrying {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
attempt: ctx.attempt as usize,
|
||||
max_attempts: ctx.result.max_attempts as usize,
|
||||
delay_ms: ctx
|
||||
.backoff_delay
|
||||
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap()),
|
||||
});
|
||||
self.emitter.emit_scoped(
|
||||
&Event::StageRetrying {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
attempt: ctx.attempt as usize,
|
||||
max_attempts: ctx.result.max_attempts as usize,
|
||||
delay_ms: ctx
|
||||
.backoff_delay
|
||||
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap()),
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -221,58 +254,66 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
|||
}
|
||||
let gv = node.inner();
|
||||
let stage_index = state.stage_index;
|
||||
let scope = stage_scope_for(state, &gv.id);
|
||||
let duration_ms = u64::try_from(result.duration.as_millis()).unwrap();
|
||||
let (loop_failure_signatures, restart_failure_signatures) =
|
||||
snapshot_failure_signatures(&self.circuit_breaker);
|
||||
|
||||
if outcome.status == StageStatus::Fail {
|
||||
self.emitter.emit(&Event::StageFailed {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
failure: outcome.failure.clone().unwrap_or_else(|| {
|
||||
FailureDetail::new("handler failed", FailureCategory::Deterministic)
|
||||
}),
|
||||
will_retry: false,
|
||||
});
|
||||
} else {
|
||||
self.emitter.emit(&Event::StageCompleted {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
duration_ms,
|
||||
status: outcome.status.to_string(),
|
||||
preferred_label: outcome.preferred_label.clone(),
|
||||
suggested_next_ids: outcome.suggested_next_ids.clone(),
|
||||
billing: outcome.usage.clone(),
|
||||
failure: outcome.failure.clone(),
|
||||
notes: outcome.notes.clone(),
|
||||
files_touched: outcome.files_touched.clone(),
|
||||
context_updates: (!outcome.context_updates.is_empty()).then(|| {
|
||||
outcome
|
||||
.context_updates
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
}),
|
||||
jump_to_node: outcome.jump_to_node.clone(),
|
||||
context_values: {
|
||||
let snapshot = state.context.snapshot();
|
||||
(!snapshot.is_empty()).then(|| snapshot.into_iter().collect::<BTreeMap<_, _>>())
|
||||
self.emitter.emit_scoped(
|
||||
&Event::StageFailed {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
failure: outcome.failure.clone().unwrap_or_else(|| {
|
||||
FailureDetail::new("handler failed", FailureCategory::Deterministic)
|
||||
}),
|
||||
will_retry: false,
|
||||
},
|
||||
node_visits: (!state.node_visits.is_empty()).then(|| {
|
||||
state
|
||||
.node_visits
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
}),
|
||||
loop_failure_signatures,
|
||||
restart_failure_signatures,
|
||||
response: response_from_outcome(&gv.id, outcome),
|
||||
attempt: result.attempts as usize,
|
||||
max_attempts: result.max_attempts as usize,
|
||||
});
|
||||
&scope,
|
||||
);
|
||||
} else {
|
||||
self.emitter.emit_scoped(
|
||||
&Event::StageCompleted {
|
||||
node_id: gv.id.clone(),
|
||||
name: gv.label().to_string(),
|
||||
index: stage_index,
|
||||
duration_ms,
|
||||
status: outcome.status.to_string(),
|
||||
preferred_label: outcome.preferred_label.clone(),
|
||||
suggested_next_ids: outcome.suggested_next_ids.clone(),
|
||||
billing: outcome.usage.clone(),
|
||||
failure: outcome.failure.clone(),
|
||||
notes: outcome.notes.clone(),
|
||||
files_touched: outcome.files_touched.clone(),
|
||||
context_updates: (!outcome.context_updates.is_empty()).then(|| {
|
||||
outcome
|
||||
.context_updates
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
}),
|
||||
jump_to_node: outcome.jump_to_node.clone(),
|
||||
context_values: {
|
||||
let snapshot = state.context.snapshot();
|
||||
(!snapshot.is_empty())
|
||||
.then(|| snapshot.into_iter().collect::<BTreeMap<_, _>>())
|
||||
},
|
||||
node_visits: (!state.node_visits.is_empty()).then(|| {
|
||||
state
|
||||
.node_visits
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
}),
|
||||
loop_failure_signatures,
|
||||
restart_failure_signatures,
|
||||
response: response_from_outcome(&gv.id, outcome),
|
||||
attempt: result.attempts as usize,
|
||||
max_attempts: result.max_attempts as usize,
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -326,37 +367,44 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
|||
node_outcomes.insert(node.id().to_string(), result.outcome.clone());
|
||||
artifact::normalize_durable_outcomes(&mut node_outcomes);
|
||||
|
||||
self.emitter.emit(&Event::CheckpointCompleted {
|
||||
node_id: node.id().to_string(),
|
||||
status,
|
||||
current_node: node.id().to_string(),
|
||||
completed_nodes: state.completed_nodes.clone(),
|
||||
node_retries: state
|
||||
.node_retries
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
context_values: context_values.into_iter().collect::<BTreeMap<_, _>>(),
|
||||
node_outcomes: node_outcomes.into_iter().collect::<BTreeMap<_, _>>(),
|
||||
next_node_id: next_node_id.map(ToOwned::to_owned),
|
||||
git_commit_sha: git_sha.clone(),
|
||||
loop_failure_signatures: loop_failure_signatures.unwrap_or_default(),
|
||||
restart_failure_signatures: restart_failure_signatures.unwrap_or_default(),
|
||||
node_visits: state
|
||||
.node_visits
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
diff,
|
||||
});
|
||||
let scope = stage_scope_for(state, node.id());
|
||||
self.emitter.emit_scoped(
|
||||
&Event::CheckpointCompleted {
|
||||
node_id: node.id().to_string(),
|
||||
status,
|
||||
current_node: node.id().to_string(),
|
||||
completed_nodes: state.completed_nodes.clone(),
|
||||
node_retries: state
|
||||
.node_retries
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
context_values: context_values.into_iter().collect::<BTreeMap<_, _>>(),
|
||||
node_outcomes: node_outcomes.into_iter().collect::<BTreeMap<_, _>>(),
|
||||
next_node_id: next_node_id.map(ToOwned::to_owned),
|
||||
git_commit_sha: git_sha.clone(),
|
||||
loop_failure_signatures: loop_failure_signatures.unwrap_or_default(),
|
||||
restart_failure_signatures: restart_failure_signatures.unwrap_or_default(),
|
||||
node_visits: state
|
||||
.node_visits
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
diff,
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
|
||||
// Emit GitCommit + GitPush events if git produced results
|
||||
if let Some(ref result) = git_result {
|
||||
if let Some(ref sha) = result.commit_sha {
|
||||
self.emitter.emit(&Event::GitCommit {
|
||||
node_id: Some(node.id().to_string()),
|
||||
sha: sha.clone(),
|
||||
});
|
||||
self.emitter.emit_scoped(
|
||||
&Event::GitCommit {
|
||||
node_id: Some(node.id().to_string()),
|
||||
sha: sha.clone(),
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
}
|
||||
for (branch, success) in &result.push_results {
|
||||
self.emitter.emit(&Event::GitPush {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use crate::event::{Emitter, Event, RunNoticeLevel};
|
|||
use crate::git::MetadataStore;
|
||||
use crate::graph::WorkflowGraph;
|
||||
use crate::graph::WorkflowNode;
|
||||
use crate::lifecycle::event::stage_scope_for;
|
||||
use crate::outcome::{BilledModelUsage, Outcome, StageStatus};
|
||||
use crate::run_dump::RunDump;
|
||||
use crate::run_options::RunOptions;
|
||||
|
|
@ -283,10 +284,14 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
}
|
||||
Err(e) => {
|
||||
// Emit CheckpointFailed and return error
|
||||
self.emitter.emit(&Event::CheckpointFailed {
|
||||
node_id: node_id.to_string(),
|
||||
error: e.clone(),
|
||||
});
|
||||
let scope = stage_scope_for(state, node_id);
|
||||
self.emitter.emit_scoped(
|
||||
&Event::CheckpointFailed {
|
||||
node_id: node_id.to_string(),
|
||||
error: e.clone(),
|
||||
},
|
||||
&scope,
|
||||
);
|
||||
return Err(CoreError::Other(format!(
|
||||
"git checkpoint commit failed for node '{node_id}': {e}"
|
||||
)));
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ async fn persist_created_run(
|
|||
manifest_blob,
|
||||
},
|
||||
record.run_id.created_at(),
|
||||
None,
|
||||
);
|
||||
let payload = fabro_store::EventPayload::new(
|
||||
serde_json::to_value(&stored).map_err(|err| FabroError::engine(err.to_string()))?,
|
||||
|
|
|
|||
|
|
@ -194,8 +194,13 @@ mod tests {
|
|||
run_id: fixtures::RUN_1,
|
||||
node_id: None,
|
||||
node_label: None,
|
||||
stage_id: None,
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor: None,
|
||||
body: EventBody::RunSubmitted(RunSubmittedProps {
|
||||
reason: None,
|
||||
definition_blob: None,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ base.ts
|
|||
common.ts
|
||||
configuration.ts
|
||||
index.ts
|
||||
models/actor-kind.ts
|
||||
models/actor-ref.ts
|
||||
models/aggregate-billing-totals.ts
|
||||
models/aggregate-billing.ts
|
||||
models/api-question-option.ts
|
||||
|
|
@ -52,6 +54,7 @@ models/disk-usage-summary-row.ts
|
|||
models/error-response-entry.ts
|
||||
models/error-response.ts
|
||||
models/event-envelope.ts
|
||||
models/event-seq.ts
|
||||
models/execute-query-request.ts
|
||||
models/execute-query-response-rows-inner-inner.ts
|
||||
models/execute-query-response.ts
|
||||
|
|
|
|||
30
lib/packages/fabro-api-client/src/models/actor-kind.ts
Normal file
30
lib/packages/fabro-api-client/src/models/actor-kind.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* High-level category of an event actor.
|
||||
*/
|
||||
|
||||
export const ActorKind = {
|
||||
USER: 'user',
|
||||
AGENT: 'agent',
|
||||
SYSTEM: 'system'
|
||||
} as const;
|
||||
|
||||
export type ActorKind = typeof ActorKind[keyof typeof ActorKind];
|
||||
|
||||
|
||||
|
||||
36
lib/packages/fabro-api-client/src/models/actor-ref.ts
Normal file
36
lib/packages/fabro-api-client/src/models/actor-ref.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { ActorKind } from './actor-kind';
|
||||
|
||||
/**
|
||||
* Optional primary actor associated with a run event. Present on control actions and durable agent output where a stable user or agent identity matters; omitted on routine runtime lifecycle events.
|
||||
*/
|
||||
export interface ActorRef {
|
||||
'kind': ActorKind;
|
||||
/**
|
||||
* Stable actor identifier when available.
|
||||
*/
|
||||
'id'?: string;
|
||||
/**
|
||||
* Display-friendly label for the actor.
|
||||
*/
|
||||
'display'?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -13,18 +13,20 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { ActorRef } from './actor-ref';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EventSeq } from './event-seq';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { RunEvent } from './run-event';
|
||||
|
||||
/**
|
||||
* Stored event envelope with assigned sequence number.
|
||||
* @type EventEnvelope
|
||||
* Stored event envelope with assigned sequence number. On the wire the envelope is flattened: seq sits alongside the RunEvent payload fields at the top level of the JSON object.
|
||||
*/
|
||||
export interface EventEnvelope {
|
||||
/**
|
||||
* Assigned event sequence number.
|
||||
*/
|
||||
'seq': number;
|
||||
'payload': RunEvent;
|
||||
}
|
||||
export type EventEnvelope = EventSeq & RunEvent;
|
||||
|
||||
|
||||
|
|
|
|||
26
lib/packages/fabro-api-client/src/models/event-seq.ts
Normal file
26
lib/packages/fabro-api-client/src/models/event-seq.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Assigned sequence number component of a stored event envelope.
|
||||
*/
|
||||
export interface EventSeq {
|
||||
/**
|
||||
* Assigned event sequence number.
|
||||
*/
|
||||
'seq': number;
|
||||
}
|
||||
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export * from './actor-kind';
|
||||
export * from './actor-ref';
|
||||
export * from './aggregate-billing';
|
||||
export * from './aggregate-billing-totals';
|
||||
export * from './api-question';
|
||||
|
|
@ -34,6 +36,7 @@ export * from './disk-usage-summary-row';
|
|||
export * from './error-response';
|
||||
export * from './error-response-entry';
|
||||
export * from './event-envelope';
|
||||
export * from './event-seq';
|
||||
export * from './execute-query-request';
|
||||
export * from './execute-query-response';
|
||||
export * from './execute-query-response-rows-inner-inner';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { ActorRef } from './actor-ref';
|
||||
|
||||
/**
|
||||
* Internal RunEvent-compatible JSON payload. The server validates this body by deserializing into the typed RunEvent struct.
|
||||
|
|
@ -25,8 +28,25 @@ export interface RunEvent {
|
|||
'run_id': string;
|
||||
'node_id'?: string;
|
||||
'node_label'?: string;
|
||||
/**
|
||||
* Stage execution identity, formatted as \"{node_id}@{visit}\".
|
||||
*/
|
||||
'stage_id'?: string;
|
||||
/**
|
||||
* Durable identity of one execution of a parallel node, formatted as \"{node_id}@{visit}\".
|
||||
*/
|
||||
'parallel_group_id'?: string;
|
||||
/**
|
||||
* Durable identity of one branch within a parallel execution, formatted as \"{parallel_group_id}:{index}\".
|
||||
*/
|
||||
'parallel_branch_id'?: string;
|
||||
'session_id'?: string;
|
||||
'parent_session_id'?: string;
|
||||
/**
|
||||
* Stable identifier for a tool call, present on agent.tool.* events and other durable events that directly describe the same tool call.
|
||||
*/
|
||||
'tool_call_id'?: string;
|
||||
'actor'?: ActorRef;
|
||||
/**
|
||||
* Event type discriminator.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue