Compare commits

...

118 commits

Author SHA1 Message Date
Scott Werner
9bd499cdbe
Merge pull request #817 from fabro-sh/codex/run-record-sql-foundation
Some checks are pending
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run
Add inactive SQL run event storage foundation
2026-08-27 16:30:05 -04:00
Scott Werner
57bbb923c2 Keep the RunSummaryStore name until the SQL cutover
Revert the run summary -> run record rename. The SQLite store is still
the summary read model today; it only grows an inactive events table
here. Renaming it now made the store file show as a delete plus add and
touched nine unrelated files. The final rename happens once, when the
SQL store becomes the run authority.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:43:56 -04:00
Scott Werner
ff2aef4564 Simplify SQL run record store write path and test fixtures
Share one bind helper across the runs insert/upsert/update statements,
compute the next event sequence once per append, and decode stored
sequence columns through a single helper. Check the run head before
decoding events, rewrite the first-visit stage listing as a UNION ALL so
each arm uses its partial index, and share the run_events insert SQL
with the test seeder.

Collapse the duplicated in-memory pool fixture, remove two tests that
only asserted Arc sharing, and fold the fabro-db test row helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 14:59:34 -04:00
Scott Werner
dc55183468
Merge pull request #814 from fabro-sh/codex/automation-run-target
Migrate automations to canonical run targets
2026-08-27 13:50:37 -04:00
Scott Werner
b62e458289 Add inactive SQL run storage foundation 2026-08-27 13:32:01 -04:00
Bryan Helmkamp
039517a6a5
Merge pull request #816 from fabro-sh/reject-tool-call-index-gaps
Reject tool call index gaps in Chat Completions streams
2026-08-27 10:56:56 -04:00
Bryan Helmkamp
5ad2817da8
Reject tool call index gaps in Chat Completions streams
The openai_compatible stream decoder grew its tool call accumulator with
empty placeholder entries whenever a delta arrived with a sparse index,
then emitted every slot as a real tool call at finish. A provider that
numbers tool_calls[].index wrongly (Venice's Anthropic translation
passes through content-block positions, so a first tool call after text
arrives with index 1) therefore produced a phantom tool call with an
empty id and name. The phantom poisoned the conversation: the agent
answered it with a tool error, and the next request was rejected by the
provider (400: tool_use.id must match '^[a-zA-Z0-9_-]+$'), failing the
run as a non-retryable deterministic error.

A gap in the index sequence is indistinguishable from lost chunks, so
the decoder now fails the stream with a clear error naming the provider
and index instead of fabricating a tool call. Error::Stream is
classified retryable, so stage retries resample the turn rather than
replaying a poisoned history.

Observed on run 01M11JZVT7V507R56BCJJHZB1B; reproduced against the live
Venice API on claude-opus-5 and claude-sonnet-5 (four non-Claude models
stream index 0 correctly) and reported to Venice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6JBmbpi2NeZXNEsftAhzd
2026-08-27 09:58:53 -04:00
fabro-releases[bot]
88185f0bd9 Bump version to 0.338.0-nightly.0 2026-08-27 12:48:55 +00:00
Scott Werner
50fed849f3 Simplify automation run-target plumbing
- Materializer derives the manifest GitContext from RunTarget::validate()
  instead of hand-building it and re-parsing the repository slug
- Drop parse_github_repository_slug and InvalidRepositorySlug, now unused
- Store reuses Automation::git_target() instead of a private duplicate
- Legacy TOML import returns the target directly rather than a tuple
- Automation target migration updates columns with a single UPDATE ... FROM
- Web: share gitTarget(), targetFromFormValues(), and one SHA validator
  across the automation form, list, detail, new, and edit views

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 18:17:22 -04:00
Scott Werner
a65c4ff779 Migrate automations to canonical run targets 2026-08-26 17:35:36 -04:00
Scott Werner
56e759d470
Merge pull request #812 from fabro-sh/codex/git-run-target-tags
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
Add tag support to Git run targets
2026-08-26 15:20:23 -04:00
Bryan Helmkamp
8ed79b2a70
Merge pull request #813 from fabro-sh/codex/title-generation-warn
Warn when run title generation fails
2026-08-26 14:53:15 -04:00
Bryan Helmkamp
eaba019acb
Warn when run title generation fails 2026-08-26 13:18:39 -04:00
Scott Werner
ce640b6ad3 Unify pinned tag and exact-commit clone paths
Introduce a PinnedRevision enum in clone_source so the Docker and Daytona
providers run one fetch/checkout/verify sequence for both an exact commit
and a tag instead of two near-identical arms. Fold the tag-specific
command builders into the generic ones, share the bare-ref grammar check
between branch and tag validation, and derive the workflow clone source
from the validated Git target in a single match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 12:18:58 -04:00
Scott Werner
fbba98defd Add tag support to Git run targets 2026-08-26 11:16:37 -04:00
Scott Werner
34014e6dce
Merge pull request #790 from fabro-sh/codex/run-intent-folder-target
Add local folder run target
2026-08-26 10:34:59 -04:00
fabro-releases[bot]
ff29795dd8 Bump version to 0.337.0-nightly.1 2026-08-26 13:41:21 +00:00
Bryan Helmkamp
6ac769c4cd
Merge pull request #810 from fabro-sh/mcp-config-name
Add --name to fabro mcp config and fabro mcp init
2026-08-26 09:22:49 -04:00
Bryan Helmkamp
2134f550a4
Merge pull request #811 from fabro-sh/on-failure-succeed
Add on_failure="succeed" as an explicit failure policy
2026-08-26 09:22:10 -04:00
Bryan Helmkamp
d8b28dfafd
Refresh generated CLI reference 2026-08-26 09:15:20 -04:00
Bryan Helmkamp
b3d112b206
Harden succeed failure policy routing 2026-08-26 09:13:04 -04:00
Bryan Helmkamp
e5046d8b1b
Refactor MCP config argument handling 2026-08-26 09:01:37 -04:00
Bryan Helmkamp
a049f94042
Add on_failure="succeed" as an explicit failure policy
A failed node with an effective `succeed` policy and no explicit recovery
route now finishes as `succeeded` and follows normal success routing. The
original failure stays on the outcome so the stage.completed event and the
checkpoint keep the diagnostic, and the outcome notes record which scope
promoted it.

- OnFailure gains a Succeed variant; Node::on_failure resolves the
  deprecated auto_status=true attribute as an alias, with an explicit
  on_failure winning
- The core executor applies the policy before the lifecycle observes the
  result, so the recorded outcome, context keys, goal gates, events, and
  routing all see the effective outcome; this replaces AutoStatusLifecycle
- Explicit routes take priority: a matching condition, preferred label,
  suggested next node, or handler jump keeps the outcome failed. A failed
  outcome takes an unconditional edge only under route, so under succeed
  any edge selection is an explicit route
- succeed applies only to failed, matching exit; the auto_status alias no
  longer promotes partially_succeeded
- Parallel branches promote after their retry loop, so a failed succeed
  branch counts as succeeded in the parent aggregate
- Validation accepts succeed and adds an auto_status_deprecated warning
  that suggests on_failure="succeed"
- Document the policy table, semantics, and deprecation; add a changelog
  entry

Closes #807

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 07:34:24 -04:00
Bryan Helmkamp
24165b10f5
Add --name to fabro mcp config and fabro mcp init
Both commands always registered the MCP client entry under the fixed
`mcpServers` key `fabro`, so users could not register separate Fabro
servers (for example production and testing) without editing the client
JSON by hand.

`--name <NAME>` now selects the `mcpServers` key. It defaults to `fabro`
for backward compatibility and rejects empty values. `fabro mcp init`
upserts only the named entry and preserves entries with other names, so
reusing a name updates that entry in place.

Closes #808

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 07:34:06 -04:00
Bryan Helmkamp
c90d195c2f
Merge pull request #806 from fabro-sh/node-on-failure
Add node-level on_failure override
2026-08-26 07:01:49 -04:00
fabro-releases[bot]
1289144f03 Bump version to 0.337.0-nightly.0 2026-08-26 09:31:24 +00:00
Bryan Helmkamp
105f180d3d
Simplify node failure policy resolution 2026-08-25 20:10:05 -04:00
Bryan Helmkamp
69f9d40499
Merge pull request #805 from fabro-sh/model-stylesheet-templates
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
Add model stylesheet templates
2026-08-25 19:02:08 -04:00
Bryan Helmkamp
491babe5da
Add node-level on_failure override
A node can now set its own on_failure attribute to override the
graph-level failed-node routing policy in either direction: a
best-effort node can keep route inside an exit graph, and a critical
node can exit while the rest of the graph keeps the default. An absent
node attribute inherits the graph policy.

- Node::on_failure returns Option<OnFailure> so absence means inherit
- Graph::resolve_on_failure(node_id) is the single resolution point,
  returning ResolvedOnFailure { policy, scope } so the executor's
  end-of-run message names the scope that stopped routing
- The core Graph trait method becomes resolve_on_failure(node_id); the
  graph-scope failure message is unchanged
- The failed-human-gate fallthrough block stays independent of a
  node-level route override
- Validation now accepts and value-checks node-level on_failure (it
  previously warned that node placement had no effect) and keeps the
  edge-placement warning with updated wording
- Document precedence in transitions, failures, and the DOT reference,
  and extend today's changelog entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Chraa21RK7i2KqHdZSJLb8
2026-08-25 18:55:39 -04:00
Bryan Helmkamp
74e2c3597c
Simplify model stylesheet template plumbing
Apply cleanup review findings on the model stylesheet template branch:

- Move the root-only stylesheet rule into visit_graph_references via a
  GraphPosition parameter, so the bundler and workflow-version stop
  re-implementing the entrypoint guard with duplicated match arms
- Let ModelStylesheetTemplateTransform build its own template store and
  skip the pass entirely when the graph has no stylesheet; drop its dead
  Transform impl and the template_render_store re-export
- Parse fix-message namespaces with the typed Namespace enum, share the
  vars/goal fix strings with script_interpolation_fix, and replace the
  attribute_name magic-string check with a restricted-namespace fix the
  stylesheet transform sets on its own render target
- Drop template_render_store's content parameter; the store's render
  always overwrites it before rendering
- Trim redundant tests and add a transform_options() helper in
  pipeline/validate.rs tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FBHEs42qNHDeKmsqTSDSQ
2026-08-25 18:54:06 -04:00
Bryan Helmkamp
a522414bdc
Add model stylesheet templates 2026-08-25 18:14:25 -04:00
Scott Werner
ca9fb1d262 Align folder target warn logs with tracing style
Capture the error with Debug so the source chain stays visible, and use
lowercase fixed message strings per the logging guidelines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:40:29 -04:00
Bryan Helmkamp
9223349101
Merge pull request #803 from fabro-sh/model-test-tools-reasoning-effort
feat(model): add tool and reasoning test controls
2026-08-25 15:39:18 -04:00
Bryan Helmkamp
fb1ecfe23f
Merge pull request #804 from fabro-sh/codex/graph-on-failure
Add graph on_failure exit policy
2026-08-25 15:39:10 -04:00
Scott Werner
9dc39ce9fa Simplify folder target admission and startup checks
Collapse the triple Folder dispatch in run-intent admission into a single
prepare_intent_target call that canonicalizes and observes Git under one
provider gate, and stop feeding target/git into the compiler input only to
overwrite them afterwards. In run start, hoist the duplicated Folder
rejection out of the Docker and Daytona arms, restore kind_name() for the
Git/None arm, and drop the unreachable absolute/symlink checks that follow
canonicalize. Dedupe the folder-target test fixtures in both crates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:53:31 -04:00
Bryan Helmkamp
32d6be7ea5
refactor(model): simplify model test plumbing
Review cleanups for the tools/reasoning-effort model test change:

- Extract a shared parse_query_enum helper in the models handler in
  place of two copy-pasted parse-or-400 match blocks.
- Collapse the duplicated basic-probe pipeline in fabro-llm behind a
  single basic_probe core; name the shared EXPANDED_MAX_TOKENS budget.
- Pass &ModelTestArgs to test_models_via_server instead of threading
  five of its fields positionally.
- Dedupe the two forwarding CLI integration tests behind a helper.
- Derive clap::ValueEnum for ReasoningEffort behind a feature-gated
  clap dep (same pattern as MergeStrategy in fabro-types) so --help,
  cli.mdx, and error output list effort values from the enum instead
  of a hand-written list that drifts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDdjG18d2AHh7mFWXFkBLn
2026-08-25 14:49:34 -04:00
Bryan Helmkamp
15d1ef5b2c
Simplify on_failure validation rule and tests
- Dedup Diagnostic construction in the on_failure_valid rule
- Use the shared node_with_attrs test helper
- Drop an executor test that duplicated existing retry-target coverage
- Build on_failure integration test graphs from DOT and share a run
  harness, exercising the parser path for valid on_failure values

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J6MnJri6oSEMZaYeADY5dP
2026-08-25 14:49:05 -04:00
Scott Werner
b5d517e4b5 Gate folder path access behind Local admission 2026-08-25 14:10:24 -04:00
Bryan Helmkamp
b4092af89f
Add graph on_failure exit policy 2026-08-25 13:45:51 -04:00
Bryan Helmkamp
5ead0145b9
feat(model): separate tool and reasoning tests 2026-08-25 13:36:51 -04:00
Scott Werner
c396a6cf6f Add local folder run target 2026-08-25 11:59:37 -04:00
Scott Werner
679d20cb52
Merge pull request #792 from fabro-sh/codex/sqlite-authorization-codes
Move pending CLI authorizations to SQLite
2026-08-25 11:46:10 -04:00
Scott Werner
0001cfba02
Merge pull request #789 from fabro-sh/codex/run-intent-none-target
Add empty workspace run target
2026-08-25 11:25:41 -04:00
fabro-releases[bot]
e74f3c6c4b Bump version to 0.336.0-nightly.1 2026-08-25 13:26:02 +00:00
Bryan Helmkamp
1465082447
Merge pull request #797 from fabro-sh/github-app-install-error-wording
fix(github): name both causes of an installation lookup 404
2026-08-25 09:09:28 -04:00
Bryan Helmkamp
4015754f15
Merge pull request #801 from fabro-sh/fix-798-sandbox-runtime-blobs
fix(sandbox): materialize prompt blobs in runtime storage, not the checkout
2026-08-25 09:09:16 -04:00
Bryan Helmkamp
f322025b3d
refactor(sandbox): home the runtime directory under the system tmp dir
Use /tmp/fabro/runtime for both Docker and Daytona instead of
provider-specific roots. A writable /tmp inside the sandbox is already
a dependency (commit-message files, exec stop-files), it needs no
root-level mkdir for non-root container users, and it makes the two
providers uniform.

The trailing runtime path component stays load-bearing: materialized
blobs at runtime/blobs/{hash}.json are recognized as managed blob
references and normalized back to blob:// in durable context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Kmn5jyrdpyCdvcfvmEDvA
2026-08-25 07:21:24 -04:00
Bryan Helmkamp
b4fd7ae00b
fix(sandbox): materialize prompt blobs in runtime storage, not the checkout
Remote prompt-value materialization wrote demoted values to
{working_directory}/.fabro/blobs inside the repository checkout, so a
later checkpoint could commit them and leak them into the run pull
request.

Give each sandbox a run-scoped runtime directory outside the source
checkout as part of the Sandbox contract:

- Sandbox::runtime_directory() names the directory; host-local
  sandboxes return None because the engine owns a host-side runtime
  directory (RunScratch) for those runs.
- Docker creates /fabro/runtime at initialize with umask 077 and
  uploads runtime files with mode 0600.
- Daytona creates /home/daytona/fabro/runtime with mode 0700.
- Both remote materialization paths in fabro-workflow share one
  materialization-path helper built on the new contract. The paths keep
  the runtime/blobs suffix, so durable context still normalizes to
  blob://sha256/... references.
- Local materialization now writes owner-private directories and files
  on Unix.

Regression coverage: an integration test runs remote-style prompt
demotion against a real git checkout, then a real checkpoint commit,
and asserts the checkout stays clean, the agent-facing file is
readable, and a deleted materialized file is recreated from the
durable blob store. A real-Docker test verifies the runtime directory
and blob file permissions inside a container.

Fixes #798

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Kmn5jyrdpyCdvcfvmEDvA
2026-08-25 07:14:31 -04:00
fabro-releases[bot]
7ae7ca9ead Bump version to 0.336.0-nightly.0 2026-08-25 09:31:30 +00:00
Bryan Helmkamp
bc7635dbd1
fix(github): name both causes of an installation lookup 404
GET /repos/{owner}/{repo}/installation returns 404 both when the App is
not installed for the owner and when the installation's repository
selection excludes the repository. The single-repository mint path
reported only the first cause, which misleads users whose App is
installed but not scoped to the repository. Name both causes and the
repository in the error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:49:49 -04:00
Scott Werner
dc1f235c48 Keep retired Slate helpers test-only 2026-08-24 17:31:15 -04:00
Scott Werner
f3ff7f27a4 Keep auth code store naming consistent 2026-08-24 17:26:17 -04:00
Scott Werner
68ef8c7e89 Leave old SlateDB authorization-code records in place
Drop the startup retirement of the auth/code keyspace instead of
carrying one-shot cleanup code forever. The records it deleted are
inert: at most a handful exist at cutover, every binary (old or new)
rejects them within 60 seconds of issue via the expiry check, and
nothing reads the keyspace after the move to SQLite. The refresh-token
retirement keeps its original inline shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:25:59 -04:00
Scott Werner
a2f0167844 Simplify SQLite authorization-code cutover
Cleanup pass over the pending-CLI-authorization move to SQLite:

- Extract a shared Database::retire_keyspace helper; the refresh-token
  and authorization-code retirements are now one-line wrappers over it.
- Inline the startup retirement call (dropping the single-use wrapper,
  its context-chain test, and the test_close_slate hook it required)
  and run both SlateDB retirement scans concurrently. Error policies
  are unchanged: authorization codes fatal, refresh tokens best-effort.
- Add a shared sqlite_row module with typed identity/timestamp row
  decoding, used by both AuthorizationCodeStore and AuthSessionStore;
  the session store's stringly Error::Other corruption errors become
  the typed InvalidStoredIdentity/InvalidStoredTimestamp variants.
- Delete Repository::gc, which had no production callers left and was
  kept alive by its own test; update the record-layer docs to match.
- Deduplicate the SQLite test-support bootstrap into sqlite_test_pool,
  reuse issue() in the invalid-timestamp test instead of a copied
  INSERT, and fold the new table into the existing existence-check loop
  in the fabro-db schema test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:25:38 -04:00
Scott Werner
05999036aa Move pending CLI authorizations to SQLite 2026-08-24 17:25:19 -04:00
Scott Werner
3e0adde73d Simplify the empty workspace run target plumbing
- Use a derived deserializer for RunTarget by making `None` an empty struct
  variant, which keeps `deny_unknown_fields` strict without a hand-rolled impl
- Make clone_source_for_run the single owner of the empty-workspace decision
  and drop the duplicated target checks in RunSession::new
- Collapse duplicated target/provider compatibility matches in admission and
  start into single matches, using a strum-derived kind name for messages
- Drop the redundant git override in persist_create_run
- Extract a shared helper for the duplicated unavailable-integration test loop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:21:54 -04:00
Bryan Helmkamp
79168d3a27
Merge pull request #796 from fabro-sh/codex/twin-openai-unknown-fields
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
fix(twin-openai): accept unknown chat fields
2026-08-24 16:46:55 -04:00
Bryan Helmkamp
300aec7f1c
fix(twin-openai): accept unknown chat fields 2026-08-24 16:41:02 -04:00
Scott Werner
2d292c28f8
Merge pull request #783 from fabro-sh/codex/sqlite-blob-startup-activation
Activate verified SQLite blob storage at server startup
2026-08-24 16:32:59 -04:00
Scott Werner
2f3b6477f2 Add empty workspace run target 2026-08-24 14:49:03 -04:00
fabro-releases[bot]
330d0f8984 Bump version to 0.335.0-nightly.1 2026-08-24 18:48:34 +00:00
Scott Werner
fb833294bf Remove stale auth-session test import 2026-08-24 14:40:38 -04:00
Bryan Helmkamp
8a24046b94
Merge pull request #793 from fabro-sh/feature/bounded-agent-tool-output
Bound oversized agent tool output
2026-08-24 14:33:14 -04:00
Bryan Helmkamp
c042b9abdc
Merge remote-tracking branch 'origin/main' into feature/bounded-agent-tool-output
# Conflicts:
#	lib/components/fabro-sandbox/src/clone_source.rs
2026-08-24 14:08:04 -04:00
Scott Werner
0e580f0a43 Adapt run intents to activated blob storage
Use the synchronous blob authority established at server startup and remove the obsolete per-request store-open error path.
2026-08-24 14:07:51 -04:00
Scott Werner
776e719383 Harden SQLite blob activation safety
Keep VACUUM snapshots private until permissions and durability are established. Refuse to recreate a missing rollback backup after import has begun, and preserve secondary cleanup failures in startup logs.
2026-08-24 14:02:35 -04:00
Scott Werner
f9f19213e6 Clarify warm SQLite blob verification 2026-08-24 14:02:35 -04:00
Scott Werner
f71d077221 Register the SQLite blob activation bridge as a server migration
The activation module described itself as a temporary compatibility
bridge but bypassed the structure the migrations strategy prescribes: no
dated migrations/ file, no src/migrations.rs registry entry, no
REMOVAL_DEADLINE, and no removal_deadline log field. The strategy doc's
removal checklist (grep REMOVAL_DEADLINE, explicit registry ordering)
would never have surfaced it, letting the bridge silently outlive its
window as a second, parallel migration mechanism in serve.rs.

The module now lives at migrations/2026082301_sqlite_blob_activation.rs,
is registered and re-exported through src/migrations.rs like the two
existing server migrations, carries a REMOVAL_DEADLINE eligibility floor
(removal still requires the evidence and explicit approval in the module
docs), and logs removal_deadline on every activation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
5629dcd7d0 Make snapshot and backup publication durable across power loss
Neither the pre-activation backup nor the pre-migration snapshot fsynced
the staged file contents or the parent directory around the publishing
rename. A crash after the import committed could lose the retained
'.pre-blob-activation.bak' (whose directory entry was never made
durable), and the next activation would then write a new backup that
already contains the imported blobs, silently breaking the documented
pre-activation rollback boundary; a torn staging file could likewise
wedge later boots in backup validation.

write_snapshot_to_staging now syncs the staged file before handing it to
the caller, and both publishers sync the destination's parent directory
after their rename (fabro-db on a blocking task, activation inside its
existing blocking publication task).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
e067de9382 Share one SQLite snapshot-staging helper between fabro-db and activation
create_backup re-implemented the staging half of fabro-db's
pre-migration snapshot (remove stale staging file, UTF-8 check,
VACUUM INTO, private permissions), and remove_file_if_exists and
set_private_permissions had been made pub precisely to hand-copy that
sequence. Any future hardening of snapshot staging would have had to
land in two crates and could drift.

fabro-db now exposes write_snapshot_to_staging with a typed
SnapshotStagingError; both the pre-migration snapshot and the
pre-activation backup stage through it, and the hand-copied helpers are
private again. The publish halves stay separate on purpose: migrations
overwrite their snapshot, activation publishes with persist_noclobber
plus integrity validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
31c7a670d5 Continue startup when the final WAL truncate checkpoint reports busy
PRAGMA wal_checkpoint(TRUNCATE) returning busy=1 aborted server startup.
Any external reader that outlives the pool's five-second busy timeout (a
replication agent, a backup tool, an operator sqlite3 shell) would crash
the boot, and a supervisor restart would loop into the same abort while
the reader persisted, over a condition that threatens no data integrity.

A busy truncate now logs a warning and startup continues; a later
checkpoint truncates the WAL once the reader is gone. Adds the
failure-path coverage the relocated checkpoint lost: a held read
snapshot blocks the truncate and activation still succeeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
e1abecc9f4 Skip the blob activation disk preflight when no mount matches the database
available_space_for_path returning None aborted startup with a fatal
UnknownFilesystem error, even on a fresh install with zero legacy rows.
Hosts with tmpfs or squashfs roots, network-filesystem data paths, or an
unreadable mount table would fail every boot with no operator override,
while the resource sampler already treats the identical condition as
benign (supported: false) and keeps running.

The preflight now logs a warning and is skipped when free space cannot
be determined; the import, verification, and integrity checks still run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
bccc5750a4 Size the blob activation disk preflight to the remaining import work
The preflight demanded ~1.5x the full legacy inventory bytes free on
every startup, with no credit for rows already imported. Because the
first activation itself consumes about twice the legacy bytes (the
SQLite copy plus the retained backup) and the legacy keyspace stays in
place for the whole retention window, a successfully activated server
could fall below the requirement and become unable to restart until an
operator freed space the server would never write.

The legacy inventory now checks each row's hash against the SQLite blobs
table and reports pending rows and bytes, and the preflight requires
1.5x only the pending bytes plus the backup reserve and fixed headroom.
A warm restart with nothing left to import needs only the headroom.
Also updates the server operations doc for this and for the
verification pass now running only on boots that import rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
94e3d46754 Cut redundant blob scans and hashing from server startup
Startup previously scanned the legacy SlateDB keyspace three times and
SHA-256-hashed every value in each pass (inventory, import,
verification), then read and rehashed every row of the live SQLite blobs
table — on every boot, even a warm restart with nothing to import. With
a large object-store-backed legacy keyspace that makes restart time
proportional to total blob bytes for the whole retention window.

The inventory pass now only validates key shapes and sizes the keyspace;
digests are still validated by the import pass before any row persists.
The independent verification sweep now runs only on boots whose import
actually inserted rows: the import pass itself byte-compares every
already-present legacy row each boot, so a no-op restart is already
fully cross-checked without a third scan or a full-table rehash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
b56aee570c Pass the SQLite pool directly to the legacy blob import and verification
import_legacy_blobs_into and verify_legacy_blobs_in took a &BlobStore and
extracted its pool through sqlite_pool_for_legacy_import, an Option that
was statically always Some in production (the None arm existed only for
the test-only Slate backend). That accessor forced a clippy
unnecessary_wraps suppression and two WrongTargetBackend error variants
no production caller could ever hit, and the activation path round-tripped
a pool it already owned through a BlobStore it had just built.

Both functions now take &SqlitePool, deleting the accessor, the
suppression, both unreachable variants, and their rejection test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
21421dce78 Isolate the shared test blob store between tests
test_blob_store was a process-wide OnceLock singleton over one in-memory
SQLite connection, so content-addressed rows written by one test were
visible to every other test in the same process. nextest's
process-per-test model masked the bleed, but plain cargo test failed
(8/24 in fabro-workflow-version) because negative existence assertions
became order-dependent.

test_blob_store now builds a fresh isolated in-memory store per call,
and test_database gives every database its own blob authority.
Reopen-style tests that model one durable blob authority across several
store handles use the new test_blob_store_at, which keeps the blob table
in a SQLite file beside the store directory, plus
test_database_with_blobs to share it explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
eb54a8d0f8 Forward fabro-store/test-support through dependent test-support features
fabro-workflow's and fabro-server's src/test_support.rs import
fabro_store::test_support, but their test-support features never enabled
fabro-store/test-support. Workspace builds passed only through feature
unification from other members' dev-dependencies, while per-crate builds
such as `cargo check -p fabro-cli --tests` or
`cargo check -p fabro-server --features test-support` failed with E0432.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
d65785d888 Simplify blob activation and share the test store fixture
Blob activation cleanups:
- Reuse fabro-db's append_to_path, remove_file_if_exists, and
  set_private_permissions instead of local duplicates.
- Return the store directly from activate_blob_storage; the report
  wrapper existed only to be logged internally and then discarded.
- Collapse compute_disk_preflight to return the required free bytes
  instead of echoing its inputs back through a struct.
- Deduplicate the "exactly one ok row" PRAGMA integrity_check protocol
  into one executor-generic helper used by the backup and live checks.
- Skip re-validating a freshly published backup; the staging copy was
  validated immediately before the atomic rename, so only a
  concurrently published file needs its own validation.
- Replace the manual anyhow wrapping plus duplicate error log in
  serve.rs with a plain .context(), matching other startup errors.
- Extract the disk-candidate enumeration in resource_sampler.rs that
  available_space_for_path had copy-pasted from sample_disk_resources.

Test fixture cleanups:
- Route all hand-assembled Database::new(..., test_blob_store()) test
  fixtures (32 sites) through fabro_store::test_support::test_database,
  and make that helper infallible instead of returning an unconditional
  Ok.
- Install the test blob schema from fabro_db::BLOBS_MIGRATION_SQL via a
  test-support-gated optional dependency instead of a four-level
  relative include_str! into fabro-db's migrations directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00
Scott Werner
fafb1ed7cc Expose shared SQLite file helpers from fabro-db
Make append_to_path, remove_file_if_exists, and set_private_permissions
public so callers stop keeping verbatim private copies, and export the
blobs migration SQL so fixtures in other crates can install the blob
schema without a relative filesystem path into this crate's source tree.
set_private_permissions now returns io::Result so each caller owns its
own error context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:34 -04:00
Scott Werner
2814c1fd45 Activate verified SQLite blob storage 2026-08-24 14:02:34 -04:00
Bryan Helmkamp
1e284c625e
Simplify bounded tool output capture
Apply cleanups from a reuse/simplification/efficiency review of the
bounded-tool-output changes:

- Share one MAX_RUN_EVENT_BODY_BYTES constant in fabro-types; the server
  body limit, the agent's serialized-output reservation, and the event
  headroom test all derive from it.
