Commit graph

148 commits

Author SHA1 Message Date
Scott Werner
aad954693e Keep checkpoints and run branch pushes in the sandbox 2026-10-01 13:01:13 -04:00
Scott Werner
3c62d851fe Retry a finished run from the start
Retry forked the source run at its last checkpoint and reran the failed
stage. It now creates a new run from the source's saved spec and starts
the workflow from the beginning in a fresh workspace, as retry did
before the Petri cutover. The new run records `retried_from` and no
`fork_source_ref`.

Retry no longer needs a checkpoint, a retained workspace, or a published
run branch, so it works for any terminal run that is not archived. To
continue from where a run stopped, fork it at a checkpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:14:45 -04:00
Scott Werner
a1926c8632 Restore configured artifact storage for workflow captures 2026-09-24 13:53:46 -04:00
Bryan Helmkamp
bd59f52e22
Build the run's display graph from Petri's admission
The rest of the change whose deletions the previous commit carries (its
`git add` stopped at an already-removed path): `fabro_types::RunGraph`
and the `fabro-petri` builder that reads it off the admitted graph, the
server's create, validate, preflight and render paths on Petri's check
alone, the consumers moved to the new shape, the OpenAPI `RunGraph`
schemas with their parity tests, the regenerated TS client, and the
docs naming Petri's diagnostic codes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:41:58 -04:00
Bryan Helmkamp
05a14a6c9b
Delete the checkpoint endpoint, fabro parse, and the fabro-workflow shims
GET /runs/{id}/checkpoint duplicated what /state serves; the hidden
fabro parse command had no user; records, run_status, outcome, and
usage_rollup in fabro-workflow only re-exported fabro_types. The
importers now name fabro_types directly. format_cost keeps its two
callers (the pull request body and the CLI stage display) and moves to
fabro_types::usage; the usage rollup tests move beside the function in
fabro-types, with test_usage in its test support.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 09:31:09 -04:00
Bryan Helmkamp
0752d4c7c4
Delete the stage artifact upload endpoint
Artifacts reach the blob table through the hooks, so the POST on
/runs/{id}/stages/{stageId}/artifacts, its octet-stream and multipart
handlers, the RequireStageArtifact extractor, the client's upload
functions, and the generated TypeScript operation go. The spec loses
the operation, the multipart variant writeRunBlob never served, and
the batch manifest schemas; fabro-types loses ArtifactUpload, the
batch upload's only input type. Every list and download path stays,
and the server tests seed the artifact store directly to cover them.
fabro-server drops multer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 09:23:47 -04:00
Bryan Helmkamp
cb26c5603d
Answer steer and interrupt with the worker's acknowledgement
The worker control bus was publish-only: the steer and interrupt
endpoints answered 202 once the control was forwarded, and a refusal
showed up only later as a `run.notice` on the run's stream.

