Every run executes on Petri, so the in-process legacy executor goes: `fabro-core` and, in `fabro-workflow`, the handlers, lifecycle, pipeline execution, routing, retry, conditions, node handlers, steering, agent memory, artifacts, checkpoints, command log, and the `start`, `resume`, `retry`, `fork`, `rewind` and `timeline` operations. The two are deleted together because the engine half of `fabro-workflow` was the only user of `fabro-core` and `fabro-core` the only runtime of that half; neither compiles without the other. Kept in `fabro-workflow`, narrowed: the parse/transform/validate/persist pipeline and `create`, `archive`, `validate` (workflow definitions still come from DOT and settings); the run tools (`run_tools`, moved from `handler/llm/fabro_tools.rs`) for Ask Fabro, `fabro exec` and Petri's host tools; the pull request pipeline (`pull_request`, moved from `pipeline/`, for the step 0 port); Run Files' diff helpers in `sandbox_git`; `git_identity`, `usage_rollup`, `run_status`, `run_materialization`, `web_search` and `workflow_bundle`. Server: `RegistryFactoryOverride` becomes `execute_in_process`; `RunAnswerTransport::InProcess` carries only the interviewer; the interrupt endpoint answers 501 `interrupt_unsupported` and every pair endpoint 501 `pair_unsupported` (status lists none); rewind, fork, retry and timeline handlers and routes are removed; the command log is served from the stage output blob; usage rollups accumulate from the settled projection after an in-process run as after a worker exit. Ported while here: - `materialize_admitted_run` materializes the goal and drops a disabled pull request block, as the legacy materializer did. - A run whose admitted graph has an agent or prompt node is refused at create when no LLM provider is ready (`fabro.model.no_ready_provider`); a workflow of commands and gates needs no model and is admitted. - The projection's question type falls back on the options, as the interview adapter does, so a gate with edge-label options answers as multiple choice. Tests: the server scenarios (lifecycle, run completion, SSE, helpers) run in process on Petri and assert Petri's stage labels and stream names; the reconcile tests assert Petri's relaunch semantics; legacy unit tests of the deleted executor are removed; three server unit tests the removal took with it are restored; the pair fixtures go with the pair feature. Petri test fixtures no longer name `[workflow] engine`. Still red after this commit, all legacy consumers the next steps delete or port: fabro-store's Slate/reducer fixtures and fabro-types legacy JSON tests (step 4); server unit tests over legacy run events (retry endpoints, list_run_events, artifacts, per-event pause/unpause, run history activation, legacy sandbox fixtures) (steps 3-4); CLI tests that parse legacy event envelopes, the legacy `events`/`attach`/`diff`/ `dump`/`inspect` snapshots, `run rewind`/`run fork`, the ACP and git-identity workflow tests, and the runner tests that drive the legacy worker by hand (steps 3-4); the web app's Petri fixtures still carry `engine` (regenerate with `FABRO_CAPTURE_PETRI_FIXTURES` in step 4). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| src | ||
| tests | ||
| Cargo.toml | ||
| README.md | ||
| VIEWS.md | ||
fabro-petri
Fabro's adapters over Petri, the workflow engine Fabro runs its workflows on.
Layering rule
Only this crate imports Petri. The workspace Cargo.toml pins the Petri
packages by revision under petri_* keys, and fabro-petri is the only
member that lists them as dependencies. Every other Fabro crate reaches the
engine through what this crate exports. A Petri pin move is therefore a change
to this crate and the lockfile, nothing else.
Engine freeze
The engine half of fabro-workflow (handler/, lifecycle/,
pipeline/execute, graph/routing.rs, node_handler.rs, retry.rs,
condition.rs, context.rs and model_fallback.rs under its src/) takes
bug fixes only. New engine behaviour goes to Petri and reaches Fabro through
this crate. The Engine freeze CI check
(.github/workflows/engine-freeze.yml) fails a pull request that adds lines
under those paths unless it carries the bugfix label. The path list is in
scripts/check-engine-freeze.sh; run it locally as
scripts/check-engine-freeze.sh origin/main to see what a branch adds there.
What it holds
Every adapter the integration plan describes lands here.
SqliteRunStore: Petri'sRunStoreandRunLogsover Fabro's SQLite database, so a run's records live in Fabro's tables (petri_runsfor the run and its writer lease,petri_recordsfor every record of every log, and the sharedblobstable). The module docs state the lease and append rules.runtime: the Petri runtime Fabro assembles, the same way at create time and at execution: the Fabro frontend with the server's settings layer, the Attractor step kinds (real, or simulated for a dry run), the model client as thePebbleClientcapability, the Fabro home.check: Petri compiles at create time. The workflow version's bundle goes into an in-memory file map (frontend::MapFiles, laid out as the bundle:workflow.tomlbeside the workflow,.fabro/project.tomlat the root),Runtime::check_sourcelowers it with the run's inputs and launch, and the admitted graphs or Petri's diagnostics come back in a shape the server maps onto Fabro's. Nothing is written to disk.admission: the admitted graphs in Fabro's blob store, named on the run spec as itsPetriAdmission, verified by digest on load.engine: a run executed by Petri, started from its admitted graphs or resumed from its records, with the outcome read from the run's record throughinspect_runand mapped to the conclusion Fabro's read side records. The run's worker process runs it overHttpRunStore; the server runs it in its own process only under its test override, overSqliteRunStore. The caller supplies the interviewer, and the secret provider and blob table when it has them.interview: Petri'sInterviewerover Fabro's questions API and the worker's control channel. A question has one id in Fabro, Petri's own (gate#2): the projection lists it pending from thequestionrecord,GET /runs/{id}/questionsserves it, and the answer posted to/questions/{qid}/answeris validated against that pending record and reaches the worker's control interviewer over the control bus (or the in-process one directly) under the same id, mapped onto Petri's answer. The adapter still posts the legacyinterview.*events (through the worker's run event sink, or the run's database in the server process) with that id and the projection's stage label, for the readers that follow the event stream rather than the projection: Slack,run attachand the web app's Q&A renderer. The store derives theinterview.answeredplatform record, with the answering principal, frominterview.completed. An expired or cancelled question is completed asinterview.timeoutorinterview.interrupted; an auto-approved run answers itself.secrets: Petri'sSecretProviderover the vault's token entries, so a{{ secrets.NAME }}reference resolves at spawn into a command's environment and is masked in every record; a sensitive answer registers as a dynamic secret.blobs: Petri'sOutputStoreover Fabro'sblobstable, through the server'sBlobStoreor the worker's client, so a large stage value leaves the records for the table underblob://sha256/<hex>.HttpRunStore: the same store as a run's worker process reaches it, over the server's/api/v1/runs/{id}/petri/*endpoints with the worker's token. The server answers from itsSqliteRunStore, so the lease and the(log, seq)rule are the store's; this layer carries requests, resends a request whose reply was lost, maps the server's error codes back toStoreError, and, for a worker, takes every lease for the worker's launch id. The module docs state the rules.petri: the Petri store vocabulary re-exported for the server, which answers the worker endpoints from aSqliteRunStorewithout naming a Petri package in its own manifest.projection: the fold of a Petri run's public events (replay_sinceover its records) and Fabro's platform records (fabro-store'splatform_records) into theRunProjectionthe API serves, row by row asVIEWS.mdmaps them. The stage key is(execution, firing); theStageIdlabel isnode@visit, made unique with the execution when two child invocations would share one.projector: the view pass and its wake-up. Records first: Petri's append and a platform record's insert return before any view work; a pass reads what is committed, folds the items past the committed positions, and writes the projection document (petri_projection), the ordered stream (petri_stream, onestream_seqper Petri event or platform record) and the narrowedrunsrow in one later transaction. The server signals the projector after each committed worker append, after each committed platform record (the run summary store's hook), at worker exit and, over every Petri run, at startup. A run that executes in the server process goes throughProjector::observe_store, which signals after each append. A torn tail (a record Petri cannot read) holds the view where it stands and reports the run incomplete with the reason. The projector also serves the stream back (Projector::stream_after, oneRunStreamItemper row:run_id,stream_seq,kind, the item's ownid,recorded_at, the item) and signals its readers after each committed pass (Projector::subscribe), which is howGET /runs/{id}/eventspages a Petri run byafterandGET /runs/{id}/attachfollows it live. The version of Petri's event contract the stream carries ispetri::EVENT_CONTRACT_VERSION.- The platform adapters the plan adds after it: hooks and the run tools.
What the projection leaves default
VIEWS.md rows with no source yet, or whose source this crate does not read
yet, keep their default value in the projection: StageProjection.diff and
Conclusion.diff.patch (the checkpoint's patch_blob is not resolved),
Checkpoint's engine-derived maps (completed_nodes, node_retries,
context_values, node_outcomes, next_node_id), agent_tools,
permission_level, script_invocation and script_timing, a stage's
notes, StageCompletion details for a parsed.note, the sandbox instance
(the matrix's two gaps), Run.ask_fabro, an interview option's
description and preview, the pull request creation state, and the
run's notices, notifications and pairings (recorded, not shown).
Every run executes on Petri. The server side is fabro-server's
server::petri_runs; the worker side is fabro-cli's
commands::run::petri_worker, which fabro run __run-worker takes. After
a server restart, a run left in flight goes back to a worker in --mode resume: the run continues from its records, as Petri's own resume does,
on workspaces the recovery protocol brought to their durable snapshots.
How it is tested
Integration tests live under tests/:
runs.rsruns thehellobundle in memory throughRuntime::standard()with the Fabro frontend and the model-free stub registry, then a command-only workflow on the host sandbox through the real step registry. Both skip, and say why, when thesandbox-driver-hostplugin executable is not onPATH(every run takes its scope's environment through it); the sandbox-plugins CI job requires them.check.rsadmits thehellobundle and round-trips its graph through the blob store, binds the launch, admits a version whoseworkflow.tomlnamesengine = "petri", reads the project settings from the map, and refuses an unknown attribute, an unknown[workflow]key (unsupported.workflow_toml.key, named inworkflow.toml) and, with a model client over the test catalog, an unknown model (attractor.model.unknown). No plugin is needed.sqlite_store.rsruns Petri's store conformance suite (petri_testkit::run_store::conformance) againstSqliteRunStore, plus the operator release, lease exclusivity, a crash between appends, and blob interoperation with Fabro'sBlobStore.interview.rsruns human gates through the engine assembly with the interview adapter over a control interviewer: a gate answered under the posted id, two parallel gates each bound to their own answer, an expiry with the gate's default, an auto-approved run, and a cancelled run.secrets.rsresolves a{{ secrets.NAME }}reference from a vault into a command's environment overSqliteRunStoreand checks the value is in nopetri_recordsrow while the masked output is.blobs.rsoffloads a command's large output to theblobstable and reads it back by theblob://sha256/<hex>reference a record carries.model.rsruns thehellobundle against the OpenAI twin with a model client over a vault that holds the key, and checks the skills step searched the configured Fabro home.
Those four need the host plugin like runs.rs does, and model.rs also
starts the twin.
projection.rsbuilds the view live (every append signals the projector) for thehellobundle on the stub registry, a command-only workflow and a two-branch parallel workflow, and checks it equals the view rebuilt from the records alone (projector::rebuild); catches a view up after every wake-up was dropped, by a signal and by the startup pass; recovers a crash between the record commit and the view transaction by applying only the missing suffix, with the positions andstream_seqcontinuing; runs two projectors over one store with child executions; and holds the view at a torn tail. All skip without the host plugin.
The conformance suite over HttpRunStore needs a server to talk to, so it
lives with the server's integration tests
(lib/apps/fabro-server/tests/it/api/petri_store.rs), which reach the suite
through this crate's test-support feature (fabro_petri::test_support).
Run them with:
ulimit -n 4096 && cargo nextest run -p fabro-petri
The server's end-to-end coverage is lib/apps/fabro-server/tests/it/scenario/petri.rs:
the hello bundle on the OpenAI twin, a command-only bundle and a
two-branch parallel bundle run to completion through the create handler and
the scheduler, in the server process under its test override, with
GET /runs/{id}/state
serving the projection over Petri's records; a human gate is answered
through the questions API; and Petri's diagnostics refuse a run at create.
The server's petri_runs unit tests cover the lease ending at worker exit
and the restart reconcile that relaunches a worker in resume mode.
lib/apps/fabro-server/tests/it/scenario/petri_stream.rs covers the stream:
a client attached to a two-branch parallel run disconnects once both
branches started, a platform notice is recorded while both branch scripts
run, the client reconnects from its last stream_seq, and the union of
what it saw is the whole stream, every item once, in order, with the notice
between the branch events and the same as the paged listing. With
FABRO_CAPTURE_PETRI_FIXTURES set, the scenarios write their settled
projection and stream under apps/fabro-web/app/test-fixtures/petri/,
which the web app's rendering tests read.
The worker path is covered with the real binary in
lib/apps/fabro-cli/tests/it/scenario/petri.rs: a command-only Petri run
executes in the worker a foreground server launched, its records reach
petri_records over the HTTP store and its lease ends with the worker; and
a run whose server and worker are both killed mid-stage resumes in a new
worker after the server restarts, with one terminal lifecycle record; a
human gate in the worker is answered through the questions API over the
control channel; two parallel gates each bind their own answer; and an
unanswered gate expires with its default. The same file reads a finished
run back through the CLI (events raw, tail and --pretty, attach,
wait, inspect), answers a gate from an attached terminal, and follows
a run live with events --follow to its end.