- Rework truncation.rs around one split_head_tail helper: drop the
  hand-rolled ceil_char_boundary (std's is stable), the duplicate
  truncate_plain_output splitter and its dead Tail arm, and the
  head_bytes field with its sentinel values.
- Return Cow from preview_tool_output and take retain_tool_output's
  input by value, so untruncated output crosses the pipeline without
  full copies. Measure serialized JSON size with a counting writer
  instead of materializing the payload.
- Reuse fabro-llm's byte-token estimate (now public) instead of a third
  copy of the 4-bytes-per-token heuristic.
- Take retain_tool_result's ToolResult by value and mutate content in
  place; extract the triplicated error retain-emit-truncate block into
  finish_error_result.
- Share the shell retain-and-record sequence between the native and
  kimi shell tools as retain_shell_output.
- Move OutputCaptureBuffer::into_parts to reuse the head allocation,
  skip the buffer round-trip in replay_exec_result when output fits,
  and replace daytona's byte-iterator suffix matching with contiguous
  slice comparisons behind one retained_slices accessor.
- Make SessionBoundEmitter's fields private.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TK3QTWQHiXhRbFwTr57LzX
2026-08-24 13:56:16 -04:00
Bryan Helmkamp
2626e5ab4a
Fix the daytona-only build of fabro-sandbox
duration_to_minutes_i32 carried stacked docker and daytona cfg attributes,
which combine as AND, so building with the daytona feature alone failed to
find the function. fabro-workflow and fabro-cli enable daytona without
docker in their production dependencies, so that combination is real.

Removing the stray docker gate surfaced items that only docker-gated code
uses: the ResolveError import in from_environment and four exact-checkout
command builders in clone_source. Gate those on the docker feature, keeping
the command builders available to clone_source's own tests under cfg(test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TK3QTWQHiXhRbFwTr57LzX
2026-08-24 13:55:10 -04:00
Scott Werner
533e1f4471
Merge pull request #780 from fabro-sh/codex/run-intent-endpoint
Add version-backed run intent creation
2026-08-24 13:40:03 -04:00
Scott Werner
856e2fadd8 Harden run-intent workflow-closure lowering
Cap closure expansion at 256 distinct workflow mounts. Mounts are keyed
by rebased path, so a small chain of stored versions that mounts a
shared dependency along two paths per level expands exponentially; a
single authenticated create request could stall the server before any
error was returned. The check also bounds the recursion depth.

Resolve file-form run goals through the certified version: expose
ValidatedWorkflowVersion::resolved_goal_file_content, which reuses the
exact grammar store validation certified, and drop the parallel
resolution (and its unreachable-for-stored-versions error variants) the
server had re-implemented. The certified entrypoint-presence invariant
replaces the MissingEntrypoint error the same way.

Destructure both environment layer types without `..` when pinning
server environment authority, so a new server-owned field becomes a
compile-time decision instead of silently escaping the pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:02:50 -04:00
Scott Werner
26b2c091ca Derive clone sources from the persisted run target
Start reconciled the persisted target against its stored GitContext
projection field by field and failed the run on any drift, which forced
every RunSpec writer to keep the pair in lockstep forever. The target is
validated at admission and owns the grammar, so derive the clone source
from it alone; the projection stays persisted as display metadata that
can no longer fail an otherwise-healthy start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:02:40 -04:00
Scott Werner
2c7f46ab64 Validate Git-target branches on the bare branch name
The selector grammar ran against a heads/-prefixed string, so its
leading-character rules saw the prefix instead of the branch: names git
itself rejects, like -foo or HEAD, passed admission and only failed
later at sandbox clone time. Check the bare branch name and reject a
literal HEAD explicitly.

Also build the Git projection's origin URL through
GitHubRepositorySlug::https_url so the URL grammar keeps one owner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:02:32 -04:00
Scott Werner
087d48f0c7 Sharpen run-intent admission error responses
Lowering and compiler rejections now carry the top-level error message
in the 422 detail, matching the diagnostic depth the legacy manifest
lane already returns for identical defects; the full source chain stays
in the server log.

Pre-persistence store failures stop claiming run_persistence_failed:
credential-store reads return credential_store_error and run-variable
snapshots return variable_store_error, so alerting keyed on codes
triages the failing subsystem instead of a persistence outage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:02:24 -04:00
Scott Werner
5606940aaf Parse create-run bodies strictly per admission lane
Both lanes now deserialize the raw request bytes directly instead of
round-tripping through a serde_json::Value, which silently collapsed
duplicate JSON keys to last-key-wins on the legacy manifest lane and
stripped line/column locations from manifest parse errors.

When neither lane accepts the body, attribution now recognizes a
defective manifest by its required keys, so a legacy manifest carrying a
stray workflow_version_id keeps its 400 manifest error instead of being
misrouted to a 422 run_intent_invalid describing a schema the caller
never used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:02:14 -04:00
Bryan Helmkamp
6e19fb2eec
Reserve event space for serialized tool output 2026-08-24 13:00:58 -04:00
Bryan Helmkamp
a28a0378dc
Report truncated tool output to agents 2026-08-24 12:53:53 -04:00
Bryan Helmkamp
401acb6cdf
Record tool output byte counts 2026-08-24 12:46:27 -04:00
Bryan Helmkamp
ddcdafa06b
Bound agent tool output capture 2026-08-24 12:34:43 -04:00
Scott Werner
b51b80e16e Preserve run creation error context 2026-08-24 12:22:38 -04:00
Scott Werner
fc822ab9b0 Fix CLI RunSpec test fixtures 2026-08-24 12:17:47 -04:00
Scott Werner
e2db53011e Fix RunSpec test fixtures 2026-08-24 12:11:49 -04:00
Scott Werner
d978a6c89d Route both create-run client methods through one submission helper
create_run_from_manifest and create_run_from_intent were byte-identical
apart from the body type; fold the shared request/retry plumbing into a
private submit_create_run(CreateRunRequest) so the two public entry
points stay thin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:55:44 -04:00
Scott Werner
96c6de0ab9 Trim create-run request parsing overhead
The create-run dispatcher deep-cloned the parsed JSON body once to
attempt the RunIntent shape and again for the RunManifest fallback,
so every legacy manifest request paid two full copies of a body that
carries entire workflow bundles. Deserialize both shapes from a
reference to the parsed value instead; routing and error attribution
are unchanged.

Also bind the lowered goal slot once in inline_goal_file rather than
re-navigating the settings layer and asserting the goal is still there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:55:44 -04:00
Scott Werner
6b65f2a6af Share the create-run pipeline tail between both admission lanes
The intent and legacy-manifest create handlers each carried a full copy
of the same post-admission sequence: LLM readiness resolution, graph
compilation and model pinning, persistence, summary read, managed-run
registration, title-generation spawn, and the 201 response. The copies
had already drifted on when the run ID is resolved (before compilation
in one lane, after in the other).

Extract one finalize_created_run tail, with a small CreatedRunErrorStyle
carrying each lane's pinned error mapping and log lines so the wire
contracts are unchanged. Both lanes now resolve identity before
compilation and share the parent-link validation, which lets the
PinnedRun copy of PreparedRun's identity accessors be deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:55:44 -04:00
Scott Werner
18d98794ae Move Git-target validation onto RunTarget in fabro-types
The Git-target grammar (slug, branch, and SHA rules plus the derived
origin URL) was implemented twice with no shared code path: once in
server admission and again in sandbox start, so the two could drift and
disagree about which persisted targets are valid.

Own it once as RunTarget::validate() in fabro-types, next to the
primitives it uses, returning the canonical target together with its
derived GitContext projection. Admission consumes it directly, and the
start path re-derives the expected clone source from the same rules
before checking the persisted projection against it. The start path now
also moves the derived strings into the sandbox spec instead of cloning
them.

While reordering admission around the shared validator, run the pure,
in-memory checks (target grammar, environment id) before the blob-store
closure fetch and lowering so malformed requests no longer pay for
version-store I/O.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:55:44 -04:00
Scott Werner
040bc6c043 Add version-backed run intent creation 2026-08-24 11:55:43 -04:00
Scott Werner
b3f602f6e9
Merge pull request #648 from fabro-sh/feat/refresh-tokens-sqlite
Move CLI auth sessions from SlateDB to SQLite
2026-08-24 11:24:50 -04:00
Scott Werner
87b49a8527 Merge main into feat/refresh-tokens-sqlite
Preserve the SQLite auth-session release notes alongside main's July 26 fixes and retain all current changelog navigation entries. Make the refresh-token rotation timestamp assertion deterministic after the merged suite exposed its wall-clock race.
2026-08-24 11:04:10 -04:00
Bryan Helmkamp
4e31b79be0
docs: refresh product documentation 2026-08-24 09:53:09 -04:00
Bryan Helmkamp
de29af0a30
docs(changelog): refresh recent product changes 2026-08-24 08:39:06 -04:00
fabro-releases[bot]
2bf86327c0 Bump version to 0.335.0-nightly.0 2026-08-24 09:36:45 +00:00
Bryan Helmkamp
5878723723
Merge pull request #791 from fabro-sh/codex/increase-daytona-snapshot-timeout
Extend Daytona snapshot activation timeout
2026-08-24 03:41:14 -04:00
Bryan Helmkamp
901bb7a6a8
fix: extend Daytona snapshot activation timeout 2026-08-23 17:11:14 -04:00
Scott Werner
3872c04430 Derive initial refresh token session state
Replace the public stored-token row with an initial-token input that carries only token-specific facts. Bind the token to the session and initialize it as unused inside AuthSessionStore so callers cannot create mismatched session/token rows.
2026-08-21 14:01:30 -04:00
Scott Werner
7edef76d77 Revoke replayed auth sessions transactionally
Delete the owning auth session inside the refresh-token rotation transaction when a spent token is replayed. Return the replay outcome only after the revocation commits, and propagate database failures without claiming the chain was revoked.
2026-08-21 13:55:41 -04:00
Scott Werner
a08dff1ce8 Merge main into feat/refresh-tokens-sqlite 2026-08-21 13:16:14 -04:00
Bryan Helmkamp
e1a1ee6e05
Remove files committed by mistake
`git add -A` over lib/ and docs/ swept in work that was already untracked in
the working tree before this branch started: lib/crates/, and three docs
files. None of it belongs to this change.

Removed from the index only, so the files stay on disk as the untracked work
they were. They net out of the branch diff entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:10:27 -04:00
Bryan Helmkamp
7d48c88d61
Rename chain_id to session_id in auth session tests
Follows the type rename: a rotation chain is now an auth session with its own
row, so the local names and the Repository doc comment should say so rather
than referring to a store that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:08:24 -04:00
Bryan Helmkamp
644a598526
Document CLI auth sessions in SQLite
Records the two new tables in the server configuration reference, and adds a
changelog entry leading with the operator-visible consequence: this upgrade
signs everyone out once, because existing refresh tokens are not migrated.

Also notes the error-code change on concurrent replay, since it is observable
even though the CLI handles both codes identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:06:15 -04:00
Bryan Helmkamp
33268479d3
Delete the SlateDB refresh token store
Removes `slate/auth_tokens.rs`, `Database::refresh_tokens()`, and its
`OnceCell` now that nothing reads them, and clears the retired `auth/refresh`
prefix once at startup. That sweep is not housekeeping we could skip: the
reaper that used to collect those records went with the store, so without it
they would sit in the object store forever. A later boot finds the prefix
empty and does nothing.

`record/transaction.rs` goes too -- rotation was its only caller, and SQLite
transactions replaced it. `KeyedMutex` stays; `AuthCodeStore` still uses it
until auth codes move.

Existing refresh tokens are not migrated. Everyone re-authenticates once on
upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:05:31 -04:00
Bryan Helmkamp
be6dd7df97
Serve CLI auth sessions from SQLite
Points the session listing, revocation, refresh, and logout paths at
`AuthSessionStore`. Listing a user's sessions and revoking one stop scanning
the whole refresh-token keyspace; both are now indexed queries.

Fixes two timestamps that were wrong by construction. `created_at` was fed
from the newest token's `issued_at`, so a session's reported start drifted
forward on every refresh, and `last_seen_at` read a field only ever set at
issue -- so both rendered the same value. They now come from the session row,
where they mean what they say.

Deletes `next_refresh_row`, which had to fabricate an identity of
("https://github.com", "0") and empty profile strings for the no-existing-row
case, because a token was required to carry chain-level fields. Rotation now
takes just the new hash, expiry, and user agent. That also removes the
pre-read it existed to feed, closing the window between that read and the
one `consume_and_rotate` did itself.

Opening the store per request is gone with it: five handlers each had a
500-response arm for "could not open the store", which field access on
AppStores cannot fail.

Drops the replay-revocation cache. Its only effect was reporting `revoked`
rather than `expired` for the third and later presentations in a concurrent
burst, and `fabro-client` (client.rs:508-513) matches both codes in one arm
and treats them identically. Replay detection itself is unaffected: it is
`Reused` into `delete_session`, which lives in the database. The concurrency
test now accepts either code, since losers that arrive after the winner's
revocation find the row already cascaded away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:00:15 -04:00
Bryan Helmkamp
701708b32d
Add SQLite-backed AuthSessionStore
Every operation the SlateDB store answers with a full keyspace scan becomes
an indexed query here: listing a user's sessions joins one row per session
via the partial unique index instead of scanning every token ever issued
and grouping by chain, and revoking one is a single DELETE that cascades.

Rotation is the structural win. Claiming the presented token is one
`UPDATE ... WHERE used_at_ms IS NULL ... RETURNING`, and it is the
transaction's first statement, so SQLite takes the write lock before
anything is read. A concurrent caller blocks on that lock and then sees the
token already spent, which is exactly the replay signal -- so the store
needs no `KeyedMutex` to serialise rotation, and the guarantee survives more
than one server process.

Expiry is checked ahead of reuse on the cold path, preserving the ordering
callers depend on: only replaying a still-live token revokes its chain.

Drops the ordering CHECKs between a session's timestamps and its tokens'.
Rotation stamps `now` from the process clock against rows written by an
earlier request, so an NTP step backwards would have turned a harmless clock
anomaly into refresh failing outright for every affected session.

The store is not wired into the server yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:42:14 -04:00
Bryan Helmkamp
c69a47c4e6
Add auth_sessions and refresh_tokens schema
A CLI auth session is a rotation chain, but the SlateDB records that back
it today store identity and profile per token, so a chain has no owner and
nothing stops its rows from disagreeing. These two tables give the chain a
home: `auth_sessions` holds the identity and profile once, `refresh_tokens`
holds only per-token facts.

Two invariants the current code relies on but never states become
constraints. The partial unique index on `(session_id) WHERE used_at_ms IS
NULL` enforces that rotation leaves exactly one live token per chain --
which is what makes the session listing an indexed lookup instead of a
scan-and-group. The foreign key with `ON DELETE CASCADE` makes revoking a
session remove its tokens without a second statement.

Tokens are retained after rotation until they expire so a replayed token
stays distinguishable from a forgery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:15:58 -04:00
Bryan Helmkamp
7b77011896
Remove dead automation TOML serializers
`Automation::to_toml_string` and the `to_persisted` helper it wrapped
have had no production callers since automations moved from
`<storage>/automations/*.toml` into SQLite. Writes now serialize through
`canonical_bytes` for revision hashing; nothing renders an `Automation`
back to a TOML document.

Repoint the canonicalization test at `parse_persisted` + `canonical_bytes`
so it exercises the production path that actually produces the bytes the
revision hash is computed over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:37:58 -04:00
283 changed files with 18974 additions and 3527 deletions

View file

@ -1 +1 @@
678e75e2f35ae90e70c4b369f74ec202b168982d
2bf86327c0afbc8a708e02c3fab58981ad53ad60

View file

@ -1 +1 @@
7ad164c45de64e7bafdadb26f519f6e0c8a00a42
de29af0a30362c70c42f426e457e8a6d269534b2

View file

@ -30,7 +30,7 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
### Docker sandbox provider
- Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities).
- The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs.
- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files.
- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. For an exact commit, the submitted branch names the working branch and the syntactically valid SHA is requested directly. No layer proves branch/SHA ancestry: a fetchable commit is checked out, an unavailable commit fails setup, and branch HEAD is never substituted.
- The sandbox layer also accepts an optional exact commit for future admitted
runs. An exact commit always requires a non-empty branch. Docker initializes
an empty repository, shallow-fetches the SHA at the same depth as a branch

109
Cargo.lock generated
View file