A steer or an interrupt now carries a request id. The worker answers it
over the control stream it arrived on with `{request_id, outcome}`,
where the outcome is `delivered` (with the stage's label) or `refused`
(with the code and the reason). The server keeps the outstanding
requests in a registry and waits up to 5 s for the answer: the endpoint
answers 202 `{"outcome":"delivered","stage":…}`, 409 with the refusal's
code (`no_live_turn`, `no_such_stage`, `steer_refused`,
`interrupt_refused`) and message, or 202 `{"outcome":"pending"}` when
the worker gave no answer in time. The `run.notice` record on refusal
stays, under the same code, so a steer to a stage that is not running is
now `no_such_stage` there too. Pause and unpause are unchanged.

The in-process test path answers a steer or an interrupt from the run's
own controls at once. `FABRO_TEST_CONTROL_ACKS_MUTED=1` on the server
mutes the worker's answers, so a test can see the pending fallback.
`fabro steer` prints the worker's answer, and a refusal is its error.
The OpenAPI spec documents the 202 body and the 409 codes; the Rust and
TypeScript clients are regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:54:11 -04:00
Bryan Helmkamp
86ac13713b
Merge branch 'petri-followup-fork' into petri-integration 2026-09-18 23:18:14 -04:00
Bryan Helmkamp
e7d55d6ec7
Serve fork, rewind, retry and the timeline on the runs API
`GET /runs/{id}/timeline` lists the run's checkpoints with their Petri
positions, stages, commits and diff summaries, and its fork origin.
`POST /runs/{id}/fork` resolves a target on that timeline, creates the
new run, seeds it through `fabro_petri::fork` and queues it in resume
mode, so its worker restores the checkpoint into a fresh workspace and
continues from the position; a position inside a parallel branch is
refused with 400 before the run exists. `POST /runs/{id}/rewind` is that
fork of a terminal run followed by the source's archive and its
`run.superseded` record (207 when the archive fails); `POST
/runs/{id}/retry` forks a terminal run at its last checkpoint, rerunning
the stage that failed. The projection carries `forked_from`. The Rust
and TypeScript clients gain the four calls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:17:25 -04:00
Bryan Helmkamp
1978257aa5
Interrupt a live agent stage's model turn over Petri
Petri 639ce3e added `ControlService::interrupt_firing` and the `LiveTurns`
capability a host installs beside the pause hooks. Fabro now drives it:
`RunControls::interrupt(stage, text)` resolves its stage the way a steer
does (a label, a node name, or the run's one live agent stage) and stops
that stage's current model turn, keeping the session; the text, when
given, is the stage's next input. `engine::run` installs the live-turn set
as a runtime capability, so without it no interrupt could ever land.

The worker maps `run.interrupt` and `run.interrupt_then_steer`, both of
which now carry an optional `stage`, to that call. The control bus is
one-way, so a refusal is recorded the way a refused steer is: a
`run.notice` on the run's stream whose code says why (`no_live_turn` when
Petri refuses a stage with no turn in flight, `no_such_stage`,
`interrupt_refused`).

The server's `POST /runs/{id}/interrupt` and `POST /runs/{id}/steer` with
`interrupt=true` forward the control and answer 202, replacing the 501
`interrupt_unsupported` stub. The interrupt endpoint takes an optional
body (`stage`, `text`), refuses a finished run with 409
`run_not_interruptible`, and forwards an interrupt of a blocked run, since
an agent stage may be running a turn beside the question and the worker
judges each stage itself. `fabro events --pretty` prints the delivered
interrupt and the stage's `attractor.turn.interrupted` report.

Verified on the twin: `fabro steer --interrupt` during a long tool call
ends the turn, the text is the agent's next request, and the stream
carries the `$interrupt` record and the interrupted-turn report; an
interrupt of a gate stage is refused with `no_live_turn` and the gate's
question is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:38:17 -04:00
Bryan Helmkamp
bc31b8d23a
Serve the run stream as the only run event API
Step 4 of the legacy executor deletion, third commit: the legacy event
API and every reader of it go, so that the next commits can delete the
event log, its reducer and the types beneath them.

The API:
- `GET /runs/{id}/events` pages the run stream only
  (`PaginatedRunStreamList` by `after`); the legacy `since_seq`,
  `before_seq` and `order` cursors, the `oneOf` envelope, the legacy
  `EventEnvelope`, `PaginatedEventList`, `RunEvent`, `EventSeq`,
  `AppendEventResponse` and `RunEventDetailResponse` schemas,
  `POST /runs/{id}/events`, `GET /runs/{id}/events/{seq}` and
  `GET /runs/{id}/stages/{stageId}/events` are deleted. `GET
  /runs/{id}/attach` and `GET /attach` frame `RunStreamItem`s only.
- The Rust and TypeScript clients regenerate; the removed models leave
  the TypeScript package.

The readers:
- `fabro-client` drops the legacy run event listing, tail and attach
  methods and `RunEventStream`; `list_run_stream_until` bounds a stream
  read.
- `fabro-tool`'s `fabro_run_events` lists, searches and details the run
  stream: `after` is the exclusive `stream_seq` cursor, `event_id` the
  item's id, filters match the item's name and `recorded_at`.
- `fabro-dump` writes the stream to `events.jsonl`; `fabro dump` reads
  it.
- The CLI's progress renderer keeps only what the run stream drives:
  the legacy event conversion, the sandbox and setup displays and their
  styles go. `fabro system events` prints stream items.
- The server's demo mode folds its agent fixture straight into the
  session projection and answers the attach stub with a stream item;
  the demo stage events endpoint is gone.
- The web app: every run is a Petri run. The legacy event hooks,
  renderer props, stage popover summary, run phases derivation and
  live-event payload handling are deleted or ported to `RunStreamItem`;
  toasts and board refreshes read the stream's platform records.
- Tests: the legacy API round trips and pagination tests are deleted;
  the CLI's MCP, attach and system event mocks serve stream pages; the
  CLI test helpers read stream items.

Still failing until the later commits: the CLI tests seeded through
`POST /runs/{id}/events`, the server tests over the legacy store, and
the legacy type tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:36:00 -04:00
Bryan Helmkamp
34996d630f
Give Ask Fabro sessions their own event log
Step 4 of the legacy executor deletion, second commit. Ask Fabro's
sessions were the last writer of `run_events`: a session's creation, its
turns and their messages, tool calls and endings went into the run's
legacy event log, keyed by the run's sequence. They now have a log of
their own.

- `run_session_events` (migration `2026091802`): one row per session
  event, numbered per session from 1, with the owning run, the turn, the
  event name and its properties. `RunSessionEventStore` appends under the
  write lock, lists a session from a sequence, names a session's owner
  from its creation event, deletes a run's sessions with the run, and
  publishes each committed event to its subscribers.
- `fabro_types::SessionEvent`: `seq`, `session_id`, `run_id`, `ts` and a
  flattened body (`event` naming the kind, `properties` its fields), with
  the same event names and property shapes the legacy events carried,
  so the web app and the CLI read the same JSON. The property structs
  move to `session_event`; `run_event::session` re-exports them under
  their old names until the legacy event log goes.
- The API: `GET /sessions/{id}/events` pages `PaginatedSessionEventList`
  by the session's own sequence, `GET /sessions/{id}/attach` replays and
  streams `SessionEvent` frames (subscribed before the replay, so no
  event falls between the two), the turn stream carries the same frames,
  and an interrupt answers with the recorded event. The session
  projection folds `SessionEvent`s; the legacy `find_session_owner` over
  `run_events` is gone.
- The CLI's `run ask` and the web app's session stream read
  `SessionEvent`; the web runtime no longer accepts the nested legacy
  envelope shape.

The two session resume tests in the server keep failing for a reason
this commit does not touch: Ask Fabro reconnects to the run's sandbox
from the projection's sandbox instance, which the Petri projection does
not carry yet (`VIEWS.md`, the `scope.acquired` gap).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:47:43 -04:00
Bryan Helmkamp
e8e681adbf
Remove the retry, rewind, fork and timeline endpoints and commands
The legacy executor replayed a run from a checkpoint; Petri resumes a
run from its records instead, and the checkpoint timeline, rewind, fork
and retry were the operations that replay carried. The server dropped
their handlers with the executor; this removes the rest:

- the API spec's `/runs/{id}/retry`, `/rewind`, `/fork` and `/timeline`
  paths with the `ForkRequest`, `ForkResponse`, `RewindRequest`,
  `RewindResponse` and `TimelineEntryResponse` schemas, and the
  generated TypeScript models;
- `fabro rewind` and `fabro fork` (with the checkpoint timeline printer
  and the repo-origin check only they used), their reference pages and
  the checkpoints guide's rewind and fork sections;
- `fabro-client`'s `rewind_run`, `fork_run` and `run_timeline`;
- the web app's Retry action on the run list and the run page.

`Run.retried_from` stays on the run type: a run that was retried before
the cutover would still name its source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:54:45 -04:00
Bryan Helmkamp
26429a7444
Merge branch 'petri-integration-api' into petri-integration
# Conflicts:
#	lib/apps/fabro-cli/tests/it/scenario/petri.rs
2026-09-18 01:39:53 -04:00
Bryan Helmkamp
523f831a7a
Serve a Petri run's events as one stream with a stream_seq cursor
`GET /runs/{id}/events` and `GET /runs/{id}/attach` serve a Petri run's
public events and Fabro's platform records as one ordered stream in a
Fabro envelope (`RunStreamItem`: `run_id`, `stream_seq`, `kind`, `id`,
`recorded_at`, `item`), read from the projector's `petri_stream` table.
The cursor is `stream_seq` (`?after=`); the item's own identity (the
Petri `EventId` as `<log>/<seq>/<index>`, or the platform record's seq)
travels beside it for deduplication. A legacy run keeps its envelope on
the same endpoints; the OpenAPI response is the union of the two lists,
and the stream list reports Petri's `EVENT_CONTRACT_VERSION`.

The attached stream follows the projector's commit signal (a wake-up,
with a poll as the fallback) and ends after the platform record of the
run's terminal lifecycle transition, the analog of the legacy stream's
`run.completed`, or a bounded grace after the projection went terminal.

`RunSpec.engine` (`RunEngine`, `PetriAdmission`, `PetriGraphRef`) is
named in the spec and reuses the Rust types. `fabro-client` matches the
union and adds `list_run_stream`, `list_run_stream_page` and
`attach_run_stream`.

A server test attaches to a two-branch parallel run, disconnects once
both branches started, records a platform notice while both branch
scripts run, reconnects from the last `stream_seq`, and checks the
union is the whole stream: every item once, in order, no gap, no
duplicate, the notice between the branch events, and the same as the
paged listing. The Petri scenarios capture their settled projection and
stream as JSON fixtures for the web app under
`FABRO_CAPTURE_PETRI_FIXTURES`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 00:47:26 -04:00
Bryan Helmkamp
34139b1936
Prove the checkpoint hooks and the recovery protocol
In-process tests over the memory store: every finish is committed on the
run branch with its identity trailers and recorded with its commit, the
run-end hooks reach Petri's local service through Fabro's wrapper, a
stage that fails on its own terms is committed and its failure route runs
on the committed files, a failed checkpoint records `checkpoint_failed`
with no route taken and a restart reports the run failed, and a
`[[run.hooks]]` hook blocks an agent's tool call through the forwarded
service, with the model told why.

