`artifact.collected {execution, firing, attempt, path, blob, bytes, digest}`
records one file a stage left in its workspace, with the bytes in the blob
table; `run.diff {base_sha, head_sha, diff_summary, patch_blob}` records
the run branch against its base; `run.branch` names the workspace the
branch was created in. `RunProjection.artifacts` lists the collected
files, and `parse_blob_ref_encoded` reads Petri's `#json` marker so a
reader knows whether a blob is text or a JSON value.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5.2 KiB
Fabro Run Stream Strategy
A run's history is two logs, and its public event API is one ordered stream over both:
- Petri's records. The engine writes every fact about execution: the run
starting and finishing, each step's firing, progress, output and outcome, a
question asked and answered, a scope acquired. Fabro stores them unchanged
in
petri_recordsthroughfabro-petri'sSqliteRunStore, and reads them through Petri's event contract (RunEvent, with itsderivedview). - Platform records. Facts Fabro knows and Petri does not: the lifecycle
before and after the engine (
run.created,run.lifecycle,run.title,run.parent,run.archived,run.superseded,run.notice), who answered a question (interview.answered), the branch and git identity a run works under, a checkpoint commit with its diff, a collected artifact (artifact.collected), the run's diff (run.diff), the pull request requests and outcomes, a notification sent, a pairing. They arePlatformRecordvalues infabro-store::platform_records, stored inplatform_recordswith a per-runseq.
The projector (fabro-petri::projection) folds both logs into the run's
RunProjection, the view GET /runs/{id}/state serves, and assigns each
record it consumes a stream_seq in petri_stream. That stream is what
GET /runs/{id}/events and the attach stream serve, item by item, as
RunStreamItem: {run_id, stream_seq, kind: petri|platform, id, recorded_at, item}. stream_seq is the cursor a client resumes from; id is the item's
own identity (<log>/<seq>/<index> for a Petri event, the record's seq for
a platform record) for deduplication.
Tracing logs are separate. Tracing is developer diagnostics; the stream is the product-facing record other systems consume. If something must be visible after a reattach, it has to be a record, not a log line.
Recording a fact
Petri's own facts need nothing from Fabro: the engine records them and the projector's fold reads them. Add Fabro code only for a fact Petri cannot know.
To record such a fact:
- Add a variant to
PlatformRecordand its kind toPlatformRecordKindinlib/components/fabro-store/src/platform_records.rs. The kind is thekindtag on the wire, lowercase dot notation (pull_request.created). A record that belongs to a stage names itsexecutionandfiring. - Append it through the server's
run_recordsmodule (or the worker's client), which commits the record and wakes the projector. Never writeplatform_recordsfrom anywhere else. - Fold it in
fabro-petri::projectionwhen the projection should show it. A record nobody reads from the projection still reaches the stream. - Update the readers that match on record kinds: the CLI's pretty stream
rendering (
petri_stream.rs), the web app's stream handling, the Slack service, and the tests or fixtures that name kinds.
Do not add a platform record that restates a Petri record. The projection already carries what the engine knows; read it there.
Reading the stream
Servers and workers hold the stream through the projector: stream_after
for a page, subscribe for live items, stream_head for the cursor to
start from. The server's stream_follower reads every run's stream once and
fans it out to the in-memory run map and the global broadcast that /attach
and the Slack service take their items from.
Clients read GET /runs/{id}/events?after=<stream_seq> for a page and the
attach stream for live items; fabro-client exposes list_run_stream,
list_run_stream_until and attach_run_stream.
When matching items:
- A Petri event's name is
item.record.body.event(run.started,step.started,step.progress.recorded,step.finished,run.finished); its parsed meaning is underitem.derived(a pending question isderived.parsed.kind == "question"). - A platform record's kind is
item.record.kind. - The run has ended when a platform
run.lifecyclerecord'stransitionissucceeded,failedordead. Petri'srun.finishedprecedes it and carries the engine's own status.
Never rebuild an item downstream: pass the RunStreamItem through as read.
Agent events
Pebble's CodingAgentEvent stream is the agent event contract. Petri stores
each event a coding agent publishes for a step as that step's progress, and
the projector folds them into StageProjection.agent with pebble's
SessionProjection. Read StageProjection.agent, or the stored progress
record itself, instead of folding the stream again. Fabro adds nothing of
its own to this stream.
Ask Fabro sessions
Ask Fabro sessions are not runs. Their events (run.session.*) live in
their own log, run_session_events, through RunSessionEventStore, numbered
per session and served by the sessions API. They never enter a run's stream.
Persistence guarantees
A record is committed before it is visible: the projector reads only what
the store has committed, and the stream's stream_seq is assigned in the
same transaction as the projection that consumed the record. A client that
resumes from its last stream_seq sees every item exactly once.
A worker cannot continue past a record it failed to append: the store's error reaches the engine and fails the run.