Little documentation
Guides and APIInternals

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 sidecar

Each 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:

  1. sequence is 1-based and dense. validateEventLogShape() asserts event.sequence === index + 1; a hole is corruption.
  2. eventId is deterministic. Same event log → same IDs.
  3. payload is canonical JSON — appendEvent() calls canonicalClone() before writing; re-reading and re-hashing always reproduces the same value.
  4. Harness append identity is durable. The Harness emitter selects appendIdempotencyKey before transport and Little Workflow stores it with the event rather than replacing it.
  5. Retries are content-bound. Repeating the same (runId, appendIdempotencyKey, canonicalRequestDigest) returns the original row and sequence. Reusing a key for different canonical content fails with EventAppendIdempotencyConflictError.

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

100%

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 kindEvent sequence
planharness.session.started → (harness.model.called → harness.model.responded → harness.tool_call.* × tools per turn) × N turns → harness.session.completed
orchestrateharness.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_stepharness.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:

EventWhen
StepScheduledStep becomes ready to run
StepAttemptStartedAttempt N begins (wraps harness session)
ArtifactCreatedStep output artifact written to Local World
StepOutputValidatedOutput validated against step schema
StepCompletedDurable boundary; inline output + outputRef
StepFailedAttempt failed; with error + retriable

Plus parallel events: ParallelGroupStarted, ParallelBranchScheduled, ParallelBranchCompleted/Failed, ParallelGroupCompleted/Failed. See Parallel.

Run-Level Events (unchanged)

EventWhen
WorkflowVersionRegisteredLWIR locked after planner session completes
RunStartedExecution begins
OuterLoopCycleCompletedOuter-loop cycle commit point (Layer B runs only)
RunCompletedRun finished successfully
RunFailedRun 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 envelope

The 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 for payload.model) and fold it into the run's totals, plus the totals of the step named by payload.stepPath when present.
  • harness.session.completed → mark session done. Its usage rollup is deliberately not accumulated: it restates the same harness.model.responded events, 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_abc

The 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 same runId returns 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 caller attribution), 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; appendEvent resolves 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 runId is 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.

On this page