## What Adds a CRUD interface for **server-managed Environments** at `/settings/environments`, driven by the `/api/v1/environments` REST API (list / create / retrieve / replace / delete), and reshapes how built-in environments are provisioned and protected. The page lives in the **Workflows** settings nav section (also introduced in this branch), positioned before Variables. ## Why The Environments REST API shipped (#453) but had no UI — environments could only be managed via the API/CLI. This gives operators a web UI alongside Variables and Secrets, and along the way tightens the model: environments are seeded at install time (not silently re-created on every boot), and the `default` fallback is an ordinary, deletable environment. ## Web UI **Pages & component** - `settings-environments.tsx` — list view: provider badge, image/resource summary, row actions (Edit/Delete). **"New environment" is a dropdown** of the enabled sandbox providers; the chosen provider is fixed for the environment's lifetime. - `settings-environments-new.tsx` / `settings-environments-edit.tsx` — create/edit flows; create reads the provider from a query param. - `environment-form.tsx` — shared form, reorganized: - **General** panel (merged identity + image): id, and an **image-source selector** (Image reference *vs* inline Dockerfile) that shows, requires, and sends only the selected, mutually-exclusive source. - **Resources**: CPU / memory / disk as **range sliders** (CPU 1–8, memory 1–16 GB, disk 1–20 GB), each always writing a concrete value. - **Environment variables** key/value editor. - **Advanced** progressive-disclosure section holding **Network** (a single "Block all network access" toggle — allow-all vs block) and **Lifecycle** (preserve / stop-on-terminal / auto-stop). Opens by default when any advanced value is non-default. - The in-form **provider control and the Labels editor were removed** — labels remain API-managed and are round-tripped untouched so UI edits never clear them. **Data layer**: `environmentsApi` client, `queryKeys.environments`, `useEnvironments` / `useEnvironment` SWR hooks. **Nav & routing**: "Environments" item in the Workflows section before Variables; routes registered in `router.tsx`. ## Backend: seed at install, deletable `default` - **Seeding moved to install time.** The server no longer seeds built-ins on startup; `EnvironmentStore::load_or_seed` → `load` (load-only). A new public `seed_environments(dir)` (idempotent, preserves operator edits) is called by both the web installer and the CLI installer. An uninstalled instance therefore has no managed environments, and a run selecting an absent environment fails explicitly (`unknown environment: default`) rather than resurrecting a built-in. - **`default` is no longer protected.** The delete guard and the `Protected` error variant are gone; deleting `default` succeeds (204) and removes the run fallback on purpose — forcing an explicit choice. `local` is unchanged (reserved, in-memory). - **`volumes` removed** from environment settings across the OpenAPI spec, generated Rust + TS clients, config layers, sandbox/server/workflow plumbing, docs, and tests. ## API contract details honored - Edit sends the environment `revision` as `If-Match`; 409 conflicts surface a "changed since you opened it" message. - The REST API accepts inline Dockerfiles only — the form never sends a Dockerfile path. ## Verification - Rust: `cargo build` (touched crates) ✅, `cargo nextest -p fabro-environment` 21/21 ✅, server env unit + `tests/it` integration 2/2 + 15/15 ✅, `clippy` (nightly, touched crates, all targets) clean ✅, `fmt --check` clean ✅. Full `--workspace` suite not run here — worth a CI pass. - Web: `bun run typecheck` ✅, `bun run build` ✅, `environment-form.test.ts` 5/5 ✅. Web suite: 512 pass / 1 unrelated pre-existing `RunDetail` failure. - **Not visually verified in-browser** — the local app is login-gated and automated loads redirect to `/login`; rendering of the form, the New-environment dropdown, and `default` delete should be confirmed in a logged-in session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Co-authored-by: Fabro <noreply@fabro.sh> Co-authored-by: Release Repro <release-repro@example.com>
7.4 KiB
Fabro Events Strategy
Fabro emits structured workflow run events during execution for observability. Events are the durable audit trail for a run: they drive the run store, SSE streaming, CLI progress rendering, and optional JSONL sinks.
Events are distinct from tracing logs. Tracing is developer diagnostics; events are product-facing state transitions and activity records that other systems consume.
Detached runs rely on this distinction. If something needs to be visible after reattach, emit a Event rather than only logging to stderr or detach.log.
Architecture
Engine/Handler -> Event -> Emitter::emit()
|- trace(raw event)
|- canonicalize -> RunEvent
`- on_event(&RunEvent)
|- run store
|- SSE
|- optional JSONL/debug sinks
`- CLI / tests / metrics listeners
The canonical RunEvent is built exactly once in the fabro-workflow::event module.
Event(infabro-workflow) is the internal typed event emitted by engine and handlers.Emitterowns an immutablerun_idand convertsEventintoRunEventviato_run_event_at().RunEvent(infabro-types) holds envelope metadata plus a typedbody: EventBody. It has no cached JSON fields; the wire format is produced only during serialization.- Every listener receives
&RunEvent, not&Event. - Bypass paths that cannot go through the emitter must call
to_run_event()once and reuse the sameRunEventfor every sink.
Canonical Envelope
Each serialized RunEvent uses this canonical envelope:
{
"id": "01960d0c-5d16-7d6e-8f61-9fd6f4a532b5",
"ts": "2026-03-30T12:00:01.000Z",
"run_id": "01JQ...",
"event": "agent.tool.started",
"session_id": "ses_child",
"parent_session_id": "ses_parent",
"node_id": "code",
"node_label": "Code",
"actor": {
"kind": "agent",
"session_id": "ses_child",
"parent_session_id": "ses_parent",
"model": "gpt-5.2"
},
"properties": {
"tool_name": "read_file",
"tool_call_id": "call_1",
"arguments": {"path": "src/main.rs"}
}
}
Always-present fields:
| Field | Type | Notes |
|---|---|---|
id |
string | UUIDv7 event id |
ts |
string | UTC timestamp with millisecond precision |
run_id |
string | Workflow run id |
event |
string | Lowercase dot-notation event name |
Optional top-level fields:
| Field | When present |
|---|---|
session_id |
Agent/session events |
parent_session_id |
Forwarded child-session events |
node_id |
Events tied to a graph node or branch |
node_label |
Display label for node_id; omitted when not applicable |
actor |
The principal responsible for the event |
Everything else lives inside properties.
Important rules:
- Optional envelope fields are omitted, not serialized as
null. - Event-specific fields do not get flattened into the top level.
- Actor identity normally lives only in top-level
actor: Principal; do not duplicate it in event-specific properties. The exception isrun.created, whoseproperties.provenance.subjectis the durable run creator stored inRunSpec; its envelopeactoris derived from the same principal. - User actors must carry canonical IdP identity through
Principal::User { identity, login, auth_method }, not a login-only string. EventPayloadvalidation requiresid,ts,run_id, andevent.
Naming
The external event name is lowercase dot notation, for example:
run.startedstage.completedagent.tool.startedsandbox.readyparallel.branch.completed
event_name() in the fabro-workflow::event module is exhaustive. Do not use wildcard fallthroughs when adding new variants.
Node And Session Metadata
node_id is the stable graph identifier. node_label is the human-facing display name. Stage events should surface both through the envelope when applicable.
Agent events now use explicit session links:
session_ididentifies the session that originally emitted the event.parent_session_ididentifies the immediate parent session for forwarded child events.- Nested sub-agents preserve immediate parentage across boundaries.
AgentEvent::SubAgentEvent no longer exists. Child activity is forwarded as normal agent events with session linkage in the envelope.
Direct-Write Paths
Most events flow through Emitter::emit(). The remaining direct-write paths must use:
to_run_event(run_id, event)- Serialize and redact once
- Reuse that exact
RunEventfor every sink
Never build the same RunEvent twice if multiple sinks receive it.
Adding A New Event
1. Add the typed event
Add a variant to Event, AgentEvent, or SandboxEvent as appropriate.
2. Add tracing
Extend Event::trace() so the raw event is observable in tracing output.
3. Add an external name
Extend event_name() with the new lowercase dot-notation string.
4. Add the EventBody variant
Add a variant to EventBody in fabro-types/src/run_event/mod.rs with a corresponding props struct. Use #[serde(rename = "dotted.name")] matching the external name from step 3.
5. Map envelope fields and construct EventBody
Update stored_event_fields() and event_body_from_event() in the fabro-workflow::event module:
- Move
node_id,node_label,session_id, andparent_session_idinto the envelope when appropriate. - Construct the
EventBodyvariant directly from theEventfields. - For
Event::Agentsub-variants, mergevisitinto the inner props and liftstagetonode_id. - For
Event::Sandboxsub-variants, unwrap and flatten into the correspondingEventBodyvariant.
6. Emit it
Prefer Emitter::emit(&Event::...).
Use to_run_event() only for true bypass paths.
For cache-backed lifecycle work, emit slow-path start events only when the operation actually misses cache or waits on remote state. Completion events should represent a real ensure step (inspect, build, pull, or poll), not a configured no-op.
7. Update consumers
Check:
- CLI progress parsing
fabro events- store validation
- tests or fixtures that inspect event names or fields
Consumer Guidance
When writing Rust consumers (listeners, store projections, CLI progress):
- Match on
event.bodyusingEventBody::*variants. This gives you typed access to event-specific fields. - Use
event.node_id,event.node_label,event.session_id, andevent.parent_session_idfor envelope metadata. - Only use
event.event_name()orevent.properties()for generic/display purposes (logging, forwarding). These involve serialization and should not be used on hot paths.
When writing external JSON consumers (SSE clients, JSONL parsers):
- Match on the
"event"field for the dot-notation event name. - Read event-specific data from
"properties". - Read stage/branch identity from
"node_id"and"node_label". - Read agent hierarchy from
"session_id"and"parent_session_id".
Do not rebuild or mutate the RunEvent in downstream listeners.
Bypass And Persistence Guarantees
Any JSONL sink, the run store, and SSE should reflect the same canonical envelope bytes after redaction.
status.json remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished.