Skip to content

Latest commit

 

History

History
376 lines (296 loc) · 57.8 KB

File metadata and controls

376 lines (296 loc) · 57.8 KB

Run Event Schema

Every workflow run produces a single ordered stream of RunEvent objects. This stream is the one contract that all surfaces consume to render live progress — streaming tokens on a node face, per-node status rings, cost waterfalls, and human-gate prompts. The events are emitted by @relavium/core and are identical regardless of where the engine runs.

The transport differs by surface and phase, but the event shape does not:

flowchart LR
  E["@relavium/core\nRunEventBus\n(runs in-process on every surface)"] -->|in-process, WebView-side| D[Desktop WebView stores]
  E -->|in-process bus| C[CLI ink renderer]
  E -->|in-process bus| V[VS Code extension host]
  E -. Phase 2 .->|HTTP SSE| P[Cloud Portal]
Loading

On the desktop the engine runs in the WebView's JS runtime (ADR-0018), so its RunEventBus and the consuming stores share one runtime — most run events never cross IPC. The only Rust→WebView channel on the LLM hot path is the delegated egress's Channel<StreamChunk> (the WebView adapter folds those chunks into agent:token run events locally); see ipc-contract.md. The cross-surface RunEvent union below is the same one HTTP SSE carries in Phase 2.

Event envelope

Every event extends a common base:

interface BaseEvent {
  type: string;             // discriminator (see table below)
  runId?: string;           // correlation key on a workflow RUN (omitted on a session)
  sessionId?: string;       // correlation key on an agent SESSION (omitted on a run)
  timestamp: string;        // ISO 8601
  sequenceNumber: number;   // monotonic per run OR per session
}

Correlation key. Exactly one of runId / sessionId is present — runId on a workflow run, sessionId on an agent session. The reused agent:token / agent:reasoning / agent:tool_call / agent:tool_result / cost:updated / budget:estimate_committed events carry runId on a run and sessionId on a session; agent:approval_requested (ADR-0057) is also dual-envelope but session-only-emitted in Phase 2.5. Consumers route on whichever is present.

sequenceNumber is monotonic per run and is the basis for gap detection: if a consumer sees a jump in sequenceNumber, it triggers a full state resync (re-read the durable run state) rather than trusting a partial view. This is what makes reconnection lossless. The envelope fields (sessionId / runId, sequenceNumber, timestamp) are stamped by the bus, not the producer: WorkflowEngine emits through the RunEventBus, and AgentSession (1.V) emits envelope-free payload drafts through an injected SessionEventSink — wiring that sink onto the bus, where the per-session sequenceNumber (and its same gap/resync rule) is assigned, is 1.W. So a session's monotonic numbering is the bus's responsibility, not the session core's.

The RunEvent union

export type RunEvent =
  | RunStartedEvent
  | NodeStartedEvent
  | AgentTokenEvent
  | AgentReasoningEvent
  | AgentToolCallEvent
  | AgentToolResultEvent
  | AgentApprovalRequestedEvent // dual-envelope; session-only-emitted in Phase 2.5 (ADR-0057)
  | AgentFilePatchProposedEvent
  | CostUpdatedEvent
  | NodeCompletedEvent
  | NodeFailedEvent
  | NodeSkippedEvent
  | NodeRetryingEvent
  | MediaJobSubmittedEvent
  | HumanGatePausedEvent
  | HumanGateResumedEvent
  | RunCompletedEvent
  | RunFailedEvent
  | RunCancelledEvent
  | RunPausedEvent
  | RunTimeoutEvent
  | BudgetWarningEvent
  | BudgetPausedEvent
  | BudgetEstimateCommittedEvent // dual-envelope (ADR-0074 §2) — a conservative ESTIMATE, never realized spend
  | CostAttemptSettledEvent; // RUN-ONLY (ADR-0076) — the REALIZED twin of the line above; the only DURABLE cost: event

RunPausedEvent is the multi-gate aggregate (below); RunTimeoutEvent / BudgetWarningEvent / BudgetPausedEvent / BudgetEstimateCommittedEvent are the resource-governance events defined in Workflow governance and reserved events.