Real-binary scenarios crash the server and its worker with SIGKILL: after
a durable finish the stage's commit is not repeated and the interrupted
stage reruns on its snapshot; a crash held before the commit reruns the
stage once; a crash held after the commit but before its record
reconciles the record from the snapshot repository; a deleted workspace
is restored; a failure route sees the same committed files after a
crash; a failed checkpoint fails the run and a restart leaves it failed.

Recovery selects the executions `inspect_run` reports incomplete, and a
run whose coordinator log is still empty is left to the worker's resume.
The worker's platform record endpoints get an API test and the generated
TypeScript client.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 00:25:10 -04:00
Bryan Helmkamp
0820252bcf
Add the Petri run store endpoints to the API
The worker shape of the integration plan (F1.3) needs a run's worker to
reach the run's Petri records over the server's API. This adds the
contract: six worker-scoped endpoints under `/api/v1/runs/{id}/petri/`
(open, release, list and append records of one log, write and read a
blob), their request and response schemas, and the generated Rust and
TypeScript clients.

A store error needs more than a code: `petri_run_leased` names the
holding owner and `petri_record_conflict` names the refused position.
`ErrorResponseEntry` gains an optional `meta` object for such
code-specific members, `ApiError` can carry it, and the client's
`ApiFailure` parses it beside the code so a caller can act on it.

Records travel as `{seq, recorded_at, record}`, the store's own unit,
with `seq` and `recorded_at` as `uint64`. The log path segment is the
log id's text (`coordinator`, `resources`, `execution <n>`), which the
generated client percent-encodes. The blob write reuses
`WriteBlobResponse`, since Petri's digest is Fabro's blob hash.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 20:12:20 -04:00
Bryan Helmkamp
aca9bbbf97
Regenerate the TypeScript API client for the usage schemas
The generated models follow the spec: TokenCounts, Cost, Usage,
ModelUsage, UsageModelRef, UsageStageRef, Speed, RunUsage,
RunUsageStage, RunUsageTotals, UsageByModel, AggregateUsage, and
AggregateUsageTotals replace the billing models, and UsageApi replaces
BillingApi. The stale billing model files are deleted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 12:31:34 -06:00
Bryan Helmkamp
ef86ad278a
Carry usage as lithos-llm's Usage and rename billing to usage
Re-pin lithos-llm to 55add4596b861a0623d00c3a54aa5c147c8d504b and
pebble to c91810fe51aece80359b9cd8efea971af0c46925, where token usage
and cost travel together as Usage { tokens: TokenCounts, cost:
Option<Cost> }. Fabro now carries that one type everywhere it used to
carry BilledTokenCounts, BilledModelUsage, UsdMicros, or a token count
beside a cost_usd_micros.

fabro-types: billing.rs is usage.rs with ModelRef, ModelUsage { model,
usage }, sum_usage, and usage_is_empty; billing_rollup.rs is
usage_rollup.rs with ProjectionUsageStage, ProjectionUsageByModel,
ProjectionUsageRollup, and usage_rollup_from_projection. Every usage
field is named usage: StageProjection.usage and usage_by_model,
Outcome<Option<ModelUsage>>, stage.completed and stage.failed usage and
usage_by_model, prompt.completed usage, run.completed and run.failed
usage (total_usd_micros is gone), Conclusion.usage, StageSummary.usage,
Run.usage. RunSize buckets by Cost.

fabro-workflow: model_usage_from_llm prices tokens from the catalog with
a Catalog cost source, with_reported_cost keeps a provider cost, and the
pebble handler's stage_usage groups pebble's accounts by model and sums
rows with Usage::saturating_add, so a total has a cost only when every
priced part was priced. The store fold's live usage is the agent's
usage plus its descendants'.

API: the OpenAPI spec deletes BilledTokenCounts, BilledModelUsage,
CompletionUsage, CompletionCost, TokenUsage, and RunBillingSummary,
adds TokenCounts, Cost, Usage, and ModelUsage, and renames every
billing schema, property, tag, path, and operation to usage. fabro-api
reuses lithos-llm's and fabro-types' types through with_replacement,
with a round-trip test per replacement.

Old stored runs get no migration: their pebble events in the old shape
read back with zero usage, and their rebuilt projections lose agent
usage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 12:31:34 -06:00
Scott Werner
ea11538038 Retire legacy manifest run creation 2026-09-13 08:49:50 -06:00
Scott Werner
d5dec0fffb Add content-based workflow version registration tools 2026-09-12 10:15:19 -06:00
Bryan Helmkamp
957fc97c5c
Merge origin/main into pebble-agent-loop
Main merged the sandbox-driver adoption (#849) in a later form than this
branch was stacked on: the driver's own exec types replace fabro-sandbox's,
shell quoting moved to fabro-util, the sandbox lifecycle collapsed, and the
driver's events are stored as run events. This branch had deleted
`fabro-agent` and put the coding agent, the environment adapter, and the
steering hub on pebble.

The resolution takes main's sandbox API and re-applies pebble on top: the
`RunSandbox` `Environment` adapter moves to `pebble_environment.rs` (main's
`environment.rs` is the sandbox spec) and runs commands through `ExecSpec`
and `ExecControls`, feeding pebble's output sink from the driver's; the
driver-era `sandbox.*` names leave the known-event list, as on main, so a
stored event with that name and no driver shape is `Unknown` rather than an
error; `program_exit_code` matches pebble's non-exhaustive termination; the
Docker and Daytona smokes use main's constructor and credentials; the
remaining `fabro_agent` paths point at fabro-sandbox.

Pebble's `mcp` feature pins sandbox-driver, and the preview-url trait
objects only cross when both sides name one revision, so pebble moved to
main's `a92c0db6` (lithoscomputer/pebble#10) and fabro pins that pebble
revision until it lands on pebble main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 09:40:00 -06:00
Bryan Helmkamp
18a3c4741e
Run agent stages, Ask Fabro, and fabro exec on pebble's CodingAgent
Replace fabro's hand-written agent loop with pebble's `CodingAgent` and
delete the `fabro-agent` crate.

Workflow: `PebbleBackend` builds one agent per stage over `RunSandbox`,
binds the stage's hooks as tool middleware, the interviewer as the
human-input provider, and a durable `EventSink` that writes every agent
event through the run event log before the agent goes on. Full-fidelity
threads continue across stages through `export`/`resume_from_export`.
Model failover takes the session record after the failed prompt and
continues it on the next route with `ResumeMode::UseModel`, so no tool
effect repeats. The steering hub targets pebble's control handle, with
a steering lease holding completion open while a human is paired.

Events: `EventBody::Agent` carries pebble's `CodingAgentEvent` envelope;
the per-variant bodies, the transcript projection, and the fabro-only
context-window, tool-summary, and skill types are gone in favor of
pebble's. The OpenAPI schemas, generated Rust and TypeScript clients,
and web readers follow.

Ask Fabro: the session runs a `CodingAgent` under a read-only permission
policy and a system prompt transform. Its conversation lives in a new
`run_session_records` table and resumes on the recorded model with the
event cursor advanced past the run log.

`fabro exec` builds the same agent over a local sandbox with pebble's
permission middleware and an interactive approval service.

The catalog fills in `metadata.agent.profile` for operator providers
that declare none, so pebble's lookup is the one resolution path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:19:15 -06:00
Bryan Helmkamp
d24042ba64
Put the driver's sandbox status on the API instead of a projection
Fabro projected the driver's status into its own state enum, resource,
network, and timestamp types for the run sandbox and inventory endpoints,
losing the provider's state string, the network policy, the sandbox kind,
and the driver's own vocabulary along the way. The API now carries the
driver's SandboxStatus itself: SandboxDetails is fabro's run record beside
the status, SandboxInfo is the provider beside the status, and the
OpenAPI schema describes the driver's types (state, resources in the
units the driver reports, the network policy, the sandbox kind, workspace
ownership) which fabro-api reuses through with_replacement with round
trip tests proving identity and JSON parity. The projection types and
their conversion go; the web sandbox page and summary panel read the
status directly, and the TypeScript client is regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 13:07:00 -06:00
Bryan Helmkamp
75e5f34c0d
Expose lithos request and response shapes through the API, server, CLI, and web
The OpenAPI spec adopts the lithos request, response, content part,
tool, usage, and cost schemas. The completions endpoint returns the
lithos `Response` JSON verbatim and SSE carries lithos `StreamEvent`s
verbatim. The models and providers endpoints serve the fabro-types
catalog views, and the install and model-test flows probe providers
through fabro-llm.