@ -2257,7 +2257,7 @@ dependencies = [
[[package]]
name = "fabro-acp"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-tokio",
@ -2276,7 +2276,7 @@ dependencies = [
[[package]]
name = "fabro-agent"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2323,7 +2323,7 @@ dependencies = [
[[package]]
name = "fabro-api"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"fabro-automation",
@ -2346,7 +2346,7 @@ dependencies = [
[[package]]
name = "fabro-auth"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2371,7 +2371,7 @@ dependencies = [
[[package]]
name = "fabro-automation"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2391,11 +2391,11 @@ dependencies = [
[[package]]
name = "fabro-build-support"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
[[package]]
name = "fabro-checkpoint"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -2411,7 +2411,7 @@ dependencies = [
[[package]]
name = "fabro-cli"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2513,7 +2513,7 @@ dependencies = [
[[package]]
name = "fabro-client"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2542,7 +2542,7 @@ dependencies = [
[[package]]
name = "fabro-config"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2572,13 +2572,14 @@ dependencies = [
[[package]]
name = "fabro-core"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"fabro-types",
"fabro-util",
"serde",
"serde_json",
"strum 0.28.0",
"thiserror 2.0.18",
"tokio",
"tokio-util",
@ -2587,19 +2588,20 @@ dependencies = [
[[package]]
name = "fabro-db"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "fabro-dev"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2618,7 +2620,7 @@ dependencies = [
[[package]]
name = "fabro-dump"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2632,7 +2634,7 @@ dependencies = [
[[package]]
name = "fabro-environment"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2654,7 +2656,7 @@ dependencies = [
[[package]]
name = "fabro-github"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2679,7 +2681,7 @@ dependencies = [
[[package]]
name = "fabro-graphviz"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -2694,7 +2696,7 @@ dependencies = [
[[package]]
name = "fabro-hooks"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"fabro-agent",
@ -2717,7 +2719,7 @@ dependencies = [
[[package]]
name = "fabro-http"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-static",
"http 1.4.0",
@ -2727,7 +2729,7 @@ dependencies = [
[[package]]
name = "fabro-install"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2746,7 +2748,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"dialoguer",
@ -2761,7 +2763,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2803,7 +2805,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"clap",
"fabro-options-metadata",
@ -2814,7 +2816,7 @@ dependencies = [
[[package]]
name = "fabro-manifest"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"fabro-api",
@ -2835,7 +2837,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2855,7 +2857,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-server"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2883,7 +2885,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-store"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"fabro-db",
@ -2901,8 +2903,9 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"clap",
"fabro-static",
"http 1.4.0",
"insta",
@ -2917,7 +2920,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2939,7 +2942,7 @@ dependencies = [
[[package]]
name = "fabro-options-metadata"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"serde",
"serde_json",
@ -2947,7 +2950,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"cc",
"libc",
@ -2956,7 +2959,7 @@ dependencies = [
[[package]]
name = "fabro-redact"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"aho-corasick",
"ref-cast",
@ -2972,7 +2975,7 @@ dependencies = [
[[package]]
name = "fabro-sandbox"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3016,7 +3019,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3068,6 +3071,7 @@ dependencies = [
"fabro-workflow",
"fabro-workflow-version",
"futures-util",
"git2",
"globset",
"hex",
"hkdf 0.12.4",
@ -3111,7 +3115,7 @@ dependencies = [
[[package]]
name = "fabro-slack"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-interview",
@ -3133,18 +3137,18 @@ dependencies = [
[[package]]
name = "fabro-spa"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"rust-embed",
]
[[package]]
name = "fabro-static"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
[[package]]
name = "fabro-store"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"bytes",
@ -3160,6 +3164,7 @@ dependencies = [
"percent-encoding",
"serde",
"serde_json",
"sha2 0.10.9",
"slatedb",
"sqlx",
"strum 0.28.0",
@ -3174,7 +3179,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -3200,7 +3205,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -3214,7 +3219,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -3239,7 +3244,7 @@ dependencies = [
[[package]]
name = "fabro-tool"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3260,7 +3265,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3274,7 +3279,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"clap",
@ -3297,7 +3302,7 @@ dependencies = [
[[package]]
name = "fabro-util"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"console 0.15.11",
@ -3320,7 +3325,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-acp",
"fabro-graphviz",
@ -3333,7 +3338,7 @@ dependencies = [
[[package]]
name = "fabro-variable"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3350,7 +3355,7 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3369,7 +3374,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -3439,7 +3444,7 @@ dependencies = [
[[package]]
name = "fabro-workflow-version"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-config",
"fabro-graphviz",
@ -8596,7 +8601,7 @@ dependencies = [
[[package]]
name = "twin-github"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"axum",
"base64",
@ -8615,7 +8620,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-stream",

View file

@ -11,7 +11,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.333.0-nightly.0"
version = "0.338.0-nightly.0"
license = "MIT"
[workspace.dependencies]

View file

@ -4,10 +4,16 @@ import type {
Automation,
AutomationTrigger,
Run,
RunProjection,
WorkflowSettings,
} from "@qltysh/fabro-api-client";
import { findApiTrigger, findScheduleTrigger } from "../lib/automation";
import {
findApiTrigger,
findScheduleTrigger,
gitTarget,
type GitRunTarget,
} from "../lib/automation";
import { Panel, Row } from "./settings-panel";
import { INPUT_CLASS } from "./ui";
import { sandboxRuntime } from "../lib/run-sandbox-lifecycle";
@ -17,7 +23,9 @@ export interface AutomationFormValues {
name: string;
description: string;
repository: string;
ref: string;
branch: string;
tag: string;
sha: string;
workflow: string;
manualEnabled: boolean;
scheduleEnabled: boolean;
@ -29,7 +37,9 @@ export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
name: "",
description: "",
repository: "",
ref: "main",
branch: "main",
tag: "",
sha: "",
workflow: "",
manualEnabled: true,
scheduleEnabled: false,
@ -46,13 +56,16 @@ const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
export function automationToFormValues(automation: Automation): AutomationFormValues {
const apiTrigger = findApiTrigger(automation);
const scheduleTrigger = findScheduleTrigger(automation);
const target = gitTarget(automation.target);
return {
id: automation.id,
name: automation.name,
description: automation.description ?? "",
repository: automation.target.repository,
ref: automation.target.ref,
workflow: automation.target.workflow,
repository: target?.repo ?? "",
branch: target?.branch ?? EMPTY_AUTOMATION_FORM.branch,
tag: target?.tag ?? "",
sha: target?.sha ?? "",
workflow: automation.workflow,
manualEnabled: apiTrigger?.enabled ?? false,
scheduleEnabled: scheduleTrigger?.enabled ?? false,
cron: scheduleTrigger?.expression ?? "0 9 * * 1-5",
@ -61,6 +74,7 @@ export function automationToFormValues(automation: Automation): AutomationFormVa
export function automationFormValuesFromRun(
run: Run,
runState?: RunProjection | null,
settings?: WorkflowSettings | null,
): AutomationFormValues {
const name = firstPresentString(
@ -75,7 +89,9 @@ export function automationFormValuesFromRun(
run.workflow.graph_name,
name,
);
const repository = githubRepositoryFromSettings(settings)
const canonicalTarget = gitTarget(runState?.spec.target);
const repository = canonicalTarget?.repo
?? githubRepositoryFromSettings(settings)
?? githubRepositoryName(run.repository?.name)
?? githubRepositoryFromOriginUrl(run.repository?.origin_url)
?? "";
@ -85,7 +101,11 @@ export function automationFormValuesFromRun(
id: kebabify(name),
name,
repository,
ref: cloneBranch ?? EMPTY_AUTOMATION_FORM.ref,
branch: canonicalTarget?.branch
?? cloneBranch
?? EMPTY_AUTOMATION_FORM.branch,
tag: canonicalTarget?.tag ?? "",
sha: canonicalTarget?.sha ?? "",
workflow: run.workflow.slug?.trim() || kebabify(workflowName),
};
}
@ -111,11 +131,31 @@ export function isFormValid(values: AutomationFormValues): boolean {
values.id.trim() !== "" &&
values.name.trim() !== "" &&
values.repository.trim() !== "" &&
values.ref.trim() !== "" &&
values.branch.trim() !== "" &&
isOptionalShaValid(values.sha) &&
values.workflow.trim() !== ""
);
}
const GIT_SHA_RE = /^[0-9a-fA-F]{40}$/;
/** An empty SHA means "no pin"; anything else must be a full 40-hex commit id. */
function isOptionalShaValid(sha: string): boolean {
const trimmed = sha.trim();
return trimmed === "" || GIT_SHA_RE.test(trimmed);
}
/** Canonical Git target sent in create/replace requests. */
export function targetFromFormValues(values: AutomationFormValues): GitRunTarget {
return {
kind: "git",
repo: values.repository.trim(),
branch: values.branch.trim(),
tag: values.tag.trim() || undefined,
sha: values.sha.trim().toLowerCase() || undefined,
};
}
function kebabify(value: string): string {
return value
.toLowerCase()
@ -194,6 +234,7 @@ export function AutomationFormFields({
lockIdAndTarget = false,
}: AutomationFormFieldsProps) {
const slugTouchedRef = useRef(values.id.length > 0);
const shaValid = isOptionalShaValid(values.sha);
function patch(partial: Partial<AutomationFormValues>) {
onChange({ ...values, ...partial });
@ -277,19 +318,59 @@ export function AutomationFormFields({
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row title={<Label required>Branch</Label>} help="Default branch to run against.">
<Row
title={<Label required>Working branch</Label>}
help="Attached branch retained with the run, including when a tag or exact commit is selected."
>
<input
type="text"
name="branch"
aria-label="Default branch"
value={values.ref}
onChange={(e) => patch({ ref: e.target.value })}
aria-label="Working branch"
value={values.branch}
onChange={(e) => patch({ branch: e.target.value })}
placeholder="main"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row
title={<Label optional>Tag</Label>}
help="Bare tag name resolved when the automation fires. Used only when exact SHA is empty."
>
<input
type="text"
name="tag"
aria-label="Tag"
value={values.tag}
onChange={(e) => patch({ tag: e.target.value })}
placeholder="v1.2.3"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row
title={<Label optional>Exact SHA</Label>}
help={
shaValid
? "A 40-character commit SHA pins exact content and takes precedence over branch and tag."
: <span className="text-coral">Enter exactly 40 hexadecimal characters.</span>
}
>
<input
type="text"
name="sha"
aria-label="Exact commit SHA"
aria-invalid={!shaValid}
value={values.sha}
onChange={(e) => patch({ sha: e.target.value })}
placeholder="0123456789abcdef0123456789abcdef01234567"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row
title={<Label required>Workflow slug</Label>}
help="Dash-separated identifier matching the workflow directory name (e.g. patch-cves)."

View file

@ -1,4 +1,13 @@
import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client";
import type { Automation, AutomationTrigger, RunTarget } from "@qltysh/fabro-api-client";
export type GitRunTarget = Extract<RunTarget, { kind: "git" }>;
/** Label shown in place of a repository when an automation's target is not Git-backed. */
export const UNSUPPORTED_TARGET_LABEL = "Unsupported target";
export function gitTarget(target: RunTarget | null | undefined): GitRunTarget | null {
return target?.kind === "git" ? target : null;
}
type TriggerOfType<K extends AutomationTrigger["type"]> = Extract<
AutomationTrigger,

View file

@ -18,7 +18,12 @@ import type {
import { toRunWithStatus } from "../data/runs";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { findApiTrigger, findScheduleTrigger } from "../lib/automation";
import {
UNSUPPORTED_TARGET_LABEL,
findApiTrigger,
findScheduleTrigger,
gitTarget,
} from "../lib/automation";
import { useAutomation, useAutomationRuns } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { useDataUpdatedAt } from "../hooks/use-data-updated-at";
@ -93,6 +98,7 @@ function AutomationHeader({ automation }: { automation: Automation }) {
const scheduleTrigger = findScheduleTrigger(automation);
const apiTrigger = findApiTrigger(automation);
const target = gitTarget(automation.target);
const canRun = apiTrigger?.enabled === true;
async function onRun() {
@ -139,10 +145,16 @@ function AutomationHeader({ automation }: { automation: Automation }) {
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
<Chip icon={FolderIcon}>
{automation.target.repository}
<span className="text-fg-muted/70"> · {automation.target.ref}</span>
{target?.repo ?? UNSUPPORTED_TARGET_LABEL}
{target ? (
<span className="text-fg-muted/70">
{" · "}{target.branch}
{target.tag ? ` · ${target.tag}` : ""}
{target.sha ? ` · ${target.sha.slice(0, 8)}` : ""}
</span>
) : null}
</Chip>
<Chip icon={RectangleStackIcon}>{automation.target.workflow}</Chip>
<Chip icon={RectangleStackIcon}>{automation.workflow}</Chip>
{scheduleTrigger ? (
<Chip icon={ClockIcon}>{scheduleTrigger.expression}</Chip>
) : null}

View file

@ -11,6 +11,7 @@ import {
AutomationFormFields,
automationToFormValues,
isFormValid,
targetFromFormValues,
triggersFromFormValues,
type AutomationFormValues,
} from "../components/automation-form";
@ -85,11 +86,8 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
automationsApi.replaceAutomation(automation.id, automation.revision, {
name: trimmedName,
description: values.description.trim() || null,
target: {
repository: values.repository.trim(),
ref: values.ref.trim(),
workflow: values.workflow.trim(),
},
target: targetFromFormValues(values),
workflow: values.workflow.trim(),
triggers: triggersFromFormValues(values),
}),
);

View file

@ -10,6 +10,8 @@ import { setupReactTestEnv } from "../lib/test-utils";
let currentRun: any = null;
let currentRunError: unknown = null;
let currentRunLoading = false;
let currentRunState: any = null;
let currentRunStateLoading = false;
let currentRunSettings: any = null;
const queryCalls: Array<{ hook: string; id: string | undefined }> = [];
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
@ -58,6 +60,14 @@ mock.module("../lib/queries", () => ({
isLoading: false,
};
},
useRunState: (id: string | undefined) => {
queryCalls.push({ hook: "useRunState", id });
return {
data: currentRunState,
error: null,
isLoading: currentRunStateLoading,
};
},
}));
mock.module("../lib/api-client", () => ({
@ -253,6 +263,8 @@ beforeEach(() => {
currentRun = null;
currentRunError = null;
currentRunLoading = false;
currentRunState = null;
currentRunStateLoading = false;
currentRunSettings = null;
queryCalls.length = 0;
createAutomationMock.mockClear();
@ -274,7 +286,9 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Automation name")).toBe("");
expect(fieldValue(renderer, "Automation slug")).toBe("");
expect(fieldValue(renderer, "Repository")).toBe("");
expect(fieldValue(renderer, "Default branch")).toBe("main");
expect(fieldValue(renderer, "Working branch")).toBe("main");
expect(fieldValue(renderer, "Tag")).toBe("");
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
expect(fieldValue(renderer, "Workflow slug")).toBe("");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
@ -299,7 +313,9 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Automation name")).toBe("Fix failing tests");
expect(fieldValue(renderer, "Automation slug")).toBe("fix-failing-tests");
expect(fieldValue(renderer, "Repository")).toBe("qltysh/fabro");
expect(fieldValue(renderer, "Default branch")).toBe("feature/from-run");
expect(fieldValue(renderer, "Working branch")).toBe("feature/from-run");
expect(fieldValue(renderer, "Tag")).toBe("");
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
expect(fieldValue(renderer, "Workflow slug")).toBe("fix-ci");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
@ -307,9 +323,35 @@ describe("AutomationsNew", () => {
renderer.root.findAllByProps({ "aria-label": "Cron expression" }),
).toHaveLength(0);
expect(queryCalls).toContainEqual({ hook: "useRun", id: "run_1" });
expect(queryCalls).toContainEqual({ hook: "useRunState", id: "run_1" });
expect(queryCalls).toContainEqual({ hook: "useRunSettings", id: "run_1" });
});
test("canonical run target wins over legacy run, settings, and sandbox projections", async () => {
currentRun = makeRun();
currentRunSettings = makeRunSettings();
currentRunState = {
spec: {
target: {
kind: "git",
repo: "canonical/repo",
branch: "release",
tag: "v2.0.0",
sha: "0123456789abcdef0123456789abcdef01234567",
},
},
};
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
expect(fieldValue(renderer, "Repository")).toBe("canonical/repo");
expect(fieldValue(renderer, "Working branch")).toBe("release");
expect(fieldValue(renderer, "Tag")).toBe("v2.0.0");
expect(fieldValue(renderer, "Exact commit SHA")).toBe(
"0123456789abcdef0123456789abcdef01234567",
);
});
test("automationFormValuesFromRun kebab-cases the workflow name fallback", () => {
const run = makeRun({
workflow: {
@ -334,7 +376,7 @@ describe("AutomationsNew", () => {
expect(textFromNode(renderer.toJSON())).toContain("fill it out manually");
expect(fieldValue(renderer, "Automation name")).toBe("");
expect(fieldValue(renderer, "Repository")).toBe("");
expect(fieldValue(renderer, "Default branch")).toBe("main");
expect(fieldValue(renderer, "Working branch")).toBe("main");
expect(fieldValue(renderer, "Workflow slug")).toBe("");
});
});

View file

@ -5,12 +5,13 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import { useRun, useRunSettings } from "../lib/queries";
import { useRun, useRunSettings, useRunState } from "../lib/queries";
import {
AutomationFormFields,
EMPTY_AUTOMATION_FORM,
automationFormValuesFromRun,
isFormValid,
targetFromFormValues,
triggersFromFormValues,
type AutomationFormValues,
} from "../components/automation-form";
@ -31,6 +32,7 @@ export default function AutomationsNew() {
const [searchParams] = useSearchParams();
const fromRunId = searchParams.get("from_run")?.trim() || undefined;
const runQuery = useRun(fromRunId);
const runStateQuery = useRunState(fromRunId);
const settingsQuery = useRunSettings(fromRunId);
if (!fromRunId) {
@ -45,8 +47,9 @@ export default function AutomationsNew() {
// Wait for both queries to settle before mounting the form, so the user's
// edits aren't blown away when settings arrive after the run.
const runPending = runQuery.isLoading && !runQuery.data;
const runStatePending = runStateQuery.isLoading && !runStateQuery.data;
const settingsPending = settingsQuery.isLoading && !settingsQuery.data;
if (runPending || settingsPending) {
if (runPending || runStatePending || settingsPending) {
return (
<div className="space-y-6">
<PageHeader />
@ -69,6 +72,7 @@ export default function AutomationsNew() {
const initialValues = automationFormValuesFromRun(
runQuery.data,
runStateQuery.data ?? null,
settingsQuery.data ?? null,
);
@ -108,11 +112,8 @@ function AutomationCreateForm({
id: values.id.trim(),
name: trimmedName,
description: values.description.trim() || null,
target: {
repository: values.repository.trim(),
ref: values.ref.trim(),
workflow: values.workflow.trim(),
},
target: targetFromFormValues(values),
workflow: values.workflow.trim(),
triggers: triggersFromFormValues(values),
}),
);

View file

@ -18,7 +18,12 @@ import { FilterButton } from "../components/runs-list/filter-button";
import type { Automation, AutomationListResponse } from "@qltysh/fabro-api-client";
import { Link, useNavigate } from "react-router";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { findScheduleTrigger, hasEnabledApiTrigger } from "../lib/automation";
import {
UNSUPPORTED_TARGET_LABEL,
findScheduleTrigger,
gitTarget,
hasEnabledApiTrigger,
} from "../lib/automation";
import { useAutomations } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { ConfirmDialog, PRIMARY_BUTTON_CLASS } from "../components/ui";
@ -81,17 +86,20 @@ const MENU_ITEM_DANGER_CLASS =
function mapAutomations(result: AutomationListResponse | undefined): AutomationRow[] {
const automations = result?.data ?? [];
return automations.map((a) => ({
id: a.id,
revision: a.revision,
name: a.name,
workflow: a.target.workflow,
repository: a.target.repository,
schedule: findScheduleTrigger(a)?.expression,
apiEnabled: hasEnabledApiTrigger(a),
icon: slugIconMap[a.target.workflow] ?? CodeBracketIcon,
color: slugColorMap[a.target.workflow] ?? "var(--color-teal-500)",
}));
return automations.map((a) => {
const target = gitTarget(a.target);
return {
id: a.id,
revision: a.revision,
name: a.name,
workflow: a.workflow,
repository: target?.repo ?? UNSUPPORTED_TARGET_LABEL,
schedule: findScheduleTrigger(a)?.expression,
apiEnabled: hasEnabledApiTrigger(a),
icon: slugIconMap[a.workflow] ?? CodeBracketIcon,
color: slugColorMap[a.workflow] ?? "var(--color-teal-500)",
};
});
}
function PlayIcon({ className }: { className?: string }) {

View file

@ -44,11 +44,18 @@ Emitted when the run record is created.
"event": "run.created",
"properties": {
"workflow_slug": "my-workflow",
"workflow_version_id": "wv_...",
"target": {
"kind": "git",
"repo": "acme/my-project",
"branch": "main",
"sha": "0123456789abcdef0123456789abcdef01234567"
},
"source_directory": "/home/user/src/my-project",
"git": {
"origin_url": "https://github.com/acme/my-project",
"branch": "main",
"sha": "abc123",
"sha": "0123456789abcdef0123456789abcdef01234567",
"dirty": "clean"
},
"fork_source_ref": null,
@ -76,9 +83,11 @@ Emitted when the run record is created.
| `labels` | object | Run labels |
| `source_directory` | string? | Submitter-side source directory |
| `workflow_slug` | string? | Workflow slug |
| `workflow_version_id` | string? | Exact immutable root workflow version used for admission |
| `target` | object? | Canonical accepted workspace target. Version-backed Git intent runs persist `kind`, `repo`, required `branch`, and optional normalized `sha`; legacy manifest runs omit it |
| `provenance` | object | Actor and request provenance |
| `manifest_blob` | string? | Blob hash for the submitted manifest |
| `git` | object? | Git provenance observed before the run: normalized `origin_url`, `branch`, optional `sha`, and `dirty` status |
| `git` | object? | Operational Git projection: normalized `origin_url`, `branch`, optional `sha`, and `dirty` status. For Git intent runs, `branch` is the submitted working branch and `sha` is the optional lowercase-normalized submitted commit; admission does not resolve it or prove branch ancestry. Legacy runs retain their observed optional-SHA semantics |
| `fork_source_ref` | object? | Source run/checkpoint reference when this run was forked |
| `in_place` | boolean | Whether the run was created with `--in-place` (no git checkpoints) |

View file

@ -257,7 +257,9 @@ honors those hand-edited values even though the browser wizard does not manage t
### SQLite state and migration backups
Shared relational state, including vault entries and server-managed definitions, lives at `<storage_root>/db/fabro.sqlite3`. Run events continue to use the `[server.slatedb]` object store.
Shared relational state, including vault entries, server-managed definitions, and CLI auth sessions, lives at `<storage_root>/db/fabro.sqlite3`. Run events continue to use the `[server.slatedb]` object store.
CLI auth sessions are stored as an `auth_sessions` row per signed-in CLI, with the rotating refresh tokens for that session in `refresh_tokens`. Pending browser-to-CLI handoffs live briefly in `oauth_authorization_codes`; the table contains a SHA-256 hash of each one-time code, never the raw bearer value. Revoking a session from **Settings → Sessions**, or with `DELETE /api/v1/auth/sessions/{id}`, deletes the session row and its tokens together.
Before applying pending SQLite migrations, Fabro creates `<storage_root>/db/fabro.sqlite3.pre-migration.bak` with SQLite's `VACUUM INTO`. Each migration run replaces the previous snapshot, so only the most recent pre-migration backup is retained.

View file

@ -28,6 +28,15 @@ fabro mcp start
Pass `--server` when the MCP client should connect to a specific Fabro server, or `--storage-dir` when it should use a non-default CLI storage directory.
Both commands register the entry under the `mcpServers` key `fabro` by default. Pass `--name` to choose a different key. Each named entry launches its own single-target `fabro mcp start` process, so you can register more than one Fabro server in the same MCP client:
```bash
fabro mcp init claude --name fabro-production --server https://fabro.example.com
fabro mcp init claude --name fabro-testing --server https://fabro-testing.example.com
```
`fabro mcp init` keeps entries with other names and replaces only the entry that matches `--name`.
| Tool | Purpose |
|---|---|
| `fabro_run_create` | Create one or more workflow runs, optionally under a parent run, starting them by default. |

View file

@ -237,7 +237,7 @@ Captured stage artifacts such as screenshots, videos, reports, and traces still
For remote sandboxes (Docker, Daytona), execution-time file access happens inside the sandbox filesystem.
- Blob refs are materialized into `{working_directory}/.fabro/blobs/{blob_hash}.json`
- Blob refs are materialized into the sandbox runtime directory, `{runtime_directory}/blobs/{blob_hash}.json`. This directory lives outside the repository checkout, so materialized blobs never show up in `git status` or in checkpoint commits.
- Explicit non-blob `file://` refs keep the existing copy-on-demand behavior and are copied into `{working_directory}/.fabro/artifacts/{filename}` when needed
In both cases, downstream handlers and agents continue to consume ordinary `file://` pointers during execution.
@ -277,6 +277,10 @@ Patterns are rooted at the sandbox working directory. `*` and `?` stay within on
Fabro prunes dependency, cache, and build directories including `.git`, `node_modules`, `target`, `.venv`, `.cache`, and `dist`.
### Browse and download captures
The Artifacts page groups captures by file path. Expand a file to see and download earlier versions. **Download all** creates a ZIP archive with the latest captured version of each path. Fabro uses stage order, retry number, and stage ID to choose the latest version, and it excludes captures from the graph's start and exit nodes.
## Observability
Outputs and artifacts appear in several observability surfaces:
@ -285,6 +289,6 @@ Outputs and artifacts appear in several observability surfaces:
|---|---|
| `StageCompleted` event | `files_touched` list for the stage |
| `WorkflowRunCompleted` event | `artifact_count` -- total number of offloaded artifacts across the run |
| Web UI | Run stage output, stage artifacts, and downloadable artifact files |
| Web UI | Run stage output, artifact version history, individual downloads, and a ZIP of the latest files |
| [Preambles](/execution/context#preamble-construction) | File list and artifact pointer references for completed stages |
| Stage logs | `status.json` in each stage's run directory contains the full outcome including `files_touched` |

View file

@ -257,18 +257,19 @@ to load its instructions, then follow them.
</Accordion>
<Note>
OpenAI and Gemini providers have their own system prompts with different identity text, tool guidance (e.g. `apply_patch` instead of `edit_file` for OpenAI), and coding conventions. The overall structure is the same.
Fabro selects an agent profile for the model. Anthropic, Claude 5, OpenAI, GPT-5.6, Gemini, and Kimi profiles can use different identity text, tool names, tool guidance, and coding conventions. The overall system-prompt structure is the same.
</Note>
### Project docs
Fabro automatically discovers project instruction files by walking the directory hierarchy from the git root to the working directory. Which files are loaded depends on the provider:
Fabro automatically discovers project instruction files by walking the directory hierarchy from the git root to the working directory. Which files are loaded depends on the agent profile:
| Provider | Files |
| Agent profile | Files |
|---|---|
| Anthropic | `AGENTS.md`, `CLAUDE.md` |
| OpenAI | `AGENTS.md`, `.codex/instructions.md` |
| Anthropic and Claude 5 | `AGENTS.md`, `CLAUDE.md` |
| OpenAI and GPT-5.6 | `AGENTS.md`, `.codex/instructions.md` |
| Gemini | `AGENTS.md`, `GEMINI.md` |
| Kimi | `AGENTS.md` |
Files are loaded in directory order (root first, deepest last) with a total budget of 32KB. If the combined content exceeds this budget, later files are truncated.

View file

@ -16,9 +16,9 @@ Sub-agent management is exposed through four built-in tools:
| Tool | Description |
|---|---|
| `spawn_agent` | Create a new sub-agent with a task prompt |
| `send_input` | Send follow-up input to a running sub-agent |
| `send_input` | Send follow-up input to a running or completed sub-agent |
| `wait` | Block until a sub-agent completes and return its result |
| `close_agent` | Cancel and remove a running sub-agent |
| `close_agent` | Close a running or completed sub-agent |
These tools are registered automatically when the session starts. They inherit the parent's permissions.
@ -32,6 +32,12 @@ Each sub-agent runs in its own session:
The parent can spawn multiple sub-agents and synchronize with them later.
## Continue a completed session
A completed sub-agent remains available until the parent closes it. Calling `send_input` starts another turn in the same child session, so the child keeps its conversation history. A message sent while the child is still running is queued for a safe turn boundary instead.
Call `wait` again to receive the new turn's result. Call `close_agent` when the child is no longer needed; a closed child cannot accept more input.
## Depth limits
Sub-agents can themselves spawn sub-agents, creating a hierarchy. `max_subagent_depth` limits how deep that tree can grow. By default the depth limit is `1`.

View file

@ -1172,13 +1172,25 @@ paths:
operationId: createRun
tags: [Runs]
summary: Create Run
description: Creates a new workflow run in `submitted` status from a self-contained manifest.
description: >-
Creates a new workflow run in `submitted` status from either a
self-contained legacy manifest or an immutable workflow-version intent.
Creation does not start or schedule the run.
Failures return the standard error body. The intent lane responds
`404` (`workflow_version_not_found`, `environment_not_found`), `422`
(`run_intent_invalid`, `target_invalid`,
`target_environment_unsupported`, `workflow_version_unusable`,
`run_compile_invalid`), `503` (`integration_unavailable`), or `500`
(`workflow_version_store_error`, `credential_store_error`,
`variable_store_error`, `run_persistence_failed`).
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RunManifest"
$ref: "#/components/schemas/CreateRunRequest"
responses:
"201":
description: Run created
@ -1186,8 +1198,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Run"
# Non-2xx statuses beyond 400 are deliberately documented in the
# endpoint description instead of declared here: progenitor-generated
# clients drop the HTTP status when a declared error response's body
# is not valid JSON (e.g. a gateway's plain-text error), which breaks
# the CLI's error display contract
# (run_create_failure_shows_action_context_and_response_body).
"400":
description: Invalid Graphviz source
description: Invalid JSON or legacy manifest
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
@ -5674,6 +5692,7 @@ paths:
description: The canonical model ID or an alias.
- $ref: "#/components/parameters/ModelTestProviderParam"
- $ref: "#/components/parameters/ModelTestModeParam"
- $ref: "#/components/parameters/ModelTestReasoningEffortParam"
responses:
"200":
description: Test result
@ -5682,7 +5701,7 @@ paths:
schema:
$ref: "#/components/schemas/ModelTestResult"
"400":
description: Invalid test mode
description: Invalid test mode or reasoning effort
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
@ -6216,6 +6235,15 @@ components:
$ref: "#/components/schemas/ProviderId"
example: openrouter
ModelTestReasoningEffortParam:
name: reasoning_effort
in: query
required: false
description: Optional native reasoning-effort level for the model test.
schema:
$ref: "#/components/schemas/ReasoningEffort"
example: high
headers:
XRequestId:
description: >
@ -6710,6 +6738,7 @@ components:
- name
- description
- target
- workflow
- triggers
properties:
id:
@ -6728,34 +6757,16 @@ components:
type: ["string", "null"]
example: Keeps dependencies fresh.
target:
$ref: "#/components/schemas/AutomationTarget"
$ref: "#/components/schemas/RunTarget"
workflow:
type: string
description: Workflow slug or path resolved in the selected repository checkout.
example: dependency-update
triggers:
type: array
items:
$ref: "#/components/schemas/AutomationTrigger"
AutomationTarget:
description: Repository and workflow selected by an automation.
type: object
additionalProperties: false
required:
- repository
- ref
- workflow
properties:
repository:
type: string
description: GitHub repository slug in `owner/repo` form.
example: fabro-sh/fabro
ref:
type: string
description: Branch, tag, or SHA selector resolved when materializing a run.
example: main
workflow:
type: string
description: Workflow slug or path resolved in the target repository.
example: dependency-update
AutomationTrigger:
description: |
Automation trigger configuration. Unknown `type` discriminator values
@ -6822,6 +6833,7 @@ components:
- id
- name
- target
- workflow
- triggers
properties:
id:
@ -6835,7 +6847,11 @@ components:
type: ["string", "null"]
example: Keeps dependencies fresh.
target:
$ref: "#/components/schemas/AutomationTarget"
$ref: "#/components/schemas/RunTarget"
workflow:
type: string
description: Workflow slug or path resolved in the selected repository checkout.
example: dependency-update
triggers:
type: array
items:
@ -6848,6 +6864,7 @@ components:
required:
- name
- target
- workflow
- triggers
properties:
name:
@ -6857,7 +6874,11 @@ components:
type: ["string", "null"]
example: Keeps dependencies fresh.
target:
$ref: "#/components/schemas/AutomationTarget"
$ref: "#/components/schemas/RunTarget"
workflow:
type: string
description: Workflow slug or path resolved in the selected repository checkout.
example: dependency-update
triggers:
type: array
items:
@ -9214,6 +9235,159 @@ components:
workflow_version_id:
$ref: "#/components/schemas/WorkflowVersionId"
CreateRunRequest:
description: >-
Transitional create body used while callers migrate independently from
self-contained manifests to immutable workflow-version intents.
oneOf:
- $ref: "#/components/schemas/RunManifest"
- $ref: "#/components/schemas/RunIntent"
RunIntent:
description: >-
A request to create, but not start, one run from an immutable workflow
version and an explicit workspace target.
type: object
additionalProperties: false
required:
- workflow_version_id
- target
- args
properties:
workflow_version_id:
$ref: "#/components/schemas/WorkflowVersionId"
target:
$ref: "#/components/schemas/RunTarget"
args:
$ref: "#/components/schemas/RunIntentArgs"
environment_id:
type: string
description: Server environment catalog ID. Omission selects `default`.
parent_id:
type: string
description: Optional orchestration parent run ID.
title:
type: string
maxLength: 100
description: Optional explicit run title, normalized by the server.
goal:
type: string
description: Optional inline goal override.
RunIntentArgs:
description: Structured run overrides accepted by workflow-version creation.
type: object
additionalProperties: false
properties:
model:
type: string
provider:
type: string
description: LLM provider; this does not select the sandbox environment.
inputs:
type: object
additionalProperties:
anyOf:
- type: string
- type: number
- type: integer
- type: boolean
labels:
type: object
additionalProperties:
type: string
RunTarget:
description: Workspace content and location requested for a run.
oneOf:
- $ref: "#/components/schemas/GitRunTarget"
- $ref: "#/components/schemas/NoneRunTarget"
- $ref: "#/components/schemas/FolderRunTarget"
discriminator:
propertyName: kind
mapping:
git: "#/components/schemas/GitRunTarget"
none: "#/components/schemas/NoneRunTarget"
folder: "#/components/schemas/FolderRunTarget"
GitRunTarget:
description: >-
Public github.com repository target. The branch names the attached
working branch. An optional tag selects a release at worker start, and
an optional exact SHA is authoritative when both are present.
type: object
additionalProperties: false
required:
- kind
- repo
- branch
properties:
kind:
type: string
enum: [git]
repo:
type: string
description: GitHub repository slug in `owner/name` form.
example: acme/my-app
branch:
type: string
description: Required attached working branch name, preserved exactly.
example: feature/foo
tag:
type: string
minLength: 1
description: >-
Optional bare tag name. Prefixes such as `refs/tags/` and `tags/`
are rejected. Without `sha`, the worker resolves this tag when the
sandbox starts and fails if it is unavailable.
example: v1.2.3
sha:
type: string
pattern: "^[0-9A-Fa-f]{40}$"
description: >-
Optional exact commit. The server lowercase-normalizes its syntax
but does not resolve it, prove branch ancestry, or prove that it
matches an accompanying tag. When present, this exact commit wins.
NoneRunTarget:
description: >-
Empty workspace with no repository. Docker and Daytona accept this
target and suppress cloning even when workflow settings enable it.
Local environments reject it; Local scratch allocation is a separate
future capability.
type: object
additionalProperties: false
required:
- kind
properties:
kind:
type: string
enum: [none]
FolderRunTarget:
description: >-
Existing directory on the Fabro server, executed in place by a Local
environment. The submitted path must be absolute and name an existing
directory; Fabro resolves symlinks and persists its canonical UTF-8
path. This target is intended for trusted single-tenant deployments.
Docker and Daytona environments always reject it. This target does not
add Local Git cloning or Local scratch workspaces. Folder runs execute
in place without Fabro Git checkpoints, so fork and rewind are
unavailable.
type: object
additionalProperties: false
required:
- kind
- path
properties:
kind:
type: string
enum: [folder]
path:
type: string
minLength: 1
description: Absolute path on the Fabro server, not on the API caller's machine.
RunManifest:
description: Self-contained workflow run manifest.
type: object
@ -11439,6 +11613,11 @@ components:
oneOf:
- $ref: "#/components/schemas/WorkflowVersionId"
- type: "null"
target:
description: Canonical workspace target accepted for a version-backed run. Absent for legacy manifest runs.
oneOf:
- $ref: "#/components/schemas/RunTarget"
- type: "null"
automation:
oneOf:
- $ref: "#/components/schemas/AutomationRef"

View file

@ -128,6 +128,12 @@ Paginated responses include a `meta` object alongside the `data` array:
When `has_more` is `true`, increment the offset by the limit to fetch the next page.
## Immutable workflow versions
`POST /api/v1/workflow-versions` validates and stores a complete workflow package. The package contains an entrypoint, its text files, and exact IDs for child-workflow dependencies. Its SHA-256 ID is based on canonical content, so submitting the same package again returns the same ID.
A package can contain at most 512 files and 512 workflow dependencies. Each file can contain at most 512 KiB, and the complete canonical package can contain at most 2 MiB. Invalid workflow content or missing dependencies return `422`.
## Versioning
The Fabro API is versioned under `/api/v1`. All versioned endpoints, including the OpenAPI document, live under that prefix. Future breaking changes can be introduced under a new versioned prefix while preserving existing clients.

View file

@ -1,5 +1,5 @@
---
title: "Shared-checkout parallelism, Fireworks, and resume history"
title: "Shared-checkout parallelism, Fireworks, and agent chat"
date: "2026-07-24"
---
@ -34,9 +34,9 @@ Fireworks AI is now an opt-in built-in provider with a curated serverless catalo
enabled = true
```
## Resumed stages keep their history
## Model-native Kimi agents and agent chat
When a run resumes after a node was cancelled or lost mid-flight, the replay now starts a new stage execution such as `work@2` instead of clearing and reusing `work@1`. The earlier execution keeps its events, session, output, timing, billing, and terminal state, and the stage UI links the new execution back to the one it resumed from.
Kimi K3 sessions now use a model-specific agent profile with Kimi Code tool names, descriptions, paging, and TODO behavior. The stage activity view also has a Chat tab that groups prompts, assistant messages, tool calls, and disclosed provider reasoning into a readable conversation.
## More
@ -51,9 +51,12 @@ When a run resumes after a node was cancelled or lost mid-flight, the replay now
- Preflight now prefers providers that are actually ready while preserving explicit provider pins and useful diagnostics for unavailable offerings
- OpenAI-compatible agent providers now receive compatible file-edit and tool schemas
- Large values inside parallel branch results now stay available to fan-in prompts through normal artifact storage
- Agent compaction now preserves history when a summary is empty or exceeds the model's reasoning budget
- Agent tool secrets now reach model-specific profiles, and OpenAI-compatible streaming usage is requested consistently
</Accordion>
<Accordion title="Improvements">
- Resumed nodes now start a new execution such as `work@2`, preserving the earlier execution's events, output, timing, billing, and terminal state
- Added `claude-opus-5` to the first-party Anthropic and optional OpenRouter model catalogs; the `opus` and `claude-opus` aliases now resolve to Opus 5
- Added `gpt-sol`, `gpt-terra`, and `gpt-luna` aliases for GPT-5.6 offerings
- Added portable `glm`, `glm52`, `glm5.2`, `deepseek`, and `deepseek-flash` aliases across direct and OpenRouter offerings

View file

@ -1,5 +1,5 @@
---
title: "Consistent workspace globs"
title: "Consistent workspace globs and model-native agents"
date: "2026-07-25"
---
@ -21,3 +21,21 @@ Fabro now compiles workspace globs once and applies them to normalized relative
Artifact traversal also uses structured provider metadata for file-size limits and prunes dependency, cache, and build directories before matching.
Artifact collection now treats each post-stage workspace state as authoritative instead of relying on modification timestamps. Fabro records the same path and content hash only once per run and captures the path again when its content changes.
## Model-native Claude 5 and GPT-5.6 agents
Claude 5 and GPT-5.6 Sol, Terra, and Luna now use profiles that match each model family's preferred tools and instructions. Child agents inherit the same task runtime, so background work and completion notifications behave consistently across profiles.
## More
<Accordion title="Fixes">
- Fixed active-time totals for stages that are still running
- Fixed background-agent completion notifications and event delivery
- Fixed Daytona streaming completion and cleanup after failed probes
- Removed the read-before-write guard that could reject valid agent edits
</Accordion>
<Accordion title="Improvements">
- The stage activity panel now opens on Chat and shows disclosed reasoning in Thread details
- Open browser tabs now report when a newer Fabro web build is available
</Accordion>

View file

@ -0,0 +1,31 @@
---
title: "CLI auth sessions move to SQLite"
date: "2026-07-26"
---
<Warning>
**Everyone signs in again after this upgrade.** Existing refresh tokens are not migrated, so every signed-in browser and CLI is logged out the moment the new server binary starts. Run `fabro auth login` again on each machine. There is no staged rollout for this — avoid upgrading mid-task.
</Warning>
## Sessions are their own record
A CLI login is a chain of refresh tokens that rotate on every use. Fabro previously stored the identity and profile on each token in that chain, so the chain itself had no record of its own. It now does: an `auth_sessions` row per login, with its tokens in `refresh_tokens`, both in `<storage_root>/db/fabro.sqlite3`.
Two dates on **Settings → Sessions** were wrong as a result and are now correct. A session's **created** date came from its newest token, so it moved forward every time the CLI refreshed, and **last seen** showed the same value rather than the last time the session was actually used.
Listing and revoking sessions no longer reads every refresh token the server has ever issued, so both stay fast as a workspace accumulates logins. Revoking a session removes its tokens in the same operation.
## Pending CLI logins
Pending CLI authorization codes now live in SQLite as SHA-256 hashes and are consumed atomically on the first exchange attempt. A login that is already between browser approval and token exchange when the server upgrades cannot carry across the storage cutover; run `fabro auth login` again. These codes expire after 60 seconds, and completed logins are unaffected.
## Refresh token replay
Replaying a refresh token still revokes its whole chain immediately. One detail changed: when several requests present the same already-rotated token at once, later ones now report `refresh_token_expired` where they previously reported `refresh_token_revoked`. The CLI treats both the same way — it discards the stored credentials and prompts you to sign in again.
## More
<Accordion title="Fixes">
- `fabro validate` now stays offline and no longer requires a server model catalog
- Child agents now share the parent task runtime across every model profile
</Accordion>

View file

@ -0,0 +1,38 @@
---
title: "Runtime fan-out and live parallel runs"
date: "2026-07-27"
---
<Warning>
**Run configuration no longer reads `{{ env.NAME }}` from the server process.** Replace environment interpolation with `{{ vars.NAME }}` for non-sensitive values or `{{ secrets.NAME }}` for vault-backed values. Hook `allowed_env_vars` was also removed.
</Warning>
## Runtime arrays with `for_each`
Parallel workflows can now create one branch per item in a runtime context array. Each branch receives its item and index, while the fan-in receives the ordered results without merging branch updates into top-level context.
```dot
batch [shape=component, for_each="context.candidates"]
batch -> reviewer
```
## Live parallel status and artifact history
The run view now shows parallel branches as they start and finish. The artifacts view groups captures by file and shows version history, while stage model popovers report token usage and cost.
## More
<Accordion title="CLI">
- Validation diagnostics now include their suggested fixes
</Accordion>
<Accordion title="Workflows">
- Command `script` values now interpolate `{{ goal }}`, `{{ inputs.NAME }}`, and `{{ vars.NAME }}`
- A node with `script` now infers the command handler when `shape` and `type` are omitted
- Validation now rejects edge targets that were never declared as nodes
</Accordion>
<Accordion title="Fixes">
- Pull request publish failures now fail the run instead of leaving a successful terminal state
- Default small-model selection now skips providers that do not offer a small model
</Accordion>

View file

@ -0,0 +1,36 @@
---
title: "Modal inference and structured review targets"
date: "2026-07-28"
---
## Run Kimi K3 through Modal
Fabro now includes an optional Modal provider for Kimi K3 Shared API and Auto Endpoints. It supports Modal's endpoint URL, two-part proxy-token authentication, model capabilities, and estimated token costs.
```toml title="settings.toml"
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
## Structured review targets
Human gates can now present an external document as the primary review link. Set `review_target=true` on the gate and provide a validated `review_target` object in workflow context.
## More
<Accordion title="Workflows">
- `fabro run --dry-run` now walks runtime `for_each` fan-out, and fan-out memory is bounded
- Provider-qualified fallback selectors such as `openrouter:kimi-k3` are now supported
</Accordion>
<Accordion title="Fixes">
- Fixed live parallel branches appearing late or failing to refresh
- Fixed Docker and Daytona runs failing to reactivate stopped or paused sandboxes
- Fixed a pipe character inside a Slack link label being parsed as a table separator
- Fixed structured-output validation so it selects the outermost final JSON object
</Accordion>
<Accordion title="Improvements">
- Run-side panels now use a shared collapsible layout
</Accordion>

View file

@ -0,0 +1,34 @@
---
title: "Context-fed commands and portable fallbacks"
date: "2026-07-29"
---
## Feed workflow context to command input
Command nodes can now read a flat runtime context value through `stdin_source`. Strings pass through unchanged, while other values use compact JSON, which makes deterministic fan-in commands easier to build.
```dot
merge [script="python3 scripts/merge.py", stdin_source="context.parallel.results"]
```
## Portable model fallback chains
Fallback chains can now mix bare providers, model aliases, and provider-qualified selectors. Model stylesheets also accept comments, so routing policy can stay readable next to the workflow.
## More
<Accordion title="Workflows">
- Raised the `stdin_source` value limit from 10 MiB to 30 MiB for wide fan-in results
- Node class lists now accept whitespace-separated names consistently
</Accordion>
<Accordion title="Fixes">
- Fixed artifacts from repeated visits to the same stage
- Fixed parent tool hooks not reaching child agent sessions
- Fixed concurrent CLI token refreshes racing across processes
- Fixed command-node inference when legacy attributes are present
</Accordion>
<Accordion title="Improvements">
- Removed the nonfunctional run agent permissions setting from run configuration and the API schema
</Accordion>

View file

@ -0,0 +1,22 @@
---
title: "Model-keyed fallback policies"
date: "2026-07-30"
---
## Fallback policy follows the requested model
You can now define a separate ordered fallback chain for each requested model. Fabro selects the chain once from the original request and does not jump into another model's policy while failing over.
```toml title="run.toml"
[run.model.fallbacks]
"kimi-k3" = ["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"]
```
## More
<Accordion title="Fixes">
- Forked runs now create a fresh sandbox when they resume
- Stored run events from older Fabro releases remain readable after event schema changes
- Modal reasoning-token usage is now included in billing totals
- Fallback resolution errors now report the underlying unavailable model or provider
</Accordion>

View file

@ -0,0 +1,39 @@
---
title: "DeepSeek, reusable subagents, and artifact downloads"
date: "2026-07-31"
---
<Warning>
**The built-in `kimi` provider is now named `moonshot`.** Rename provider pins and `[llm.providers.kimi]` configuration to `moonshot`. Use `MOONSHOT_API_KEY`; `KIMI_API_KEY` remains a legacy credential fallback.
</Warning>
## Direct DeepSeek support
Fabro now has an opt-in direct DeepSeek provider with DeepSeek V4 Flash, model aliases, reasoning-effort controls, and catalog pricing. DeepSeek aliases also work through Fireworks and OpenRouter when those providers are enabled.
## Reuse completed subagent sessions
Parent agents can now resume a completed child session instead of starting a new one. The run keeps the child history and continues forwarding its events to the stage view.
## Download current artifacts as a ZIP
The artifacts page can now download the latest version of every run artifact in one ZIP archive. The server builds the archive from the current file set and tolerates artifacts that disappear during collection.
## More
<Accordion title="Workflows">
- Human-input waits now pause workflow stall timeouts
- Output schemas are now included in agent task instructions
</Accordion>
<Accordion title="Fixes">
- Fixed stale MCP server processes after Fabro upgrades
- Fixed Bedrock tool identifiers that contain unsupported characters
- Structured-output repair failures now include actionable validation details
- Provider quota errors now enter the correct failover path
- Fixed canceled parallel branch durations
</Accordion>
<Accordion title="Improvements">
- The steering bar now starts collapsed and expands when you select it
</Accordion>

View file

@ -0,0 +1,26 @@
---
title: "Durable pull request creation"
date: "2026-08-01"
---
<Warning>
**Pull request creation is now asynchronous, and run IDs are server-owned.** `POST /api/v1/runs/{id}/pull_request` now returns `202` with a `PullRequestCreation` record and a `Location` to poll. Remove `run_id` from run manifests and stop using the removed CLI run-ID override; the server always allocates the run ID.
</Warning>
## Pull request creation survives restarts
Explicit pull request creation is now stored before GitHub work begins. The request returns immediately, and you can poll its durable `pending`, `succeeded`, or `failed` state even if the server restarts.
## More
<Accordion title="API">
- New `GET /api/v1/runs/{id}/pull_request/creation` endpoint returns the latest explicit pull request request
- Run projections now include the latest `pull_request_creation` state
</Accordion>
<Accordion title="Fixes">
- Runs now fail when routing bypasses a required goal gate
- Reused child agents continue forwarding events after broadcast lag
- Artifact archives batch storage reads and tolerate files that vanish during download
- Structured-output repair errors now identify unexpected properties in a stable order
</Accordion>

View file

@ -0,0 +1,15 @@
---
title: "Leaner run manifests and Qwen3.8 Max"
date: "2026-08-03"
---
<Warning>
**Unused run-manifest fields were removed from the API schema.** `GitContext.push_outcome`, `ManifestGoal.path`, and `ManifestTarget.identifier` are no longer generated in clients. Servers still accept older request bodies that contain these fields.
</Warning>
## More
<Accordion title="Improvements">
- Added Qwen3.8 Max to the optional OpenRouter model catalog
- Agent task reminders now remain consistent when a turn is interrupted
</Accordion>

View file

@ -0,0 +1,15 @@
---
title: "Faster Daytona edits and Kimi K3 Fast"
date: "2026-08-04"
---
## More
<Accordion title="CLI">
- Increased the `fabro doctor` server health-check timeout to avoid false failures on slower local servers
</Accordion>
<Accordion title="Improvements">
- Agent file edits in Daytona now skip an unnecessary folder lookup
- Added Kimi K3 Fast to the optional Fireworks model catalog
</Accordion>

View file

@ -0,0 +1,14 @@
---
title: "Correct node visit limits"
date: "2026-08-05"
---
## More
<Accordion title="Workflows">
- A node may now execute exactly `max_visits` times before the cycle guard stops the run
</Accordion>
<Accordion title="Fixes">
- Server diagnostics now stay within the client timeout instead of leaving `fabro doctor` waiting on a late response
</Accordion>

View file

@ -0,0 +1,14 @@
---
title: "Run filters and live billing totals"
date: "2026-08-06"
---
## Faster run browsing
The runs list now fills its repository and workflow filters from available runs. Billing totals for active runs also update from live events instead of waiting for the run to finish.
## More
<Accordion title="Fixes">
- Event redaction no longer mutates the executable run specification used to resume, retry, or fork a run
</Accordion>

View file

@ -0,0 +1,10 @@
---
title: "Safer workflow root paths"
date: "2026-08-12"
---
## More
<Accordion title="Fixes">
- Workflow bundling once again normalizes relative root paths before reading them, rejects `~`-prefixed roots, and prevents `..` segments from resolving through a symlink to a different file
</Accordion>

View file

@ -0,0 +1,23 @@
---
title: "Immutable workflow versions"
date: "2026-08-13"
---
## Store validated workflow packages by content
The API can now validate and store an immutable workflow package with its entrypoint, files, and exact child-workflow dependencies. Repeating the same canonical package returns the same SHA-256 workflow version ID.
```http
POST /api/v1/workflow-versions
```
## More
<Accordion title="API">
- Invalid workflow-version content and missing or non-canonical dependencies return `422` errors
- Workflow version IDs are accepted case-insensitively and returned in canonical lowercase form
</Accordion>
<Accordion title="Fixes">
- Workflow-version validation now detects file path collisions that were previously hidden by sort order
</Accordion>

View file

@ -0,0 +1,15 @@
---
title: "Exact workflow lineage"
date: "2026-08-14"
---
## More
<Accordion title="API">
- Run specifications and projections now preserve the exact immutable `workflow_version_id` used at admission
- Workflow-version goals now include and validate the complete closure of child workflow dependencies
</Accordion>
<Accordion title="Fixes">
- Clone-based sandboxes now verify an admitted exact commit instead of silently using a newer branch head
</Accordion>

View file

@ -0,0 +1,18 @@
---
title: "Content-addressed blob hashes"
date: "2026-08-16"
---
<Warning>
**The blob write response field changed from `id` to `hash`.** Regenerate API clients from version `0.2.0` of the OpenAPI spec. Older generated clients fail when the response no longer contains `id`.
</Warning>
## More
<Accordion title="API">
- Blob read routes now name the path parameter `blobHash`, and blob write responses return the SHA-256 value in `hash`
</Accordion>
<Accordion title="Improvements">
- Run dump hydration now shares a blob cache across JSON and text output
</Accordion>

View file

@ -0,0 +1,16 @@
---
title: "Reliable workflow dependency discovery"
date: "2026-08-17"
---
## More
<Accordion title="API">
- SHA-256 blob and workflow-version hashes now use canonical lowercase values across API responses and generated clients
</Accordion>
<Accordion title="Fixes">
- Template dependencies are now found when their paths overlap workflow discovery roots
- Template discovery errors now identify the source file that introduced the invalid dependency
- File-based workflow-version goals now reject broken transitive includes before a run starts
</Accordion>

View file

@ -0,0 +1,10 @@
---
title: "Fireworks account failover"
date: "2026-08-18"
---
## More
<Accordion title="Fixes">
- Fireworks HTTP `412` account suspension responses now activate configured model fallbacks instead of being treated as invalid requests
</Accordion>

View file

@ -0,0 +1,20 @@
---
title: "Reliable sandbox pushes and Daytona recovery"
date: "2026-08-20"
---
## Git pushes survive token rotation
Sandbox pushes now use a cached GitHub installation-token source and retry with pinned credentials. Long runs can refresh expired tokens without changing credentials in the middle of one push attempt.
## More
<Accordion title="Fixes">
- Daytona activation, start, and stop now wait and retry during provider state transitions
- Clone-based sandboxes now verify exact commits after checkout
- Sandbox state-change conflicts are now treated as transient infrastructure failures
</Accordion>
<Accordion title="Improvements">
- Daytona sandboxes now default to a 120-minute auto-stop interval; set `auto_stop = "0s"` to disable it
</Accordion>

View file

@ -1,8 +1,30 @@
---
title: "Additional GitHub repositories and Venice search"
title: "Run intents, additional repositories, and Venice search"
date: "2026-08-21"
---
## Version-backed run creation
Fabro's canonical `POST /api/v1/runs` endpoint now also accepts a strict
`RunIntent` body that creates a submitted run from an immutable workflow
version, a named server environment, and an explicit public GitHub repository
target. Creation remains separate from execution; call the existing start
endpoint when the run should begin.
Git targets require a branch and may pin a full commit SHA. Fabro normalizes a
submitted SHA to lowercase and uses that exact commit during sandbox setup,
without resolving it during admission or falling back to a newer branch HEAD.
The environment ID defaults to `default`; this first target slice supports
clone-enabled Docker and Daytona environments.
Existing manifest callers continue to work unchanged and can migrate
independently. Intent runs do not consume repository `project.toml` settings:
the selected server environment owns provider, working directory, and image,
while workflow versions may still overlay resources, network, lifecycle,
labels, and environment variables. Project metadata therefore no longer adds
run labels on this path, and repository-wide settings must move into workflow
versions or server defaults before a caller migrates.
## One token for the whole repository set
A run can now declare additional GitHub repositories that its stages may access through the managed `GITHUB_TOKEN`:
@ -24,3 +46,20 @@ Declaring additional repositories requires `contents = "read"` or `contents = "w
The built-in `web_search` tool now supports Venice as an automatic alternative to direct Brave Search. Fabro uses `BRAVE_SEARCH_API_KEY` when present. Otherwise it uses `VENICE_API_KEY` with Venice's Brave search engine. If neither key is present, the tool is not registered. Failed calls do not fall back between providers.
See [Venice Search](/integrations/venice-search) and [Brave Search](/integrations/brave-search).
## More
<Accordion title="Workflows">
- Docker and Daytona clones now default to 100 commits; set `[run.clone] depth = 0` for full history or a smaller positive depth for a shallower clone
</Accordion>
<Accordion title="Fixes">
- Runs now fail if durable event persistence is lost instead of continuing with incomplete history
- Venice model responses now include provider-reported top-level costs in billing totals
- Daytona post-clone setup now has a bounded wait
</Accordion>
<Accordion title="Improvements">
- Large context values moved out of agent prompt preambles now render as concise file references with bounded previews
- Added the Venice built-in model provider catalog and integration guide
</Accordion>

View file

@ -0,0 +1,27 @@
---
title: "Empty run workspaces and more reliable Daytona activation"
date: "2026-08-23"
---
Version-backed run intents can now use `{ "kind": "none" }` when a workflow
should start without a repository. The target creates an empty Docker or
Daytona workspace and suppresses cloning even when the resolved workflow
settings enable it.
Local environments reject the `none` target. Server-managed Local scratch
workspaces remain a separate future capability.
Run intents can also use
`{ "kind": "folder", "path": "/absolute/server/path" }` with a Local
environment to execute in an existing server directory. Fabro resolves the
submitted path to an existing canonical directory, persists that path, and
uses it instead of the environment's `cwd`. Folder targets are intended for
trusted single-tenant deployments and are rejected by Docker and Daytona.
They execute in place without Fabro Git checkpoints, so retries retain the
folder target while fork and rewind remain unavailable.
## More
<Accordion title="Fixes">
- Daytona snapshot activation now allows the provider more time to finish before Fabro reports a timeout
</Accordion>

View file

@ -0,0 +1,37 @@
---
title: "Model stylesheet templates and failure routing"
date: "2026-08-25"
---
Workflows can now set graph-level `on_failure="exit"` to stop after a failed
node when no explicit recovery route matches. Fabro skips the unconditional
edge, checks configured retry targets, and ends the run as failed if no retry
target exists.
The default `on_failure="route"` preserves existing workflow behavior.
A node can also set its own `on_failure` to override the graph policy in
either direction: a best-effort node can use `on_failure="route"` inside an
`exit` graph, or a single critical node can use `on_failure="exit"` while the
rest of the graph keeps the default.
```dot
digraph Build {
graph [on_failure="exit"]
start [shape=Mdiamond]
exit [shape=Msquare]
plan [prompt="Plan the work"]
implement [prompt="Implement the plan"]
verify [prompt="Verify the implementation"]
start -> plan -> implement -> verify -> exit
}
```
## Model stylesheet templates
The root graph's `model_stylesheet` now supports MiniJinja templates. A stylesheet can use typed run inputs and server-managed variables through `inputs` and `vars`. Conditions, loops, filters, macros, local values, and static includes use the same template engine as workflow goals and prompts.
Fabro renders the stylesheet before parsing and applying its rules. Undefined values produce the existing `template_undefined_variable` diagnostic. Offline validation skips stylesheet syntax checks until those values are available, which avoids a second error from incomplete generated stylesheet text.
Stylesheet templates do not expose `goal`, `env`, or `secrets`. Stylesheets on imported graphs remain ignored and now produce an `imported_model_stylesheet_ignored` warning.

View file

@ -0,0 +1,34 @@
---
title: "Explicit succeed failure policy"
date: "2026-08-26"
---
`on_failure` now accepts a third policy, `succeed`, alongside `route` and
`exit`. A failed node with an effective `succeed` policy and no explicit
recovery route finishes as `succeeded` and follows normal success routing.
The original failure details stay on the `stage.completed` event and in the
checkpoint, and the outcome's notes record the promotion.
Set it on a node to mark a best-effort step inside a strict graph, or on the
graph to apply it everywhere:
```dot
digraph Review {
graph [on_failure="exit"]
start [shape=Mdiamond]
exit [shape=Msquare]
required_check [script="./required-check"]
optional_scan [script="./optional-scan" on_failure="succeed"]
start -> required_check -> optional_scan -> exit
}
```
An explicit `condition="outcome=failed"` edge still takes priority over the
promotion. A promoted outcome satisfies goal gates, and a failed parallel
branch with a `succeed` policy counts as succeeded in its parent's result.
`auto_status=true` is now a deprecated alias for `on_failure="succeed"`.
Existing workflows keep working, and validation reports a new
`auto_status_deprecated` warning with the replacement. The alias no longer
promotes `partially_succeeded` outcomes; only `failed` outcomes are affected.

View file

@ -187,6 +187,13 @@
"api-reference/client-sdks"
]
},
{
"group": "Workflow Versions",
"icon": "code-branch",
"pages": [
"POST /api/v1/workflow-versions"
]
},
{
"group": "Runs",
"icon": "play",
@ -202,6 +209,8 @@
"POST /api/v1/runs/{id}/cancel",
"POST /api/v1/runs/{id}/pause",
"POST /api/v1/runs/{id}/unpause",
"POST /api/v1/runs/{id}/pull_request",
"GET /api/v1/runs/{id}/pull_request/creation",
"GET /api/v1/runs/{id}/graph",
"GET /api/v1/runs/{id}/events"
]
@ -220,6 +229,7 @@
"icon": "file-export",
"pages": [
"GET /api/v1/runs/{id}/artifacts",
"GET /api/v1/runs/{id}/artifacts/download",
"GET /api/v1/runs/{id}/billing",
{
"group": "Run Internals",
@ -299,13 +309,34 @@
"group": "August 2026",
"icon": "clock-rotate-left",
"pages": [
"changelog/2026-08-21"
"changelog/2026-08-26",
"changelog/2026-08-25",
"changelog/2026-08-23",
"changelog/2026-08-21",
"changelog/2026-08-20",
"changelog/2026-08-18",
"changelog/2026-08-17",
"changelog/2026-08-16",
"changelog/2026-08-14",
"changelog/2026-08-13",
"changelog/2026-08-12",
"changelog/2026-08-06",
"changelog/2026-08-05",
"changelog/2026-08-04",
"changelog/2026-08-03",
"changelog/2026-08-01"
]
},
{
"group": "July 2026",
"icon": "clock-rotate-left",
"pages": [
"changelog/2026-07-31",
"changelog/2026-07-30",
"changelog/2026-07-29",
"changelog/2026-07-28",
"changelog/2026-07-27",
"changelog/2026-07-26",
"changelog/2026-07-25",
"changelog/2026-07-24",
"changelog/2026-07-23",

View file

@ -3,13 +3,37 @@ title: "Automations"
description: "Named, repeatable run configurations with API and schedule triggers"
---
An **automation** is a saved run configuration — a repository, ref, and workflow — plus the triggers that may start it. Every trigger fire creates and starts a normal Fabro run through the same pipeline as `POST /api/v1/runs`, so automation runs get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
An **automation** is a saved run configuration — a Git repository, working branch, optional tag or exact commit, and workflow — plus the triggers that may start it. Every trigger fire creates and starts a normal Fabro run through the same pipeline as `POST /api/v1/runs`, so automation runs get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
## Defining automations
The server stores automations in its SQLite database. Manage them in the web UI at `/automations` or through the `/api/v1/automations` REST API.
When upgrading from file-backed automation storage, startup imports every valid `automations/*.toml` file next to the active `settings.toml`. Existing SQLite definitions win on ID conflicts. After a successful import, Fabro renames the directory to a timestamped backup such as `automations.imported-20260711T180000000000Z.bak`. Invalid TOML leaves the original directory untouched for operator repair.
New definitions use Fabro's canonical Git run target. The working branch is always required. An optional tag selects that tag when no exact commit is present, and an optional 40-character commit SHA pins the run exactly. The exact commit wins when both a tag and SHA are present; the branch is retained as the run's working branch in every case.
```json title="Create automation request"
{
"name": "Nightly release",
"description": "Cut a nightly build from main",
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "main",
"tag": "v1.2.3",
"sha": "0123456789abcdef0123456789abcdef01234567"
},
"workflow": "release",
"triggers": [
{ "type": "api", "id": "manual", "enabled": true }
]
}
```
Automations currently support Git targets only. Folder and empty run targets are rejected during validation.
### Upgrading legacy targets
When upgrading from file-backed automation storage, startup imports every valid `automations/*.toml` file next to the active `settings.toml`. Existing SQLite definitions win on ID conflicts. After a successful import, Fabro renames the directory to a timestamped backup such as `automations.imported-20260711T180000000000Z.bak`. Invalid TOML or an invalid target leaves the original directory untouched for operator repair.
The legacy files use this shape:
@ -34,7 +58,19 @@ enabled = true
expression = "0 0 * * *"
```
The target names a GitHub repository as an `owner/repo` slug, the ref to run against, and a project workflow defined in that repository. When a trigger fires, Fabro clones the repository at the ref, resolves the workflow, and creates and starts the run. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
Fabro converts legacy refs deterministically:
- A 40-character hexadecimal SHA becomes an exact commit on working branch `main`.
- `refs/tags/<name>` and `tags/<name>` become a tag on working branch `main`.
- `refs/heads/<name>` and `heads/<name>` become a working branch.
- `HEAD` becomes working branch `main`.
- Any other bare value becomes a working branch.
The `main` default is only a migration assumption. If the repository uses another working branch, edit the imported automation before running it.
The same conversion runs transactionally for automations already in SQLite. An unsupported `refs/*` selector or an invalid branch or tag name aborts startup with an actionable error instead of guessing. The database remains on its previous schema and data, and the migration snapshot remains available. Edit the unsupported legacy `target_ref` to a branch, head selector, tag selector, `HEAD`, or exact SHA, then restart Fabro.
When a trigger fires, Fabro clones the repository at the selected branch, tag, or exact commit, resolves the workflow, and creates and starts the run. The created run records the exact checked-out commit in its canonical target, so later inspection and automation creation preserve the revision that actually ran. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
## Triggers

View file

@ -230,6 +230,12 @@ The preamble includes:
Internal keys (prefixed with `internal.`, `current`, `graph.`, `thread.`, `response.`) are excluded from preambles to avoid noise.
### Large preamble values
In fidelity modes that render context or completed-stage output, one value can contribute at most 8 KiB of serialized JSON inline. Fabro stores larger values as content-addressed blobs, materializes them as readable files, and puts the size, file path, and a 300-character preview in the preamble. The agent can read the file when it needs the full value.
This prompt limit is separate from durable artifact offloading. If Fabro cannot demote a value, it logs a warning and keeps that value inline so the stage can continue.
## Artifact offloading
When a stage produces a large output (over 100KB of serialized JSON), Fabro stores the serialized bytes in a global content-addressed blob store and replaces the context value with a durable blob ref. Command output is always finalized into a blob ref after command completion, even when it is small or empty:
@ -244,7 +250,7 @@ Checkpoints and checkpoint-completed events persist these `blob://` refs, not ho
Before Fabro builds a preamble or starts the next stage, it resolves any blob refs into execution-local files so handlers and agents still see normal `file://` references:
- Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_hash}.json`
- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_hash}.json`
- Remote sandboxes materialize blobs under the sandbox runtime directory, `{runtime_directory}/blobs/{blob_hash}.json`. This directory lives outside the repository checkout, so materialized blobs never appear in `git status` and are never committed by a checkpoint.
These materialized `file://` paths are runtime-only. They are not written back into durable context snapshots.

View file

@ -231,6 +231,18 @@ Install seeds a `default` environment into SQLite. It is a normal persisted envi
Create a server-managed local-provider environment through the environments API when you need a host `cwd`.
A version-backed run intent can submit
`{ "kind": "folder", "path": "/absolute/server/path" }` to run in an existing
server directory. Fabro accepts this target only with a Local environment,
resolves symlinks and `..`, requires an existing directory, and persists the
canonical UTF-8 path. The target path takes precedence over the environment's
`cwd`. Because the run executes in place with the Local provider's unrestricted
host access, use folder targets only in trusted single-tenant deployments.
Docker and Daytona always reject folder targets. This does not add Local Git
cloning or Local scratch workspaces for the `none` target. Local folder runs
execute in place without Fabro Git checkpoints: retries retain the canonical
folder target, but fork and rewind are unavailable for these runs.
When `cwd` is set, local runs execute commands from that absolute server-side
path. When it is unset, Fabro keeps same-host compatibility by using the
submitted source directory only if that path exists on the server. If neither is
@ -258,7 +270,7 @@ memory = "4GB"
mode = "block"
```
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start with an empty workspace. Set `[run.clone] depth = 0` to clone full history. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.

View file

@ -40,6 +40,53 @@ approve -> manual_review [condition="outcome=failed"]
If no `outcome=failed` edge or `retry_target` exists, the run stops rather than advancing past the approval gate.
## Stop linear workflows on failure
By default, Fabro uses `on_failure="route"`. A failed node can take an unconditional edge when no explicit route matches. This compatibility default lets existing workflows decide how later nodes handle the failure.
Set graph-level `on_failure="exit"` to stop a linear workflow at a failed node:
```dot title="stop-on-failure.fabro"
digraph Build {
graph [on_failure="exit"]
start [shape=Mdiamond]
exit [shape=Msquare]
plan [prompt="Plan the work"]
implement [prompt="Implement the plan"]
verify [prompt="Verify the implementation"]
start -> plan -> implement -> verify -> exit
}
```
Fabro still uses an explicit recovery edge, such as `condition="outcome=failed"`, before it applies this policy. Matching preferred labels and suggested next node IDs also remain explicit routes. If no explicit edge matches, `exit` skips the unconditional edge and checks retry targets. The run ends as failed only when no retry target exists.
Set `on_failure` on a node to control that node alone. The node-level attribute overrides the graph level, in both directions: a node can opt out of a graph-level `exit` with `on_failure="route"`, or stop the run on its own failure with `on_failure="exit"` while the rest of the graph keeps the default. A node without the attribute inherits the graph policy. See [Failed-node routing policy](/workflows/transitions#failed-node-routing-policy).
The policy applies only to `failed`. Other outcomes keep their normal routing behavior. For parallel nodes, the policy uses the completed parallel node's final outcome. It does not stop or cancel individual branches early.
## Treat a failed node as succeeded
Set `on_failure="succeed"` on a best-effort node so its failure never blocks the workflow. This pairs well with a strict graph default:
```dot title="best-effort-node.fabro"
digraph Review {
graph [on_failure="exit"]
start [shape=Mdiamond]
exit [shape=Msquare]
required_check [script="./required-check"]
optional_scan [script="./optional-scan" on_failure="succeed"]
start -> required_check -> optional_scan -> exit
}
```
When `optional_scan` fails, Fabro first checks explicit recovery routes with the `failed` outcome. If none match, it rewrites the outcome to `succeeded` and routes the node as a success. Retries still run first; only the final outcome changes. The original failure stays on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion. A promoted outcome satisfies a goal gate. Setting `on_failure="succeed"` on the graph applies it to every node.
`succeed` applies only to `failed`. It does not change a `partially_succeeded` outcome. `auto_status=true` is the deprecated spelling of this policy; validation warns and suggests `on_failure="succeed"`.
## Retry layers
Fabro retries failures at three levels: **LLM retries** handle transient API errors inside a single model call, **turn-level retries** recover from dropped streams mid-response, and **node retries** re-execute the entire node handler when the first two levels aren't enough. These layers are independent — a node retry re-runs the full handler, which gets its own fresh set of LLM and turn-level retries.
@ -304,9 +351,14 @@ A node failure does **not** automatically terminate the run. Fabro follows this
2. **Turn-level retries** — dropped streams retry the same agent turn (up to 3 retries), preserving conversation history
3. **Provider failover** — if configured, switch to a fallback provider
4. **Node retries** — re-execute the entire handler (per the retry policy)
4. **Edge routing** — if the node ultimately fails, look for an outgoing edge that matches (e.g., `condition="outcome=failed"`)
5. **Retry target** — if no matching edge exists, check `retry_target` / `fallback_retry_target` on the node and graph
6. **Run failure** — if none of the above produces a path forward, the run terminates
5. **Direct jump** — use `jump_to_node` when the outcome supplies one
6. **Explicit edge routing** — look for a matching condition, preferred label, or suggested next node
7. **Failure policy** — with no explicit route, apply the effective `on_failure` (node-level `on_failure` first, then graph-level): `exit` skips the unconditional edge, `succeed` promotes the outcome to `succeeded` and routes it as a success, and `route` (or no attribute) keeps normal fallback routing
8. **Unconditional edge** — in `route` mode, or after a `succeed` promotion, use an edge without a condition as the fallback
9. **Retry target** — if no edge was selected, check `retry_target` and `fallback_retry_target` on the node, then on the graph
10. **Run failure** — if none of the above produces a path forward, the run terminates
When a retry target sends the run back to a failing path, use graph-level `max_node_visits` or node-level `max_visits` to stop an unbounded cycle.
The run also terminates immediately for:

View file

@ -124,7 +124,7 @@ When running workflows through the API server, subscribe to the [run events endp
### Web UI
The web frontend consumes the SSE stream automatically and shows stage progress, tool calls, command output, and human interaction as they happen. Use the stage `Thread` and `Debug` views for per-stage activity, or the run-level `Run Events` page when you need the full event stream with search and category filters. The `Run Events` page also includes a Waterfall view for comparing stage durations and inspecting timing details from hover popovers.
The web frontend consumes the SSE stream automatically and shows stage progress, tool calls, command output, and human interaction as they happen. Use the stage `Chat` view for a readable conversation, `Thread` for the detailed agent transcript and disclosed provider reasoning, and `Debug` for raw stage events. Use the run-level `Run Events` page when you need the full event stream with search and category filters. The `Run Events` page also includes a Waterfall view for comparing stage durations and inspecting timing details from hover popovers.
<Frame caption="The Stages tab shows the full agent conversation including tool calls and responses.">
<img src="/images/web/run-stages.png" alt="Fabro web UI run stages showing agent conversation with tool calls" />

View file

@ -83,24 +83,28 @@ In this example, if the agent returns a retryable failure and all 5 standard-pol
See [Retry policies](/execution/failures#retry-policies) for the available presets and backoff settings.
## `auto_status`
## Succeed on failure
When `auto_status=true`, any non-`succeeded` and non-`skipped` outcome is silently overridden to `succeeded` after the handler completes. This is applied after the retry loop, so retries still happen normally — only the final outcome is overridden.
When a node's effective `on_failure` policy is `succeed`, a `failed` outcome with no explicit recovery route is promoted to `succeeded`. This is applied after the retry loop, so retries still happen normally — only the final outcome changes. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion.
| Attribute | Type | Default |
|---|---|---|
| `auto_status` | Boolean | `false` |
| `on_failure` | String | inherits the graph-level `on_failure` (default `route`) |
```dot
scan [
label="Scan",
shape=parallelogram,
auto_status=true,
on_failure="succeed",
script="find . -name '*.log' | head -20"
]
```
Use `auto_status` for nodes whose failure should never block the workflow — optional scans, best-effort cleanup steps, or informational commands where the output matters more than the exit code.
Use `on_failure="succeed"` for nodes whose failure should never block the workflow — optional scans, best-effort cleanup steps, or informational commands where the output matters more than the exit code. An explicit `condition="outcome=failed"` edge still takes priority; the promotion applies only when no explicit route matches. The policy applies only to `failed` and leaves `partially_succeeded` unchanged. See [Failed-node routing policy](/workflows/transitions#failed-node-routing-policy) for the full set of policies.
<Note>
`auto_status=true` is the deprecated spelling of `on_failure="succeed"`. Fabro still accepts it as an alias, and validation reports an `auto_status_deprecated` warning with the replacement. Unlike the old attribute, the alias no longer promotes `partially_succeeded` outcomes.
</Note>
## Goal gate interaction
@ -111,7 +115,7 @@ make the workflow fail.
Nodes marked with `goal_gate=true` are checked when the workflow reaches the exit node. A goal gate is satisfied if its last outcome was `succeeded` **or** `partially_succeeded`. Any other outcome (`failed`, `skipped`) causes the workflow to fail, even though execution reached the exit.
This means `allow_partial=true` on a goal gate node lets the gate pass even if the node exhausted its retries — the promoted `partially_succeeded` outcome counts as passing.
This means `allow_partial=true` on a goal gate node lets the gate pass even if the node exhausted its retries — the promoted `partially_succeeded` outcome counts as passing. Likewise, a `succeeded` outcome promoted by `on_failure="succeed"` satisfies the gate.
See [Goal gates](/execution/failures#goal-gates) for retry target resolution and failure behavior.

View file

@ -444,16 +444,19 @@ repo_url = "https://github.com/fabro-sh/fabro"
language = "rust"
```
Inputs can be used in graph `goal` and node `prompt` attributes with `{{ inputs.name }}` syntax:
Inputs can be used in graph `goal`, root `model_stylesheet`, and node `prompt` attributes with `{{ inputs.name }}` syntax:
```dot title="c-i.fabro"
digraph CI {
graph [goal="Run tests for {{ inputs.repo_name }}"]
graph [
goal="Run tests for {{ inputs.repo_name }}",
model_stylesheet="{% if inputs.language == 'rust' %}* { reasoning_effort: high; }{% endif %}"
]
test [label="Test", prompt="Clone {{ inputs.repo_url }} and run the {{ inputs.language }} test suite."]
}
```
Inputs cannot parameterize workflow structure, file references such as node IDs, edges, `import` paths, `@file` paths, or child workflow paths, or any attribute besides `prompt` and `goal` — other attributes such as `script` and `label` are literal text.
Inputs cannot parameterize workflow structure, file references such as node IDs, edges, `import` paths, `@file` paths, or child workflow paths, or any full-template attribute besides `prompt`, `goal`, and the root `model_stylesheet`. Command `script` supports only simple value substitution. Other attributes such as `label` are literal text.
If a workflow template references an undefined input like `{{ inputs.langauge }}`, `fabro validate` reports a warning. Run-style commands promote that diagnostic to an error before creating or starting a run.

View file

@ -231,7 +231,7 @@ Custom Daytona snapshot names are computed from the Dockerfile, resource hints,
### "Timed out waiting for snapshot to become active"
Snapshot creation took longer than 10 minutes. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active.
Snapshot creation took longer than 30 minutes. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active.
### Git clone fails for private repositories

View file

@ -53,7 +53,7 @@ Fabro does not use the `gpt56` profile for DeepSeek. That profile has a smaller
```bash
fabro model list --provider deepseek
fabro model test --provider deepseek --model deepseek-v4-flash --deep
fabro model test --provider deepseek --model deepseek-v4-flash --tools
fabro run workflow.fabro --provider deepseek --model deepseek
```

View file

@ -227,6 +227,30 @@ When a workflow runs in a remote sandbox (Daytona or Docker), Fabro clones the c
For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints.
#### Git targets for run intents
The `RunIntent` create body always names a GitHub repository and a working
branch. It may also select a bare tag, pin a full 40-character commit SHA, or
include both:
| Target fields | Revision selected when the worker starts |
|---|---|
| `branch` | The branch HEAD |
| `branch` + `sha` | The exact commit |
| `branch` + `tag` | The tag's peeled commit |
| `branch` + `tag` + `sha` | The exact commit; the tag remains part of the run's identity |
`branch` is always the attached branch inside the sandbox. `tag` is a bare tag
name such as `v1.2.3`; `refs/tags/v1.2.3` and `tags/v1.2.3` are rejected. An
unpinned tag is resolved when the worker starts, so moving a tag before that
point changes the selected commit.
Creating the run validates the selectors and lowercase-normalizes `sha`, but
does not contact GitHub or prove ancestry. An exact SHA is authoritative:
Fabro does not prove it belongs to the branch or matches the accompanying tag.
If a requested tag or exact commit is unavailable, sandbox setup fails without
falling back to a same-named branch or the branch's newer HEAD.
### GITHUB_TOKEN injection
When any settings layer declares `[run.integrations.github.permissions]`, Fabro prepares a scoped GitHub App token source and exposes it as the `GITHUB_TOKEN` environment variable in sandbox command and agent execution. Agents running inside the sandbox can use this token for GitHub API calls and pushes within the granted permissions. The GitHub CLI (`gh`) reads `GITHUB_TOKEN` automatically, so command stages can run `gh pr list`, `gh issue create`, and similar commands without an explicit `gh auth login`.

View file

@ -48,7 +48,7 @@ Both models support text input, tool calling, native reasoning, streaming, and a
```bash
fabro model list --provider poolside
fabro model test --model laguna-xs-2.1 --deep
fabro model test --model laguna-xs-2.1 --tools
fabro run workflow.fabro --model laguna-s-2.1
```
@ -104,7 +104,7 @@ enabled = true
The OpenRouter routes use vendor-namespaced model IDs so they can coexist with direct Poolside routes:
```bash
fabro model test --model poolside/laguna-xs-2.1 --deep
fabro model test --model poolside/laguna-xs-2.1 --tools
fabro run workflow.fabro --model poolside/laguna-s-2.1
```

View file

@ -57,7 +57,7 @@ Pin Venice when the run must use Venice:
```bash
fabro model list --provider venice
fabro model test --provider venice --model deepseek-v4-flash --deep
fabro model test --provider venice --model deepseek-v4-flash --tools
fabro run workflow.fabro --provider venice --model deepseek-v4-flash
```

View file

@ -611,6 +611,7 @@ fabro mcp config [OPTIONS]
| Option | Description |
| --- | --- |
| `--name <name>` | Name of the mcpServers entry; use distinct names to register multiple Fabro servers<br />Default: `fabro` |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
@ -632,6 +633,7 @@ fabro mcp init [OPTIONS] <AGENT>
| Option | Description |
| --- | --- |
| `--name <name>` | Name of the mcpServers entry; use distinct names to register multiple Fabro servers<br />Default: `fabro` |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
@ -693,11 +695,12 @@ fabro model test [OPTIONS]
| Option | Description |
| --- | --- |
| `--deep` | Run a multi-turn tool-use test (catches reasoning round-trip bugs) |
| `-j, --jobs <jobs>` | Number of model tests to run concurrently in bulk mode<br />Default: `4` |
| `-m, --model <model>` | Test a specific model |
| `-p, --provider <provider>` | Filter by provider |
| `--reasoning-effort <reasoning_effort>` | Request a reasoning-effort level<br />Values: `low`, `medium`, `high`, `xhigh`, `max` |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--tools` | Run a multi-turn tool-use test |
### `fabro parent`

View file

@ -75,8 +75,9 @@ rankdir=LR
|---|---|---|
| `goal` | String | Workflow objective — guides agent behavior |
| `rankdir` | Identifier | Layout direction: `LR` (left-to-right) or `TB` (top-to-bottom) |
| `model_stylesheet` | String | CSS-like rules for model assignment (see [Model Stylesheets](/workflows/stylesheets)) |
| `model_stylesheet` | String | CSS-like rules for model assignment. The root value supports a MiniJinja template with `inputs` and `vars` (see [Model Stylesheets](/workflows/stylesheets)) |
| `default_max_retries` | Integer | Default retry count for all nodes (default: 0) |
| `on_failure` | String | Failed-node policy when no explicit recovery route matches: `route` (default), `exit`, or `succeed` |
| `retry_target` | String | Default node ID to jump to on retry |
| `fallback_retry_target` | String | Fallback retry target if primary target fails |
| `default_fidelity` | String | Default [fidelity level](/execution/context) for all nodes |
@ -198,12 +199,13 @@ Other node types still need their shape, because their attributes don't identify
| `class` | String | Classes for [stylesheet](/workflows/stylesheets) targeting. Separate multiple classes with spaces. Commas are also accepted for compatibility. |
| `timeout` | Duration | Execution timeout (e.g. `900s`). An agent's wait for human input does not consume this budget. On a human node, this is the response deadline. |
| `max_visits` | Integer | Max times this node can execute in a run. Overrides the graph-level `max_node_visits` for this node. |
| `on_failure` | String | Failed-node policy for this node: `route`, `exit`, or `succeed`. Overrides the graph-level `on_failure`. See [Node Outcomes](/execution/outcomes#succeed-on-failure). |
| `max_retries` | Integer | Override default retry count |
| `retry_policy` | String | Named preset: `none`, `standard`, `aggressive`, `linear`, `patient` |
| `retry_target` | String | Node ID to jump to on retry |
| `fallback_retry_target` | String | Fallback node ID if primary `retry_target` is unreachable |
| `goal_gate` | Boolean | When `true`, workflow fails if this node didn't finish with `succeeded` or `partially_succeeded`. See [Node Outcomes](/execution/outcomes#goal-gate-interaction). |
| `auto_status` | Boolean | When `true`, overrides any non-`succeeded`/non-`skipped` outcome to `succeeded` after the handler completes. See [Node Outcomes](/execution/outcomes#auto_status). |
| `auto_status` | Boolean | Deprecated alias for `on_failure="succeed"`. Validation warns when it is present. |
| `allow_partial` | Boolean | When `true` and retries are exhausted on a retry-requesting failure, promotes the outcome to `partially_succeeded` instead of `failed`. Default `false`. See [Node Outcomes](/execution/outcomes#allow_partial). |
| `selection` | String | Edge tiebreaking strategy: `deterministic` (default) or `random` (weighted-random). Cannot be combined with conditional edges. |

View file

@ -53,6 +53,49 @@ Common flags:
See [Server Configuration](/administration/server-configuration) for the full `settings.toml` reference.
### SQLite blob storage activation
On startup, Fabro activates SQLite as the only live content-addressed blob
store before it opens routes, schedulers, workers, webhooks, reapers, or the
ready callback. The activation inventories the exact legacy SlateDB blob
prefix, checks disk headroom sized to the rows not yet imported (a warm
restart with nothing left to import only needs a small fixed headroom; on
filesystems whose free space cannot be determined the check is skipped with
a warning), imports in bounded transactions, compares every legacy blob
byte-for-byte with SQLite, runs a live SQLite integrity check, and attempts a
final WAL truncate checkpoint. A busy final truncate logs a warning and startup
continues so a later checkpoint can finish after the blocking reader exits.
Boots that import new rows additionally re-verify every
legacy blob against SQLite and validate every SQLite blob row independently.
Any failure stops startup. Warm boots that import no rows skip that full target
scan: the import pass has already byte-compared every retained legacy row, and
SQLite-only blobs are hash-validated when read. Rows committed by an interrupted
import are retained so the next startup can resume, but the legacy source is
never modified and there is no fallback or dual read/write path.
For a non-empty legacy inventory, the first activation also creates the
private sibling backup
`fabro.sqlite3.pre-blob-activation.bak`. Fabro writes the staging database
inside a private same-directory area, applies owner-only permissions, flushes
and validates it, then publishes the backup without overwriting an existing file.
A valid retained backup is revalidated on every warm restart and is preserved
as the original pre-activation safety artifact. If any legacy row is already
present in SQLite, a missing retained backup stops startup rather than silently
moving that rollback boundary forward. It is not a promise that an
older binary can safely resume after the activated server has accepted new
work; recovery after that boundary is forward-only. Empty legacy inventories
do not need this backup.
Keep both the unchanged legacy `blobs/sha256` prefix and the private activation
backup for at least 30 consecutive calendar days after the first successful
production activation. Cleanup is eligible only after a successful cold
activation, a later warm restart that revalidates the backup and byte-compares
every retained legacy blob against SQLite, and 30 days of production observation
with no unresolved inventory, import, verification, integrity, backup, or
checkpoint failure. Scott must review that evidence and explicitly authorize a
separate cleanup change. Day 30 is only the earliest eligibility date; nothing
is deleted automatically, and incomplete evidence extends the support window.
## Submitting runs
Workflows are submitted via the REST API and executed in the background. The exact request body is documented in the API reference:

View file

@ -198,6 +198,8 @@ keeps the results in input order. The source lookup is flat:
`context.candidates` checks that exact key and then `candidates`; it does not
traverse nested objects.
Each item can contribute up to 64 KiB of serialized JSON to its branch prompt. Fabro stores larger items as content-addressed blobs and replaces the inline item with its size, a readable file path, and a 300-character preview. The preview remains inside the untrusted-data fence, and the branch agent can read the file for the full item.
The template target must be an agent or prompt node, and nested `for_each` is
rejected. An empty source array succeeds with `parallel.results=[]` and skips
straight to `aggregate`. Missing, invalid, non-array, or over-long sources fail

View file

@ -33,10 +33,70 @@ digraph Example {
```
In this example:
- **spec** gets Haiku (matches `*`)
- **implement** and **test** get Sonnet with high reasoning (match `.coding`)
- **review** gets Gemini Pro (matches `#review`)
## Template stylesheets
The root graph's `model_stylesheet` is a [MiniJinja template](/workflows/variables). It can read typed run inputs and server-managed variables through `inputs` and `vars`:
```dot title="variable-effort.fabro"
digraph Review {
graph [
model_stylesheet="
* { reasoning_effort: low; }
{% if inputs.effort == 'deep' %}
.variable-effort { reasoning_effort: high; }
{% elif inputs.effort == 'balanced' %}
.variable-effort { reasoning_effort: medium; }
{% endif %}
"
]
triage [prompt="Triage the change"]
review [prompt="Review the change", class="variable-effort"]
}
```
Stylesheet templates support expressions, conditionals, loops, filters, macros, `{% set %}`, and normal local values such as `loop`. They do not expose `goal`, `env`, or `secrets`.
Fabro renders a stylesheet once. If an input or variable contains `{{ ... }}` or `{% ... %}`, that text stays literal. Fabro does not render it again.
Template output is not escaped as stylesheet syntax. Map user-facing choices to fixed declarations instead of inserting unrestricted text directly:
```dot
model_stylesheet="
{% set efforts = {'quick': 'low', 'thorough': 'high'} %}
.review { reasoning_effort: {{ efforts[inputs.review_mode] }}; }
"
```
Use single quotes inside MiniJinja expressions when possible. A double quote must follow normal DOT string escaping because the surrounding graph attribute uses double quotes. MiniJinja braces need no extra escaping inside the quoted DOT attribute.
Static template includes are supported and resolve relative to the workflow template root:
```dot
graph [model_stylesheet="{% include 'styles/models.partial' %}"]
```
Include paths must be literal. Dynamic or root-escaping include paths fail validation. `model_stylesheet` does not support the `@file` shorthand.
Fabro uses this order:
1. Parse the DOT source.
2. Expand workflow imports and supported file references.
3. Render the root `model_stylesheet` with `{ inputs, vars }`.
4. Parse and apply the rendered stylesheet.
5. Resolve model and provider selectors.
6. Validate the transformed graph.
A `model_stylesheet` on an imported graph is ignored and produces an `imported_model_stylesheet_ignored` warning. Put the stylesheet on the root graph. A root stylesheet can target imported nodes by their generated IDs, classes, or shapes.
If an input or variable is unavailable, `fabro validate` reports `template_undefined_variable`. It skips stylesheet syntax and model checks for that validation pass. Run-style commands treat the same diagnostic as an error before they create or start a run.
## Selectors
Each rule starts with a selector that determines which nodes it applies to:
@ -60,7 +120,7 @@ This node matches both `.coding` and `.critical` rules.
## Properties
Stylesheets support four properties:
Stylesheets support five properties:
| Property | Description | Example |
|---|---|---|

View file

@ -7,14 +7,94 @@ After each node finishes, Fabro must decide which edge to follow to the next nod
## How transitions work
When a node completes, it produces an **outcome** with a [stage outcome](/execution/outcomes) (`succeeded`, `failed`, `partially_succeeded`, or `skipped`) and optional signals like a preferred label or suggested next node. Fabro evaluates the outgoing edges in a fixed priority order:
When a node completes, it produces an **outcome** with a [stage outcome](/execution/outcomes) (`succeeded`, `failed`, `partially_succeeded`, or `skipped`) and optional signals like a preferred label or suggested next node. A node's retry policy runs before routing starts. Fabro then selects the next step in this order:
1. **Condition match** — Edges with a `condition` attribute are evaluated first. If one or more conditions match, the edge with the highest `weight` wins (lexical tiebreak on target node ID).
2. **Preferred label** — If the node's outcome includes a preferred label (e.g. from a human gate selection), the edge whose `label` matches is chosen.
3. **Suggested next** — If the node suggests a specific next node ID, the edge pointing to that node is chosen.
4. **Unconditional fallback** — Edges without conditions are considered last, again using `weight` then lexical tiebreak.
1. **Direct jump** — An outcome's `jump_to_node` value bypasses edge selection.
2. **Condition match** — Edges with a `condition` attribute are evaluated first. If one or more conditions match, the edge with the highest `weight` wins (lexical tiebreak on target node ID).
3. **Preferred label** — If the node's outcome includes a preferred label (for example, from a human gate selection), the edge whose `label` matches is chosen.
4. **Suggested next** — If the node suggests a specific next node ID, the edge pointing to that node is chosen.
5. **Failure policy** — For a failed outcome with no explicit route, the effective `on_failure` policy (node-level `on_failure` first, then graph-level) decides what happens next. `exit` skips the unconditional fallback. `succeed` promotes the outcome to `succeeded` and routes it as a success. `route` continues to the unconditional fallback.
6. **Unconditional fallback** — Edges without conditions are considered last, again using `weight` then lexical tiebreak.
7. **Retry target** — For a failed outcome with no selected edge, Fabro checks node-level and graph-level `retry_target` and `fallback_retry_target` values.
If no edge matches at all, the workflow halts with an error.
If no edge or retry target supplies a next node, the workflow ends. A failed node produces a failed run outcome.
## Failed-node routing policy
The `on_failure` attribute controls what happens to a failed node when no explicit recovery route matches:
| Policy | Effective outcome | Fallback routing |
|---|---|---|
| `route` (default) | stays `failed` | takes the unconditional edge |
| `exit` | stays `failed` | skips the unconditional edge; the run ends unless a retry target applies |
| `succeed` | becomes `succeeded` | uses normal success routing |
Set it at the graph level to apply the policy to every node, or on a node to control that node alone. A node-level `on_failure` overrides the graph level. A node without the attribute inherits the graph policy.
This lets a linear workflow stop at the first failed work node:
```dot title="stop-on-failure.fabro"
digraph Build {
graph [on_failure="exit"]
start [shape=Mdiamond]
exit [shape=Msquare]
plan [prompt="Plan the work"]
implement [prompt="Implement the plan"]
verify [prompt="Verify the implementation"]
start -> plan -> implement -> verify -> exit
}
```
Node-level overrides work in both directions. A strict graph can mark one best-effort node as `route` so its failure continues down the unconditional edge, and a default graph can mark one critical node as `exit`:
```dot title="mixed-policies.fabro"
digraph Build {
graph [on_failure="exit"]
start [shape=Mdiamond]
exit [shape=Msquare]
implement [prompt="Implement the change"]
lint [prompt="Run optional lint cleanup" on_failure="route"]
verify [prompt="Verify the implementation"]
start -> implement -> lint -> verify -> exit
}
```
Use `succeed` for a best-effort node whose failure must not block the workflow. Its failure becomes a `succeeded` outcome, so the node's normal success routing applies:
```dot title="best-effort-node.fabro"
digraph Review {
graph [on_failure="exit"]
start [shape=Mdiamond]
exit [shape=Msquare]
required_check [script="./required-check"]
optional_scan [script="./optional-scan" on_failure="succeed"]
start -> required_check -> optional_scan -> exit
}
```
Under `succeed`, Fabro first checks explicit routes against the original `failed` outcome. If a `condition="outcome=failed"` edge, a matching preferred label, a matching suggested next node, or a handler jump applies, the outcome stays `failed` and that route is taken. Otherwise Fabro rewrites the outcome to `succeeded` before it records the node, so goal gates, the run context, events, and routing all see the promoted outcome. Edge selection then runs again: `condition="outcome=succeeded"` edges and unconditional edges apply. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record which scope promoted it. A promoted outcome is not `failed`, so retry targets do not apply to it.
Both `exit` and `succeed` apply only to the `failed` outcome. They do not change routing for `succeeded`, `partially_succeeded`, or `skipped` outcomes.
Conditioned edges, matching preferred labels, and matching suggested node IDs are explicit recovery routes. They take priority under every policy. An unmatched preferred label or suggested node ID does not make an unconditional edge explicit.
Retry targets also remain available under `exit`. Fabro checks them after it skips the unconditional fallback. Use graph-level `max_node_visits` or node-level `max_visits` to bound workflows whose retry targets return to a failing path.
A failed human gate never falls through to an unconditional edge as a failure, regardless of policy. Node-level `on_failure="route"` does not change that; route an interrupted gate explicitly with `condition="outcome=failed"`. Under `succeed`, an interrupted gate with no explicit route is promoted like any other node and then follows its success routing.
When `exit` stops routing, Fabro checkpoints the failed node without a next node and ends the run as failed. It does not execute the graph's exit node or emit an edge selection for an edge it did not take. An explicit recovery route can still reach the exit node normally.
For a parallel node, `exit` and `succeed` see the final outcome returned by the parallel handler. `exit` can stop routing for a failed parallel outcome; `succeed` promotes it. Neither adds branch-level fail-fast behavior, and a `partially_succeeded` parallel outcome continues normally. Inside the fan-out, a branch node whose effective policy is `succeed` counts as `succeeded` in the parent's aggregate when it fails. Branches have no edge routing, so there is no explicit route to prefer.
<Note>
`auto_status=true` is the deprecated spelling of node-level `on_failure="succeed"`. Fabro still accepts it as an alias and validation warns with the replacement. See [Node Outcomes](/execution/outcomes#succeed-on-failure).
</Note>
## Edge attributes
@ -132,7 +212,7 @@ The `[A]`, `[R]`, `[S]` prefixes are keyboard accelerators — Fabro strips them
## Unconditional edges
An edge without a `condition` attribute always matches. When a node has a single outgoing edge, it doesn't need a condition:
An edge without a `condition` attribute is the normal fallback. When a node has a single outgoing edge, it doesn't need a condition:
```dot
start -> plan -> implement -> exit
@ -145,6 +225,8 @@ gate -> fast_path [condition="outcome=succeeded"]
gate -> slow_path
```
For a failed outcome, `on_failure="exit"` skips this fallback after explicit routes are checked, and `on_failure="succeed"` promotes the outcome to `succeeded` before taking it. The default `on_failure="route"` keeps the behavior shown above.
## Weight tiebreaking
When multiple edges match (e.g. two unconditional edges), `weight` determines the winner. Higher weight wins:

View file

@ -3,7 +3,7 @@ title: "Variables"
description: "Using templates in workflows"
---
Fabro renders `{{ ... }}` templates in exactly two workflow attributes: the graph `goal` and node `prompt`s. A command node's `script` gets narrower treatment — [simple value substitution](#command-node-scripts), not templating. Every other attribute is literal text.
Fabro renders full MiniJinja templates in three workflow attributes: the graph `goal`, the root graph's `model_stylesheet`, and node `prompt`s. A command node's `script` gets narrower treatment — [simple value substitution](#command-node-scripts), not templating. Every other attribute is literal text.
## Template context
@ -15,7 +15,9 @@ Goal templates can reference inputs and server-managed variables. Prompt templat
| `{{ inputs.name }}` | A value from `[run.inputs]`, optionally overridden by CLI input flags |
| `{{ vars.NAME }}` | A server-managed variable snapshotted when the run is created |
Secrets are **not** available in goal or prompt templates. Use `{{ secrets.NAME }}` only in the configuration fields that support run-boundary interpolation.
The root `model_stylesheet` receives only `inputs` and `vars`. It does not receive `goal`. See [Model Stylesheets](/workflows/stylesheets#template-stylesheets) for examples and output safety guidance.
Secrets are **not** available in goal, prompt, or model stylesheet templates. Use `{{ secrets.NAME }}` only in the configuration fields that support run-boundary interpolation.
## Run config inputs
@ -36,7 +38,7 @@ repo_url = "https://github.com/fabro-sh/fabro"
language = "rust"
```
These values are available in the graph `goal` and node `prompt` attributes:
These values are available in the graph `goal`, root `model_stylesheet`, and node `prompt` attributes:
```dot title="check.fabro"
digraph Check {
@ -51,7 +53,7 @@ digraph Check {
}
```
Other attributes — `label`, `model`, `provider`, `condition`, and all edge attributes — do not render templates. If one of them contains `{{ … }}` or `{% … %}`, the syntax is treated as literal text and Fabro records a `detemplated_attribute` warning suggesting you move the dynamic value into a `prompt` or `goal`.
Other attributes — `label`, `model`, `provider`, `condition`, and all edge attributes — do not render templates. If one of them contains `{{ … }}` or `{% … %}`, the syntax is treated as literal text and Fabro records a `detemplated_attribute` warning suggesting you move the dynamic value into a `prompt`, `goal`, or `model_stylesheet`.
Override individual inputs at run time with repeatable `-I` / `--input` flags:
@ -125,7 +127,7 @@ Use server-managed variables for non-sensitive values that should be shared acro
fabro variable set DEPLOY_ENV staging --description "Deployment target"
```
Run configuration strings, graph goals, and node prompts can reference these values with `{{ vars.NAME }}`:
Run configuration strings, graph goals, root model stylesheets, and node prompts can reference these values with `{{ vars.NAME }}`:
```toml title="workflow.toml"
_version = 1
@ -171,9 +173,10 @@ Fabro keeps workflow structure static and renders workflow templates once:
2. Literal `import`, `@file`, graph-goal file, and child-workflow references are resolved.
3. The graph `goal` is rendered with the `{ inputs, vars }` context.
4. Node `prompt` attributes are rendered with the `{ goal, inputs, vars }` context.
5. Node `script` attributes have their `{{ goal }}`, `{{ inputs.* }}`, and `{{ vars.* }}` values substituted.
5. The root `model_stylesheet` is rendered with the `{ inputs, vars }` context, then parsed and applied.
6. Node `script` attributes have their `{{ goal }}`, `{{ inputs.* }}`, and `{{ vars.* }}` values substituted.
Templates are not supported in graph syntax, node IDs, edge structure, `import` paths, `@file` paths, child workflow paths, other file references, or any attribute besides `prompt` and `goal` — and `script`, which takes value substitution rather than templates.
Templates are not supported in graph syntax, node IDs, edge structure, `import` paths, `@file` paths, child workflow paths, other file references, or any attribute besides `prompt`, `goal`, and the root `model_stylesheet` — and `script`, which takes value substitution rather than templates.
Command `stdin_source` values are literal context keys. Fabro resolves them at
stage execution time, after upstream nodes have updated the workflow context.
@ -188,9 +191,9 @@ In a `script`, an undefined value records the same diagnostic but leaves the tok
## Template includes
Prompt and goal templates support static MiniJinja loader dependencies such as `{% include "partial.md" %}`. Includes are resolved relative to the template file being rendered and can be nested.
Prompt, goal, and root model stylesheet templates support static MiniJinja loader dependencies such as `{% include "partial.md" %}`. Includes are resolved relative to the template file being rendered and can be nested.
Fabro discovers those static dependencies while building the run manifest so sandbox providers receive every required prompt file. Dynamic loader expressions such as `{% include inputs.partial %}` are rejected; use a literal include path and choose content with normal template conditionals instead.
Fabro discovers those static dependencies while building the run manifest so sandbox providers receive every required template file. Dynamic loader expressions such as `{% include inputs.partial %}` are rejected; use a literal include path and choose content with normal template conditionals instead.
## Escaping

View file

@ -22,7 +22,7 @@ fabro-auth = { path = "../../foundation/fabro-auth" }
fabro-config = { path = "../../foundation/fabro-config" }
fabro-environment = { path = "../../components/fabro-environment" }
fabro-llm = { path = "../../components/fabro-llm" }
fabro-model = { path = "../../foundation/fabro-model" }
fabro-model = { path = "../../foundation/fabro-model", features = ["clap"] }
fabro-oauth = { path = "../../foundation/fabro-oauth" }
fabro-github = { path = "../../components/fabro-github" }
fabro-agent = { path = "../../components/fabro-agent" }

View file

@ -5,6 +5,7 @@ use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use fabro_agent::cli::AgentArgs;
use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer};
use fabro_model::ReasoningEffort;
use fabro_server::serve::DEFAULT_TCP_PORT;
use fabro_static::EnvVars;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
@ -190,8 +191,13 @@ pub(crate) struct McpStartArgs {
pub(crate) connection: ServerConnectionArgs,
}
#[derive(Args, Debug, Clone, Default)]
#[derive(Args, Debug, Clone)]
pub(crate) struct McpConfigArgs {
/// Name of the mcpServers entry; use distinct names to register multiple
/// Fabro servers
#[arg(long, value_name = "NAME", default_value = fabro_mcp_server::SERVER_NAME, value_parser = clap::builder::NonEmptyStringValueParser::new())]
pub(crate) name: String,
#[command(flatten)]
pub(crate) connection: ServerConnectionArgs,
}
@ -201,7 +207,7 @@ pub(crate) struct McpInitArgs {
pub(crate) agent: McpAgent,
#[command(flatten)]
pub(crate) connection: ServerConnectionArgs,
pub(crate) config: McpConfigArgs,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
@ -1091,9 +1097,13 @@ pub(crate) struct ModelTestArgs {
)]
pub(crate) jobs: usize,
/// Run a multi-turn tool-use test (catches reasoning round-trip bugs)
#[arg(long)]
pub(crate) deep: bool,
/// Run a multi-turn tool-use test
#[arg(long, alias = "deep")]
pub(crate) tools: bool,
/// Request a reasoning-effort level
#[arg(long, value_enum)]
pub(crate) reasoning_effort: Option<ReasoningEffort>,
}
#[derive(Args)]

View file

@ -2,7 +2,9 @@ use std::fmt::Write as _;
use anyhow::{Context as _, Result};
use crate::args::{McpAgent, McpCommand, McpNamespace, ServerConnectionArgs};
use crate::args::{
McpAgent, McpCommand, McpConfigArgs, McpInitArgs, McpNamespace, ServerConnectionArgs,
};
use crate::command_context::CommandContext;
use crate::server_client;
@ -12,12 +14,12 @@ pub(crate) async fn dispatch(ns: McpNamespace, base_ctx: &CommandContext) -> Res
fabro_mcp_server::start(server_settings(base_ctx, &args.connection)?).await
}
McpCommand::Config(args) => {
let json = fabro_mcp_server::config_json(&config_settings(&args.connection))?;
let json = fabro_mcp_server::config_json(&config_settings(&args))?;
let _ = write!(base_ctx.printer().stdout_important(), "{json}");
Ok(())
}
McpCommand::Init(args) => {
fabro_mcp_server::init_agent(&init_settings(args.agent, &args.connection)?)?;
fabro_mcp_server::init_agent(&init_settings(&args)?)?;
Ok(())
}
}
@ -56,21 +58,19 @@ fn server_settings(
})
}
fn init_settings(
agent: McpAgent,
connection: &ServerConnectionArgs,
) -> Result<fabro_mcp_server::McpInitSettings> {
fn init_settings(args: &McpInitArgs) -> Result<fabro_mcp_server::McpInitSettings> {
Ok(fabro_mcp_server::McpInitSettings {
agent: McpAgentForServer(agent).into(),
config: config_settings(connection),
agent: McpAgentForServer(args.agent).into(),
config: config_settings(&args.config),
home_dir: home_dir()?,
})
}
fn config_settings(connection: &ServerConnectionArgs) -> fabro_mcp_server::McpConfigSettings {
fn config_settings(args: &McpConfigArgs) -> fabro_mcp_server::McpConfigSettings {
fabro_mcp_server::McpConfigSettings {
server: connection.target.server.clone(),
storage_dir: connection.storage_dir.clone_path(),
name: args.name.clone(),
server: args.connection.target.server.clone(),
storage_dir: args.connection.storage_dir.clone_path(),
}
}

View file

@ -254,14 +254,15 @@ fn model_test_row_from_status(model: &Model, status: &str, result_color: Color)
)]
async fn test_models_via_server(
client: &server_client::Client,
provider: Option<&str>,
model: Option<&str>,
deep: bool,
jobs: usize,
args: &ModelTestArgs,
styles: &Styles,
json_output: bool,
) -> Result<()> {
let request_mode = deep.then_some(ModelTestMode::Deep);
let provider = args.provider.as_deref();
let model = args.model.as_deref();
let jobs = args.jobs;
let reasoning_effort = args.reasoning_effort;
let request_mode = args.tools.then_some(ModelTestMode::Deep);
let use_color = styles.use_color;
let mut title = models_title(use_color);
@ -288,7 +289,12 @@ async fn test_models_via_server(
} else {
Some(
client
.test_model(model_id, requested_provider.as_ref(), request_mode)
.test_model(
model_id,
requested_provider.as_ref(),
request_mode,
reasoning_effort,
)
.await,
)
};
@ -375,7 +381,12 @@ async fn test_models_via_server(
let client = client.clone();
async move {
let result = client
.test_model(info.id.as_str(), Some(&info.provider), request_mode)
.test_model(
info.id.as_str(),
Some(&info.provider),
request_mode,
reasoning_effort,
)
.await;
if !json_output {
eprintln!("Testing {}... done", info.id);
@ -486,23 +497,8 @@ async fn run_models(
print_models_table(&models, &styles);
}
}
ModelsCommand::Test(ModelTestArgs {
provider,
model,
deep,
jobs,
..
}) => {
test_models_via_server(
client,
provider.as_deref(),
model.as_deref(),
deep,
jobs,
&styles,
json_output,
)
.await?;
ModelsCommand::Test(args) => {
test_models_via_server(client, &args, &styles, json_output).await?;
}
}
@ -518,7 +514,8 @@ impl Default for ModelsCommand {
#[cfg(test)]
mod tests {
use fabro_model::{
ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature,
ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffort,
ReasoningEffortFeature,
};
use super::*;
@ -674,20 +671,24 @@ mod tests {
.await;
let client = test_client(&server.url(""));
let response = client.test_model("test-model", None, None).await.unwrap();
let response = client
.test_model("test-model", None, None, None)
.await
.unwrap();
assert_eq!(response.status, api_types::ModelTestResultStatus::Ok);
assert!(response.error_message.is_none());
}
#[tokio::test]
async fn test_model_via_server_passes_mode_and_parses_error() {
async fn test_model_via_server_passes_mode_and_reasoning_effort() {
let server = httpmock::MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method("POST")
.path("/api/v1/models/test-model/test")
.query_param("mode", "deep");
.query_param("mode", "deep")
.query_param("reasoning_effort", "high");
then.status(200)
.header("Content-Type", "application/json")
.body(
@ -704,7 +705,12 @@ mod tests {
let client = test_client(&server.url(""));
let response = client
.test_model("test-model", None, Some(ModelTestMode::Deep))
.test_model(
"test-model",
None,
Some(ModelTestMode::Deep),
Some(ReasoningEffort::High),
)
.await
.unwrap();
@ -732,7 +738,10 @@ mod tests {
.await;
let client = test_client(&server.url(""));
let response = client.test_model("kimi-k2.5", None, None).await.unwrap();
let response = client
.test_model("kimi-k2.5", None, None, None)
.await
.unwrap();
assert_eq!(response.status, api_types::ModelTestResultStatus::Skip);
assert!(response.error_message.is_none());
@ -756,7 +765,7 @@ mod tests {
.await;
let client = test_client(&server.url(""));
let result = client.test_model("bad-model", None, None).await;
let result = client.test_model("bad-model", None, None, None).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Model not found"));
}
@ -800,17 +809,14 @@ mod tests {
let client = test_client(&server.url(""));
test_models_via_server(
&client,
None,
Some("venice-large"),
false,
1,
&Styles::new(false),
true,
)
.await
.unwrap();
let args = ModelTestArgs {
model: Some("venice-large".to_string()),
jobs: 1,
..ModelTestArgs::default()
};
test_models_via_server(&client, &args, &Styles::new(false), true)
.await
.unwrap();
}
#[tokio::test]
@ -871,12 +877,13 @@ mod tests {
})
.await;
let args = ModelTestArgs {
jobs: 2,
..ModelTestArgs::default()
};
test_models_via_server(
&test_client(&server.url("")),
None,
None,
false,
2,
&args,
&Styles::new(false),
true,
)

View file

@ -844,6 +844,7 @@ mod tests {
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
source_directory: None,
labels: std::collections::HashMap::default(),

View file

@ -1029,10 +1029,13 @@ mod tests {
emit(
&mut ui,
agent_event("plan", AgentEvent::ToolCallCompleted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
output: serde_json::json!({"ok": true}),
is_error: false,
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
output: serde_json::json!({"ok": true}),
is_error: false,
output_bytes_observed: 11,
output_bytes_retained: 11,
output_bytes_omitted: 0,
}),
);
emit(&mut ui, stage_completed("plan", "Plan"));
@ -1570,10 +1573,13 @@ mod tests {
let tool_completed = serde_json::to_string(&to_run_event_at(
&fixtures::RUN_1,
&agent_event("code", AgentEvent::ToolCallCompleted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
output: serde_json::json!({"ok": true}),
is_error: false,
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
output: serde_json::json!({"ok": true}),
is_error: false,
output_bytes_observed: 11,
output_bytes_retained: 11,
output_bytes_omitted: 0,
}),
completed_ts,
None,

View file

@ -304,7 +304,7 @@ async fn main_inner(worker_token: Option<String>) -> (String, Result<()>) {
commands::dump::run(&args, &base_ctx).await?;
}
Commands::RunsCmd(cmd) => {
commands::runs::dispatch(cmd, &base_ctx).await?;
Box::pin(commands::runs::dispatch(cmd, &base_ctx)).await?;
}
Commands::Model { command } => {
commands::model::execute(command, &base_ctx).await?;

View file

@ -122,10 +122,11 @@ fn config_help() {
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
--name <NAME> Name of the mcpServers entry; use distinct names to register multiple Fabro servers [default: fabro]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
@ -151,10 +152,11 @@ fn init_help() {
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
--name <NAME> Name of the mcpServers entry; use distinct names to register multiple Fabro servers [default: fabro]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
@ -221,6 +223,55 @@ fn config_preserves_connection_flags() {
"#);
}
#[test]
fn config_uses_custom_entry_name() {
let context = test_context!();
let mut cmd = context.command();
cmd.args([
"mcp",
"config",
"--name",
"fabro-production",
"--server",
"https://fabro.example.test",
]);
fabro_snapshot!(context.filters(), cmd, @r#"
success: true
exit_code: 0
----- stdout -----
{
"mcpServers": {
"fabro-production": {
"command": "fabro",
"args": [
"mcp",
"start",
"--server",
"https://fabro.example.test"
]
}
}
}
----- stderr -----
"#);
}
#[test]
fn config_rejects_empty_entry_name() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["mcp", "config", "--name", ""]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: a value is required for '--name <NAME>' but none was supplied
For more information, try '--help'.
");
}
#[test]
fn init_cursor_writes_idempotent_config() {
let context = test_context!();
@ -417,6 +468,91 @@ fn init_preserves_existing_servers() {
"#);
}
#[test]
fn init_merges_multiple_named_fabro_entries() {
let context = test_context!();
context
.command()
.args(["mcp", "init", "cursor"])
.assert()
.success();
context
.command()
.args([
"mcp",
"init",
"cursor",
"--name",
"fabro-production",
"--server",
"https://production.example.test",
])
.assert()
.success();
context
.command()
.args([
"mcp",
"init",
"cursor",
"--name",
"fabro-testing",
"--server",
"https://testing.example.test",
])
.assert()
.success();
// Reusing a name updates only that entry.
context
.command()
.args([
"mcp",
"init",
"cursor",
"--name",
"fabro-production",
"--server",
"https://production.example.test:8443",
])
.assert()
.success();
let config_path = context.home_dir.join(".cursor").join("mcp.json");
let config: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(config_path).unwrap()).unwrap();
fabro_json_snapshot!(context, config, @r#"
{
"mcpServers": {
"fabro": {
"command": "fabro",
"args": [
"mcp",
"start"
]
},
"fabro-production": {
"command": "fabro",
"args": [
"mcp",
"start",
"--server",
"https://production.example.test:8443"
]
},
"fabro-testing": {
"command": "fabro",
"args": [
"mcp",
"start",
"--server",
"https://testing.example.test"
]
}
}
}
"#);
}
#[test]
fn init_invalid_json_fails_without_overwrite() {
let context = test_context!();

View file

@ -86,21 +86,81 @@ fn help() {
Usage: fabro model test [OPTIONS]
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-p, --provider <PROVIDER> Filter by provider
-m, --model <MODEL> Test a specific model
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
-j, --jobs <JOBS> Number of model tests to run concurrently in bulk mode [default: 4]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--deep Run a multi-turn tool-use test (catches reasoning round-trip bugs)
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
--json
Output as JSON [env: FABRO_JSON=]
--server <SERVER>
Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug
Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-p, --provider <PROVIDER>
Filter by provider
-m, --model <MODEL>
Test a specific model
--no-upgrade-check
Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
-j, --jobs <JOBS>
Number of model tests to run concurrently in bulk mode [default: 4]
--quiet
Suppress non-essential output [env: FABRO_QUIET=]
--tools
Run a multi-turn tool-use test
--verbose
Enable verbose output [env: FABRO_VERBOSE=]
--reasoning-effort <REASONING_EFFORT>
Request a reasoning-effort level [possible values: low, medium, high, xhigh, max]
-h, --help
Print help
----- stderr -----
");
}
fn assert_model_test_forwards(cli_args: &[&str], expected_query: &[(&str, &str)]) {
let context = test_context!();
let server = MockServer::start();
context.set_http_target(&server.base_url());
let list = mock_model_list(&server, [model_json("test-model", "anthropic", true)]);
let test = server.mock(|when, then| {
let mut when = when.method("POST").path("/api/v1/models/test-model/test");
for (name, value) in expected_query {
when = when.query_param(*name, *value);
}
then.status(200)
.header("Content-Type", "application/json")
.json_body(serde_json::json!({
"model_id": "test-model",
"provider": "anthropic",
"status": "ok"
}));
});
let mut cmd = context.command();
cmd.args(["model", "test", "--model", "test-model"]);
cmd.args(cli_args);
let output = cmd.output().expect("command should execute");
assert!(
output.status.success(),
"model test should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
list.assert();
test.assert();
}
#[test]
fn model_test_tools_and_reasoning_effort_are_forwarded() {
assert_model_test_forwards(&["--tools", "--reasoning-effort", "low"], &[
("mode", "deep"),
("reasoning_effort", "low"),
]);
}
#[test]
fn model_test_deep_remains_an_alias_for_tools() {
assert_model_test_forwards(&["--deep"], &[("mode", "deep")]);
}
#[test]
fn model_test_unknown_model_errors() {
let context = test_context!();

View file

@ -76,9 +76,9 @@ fn preflight_rejects_unbound_template_inputs() {
Goal: Demo
error: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
error: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
× Validation failed
");
}

View file

@ -199,9 +199,27 @@ fn bare_fabro_with_unbound_inputs_validates_structurally_with_warning() {
Workflow: TemplatedUnbound (3 nodes, 2 edges)
Graph: [FIXTURES]/templated_unbound.fabro
warning: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
warning: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
Validation: OK
");
}
#[test]
fn unbound_model_stylesheet_input_warns_without_css_error() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("model_stylesheet_unbound.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: ModelStylesheetUnbound (3 nodes, 2 edges)
Graph: [FIXTURES]/model_stylesheet_unbound.fabro
warning: [FIXTURES]/model_stylesheet_unbound.fabro:4:38: undefined template variable `inputs.effort` in graph attribute `model_stylesheet` (template_undefined_variable)
fix: bind `effort` via `[run.inputs]` in workflow.toml, or pass `--input effort=<value>`
Validation: OK
");
}
@ -224,7 +242,7 @@ fn bare_fabro_with_unbound_inputs_in_imported_prompt_validates_structurally_with
Workflow: TemplatedUnboundImported (3 nodes, 2 edges)
Graph: [FIXTURES]/templated_unbound_imported/workflow.fabro
warning: [FIXTURES]/templated_unbound_imported/work.md:1:12: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
Validation: OK
");
}
@ -246,7 +264,7 @@ fn bare_fabro_with_unbound_inputs_in_template_partial_validates_structurally_wit
Workflow: TemplatedUnboundPartial (3 nodes, 2 edges)
Graph: [FIXTURES]/templated_unbound_partial/workflow.fabro
warning: [FIXTURES]/templated_unbound_partial/test-include.partial.md:1:4: undefined template variable `inputs.hello` in node `test_imported_include` attribute `prompt` [node: test_imported_include] (template_undefined_variable)
fix: bind `inputs.hello` via `[run.inputs]` in workflow.toml, or pass `--input inputs.hello=<value>`
fix: bind `hello` via `[run.inputs]` in workflow.toml, or pass `--input hello=<value>`
Validation: OK
");
}
@ -358,3 +376,57 @@ fn invalid() {
× Validation failed
");
}
#[test]
fn invalid_node_on_failure_is_a_validation_failure() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("on_failure_node_invalid.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Workflow: InvalidNodeOnFailure (3 nodes, 2 edges)
Graph: [FIXTURES]/on_failure_node_invalid.fabro
error [node: work]: Node 'work' has invalid on_failure value 'stop' (on_failure_valid)
fix: Use one of: route, exit, succeed
× Validation failed
");
}
#[test]
fn deprecated_auto_status_warns_with_succeed_policy_replacement() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("auto_status_deprecated.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: DeprecatedAutoStatus (3 nodes, 2 edges)
Graph: [FIXTURES]/auto_status_deprecated.fabro
warning [node: scan]: Node 'scan' sets deprecated 'auto_status=true' (auto_status_deprecated)
fix: Use on_failure=\"succeed\" instead
Validation: OK
");
}
#[test]
fn invalid_on_failure_is_a_validation_failure() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("on_failure_invalid.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Workflow: InvalidOnFailure (2 nodes, 1 edges)
Graph: [FIXTURES]/on_failure_invalid.fabro
error: Graph has invalid on_failure value 'stop' (on_failure_valid)
fix: Use one of: route, exit, succeed
× Validation failed
");
}

View file

@ -48,6 +48,7 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
graph_source: None,
workflow_slug: Some("remote-workflow".to_string()),
workflow_version_id: None,
target: None,
automation: None,
source_directory: Some("/srv/repo".to_string()),
labels: std::collections::HashMap::default(),

View file

@ -9,7 +9,7 @@ use anyhow::{Context as _, Result, anyhow};
use serde_json::map::Entry;
use serde_json::{Map, Value, json};
use crate::{McpAgent, McpConfigSettings, McpInitSettings, SERVER_NAME};
use crate::{McpAgent, McpConfigSettings, McpInitSettings};
pub fn config_json(settings: &McpConfigSettings) -> Result<String> {
serde_json::to_string_pretty(&generic_config(settings))
@ -18,19 +18,16 @@ pub fn config_json(settings: &McpConfigSettings) -> Result<String> {
}
pub fn init_agent(settings: &McpInitSettings) -> Result<()> {
let entry = server_entry(&settings.config);
for path in agent_config_paths(settings.agent, &settings.home_dir) {
merge_server_entry(&path, entry.clone())?;
merge_server_entry(&path, &settings.config)?;
}
Ok(())
}
fn generic_config(settings: &McpConfigSettings) -> Value {
json!({
"mcpServers": {
SERVER_NAME: server_entry(settings)
}
})
let mut servers = Map::new();
servers.insert(settings.name.clone(), server_entry(settings));
json!({ "mcpServers": servers })
}
fn server_entry(settings: &McpConfigSettings) -> Value {
@ -53,7 +50,7 @@ fn start_args(settings: &McpConfigSettings) -> Vec<String> {
args
}
fn merge_server_entry(path: &Path, entry: Value) -> Result<()> {
fn merge_server_entry(path: &Path, settings: &McpConfigSettings) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
@ -80,7 +77,7 @@ fn merge_server_entry(path: &Path, entry: Value) -> Result<()> {
path.display()
)
})?;
servers_object.insert(SERVER_NAME.to_string(), entry);
servers_object.insert(settings.name.clone(), server_entry(settings));
let rendered = serde_json::to_string_pretty(&root)
.map(|json| format!("{json}\n"))

View file

@ -13,9 +13,9 @@ pub use config::{config_json, init_agent};
use fabro_client::Client;
pub use server::start;
/// The name this MCP server reports over the wire and registers under in agent
/// config files.
pub(crate) const SERVER_NAME: &str = "fabro";
/// The name this MCP server reports over the wire. It is also the default
/// `mcpServers` key that `fabro mcp config` and `fabro mcp init` register.
pub const SERVER_NAME: &str = "fabro";
pub type FabroClientFuture = Pin<Box<dyn Future<Output = Result<Client>> + Send>>;
@ -39,8 +39,10 @@ impl std::fmt::Debug for FabroMcpServerSettings {
}
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
pub struct McpConfigSettings {
/// The `mcpServers` key the generated client entry is registered under.
pub name: String,
pub server: Option<String>,
pub storage_dir: Option<PathBuf>,
}

View file

@ -10,7 +10,7 @@ description = "HTTP server for Fabro pipelines"
doctest = false
[features]
test-support = []
test-support = ["fabro-store/test-support"]
[[test]]
name = "it"
@ -74,6 +74,7 @@ tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml = "0.9"
sqlx.workspace = true
anyhow.workspace = true
async-trait.workspace = true
async_zip.workspace = true
@ -112,12 +113,12 @@ chrono = { workspace = true }
[dev-dependencies]
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
git2.workspace = true
tokio = { workspace = true, features = ["test-util", "macros"] }
tower = "0.5"
http-body-util = "0.1"
httpmock = "0.8"
serde_yaml = "0.9"
sqlx.workspace = true
tracing-subscriber.workspace = true
tokio-util.workspace = true
tokio-tungstenite.workspace = true

View file

@ -0,0 +1,818 @@
//! Fail-closed activation of SQLite blob storage.
//!
//! This compatibility bridge remains until at least 30 calendar days after
//! the first successful production activation, and until the cold-start,
//! warm-restart, production-observation, and backup-integrity evidence is
//! complete and Scott explicitly approves its removal. The date is an
//! eligibility floor, never an automatic deletion trigger.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use futures_util::TryStreamExt as _;
use object_store::ObjectStore;
use sqlx::Connection as _;
use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection};
use tokio::fs;
use tokio::task::{JoinError, spawn_blocking};
use tracing::{debug, info, warn};
use crate::server::resource_sampler;
/// Earliest date this bridge becomes eligible for removal, assuming the first
/// production activation happens no earlier than this change ships. Removal
/// additionally requires the evidence and explicit approval described in the
/// module docs; the date alone never triggers deletion.
pub(crate) const REMOVAL_DEADLINE: &str = "2026-09-22";
const DISK_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
const BACKUP_SUFFIX: &str = ".pre-blob-activation.bak";
const STAGING_SUFFIX: &str = ".tmp";
#[derive(Debug, thiserror::Error)]
pub(crate) enum BlobActivationError {
#[error("canonicalizing the SQLite database path {path}")]
Canonicalize {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("inventorying the legacy blob source")]
Inventory(#[source] fabro_store::LegacyBlobInventoryError),
#[error("reading activation backup metadata at {path}")]
BackupMetadata {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("activation backup is not a regular file at {path}")]
BackupNotRegular { path: PathBuf },
#[error("activation backup permissions are not private at {path}")]
BackupNotPrivate { path: PathBuf },
#[error(
"activation backup is missing at {path} while {existing_rows} of {legacy_rows} legacy blob rows are already present in SQLite"
)]
MissingBackupAfterImport {
path: PathBuf,
legacy_rows: u64,
existing_rows: u64,
},
#[error("opening or checking activation backup integrity at {path}")]
BackupIntegrity {
path: PathBuf,
#[source]
source: sqlx::Error,
},
#[error("activation backup integrity check did not return exactly one ok result at {path}")]
BackupIntegrityFailed { path: PathBuf },
#[error("reading SQLite file metadata at {path}")]
SqliteMetadata {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("the blob activation disk requirement overflowed")]
DiskRequirementOverflow,
#[error(
"insufficient disk space for blob activation: {available_bytes} bytes available, {required_bytes} required"
)]
InsufficientDisk {
required_bytes: u64,
available_bytes: u64,
},
#[error("staging the pre-activation SQLite backup")]
StageBackup(#[source] fabro_db::SnapshotStagingError),
#[error("joining the activation backup publication task")]
JoinBackupPublication(#[source] JoinError),
#[error("publishing the activation backup at {path} without overwriting")]
PublishBackup {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("importing legacy blobs into SQLite")]
Import(#[source] Box<fabro_store::LegacyBlobImportError>),
#[error("verifying legacy and SQLite blobs")]
Verification(#[source] Box<fabro_store::LegacyBlobVerificationError>),
#[error("running the live SQLite integrity check")]
LiveIntegrity(#[source] sqlx::Error),
#[error("the live SQLite integrity check did not return exactly one ok result")]
LiveIntegrityFailed,
#[error("running the final SQLite WAL truncate checkpoint")]
FinalCheckpoint(#[source] sqlx::Error),
}
pub(crate) async fn activate_blob_storage(
database: &fabro_db::Database,
sqlite_path: &Path,
object_store: Arc<dyn ObjectStore>,
slatedb_prefix: String,
flush_interval: Duration,
cache_path: Option<PathBuf>,
) -> Result<Arc<fabro_store::Database>, BlobActivationError> {
let canonical_path = fs::canonicalize(sqlite_path).await.map_err(|source| {
BlobActivationError::Canonicalize {
path: sqlite_path.to_path_buf(),
source,
}
})?;
let backup_path = fabro_db::append_to_path(&canonical_path, BACKUP_SUFFIX);
info!(
database_path = %canonical_path.display(),
backup_path = %backup_path.display(),
"Starting SQLite blob storage activation"
);
let blob_store = Arc::new(fabro_store::BlobStore::new(database.clone_pool()));
let run_summary_store = Arc::new(fabro_store::RunSummaryStore::new(database.clone_pool()));
let store = Arc::new(fabro_store::Database::new(
object_store,
slatedb_prefix,
flush_interval,
cache_path,
Arc::clone(&blob_store),
run_summary_store,
));
let inventory = store
.legacy_blob_inventory(database.pool())
.await
.map_err(BlobActivationError::Inventory)?;
let backup_exists = backup_exists(&backup_path).await?;
if backup_exists {
validate_backup(&backup_path).await?;
}
if !backup_exists && inventory.pending_rows < inventory.rows {
return Err(BlobActivationError::MissingBackupAfterImport {
path: backup_path,
legacy_rows: inventory.rows,
existing_rows: inventory.rows - inventory.pending_rows,
});
}
let backup_required = inventory.rows > 0 && !backup_exists;
// The resource sampler treats a path with no matching mount as an
// unsupported-but-benign condition (tmpfs or squashfs roots, network
// filesystems, an unreadable mount table), so the preflight does too:
// skipping the capacity check must not block a boot the import itself
// could complete.
if let Some(available_free_bytes) = resource_sampler::available_space_for_path(&canonical_path)
{
let backup_reserve = if backup_required {
sqlite_file_set_bytes(&canonical_path).await?
} else {
0
};
// Only the rows the import still has to copy need new space; rows
// already present in SQLite cost nothing on a warm restart.
let required_free_bytes = compute_disk_preflight(
inventory.pending_bytes,
backup_reserve,
available_free_bytes,
)?;
debug!(
legacy_rows = inventory.rows,
legacy_bytes = inventory.bytes,
pending_rows = inventory.pending_rows,
pending_bytes = inventory.pending_bytes,
backup_required,
backup_reserve,
required_free_bytes,
available_free_bytes,
"Checked SQLite blob activation disk capacity"
);
} else {
warn!(
database_path = %canonical_path.display(),
"No filesystem mount matched the SQLite database path; skipping the blob activation disk preflight"
);
}
let retained_backup = if backup_exists {
Some(backup_path)
} else if backup_required {
create_backup(database.pool(), &backup_path).await?;
Some(backup_path)
} else {
None
};
let import = store
.import_legacy_blobs_into(database.pool())
.await
.map_err(|source| BlobActivationError::Import(Box::new(source)))?;
// The import pass already validates every legacy digest and byte-compares
// every already-present row on each boot, so the independent verification
// sweep only needs to double-check boots that actually inserted rows.
let verification = if import.imported_rows > 0 {
Some(
store
.verify_legacy_blobs_in(database.pool())
.await
.map_err(|source| BlobActivationError::Verification(Box::new(source)))?,
)
} else {
None
};
validate_live_integrity(database.pool()).await?;
final_truncate_checkpoint(database.pool()).await?;
info!(
legacy_rows = inventory.rows,
legacy_bytes = inventory.bytes,
imported_rows = import.imported_rows,
existing_rows = import.existing_rows,
matched_rows = verification.as_ref().map(|report| report.matched_rows),
target_rows = verification.as_ref().map(|report| report.target_rows),
passive_checkpoints = import.passive_checkpoints,
backup_required,
backup_path = ?retained_backup,
removal_deadline = REMOVAL_DEADLINE,
"Activated SQLite blob storage"
);
Ok(store)
}
/// Fail-closed disk capacity check; returns the required free bytes.
fn compute_disk_preflight(
pending_bytes: u64,
backup_reserve: u64,
available_free_bytes: u64,
) -> Result<u64, BlobActivationError> {
let half = pending_bytes
.checked_add(1)
.ok_or(BlobActivationError::DiskRequirementOverflow)?
/ 2;
let required_free_bytes = backup_reserve
.checked_add(pending_bytes)
.and_then(|value| value.checked_add(half))
.and_then(|value| value.checked_add(DISK_HEADROOM_BYTES))
.ok_or(BlobActivationError::DiskRequirementOverflow)?;
if available_free_bytes < required_free_bytes {
return Err(BlobActivationError::InsufficientDisk {
required_bytes: required_free_bytes,
available_bytes: available_free_bytes,
});
}
Ok(required_free_bytes)
}
async fn backup_exists(path: &Path) -> Result<bool, BlobActivationError> {
match fs::metadata(path).await {
Ok(_) => Ok(true),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(source) => Err(BlobActivationError::BackupMetadata {
path: path.to_path_buf(),
source,
}),
}
}
async fn sqlite_file_set_bytes(path: &Path) -> Result<u64, BlobActivationError> {
let mut total = required_file_bytes(path).await?;
for suffix in ["-wal", "-shm"] {
let sibling = fabro_db::append_to_path(path, suffix);
let bytes = optional_file_bytes(&sibling).await?;
total = total
.checked_add(bytes)
.ok_or(BlobActivationError::DiskRequirementOverflow)?;
}
Ok(total)
}
async fn required_file_bytes(path: &Path) -> Result<u64, BlobActivationError> {
fs::metadata(path)
.await
.map(|metadata| metadata.len())
.map_err(|source| BlobActivationError::SqliteMetadata {
path: path.to_path_buf(),
source,
})
}
async fn optional_file_bytes(path: &Path) -> Result<u64, BlobActivationError> {
match fs::metadata(path).await {
Ok(metadata) => Ok(metadata.len()),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(source) => Err(BlobActivationError::SqliteMetadata {
path: path.to_path_buf(),
source,
}),
}
}
async fn create_backup(
pool: &sqlx::SqlitePool,
backup_path: &Path,
) -> Result<(), BlobActivationError> {
let staging_path = fabro_db::append_to_path(backup_path, STAGING_SUFFIX);
fabro_db::write_snapshot_to_staging(pool, &staging_path)
.await
.map_err(BlobActivationError::StageBackup)?;
validate_backup(&staging_path).await?;
let publish_staging = staging_path.clone();
let publish_backup = backup_path.to_path_buf();
let already_exists = spawn_blocking(move || {
let staging = tempfile::TempPath::from_path(publish_staging);
match staging.persist_noclobber(&publish_backup) {
Ok(()) => {
// Make the rename's directory entry durable: the retained
// backup is the documented rollback artifact, so it must not
// vanish in a crash after the import has already committed.
fabro_db::sync_parent_directory(&publish_backup)?;
Ok(false)
}
Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(true),
Err(error) => Err(error.error),
}
})
.await
.map_err(BlobActivationError::JoinBackupPublication)?
.map_err(|source| BlobActivationError::PublishBackup {
path: backup_path.to_path_buf(),
source,
})?;
// The staging copy was validated just before the atomic rename, so only a
// concurrently published file still needs its own validation.
if already_exists {
debug!(
backup_path = %backup_path.display(),
"Reusing concurrently published SQLite blob activation backup"
);
validate_backup(backup_path).await?;
}
Ok(())
}
async fn validate_backup(path: &Path) -> Result<(), BlobActivationError> {
let metadata =
fs::symlink_metadata(path)
.await
.map_err(|source| BlobActivationError::BackupMetadata {
path: path.to_path_buf(),
source,
})?;
if !metadata.is_file() {
return Err(BlobActivationError::BackupNotRegular {
path: path.to_path_buf(),
});
}
validate_private_permissions(path, &metadata)?;
let options = SqliteConnectOptions::new()
.filename(path)
.read_only(true)
.immutable(true)
.create_if_missing(false);
let mut connection = SqliteConnection::connect_with(&options)
.await
.map_err(|source| BlobActivationError::BackupIntegrity {
path: path.to_path_buf(),
source,
})?;
let ok = integrity_check_is_ok(&mut connection)
.await
.map_err(|source| BlobActivationError::BackupIntegrity {
path: path.to_path_buf(),
source,
})?;
if !ok {
return Err(BlobActivationError::BackupIntegrityFailed {
path: path.to_path_buf(),
});
}
Ok(())
}
/// Returns whether `PRAGMA integrity_check` reports exactly one `ok` row.
async fn integrity_check_is_ok<'a, E>(executor: E) -> Result<bool, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Sqlite>,
{
let mut rows = sqlx::query_scalar::<_, String>("PRAGMA integrity_check").fetch(executor);
let first = rows.try_next().await?;
let second = rows.try_next().await?;
Ok(first.as_deref() == Some("ok") && second.is_none())
}
#[cfg(unix)]
fn validate_private_permissions(
path: &Path,
metadata: &std::fs::Metadata,
) -> Result<(), BlobActivationError> {
use std::os::unix::fs::PermissionsExt as _;
if metadata.permissions().mode() & 0o077 != 0 {
return Err(BlobActivationError::BackupNotPrivate {
path: path.to_path_buf(),
});
}
Ok(())
}
#[cfg(not(unix))]
fn validate_private_permissions(
_path: &Path,
_metadata: &std::fs::Metadata,
) -> Result<(), BlobActivationError> {
Ok(())
}
async fn validate_live_integrity(pool: &sqlx::SqlitePool) -> Result<(), BlobActivationError> {
let ok = integrity_check_is_ok(pool)
.await
.map_err(BlobActivationError::LiveIntegrity)?;
if !ok {
return Err(BlobActivationError::LiveIntegrityFailed);
}
Ok(())
}
async fn final_truncate_checkpoint(pool: &sqlx::SqlitePool) -> Result<(), BlobActivationError> {
let (busy, _, _): (i64, i64, i64) = sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
.fetch_one(pool)
.await
.map_err(BlobActivationError::FinalCheckpoint)?;
if busy != 0 {
// A concurrent reader (a backup tool, a replication agent, an
// operator shell) can keep the WAL from truncating. An untruncated
// WAL threatens no data integrity, so it must not block startup; a
// later checkpoint truncates once the reader is gone.
warn!("The final SQLite WAL truncate checkpoint could not complete; continuing startup");
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use fabro_db::append_to_path;
use object_store::ObjectStore;
use object_store::memory::InMemory;
use tokio::fs;
use super::{
BACKUP_SUFFIX, BlobActivationError, DISK_HEADROOM_BYTES, activate_blob_storage,
compute_disk_preflight, create_backup, final_truncate_checkpoint, sqlite_file_set_bytes,
validate_backup,
};
type TestResult<T> = Result<T, Box<dyn std::error::Error>>;
#[test]
fn disk_preflight_passes_at_equality_and_fails_one_byte_below() {
let pending_bytes = 3;
let backup_reserve = 10;
let required = backup_reserve + pending_bytes + 2 + DISK_HEADROOM_BYTES;
let required_free_bytes = compute_disk_preflight(pending_bytes, backup_reserve, required)
.expect("exact equality must pass");
assert_eq!(required_free_bytes, required);
let error = compute_disk_preflight(pending_bytes, backup_reserve, required - 1)
.expect_err("one byte below must fail");
assert!(matches!(
error,
BlobActivationError::InsufficientDisk { .. }
));
}
#[test]
fn disk_preflight_requires_only_headroom_without_a_backup_reserve() {
let required_free_bytes =
compute_disk_preflight(2, 0, u64::MAX).expect("available capacity should pass");
assert_eq!(required_free_bytes, 3 + DISK_HEADROOM_BYTES);
}
#[test]
fn disk_preflight_fails_closed_on_overflow() {
let error =
compute_disk_preflight(u64::MAX, 1, u64::MAX).expect_err("overflow must fail closed");
assert!(matches!(
error,
BlobActivationError::DiskRequirementOverflow
));
}
#[tokio::test]
async fn disk_preflight_counts_the_sqlite_file_set_for_a_required_backup() -> TestResult<()> {
let directory = tempfile::tempdir()?;
let sqlite_path = directory.path().join("fabro.sqlite3");
fs::write(&sqlite_path, [0_u8; 3]).await?;
fs::write(append_to_path(&sqlite_path, "-wal"), [0_u8; 5]).await?;
fs::write(append_to_path(&sqlite_path, "-shm"), [0_u8; 7]).await?;
assert_eq!(sqlite_file_set_bytes(&sqlite_path).await?, 15);
Ok(())
}
#[tokio::test]
async fn backup_is_private_integrity_clean_and_does_not_create_journal_siblings()
-> TestResult<()> {
let directory = tempfile::tempdir()?;
let sqlite_path = directory.path().join("fabro.sqlite3");
let database = fabro_db::Database::connect(&sqlite_path).await?;
database.migrate().await?;
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
create_backup(database.pool(), &backup_path).await?;
validate_backup(&backup_path).await?;
assert!(backup_path.is_file());
assert!(!append_to_path(&backup_path, "-wal").exists());
assert!(!append_to_path(&backup_path, "-shm").exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(&backup_path)?.permissions().mode() & 0o077,
0
);
}
Ok(())
}
#[tokio::test]
async fn backup_publication_never_overwrites_an_existing_valid_backup() -> TestResult<()> {
let directory = tempfile::tempdir()?;
let sqlite_path = directory.path().join("fabro.sqlite3");
let database = fabro_db::Database::connect(&sqlite_path).await?;
database.migrate().await?;
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
create_backup(database.pool(), &backup_path).await?;
let original = fs::read(&backup_path).await?;
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
.bind(fabro_types::BlobHash::new(b"later").to_string())
.bind(b"later".as_slice())
.execute(database.pool())
.await?;
create_backup(database.pool(), &backup_path).await?;
assert_eq!(fs::read(&backup_path).await?, original);
Ok(())
}
#[tokio::test]
async fn failed_backup_copy_never_publishes_a_destination() -> TestResult<()> {
let directory = tempfile::tempdir()?;
let sqlite_path = directory.path().join("fabro.sqlite3");
let database = fabro_db::Database::connect(&sqlite_path).await?;
database.migrate().await?;
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
database.pool().close().await;
let error = create_backup(database.pool(), &backup_path)
.await
.expect_err("a closed pool must fail backup creation");
assert!(matches!(
error,
BlobActivationError::StageBackup(fabro_db::SnapshotStagingError::Write { .. })
));
assert!(!backup_path.exists());
Ok(())
}
#[tokio::test]
async fn cold_activation_and_warm_restart_share_verified_sqlite_blobs() -> TestResult<()> {
let directory = tempfile::tempdir()?;
let sqlite_path = directory.path().join("fabro.sqlite3");
let database = fabro_db::Database::connect(&sqlite_path).await?;
database.migrate().await?;
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let source = fabro_store::test_support::test_database(
Arc::clone(&object_store),
"activation-test",
Duration::from_millis(1),
None,
);
let legacy_bytes = b"legacy-blob";
let legacy_hash = fabro_store::test_support::put_legacy_blob(&source, legacy_bytes).await?;
drop(source);
let store = activate_blob_storage(
&database,
&sqlite_path,
Arc::clone(&object_store),
"activation-test".to_string(),
Duration::from_millis(1),
None,
)
.await?;
assert_eq!(
store.blobs().read(&legacy_hash).await?.as_deref(),
Some(legacy_bytes.as_slice())
);
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
let original_backup = fs::read(&backup_path).await?;
let run_id = fabro_types::RunId::new();
let writer = store.create_run(&run_id).await?;
let reader = store.open_run_reader(&run_id).await?;
let sqlite_only_bytes = b"written-after-activation";
let sqlite_only_hash = writer.write_blob(sqlite_only_bytes).await?;
assert_eq!(
reader.read_blob(&sqlite_only_hash).await?.as_deref(),
Some(sqlite_only_bytes.as_slice())
);
drop(reader);
drop(writer);
drop(store);
let warm = activate_blob_storage(
&database,
&sqlite_path,
object_store,
"activation-test".to_string(),
Duration::from_millis(1),
None,
)
.await?;
assert_eq!(fs::read(&backup_path).await?, original_backup);
assert_eq!(
warm.blobs().read(&sqlite_only_hash).await?.as_deref(),
Some(sqlite_only_bytes.as_slice())
);
Ok(())
}
#[tokio::test]
async fn missing_backup_after_prior_import_fails_closed() -> TestResult<()> {
let directory = tempfile::tempdir()?;
let sqlite_path = directory.path().join("fabro.sqlite3");
let database = fabro_db::Database::connect(&sqlite_path).await?;
database.migrate().await?;
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let source = fabro_store::test_support::test_database(
Arc::clone(&object_store),
"missing-backup-test",
Duration::from_millis(1),
None,
);
let bytes = b"already-imported";
let hash = fabro_store::test_support::put_legacy_blob(&source, bytes).await?;
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
.bind(hash.to_string())
.bind(bytes.as_slice())
.execute(database.pool())
.await?;
drop(source);
let error = activate_blob_storage(
&database,
&sqlite_path,
object_store,
"missing-backup-test".to_string(),
Duration::from_millis(1),
None,
)
.await
.expect_err("startup must not move the pre-activation rollback boundary");
assert!(matches!(
error,
BlobActivationError::MissingBackupAfterImport {
legacy_rows: 1,
existing_rows: 1,
..
}
));
assert!(!append_to_path(&sqlite_path, BACKUP_SUFFIX).exists());
Ok(())
}
#[tokio::test]
async fn empty_inventory_skips_backup_and_serves_existing_sqlite_rows() -> TestResult<()> {
let directory = tempfile::tempdir()?;
let sqlite_path = directory.path().join("fabro.sqlite3");
let database = fabro_db::Database::connect(&sqlite_path).await?;
database.migrate().await?;
let bytes = b"sqlite-only";
let hash = fabro_types::BlobHash::new(bytes);
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
.bind(hash.to_string())
.bind(bytes.as_slice())
.execute(database.pool())
.await?;
let activated = activate_blob_storage(
&database,
&sqlite_path,
Arc::new(InMemory::new()),
"empty-activation-test".to_string(),
Duration::from_millis(1),
None,
)
.await?;
assert!(!append_to_path(&sqlite_path, BACKUP_SUFFIX).exists());
assert_eq!(
activated.blobs().read(&hash).await?.as_deref(),
Some(bytes.as_slice())
);
Ok(())
}
#[tokio::test]
async fn busy_final_checkpoint_warns_and_does_not_fail_startup() -> TestResult<()> {
use sqlx::Connection as _;
use sqlx::sqlite::{
SqliteConnectOptions, SqliteConnection, SqliteJournalMode, SqlitePoolOptions,
};
let directory = tempfile::tempdir()?;
let sqlite_path = directory.path().join("fabro.sqlite3");
let database = fabro_db::Database::connect(&sqlite_path).await?;
database.migrate().await?;
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
.bind(fabro_types::BlobHash::new(b"wal-content").to_string())
.bind(b"wal-content".as_slice())
.execute(database.pool())
.await?;
// A reader holding an open snapshot models a backup tool or operator
// shell that outlives the checkpoint's busy timeout.
let reader_options = SqliteConnectOptions::new()
.filename(&sqlite_path)
.read_only(true)
.create_if_missing(false);
let mut reader = SqliteConnection::connect_with(&reader_options).await?;
sqlx::query("BEGIN").execute(&mut reader).await?;
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM blobs")
.fetch_one(&mut reader)
.await?;
// A short busy timeout keeps the blocked truncate from stalling the
// test for the production pool's full five seconds.
let checkpoint_options = SqliteConnectOptions::new()
.filename(&sqlite_path)
.journal_mode(SqliteJournalMode::Wal)
.busy_timeout(Duration::from_millis(50))
.create_if_missing(false);
let checkpoint_pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(checkpoint_options)
.await?;
final_truncate_checkpoint(&checkpoint_pool).await?;
// The reader really did block the truncate: the WAL was not reset.
let wal_bytes = fs::metadata(append_to_path(&sqlite_path, "-wal"))
.await?
.len();
assert!(wal_bytes > 0, "the WAL should remain untruncated");
drop(reader);
Ok(())
}
#[tokio::test]
async fn invalid_retained_backup_fails_before_importing() -> TestResult<()> {
let directory = tempfile::tempdir()?;
let sqlite_path = directory.path().join("fabro.sqlite3");
let database = fabro_db::Database::connect(&sqlite_path).await?;
database.migrate().await?;
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let source = fabro_store::test_support::test_database(
Arc::clone(&object_store),
"invalid-backup-test",
Duration::from_millis(1),
None,
);
fabro_store::test_support::put_legacy_blob(&source, b"must-not-import").await?;
drop(source);
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
fs::write(&backup_path, b"not a database").await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&backup_path, std::fs::Permissions::from_mode(0o600)).await?;
}
let error = activate_blob_storage(
&database,
&sqlite_path,
object_store,
"invalid-backup-test".to_string(),
Duration::from_millis(1),
None,
)
.await
.expect_err("an invalid retained backup must fail closed");
assert!(matches!(
error,
BlobActivationError::BackupIntegrity { .. }
| BlobActivationError::BackupIntegrityFailed { .. }
));
let destination_rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blobs")
.fetch_one(database.pool())
.await?;
assert_eq!(destination_rows, 0);
Ok(())
}
}

View file

@ -28,7 +28,8 @@ use url::{Host, Url};
use crate::auth::browser_shell::browser_shell;
use crate::auth::{
self, AuthCode, AuthErrorCode, ConsumeOutcome, JwtSubject, REFRESH_TOKEN_PREFIX, RefreshToken,
self, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, JwtSubject,
PendingCliAuthorization, REFRESH_TOKEN_PREFIX, RotateOutcome,
};
use crate::jwt_auth::{AuthMode, ConfiguredAuth, bearer_token_from_headers};
use crate::principal_middleware::{
@ -389,18 +390,12 @@ async fn token(
);
}
let auth_codes = match state.store_ref().auth_codes().await {
Ok(store) => store,
Err(err) => {
warn!(error = %err, "Failed to open auth code store");
return oauth_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Could not complete authentication",
);
}
};
let Some(entry) = (match auth_codes.consume(code).await {
let Some(entry) = (match state
.stores
.auth_codes
.consume(code, chrono::Utc::now())
.await
{
Ok(entry) => entry,
Err(err) => {
warn!(error = %err, "Failed to consume auth code");
@ -466,32 +461,28 @@ async fn token(
let refresh_expires_at = now + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS);
let refresh_secret = random_secret();
let refresh_token = format!("{REFRESH_TOKEN_PREFIX}{refresh_secret}");
let refresh_row = RefreshToken {
token_hash: hash_refresh_secret(&refresh_secret),
chain_id: uuid::Uuid::new_v4(),
let session = AuthSessionRecord {
id: uuid::Uuid::new_v4(),
identity: entry.identity.clone(),
login: entry.login.clone(),
name: entry.name.clone(),
email: entry.email.clone(),
avatar_url: entry.avatar_url.clone(),
issued_at: now,
expires_at: refresh_expires_at,
last_used_at: now,
used: false,
user_agent: sanitize_user_agent(request_user_agent(&headers)),
created_at: now,
last_used_at: now,
};
let auth_tokens = match state.store_ref().refresh_tokens().await {
Ok(store) => store,
Err(err) => {
warn!(error = %err, "Failed to open refresh token store");
return oauth_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Could not complete authentication",
);
}
let refresh_row = InitialRefreshToken {
token_hash: hash_refresh_secret(&refresh_secret),
issued_at: now,
expires_at: refresh_expires_at,
};
if let Err(err) = auth_tokens.insert_refresh_token(refresh_row.clone()).await {
if let Err(err) = state
.stores
.auth_sessions
.create_session(&session, &refresh_row)
.await
{
warn!(error = %err, "Failed to persist refresh token");
return oauth_error(
StatusCode::INTERNAL_SERVER_ERROR,
@ -516,7 +507,7 @@ async fn token(
);
log_cli_auth_tokens_issued(&entry.login, &entry.email);
auth_slot.replace(refresh_user_context(&refresh_row));
auth_slot.replace(refresh_user_context(&session));
Json(CliTokenResponse {
access_token,
@ -524,10 +515,10 @@ async fn token(
refresh_token,
refresh_token_expires_at: refresh_expires_at,
subject: subject_response(
&refresh_row.identity,
&refresh_row.login,
&refresh_row.name,
&refresh_row.email,
&session.identity,
&session.login,
&session.name,
&session.email,
),
})
.into_response()
@ -570,37 +561,19 @@ async fn refresh(
"Could not refresh authentication",
);
};
let auth_tokens = match state.store_ref().refresh_tokens().await {
Ok(store) => store,
Err(err) => {
warn!(error = %err, "Failed to open refresh token store");
return oauth_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Could not refresh authentication",
);
}
};
let auth_sessions = &state.stores.auth_sessions;
let now = chrono::Utc::now();
let secret_hash = hash_refresh_secret(&secret);
let existing = match auth_tokens.find_refresh_token(&secret_hash).await {
Ok(existing) => existing,
Err(err) => {
warn!(error = %err, "Failed to load refresh token before rotation");
return oauth_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Could not refresh authentication",
);
}
};
let next_secret = random_secret();
let next_user_agent = sanitize_user_agent(request_user_agent(&headers));
let outcome = match auth_tokens
.consume_and_rotate(
secret_hash,
next_refresh_row(existing.as_ref(), &next_secret, &next_user_agent, now),
let refresh_expires_at = now + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS);
let outcome = match auth_sessions
.rotate(
&secret_hash,
&hash_refresh_secret(&next_secret),
refresh_expires_at,
&next_user_agent,
now,
)
.await
@ -616,42 +589,31 @@ async fn refresh(
}
};
let (old, new_row) = match outcome {
ConsumeOutcome::NotFound | ConsumeOutcome::Expired => {
let session = match outcome {
RotateOutcome::NotFound | RotateOutcome::Expired => {
auth_slot.replace(RequestAuthContext::invalid());
if auth_tokens.was_recently_replay_revoked(&secret_hash, now) {
return oauth_error(
StatusCode::UNAUTHORIZED,
"refresh_token_revoked",
"Refresh token revoked",
);
}
return oauth_error(
StatusCode::UNAUTHORIZED,
"refresh_token_expired",
"Refresh token expired",
);
}
ConsumeOutcome::Reused(old) => {
RotateOutcome::ReplayedAndRevoked(session) => {
auth_slot.replace(RequestAuthContext::invalid());
auth_tokens.mark_refresh_token_replay(secret_hash, now);
if let Err(err) = auth_tokens.delete_chain(old.chain_id).await {
warn!(error = %err, chain_id = %old.chain_id, "Failed to revoke replayed refresh token chain");
}
log_refresh_token_replay(old.chain_id, old.identity.subject(), &next_user_agent);
log_refresh_token_replay(session.id, session.identity.subject(), &next_user_agent);
return oauth_error(
StatusCode::UNAUTHORIZED,
"refresh_token_revoked",
"Refresh token revoked",
);
}
ConsumeOutcome::Rotated(old, new_row) => (old, *new_row),
RotateOutcome::Rotated(session) => session,
};
if !login_allowed(state.as_ref(), &old.login) {
if !login_allowed(state.as_ref(), &session.login) {
auth_slot.replace(RequestAuthContext::invalid());
if let Err(err) = auth_tokens.delete_chain(old.chain_id).await {
warn!(error = %err, chain_id = %old.chain_id, "Failed to revoke deauthorized refresh token chain");
if let Err(err) = auth_sessions.delete_session(session.id).await {
warn!(error = %err, session_id = %session.id, "Failed to revoke deauthorized refresh token chain");
}
return oauth_error(StatusCode::FORBIDDEN, "unauthorized", "Login not permitted");
}
@ -661,24 +623,29 @@ async fn refresh(
jwt_key,
jwt_issuer,
&JwtSubject {
identity: old.identity.clone(),
login: old.login.clone(),
name: old.name.clone(),
email: old.email.clone(),
avatar_url: old.avatar_url.clone(),
identity: session.identity.clone(),
login: session.login.clone(),
name: session.name.clone(),
email: session.email.clone(),
avatar_url: session.avatar_url.clone(),
user_url: String::new(),
auth_method: AuthMethod::Github,
},
chrono::Duration::minutes(ACCESS_TOKEN_TTL_MINUTES),
);
auth_slot.replace(refresh_user_context(&old));
auth_slot.replace(refresh_user_context(&session));
Json(CliTokenResponse {
access_token,
access_token_expires_at: access_expires_at,
refresh_token: format!("{REFRESH_TOKEN_PREFIX}{next_secret}"),
refresh_token_expires_at: new_row.expires_at,
subject: subject_response(&old.identity, &old.login, &old.name, &old.email),
refresh_token_expires_at: refresh_expires_at,
subject: subject_response(
&session.identity,
&session.login,
&session.name,
&session.email,
),
})
.into_response()
}
@ -701,20 +668,9 @@ async fn logout(
}
RefreshCredential::Present(secret) => secret,
};
let auth_tokens = match state.store_ref().refresh_tokens().await {
Ok(store) => store,
Err(err) => {
warn!(error = %err, "Failed to open refresh token store");
return oauth_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Could not complete logout",
);
}
};
let existing = match auth_tokens
.find_refresh_token(&hash_refresh_secret(&secret))
let auth_sessions = &state.stores.auth_sessions;
let existing = match auth_sessions
.find_session_by_token_hash(&hash_refresh_secret(&secret))
.await
{
Ok(existing) => existing,
@ -728,17 +684,17 @@ async fn logout(
}
};
if let Some(refresh_token) = existing {
auth_slot.replace(refresh_user_context(&refresh_token));
if let Err(err) = auth_tokens.delete_chain(refresh_token.chain_id).await {
warn!(error = %err, chain_id = %refresh_token.chain_id, "Failed to revoke refresh token chain during logout");
if let Some(session) = existing {
auth_slot.replace(refresh_user_context(&session));
if let Err(err) = auth_sessions.delete_session(session.id).await {
warn!(error = %err, session_id = %session.id, "Failed to revoke refresh token chain during logout");
return oauth_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Could not complete logout",
);
}
log_cli_refresh_chain_logged_out(&refresh_token.login, &refresh_token.email);
log_cli_refresh_chain_logged_out(&session.login, &session.email);
} else {
auth_slot.replace(RequestAuthContext::invalid());
}
@ -956,13 +912,13 @@ fn refresh_credential_from_headers(headers: &HeaderMap) -> RefreshCredential {
}
}
fn refresh_user_context(refresh_token: &RefreshToken) -> RequestAuthContext {
fn refresh_user_context(session: &AuthSessionRecord) -> RequestAuthContext {
RequestAuthContext::authenticated(
Principal::user_with_avatar(
refresh_token.identity.clone(),
refresh_token.login.clone(),
session.identity.clone(),
session.login.clone(),
AuthMethod::Github,
non_empty_avatar_url(&refresh_token.avatar_url),
non_empty_avatar_url(&session.avatar_url),
),
None,
)
@ -972,31 +928,6 @@ fn hash_refresh_secret(secret: &str) -> [u8; 32] {
Sha256::digest(secret.as_bytes()).into()
}
fn next_refresh_row(
existing: Option<&RefreshToken>,
next_secret: &str,
user_agent: &str,
now: chrono::DateTime<chrono::Utc>,
) -> RefreshToken {
let fallback_identity = fabro_types::IdpIdentity::new("https://github.com", "0")
.expect("static identity should be valid");
RefreshToken {
token_hash: hash_refresh_secret(next_secret),
chain_id: existing.map_or_else(uuid::Uuid::new_v4, |token| token.chain_id),
identity: existing
.map_or_else(|| fallback_identity.clone(), |token| token.identity.clone()),
login: existing.map_or_else(String::new, |token| token.login.clone()),
name: existing.map_or_else(String::new, |token| token.name.clone()),
email: existing.map_or_else(String::new, |token| token.email.clone()),
avatar_url: existing.map_or_else(String::new, |token| token.avatar_url.clone()),
issued_at: now,
expires_at: now + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS),
last_used_at: now,
used: false,
user_agent: user_agent.to_string(),
}
}
fn user_agent_fingerprint(user_agent: &str) -> String {
let digest = Sha256::digest(user_agent.as_bytes());
hex::encode(&digest[..8])
@ -1006,9 +937,9 @@ fn log_cli_auth_tokens_issued(login: &str, email: &str) {
info!(login = %login, email = %email, "Issued CLI auth tokens");
}
fn log_refresh_token_replay(chain_id: uuid::Uuid, idp_subject: &str, user_agent: &str) {
fn log_refresh_token_replay(session_id: uuid::Uuid, idp_subject: &str, user_agent: &str) {
warn!(
chain_id = %chain_id,
session_id = %session_id,
idp_subject = %idp_subject,
user_agent_fingerprint = %user_agent_fingerprint(user_agent),
"Refresh token replay detected"
@ -1180,8 +1111,7 @@ async fn issue_auth_code_response(
let Some(redirect_uri) = canonical_loopback_redirect_uri(redirect_uri) else {
return static_error_page(INVALID_REDIRECT_URI);
};
let entry = AuthCode {
code: code.clone(),
let entry = PendingCliAuthorization {
identity,
login: session.login.clone(),
name: session.name.clone(),
@ -1192,20 +1122,7 @@ async fn issue_auth_code_response(
expires_at: chrono::Utc::now() + chrono::Duration::seconds(60),
};
let store = match state.store_ref().auth_codes().await {
Ok(store) => store,
Err(err) => {
warn!(error = %err, "Failed to open auth code store");
return redirect_with_error(
&redirect_uri,
state_token,
"server_error",
"Could not complete GitHub sign-in",
);
}
};
if let Err(err) = store.insert(entry).await {
if let Err(err) = state.stores.auth_codes.issue(&code, &entry).await {
warn!(error = %err, "Failed to persist auth code");
return redirect_with_error(
&redirect_uri,
@ -1248,9 +1165,12 @@ mod tests {
CliFlowCookie, DEV_TOKEN_LOGIN_INSTRUCTIONS, add_cli_flow_cookie, read_private_cli_flow,
user_agent_fingerprint, web_routes,
};
use crate::auth::{self, AuthCode, AuthErrorCode, RefreshToken};
use crate::auth::{
self, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, PendingCliAuthorization,
};
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
use crate::principal_middleware::{AuthStatus, RequestAuthContext};
use crate::server::AppState;
use crate::web_auth::SessionCookie;
fn test_cookie_key() -> Key {
@ -1391,10 +1311,10 @@ client_id = "github-client-id"
}
async fn insert_auth_code(state: &crate::server::AppState, code: &str, verifier: &str) {
let auth_codes = state.store_ref().auth_codes().await.unwrap();
auth_codes
.insert(AuthCode {
code: code.to_string(),
state
.stores
.auth_codes
.issue(code, &PendingCliAuthorization {
identity: fabro_types::IdpIdentity::new("https://github.com", "12345")
.expect("identity should be valid"),
login: "octocat".to_string(),
@ -1413,23 +1333,37 @@ client_id = "github-client-id"
Sha256::digest(secret.as_bytes()).into()
}
fn refresh_row(secret: &str) -> RefreshToken {
fn session_and_token(secret: &str) -> (AuthSessionRecord, InitialRefreshToken) {
let now = chrono::Utc::now();
RefreshToken {
token_hash: hash_refresh_secret(secret),
chain_id: Uuid::new_v4(),
let session = AuthSessionRecord {
id: Uuid::new_v4(),
identity: fabro_types::IdpIdentity::new("https://github.com", "12345")
.expect("identity should be valid"),
login: "octocat".to_string(),
name: "The Octocat".to_string(),
email: "octocat@example.com".to_string(),
avatar_url: "https://example.com/octocat.png".to_string(),
issued_at: now,
expires_at: now + chrono::Duration::days(30),
last_used_at: now,
used: false,
user_agent: "fabro-test".to_string(),
}
created_at: now,
last_used_at: now,
};
let token = InitialRefreshToken {
token_hash: hash_refresh_secret(secret),
issued_at: now,
expires_at: now + chrono::Duration::days(30),
};
(session, token)
}
async fn open_cli_session(state: &AppState, secret: &str) -> Uuid {
let (session, token) = session_and_token(secret);
state
.stores
.auth_sessions
.create_session(&session, &token)
.await
.unwrap();
session.id
}
#[derive(Default)]
@ -1769,9 +1703,10 @@ client_id = "github-client-id"
.nth(1)
.and_then(|segment| segment.split('&').next())
.expect("auth code should be present");
let auth_codes = state.store_ref().auth_codes().await.unwrap();
let entry = auth_codes
.consume(code)
let entry = state
.stores
.auth_codes
.consume(code, chrono::Utc::now())
.await
.unwrap()
.expect("code should exist");
@ -2014,9 +1949,9 @@ client_id = "github-client-id"
.unwrap()
.strip_prefix("fabro_refresh_")
.unwrap();
let auth_tokens = state.store_ref().refresh_tokens().await.unwrap();
let refresh = auth_tokens
.find_refresh_token(&hash_refresh_secret(refresh_secret))
let auth_sessions = &state.stores.auth_sessions;
let refresh = auth_sessions
.find_session_by_token_hash(&hash_refresh_secret(refresh_secret))
.await
.unwrap()
.expect("refresh token should be stored");
@ -2126,6 +2061,49 @@ client_id = "github-client-id"
assert_eq!(body["error"], "invalid_code");
}
#[tokio::test]
async fn token_storage_failure_returns_safe_oauth_error() {
let (app, state) = test_router(github_settings("https://fabro.example"));
state.stores.auth_codes.test_close().await;
let raw_code = "raw-code-that-must-not-escape";
let raw_verifier = "raw-verifier-that-must-not-escape";
let redirect_uri = "http://127.0.0.1:4444/callback";
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/cli/token")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"grant_type": "authorization_code",
"code": raw_code,
"code_verifier": raw_verifier,
"redirect_uri": redirect_uri
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let rendered = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&rendered).unwrap(),
json!({
"error": "server_error",
"error_description": "Could not complete authentication"
})
);
for sensitive in [raw_code, raw_verifier, redirect_uri] {
assert!(!rendered.contains(sensitive));
}
}
#[tokio::test]
async fn token_rejects_userinfo_injected_redirect_uri() {
let (app, state) = test_router(github_settings("https://fabro.example"));
@ -2168,11 +2146,8 @@ client_id = "github-client-id"
async fn refresh_rotates_tokens_and_replay_revokes_chain() {
let (app, state) = test_router(github_settings("https://fabro.example"));
let initial_secret = "refresh-secret-1";
let auth_tokens = state.store_ref().refresh_tokens().await.unwrap();
auth_tokens
.insert_refresh_token(refresh_row(initial_secret))
.await
.unwrap();
open_cli_session(&state, initial_secret).await;
let auth_sessions = &state.stores.auth_sessions;
let refresh_request = || {
Request::builder()
@ -2211,15 +2186,15 @@ client_id = "github-client-id"
let new_secret = rotated.strip_prefix("fabro_refresh_").unwrap();
assert!(
auth_tokens
.find_refresh_token(&hash_refresh_secret(initial_secret))
auth_sessions
.find_session_by_token_hash(&hash_refresh_secret(initial_secret))
.await
.unwrap()
.is_none()
);
assert!(
auth_tokens
.find_refresh_token(&hash_refresh_secret(new_secret))
auth_sessions
.find_session_by_token_hash(&hash_refresh_secret(new_secret))
.await
.unwrap()
.is_none()
@ -2233,14 +2208,7 @@ client_id = "github-client-id"
github_auth_mode(),
);
let initial_secret = "refresh-secret-auth-context";
state
.store_ref()
.refresh_tokens()
.await
.unwrap()
.insert_refresh_token(refresh_row(initial_secret))
.await
.unwrap();
open_cli_session(&state, initial_secret).await;
let response = app
.clone()
@ -2296,11 +2264,8 @@ client_id = "github-client-id"
async fn concurrent_refresh_has_one_winner_and_revokes_chain() {
let (app, state) = test_router(github_settings("https://fabro.example"));
let initial_secret = "refresh-secret-concurrent";
let auth_tokens = state.store_ref().refresh_tokens().await.unwrap();
auth_tokens
.insert_refresh_token(refresh_row(initial_secret))
.await
.unwrap();
open_cli_session(&state, initial_secret).await;
let auth_sessions = &state.stores.auth_sessions;
let barrier = Arc::new(Barrier::new(33));
let mut tasks = JoinSet::new();
@ -2348,7 +2313,11 @@ client_id = "github-client-id"
.map(str::to_string);
}
StatusCode::UNAUTHORIZED => {
assert_eq!(body["error"], "refresh_token_revoked");
let error = body["error"].as_str().unwrap_or_default();
assert!(
error == "refresh_token_revoked" || error == "refresh_token_expired",
"unexpected refresh error {error}"
);
revoked += 1;
}
other => panic!("unexpected refresh status {other}: {body}"),
@ -2359,15 +2328,15 @@ client_id = "github-client-id"
assert_eq!(revoked, 31);
let rotated_secret = rotated_secret.expect("one refresh should rotate the token");
assert!(
auth_tokens
.find_refresh_token(&hash_refresh_secret(initial_secret))
auth_sessions
.find_session_by_token_hash(&hash_refresh_secret(initial_secret))
.await
.unwrap()
.is_none()
);
assert!(
auth_tokens
.find_refresh_token(&hash_refresh_secret(&rotated_secret))
auth_sessions
.find_session_by_token_hash(&hash_refresh_secret(&rotated_secret))
.await
.unwrap()
.is_none()
@ -2378,17 +2347,24 @@ client_id = "github-client-id"
async fn logout_deletes_refresh_token_chain_and_returns_no_content() {
let (app, state) = test_router(github_settings("https://fabro.example"));
let secret = "refresh-secret-logout";
let token = refresh_row(secret);
let chain_id = token.chain_id;
let auth_tokens = state.store_ref().refresh_tokens().await.unwrap();
auth_tokens.insert_refresh_token(token).await.unwrap();
let (session, token) = session_and_token(secret);
let auth_sessions = &state.stores.auth_sessions;
auth_sessions
.create_session(&session, &token)
.await
.unwrap();
let sibling = RefreshToken {
token_hash: hash_refresh_secret("refresh-secret-logout-2"),
chain_id,
..refresh_row("refresh-secret-logout-2")
};
auth_tokens.insert_refresh_token(sibling).await.unwrap();
let now = chrono::Utc::now();
auth_sessions
.rotate(
&hash_refresh_secret(secret),
&hash_refresh_secret("refresh-secret-logout-2"),
now + chrono::Duration::days(30),
"fabro-test",
now,
)
.await
.unwrap();
let response = app
.oneshot(
@ -2407,15 +2383,15 @@ client_id = "github-client-id"
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert!(
auth_tokens
.find_refresh_token(&hash_refresh_secret(secret))
auth_sessions
.find_session_by_token_hash(&hash_refresh_secret(secret))
.await
.unwrap()
.is_none()
);
assert!(
auth_tokens
.find_refresh_token(&hash_refresh_secret("refresh-secret-logout-2"))
auth_sessions
.find_session_by_token_hash(&hash_refresh_secret("refresh-secret-logout-2"))
.await
.unwrap()
.is_none()

View file

@ -30,7 +30,10 @@ pub(crate) const REFRESH_TOKEN_PREFIX: &str = "fabro_refresh_";
pub(crate) use browser_shell::browser_shell;
pub(crate) use cli_flow::web_routes;
pub(crate) use fabro_store::{AuthCode, ConsumeOutcome, RefreshToken};
pub(crate) use fabro_store::PendingCliAuthorization;
pub(crate) use fabro_store::auth_session_store::{
AuthSessionRecord, InitialRefreshToken, RotateOutcome,
};
pub use github_endpoints::GithubEndpoints;
pub(crate) use jwt::{JwtError, JwtSubject, issue, verify};
pub(crate) use keys::{

View file

@ -1,14 +1,12 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context as _;
use async_trait::async_trait;
use fabro_api::types::RunManifest;
use fabro_automation::{AutomationId, AutomationTarget};
use fabro_automation::AutomationId;
use fabro_config::{EnvironmentLayer, MergeMap};
use fabro_manifest::ManifestBuildInput;
use fabro_types::{DirtyStatus, GitContext, GitHubRepositorySlug, RunId};
use fabro_util::error::collect_chain;
use fabro_types::{GitHubRepositorySlug, GitRunTarget, RunId, RunTarget, TargetValidationError};
use tokio::{fs, task};
use crate::git_checkout::{
@ -18,7 +16,8 @@ use crate::git_checkout::{
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AutomationRunMaterializeInput {
pub automation_id: AutomationId,
pub target: AutomationTarget,
pub target: GitRunTarget,
pub workflow: String,
pub run_id: RunId,
pub user_settings_path: PathBuf,
pub temp_root: PathBuf,
@ -28,28 +27,52 @@ pub(crate) struct AutomationRunMaterializeInput {
pub(crate) struct AutomationRunMaterialized {
pub manifest: RunManifest,
pub submitted_manifest_bytes: Vec<u8>,
pub target: GitRunTarget,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[derive(thiserror::Error, Debug)]
pub(crate) enum RunMaterializeError {
#[error("invalid repository target: {0}")]
InvalidTarget(String),
#[error("failed to clone repository: {0}")]
CloneFailed(String),
#[error("failed to resolve workflow: {0}")]
WorkflowNotFound(String),
#[error("failed to build run manifest: {0}")]
Manifest(String),
#[error("failed to load GitHub credentials: {0}")]
Credentials(String),
}
impl From<GitCheckoutError> for RunMaterializeError {
fn from(value: GitCheckoutError) -> Self {
match value {
GitCheckoutError::CloneFailed(message) => Self::CloneFailed(message),
}
}
#[error("invalid automation Git target")]
InvalidTarget {
#[source]
source: TargetValidationError,
},
#[error("failed to prepare automation checkout")]
Checkout {
#[from]
source: GitCheckoutError,
},
#[error("failed to prepare automation temporary directory {path}")]
TempDirectory {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to resolve automation workflow")]
WorkflowNotFound {
#[source]
source: anyhow::Error,
},
#[error("failed to build run manifest")]
Manifest {
#[source]
source: anyhow::Error,
},
#[error("manifest build task failed")]
ManifestTask {
#[source]
source: task::JoinError,
},
#[error("failed to serialize materialized run manifest")]
SerializeManifest {
#[source]
source: serde_json::Error,
},
#[error("failed to load GitHub credentials")]
Credentials {
#[source]
source: anyhow::Error,
},
}
#[async_trait]
@ -93,13 +116,17 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
&self,
input: AutomationRunMaterializeInput,
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
let repo = parse_target_repository(&input.target.repository)?;
fs::create_dir_all(&input.temp_root).await.map_err(|err| {
RunMaterializeError::CloneFailed(format!(
"failed to create temp root {}: {err}",
input.temp_root.display()
))
})?;
let repo = GitHubRepositorySlug::try_new(&input.target.repo).ok_or(
RunMaterializeError::InvalidTarget {
source: TargetValidationError::Repository,
},
)?;
fs::create_dir_all(&input.temp_root)
.await
.map_err(|source| RunMaterializeError::TempDirectory {
path: input.temp_root.clone(),
source,
})?;
let temp_dir = tempfile::Builder::new()
.prefix(&format!(
"automation-{}-{}-",
@ -107,11 +134,9 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
input.run_id
))
.tempdir_in(&input.temp_root)
.map_err(|err| {
RunMaterializeError::CloneFailed(format!(
"failed to create per-run temp directory under {}: {err}",
input.temp_root.display()
))
.map_err(|source| RunMaterializeError::TempDirectory {
path: input.temp_root.clone(),
source,
})?;
let checkout_dir = temp_dir.path().join("repo");
let auth = resolve_git_auth_config(
@ -121,62 +146,43 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
self.http_client.clone(),
)
.await
.map_err(|err| RunMaterializeError::CloneFailed(render_error_chain(err.as_ref())))?;
.map_err(|source| RunMaterializeError::Credentials { source })?;
let checked_out_sha = self
.repo_cache
.prepare_worktree(WorktreePrepareInput {
repo: &repo,
ref_selector: &input.target.ref_selector,
target: &input.target,
auth: auth.as_ref(),
worktree_dir: &checkout_dir,
})
.await?;
let mut exact_target = input.target;
exact_target.sha = Some(checked_out_sha);
let manifest_input = ManifestFromCheckoutInput {
workflow: input.target.workflow,
workflow: input.workflow,
user_settings_path: input.user_settings_path,
checkout_dir,
git_context: ManifestGitContextInput {
repo,
ref_selector: input.target.ref_selector,
checked_out_sha,
},
target: exact_target,
environment_defaults: self.environment_defaults.clone(),
};
task::spawn_blocking(move || build_manifest_from_checkout(manifest_input))
.await
.map_err(|err| {
RunMaterializeError::Manifest(format!("manifest build task failed: {err}"))
})?
.map_err(|source| RunMaterializeError::ManifestTask { source })?
}
}
fn render_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
collect_chain(error).join(": ")
}
fn parse_target_repository(value: &str) -> Result<GitHubRepositorySlug, RunMaterializeError> {
fabro_automation::parse_github_repository_slug(value)
.map_err(|err| RunMaterializeError::InvalidTarget(err.to_string()))
}
#[derive(Debug)]
pub(crate) struct ManifestFromCheckoutInput {
workflow: String,
user_settings_path: PathBuf,
checkout_dir: PathBuf,
git_context: ManifestGitContextInput,
target: GitRunTarget,
environment_defaults: MergeMap<EnvironmentLayer>,
}
#[derive(Debug)]
pub(crate) struct ManifestGitContextInput {
repo: GitHubRepositorySlug,
ref_selector: String,
checked_out_sha: String,
}
fn build_manifest_from_checkout(
args: ManifestFromCheckoutInput,
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
@ -184,9 +190,17 @@ fn build_manifest_from_checkout(
workflow,
user_settings_path,
checkout_dir,
git_context,
target,
environment_defaults,
} = args;
// Re-validating the exact target (now carrying the checked-out SHA) yields
// the same `GitContext` projection the run-intent path uses.
let validated = RunTarget::Git(target)
.validate()
.map_err(|source| RunMaterializeError::InvalidTarget { source })?;
let RunTarget::Git(target) = validated.target else {
unreachable!("validating a Git target yields a Git target");
};
let built = fabro_manifest::build_run_manifest(ManifestBuildInput {
workflow: workflow.into(),
cwd: checkout_dir,
@ -194,33 +208,28 @@ fn build_manifest_from_checkout(
environment_defaults,
..ManifestBuildInput::default()
})
.map_err(|err| manifest_build_error(&err))?;
.map_err(manifest_build_error)?;
let mut manifest = built.manifest;
manifest.git = Some(GitContext {
origin_url: git_context.repo.https_url(),
branch: git_context.ref_selector,
sha: Some(git_context.checked_out_sha),
dirty: DirtyStatus::Clean,
});
manifest.git = validated.git;
let submitted_manifest_bytes = serde_json::to_vec(&manifest)
.context("failed to serialize materialized run manifest")
.map_err(|err| RunMaterializeError::Manifest(err.to_string()))?;
.map_err(|source| RunMaterializeError::SerializeManifest { source })?;
Ok(AutomationRunMaterialized {
manifest,
submitted_manifest_bytes,
target,
})
}
fn manifest_build_error(error: &anyhow::Error) -> RunMaterializeError {
fn manifest_build_error(error: anyhow::Error) -> RunMaterializeError {
if error.chain().any(|source| {
source
.downcast_ref::<fabro_config::Error>()
.is_some_and(|err| matches!(err, fabro_config::Error::WorkflowNotFound(_)))
}) {
RunMaterializeError::WorkflowNotFound(render_error_chain(error.as_ref()))
RunMaterializeError::WorkflowNotFound { source: error }
} else {
RunMaterializeError::Manifest(render_error_chain(error.as_ref()))
RunMaterializeError::Manifest { source: error }
}
}
@ -233,23 +242,28 @@ pub struct TestAutomationRunMaterializer {
#[cfg(any(test, feature = "test-support"))]
struct TestAutomationRunMaterializerState {
captured_inputs: Vec<AutomationRunMaterializeInput>,
response: Result<AutomationRunMaterialized, RunMaterializeError>,
response: Result<Box<AutomationRunMaterialized>, TargetValidationError>,
}
#[cfg(any(test, feature = "test-support"))]
impl TestAutomationRunMaterializer {
pub fn succeed(manifest: RunManifest, submitted_manifest_bytes: Vec<u8>) -> Self {
Self::new(Ok(AutomationRunMaterialized {
pub fn succeed(
manifest: RunManifest,
submitted_manifest_bytes: Vec<u8>,
target: GitRunTarget,
) -> Self {
Self::new(Ok(Box::new(AutomationRunMaterialized {
manifest,
submitted_manifest_bytes,
}))
target,
})))
}
pub fn fail_invalid_target(message: impl Into<String>) -> Self {
Self::new(Err(RunMaterializeError::InvalidTarget(message.into())))
pub fn fail_invalid_target() -> Self {
Self::new(Err(TargetValidationError::Repository))
}
fn new(response: Result<AutomationRunMaterialized, RunMaterializeError>) -> Self {
fn new(response: Result<Box<AutomationRunMaterialized>, TargetValidationError>) -> Self {
Self {
inner: std::sync::Arc::new(std::sync::Mutex::new(TestAutomationRunMaterializerState {
captured_inputs: Vec::new(),
@ -283,7 +297,11 @@ impl AutomationRunMaterializer for TestAutomationRunMaterializer {
.lock()
.expect("test automation materializer lock poisoned");
guard.captured_inputs.push(input);
guard.response.clone()
guard
.response
.clone()
.map(|materialized| *materialized)
.map_err(|source| RunMaterializeError::InvalidTarget { source })
}
}
@ -328,17 +346,17 @@ mod tests {
.unwrap();
let user_settings_path = temp.path().join("settings.toml");
fs::write(&user_settings_path, "_version = 1\n").unwrap();
let repo = parse_target_repository("workspace-org/app").unwrap();
let sha = "0123456789abcdef0123456789abcdef01234567".to_string();
let materialized = build_manifest_from_checkout(ManifestFromCheckoutInput {
workflow: "demo".to_string(),
user_settings_path: user_settings_path.clone(),
checkout_dir: checkout.clone(),
git_context: ManifestGitContextInput {
repo,
ref_selector: "release".to_string(),
checked_out_sha: sha.clone(),
target: GitRunTarget {
repo: "workspace-org/app".to_string(),
branch: "release".to_string(),
tag: Some("v1".to_string()),
sha: Some(sha.clone()),
},
environment_defaults: test_environment_defaults(),
})
@ -365,6 +383,8 @@ mod tests {
assert_eq!(git.branch, "release");
assert_eq!(git.sha.as_deref(), Some(sha.as_str()));
assert_eq!(git.dirty, DirtyStatus::Clean);
assert_eq!(materialized.target.tag.as_deref(), Some("v1"));
assert_eq!(materialized.target.sha.as_deref(), Some(sha.as_str()));
let submitted_manifest: serde_json::Value =
serde_json::from_slice(&materialized.submitted_manifest_bytes)
.expect("submitted bytes should be a manifest");

View file

@ -1531,6 +1531,9 @@ mod runs {
output: serde_json::json!("[redis]\nhost = \"redis-prod.internal\"\nport = 6379"),
is_error: false,
visit: 1,
output_bytes_observed: None,
output_bytes_retained: None,
output_bytes_omitted: None,
tool_result: None,
turn_id: None,
}),
@ -1557,6 +1560,9 @@ mod runs {
output: serde_json::json!("[redis]\nhost = \"redis-staging.internal\"\nport = 6379"),
is_error: false,
visit: 1,
output_bytes_observed: None,
output_bytes_retained: None,
output_bytes_omitted: None,
tool_result: None,
turn_id: None,
}),

View file

@ -1,10 +1,11 @@
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_store::KeyedMutex;
use fabro_types::GitHubRepositorySlug;
use fabro_types::{GitHubRepositorySlug, GitRunTarget};
use tokio::process::Command;
use tokio::{fs, time};
@ -15,10 +16,64 @@ const GIT_WORKTREE_PRUNE_TIMEOUT: Duration = Duration::from_secs(10);
const GIT_REV_PARSE_TIMEOUT: Duration = Duration::from_secs(10);
/// Error returned while preparing a checkout from a git source.
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[derive(thiserror::Error, Debug)]
pub(crate) enum GitCheckoutError {
#[error("failed to clone repository: {0}")]
CloneFailed(String),
#[error("failed to create Git cache directory {path}")]
CacheDirectory {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to clone repository")]
Clone {
#[source]
source: GitCommandError,
},
#[error("failed to fetch branch {branch:?}")]
FetchBranch {
branch: String,
#[source]
source: GitCommandError,
},
#[error("failed to fetch tag {tag:?}")]
FetchTag {
tag: String,
#[source]
source: GitCommandError,
},
#[error("failed to fetch exact commit {sha}")]
FetchCommit {
sha: String,
#[source]
source: GitCommandError,
},
#[error("failed to resolve fetched Git target to a commit")]
ResolveCommit {
#[source]
source: GitCommandError,
},
#[error("failed to add Git worktree")]
AddWorktree {
#[source]
source: GitCommandError,
},
}
#[derive(thiserror::Error, Debug)]
pub(crate) enum GitCommandError {
#[error("{command} timed out after {timeout_secs}s")]
Timeout {
command: String,
timeout_secs: u64,
},
#[error("failed to run {command}")]
Spawn {
command: String,
#[source]
source: std::io::Error,
},
#[error("{message}")]
Exit { message: String },
}
/// Persistent on-disk cache of bare GitHub clones, one per `(owner, repo)`.
@ -113,25 +168,30 @@ impl GitRepoCache {
if !bare_exists {
if let Some(parent) = bare_dir.parent() {
fs::create_dir_all(parent).await.map_err(|err| {
GitCheckoutError::CloneFailed(format!(
"failed to create cache dir {}: {err}",
parent.display()
))
GitCheckoutError::CacheDirectory {
path: parent.to_path_buf(),
source: err,
}
})?;
}
run_git_plan(build_bare_clone_plan(clone_url, bare_dir, args.auth)).await?;
run_git_plan(build_bare_clone_plan(clone_url, bare_dir, args.auth))
.await
.map_err(|source| GitCheckoutError::Clone { source })?;
}
let fetch_target = GitFetchTarget::from(args.target);
run_git_plan(build_bare_fetch_plan(
bare_dir,
clone_url,
args.ref_selector,
&fetch_target.selector(),
args.auth,
))
.await?;
.await
.map_err(|source| fetch_target.checkout_error(source))?;
let checked_out_sha = run_git_plan(build_rev_parse_fetch_head_plan(bare_dir))
.await
.map_err(|source| GitCheckoutError::ResolveCommit { source })
.map(|stdout| String::from_utf8_lossy(&stdout).trim().to_string())?;
add_worktree_with_stale_retry(bare_dir, args.worktree_dir, &checked_out_sha).await?;
@ -142,11 +202,55 @@ impl GitRepoCache {
pub(crate) struct WorktreePrepareInput<'a> {
pub repo: &'a GitHubRepositorySlug,
pub ref_selector: &'a str,
pub target: &'a GitRunTarget,
pub auth: Option<&'a GitAuthConfig>,
pub worktree_dir: &'a Path,
}
enum GitFetchTarget<'a> {
Branch(&'a str),
Tag(&'a str),
Commit(&'a str),
}
impl<'a> From<&'a GitRunTarget> for GitFetchTarget<'a> {
fn from(target: &'a GitRunTarget) -> Self {
if let Some(sha) = target.sha.as_deref() {
Self::Commit(sha)
} else if let Some(tag) = target.tag.as_deref() {
Self::Tag(tag)
} else {
Self::Branch(&target.branch)
}
}
}
impl GitFetchTarget<'_> {
fn selector(&self) -> Cow<'_, str> {
match self {
Self::Branch(selector) | Self::Commit(selector) => Cow::Borrowed(selector),
Self::Tag(tag) => Cow::Owned(format!("refs/tags/{tag}")),
}
}
fn checkout_error(&self, source: GitCommandError) -> GitCheckoutError {
match self {
Self::Branch(branch) => GitCheckoutError::FetchBranch {
branch: (*branch).to_string(),
source,
},
Self::Tag(tag) => GitCheckoutError::FetchTag {
tag: (*tag).to_string(),
source,
},
Self::Commit(sha) => GitCheckoutError::FetchCommit {
sha: (*sha).to_string(),
source,
},
}
}
}
async fn bare_clone_may_be_corrupt(bare_dir: &Path) -> bool {
match fs::metadata(&bare_dir.join("HEAD")).await {
Ok(meta) => meta.len() == 0,
@ -348,7 +452,8 @@ fn build_worktree_prune_plan(bare_dir: &Path) -> GitCommandPlan {
}
fn build_rev_parse_fetch_head_plan(bare_dir: &Path) -> GitCommandPlan {
GitCommandPlan::new(["rev-parse", "FETCH_HEAD"], GIT_REV_PARSE_TIMEOUT).current_dir(bare_dir)
GitCommandPlan::new(["rev-parse", "FETCH_HEAD^{commit}"], GIT_REV_PARSE_TIMEOUT)
.current_dir(bare_dir)
}
async fn add_worktree_with_stale_retry(
@ -360,14 +465,14 @@ async fn add_worktree_with_stale_retry(
Ok(_) => Ok(()),
Err(first_err) => {
tracing::warn!(
%first_err,
error = ?first_err,
bare_dir = %bare_dir.display(),
worktree_dir = %worktree_dir.display(),
"git worktree add failed; pruning stale worktree entries and retrying"
);
if let Err(prune_err) = run_git_plan(build_worktree_prune_plan(bare_dir)).await {
tracing::warn!(
%prune_err,
error = ?prune_err,
bare_dir = %bare_dir.display(),
"failed to prune stale git worktree entries"
);
@ -375,11 +480,12 @@ async fn add_worktree_with_stale_retry(
run_git_plan(build_worktree_add_plan(bare_dir, worktree_dir, target))
.await
.map(|_| ())
.map_err(|source| GitCheckoutError::AddWorktree { source })
}
}
}
async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCheckoutError> {
async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCommandError> {
let mut command = Command::new(&plan.program);
command.args(&plan.args);
command.envs(plan.env.iter().map(|(key, value)| (key, value)));
@ -390,18 +496,13 @@ async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCheckoutError>
let output = time::timeout(plan.timeout, command.output())
.await
.map_err(|_| {
GitCheckoutError::CloneFailed(format!(
"{} timed out after {}s",
safe_command_label(&plan),
plan.timeout.as_secs()
))
.map_err(|_| GitCommandError::Timeout {
command: safe_command_label(&plan),
timeout_secs: plan.timeout.as_secs(),
})?
.map_err(|err| {
GitCheckoutError::CloneFailed(format!(
"failed to run {}: {err}",
safe_command_label(&plan)
))
.map_err(|err| GitCommandError::Spawn {
command: safe_command_label(&plan),
source: err,
})?;
if output.status.success() {
@ -422,10 +523,9 @@ async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCheckoutError>
message.push_str(": ");
message.push_str(stdout.trim());
}
Err(GitCheckoutError::CloneFailed(redact_git_output(
&message,
&plan.sensitive_values,
)))
Err(GitCommandError::Exit {
message: redact_git_output(&message, &plan.sensitive_values),
})
}
fn safe_command_label(plan: &GitCommandPlan) -> String {
@ -466,6 +566,15 @@ mod tests {
GitHubRepositorySlug::try_new(value).expect("slug should parse")
}
fn git_target(branch: &str, tag: Option<&str>, sha: Option<&str>) -> GitRunTarget {
GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: branch.to_string(),
tag: tag.map(str::to_string),
sha: sha.map(str::to_string),
}
}
#[test]
fn target_repository_urls_are_github_metadata_urls_without_credentials() {
let repo = repository_slug("fabro-sh/fabro");
@ -546,7 +655,7 @@ mod tests {
assert_eq!(prune.timeout, Duration::from_secs(10));
let rev_parse = build_rev_parse_fetch_head_plan(&bare_dir);
assert_eq!(rev_parse.args, vec!["rev-parse", "FETCH_HEAD"]);
assert_eq!(rev_parse.args, vec!["rev-parse", "FETCH_HEAD^{commit}"]);
assert_eq!(rev_parse.current_dir.as_deref(), Some(bare_dir.as_path()));
assert_eq!(rev_parse.timeout, Duration::from_secs(10));
}
@ -638,11 +747,16 @@ mod tests {
.args(["-C", work.to_str().unwrap(), "commit", "-m", "seed"])
.status()
.expect("git commit seed");
std::process::Command::new("git")
.args(["-C", work.to_str().unwrap(), "tag", "-a", "v1", "-m", "v1"])
.status()
.expect("git tag seed");
std::process::Command::new("git")
.args([
"-C",
work.to_str().unwrap(),
"push",
"--follow-tags",
upstream.to_str().unwrap(),
"main",
])
@ -689,13 +803,14 @@ mod tests {
let cache = GitRepoCache::new(temp.path().join("cache"));
let repo = repository_slug("fabro-sh/fabro");
let upstream_url = upstream.to_str().unwrap().to_string();
let target = git_target("main", None, None);
let worktree_a = temp.path().join("wt-a");
let sha_a = cache
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
ref_selector: "main",
target: &target,
auth: None,
worktree_dir: &worktree_a,
},
@ -717,7 +832,7 @@ mod tests {
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
ref_selector: "main",
target: &target,
auth: None,
worktree_dir: &worktree_b,
},
@ -741,13 +856,14 @@ mod tests {
let cache = GitRepoCache::new(temp.path().join("cache"));
let repo = repository_slug("fabro-sh/fabro");
let upstream_url = upstream.to_str().unwrap().to_string();
let target = git_target("main", None, None);
let worktree_a = temp.path().join("wt-a");
cache
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
ref_selector: "main",
target: &target,
auth: None,
worktree_dir: &worktree_a,
},
@ -765,7 +881,7 @@ mod tests {
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
ref_selector: "main",
target: &target,
auth: None,
worktree_dir: &worktree_b,
},
@ -781,4 +897,84 @@ mod tests {
.is_empty()
);
}
#[tokio::test]
async fn tag_and_exact_commit_modes_return_the_peeled_sha() {
let temp = TempDir::new().unwrap();
let upstream = temp.path().join("upstream.git");
let expected_sha = seed_upstream(&upstream);
let cache = GitRepoCache::new(temp.path().join("cache"));
let repo = repository_slug("fabro-sh/fabro");
let upstream_url = upstream.to_str().unwrap().to_string();
for (name, target) in [
("tag", git_target("main", Some("v1"), None)),
(
"pinned-tag",
git_target("main", Some("v1"), Some(&expected_sha)),
),
("commit", git_target("main", None, Some(&expected_sha))),
] {
let sha = cache
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &target,
auth: None,
worktree_dir: &temp.path().join(name),
},
&upstream_url,
)
.await
.expect("target should materialize");
assert_eq!(sha, expected_sha, "{name}");
}
}
#[tokio::test]
async fn missing_tag_and_unavailable_commit_are_distinct_errors() {
let temp = TempDir::new().unwrap();
let upstream = temp.path().join("upstream.git");
seed_upstream(&upstream);
let cache = GitRepoCache::new(temp.path().join("cache"));
let repo = repository_slug("fabro-sh/fabro");
let upstream_url = upstream.to_str().unwrap().to_string();
let missing_tag = git_target("main", Some("missing"), None);
let unavailable_sha = "ffffffffffffffffffffffffffffffffffffffff";
let unavailable_commit = git_target("main", None, Some(unavailable_sha));
let tag_error = cache
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &missing_tag,
auth: None,
worktree_dir: &temp.path().join("missing-tag"),
},
&upstream_url,
)
.await
.expect_err("missing tag should fail");
assert!(matches!(
tag_error,
GitCheckoutError::FetchTag { tag, .. } if tag == "missing"
));
let commit_error = cache
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &unavailable_commit,
auth: None,
worktree_dir: &temp.path().join("missing-commit"),
},
&upstream_url,
)
.await
.expect_err("unavailable commit should fail");
assert!(matches!(
commit_error,
GitCheckoutError::FetchCommit { sha, .. } if sha == unavailable_sha
));
}
}

View file

@ -37,6 +37,7 @@ mod request_id;
mod run_compiler;
mod run_files;
mod run_files_security;
mod run_intent;
mod run_manifest;
mod run_selector;
mod run_title_generation;

View file

@ -7,9 +7,12 @@ use fabro_vault::SecretStore;
mod legacy_vault_entries;
#[path = "../migrations/2026052501_optional_server_env_secrets_to_vault.rs"]
mod optional_server_env_secrets_to_vault;
#[path = "../migrations/2026082301_sqlite_blob_activation.rs"]
mod sqlite_blob_activation;
pub(crate) use legacy_vault_entries::REMOVAL_DEADLINE as LEGACY_VAULT_REMOVAL_DEADLINE;
pub(crate) use optional_server_env_secrets_to_vault::REMOVAL_DEADLINE as OPTIONAL_SERVER_ENV_SECRETS_REMOVAL_DEADLINE;
pub(crate) use sqlite_blob_activation::activate_blob_storage;
pub(crate) type LegacyVaultMigrationReport = legacy_vault_entries::LegacyVaultMigrationReport;
pub(crate) type OptionalServerEnvSecretsMigrationReport =

View file

@ -37,7 +37,7 @@ use fabro_model::{Catalog, ProviderId};
use fabro_types::settings::interp::{InterpString, ResolveError};
use fabro_types::settings::run::{McpServerSettings, RunGoal};
use fabro_types::{
AutomationRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,
AutomationRef, GitContext, ManifestPath, RunId, RunProvenance, RunTarget, WorkflowSettings,
WorkflowVersionId,
};
use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError};
@ -80,7 +80,7 @@ pub(crate) struct RawRunCompilerInput {
pub(crate) server_run_defaults: RunLayer,
pub(crate) server_environment_defaults: MergeMap<EnvironmentLayer>,
pub(crate) server_mcp_catalog: HashMap<String, McpServerSettings>,
pub(crate) project_settings: Vec<ProjectSettingsSource>,
pub(crate) settings_input: RunCompilerSettingsInput,
pub(crate) user_toml: Vec<String>,
pub(crate) run_overrides: Option<RunLayer>,
pub(crate) cli_overrides: Option<CliLayer>,
@ -93,12 +93,25 @@ pub(crate) struct RawRunCompilerInput {
pub(crate) storage_root: PathBuf,
pub(crate) workflow_slug: Option<String>,
pub(crate) workflow_version_id: Option<WorkflowVersionId>,
pub(crate) target: Option<RunTarget>,
pub(crate) provenance: RunProvenance,
pub(crate) web_url: Option<String>,
pub(crate) submitted_manifest_bytes: Option<Vec<u8>>,
pub(crate) automation: Option<AutomationRef>,
}
/// Settings already admitted by the caller, or the unchanged legacy manifest
/// inputs that still need their historical parsing and lookup behavior.
#[derive(Debug)]
pub(crate) enum RunCompilerSettingsInput {
LegacyManifest {
project_settings: Vec<ProjectSettingsSource>,
},
Admitted {
workflow_layer: Option<Box<SettingsLayer>>,
},
}
/// Stage-one output: the selected bundled workflow and all client settings
/// sources have been parsed and normalized, but no settings have been layered.
pub(crate) struct NormalizedRun {
@ -124,6 +137,7 @@ struct RunMetadata {
storage_root: PathBuf,
workflow_slug: Option<String>,
workflow_version_id: Option<WorkflowVersionId>,
target: Option<RunTarget>,
submitted_manifest_bytes: Option<Vec<u8>>,
title: Option<String>,
automation: Option<AutomationRef>,
@ -157,6 +171,16 @@ impl PreparedRun {
&self.layered.settings
}
pub(crate) fn with_target_and_git(
mut self,
target: RunTarget,
git: Option<GitContext>,
) -> Self {
self.layered.metadata.target = Some(target);
self.layered.metadata.git = git;
self
}
pub(crate) fn with_identity(
mut self,
run_id: Option<RunId>,
@ -305,7 +329,7 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result<NormalizedR
server_run_defaults,
server_environment_defaults,
server_mcp_catalog,
project_settings,
settings_input,
user_toml,
run_overrides,
cli_overrides,
@ -318,6 +342,7 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result<NormalizedR
storage_root,
workflow_slug,
workflow_version_id,
target,
provenance,
web_url,
submitted_manifest_bytes,
@ -331,32 +356,40 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result<NormalizedR
})?;
workflow.path = entrypoint.clone();
let workflow_layer = workflow
.config
.as_ref()
.map(|config| {
settings_layer_with_resolved_dockerfiles(
&config.source,
&config.path,
&workflow.files,
SettingsSource::Workflow,
)
})
.transpose()?;
let project_layers = project_settings
.into_iter()
.map(|project| {
let path = project
.path
.map_err(|source| invalid_settings(InvalidSettingsError::ProjectPath { source }))?;
settings_layer_with_resolved_dockerfiles(
&project.toml,
&path,
&workflow.files,
SettingsSource::Project,
)
})
.collect::<Result<Vec<_>>>()?;
let (workflow_layer, project_layers) = match settings_input {
RunCompilerSettingsInput::LegacyManifest { project_settings } => {
let workflow_layer = workflow
.config
.as_ref()
.map(|config| {
settings_layer_with_resolved_dockerfiles(
&config.source,
&config.path,
&workflow.files,
SettingsSource::Workflow,
)
})
.transpose()?;
let project_layers = project_settings
.into_iter()
.map(|project| {
let path = project.path.map_err(|source| {
invalid_settings(InvalidSettingsError::ProjectPath { source })
})?;
settings_layer_with_resolved_dockerfiles(
&project.toml,
&path,
&workflow.files,
SettingsSource::Project,
)
})
.collect::<Result<Vec<_>>>()?;
(workflow_layer, project_layers)
}
RunCompilerSettingsInput::Admitted { workflow_layer } => {
(workflow_layer.map(|layer| *layer), Vec::new())
}
};
Ok(NormalizedRun {
workflow_bundle,
@ -378,6 +411,7 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result<NormalizedR
storage_root,
workflow_slug,
workflow_version_id,
target,
submitted_manifest_bytes,
title,
automation,
@ -539,6 +573,7 @@ pub(crate) fn assemble_run(pinned: PinnedRun) -> CreateRunPersistenceInput {
storage_root,
workflow_slug,
workflow_version_id,
target,
submitted_manifest_bytes,
title,
automation,
@ -552,6 +587,7 @@ pub(crate) fn assemble_run(pinned: PinnedRun) -> CreateRunPersistenceInput {
storage_root,
workflow_slug,
workflow_version_id,
target,
submitted_manifest_bytes,
title,
automation,
@ -706,7 +742,9 @@ mod tests {
server_run_defaults: RunLayer::default(),
server_environment_defaults: fabro_environment::seeded_catalog_layer(),
server_mcp_catalog: HashMap::new(),
project_settings: Vec::new(),
settings_input: RunCompilerSettingsInput::LegacyManifest {
project_settings: Vec::new(),
},
user_toml: Vec::new(),
run_overrides: None,
cli_overrides: None,
@ -719,6 +757,7 @@ mod tests {
storage_root: PathBuf::from("/tmp/fabro-storage"),
workflow_slug: None,
workflow_version_id: None,
target: None,
provenance: provenance(),
web_url: None,
submitted_manifest_bytes: None,
@ -843,7 +882,12 @@ target = "workflow"
include = ["reports/{{ vars.owner }}/*.json"]
"#;
let mut input = raw_input(Some(workflow_toml), HashMap::new());
input.project_settings.push(ProjectSettingsSource {
let RunCompilerSettingsInput::LegacyManifest { project_settings } =
&mut input.settings_input
else {
panic!("test fixture should use legacy manifest settings");
};
project_settings.push(ProjectSettingsSource {
path: Ok(manifest_path(".fabro/project.toml")),
toml: r#"
_version = 1

View file

@ -2382,6 +2382,7 @@ index 1111111..2222222 160000
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
source_directory: None,
labels: HashMap::default(),

View file

@ -0,0 +1,770 @@
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use fabro_config::parse::SettingsSource;
use fabro_config::{EnvironmentLayer, RunEnvironmentLayer, RunGoalLayer, SettingsLayer};
use fabro_environment::{EnvironmentId, EnvironmentValidationError};
use fabro_types::settings::InterpString;
use fabro_types::{
GitContext, ManifestPath, RunTarget, SandboxProviderKind, TargetValidationError, WorkflowPath,
WorkflowVersionId,
};
use fabro_workflow::git;
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
use fabro_workflow_version::{LoadedWorkflowVersionClosure, ValidatedWorkflowVersion};
use thiserror::Error;
use tokio::{fs, task};
use crate::run_compiler::{RunCompilerError, settings_layer_with_resolved_dockerfiles};
#[derive(Debug, Error)]
pub(crate) enum RunIntentAdmissionError {
#[error("workflow-version closure could not be loaded")]
VersionStore {
#[source]
source: fabro_workflow_version::WorkflowVersionStoreError,
},
#[error(transparent)]
Lowering(#[from] WorkflowClosureLoweringError),
#[error(transparent)]
Target(#[from] TargetValidationError),
#[error(transparent)]
FolderTarget(#[from] FolderTargetValidationError),
#[error(transparent)]
Environment(#[from] EnvironmentSelectionError),
#[error(transparent)]
Compiler(#[from] RunCompilerError),
#[error("run variables could not be loaded")]
VariableSnapshot {
#[source]
source: fabro_variable::Error,
},
}
#[derive(Debug, Error)]
pub(crate) enum FolderTargetValidationError {
#[error("folder target path must be absolute")]
Relative,
#[error("folder target path does not name an accessible filesystem entry")]
Canonicalize {
#[source]
source: std::io::Error,
},
#[error("folder target path must name a directory")]
NotDirectory,
#[error("folder target canonical path must be valid UTF-8")]
NonUtf8,
}
#[derive(Debug)]
pub(crate) struct PreparedIntentTarget {
pub(crate) target: RunTarget,
pub(crate) git: Option<GitContext>,
}
/// Materialize filesystem-backed target facts after the effective environment
/// has been admitted as Local and before run allocation. Folder targets are
/// canonicalized once for durable identity and their optional Git metadata is
/// observed under the same provider gate, so rejected requests never scan host
/// repositories. Other targets pass through with their validated projection.
pub(crate) async fn prepare_intent_target(
target: RunTarget,
git: Option<GitContext>,
) -> Result<PreparedIntentTarget, FolderTargetValidationError> {
let RunTarget::Folder { path } = target else {
return Ok(PreparedIntentTarget { target, git });
};
let submitted = PathBuf::from(path);
if !submitted.is_absolute() {
return Err(FolderTargetValidationError::Relative);
}
let canonical = fs::canonicalize(&submitted)
.await
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
let metadata = fs::metadata(&canonical)
.await
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
if !metadata.is_dir() {
return Err(FolderTargetValidationError::NotDirectory);
}
let path = canonical_folder_text(&canonical)?;
let git = task::spawn_blocking(move || {
git::observe_git_context(&canonical).unwrap_or_else(|error| {
tracing::warn!(
error = ?error,
path = %canonical.display(),
"failed to observe optional git metadata for folder target"
);
None
})
})
.await
.unwrap_or_else(|error| {
tracing::warn!(
error = ?error,
path,
"folder target git observation task failed"
);
None
});
Ok(PreparedIntentTarget {
target: RunTarget::Folder { path },
git,
})
}
fn canonical_folder_text(path: &Path) -> Result<String, FolderTargetValidationError> {
path.to_str()
.map(str::to_string)
.ok_or(FolderTargetValidationError::NonUtf8)
}
#[derive(Debug, Error)]
pub(crate) enum EnvironmentSelectionError {
#[error("invalid environment ID `{value}`")]
InvalidId {
value: String,
#[source]
source: EnvironmentValidationError,
},
#[error("environment `{id}` not found")]
NotFound { id: EnvironmentId },
#[error("{detail}")]
TargetUnsupported { detail: &'static str },
#[error("{detail}")]
ProviderDisabled {
provider: SandboxProviderKind,
detail: String,
},
#[error("{name} is not configured for sandbox provider `{provider}`")]
MissingCredential {
provider: SandboxProviderKind,
name: &'static str,
},
#[error("failed to read sandbox credential `{name}`")]
CredentialStore {
name: &'static str,
#[source]
source: fabro_vault::SecretStoreError,
},
}
#[derive(Debug)]
pub(crate) struct LoweredWorkflowClosure {
pub(crate) workflow_bundle: WorkflowBundle,
pub(crate) entrypoint: ManifestPath,
pub(crate) workflow_layer: Option<SettingsLayer>,
}
/// Ceiling on the number of distinct workflow mounts one closure may expand
/// into. Mounts are keyed by rebased path, so a small dependency graph that
/// re-mounts shared versions along many paths can otherwise expand
/// exponentially and stall admission on a single request.
const MAX_WORKFLOW_MOUNTS: usize = 256;
#[derive(Debug, Error)]
pub(crate) enum WorkflowClosureLoweringError {
#[error("workflow version `{id}` is missing from the loaded closure")]
MissingVersion { id: WorkflowVersionId },
#[error("workflow path `{path}` cannot be mounted at `{mount}`")]
InvalidMount {
path: WorkflowPath,
mount: ManifestPath,
},
#[error("workflow mount `{path}` resolves to two different workflow versions")]
ConflictingMount { path: ManifestPath },
#[error("workflow version closure expands into more than {limit} workflow mounts")]
MountLimitExceeded { limit: usize },
#[error("workflow-version settings are unusable")]
Settings {
#[source]
source: Box<RunCompilerError>,
},
}
pub(crate) fn lower_workflow_closure(
closure: &LoadedWorkflowVersionClosure,
) -> Result<LoweredWorkflowClosure, WorkflowClosureLoweringError> {
let entrypoint = manifest_path(closure.root().entrypoint(), closure.root().entrypoint())?;
let mut mounts = HashMap::new();
let mut workflows = HashMap::new();
mount_version(
closure,
closure.root_id(),
entrypoint.clone(),
&mut mounts,
&mut workflows,
)?;
let root_workflow = workflows
.get(&entrypoint)
.expect("root workflow should be mounted");
let workflow_layer = root_workflow
.config
.as_ref()
.map(|config| {
settings_layer_with_resolved_dockerfiles(
&config.source,
&config.path,
&root_workflow.files,
SettingsSource::Workflow,
)
.map_err(|source| WorkflowClosureLoweringError::Settings {
source: Box::new(source),
})
.map(|mut layer| {
inline_goal_file(&mut layer, closure.validated_root());
layer
})
})
.transpose()?;
Ok(LoweredWorkflowClosure {
workflow_bundle: WorkflowBundle::new(workflows),
entrypoint,
workflow_layer,
})
}
pub(crate) fn pin_workflow_environment_authority(layer: &mut SettingsLayer, environment_id: &str) {
// Both blocks destructure without `..` so adding a field to either layer
// type forces a compile-time decision here: server-owned facts are
// cleared off the immutable workflow layer, workflow-owned facts pass
// through.
if let Some(environment) = layer.environments.get_mut(environment_id) {
let EnvironmentLayer {
provider,
cwd,
image,
resources: _,
network: _,
lifecycle: _,
labels: _,
env: _,
} = environment;
*provider = None;
*cwd = None;
*image = None;
}
if let Some(environment) = layer.run.as_mut().and_then(|run| run.environment.as_mut()) {
let RunEnvironmentLayer {
id: _,
image,
resources: _,
network: _,
lifecycle: _,
labels: _,
env: _,
} = environment;
*image = None;
}
}
fn mount_version(
closure: &LoadedWorkflowVersionClosure,
id: WorkflowVersionId,
mounted_entrypoint: ManifestPath,
mounts: &mut HashMap<ManifestPath, WorkflowVersionId>,
workflows: &mut HashMap<ManifestPath, BundledWorkflow>,
) -> Result<(), WorkflowClosureLoweringError> {
if let Some(existing) = mounts.get(&mounted_entrypoint) {
return if *existing == id {
Ok(())
} else {
Err(WorkflowClosureLoweringError::ConflictingMount {
path: mounted_entrypoint,
})
};
}
mounts.insert(mounted_entrypoint.clone(), id);
// Recursion depth is bounded by the mount count (every level inserts a
// distinct mount before descending), so this cap also bounds the stack.
if mounts.len() > MAX_WORKFLOW_MOUNTS {
return Err(WorkflowClosureLoweringError::MountLimitExceeded {
limit: MAX_WORKFLOW_MOUNTS,
});
}
let version = closure
.get(&id)
.ok_or(WorkflowClosureLoweringError::MissingVersion { id })?;
let mut files = HashMap::new();
for (path, content) in version.files() {
files.insert(
manifest_path(version.entrypoint(), path).and_then(|local| {
rebase_path(version.entrypoint(), &mounted_entrypoint, &local, path)
})?,
content.clone(),
);
}
let source = version
.files()
.get(version.entrypoint())
.cloned()
.expect("validated workflow versions contain their entrypoint file");
let config_local = WorkflowPath::new("workflow.toml")
.expect("the static workflow config path should be valid");
let config_path = version.files().get(&config_local).map(|source| {
rebase_path(
version.entrypoint(),
&mounted_entrypoint,
&ManifestPath::from_wire(config_local.as_str())
.expect("validated workflow path should be a manifest path"),
&config_local,
)
.map(|path| ParsedWorkflowConfig {
path,
source: source.clone(),
})
});
let config = config_path.transpose()?;
workflows.insert(mounted_entrypoint.clone(), BundledWorkflow {
path: mounted_entrypoint.clone(),
source,
config,
files,
});
for (binding, dependency_id) in version.workflow_dependencies() {
let local = ManifestPath::from_wire(binding.as_str())
.expect("validated workflow path should be a manifest path");
let dependency_mount =
rebase_path(version.entrypoint(), &mounted_entrypoint, &local, binding)?;
mount_version(closure, *dependency_id, dependency_mount, mounts, workflows)?;
}
Ok(())
}
fn manifest_path(
entrypoint: &WorkflowPath,
path: &WorkflowPath,
) -> Result<ManifestPath, WorkflowClosureLoweringError> {
ManifestPath::from_wire(path.as_str()).ok_or_else(|| {
WorkflowClosureLoweringError::InvalidMount {
path: path.clone(),
mount: ManifestPath::from_wire(entrypoint.as_str())
.expect("validated entrypoint should be a manifest path"),
}
})
}
fn rebase_path(
local_entrypoint: &WorkflowPath,
mounted_entrypoint: &ManifestPath,
local_path: &ManifestPath,
workflow_path: &WorkflowPath,
) -> Result<ManifestPath, WorkflowClosureLoweringError> {
let relative = relative_path(
local_entrypoint_parent(local_entrypoint),
local_path.as_path(),
);
let mapped = ManifestPath::from_reference(
mounted_entrypoint.parent_or_dot(),
&relative.to_string_lossy(),
)
.filter(|path| !path.as_path().starts_with(".."))
.ok_or_else(|| WorkflowClosureLoweringError::InvalidMount {
path: workflow_path.clone(),
mount: mounted_entrypoint.clone(),
})?;
Ok(mapped)
}
fn local_entrypoint_parent(entrypoint: &WorkflowPath) -> &Path {
Path::new(entrypoint.as_str())
.parent()
.unwrap_or_else(|| Path::new("."))
}
fn relative_path(base: &Path, path: &Path) -> PathBuf {
let base = base
.components()
.filter_map(normal_component)
.collect::<Vec<_>>();
let path = path
.components()
.filter_map(normal_component)
.collect::<Vec<_>>();
let common = base
.iter()
.zip(&path)
.take_while(|(left, right)| left == right)
.count();
let mut relative = PathBuf::new();
for _ in &base[common..] {
relative.push("..");
}
for component in &path[common..] {
relative.push(component);
}
relative
}
fn normal_component(component: Component<'_>) -> Option<&std::ffi::OsStr> {
match component {
Component::Normal(value) => Some(value),
Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
None
}
}
}
/// Inline a file-form run goal using the goal-file resolution the
/// workflow-version store certified, so the resolution grammar has exactly
/// one owner in `fabro-workflow-version`.
fn inline_goal_file(layer: &mut SettingsLayer, root: &ValidatedWorkflowVersion) {
let Some(goal) = layer.run.as_mut().and_then(|run| run.goal.as_mut()) else {
return;
};
if !matches!(&*goal, RunGoalLayer::File { .. }) {
return;
}
// `layer` is parsed from the same `workflow.toml` source the certified
// root version carries, so a file-form goal here always resolves there.
let content = root
.resolved_goal_file_content()
.expect("stored workflow versions certify their goal-file references");
*goal = RunGoalLayer::Inline(InterpString::parse(content));
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use fabro_types::{WorkflowVersion, WorkflowVersionId};
use fabro_workflow_version::{ValidatedWorkflowVersion, WorkflowVersionStore};
use super::*;
fn workflow_path(value: &str) -> WorkflowPath {
WorkflowPath::new(value).unwrap()
}
fn version(
entrypoint: &str,
files: impl IntoIterator<Item = (&'static str, &'static str)>,
dependencies: impl IntoIterator<Item = (&'static str, WorkflowVersionId)>,
) -> ValidatedWorkflowVersion {
let files = files
.into_iter()
.map(|(path, source)| (workflow_path(path), source.to_string()))
.collect();
let dependencies = dependencies
.into_iter()
.map(|(path, id)| (workflow_path(path), id))
.collect::<BTreeMap<_, _>>();
ValidatedWorkflowVersion::new(
WorkflowVersion::new(workflow_path(entrypoint), files, dependencies).unwrap(),
)
.unwrap()
}
#[tokio::test]
async fn prepares_a_canonical_folder_target_without_git_projection() {
let dir = tempfile::tempdir().unwrap();
let target_dir = dir.path().join("target");
std::fs::create_dir(&target_dir).unwrap();
std::fs::create_dir(dir.path().join("nested")).unwrap();
let submitted = dir.path().join("nested").join("..").join("target");
let prepared = prepare_intent_target(
RunTarget::Folder {
path: submitted.to_string_lossy().to_string(),
},
None,
)
.await
.unwrap();
let canonical = target_dir.canonicalize().unwrap();
assert_eq!(prepared.git, None);
assert_eq!(prepared.target, RunTarget::Folder {
path: canonical.to_string_lossy().to_string(),
});
}
#[tokio::test]
async fn rejects_relative_missing_and_file_folder_targets() {
let relative = prepare_intent_target(
RunTarget::Folder {
path: "relative/path".to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(relative, FolderTargetValidationError::Relative));
let dir = tempfile::tempdir().unwrap();
let missing = prepare_intent_target(
RunTarget::Folder {
path: dir.path().join("missing").to_string_lossy().to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(
missing,
FolderTargetValidationError::Canonicalize { .. }
));
let file = dir.path().join("file");
fs::write(&file, "not a directory").await.unwrap();
let file = prepare_intent_target(
RunTarget::Folder {
path: file.to_string_lossy().to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(file, FolderTargetValidationError::NotDirectory));
}
#[cfg(unix)]
#[test]
fn rejects_a_non_utf8_canonical_folder_target() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let path = PathBuf::from(OsString::from_vec(vec![b'f', b'o', 0x80]));
let error = canonical_folder_text(&path).unwrap_err();
assert!(matches!(error, FolderTargetValidationError::NonUtf8));
}
#[tokio::test]
async fn lowers_nested_entrypoints_and_inlines_goal_files() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
let grandchild = version(
"deep/leaf.fabro",
[("deep/leaf.fabro", "digraph Leaf {}")],
[],
);
let grandchild_id = store.put(&grandchild).await.unwrap();
let child = version(
"pkg/child.fabro",
[(
"pkg/child.fabro",
"digraph Child { leaf [stack.child_workflow=\"../nested/leaf.fabro\"] }",
)],
[("nested/leaf.fabro", grandchild_id)],
);
let child_id = store.put(&child).await.unwrap();
let root = version(
"flows/root.fabro",
[
(
"flows/root.fabro",
"digraph Root { child [stack.child_workflow=\"../deps/run.fabro\"] }",
),
(
"workflow.toml",
"_version = 1\n[run.goal]\nfile = \"goal.md\"\n",
),
("goal.md", "Ship {{ vars.owner }}"),
],
[("deps/run.fabro", child_id)],
);
let root_id = store.put(&root).await.unwrap();
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
let lowered = lower_workflow_closure(&closure).unwrap();
assert!(
lowered
.workflow_bundle
.workflow(&lowered.entrypoint)
.is_some()
);
assert!(
lowered
.workflow_bundle
.workflow(&ManifestPath::from_wire("deps/run.fabro").unwrap())
.is_some()
);
assert!(
lowered
.workflow_bundle
.workflow(&ManifestPath::from_wire("nested/leaf.fabro").unwrap())
.is_some()
);
assert!(matches!(
lowered
.workflow_layer
.as_ref()
.and_then(|layer| layer.run.as_ref())
.and_then(|run| run.goal.as_ref()),
Some(RunGoalLayer::Inline(_))
));
}
#[tokio::test]
async fn lowers_same_version_at_distinct_mount_paths() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
let child = version(
"pkg/child.fabro",
[("pkg/child.fabro", "digraph Child {}")],
[],
);
let child_id = store.put(&child).await.unwrap();
let root = version(
"flows/root.fabro",
[(
"flows/root.fabro",
"digraph Root { one [stack.child_workflow=\"../children/one.fabro\"] two [stack.child_workflow=\"../children/two.fabro\"] }",
)],
[
("children/one.fabro", child_id),
("children/two.fabro", child_id),
],
);
let root_id = store.put(&root).await.unwrap();
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
let lowered = lower_workflow_closure(&closure).unwrap();
assert!(
lowered
.workflow_bundle
.workflow(&ManifestPath::from_wire("children/one.fabro").unwrap())
.is_some()
);
assert!(
lowered
.workflow_bundle
.workflow(&ManifestPath::from_wire("children/two.fabro").unwrap())
.is_some()
);
}
#[tokio::test]
async fn rejects_closures_that_expand_past_the_mount_limit() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
// A chain of tiny versions where each level mounts the next twice is
// cheap to store and load (the closure dedupes by id) but expands to
// 2^depth distinct mounts.
let leaf = version("flow.fabro", [("flow.fabro", "digraph Leaf {}")], []);
let mut previous = store.put(&leaf).await.unwrap();
for _ in 0..9 {
let fan = version(
"flow.fabro",
[(
"flow.fabro",
"digraph Fan { a [stack.child_workflow=\"a/next.fabro\"] b [stack.child_workflow=\"b/next.fabro\"] }",
)],
[("a/next.fabro", previous), ("b/next.fabro", previous)],
);
previous = store.put(&fan).await.unwrap();
}
let closure = store.get_closure(&previous).await.unwrap().unwrap();
let error = lower_workflow_closure(&closure).unwrap_err();
assert!(matches!(
error,
WorkflowClosureLoweringError::MountLimitExceeded {
limit: MAX_WORKFLOW_MOUNTS,
}
));
}
#[tokio::test]
async fn rejects_distinct_versions_that_converge_on_one_mount_path() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
let first_leaf = version(
"leaf/first.fabro",
[("leaf/first.fabro", "digraph FirstLeaf {}")],
[],
);
let first_leaf_id = store.put(&first_leaf).await.unwrap();
let second_leaf = version(
"leaf/second.fabro",
[("leaf/second.fabro", "digraph SecondLeaf {}")],
[],
);
let second_leaf_id = store.put(&second_leaf).await.unwrap();
let first_parent = version(
"a/first.fabro",
[(
"a/first.fabro",
"digraph FirstParent { child [stack.child_workflow=\"../shared/collision.fabro\"] }",
)],
[("shared/collision.fabro", first_leaf_id)],
);
let first_parent_id = store.put(&first_parent).await.unwrap();
let second_parent = version(
"b/second.fabro",
[(
"b/second.fabro",
"digraph SecondParent { child [stack.child_workflow=\"../shared/collision.fabro\"] }",
)],
[("shared/collision.fabro", second_leaf_id)],
);
let second_parent_id = store.put(&second_parent).await.unwrap();
let root = version(
"flows/root.fabro",
[(
"flows/root.fabro",
"digraph Root { first [stack.child_workflow=\"../left/first.fabro\"] second [stack.child_workflow=\"../right/second.fabro\"] }",
)],
[
("left/first.fabro", first_parent_id),
("right/second.fabro", second_parent_id),
],
);
let root_id = store.put(&root).await.unwrap();
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
let error = lower_workflow_closure(&closure).unwrap_err();
assert!(matches!(
error,
WorkflowClosureLoweringError::ConflictingMount { .. }
));
}
#[tokio::test]
async fn rejects_rebased_files_that_escape_the_runtime_root() {
let (database, _) = crate::test_support::test_store_bundle();
let blobs = database.blobs();
let store = WorkflowVersionStore::new(blobs);
let child = version(
"nested/child.fabro",
[
("nested/child.fabro", "digraph Child {}"),
("workflow.toml", "_version = 1"),
],
[],
);
let child_id = store.put(&child).await.unwrap();
let root = version(
"root.fabro",
[(
"root.fabro",
"digraph Root { child [stack.child_workflow=\"child.fabro\"] }",
)],
[("child.fabro", child_id)],
);
let root_id = store.put(&root).await.unwrap();
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
let error = lower_workflow_closure(&closure).unwrap_err();
assert!(matches!(
error,
WorkflowClosureLoweringError::InvalidMount { .. }
));
}
}

View file

@ -935,6 +935,7 @@ fn preflight_sandbox_spec(
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
}
}
@ -947,6 +948,7 @@ fn preflight_sandbox_spec(
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
api_key: daytona_api_key,
}

View file

@ -57,7 +57,7 @@ pub(crate) async fn generate_title_or_current(input: GenerateTitleInput<'_>) ->
let result = match generate::generate_object(params, title_response_schema()).await {
Ok(result) => result,
Err(err) => {
tracing::debug!(error = %err, "Run title generation failed");
tracing::warn!(run_id = %input.prompt.run_id, error = %err, "Run title generation failed");
return current_title;
}
};

View file

@ -773,14 +773,27 @@ where
} else {
None
};
let store = Arc::new(fabro_store::Database::new(
let store = migrations::activate_blob_storage(
&database,
&sqlite_path,
object_store,
slatedb_prefix,
flush_interval,
cache_path,
));
let auth_code_store = store.auth_codes().await?;
let auth_token_store = store.refresh_tokens().await?;
)
.await
.context("activating SQLite blob storage")?;
// Refresh tokens now live in SQLite. Nothing reads the old records and no
// reaper collects them any more, so clear them out once rather than
// leaving them in the object store forever. Pending authorization codes
// also moved to SQLite, but their old records are left in place: at most a
// handful exist at cutover, every binary rejects them within 60 seconds of
// issue, and nothing reads their keyspace again.
match store.retire_refresh_token_keyspace().await {
Ok(0) => {}
Ok(removed) => info!(removed, "Removed retired SlateDB refresh token records"),
Err(err) => warn!(error = %err, "Failed to remove retired SlateDB refresh token records"),
}
let (artifact_object_store, artifact_prefix) = build_artifact_object_store_with_server_secrets(
&resolved_server_settings,
&server_secrets,
@ -846,8 +859,8 @@ where
.await?;
spawn_auth_store_reapers(
Arc::clone(&auth_code_store),
Arc::clone(&auth_token_store),
Arc::clone(&state.stores.auth_codes),
Arc::clone(&state.stores.auth_sessions),
shutdown.clone(),
);
@ -1117,11 +1130,11 @@ async fn shutdown_signal() {
fn spawn_auth_store_reapers(
auth_codes: Arc<fabro_store::AuthCodeStore>,
auth_tokens: Arc<fabro_store::RefreshTokenStore>,
auth_sessions: Arc<fabro_store::AuthSessionStore>,
shutdown: CancellationToken,
) {
spawn_auth_code_reaper(auth_codes, shutdown.clone());
spawn_refresh_token_reaper(auth_tokens, shutdown);
spawn_refresh_token_reaper(auth_sessions, shutdown);
}
fn spawn_auth_code_reaper(
@ -1146,7 +1159,7 @@ fn spawn_auth_code_reaper(
}
fn spawn_refresh_token_reaper(
auth_tokens: Arc<fabro_store::RefreshTokenStore>,
auth_sessions: Arc<fabro_store::AuthSessionStore>,
shutdown: CancellationToken,
) {
tokio::spawn(async move {
@ -1158,7 +1171,7 @@ fn spawn_refresh_token_reaper(
() = shutdown.cancelled() => break,
_ = interval.tick() => {
let cutoff = chrono::Utc::now() - chrono::Duration::days(7);
if let Err(err) = auth_tokens.gc_expired(cutoff).await {
if let Err(err) = auth_sessions.gc_expired(cutoff).await {
warn!(error = %err, "Failed to garbage collect expired refresh tokens");
}
}

View file

@ -85,8 +85,9 @@ use fabro_slack::threads::ThreadRegistry;
use fabro_slack::{blocks as slack_blocks, connection as slack_connection};
use fabro_static::EnvVars;
use fabro_store::{
ArtifactKey, ArtifactStore, CachedRunProjection, Database, EventEnvelope, EventPayload,
KeyedMutex, NodeArtifact, PendingInterviewRecord, RunSummaryStore, StageArtifactEntry, StageId,
ArtifactKey, ArtifactStore, AuthCodeStore, AuthSessionStore, CachedRunProjection, Database,
EventEnvelope, EventPayload, KeyedMutex, NodeArtifact, PendingInterviewRecord, RunSummaryStore,
StageArtifactEntry, StageId,
};
#[cfg(test)]
use fabro_types::BlockedReason;
@ -174,7 +175,7 @@ use crate::{
mod automation_scheduler;
mod handler;
mod pull_request_supervisor;
mod resource_sampler;
pub(crate) mod resource_sampler;
mod session_runtime;
pub(crate) use automation_scheduler::spawn_automation_scheduler;
@ -1153,6 +1154,8 @@ pub struct AppState {
pub(crate) struct AppStores {
pub(crate) runs: Arc<Database>,
pub(crate) run_summaries: Arc<RunSummaryStore>,
pub(crate) auth_codes: Arc<AuthCodeStore>,
pub(crate) auth_sessions: Arc<AuthSessionStore>,
pub(crate) automations: Arc<AutomationStore>,
pub(crate) environments: Arc<EnvironmentStore>,
pub(crate) mcp_servers: Arc<McpServerStore>,
@ -1160,6 +1163,22 @@ pub(crate) struct AppStores {
pub(crate) variables: Arc<VariableStore>,
}
#[cfg(any(test, feature = "test-support"))]
impl AppState {
/// Access the auth session store so tests can seed CLI sessions against
/// the same SQLite pool the router reads from.
#[must_use]
pub fn test_auth_session_store(&self) -> &Arc<AuthSessionStore> {
&self.stores.auth_sessions
}
/// Access the auth-code store used by this router.
#[must_use]
pub fn test_auth_code_store(&self) -> &Arc<AuthCodeStore> {
&self.stores.auth_codes
}
}
impl AppState {
pub(crate) fn automation_store(&self) -> &AutomationStore {
&self.stores.automations
@ -1186,7 +1205,7 @@ impl AppState {
let credentials = self
.github_credentials(&settings.server.integrations.github)
.await
.map_err(|err| RunMaterializeError::Credentials(err.to_string()))?;
.map_err(|source| RunMaterializeError::Credentials { source })?;
ProductionAutomationRunMaterializer::new(
credentials,
self.github_api_base_url.clone(),
@ -2428,8 +2447,9 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
})
.context("load environments")?,
);
let run_summaries =
store.attach_run_summary_store(Arc::new(RunSummaryStore::new(db_pool.clone())));
let run_summaries = store.run_summary_store();
let auth_codes = Arc::new(AuthCodeStore::new(db_pool.clone()));
let auth_sessions = Arc::new(AuthSessionStore::new(db_pool.clone()));
let mcp_server_dir = mcp_server_dir_for_active_config(&active_config_path);
let mcp_server_pool = db_pool.clone();
let mcp_server_store = Arc::new(
@ -2536,6 +2556,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
stores: AppStores {
runs: store,
run_summaries,
auth_codes,
auth_sessions,
automations: automation_store,
environments: environment_store,
mcp_servers: mcp_server_store,

View file

@ -8,7 +8,7 @@ use croner::errors::CronError;
use fabro_automation::{
Automation, AutomationId, AutomationRevision, AutomationTriggerId, parse_schedule_expression,
};
use fabro_types::{AutomationRef, Principal, RunId, SystemActorKind};
use fabro_types::{AutomationRef, Principal, RunId, RunTarget, SystemActorKind};
use tokio::time::sleep;
use tracing::{Instrument, error, info, info_span, warn};
@ -229,10 +229,18 @@ async fn fire_scheduled_automation_run(
) {
let automation_id = automation.id.clone();
let run_id = RunId::new();
let Some(target) = automation.git_target().cloned() else {
error!(
automation_id = %automation_id,
"Stored automation target is not Git-backed",
);
return;
};
let materialized = match state
.materialize_automation_run(AutomationRunMaterializeInput {
automation_id: automation_id.clone(),
target: automation.target.clone(),
target,
workflow: automation.workflow.clone(),
run_id,
user_settings_path: state.active_config_path().to_path_buf(),
temp_root: state.automation_temp_root(),
@ -243,7 +251,7 @@ async fn fire_scheduled_automation_run(
Err(err) => {
error!(
due_at = %due_at,
error = %err,
error = ?err,
"Failed to materialize scheduled automation run",
);
return;
@ -271,6 +279,7 @@ async fn fire_scheduled_automation_run(
actor: actor.clone(),
headers: HeaderMap::new(),
automation: Some(automation_ref),
target: Some(RunTarget::Git(materialized.target)),
},
))
.await;
@ -335,10 +344,10 @@ fn run_due_schedules_once<'a>(
#[cfg(test)]
mod tests {
use fabro_api::types::RunManifest;
use fabro_automation::{AutomationDraft, AutomationTarget, AutomationTrigger, ScheduleTrigger};
use fabro_automation::{AutomationDraft, AutomationTrigger, ScheduleTrigger};
use fabro_static::EnvVars;
use fabro_store::ListRunsQuery;
use fabro_types::RunStatus;
use fabro_types::{GitRunTarget, RunStatus};
use serde_json::json;
use super::*;
@ -350,14 +359,19 @@ mod tests {
.with_timezone(&Utc)
}
fn target() -> AutomationTarget {
AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main".to_string(),
workflow: "workflow.fabro".to_string(),
fn git_target() -> GitRunTarget {
GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
}
}
fn target() -> RunTarget {
RunTarget::Git(git_target())
}
fn schedule_trigger(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
AutomationTrigger::Schedule(ScheduleTrigger {
id: AutomationTriggerId::new(id).expect("test trigger id should be valid"),
@ -373,6 +387,7 @@ mod tests {
name: name.to_string(),
description: None,
target: target(),
workflow: "workflow.fabro".to_string(),
triggers,
}
}
@ -390,6 +405,7 @@ mod tests {
name: name.to_string(),
description: None,
target: target(),
workflow: "workflow.fabro".to_string(),
triggers,
})
.await
@ -422,7 +438,9 @@ mod tests {
let manifest = minimal_manifest();
let submitted_manifest_bytes =
serde_json::to_vec(&manifest).expect("manifest should serialize");
TestAutomationRunMaterializer::succeed(manifest, submitted_manifest_bytes)
let mut exact_target = git_target();
exact_target.sha = Some("0123456789abcdef0123456789abcdef01234567".to_string());
TestAutomationRunMaterializer::succeed(manifest, submitted_manifest_bytes, exact_target)
}
fn test_state_with_materializer(materializer: TestAutomationRunMaterializer) -> Arc<AppState> {
@ -696,7 +714,7 @@ mod tests {
#[tokio::test]
async fn failing_materializer_waits_until_next_cron_occurrence() {
let materializer = TestAutomationRunMaterializer::fail_invalid_target("boom");
let materializer = TestAutomationRunMaterializer::fail_invalid_target();
let state = test_state_with_materializer(materializer.clone());
create_automation(state.as_ref(), "nightly", "Nightly", vec![
schedule_trigger("schedule", "* * * * *", true),

View file

@ -6,7 +6,8 @@ use fabro_automation::{
Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationStoreError,
};
use fabro_store::{RunSummaryListQuery, RunSummaryVisibility};
use fabro_types::{AutomationRef, RunId};
use fabro_types::{AutomationRef, RunId, RunTarget};
use fabro_util::error as error_util;
use serde::Serialize;
use super::super::{
@ -116,12 +117,20 @@ async fn create_automation_run(
.into_response();
};
let api_trigger_id = api_trigger.id.to_string();
let Some(target) = automation.git_target().cloned() else {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"Stored automation target is not Git-backed",
)
.into_response();
};
let run_id = RunId::new();
let materialized = match state
.materialize_automation_run(AutomationRunMaterializeInput {
automation_id: automation.id.clone(),
target: automation.target.clone(),
target,
workflow: automation.workflow.clone(),
run_id,
user_settings_path: state.active_config_path().to_path_buf(),
temp_root: state.automation_temp_root(),
@ -130,8 +139,8 @@ async fn create_automation_run(
{
Ok(materialized) => materialized,
Err(err) => {
return ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, err.to_string())
.into_response();
let message = error_util::collect_chain(&err).join(": ");
return ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, message).into_response();
}
};
let explicit_title_supplied = materialized.manifest.title.is_some();
@ -151,6 +160,7 @@ async fn create_automation_run(
actor: actor.clone(),
headers,
automation: Some(automation_ref),
target: Some(RunTarget::Git(materialized.target)),
},
))
.await;

View file

@ -1,5 +1,7 @@
use std::sync::Arc;
use axum::extract::DefaultBodyLimit;
use fabro_types::run_event::MAX_RUN_EVENT_BODY_BYTES;
use fabro_types::{
RunEventDetailContent, RunEventDetailContentKind, RunEventDetailEnvelope,
RunEventDetailResponse,
@ -20,7 +22,9 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
.route("/attach", get(attach_events))
.route(
"/runs/{id}/events",
get(list_run_events).post(append_run_event),
get(list_run_events)
.post(append_run_event)
.layer(DefaultBodyLimit::max(MAX_RUN_EVENT_BODY_BYTES)),
)
.route("/runs/{id}/events/{seq}", get(get_run_event_detail))
.route(
@ -625,6 +629,7 @@ mod stage_events_tests {
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,

View file

@ -809,12 +809,12 @@ async fn batch_archive_runs(
State(state): State<Arc<AppState>>,
Json(request): Json<BatchRunLifecycleRequest>,
) -> Response {
batch_run_archive_action(
Box::pin(batch_run_archive_action(
state,
Principal::User(user),
request,
ArchiveAction::Archive,
)
))
.await
}
@ -823,12 +823,12 @@ async fn batch_unarchive_runs(
State(state): State<Arc<AppState>>,
Json(request): Json<BatchRunLifecycleRequest>,
) -> Response {
batch_run_archive_action(
Box::pin(batch_run_archive_action(
state,
Principal::User(user),
request,
ArchiveAction::Unarchive,
)
))
.await
}

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use fabro_auth::ApiCredential;
use fabro_llm::client::Client as LlmClient;
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
use fabro_model::ModelSelectionError;
use fabro_model::{ModelSelectionError, ReasoningEffort};
use fabro_redact::redact_string;
use super::super::{
@ -41,9 +41,11 @@ struct ModelListParams {
#[derive(serde::Deserialize)]
struct ModelTestParams {
#[serde(default)]
mode: Option<String>,
mode: Option<String>,
#[serde(default)]
provider: Option<String>,
provider: Option<String>,
#[serde(default)]
reasoning_effort: Option<String>,
}
async fn list_models(
@ -188,24 +190,32 @@ async fn test_providers(_auth: RequiredUser, State(state): State<Arc<AppState>>)
}
}
fn parse_query_enum<T: FromStr>(value: Option<&str>, label: &str) -> Result<Option<T>, ApiError> {
value
.map(|value| {
T::from_str(value).map_err(|_| {
ApiError::new(StatusCode::BAD_REQUEST, format!("invalid {label}: {value}"))
})
})
.transpose()
}
async fn test_model(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<ModelTestParams>,
) -> Response {
let mode = match params.mode.as_deref() {
Some(value) => match ModelTestMode::from_str(value) {
Ok(mode) => mode,
Err(_) => {
return ApiError::new(
StatusCode::BAD_REQUEST,
format!("invalid model test mode: {value}"),
)
.into_response();
}
},
None => ModelTestMode::Basic,
let mode = match parse_query_enum(params.mode.as_deref(), "model test mode") {
Ok(mode) => mode.unwrap_or(ModelTestMode::Basic),
Err(error) => return error.into_response(),
};
let reasoning_effort = match parse_query_enum::<ReasoningEffort>(
params.reasoning_effort.as_deref(),
"reasoning effort",
) {
Ok(reasoning_effort) => reasoning_effort,
Err(error) => return error.into_response(),
};
let llm_result = match state.resolve_llm_client().await {
Ok(result) => result,
@ -253,7 +263,7 @@ async fn test_model(
}
let client = Arc::new(llm_result.client);
let outcome = run_model_test(info, mode, client).await;
let outcome = run_model_test(info, mode, reasoning_effort, client).await;
Json(serde_json::json!({
"model_id": info.id,
"provider": info.provider,

View file

@ -1025,6 +1025,7 @@ mod tests {
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,

Some files were not shown because too many files have changed in this diff Show more