type Meaning Key payload fields
run:started A run began. workflowId (the workflows.id UUID FK, not the authored slug — ADR-0022), inputs (secret-typed inputs masked — see Security), executionMode: 'local' | 'cloud' | 'managed'
node:started A node began executing. nodeId, nodeType, attemptNumber? (1-based; absent ⇒ attempt 1, present + >1 ⇒ a node-retry re-dispatch — 1.S)
agent:token A streaming LLM token from an agent node. nodeId, token, model
agent:reasoning A streaming reasoning ("thinking") delta from an agent node (EA6, 2.5.H — a pure host-emit: the @relavium/llm seam already carries the reasoning chunks; the turn core emits one per reasoning_delta). A dual-envelope event like agent:token (carried on the session stream too), so a surface renders a live, collapsible "thinking" panel. Never carries the ephemeral same-provider signature (ADR-0030). Amends ADR-0036. nodeId, text, model
agent:tool_call An agent invoked a tool. nodeId, model (the invoking model — so a tool call is attributable across a failover), toolId, toolInput (sanitized — no secrets), attemptNumber? (1-based, matches cost:updated)
agent:tool_result A tool returned. nodeId, toolId, success, outputSummary (truncated for UI), attemptNumber?
agent:approval_requested A side-effecting tool dispatch is awaiting an interactive per-tool approval decision (ADR-0057 EA3/EA5). The engine's confirmDispatch emits it — for every governed dispatch reaching the gate, whether the host then prompts a human or auto-decides — just before invoking the host's ConfirmActionHook; the registry then awaits the verdict (approve ⇒ dispatch, reject ⇒ a fatal tool_denied). A dual-envelope event (runId/sessionId), like agent:tool_call — in Phase 2.5 emitted only on the chat session path (the approval regime), and carried on the session stream (not run-only — it is not dropped like agent:file_patch_proposed). nodeId, toolId, action: 'fs_write' | 'process' | 'egress' | 'os' (the governed side-effect class — tool-registry.md), preview (secret-free, display-only: { path? } for a write, { command? } for a process, { host? } for egress, {} for an os action like read_clipboard/notify — never a full URL/query, never a secret. Secret-freedom is enforced by the registry's redaction, not asserted, so a field may contain the literal [redacted] marker and is not guaranteed to be a resolvable path/command/host — a machine consumer must never parse it; see tool-registry.md §Preview redaction), attemptNumber?
agent:file_patch_proposed An agent proposed a file change (gated — no write until the user accepts; e.g. the VS Code inline-diff review). nodeId, patches: [{ uri, unifiedDiff }] (≥1 — an empty proposal is meaningless), attemptNumber?
cost:updated A node's token cost was tallied (drives the cost waterfall). nodeId, model, inputTokens, outputTokens, costMicrocents, cumulativeCostMicrocents (integer micro-cents — canonical unit in llm-provider-seam.md; includes realized media spend, folded as a disjoint addend per ADR-0044 §3 — the per-unit Usage.mediaUnits axis is not yet a field on this event, deferred, see deferred-tasks.md), attemptNumber? (1-based within-chain FallbackChain attempt — resets per node-retry re-dispatch; distinct from node:*.attemptNumber, see the two attemptNumber families note), priced? (ADR-0070 — additive + optional, so an older reader ignores it: false when the egress could not be priced. An unpriced model still emits this event with its real tokens and costMicrocents: 0, which makes "cost 0 + tokens > 0" ambiguous between could not price and genuinely free — an ambiguity nothing else in the event resolves. The durable session_costs row records it as an unpriced_calls counter, not a boolean, because a model can be priced mid-session). Generative-node variant (1.AG Section C, ADR-0045 §5): a media_surface: 'generative' agent node emits exactly one cost:updated with inputTokens / outputTokens = 0 (no token billing — the spend rides entirely in costMicrocents as the per-modality media addend) and no attemptNumber (no FallbackChain on the generative path — one provider, no failover).
node:completed A node finished successfully. nodeId, output, tokensUsed: {input, output, model?} (model only for LLM nodes), durationMs, selected? (a condition's chosen target ids — the authoritative branch record checkpoint/resume restores from, 1.R; may be an empty array when the condition routes to no branch, dimming all downstream), attemptNumber? (1-based node-retry dispatch attempt — 1.S; absent ⇒ attempt 1), cumulativeCostMicrocents? (the run-wide running total snapshotted at this node boundary — one of the durable absolute totals checkpoint/resume maxes over, alongside node:failed's, budget:paused.spentMicrocents, and cost:attempt_settled.cumulativeCostMicrocents since ADR-0076; see the fold rule below for why it is a max and never a sum. This snapshot is still the ONLY carrier of media spend, which emits no attempt row. cost:updated is streamed-only and is not a durable source at all. The engine always populates this field. node:failed mirrors it, 2.S/D-GC)
node:failed A node failed (TERMINAL — exactly one per node; emitted when the node-retry budget is exhausted, on a fatal / retry_on-excluded failure, or when a pending retry is abandoned by a cancel or a sibling abort — see 1.S). nodeId, error: {code, message, retryable, correlationId?} (code is an ErrorCode; correlationId is a secret-free id joined to the internal log — ADR-0036), attemptNumber? (the last attempt, when a retry budget was spent — 1.S), cumulativeCostMicrocents? (the run-wide running total snapshotted AT this node boundary — the durable fail-cost so a billed-but-failed paid media job's realized spend survives the transient cost:updated, 2.S/D-GC ADR-0045 §5; mirrors node:completed)
node:retrying A retryable node attempt failed and the engine will re-dispatch the whole node (1.S, ADR-0040) — non-terminal (the node continues; node:failed is the terminal). nodeId, attemptNumber (the attempt that just failed, 1-based), error: {code, message, retryable} (the NodeFailure shape — no correlationId; that anchors the terminal failure), delayMs (backoff before the next attempt)
node:skipped A node was skip-propagated (never ran). nodeId, reason: 'branch_not_taken' | 'upstream_unreachable' (branch_not_taken = a condition routed away from it; upstream_unreachable = every in-edge is dead because an upstream was skipped/failed). Emitted so the event log is a complete, replayable record — checkpoint/resume reconstructs a skipped vertex from it (run-plan.md) and a surface can render the dimmed path instead of the node silently vanishing.
media_job:submitted An async media-generation job was submitted; the engine owns its poll/checkpoint/resume/cancel loop (1.AG, ADR-0045) — non-terminal (the node parks until its node:completed/node:failed). Durable so a crash-resume re-attaches (re-polls the opaque jobId) instead of re-submitting; per-poll progress is transient (off this durable stream). nodeId, jobId (Relavium-opaque — never the vendor op-name), provider, model, modality: 'image' | 'audio' | 'video', startedAt, deadlineAt, units?, acceptedCostMicrocents?
human_gate:paused Execution suspended at a human gate. nodeId, gateId, gateType: 'approval' | 'input' | 'review', message, assignee?, timeoutMs?, timeoutAction?: 'approve' | 'reject' (on-timeout policy, present only with timeoutMs), expiresAt?
human_gate:resumed A gate decision was applied; execution continues. nodeId, decision: 'approved' | 'rejected' | 'input_provided', decidedBy, payload?
run:paused The run is suspended on ≥1 gate AND/OR ≥1 async media job — the multi-suspension aggregate (parallel branches may each reach a gate or a media job). pendingGateCount is the count of gateIds[] (they must agree) and both are 0/empty for a media-only park; pendingMediaJobNodeIds lists nodes parked on the engine-owned pollMediaJob loop (1.AG Section D, ADR-0045 §2). At least one suspension reason (a gate or a media job) always holds. A resume disambiguates by registry: a gate by gateId (a decision), a media job by nodeId (a re-attach). pendingGateCount, gateIds[], pendingMediaJobNodeIds[]?
run:completed The run finished. outputs (a record keyed by each terminal output vertex's node id, the value being that vertex's captured output — see run-plan.md §output capture), totalTokensUsed, totalCostMicrocents (integer micro-cents closing total for the whole run), durationMs
run:failed The run failed. error: {code, message, retryable, nodeId?, correlationId?} (code is an ErrorCode; nodeId is the root-cause node; correlationId joins to the internal log — ADR-0036), partialOutputs, cumulativeCostMicrocents? (the run-wide running total at failure — the durable fail-cost for a paid media job a sibling node's failure abandoned, whose lone estimate addend is folded just before this terminal after the root-cause node:failed snapshot, 2.S/D-GC ADR-0045 §5; mirrors run:cancelled and the run:completed counterpart totalCostMicrocents)
run:cancelled The run was cancelled. cumulativeCostMicrocents? (the run-wide running total at cancellation — the durable fail-cost for a paid media job pending at the cancel, whose lone estimate addend is folded just before this terminal, 2.S/D-GC ADR-0045 §5; the run:completed counterpart is totalCostMicrocents)
cost:attempt_settled One settled provider attempt's REALIZED charge, made durable (ADR-0076) — the realized twin of budget:estimate_committed, emitted from the same callback and joined at the same barriers (ADR-0077). It exists because cost:updated is streamed and never persisted, so before this event a crash mid-agent-loop discarded every charge since the last node boundary — and the resumed run spent it AGAIN, against a cap understated by exactly that amount. RUN-ONLY (runId always, never sessionId), unlike cost:updated and unlike its estimate twin: the session path already records the same per-attempt increment into session_costs synchronously (ADR-0070 + #W15-4), so it needs no arm here. Scope: it covers the provider attempts of an AGENT TURN. A media job's realized cost (ADR-0045 §5) does not emit one and does not need to — it is already durable through the node:completed / node:failed / run:* cumulative snapshots. And no cost event of any kind makes a TOOL EFFECT idempotent; that is a separate decision about a separate failure. nodeId, model, attemptNumber (required, 1-based within-chain — optional on cost:updated only because that event predates this one and has historical rows; a per-attempt ledger row that cannot say which attempt it is, is not a ledger), inputTokens, outputTokens, costMicrocents (this attempt's delta, not a cumulative — 0 is reachable for an unpriced or genuinely free model; a delta a restoring reader must never sum — see the fold rule below), cumulativeCostMicrocents (the run-wide total after this attempt — a durable ABSOLUTE total, and one of the inputs a restoring reader takes Math.max over; the fold rule below is the canonical statement), priced (required, same reason as attemptNumber: without it costMicrocents: 0 with real tokens cannot distinguish could not price from genuinely free, which is the ambiguity a ledger exists to remove)

Which cost event is authoritative for what — the three-way split, stated once. cost:updated is the live observation: streamed, never persisted, last-wins, and it is what a surface renders while a run is moving. cost:attempt_settled is the durable record of what was charged: one row per settled attempt, and the only cost event a reader can trust after a restart. Restoring a run's realized total is Math.max over every durable ABSOLUTE total in the log — the node-boundary snapshots (node:completed / node:failed / run:* cumulativeCostMicrocents), budget:paused.spentMicrocents, and cost:attempt_settled.cumulativeCostMicrocents. Each is read immediately after its own increment, so each is a true run-wide total at that instant and the largest is the engine's real total whatever order the rows landed in. Do not sum the attempt deltas, by either obvious route: adding costMicrocents into the same total double-counts against a node snapshot that already includes those attempts, and summing them into a separate accumulator to max against the snapshots under-counts — the two sources cover different money, since a media node writes a snapshot and emits no attempt row at all, so every attempt made after the last node boundary vanishes whenever earlier media spend is the larger figure. (Math.max is right here and wrong for the conservative twin below for one reason only: realized spend is monotonic, while a conservative commitment can be deliberately released.) The derived run_costs / runs rows this folds into are specified in database-schema.md, not here.

The money basis is FROZEN at submit time (ADR-0074 §3). units is the authored billed volume the submission was priced on, and acceptedCostMicrocents is what the admission actually reserved. A resume restores the reservation from acceptedCostMicrocents with no pricing lookup, so neither a workflow edit nor a user-price/catalog change between submission and resume can move a commitment the provider has already accepted, or rewrite the job's historical cost. acceptedCostMicrocents: 0 is meaningful and distinct from absent — it says the submission was priced and reserved nothing, which is what an unpriced model's allow-degrade path does. acceptedCostMicrocents present ⇒ units required (schema-enforced): a frozen cost on an unfrozen basis would restore the old reservation while re-deriving the volume, which is the drift §3 exists to prevent. The converse does NOT hold — units alone is legitimate, and is exactly what the approved-bypass path writes (it freezes the volume and omits the cost, because no pricing hook ran and 0 would freeze "priced at zero" for a job that was never priced). Both absent means the row is LEGACY (written before §3): resume must re-derive the volume from the workflow definition and re-price from today's catalog, which may under-reserve if the price has fallen, so with a cap configured the governor fails closed: new egress is held — awaited, not refused — until that job settles and its realized charge replaces the guess. Holding rather than throwing is deliberate: budget_exceeded is not in RETRYABLE_ERROR_CODES and retry_on cannot widen it, so a thrown refusal would kill a sibling node and abort the run, abandoning the very job it was waiting for. The hold is bounded by the job's own deadlineAt and is always broken by an abort, and it announces itself once through the governor's hold notice (routed to stderr by relavium gate) — that notice is the observability §3 requires, so a resume is never a silent stall.

Two attemptNumber families

attemptNumber appears on two independent counter families that must not be conflated (1.S, ADR-0040):

  • Node-retry dispatch attempt — on node:started / node:completed / node:failed / node:retrying. The engine's above-chain whole-node re-dispatch index. Absent ⇒ attempt 1; present + >1 ⇒ a re-dispatch (distinguishes "attempt N starting" from a replay).
  • Within-chain attempt — on cost:updated / cost:attempt_settled / agent:tool_call / agent:tool_result / agent:file_patch_proposed (and budget:estimate_committed). The within-chain FallbackChain attempt index inside a single node dispatch; it resets to 1 on every node-retry re-dispatch (a fresh chain runs each time). It is optional on all of them except cost:attempt_settled, where it is required — that event has no historical rows to accommodate, and it is the one whose identity is the attempt.

The two do not join: on a node the budget retried, node:completed.attemptNumber may be 2 while the accompanying cost:updated.attemptNumber is 1. To attribute cost to a node-retry attempt, partition the sequenceNumber-ordered stream at each node:started / node:retrying boundary — do not key by (nodeId, attemptNumber) across families. (Run totals are unaffected: cost:updated.cumulativeCostMicrocents is the engine's authoritative running total for a LIVE reader. A reader reconstructing a run's total from the durable log uses the three-way split above instead — cost:updated is never persisted, so it is not available to one.)

Selected definitions

These TypeScript shapes are illustrative. The enforced, runtime-validated implementation is the Zod schema set in @relavium/shared (run-event.ts), from which the TS types are inferred (ADR-0020). This document remains the canonical contract (the human-readable spec the schema implements); if the two ever diverge, this spec wins and the schema is corrected to it.

export interface AgentTokenEvent extends BaseEvent {
  type: 'agent:token';
  nodeId: string;
  token: string;            // streaming LLM token
  model: string;
}

export interface AgentReasoningEvent extends BaseEvent {
  type: 'agent:reasoning';  // EA6 (2.5.H) — reasoning counterpart of agent:token; dual-envelope; host-emit
  nodeId: string;
  text: string;             // streaming reasoning ("thinking") delta — never the ephemeral signature (ADR-0030)
  model: string;
}

export interface CostUpdatedEvent extends BaseEvent {
  type: 'cost:updated';
  nodeId: string;
  model: string;                  // canonical model id the cost was priced against
  inputTokens: number;
  outputTokens: number;
  costMicrocents: number;         // integer micro-cents (canonical unit defined in llm-provider-seam.md); this attempt, from Relavium's pricing table (never the provider)
  cumulativeCostMicrocents: number; // integer micro-cents running total for the whole run — INCLUDES realized media spend, folded as a disjoint addend (ADR-0044 §3)
  // NOTE (1.AF): the per-unit `Usage.mediaUnits` axis (image per-count, audio/video per-second; a token-based
  // provider's audio rides as unit:'count') is NOT yet a field on this event. Realized media spend already
  // folds into `cumulativeCostMicrocents`; surfacing the disjoint per-unit counts here needs `MediaUnitsEntry`
  // relocated to `@relavium/shared` first (run-event.ts cannot import the `@relavium/llm` seam type). Deferred —
  // see deferred-tasks.md.
  attemptNumber?: number;         // 1-based WITHIN-CHAIN attempt; resets per node-retry re-dispatch — distinct from node:*.attemptNumber (see "Two attemptNumber families")
  priced?: boolean;               // ADR-0070 — additive + optional, so an older reader ignores it and an older producer may omit it (absent ⇒ treat as priced). `false` = the egress could NOT be priced: the tokens are real, `costMicrocents` is 0, and that 0 must not be read as "this model is free". The durable `session_costs` row keeps it as an `unpriced_calls` COUNTER, not a boolean — a model can become priced mid-session.
}

export interface NodeCompletedEvent extends BaseEvent {
  type: 'node:completed';
  nodeId: string;
  output: unknown;
  // `model` is present only when an LLM produced the tokens. A non-agent node (condition,
  // transform, merge, parallel, input, output, human_gate) completes with input/output 0 and
  // no model — so `model` is optional.
  tokensUsed: { input: number; output: number; model?: string };
  durationMs: number;
  selected?: string[];      // a `condition` node only: the immediate target ids it routed to (the live branches); MAY be empty when it routes to no branch (all downstream skip-propagated). The authoritative record checkpoint/resume restores `selectedTargets` from (1.R).
  attemptNumber?: number;   // 1-based NODE-RETRY dispatch attempt (1.S); absent ⇒ attempt 1 — distinct from cost:updated.attemptNumber (see "Two attemptNumber families")
  cumulativeCostMicrocents?: number; // run-wide running total at this node boundary — the durable cost source checkpoint/resume restores (cost:updated is streamed-only); engine always populates. node:failed mirrors it (2.S/D-GC)
}

export interface NodeSkippedEvent extends BaseEvent {
  type: 'node:skipped';
  nodeId: string;
  reason: 'branch_not_taken' | 'upstream_unreachable';
}

export interface NodeRetryingEvent extends BaseEvent {
  type: 'node:retrying';        // 1.S — a retryable attempt failed; the engine will re-dispatch the whole node. NON-TERMINAL.
  nodeId: string;
  attemptNumber: number;        // the attempt that just failed (1-based); the next attempt is attemptNumber + 1
  error: { code: ErrorCode; message: string; retryable: boolean }; // the NodeFailure shape — no correlationId (that anchors the terminal node:failed)
  delayMs: number;              // backoff before the next attempt
}

export interface MediaJobSubmittedEvent extends BaseEvent {
  type: 'media_job:submitted'; // 1.AG/ADR-0045 §2 — an async media job was submitted; the node PARKS (non-terminal suspension). DURABLE (resume re-attaches). ADR-0074 §3 adds units?/acceptedCostMicrocents? — the frozen money basis; both absent = a legacy row.
  nodeId: string;
  jobId: string;               // the Relavium-opaque job id the engine re-polls — never the vendor operation-name (ADR-0011 I1)
  provider: 'anthropic' | 'openai' | 'gemini' | 'deepseek'; // the bound LlmProviderId (closed z.enum(LLM_PROVIDERS); failover does not apply to an in-flight job)
  model: string;               // canonical model id
  modality: 'image' | 'audio' | 'video';
  startedAt: string;           // ISO-8601 submit time
  deadlineAt: string;          // ISO-8601 = startedAt + [defaults].media_job_deadline_ms; on resume now > deadlineAt short-circuits a doomed re-poll
}

export interface HumanGatePausedEvent extends BaseEvent {
  type: 'human_gate:paused';
  nodeId: string;
  gateId: string;           // stable id of this gate instance; required by the resume path — engine.resume(runId, gateId, decision)
  gateType: 'approval' | 'input' | 'review';
  message: string;
  assignee?: string;
  timeoutMs?: number;
  timeoutAction?: 'approve' | 'reject';  // on-timeout policy (present only with timeoutMs); lets a surface show how the gate auto-resolves and a Phase-2 crash-resume re-arm the timer from the log
  expiresAt?: string;
}

export interface BudgetWarningEvent extends BaseEvent {
  type: 'budget:warning';
  spentMicrocents: number;
  limitMicrocents: number;
  thresholdPct: number;     // 0–100, rounded from spent/limit at the pre-egress check point
}

export interface BudgetPausedEvent extends BaseEvent {
  type: 'budget:paused';
  nodeId: string;           // the agent node whose next LLM call would exceed the cap
  spentMicrocents: number;
  limitMicrocents: number;
  gateId: string;           // stable id of the budget gate; required by engine.resume(runId, gateId, decision)
}

export interface BudgetEstimateCommittedEvent extends BaseEvent {
  type: 'budget:estimate_committed';
  nodeId?: string;          // the agent node that owned the attempt; on a session turn, the agent ref (as on cost:updated)
  attemptNumber?: number;   // 1-based WITHIN-CHAIN attempt, same semantics as cost:updated
  model: string;            // canonical model id — the per-model conservative attribution key
  estimateMicrocents: number;                 // THIS commitment's bounded amount
  cumulativeConservativeMicrocents: number;   // the owner-local running total after it (a snapshot, not a delta)
}

export interface RunTimeoutEvent extends BaseEvent {
  type: 'run:timeout';
  elapsedMs: number;
  timeoutMs: number;
}

export interface CostAttemptSettledEvent extends BaseEvent {
  type: 'cost:attempt_settled';
  // RUN-ONLY: `runId` always, `sessionId` never — unlike its estimate twin and unlike cost:updated.
  nodeId: string;           // required — on the run path every attempt has an owning vertex
  model: string;            // canonical model id this attempt actually ran on
  attemptNumber: number;    // 1-based WITHIN-CHAIN attempt. REQUIRED here, optional on cost:updated — see below
  inputTokens: number;
  outputTokens: number;
  costMicrocents: number;   // THIS attempt's realized charge. A restoring reader must NOT sum these — see the fold rule above. 0 is reachable (unpriced/free)
  cumulativeCostMicrocents: number; // the run-wide total AFTER it — a durable ABSOLUTE total; restore is Math.max over the absolutes, never a sum of the deltas
  priced: boolean;          // REQUIRED here, optional on cost:updated — see below
}

Security: event payloads never carry secrets

agent:tool_call.toolInput is sanitized (no secrets) and agent:tool_result.outputSummary is truncated. run:started.inputs carries workflow inputs, but any secret-typed input is masked — the value is replaced with { secret: true, ref }, never the raw value. The ref is a SELF-reference — inputs.<name>, naming the slot the value came from — not a keychain or env reference. It identifies which input was masked; it does not say where the credential lives, and nothing can resolve it back to one. That distinction is load-bearing on resume: ADR-0083 §6 verifies the SLOT — that the same named secret input is re-supplied — and explicitly cannot prove the value is the same credential or that nothing was rotated. API keys and other secrets never appear in any event payload — this holds across the in-process bus, HTTP SSE, and any persisted run log. (On the desktop the raw provider key never even reaches the WebView: egress is Rust-delegated, ADR-0018.)

The same { secret: true, ref } MaskedSecret marker can also appear in node:completed.output (for an input node, which emits the masked inputs) and therefore in run:completed.outputs / run:failed.partialOutputs wherever a secret-typed input would otherwise surface — the engine masks secret inputs at the ingress so a raw secret never reaches an output payload (see run-plan.md §output capture). Any surface rendering of node/run outputs must treat a MaskedSecret object as a redacted placeholder, not displayable data.

Consuming the stream

The consumer pattern is identical for every surface, local or cloud:

const handle = engine.start(workflowId, inputs);
for await (const event of handle.events) {
  switch (event.type) {
    case 'agent:token':        renderStreamingToken(event.nodeId, event.token); break;
    case 'node:completed':     markNodeDone(event.nodeId, event.tokensUsed);    break;
    case 'human_gate:paused':  showApprovalUI(event);                           break;
    case 'run:completed':      showResult(event.outputs);                       break;
  }
}

On the desktop the same events are produced and consumed WebView-side over the engine's in-process RunEventBus (they do not cross IPC) — see ipc-contract.md. On the cloud portal (Phase 2) they arrive over HTTP SSE. In all cases the consumer routes by nodeId into the per-node status map in runStore (kept deliberately separate from the canvas store to avoid re-rendering ReactFlow on every token — see ../shared-core/store-shapes.md).

Human-gate suspend/resume across the stream

A human gate threads two events through the stream around a suspension:

  1. Engine reaches a human_gate node, persists full run state, emits human_gate:paused carrying the gateId, and suspends — the process may even exit.
  2. A surface renders the approval UI and the user acts; the surface calls engine.resume(runId, gateId, decision), passing back the gateId it received on the paused event (it identifies which gate is being resolved).
  3. The engine reloads state, emits human_gate:resumed, and the run continues.

The gate decision object:

export interface GateDecision {
  decision: 'approved' | 'rejected' | 'input_provided';
  decidedBy: string;        // user id, or 'timeout' when a gate auto-resolves on timeout
  payload?: unknown;        // for gate_type = input
  comment?: string;
}

Timeout behavior (timeout_action on the node) maps to decidedBy: 'timeout' when a gate auto-resolves. The timeout_action: escalate value is reserved in v1.0 (a timeout resolves only as approve or reject); see workflow-yaml-spec.md.

Session event namespace

An agent session (ADR-0024) is driven on the same RunEventBus, but emits a disjoint session:* namespace keyed by sessionId instead of runId. Consumers route purely on the type discriminant, so the two namespaces never collide.

interface BaseSessionEvent {
  type: string;             // 'session:*' (see below)
  sessionId: string;
  timestamp: string;        // ISO 8601
  sequenceNumber: number;   // monotonic per session — same gap-detection/resync rule as a run
}

export type SessionEvent =
  | SessionStartedEvent       // 'session:started'   — { agentRef, model, context }
  | SessionTurnStartedEvent   // 'session:turn_started'   — a user message began an assistant turn
  | SessionTurnCompletedEvent // 'session:turn_completed' — { stopReason, tokensUsed, error? }; stopReason is the SESSION superset (the 5 LLM StopReasons + 'aborted' — the EA7 mid-turn abort, ADR-0057)
  | SessionCancelledEvent     // 'session:cancelled' — cancel() was called; the session ends (terminal). NOT a mid-turn abort (EA7), which keeps the session alive — see session:turn_completed{stopReason:'aborted'}.
  | SessionExportedEvent      // 'session:exported'  — { workflowPath } (chat-to-workflow export)
  | SessionCompactingEvent    // 'session:compacting' — { reason:'manual'|'auto-threshold' } (ADR-0062 §7: compaction STARTED — the "Summarizing…" moment; carries no counts, they ride the terminal below)
  | SessionCompactedEvent     // 'session:compacted' — { reason:'manual'|'auto-threshold', summary, keptMessageCount, tokensBefore, tokensAfter, tokensUsed } (ADR-0062: model-summarised compaction; tokensUsed is the summarization spend, accounted to the session budget)
  | SessionTrimmedEvent;      // 'session:trimmed'   — { reason:'manual'|'auto-fallback', keptMessageCount, droppedMessageCount } (ADR-0062: deterministic history trim, no LLM call, no cost; the view surfaces the auto-fallback trim)

session:compacting, session:compacted, and session:trimmed are side events (like session:exported), never a stream terminal. session:compacting is the paired START of a compaction moment (a /compact or an auto-threshold trigger — amending the ADR-0036 event substrate per ADR-0062 §7): the host drives a labeled "Summarizing…" indicator off it, and the moment ends on the terminal session:compacted (success) / session:trimmed auto-fallback (the summariser failed → deterministic trim) — or, for a manual /compact failure, when compact() settles (no terminal event). The host writes the append-only boundary marker row (session_messages.compaction_dropped_through_sequence) on session:compacted / session:trimmed (never on session:compacting) — see ADR-0062 and agent-session-spec.md.

A turn that fails (a provider error, a rate limit, an exhausted budget cap) still emits session:turn_completed with an error?: { code, message, retryable, correlationId? } — the same closed ErrorCode taxonomy and secret-free correlation id as run events (ADR-0036) — so a surface can render the failure rather than a silent stall. On such a failure tokensUsed carries the turn's real accumulated usage — non-zero when a provider had already engaged before the failure (e.g. a tool round or a later turn that then failed), and zero when the failure was pre-egress (a no-plan-entries / budget-pause / hard-cap block engaged no provider); EA2, ADR-0055. A cancellation is distinct: it emits session:cancelled (not turn_completed) and the in-flight user message is rolled back from the transcript, so a cancelled turn leaves no partial assistant turn behind (see agent-session-spec.md). A mid-turn abort (ADR-0057 EA7, the Esc interrupt) is distinct from BOTH: it settles one session:turn_completed with stopReason: 'aborted' and no error (it is user-initiated, not a failure), rolls the pending user message back, and keeps the session alive (→ idle) — it is not session:cancelled (the session continues). 'aborted' is a session-only stop-reason (the @relavium/llm StopReason stays its five values).

Within a turn, the conversational work reuses the same agent:token / agent:reasoning / agent:tool_call / agent:tool_result / cost:updated event shapes the AgentRunner already emits — carried on the session envelope (sessionId). The per-turn append of user/assistant/tool messages is persisted as session_messages (see database-schema.md); the contract is owned by agent-session-spec.md. On every surface session events are produced and consumed in-process exactly like run events — only llm_stream crosses IPC on the desktop (ipc-contract.md). So the complete typed event stream for a session is the eight session:* lifecycle/side events (the SessionEvent union above — started / turn_started / turn_completed / cancelled / exported / compacting / compacted / trimmed) plus agent:token / agent:reasoning / agent:tool_call / agent:tool_result / cost:updated (and, on the chat approval path, agent:approval_requested — ADR-0057) carrying sessionId — plus budget:estimate_committed once ADR-0074 §4 wires the session's durable budget write; the schema and the sink already carry it, but no session producer reaches it yet — this full set is exactly what relavium chat --json emits (agent:reasoning included: a --json consumer that does not want it filters on type).

The session stream (SessionHandle, 1.W). A session is long-lived across turns, so — unlike a run's exactly-one-terminal RunHandle — the SessionHandle.events async-iterable stays open across turns: session:turn_completed is a per-turn boundary, not a stream terminal. The stream closes only on session:cancelled (the session's sole terminal); session:exported is a side event (1.Z), never a terminal. The bus assigns the per-session sequenceNumber — a monotonic counter keyed on sessionId, independent of any run's runId counter on the same shared bus (ADR-0036 "one bus, two namespaces") — with the same gap-detection / resync rule as a run. AgentSession (1.V) emits envelope-free drafts through its injected SessionEventSink; 1.W's createSessionEventSink attaches the sessionId and the bus stamps the sequenceNumber + timestamp at the one authoritative translation point. The bus's validation gate accepts both families via the combined RunOrSessionEventSchema (@relavium/shared). agent:file_patch_proposed is run-only (it carries runId, emitted by the AgentRunner workflow adapter — not the shared turn core), so it is not part of a session stream; createSessionEventSink drops it defensively at the seam. By contrast, agent:token / agent:reasoning (EA6), agent:approval_requested (ADR-0057 EA3/EA5) are dual-envelope events the chat session path emits, so the sink carries them (they are not run-only). budget:estimate_committed (ADR-0074 §2) is dual-envelope and the sink carries it too, but the session producer is §4's work — today only the run path emits it. cost:attempt_settled (ADR-0076) is run-only like agent:file_patch_proposed, and for a stronger reason than a sink-level drop: the hook that produces it is an optional turn-core parameter the session path never sets (ADR-0077), so nothing is emitted there to drop. The session's realized spend is recorded per attempt into session_costs instead (ADR-0070), synchronously and before the next egress (#W15-4).

Workflow governance and reserved events

@relavium/core resource governance adds four run events — the first three from ADR-0028, the fourth from ADR-0074:

type Meaning Key payload fields
budget:warning Pre-egress worst-case cost estimate would exceed the configured cap, and on_exceed: warn is set. Emitted once per run before the capped egress; execution continues. thresholdPct is clamp(round(spent / limit * 100), 0, 100) observed at the pre-egress check point. spentMicrocents, limitMicrocents, thresholdPct
budget:paused Pre-egress estimate would exceed the cap with on_exceed: pause_for_approval; the run suspends like a human gate and is resumed via engine.resume(runId, gateId, decision). decision: approved continues; rejected closes the run with run:failed{code: budget_exceeded}. nodeId, spentMicrocents, limitMicrocents, gateId
budget:estimate_committed A conservative budget commitment made durable (ADR-0074 §2): a provider may already have accepted or billed a call, but the response carried no trustworthy usage (a clean EOF with no terminal usage, a partial-stream failure, a cost-tracker failure), so the reservation is retained at its bounded estimate instead of being released — releasing it would reopen a strict cap against money that may already be owed. An ESTIMATE, never realized spend: it is deliberately its own type rather than a field on cost:updated, because folding it into actual-cost reporting would present an upper bound as an invoice. cost:updated, the per-model actual-cost attribution, and the total_cost_microcents totals all stay realized-only. Dual-envelope (runId on a run, sessionId on a session) — the governor is shared by workflows and resumable chat. Durability lands per surface: on a run it is a run_events row; on a session it is a durable session-budget write plus a streamed session event (it is not a run_events row there). A surface renders the amount as estimated, possibly billed, and — per §1 — also offers to release it. nodeId? (the agent node; on a session turn the agent ref, as on cost:updated), attemptNumber? (1-based within-chain, as on cost:updated), model, estimateMicrocents (positive), cumulativeConservativeMicrocents
run:timeout The run hit its timeout_ms. elapsedMs, timeoutMs

How a reader RESTORES the conservative total: sum estimateMicrocents, less any release. Never last-wins over cumulativeConservativeMicrocents. That snapshot is a producer-side convenience for display and cross-checking (a refinement pins cumulativeConservativeMicrocents >= estimateMicrocents), not the restore source, for two independent reasons. Order: the engine assigns sequenceNumber after an await in #emitDurable and explicitly states that "concurrent events have no canonical order", so under a fan_out the lower seq can carry the higher cumulative — and restoring from the last one would hand already-owed money back to the cap as headroom, the exact bypass ADR-0074 closes. (cost:updated does use last-wins, but only because it is streamed, never persisted; the durable snapshot sibling node:completed.cumulativeCostMicrocents is folded with Math.max precisely for order-independence.) Monotonicity: Math.max over those snapshots is order-independent and would be correct today — each snapshot is read immediately after its own increment, so the largest is the true running total — but it stops being correct the moment §1's deliberate release DECREASES the total. A sum of signed deltas is correct in both worlds, so that is the rule. Until the reserved release event below is activated, the sum is unsigned and no release exists.

These four (and run:paused / human_gate:paused) are non-terminal — they signal a governance/suspension state, not the run's end. A run that cannot continue past a timeout or budget cap still closes with exactly one run:failed carrying code: run_timeout / budget_exceeded. The exactly-one-terminal-event invariant (run:completed | run:failed | run:cancelled) and its precedence are owned by ADR-0036.

Reserved (declared, but emitted by no Phase-1 code):

  • Loops (loops ADR, 0030+): iteration:started / iteration:completed, and an optional iterationIndex? / iterationTotal? on node-level events. Reserved so the schema is future-proof without Phase-1 bloat.
  • Conservative-commitment release (ADR-0074 §1): budget:estimate_released, carrying the same envelope plus model, nodeId?, attemptNumber?, releasedMicrocents and the post-release cumulativeConservativeMicrocents — i.e. the commit event's identity fields, not just the amount. model is load-bearing rather than symmetric: §4 commits to a per-model conservative attribution beside the owner-level aggregate (keyed the way session_costs is keyed), and without it a release can decrement the grand total but not say which model's bucket to decrement — so the aggregate and the breakdown would diverge the first time a user released anything. Cheap to settle here while it is still prose; a one-way door once it ships. §1 requires that a commitment be clearable by an explicit user decision — "what is forbidden is the system silently deciding the estimate was wrong" — and a release must be durable or it silently returns on the next resume. Reserved rather than emitted because Phase-1 has no surface for the decision yet; declaring it now is what makes the restore rule above ("sum, less any release") a settled contract instead of an open question. When it lands, the fold subtracts it and the sum becomes signed.
  • Steering (agent-sessions.md): agent:directive_injected (mode: 'non_blocking' | 'blocking', directiveLength — not the content, so no secret/PII enters the stream), agent:context_compacted, agent:context_cleared. Security envelope: a directive applies only to a running or paused agent; completed nodes are immutable.

Error-code taxonomy

node:failed.error.code and run:failed.error.code are a closed ErrorCode enum (not a free string), so surfaces can branch on cause and retryable is unambiguous:

validation · content_filter · provider_auth · provider_rate_limit · provider_unavailable · tool_denied · tool_failed · tool_unavailable · budget_exceeded · effect_needs_attention · run_timeout · turn_limit · cancelled · sandbox_error · internal

The retryable/fatal mapping is owned by error-handling.md (e.g. provider_rate_limit/provider_unavailable retryable; provider_auth/validation/content_filter/tool_denied/tool_unavailable/turn_limit/cancelled fatal). tool_unavailable is a required ToolHost capability arm (fs/process/egress/…) not being wired — a host/config gap, not the model's fault — so a surface names the missing capability + the tool actionably instead of an opaque internal (EA1, ADR-0055); it is distinct from tool_denied (a policy/grant denial of a present capability). content_filter is a provider content-policy rejection (text or media generation) — a fatal cause distinct from validation (an authoring/shape error), so a surface shows the right reason; the content_filter LlmErrorKind maps here (1.AG, ADR-0045 §6). turn_limit is the limit-family code for a hard agent/session turn/round cap (the exact knob is settled with AgentSession, 1.V) — distinct from run_timeout/budget_exceeded so a capped conversation surfaces its own cause rather than a silent stop; continuing past it is an explicit user action, never a retry. It is not the [chat].max_messages knob, which is a session-history trim threshold (config-spec.md) — trimming continues the session and emits no error. effect_needs_attention is a durable EXTERNAL side effect whose outcome this process cannot establish, on a tool the engine may not safely retry (ADR-0080; effect-journal.md) — fatal and deliberately not retryable, because retrying is the duplicate the effect journal exists to prevent. It is distinct from tool_failed (the call demonstrably did not happen) and from internal (an engine fault): here the effect may well have SUCCEEDED, and that ambiguity is the whole content of the code. Messages remain user-safe and secret-free.

Forward-compatibility

This schema is versioned by additive evolution, not a version field. The following are always v1.0-legal and never a breaking change, provided consumers ignore unknown types and unknown fields and treat an absent optional field as omitted (not null):

  • adding a new optional field to an existing event;
  • adding a new event type (including activating any reserved type above).

Removing or repurposing an existing field/type is a breaking change and is not done within the contract.

Where "ignore unknown types" is actually enforced. For a long time it was not: RunEventSchema is a z.discriminatedUnion('type', …), so it throws on a type it does not know, and every stored row was parsed through it — one event written by a newer binary made an entire run unreadable to an older one. ADR-0074 §5 settles it at a single seam: parseStoredRunEvent (run-event.ts) is the read boundary, and it draws exactly one distinction — an unknown type is a newer writer, so the row is dropped; a known type with an unparseable body is corruption, so it still throws (ADR-0050's durability-first posture). Every write-side parse stays strict, because a producer emitting an unknown type is a bug, not forward evolution. A new reader of stored events uses that function; it must never re-implement the rule or wrap a read in a catch.

Where the promise does NOT apply: a REPLAY. "Ignore unknown types" is addressed to consumers that RENDER the stream. A caller that reconstructs authoritative state in order to do something — checkpointer.ts seeding engine.resumeFromCheckpoint — cannot safely ignore a row it does not understand: it has no way to know whether that row was a node terminal, an async job submission, a gate decision or a cost commitment, so tolerating the hole means re-running completed work or re-submitting an already-billed job, silently. ADR-0075 narrows ADR-0074 §5 accordingly: the replay read (loadRunEventLogForReplay) refuses when any row was skipped, and every display read stays tolerant. There is no session counterpart because no session resume reads a stored event log — a session's durable state is typed rows, not events.

cost:attempt_settled is the first event to exercise that carve-out, and it is why ADR-0075 landed first. Adding it is additive and v1.0-legal for every RENDERING consumer — an older relavium logs drops the row and shows the rest of the run. It is deliberately not additive for a REPLAY: an older binary resuming a log that contains it would re-run paid work against a cap missing the very charges the event exists to record, which is the failure that makes the fix self-defeating on a downgrade. The refusal is the correct behaviour, and the remedy is an upgrade. Read the two halves together before adding the next durable type — "adding a new event type is never a breaking change" is true of the stream and false of the resume.

Transport notes

Phase 1 — local (in-process on every surface)

  • Desktop: the engine runs in the WebView's JS runtime (ADR-0018), so run events are delivered WebView-side over the engine's in-process RunEventBus — they do not cross IPC as RunEvents. The one Rust→WebView channel on the hot path is the delegated LLM egress's typed, backpressure-aware Channel<StreamChunk>: if the WebView consumer lags, the Rust sender awaits, throttling the egress without dropping chunks; the adapter folds those chunks into agent:token events on the WebView-side bus. See ipc-contract.md.
  • CLI / VS Code: the engine runs in-process; events are delivered via the engine's RunEventBus (a platform-free, in-house typed event bus — not Node's node:events; ADR-0036) or the co-equal RunHandle.events async iterable.

Phase 2 — cloud (HTTP SSE)

The cloud API exposes the same stream as Server-Sent Events. Reconnection uses sequenceNumber (and SSE Last-Event-ID) for gap detection and resync against durable run state. A singleton SseManager owns the EventSource lifecycle with exponential-backoff reconnect (500ms → 1s → 2s → 4s, cap 30s) and a GET /runs/:id/state resync on reconnect.

Legacy event-name note. Earlier design drafts used dotted event names (node.started, node.token, node.completed, node.error, run.complete, human_gate.pending, cost.update) with a { type, nodeId, payload, seqNo } envelope. The canonical contract going forward is the colon-namespaced RunEvent union above with sequenceNumber. New code targets the union; the dotted names are recorded here only to disambiguate older references.