Event Log
The durability layer. SQLite/WAL-backed event envelopes with two-level vocabulary covering both LWIR steps and harness sessions.
Little Workflow: published on npm as little-workflow@0.2.0-alpha.1 (2026-10-06). Sections labeled UNRELEASED describe source-only changes. Release status
The alpha event log is an append-only table in <dataDir>/events/events.db, backed by SQLite WAL mode. Every fact the runtime records lands here in per-run commit order before the append resolves. Replay reads the committed envelopes through the World API and rebuilds state.
The implementation lives in world.ts (appendEvent, listEvents). Materialization lives in run-state.ts.
Storage Layout
.little-workflow/
events/
events.db ← committed event rows
events.db-wal ← SQLite write-ahead log
events.db-shm ← SQLite shared-memory sidecarEach stored row contains one canonical-JSON EventEnvelope payload plus indexed run metadata:
{"eventId":"evt_4f0a...","runId":"run_a","sequence":1,"type":"harness.session.started","recordedAt":"2026-05-21T10:00:00.000Z","payload":{"runId":"run_a","role":"planner","task":{"kind":"plan"},"manifest":{...},"manifestHash":"sha256:..."}}
{"eventId":"evt_2c81...","runId":"run_a","sequence":2,"type":"harness.model.called","recordedAt":"2026-05-21T10:00:01.000Z","payload":{"turn":1,"promptHash":"sha256:...","request":{"model":"gpt-5","messages":[...],"tools":[...]}}}The storage file is not a public inspection surface. Use little events <runId> or world.listEvents(runId); the CLI prints one JSON envelope per line for jq-friendly inspection.
Envelope Shape
type EventEnvelope = {
eventId: string; // evt_<16 hex>; deterministic: sha256({runId, sequence, type, payload})
runId: string; // run_<...>
sequence: number; // 1-based, strictly monotonic per run
type: EventType;
appendIdempotencyKey?: string; // Harness appends only
canonicalRequestDigest?: string; // Harness appends only
recordedAt: string; // ISO-8601
payload: Record<string, unknown>;
};Five invariants make the log replay-safe:
sequenceis 1-based and dense.validateEventLogShape()assertsevent.sequence === index + 1; a hole is corruption.eventIdis deterministic. Same event log → same IDs.payloadis canonical JSON —appendEvent()callscanonicalClone()before writing; re-reading and re-hashing always reproduces the same value.- Harness append identity is durable. The Harness emitter selects
appendIdempotencyKeybefore transport and Little Workflow stores it with the event rather than replacing it. - Retries are content-bound. Repeating the same
(runId, appendIdempotencyKey, canonicalRequestDigest)returns the original row and sequence. Reusing a key for different canonical content fails withEventAppendIdempotencyConflictError.
Generic Workflow facts do not need an append key. Harness events do: the public Little Harness session port requires a stable caller-generated key, and the Little Workflow adapter derives and persists its canonical request digest so a lost append response can be retried without creating a second event.
Full Event Vocabulary
The vocabulary has two tiers: Harness events (new — emitted by every harness session) and LWIR step events (unchanged from prior alpha).
Harness Session Events
Emitted by every harness session (planner, orchestrator, worker, fixer). The same event types apply to all roles.
harness.session.started
type SessionStartedPayload = {
runId: RunId;
role: HarnessRole; // "planner" | "orchestrator" | "worker.ai-generate" | "worker.code-run" | "worker.tool-call" | "fixer"
task: { kind: HarnessTask["kind"] }; // task kind; full task stored separately (large)
manifest: PlannerManifest | OrchestratorManifest | WorkerManifest | FixerManifest;
manifestHash: string; // sha256 of canonicalized manifest
parentRunId?: RunId; // present for sub-runs inside an orchestrator pipeline
};Sample (planner role):
{
"type": "harness.session.started",
"payload": {
"runId": "run_abc123",
"role": "planner",
"task": { "kind": "plan" },
"manifest": {
"harnessId": "workflowHarness@1.0.0",
"plannerModelSlotId": "gpt-5",
"systemPromptHash": "sha256:a1b2c3...",
"skillsHash": "sha256:...",
"workflowDefinitionHash": "sha256:def123",
"globalToolsHash": "sha256:...",
"memoryStoreIds": ["workflow:candidate-review", "org"]
},
"manifestHash": "sha256:plan789"
}
}harness.session.completed
type SessionCompletedPayload = {
runId: RunId;
output?: unknown; // present for plan/orchestrate/execute_step/fix_step happy path
usage: { // token-only rollup; never carries a dollar figure
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
reasoningTokens: number;
};
// For fix_step only:
fixedSource?: string; // the repaired code body
};This rollup is the harness summing its own harness.model.responded events for session-level consumers. The run materializer skips it on purpose — see Materialization.
harness.session.failed
type SessionFailedPayload = {
runId: RunId;
error: ErrorEnvelope;
};Agent-Loop Events
Emitted by plan, orchestrate, ai.generate worker, and fix_step sessions. Not emitted by code.run / tool.call / decision workers (no model in the loop).
harness.model.called
type ModelCalledPayload = {
turn: number; // 1-indexed turn number in the agent loop
promptHash: string; // sha256 of canonicalized {system, messages, tools}
request: {
model: string; // slot id (e.g., "gpt-5")
messages: Message[]; // truncated/artifact-refed if huge
tools: ToolReference[]; // shallow refs (toolName + schemaHash)
};
};harness.model.responded
type ModelRespondedPayload = {
turn: number;
callId: string;
stepPath?: string; // stamped by the runtime; absent for planner/orchestrator
model: { // pricing key — payload, not metadata, so it persists
provider: string;
modelId: string;
};
response: {
text?: string;
toolCalls?: ToolCallRequest[]; // model's tool invocations this turn
usage: { // token-only; `costUsd` here is rejected by the recorder
inputTokens: number;
outputTokens: number;
cachedInputTokens?: number; // subset of inputTokens, not an addition to it
reasoningTokens?: number; // subset of outputTokens, billed at the output rate
};
};
};This is the atomic, priceable unit of a run's cost. model identifies what to price it against; stepPath is what attributes it to a step correctly even when parallel branches interleave.
Tool-Call Events
Emitted by ALL roles when a tool is invoked. The caller field distinguishes who triggered the call.
harness.tool_call.started
type ToolCallStartedPayload = {
turn?: number; // present when caller="model"; absent otherwise
callId: string; // unique id; matches the corresponding Succeeded/Failed event
caller: "model" | "code";
toolName: string;
args: unknown; // canonical-JSON; large args become artifact refs
};caller: "model" — the harness's agent loop (planner, orchestrator, AI worker, fixer) invoked the tool.
caller: "code" — a code.run step body called tools.x(args) via the proxy, or a tool.call step's direct invocation.
Sample (model-triggered, orchestrator calling plan_workflow):
{
"type": "harness.tool_call.started",
"payload": {
"turn": 1,
"callId": "call_abc001",
"caller": "model",
"toolName": "plan_workflow",
"args": { "workflowId": "candidate.review", "input": { "jobDescription": "..." } }
}
}Sample (code-triggered, code.run body calling fetchCandidates):
{
"type": "harness.tool_call.started",
"payload": {
"callId": "call_code001",
"caller": "code",
"toolName": "fetchCandidates",
"args": { "jobId": "ENG-123" }
}
}harness.tool_call.succeeded
type ToolCallSucceededPayload = {
callId: string;
result: unknown; // canonical-JSON; large results become artifact refs
durationMs: number;
};harness.tool_call.failed
type ToolCallFailedPayload = {
callId: string;
error: ErrorEnvelope;
durationMs: number;
};Direct-Execute Step Events
Emitted by code.run, tool.call, and decision workers (no agent loop — no model events).
harness.execute_step.started
type ExecuteStepStartedPayload = {
stepPath: string;
stepKind: "code.run" | "tool.call" | "decision";
inputHash: string; // sha256 of canonicalized step input
};harness.execute_step.succeeded
type ExecuteStepSucceededPayload = {
stepPath: string;
outputHash: string;
durationMs: number;
};Event Sequencing by Task Kind
| Task kind | Event sequence |
|---|---|
plan | harness.session.started → (harness.model.called → harness.model.responded → harness.tool_call.* × tools per turn) × N turns → harness.session.completed |
orchestrate | harness.session.started → (harness.model.called → harness.model.responded → harness.tool_call.* × tools per turn) × N turns → harness.session.completed |
execute_step (ai.generate) | harness.session.started → harness.execute_step.started → harness.model.called → harness.model.responded → harness.execute_step.succeeded → harness.session.completed (one model call, no tool calls) |
execute_step (code.run) | harness.session.started → harness.execute_step.started → (harness.tool_call.* with caller:"code" × tools invoked by code) → harness.execute_step.succeeded → harness.session.completed |
execute_step (tool.call) | harness.session.started → harness.execute_step.started → harness.tool_call.* with caller:"code" (single) → harness.execute_step.succeeded → harness.session.completed |
execute_step (decision) | harness.session.started → harness.execute_step.started → harness.execute_step.succeeded → harness.session.completed |
fix_step | harness.session.started → agent-loop events × N turns → harness.session.completed (with fixedSource) |
LWIR Step Events (unchanged)
These events are emitted by the runtime scheduler for each LWIR step, wrapping the harness session events:
| Event | When |
|---|---|
StepScheduled | Step becomes ready to run |
StepAttemptStarted | Attempt N begins (wraps harness session) |
ArtifactCreated | Step output artifact written to Local World |
StepOutputValidated | Output validated against step schema |
StepCompleted | Durable boundary; inline output + outputRef |
StepFailed | Attempt failed; with error + retriable |
Plus parallel events: ParallelGroupStarted, ParallelBranchScheduled, ParallelBranchCompleted/Failed, ParallelGroupCompleted/Failed. See Parallel.
Run-Level Events (unchanged)
| Event | When |
|---|---|
WorkflowVersionRegistered | LWIR locked after planner session completes |
RunStarted | Execution begins |
OuterLoopCycleCompleted | Outer-loop cycle commit point (Layer B runs only) |
RunCompleted | Run finished successfully |
RunFailed | Run finished with error |
type OuterLoopCycleCompletedPayload = {
outerLoopId: string;
cycleNumber: number;
supervise: {
kind: "done" | "continue";
finalOutput?: unknown;
promptNote?: string;
};
};Append Path
appendEvent(world, runId, { type, payload })
→ serialize on the per-run write chain (eventChainKey)
→ assert referenced artifacts exist (before persistence)
→ compute sequence from MAX(sequence), build envelope, hash → eventId
→ INSERT INTO events (...) via better-sqlite3
→ return deep-frozen envelopeThe single-writer chain is keyed per run. Each append is an INSERT into the events table of the embedded better-sqlite3 database (WAL mode, synchronous=NORMAL), which fsyncs the WAL before run() returns. The per-run chain serializes the MAX(sequence) read and the INSERT so they cannot interleave; a rejected append (failed artifact-reference check) never consumes a sequence number.
Materialization
materializeRunStateFromEvents(runId, events) folds the event stream into a MaterializedRunState:
type MaterializedRunState = {
runId: string;
workflowVersionId?: string;
status: "pending" | "running" | "completed" | "failed";
startedAt?: string;
finishedAt?: string;
output?: unknown;
outputRef?: ArtifactRef;
usage: UsageTotals; // run-wide tokens + priced dollars
steps: Record<string, MaterializedStepState>; // each step also carries a UsageTotals
artifacts: readonly ArtifactRef[];
error?: unknown;
eventCount: number;
};
type UsageTotals = {
readonly inputTokens: number;
readonly outputTokens: number;
readonly cachedInputTokens: number; // subset of inputTokens
readonly reasoningTokens: number; // subset of outputTokens
readonly costUsd: number | null; // null = unpriced, NOT free
readonly pricedCalls: number;
readonly unpricedCalls: number;
};The folder now handles both harness events AND LWIR step events:
harness.session.started→ track session start; check manifest hash for drift.harness.model.responded→ price the call (tokens × registry rates forpayload.model) and fold it into the run's totals, plus the totals of the step named bypayload.stepPathwhen present.harness.session.completed→ mark session done. Itsusagerollup is deliberately not accumulated: it restates the sameharness.model.respondedevents, so summing it would double-count every model call in the session.StepCompleted→ mark step completed; store output + artifact ref. Carries no usage of its own.StepFailed→ mark step failed; attach error.RunCompleted/RunFailed→ set run terminal state.
Pricing
Materialization is the only place a dollar figure is produced. Events are token-only by construction — the recorder throws if any recorded usage payload carries a costUsd — so a run total is always computed from tokens and never re-summed from a stored cost.
Rates come from the bundled registry, quoted in USD per one million tokens. Cached input tokens are billed at the model's published cache-read rate where the registry has one and at the full input rate otherwise, which over-estimates rather than inventing a discount.
costUsd is number | null. A call whose provider/modelId has no registry entry is priced as null and counted in unpricedCalls — never folded in as 0. An aggregate is null only when every call in scope was unpriced; a genuine 0 means there were no model calls at all. Any consumer that treats null as free has inverted the guarantee this design exists to provide.
Sub-Run Event Logs
Sub-runs spawned by an orchestrator's run_workflow / start_workflow have their own run ids and independent event streams in the same Local World event store. The orchestrator's stream records harness.tool_call.started(start_workflow, args: { ..., subRunId: "run_sub_abc" }) and harness.tool_call.succeeded(result: { runId: "run_sub_abc", ... }), but does NOT inline the sub-run's events.
To audit a pipeline run:
# Orchestrator's session:
little events run_pipeline_xyz
# A specific sub-run's events:
little events run_sub_abcThe parentRunId field on harness.session.started links a sub-run back to its orchestrator's pipeline run.
Inline Output vs Artifact Refs
Step outputs smaller than 256,000 bytes (canonical JSON) are stored inline in StepCompleted.payload.output. Larger outputs are omitted from inline and accessed via outputRef. The same applies to large harness.tool_call.started.args and harness.tool_call.succeeded.result payloads — they become artifact refs when they exceed the threshold.
What This Log Buys You
- Idempotency.
runWorkflow()with the samerunIdreturns the persisted result or replays the failure. Two-level memoization means the same result at both the LWIR step level and the harness model-call/tool-call level. - Auditability. Every model call, tool call (with
callerattribution), artifact write, and validation result is recorded.little events <run-id>dumps committed envelopes in order. - Crash safety. SQLite WAL transactions bound recovery to committed event rows;
appendEventresolves only after the synchronous insert has committed. - Replay determinism. Replay is a pure fold over this log; same bytes → same
MaterializedRunState.
What This Log Is Not
- Not multi-writer. Two processes writing to the same
runIdis a bug; the run lock prevents it. - Not durable across hosts. Alpha is one filesystem on one machine; Postgres-backed event stores come in v0.1-beta.
- Not a query API. The backing store is SQLite, but alpha exposes committed events through
world.listEvents(runId)and CLI JSON output rather than arbitrary SQL.