The CLI builds its catalog from the operator overlay, drives `fabro exec`
through the server gateway adapter, and parses reasoning effort with the
shared controls. The web app reads content parts as lithos-tagged
objects. The TypeScript client is regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:26:57 -06:00
Scott Werner
f1b569d9e6 Remove unsupported workflow playground 2026-09-03 12:04:35 -04:00
Bryan Helmkamp
5ead0145b9
feat(model): separate tool and reasoning tests 2026-08-25 13:36:51 -04:00
Scott Werner
b51b80e16e Preserve run creation error context 2026-08-24 12:22:38 -04:00
Scott Werner
040bc6c043 Add version-backed run intent creation 2026-08-24 11:55:43 -04:00
Scott Werner
a52e2c3334 Bump API spec version to 0.2.0 for the blob-write wire break
The WriteBlobResponse field rename (id -> hash) is a breaking change to
the wire contract with no compatibility shim, so signal it in the spec
version. There is no runtime version handshake; clients generated from
the older spec fail on the missing field until rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:56:19 -04:00
Scott Werner
bf4265e1b8 Unify blob hash vocabulary 2026-08-17 13:56:19 -04:00
Scott Werner
178320e7a5 Add immutable workflow version resource
Add the WorkflowVersion domain resource with exactly entrypoint, files,
and workflow_dependencies, plus strict WorkflowPath validation and
deterministic canonical raw JSON. Semantic validation of graph imports,
templates, file references, workflow.toml rules, Dockerfile paths, and
exact child-workflow dependency bindings lives in the new
fabro-workflow-version crate, which validates the complete stored
dependency closure through the shared blob store before writing a root.
The authenticated create-only POST /api/v1/workflow-versions endpoint
ships with its OpenAPI contract, Rust type replacements, and generated
TypeScript client.

Squashed from the resource commits of the original combined branch;
the walker unification this builds on landed separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:34:52 -04:00
Bryan Helmkamp
5c6289df80
Simplify async pull request creation
Structural cleanup of the durable pull request creation feature, from a
three-agent review (reuse, quality, efficiency) of the branch:

- Move the supervisor out of handler/ into server/pull_request_supervisor.rs,
  collapse its double bookkeeping into one task-id map, and fold the five
  copy-pasted failure arms into attempt_pull_request_creation.
- Tag pull_request.failed events with the creation id they resolve, so a
  publish-stage failure can never fail an unrelated explicit creation. The
  reducer gains PullRequestCreation::succeed/fail transition methods.
- Scan pending creations through a narrow projection-cache accessor instead
  of materializing every run summary, raise the scan interval to 30s (notify
  covers the live path), and cap retries for runs whose worker cannot even
  record a failure.
- Answer "creation already pending" POSTs before taking the per-run create
  lock, which a worker can hold for the whole creation.
- Replace the hand-rolled per-run lock map with fabro_store::KeyedMutex.
- Reuse cheap Arc'd projections (cached_run_projection) on the poll endpoint
  and in the worker instead of deep-cloning run summaries and diffs.
- Merge ExistingPullRequest into fabro_github::CreatedPullRequest and
  extract one reconcile_existing_pull_request helper for both call sites.
- Give the client poll loop a 15-minute deadline; document that Retry-After
  and the poll interval are the same constant.
- Resolve a wedged pending creation (run already has a pull request) as a
  durable failure instead of skipping it forever.
- Tests: shared wait_for_pull_request_creation helper, a pinned generation-
  failure assertion, and a new pipeline test proving reconciliation adopts
  an existing PR without an LLM call or create request.

Verified: cargo build --workspace, cargo nextest run --workspace (7,767
passed), nightly clippy -D warnings, fmt --check, insta (no pending), bun
typecheck in fabro-api-client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 14:51:29 -04:00
Bryan Helmkamp
30ce02f2a8
Make pull request creation durable and asynchronous 2026-08-01 19:28:52 -04:00
Bryan Helmkamp
8b767c658b
fix: make the artifact tie-break match the artifacts page
Two artifacts can share a filename, a retry, and an absent stage, in
which case the winner was whichever the object store listed first. Break
the tie on the serialized stage ID, which is the third key the artifacts
page sorts on. Compare the `node@visit` string rather than StageId's own
ordering: the page compares the string, so "unknown@2" beats
"unknown@10" there and now here too.

The spec said captures from the `start` and `exit` nodes are excluded,
but the exclusion is by handler type, so a node named `start` that does
real work keeps its artifacts. Say that instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:20:40 -04:00
Bryan Helmkamp
daaca3f479
refactor: simplify artifact ZIP download
Path safety now lives in one place. The NUL-byte and drive-letter rules
move from a server-only helper into the store's own filename validation,
so uploads reject those paths at write time instead of only the ZIP read
path catching them. The download still re-checks, because artifacts
stored before the rule existed can still carry an unsafe path, but it
now skips a bad path rather than failing the whole archive.

Promote is_boundary_stage to RunProjection and drop the three identical
private copies. The ZIP download used a node-name match instead, which
would have dropped artifacts from a working node that happened to be
named "start".

Compress the archive. Entries were Stored while the response was also
excluded from transfer compression, so text artifacts moved at full
size. async_zip gains the deflate feature; async-compression and flate2
were already in the lock file.

Log archive failures unconditionally. The send-succeeded guard meant a
client that had already disconnected left no record at all, which is the
case where the log is the only evidence.

Also: collapse the duplicate 500 arms, drop the dead stage-ID tiebreaker
and the cached order in the selection map, name the accessible label
after the visible one, share the run URL prefix between the two download
href builders, and document the mid-stream truncation behavior in the
OpenAPI description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:09:54 -04:00
Bryan Helmkamp
980aabc543
feat: download latest artifacts as zip 2026-07-31 13:23:35 -04:00
Bryan Helmkamp
bb1afae363
feat(events): add backward cursor pagination 2026-07-24 07:23:23 -04:00
Bryan Helmkamp
5cf1c7d183
Harden cancellation and interrupt lifecycles 2026-07-23 20:40:22 -04:00
Bryan Helmkamp
65cdf52061
feat: make model aliases provider-aware 2026-07-23 10:12:25 -04:00
Scott Werner
173968a780
feat(server): mcp-servers HTTP API — handlers + AppState wiring (#532)
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run
## What

Adds the **mcp-servers HTTP API**: `GET/POST /api/v1/mcp-servers` and
`GET/PUT/DELETE /api/v1/mcp-servers/{id}` on top of the merged
`fabro-mcp-store` foundation and OpenAPI spec.

This includes the AppState wiring needed for the catalog to work end to
end: `McpServerStore` construction from `{active-config-dir}/mcps/`, an
`AppState` accessor, the `fabro-server` dependency, and route
registration for list/create/get/replace/delete handlers.

The API mirrors the automations concurrency pattern with ETags on
read/write responses and required `If-Match` headers for replace/delete.

## Resolved before merge

- **Credential-omitting read model:** read responses now return
`McpServerView` / `McpTransportView`, so stored env/header values are
not exposed by GET/list/create/replace responses. Responses include only
`env_keys` / `header_keys`; persisted values remain available to runtime
execution.
- **Manifest catalog references:** run manifest validation, graph
rendering, preflight, and run creation now resolve server-managed MCP
catalog references such as `[run.agent.mcps.<name>] id = "..."`.
- **Schema strictness:** unknown MCP transport fields are rejected,
aligning the reused Rust domain type with the OpenAPI
`additionalProperties: false` contract.
- **Create response headers:** the `POST /mcp-servers` 201 response now
documents its `ETag` header in OpenAPI.

## Follow-up intentionally left out

Credential-literal validation remains structural only: create/replace
currently accept literal env/header values and persist them for runtime
use. The warn-vs-hard-reject UX is a separate follow-up for the settings
UI; it is not a response-omission issue.

## Testing

Current PR checks are green:

- Rust: format, clippy, generated docs, Linux tests
- TypeScript: build, test, typecheck

Local checks run during the simplify/CI-fix pass:

- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --locked --workspace --all-targets
-- -D warnings`
- `cargo nextest run -p fabro-config run_agent_mcps`
- `cargo nextest run -p fabro-mcp-store`
- `cargo nextest run -p fabro-api --test mcp_server_round_trip`
- `cargo build -p fabro-api`
- `cargo nextest run -p fabro-server --features test-support
system_sandbox_provider`
- `cargo nextest run -p fabro-server --features test-support --test it
mcp_servers`

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:06:24 -04:00
Scott Werner
d590122531
feat: chat-driven workflow builder at /playground (#450)
## Summary

Adds a new `/playground` route where users build a Fabro workflow by
chatting with Ask Fabro on the right while watching a live canvas
re-render on the left. The workflow can be downloaded as a `.fabro.zip`
or — eventually — launched as a real Fabro run; today the "Run for
real" button POSTs to `/api/v1/runs` and redirects to the resulting
`/runs/{id}` page, with a placeholder project/repo/folder picker.

The feature is built as a standalone component subtree under
`apps/fabro-web/app/components/playground/` with no `AppShell` or
`react-router` dependencies, so it can be re-embedded in other contexts
later by passing `chatEndpoint`, `authMode`, and an optional
`realRunRedirect` prop.

## What changed

**Frontend (`apps/fabro-web/`)**

- New `/playground` route + `<Playground>` component tree.
- Live SVG canvas via `@viz-js/viz` with click-to-inspect (read-only
  node detail panel), pan, zoom, fit-to-window, and a simulated walk
  through the graph driven by a Play button.
- Docked chat sidebar (assistant-ui) wired to the new
  `/api/v1/playground/chat` endpoint, with auto-retry on parse failure
  and a playground-specific tool-call summary that reads
  `Wrote workflow.fabro (N nodes, M edges)`.
- File tabs (`workflow.fabro` / `workflow.toml` / `README.md`),
  `.fabro.zip` download via `fflate`, and a "Run for real" toolbar
  button that POSTs an inline `RunManifest` to `/api/v1/runs`.
- Draft persists across page refreshes via `localStorage`.

**Backend (`lib/crates/fabro-server/`)**

- New `POST /api/v1/playground/chat` SSE endpoint. Server is stateless
  across turns: each request carries the full draft, the server runs
  the LLM with a single `write_workflow_file` tool, streams
  `StreamEvent` frames back, and lets the client own diffing/animating
  the result into the canvas.
- Request-size caps before the LLM call (50 messages, 100 nodes, 200
  edges) so a misbehaving or malicious client can't drag multi-MB
  transcripts through token billing.

**Spec / wire contract**

- OpenAPI: new `playground/chat` operation + four new schemas
  (`CreatePlaygroundChatRequest`, `PlaygroundWorkflowDraft`,
  `PlaygroundWorkflowNode`, `PlaygroundWorkflowEdge`).
- `lib/packages/fabro-api-client` not regenerated yet (the playground
  uses raw `fetch`); reviewers who want the TS client to pick up the
  new types can run `bun run generate` in that package.

## Key design decisions

1. **Single `write_workflow_file` tool, not six per-op tools.** The
   first cut exposed `add_node`/`update_node`/`connect`/etc. as
   discrete tool calls. The model would routinely add nodes without
   wiring them up, leaving the canvas in a broken half-state. Pivoted
   to a single tool that takes the full new `workflow.fabro` content;
   the browser parses the DOT, diffs it against the local draft, and
   animates the resulting reducer ops in. The model only has to "get
   the file right", and the canvas still paints node-by-node thanks
   to the client-side animator.

2. **Stateless server.** Each chat turn POSTs the full current draft;
   nothing is persisted server-side. Keeps the endpoint cheap, makes
   refresh-resumption trivial (browser owns the truth), and means the
   same endpoint can later sit behind a rate-limited anonymous variant
   without growing per-session state.

3. **Standalone component subtree.** `<Playground>` has no
   `AppShell`/router/store dependencies. All cross-cutting concerns
   flow in as props (`chatEndpoint`, `authMode`, `realRunRedirect`).
   This is the structural hook that makes future re-embedding possible
   without a refactor.

4. **Chat is the only mutation path.** Click-to-inspect on the canvas
   is read-only. Bi-directional canvas editing was explicitly cut from
   scope to keep one source of truth for "how the workflow changed."

5. **Inline `RunManifest` instead of temp-dir-then-clone.** The
   playground has no project to run against, so the `Run for real`
   modal builds a `RunManifest` that carries the full DOT and
   `workflow.toml` source inline (`workflows[key].{source, config}`).
   `cwd` is pinned to a fixed `/tmp/fabro-playground` constant — no
   LLM-controlled segment in a filesystem-looking field.

6. **React effects policy compliance.** All `useEffect` calls in
   playground component code go through the existing primitives in
   `app/hooks/effects.ts` (`useDocumentEvent`, `useInterval`) or a
   purpose-named hook (`useCanvasRender`).

## Still outstanding (planned follow-ups)

- [ ] **Actually kicking off the ad-hoc run.** "Run for real" today
      POSTs a manifest with a placeholder project/repo/folder
      fieldset. The intent is to reuse the project-picker pattern
      being introduced on the in-flight automations branch — once
      that pattern lands, the disabled inputs in
      `run-for-real-modal.tsx` become the live surface.
- [ ] **Header link to `/playground`.** No nav entry yet; users have
      to type the URL directly.
- [ ] **Live SSE-driven canvas overlay** via
      `GET /api/v1/runs/{id}/attach` — currently the modal redirects
      to the standard run-view page; the "watch it build on the
      playground canvas" experience comes when the `stage.*` events
      are wired through.
- [ ] **Regenerate `lib/packages/fabro-api-client`** so the new types
      ship to TS consumers.
- [ ] **Smoke test:** end-to-end download → unzip →
      `fabro run <name>` round-trip.
- [ ] **`scripts/build.ts` dist-symlink bug:** `pruneOldBuilds` can
      delete the directory `apps/fabro-web/dist` points at, which
      pins the dev server in 503 "build in progress" forever.
      Workaround documented; the real fix is a separate PR.

## Test plan

- [ ] `cd apps/fabro-web && bun run test app/components/playground/` —
111 tests pass
- [ ] `cd apps/fabro-web && bun run typecheck` — clean
- [ ] `cargo test -p fabro-server playground` — 6 tests pass
- [ ] Visit `/playground`; the canvas renders the welcome `start → ??? →
exit` ghost.
- [ ] Type "build me a release-notes workflow" in chat; nodes/edges
animate in; ack reads `Wrote workflow.fabro (N nodes, M edges)`.
- [ ] Click a node → inspector panel populates; click empty canvas →
deselects.
- [ ] Click `Simulate`; nodes light up `start → ... → exit` along the
resolved path.
- [ ] Click `Download .fabro`; unzip; `cd <unzipped> && fabro run
<name>` runs locally.
- [ ] Click `Run for real` → modal opens → confirm → POST succeeds →
redirected to `/runs/{id}` → run executes.
- [ ] Refresh the page; the draft persists from localStorage.
- [ ] Click `Start over` → `Yes`; canvas resets to welcome state.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 11:24:56 -04:00
fabro-sh-0530[bot]
037073d2b2
feat: Add Environment REST CRUD API under /api/v1/environments (#453)
## Summary

Adds a server-managed Environment CRUD API at `/api/v1/environments`,
modeled after the existing Automations API and backed by
`EnvironmentStore`. The API manages only server-side environment
definitions in `environments/*.toml`; client-side catalogs (workflow,
project TOML, run inputs) are unaffected.

### Plan Summary

- **OpenAPI contract**: new `Environments` tag, `EnvironmentId` path
parameter, five CRUD paths, list envelope, and REST-specific inline-only
image schema (`EnvironmentApiImageSettings`)
- **Server handler** (`environments.rs`): mirrors `automations.rs` —
auth guard, ETag/If-Match, and `EnvironmentStoreError → ApiError`
mapping
- **Shared handler utilities**: `parse_required_if_match` and
`json_with_etag_response` extracted from `automations.rs` into
`handler/mod.rs` so both modules share them
- **Inline-only Dockerfile enforcement**: `ApiDockerfileSource::Path` is
parsed and immediately rejected with `422`; the file is never read
- **Manifest refresh**:
`refresh_manifest_run_settings_from_environment_catalog()` called after
create, replace, and delete so `/system/info` and default run settings
stay consistent
- **Client regeneration**: TypeScript Axios client regenerated with
`EnvironmentsApi` and new model files; Rust `fabro-api` type aliases
updated
- **Tests**: integration suite in `tests/it/api/environments.rs`
covering all CRUD paths, error cases, and the manifest-refresh
invariant; OpenAPI conformance test verifies generated surfaces

## Key Design Decisions

**Inline-only Dockerfile at the REST boundary.** Allowing `path` sources
over REST would let callers silently read arbitrary server-local files
into the environment catalog. The handler recognizes the `path`
discriminant so it can return a descriptive `422` rather than a generic
parse error, but the payload is discarded via `IgnoredAny` — no disk
access occurs.

**Shared ETag utilities instead of per-handler helpers.** The original
`parse_required_if_match` and ETag header builder in `automations.rs`
were duplicated for environments. They're now generic over any `FromStr`
revision type in `handler/mod.rs`, making future resource handlers
cheaper to add.

**`Environment` response type aliased to domain type.** The
OpenAPI-generated `Environment` response struct is replaced with
`fabro_environment::Environment` via `build.rs` `with_replacement`. A
compile-time function-cast witness in
`fabro-api/tests/environment_round_trip.rs` confirms the alias holds.
Request types (`CreateEnvironmentRequest`, `ReplaceEnvironmentRequest`)
stay API-specific because their image schema differs from the
workflow/settings schema.

**Stale revision → `409`.** Consistent with Automations; `428` is
reserved for missing `If-Match` only.


### Fabro Details

<details>
<summary>Ran 8 stages in 59m 23s for $30.41</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 9s | – | 0 |
| preflight_lint | 2m 25s | – | 0 |
| implement | 25m 43s | $19.79 | 0 |
| simplify_opus | 14m 34s | $6.98 | 0 |
| simplify_gpt | 4m 39s | $3.64 | 0 |
| verify | 9m 14s | – | 0 |
| **Total** | **59m 23s** | **$30.41** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
2026-05-29 17:30:57 -04:00
Bryan Helmkamp
a992a7d76b
feat(runs): allow retrying succeeded runs
Broaden manual retry eligibility to all unarchived terminal runs while preserving active and archived precondition failures.
2026-05-27 18:49:45 -04:00
fabro-sh-0530[bot]
c767db897f
Add Automations API contract to OpenAPI spec and update generated clien… (#436)
## Summary

Defines the public Automations REST API contract in the OpenAPI spec,
updates the `RunSandbox` schema to reflect the new sandbox lifecycle
model, adds `fabro-automation` as a dependency to `fabro-api` for type
reuse, and removes the retired `fabro-devcontainer` crate and all
references to it.

## What changed

### Automations API (`fabro-api.yaml`)
Seven new paths under `/api/v1/automations` covering the full CRUD
surface plus run sub-resources:

```
GET/POST   /automations
GET/PUT/DELETE /automations/{id}
GET/POST   /automations/{id}/runs
```

New schemas: `Automation`, `AutomationTarget`, `AutomationTrigger`
(discriminated oneOf on `type`), `AutomationApiTrigger`,
`AutomationScheduleTrigger`, `CreateAutomationRequest`,
`ReplaceAutomationRequest`, `AutomationListResponse`.

Key contract decisions:
- `AutomationTrigger` uses an OpenAPI discriminator (`propertyName:
type`); unknown discriminator values → HTTP 422, not 400.
- `PUT` and `DELETE` require an `If-Match` header (428 if absent, 409 on
mismatch); `GET` and `PUT` responses carry an `ETag`.
- `POST /automations/{id}/runs` fires the automation's enabled API
trigger; 409 if the automation is disabled or lacks one.
- Run sub-resource responses reuse the existing `Run` and
`PaginatedRunList` schemas.

### `RunSandbox` schema refactor
The sandbox schema is restructured to express the full lifecycle rather
than only the ready state:

| Before | After |
|---|---|
| Flat object with `provider`, `image`, `snapshot`, `runtime` |
Discriminated by `kind`: `planned`, `initializing`, `ready`, `failed` |
| `runtime` was nullable | Moved into `RunSandboxInstance`
(non-nullable); present only when `kind = ready` |
| No failure detail | New `RunSandboxFailure` schema with `error`,
`causes`, `duration_ms` |

`SandboxDetails.sandbox` now references `RunSandboxInstance` (the ready
state), which preserves the existing shape for the details endpoint
while the richer `RunSandbox` type appears on run responses.

### Web UI (`run-sandbox-lifecycle.ts`)
New helper module that bridges the old flat-object sandbox wire shape
and the new lifecycle-keyed shape, with display metadata for each
lifecycle state. Consumers (`RunSummaryPanel`, `TerminalView`,
`RunSandbox` route, `run-detail` header/tabs) updated to route through
these helpers so both old and new wire shapes are handled transparently.

### `fabro-devcontainer` removal
The `fabro-devcontainer` crate is removed from `Cargo.lock`,
`AGENTS.md`, nextest config, and all doc references. Public-facing
changelog entries for devcontainer-specific features are removed or
retitled.

### Plan Summary
- Add Automations CRUD + run sub-resource paths and schemas to the
OpenAPI spec
- Restructure `RunSandbox` schema to model lifecycle states (`planned →
initializing → ready | failed`)
- Add `fabro-automation` dependency to `fabro-api` for domain-type
reuse; add JSON parity round-trip tests
- Regenerate Rust API types and TypeScript client
- Remove `fabro-devcontainer` crate and all references
- Add `run-sandbox-lifecycle.ts` helper module in the web UI and update
all sandbox-state consumers


### Fabro Details

<details>
<summary>Ran 9 stages in 83m 29s for $35.47</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 22s | – | 0 |
| preflight_lint | 2m 40s | – | 0 |
| implement | 45m 36s | $28.67 | 0 |
| simplify_opus | 7m 46s | $2.46 | 0 |
| simplify_gpt | 6m 7s | $3.92 | 0 |
| verify | 12m 8s | – | 0 |
| fixup | 5m 52s | $0.43 | 0 |
| **Total** | **83m 29s** | **$35.47** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
2026-05-27 14:07:01 -04:00
Bryan Helmkamp
b1bd2f522c
feat(server): add variables API (#430)
## Summary

Adds a workflow-visible variables store and HTTP API for managing
non-sensitive run variables, then wires those variables into run config
interpolation before run creation, validation, and preflight.

## What Changed

- Adds `/api/v1/variables` CRUD endpoints backed by a JSON variable
store and generated Rust/TypeScript API types.
- Supports `{{ vars.NAME }}` interpolation alongside existing `{{
env.NAME }}` handling for run-owned config fields, including
environment, MCP, hook, artifact, checkpoint, SCM, and notification
settings.
- Reuses canonical `fabro-types` variable DTOs in `fabro-api` and adds
OpenAPI name patterns so clients see the same env-style variable
contract enforced by the server.
- Keeps variable updates store-owned with `update_existing`, avoiding
duplicated not-found/update semantics in the HTTP handler.
- Shares env-style name validation between variables, interpolation
parsing, and vault token names to avoid grammar drift.

Variables are intentionally non-sensitive: list/get responses include
values, unlike vault secrets.

## Validation

- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo test -p fabro-types`
- `cargo test -p fabro-variable`
- `cargo test -p fabro-api --test variable_round_trip`
- `cargo test -p fabro-server --features test-support --test it
api::variables`
- `cargo +nightly-2026-04-14 clippy -p fabro-types -p fabro-variable -p
fabro-vault --all-targets -- -D warnings`
- `cargo +nightly-2026-04-14 clippy -p fabro-server --features
test-support --all-targets -- -D warnings`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex/)
2026-05-27 11:46:36 -04:00
Bryan Helmkamp
ab0f94fd82
feat(system): report runtime integration status (#416)
## Summary

Settings > Integrations now reflects the server's actual integration
readiness instead of only static `settings.toml` booleans. This adds
`/api/v1/system/integrations` as the runtime source of truth, covering
server config, vault credential presence, and Slack Socket Mode
connection state.

## What Changed

- Added shared `fabro-types` integration status models and reused them
from `fabro-api` to avoid duplicate API/domain types.
- Added `GET /api/v1/system/integrations` to the OpenAPI spec, Rust
server routes, demo routes, and generated TypeScript client.
- Reports GitHub and Slack status as `disabled`, `missing_credentials`,
`configured`, `connecting`, `connected`, or `error`, with non-secret
metadata and missing credential names.
- Tracks Slack Socket Mode runtime state from the Slack connection loop
and respects explicit `server.integrations.slack.enabled = false` even
when vault tokens exist.
- Updated the Integrations settings page to read the new runtime
endpoint, so a vault-configured Slack setup no longer appears simply as
disabled.

## Verification

- `cargo build -p fabro-api`
- `cargo nextest run -p fabro-api system_integrations`
- `cargo nextest run -p fabro-config
resolved_server_integrations_are_slack_only_for_chat`
- `cargo nextest run -p fabro-slack
run_event_loop_notifies_connected_status`
- `cargo nextest run -p fabro-server --features test-support --test it
get_system_integrations`
- `cargo nextest run -p fabro-server`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cd apps/fabro-web && bun test
app/routes/settings-integrations.test.tsx app/lib/query-keys.test.ts`
- `cd apps/fabro-web && bun run typecheck`
- `cd apps/fabro-web && bun run build`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
2026-05-26 19:27:32 -04:00
fabro-sh-0530[bot]
bd72570437
Add provider-backed sandbox inventory API and rename SandboxProvider to… (#409)
## Summary

Exposes `GET /api/v1/sandboxes` and `GET /api/v1/sandboxes/{id}`
endpoints that query sandbox inventory directly from configured
providers (Docker, Daytona), independent of run projections. Also
renames the existing `SandboxProvider` enum to `SandboxProviderKind`
throughout the codebase to free the name for the new `SandboxProvider`
trait.

### Plan Summary

- **OpenAPI + types**: New `SandboxInfo`, `SandboxListResponse`,
`SandboxListMeta`, `SandboxProviderLookupError`, and
`SandboxProviderKind` schemas added to the API spec; canonical Rust DTOs
added to `fabro-types`.
- **Provider trait and registry**: `SandboxProvider` trait (`list`,
`get`, `create`, `delete`) and `SandboxProviderRegistry` introduced in
`fabro-sandbox/src/provider.rs`. Registry fans out calls across all
configured providers and implements fail-soft semantics for list and
conflict/unavailable detection for get.
- **Provider implementations**: `DockerSandboxProvider` uses Bollard
label-filtered container listing and per-inspect;
`DaytonaSandboxProvider` uses the SDK with paginated label-filtered
listing. Both verify `sh.fabro.managed=true`.
- **Shared detail mapping**: Docker and Daytona inspect-to-`SandboxInfo`
paths extracted into `docker_info_from_inspect` /
`daytona_info_from_sdk_sandbox` so run-scoped `SandboxDetails` and
inventory `SandboxInfo` share the same normalization logic.
- **Monitoring UI**: `RunsInfo` now exposes `scheduler_slots_used`; the
monitoring panel displays "slots used" instead of the raw active-run
count.

## What changed and why

**`SandboxProvider` → `SandboxProviderKind`** is a mechanical rename
across ~20 call sites so the unqualified name `SandboxProvider` can be
claimed by the new trait without collision.

**Registry lookup semantics** for `get_managed_by_native_id`:

| Outcome | HTTP |
|---|---|
| Exactly one provider matches | `200` |
| All providers succeed, none match | `404` |
| Two or more providers match the same id | `409` |
| No match + at least one provider failed | `502` |

List is always fail-soft: partial results are returned and failing
providers appear in `meta.provider_errors`.

**`DockerFields` / `DaytonaFields` structs** were introduced inside
`details.rs` to hold the shared normalization output. Both
`map_docker_inspect` (run-scoped) and `docker_info_from_inspect`
(inventory) now delegate to `docker_fields_from_inspect`, eliminating
duplicate field-extraction logic. Same pattern for Daytona.

**`futures` moved from optional to unconditional** in
`fabro-sandbox/Cargo.toml` because `join_all` / `try_join_all` are now
used in `provider.rs`, which is not feature-gated.

**`local` provider** intentionally returns an empty list and `None` for
get — it has no provider-managed inventory.


### Fabro Details

<details>
<summary>Ran 8 stages in 102m 54s for $41.81</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 6s | – | 0 |
| preflight_lint | 2m 20s | – | 0 |
| implement | 56m 40s | $10.82 | 0 |
| simplify_opus | 27m 50s | $26.24 | 0 |
| simplify_gpt | 5m 2s | $4.76 | 0 |
| verify | 8m 24s | – | 0 |
| **Total** | **102m 54s** | **$41.81** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
2026-05-25 22:41:57 -04:00
fabro-sh-0530[bot]
bd837fc0f0
Add POST /api/v1/providers/test endpoint (#406)
## Summary

Adds `POST /api/v1/providers/test` so API, CLI, and UI callers can check
LLM provider health without parsing `/health/diagnostics`. The endpoint
tests every configured provider once using the catalog probe model and
returns typed, per-provider results with an aggregate summary — all at
HTTP 200, with provider failures expressed in the body.

This PR also adds `scheduler_slots_used` to `SystemRunCounts` to
distinguish runs occupying concurrency slots from all "active" runs
(e.g. runs blocked waiting for human input count as active but do not
hold a scheduler slot).

### What changed and why

**Provider probe logic** (`diagnostics.rs`)

The inline probe loop inside `check_llm_providers` was extracted into
`test_llm_providers` / `probe_single_provider`, which both the new
endpoint and the existing diagnostics check now share. The extraction
preserves the diagnostics output format: `diagnostic_detail` (a
`#[serde(skip)]` field) carries the richer context string used for the
`LLM Providers` section, while `error_message` carries the redacted,
public-facing error.

Key decisions:
- `ProviderProbeStatus` is `ok | error` only — no `skip`, because v1
only iterates configured providers.
- `model_id` is nullable so auth/registration failures (where no probe
was sent) can be expressed cleanly.
- API key values appearing in upstream error responses are passed
through `redact_string` before being stored in `error_message`.

**Route** (`handler/models.rs`)

`.route("/providers/test", post(test_providers))` added alongside
`/providers`, protected by the same `RequiredUser` extractor.

**`scheduler_slots_used`** (`handler/system.rs`, `server.rs`)

The status predicate (`Starting | Running | Blocked | Paused`) was
already duplicated between the scheduler loop and `get_system_info`.
It's now a named function `counts_toward_scheduler_capacity`, used in
both places and in the new `SystemRunCounts` field. The web UI
monitoring panel was updated to display "slots used" instead of
"active."

**Generated clients**

OpenAPI spec updated; Rust and TypeScript clients regenerated. New
TypeScript types: `ProviderTestList`, `ProviderTestResult`,
`ProviderTestStatus`, `ProviderTestSummary`.

### Plan Summary

- Add `testProviders` OpenAPI operation and `ProviderTestList` /
supporting schemas to `fabro-api.yaml`.
- Extract shared `test_llm_providers` from `check_llm_providers` in
`diagnostics.rs`; keep diagnostics output identical.
- Wire `POST /providers/test` handler in `handler/models.rs`.
- Add `scheduler_slots_used` to `SystemRunCounts` and extract
`counts_toward_scheduler_capacity` predicate.
- Regenerate Rust and TypeScript API clients.
- Add integration tests covering: no providers, successful probe, auth
failure (no upstream call), registration failure, mixed catalog order,
and API key non-leakage.


### Fabro Details

<details>
<summary>Ran 8 stages in 53m 33s for $40.83</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 8s | – | 0 |
| preflight_lint | 2m 19s | – | 0 |
| implement | 23m 56s | $30.73 | 0 |
| simplify_opus | 12m 53s | $5.80 | 0 |
| simplify_gpt | 2m 40s | $4.30 | 0 |
| verify | 9m 5s | – | 0 |
| **Total** | **53m 33s** | **$40.83** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
2026-05-25 21:57:26 -04:00
Bryan Helmkamp
2a2b410802
feat: remove demo-mode toggle button and endpoint
Demo mode remains available via the X-Fabro-Demo header or the
fabro-demo=1 cookie set manually in browser devtools, but the UI
button and the POST /api/v1/demo/toggle endpoint are gone. The
fixture machinery and the auth/me demoMode flag (used by the SPA to
render Automations and the /start landing) are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:35:37 -04:00