Compare commits

...

175 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
fabro-releases[bot]
7200d437e9 Bump version to 0.333.0-nightly.0
Some checks failed
Rust / Format (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Generated Docs (push) Has been cancelled
Rust / Test (Linux) (push) Has been cancelled
Rust / Test (macOS) (push) Has been cancelled
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
2026-08-22 04:04:57 +00:00
Bryan Helmkamp
a22d8f48a9
Merge pull request #782 from fabro-sh/prompt-value-budget
Bound what one value may contribute to a prompt preamble
2026-08-21 23:48:49 -04:00
Release Repro
0c50ce641b
Render offloaded prompt values concisely 2026-08-21 23:44:02 -04:00
Bryan Helmkamp
ec5aeeb5c2
Tighten the prompt-demotion pass after review
Apply the cleanup findings from a four-angle review (reuse,
simplification, efficiency, altitude) of the demotion change:

- Share one size gate: serialized_if_over now backs both offload_value
  and demote_value_for_prompt, restoring the cheap short-string and
  scalar pre-checks so per-node demotion no longer serializes every
  small value just to measure it.
- Stop re-writing blobs every node: materialize_value_bytes writes the
  sandbox file directly from the in-hand bytes and short-circuits on the
  content-addressed file's existence, so an already-demoted value costs
  one existence probe instead of a store round-trip per node visit. The
  local file write is shared with materialize_blob_ref.
- Demote over the resolved snapshot map instead of re-snapshotting a
  Context copy, making the context and outcome loops symmetric and
  saving a full deep clone per node; the fidelity lifecycle builds the
  Context after the pass.
- Skip the pass entirely for Full and Truncate fidelities (nothing
  renders context values), except parallel nodes whose branch stash may
  render at a richer fidelity.
- Build is_preamble_hidden_key on is_engine_internal_key instead of
  restating its prefixes, and call it directly from the preamble
  renderer rather than through a wrapper.
- Document that outcome updates are demoted wholesale and that
  BranchWorkItem.item carries the prompt-ready (possibly demoted) item;
  drop the item rebinding and redundant test assertions; restore the
  local integration test's confinement assertion and make the remote
  one non-vacuous.

Skipped by choice: unifying the crate's several truncation helpers and
rendering the marker through the "See:" pointer family (cross-module
coupling out of proportion to the preview cosmetics), per-branch
demotion inside parallel.results (wholesale demotion is what bounds the
total), and cross-node demotion memoization (the file-existence
short-circuit already reduces repeats to a stat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FH8Jj9Y4E4Tu5g1jwDtHAb
2026-08-21 21:21:59 -04:00
Bryan Helmkamp
58f00c85c8
Merge pull request #776 from jesseproudman/feat/venice-search-provider
Add Venice as a web_search backend
2026-08-21 21:11:42 -04:00
Bryan Helmkamp
3217a05aad
Merge origin/main into feat/venice-search-provider 2026-08-21 21:04:28 -04:00
Bryan Helmkamp
88ed2ac9a3
refactor(search): select backend from available credentials 2026-08-21 20:09:52 -04:00
Bryan Helmkamp
15862aac70
Bound what one value may contribute to a prompt preamble
Compact and summary preambles render workflow context values and stage
outputs with no per-value size limit. A late-run node inherits everything
the run has accumulated, and one oversized value (a join result, a jobs
list, a single-line command emit) can push the composed prompt past the
model's context window. A security-review run failed exactly this way:
its dedupe stage assembled a ~1.8M-token prompt against a 1M-token model
limit, made almost entirely of accumulated context the agent never
needed inline.

Reuse the existing blob machinery at the last mile. Before the preamble
builders run, any resolved context or outcome value whose serialized
JSON exceeds 8KB is persisted as a content-addressed blob, materialized
as a real file in the sandbox, and replaced with a small marker holding
a preview, the byte count, and the file path. The agent reads the file
if it needs the data. for_each items get the same treatment at fan-out
with a more generous 64KB budget, since the item is the branch's work
assignment; branch labels still come from the full item. Keys the
preamble never renders are left alone, and a value that fails to demote
stays inline and is logged: demotion bounds prompt size, it does not
gate execution.

The two downstream-resolution integration tests asserted that resolving
text values writes no files; demotion now legitimately materializes the
oversized response for preamble use, so they instead pin that resolution
returned the full inline text and that nothing is written outside the
sandbox blob directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FH8Jj9Y4E4Tu5g1jwDtHAb
2026-08-21 20:08:35 -04:00
Bryan Helmkamp
261dad0d58
Merge pull request #779 from fabro-sh/codex/fail-closed-run-event-persistence
Fail runs when event persistence is lost
2026-08-21 19:57:19 -04:00
Bryan Helmkamp
d80dde2320
Merge remote-tracking branch 'origin/main' into codex/fail-closed-run-event-persistence
# Conflicts:
#	lib/components/fabro-workflow/src/operations/start.rs
2026-08-21 19:49:44 -04:00
Bryan Helmkamp
ab900485b6
Merge pull request #770 from fabro-sh/feat/release-push-rescue
Rescue release pushes when origin/main moves mid-release
2026-08-21 19:43:52 -04:00
Bryan Helmkamp
cfa8ae92c0
style: apply pinned rustfmt 2026-08-21 19:34:38 -04:00
Bryan Helmkamp
61394ba2f1
Simplify run-event persistence failure plumbing
- Make the failure watch channel the single record of the latched
  failure; drop the worker task's mirrored local state.
- Replace the hand-rolled wait loop with watch::Receiver::wait_for.
- Extract race_persistence/flush_or_stop helpers so the select!/flush
  scaffolding in RunSession::run exists once instead of three times.
- Return RunEventPersistenceError from append_event_to_sink and add a
  From impl on Error, replacing four hand-written per-event message
  strings with the event name derived from the event itself.
- Dedupe the RunCreated test seed literal in initialize.rs and drop the
  dead BlockingHandler::simulate override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ryyhtbc1eNtCLw8GjrFQXZ
2026-08-21 19:29:14 -04:00
Bryan Helmkamp
0d028f9b1e
Harden release push recovery 2026-08-21 19:28:55 -04:00
Bryan Helmkamp
45e06d2a6e
Merge pull request #775 from fabro-sh/claude/additional-github-repositories
Additional GitHub repository access
2026-08-21 18:55:31 -04:00
Bryan Helmkamp
ad4f9decb8
Merge pull request #777 from fabro-sh/fix/venice-top-level-cost
fix(llm): capture Venice top-level costs
2026-08-21 18:51:50 -04:00
Bryan Helmkamp
868d8857bf
Merge pull request #778 from fabro-sh/feat/daytona-clone-depth
Support configurable sandbox clone depth
2026-08-21 18:51:06 -04:00
Bryan Helmkamp
09f5bb0f84
Simplify clone depth plumbing
Make RunCloneSettings::DEFAULT_DEPTH the single owner of the default
depth, and interpret the "0 = full history" sentinel in one place via
RunCloneSettings::depth_limit(). Docker's clone_depth becomes
Option<usize> to match Daytona's encoding, with a shared
depth_argument() helper for both git command builders. Drop the
unreachable Option on the resolved depth field, the hand-written
DaytonaSettings::Default, and the pure-forwarding
daytona_git_clone_options helper. The blob-import test helper reuses
the pool's own connect options instead of rebuilding a partial copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bgXj5J218RXfiT72qhbLV
2026-08-21 18:37:39 -04:00
Bryan Helmkamp
129fa0ea0c
fix: fail runs when event persistence is lost 2026-08-21 18:31:22 -04:00
Bryan Helmkamp
fe1d9dc691
test: isolate SQLite checkpoint restoration 2026-08-21 17:57:22 -04:00
Bryan Helmkamp
1669791956
test: update clone depth snapshots 2026-08-21 17:47:02 -04:00
Bryan Helmkamp
6179470eb2
feat: default sandbox clone depth to 100 2026-08-21 17:25:58 -04:00
Bryan Helmkamp
4c467cd6ba
refactor(github): simplify repository access checks 2026-08-21 17:12:27 -04:00
Bryan Helmkamp
438bab29f0
feat: support shallow sandbox clones 2026-08-21 17:11:24 -04:00
Bryan Helmkamp
47954f731e
refactor(github): deduplicate additional-repository access plumbing
Consolidate the copies that review found across the feature:

- One GITHUB_CREDENTIAL_HELPER / GITHUB_CREDENTIAL_HELPER_KEY pair in
  fabro-github, with apply_probe_git_env() for probe commands; the runtime
  git bridge, server preflight probe, and live contract test all consume it
  so the probes exercise exactly what the bridge configures.
- GitHubRepositoryAccess::resolve_verified_token() owns the
  resolve-installations-then-mint choreography shared by server preflight,
  workflow initialization, and the live test.
- A shared lookup_installation() helper backs both the shared-installation
  resolution and the mint's installation lookup.
- The contents = read|write rule lives once as
  RunIntegrationsGithubSettings::contents_permission_allows_repository_access.
- The preflight probe paces retries with fabro-sandbox's exported
  replication_backoff() (3s/9s) instead of a contradicting 1s/2s loop, and
  shares one run_ls_remote() runner with the existing remote-ref check.

Also: collapse the dead Ok(None) arm and repeated error blocks in the
preflight token check, drop the derivable bridge_entry_count(), privatize
resolve_permissions() behind resolve_integration(), make
GitHubRepositorySlug ordering/hashing allocation-free, and use EnvVars
constants for env names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:31:48 -04:00
Scott Werner
891eb43e9a
Merge pull request #761 from fabro-sh/codex/strict-legacy-blob-import
Import legacy blobs strictly into SQLite
2026-08-21 16:30:45 -04:00
Bryan Helmkamp
68e3cb8419
fix(llm): capture Venice top-level costs 2026-08-21 15:55:45 -04:00
Jesse Proudman
53efde3930 feat(search): add Venice backend for web_search
Brave stays the default. Shops that already vault VENICE_API_KEY
can drop BRAVE_SEARCH_API_KEY by setting
[server.integrations.search] provider = "venice".

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 12:31:03 -07:00
Bryan Helmkamp
84b75f29f1
docs(api): document additional github repository access
- Add `additional_repositories` to the RunIntegrationsGithubSettings
  OpenAPI schema and reuse the canonical Rust settings types through
  `with_replacement`, with type-identity witnesses and JSON parity
  tests for populated and empty repository sets.
- Regenerate the TypeScript API client.
- Document the feature in the GitHub integration and run-configuration
  guides: exact layer replacement rules, single-token scope, gh/API
  support, App-versus-PAT scope, the same-owner/same-installation
  requirement, validation errors, supported Git URL forms, hard-failure
  semantics for declared repositories, GH_TOKEN precedence, and the
  security boundary (no second server-side repository intersection;
  contents = "write" lets any stage push to any declared repository).
  Correct the earlier claim that injecting GITHUB_TOKEN alone makes
  arbitrary additional private clones work.
- Add a dated changelog entry and an opt-in live GitHub App e2e test
  that verifies a scoped multi-repository token reads every declared
  repository (and that a primary-only token cannot), with repositories
  supplied through the test environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:12:23 -04:00
Bryan Helmkamp
d95b6cace1
feat(server): preflight additional github repository access
When a run declares additional repositories, preflight now proves the
whole effective set works instead of treating a minted token as proof:

- It constructs the same validated `GitHubRepositoryAccess` used by
  runtime initialization, so the two paths cannot disagree.
- In App mode it first resolves every repository's installation with
  the App JWT and requires one shared installation ID, naming any
  repository the App cannot see before the mint; then it mints the one
  scoped token, failing with the raw error on rejection.
- Every effective repository gets a non-interactive
  `git ls-remote <url> HEAD` probe through a shared helper that keeps
  the token out of the URL, argv, and errors (a credential helper reads
  GITHUB_TOKEN from the child environment), retries auth-shaped
  failures with the same token to cover replication lag (classified
  via fabro_sandbox::classify_failure), and reports one check per
  repository in deterministic primary-first order under bounded
  concurrency.
- A resolved run environment that defines GH_TOKEN produces a warning
  (gh prefers it over the managed token) without failing preflight.
- With no additional repositories declared, the primary-only mint
  check is byte-for-byte unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 14:59:45 -04:00
Bryan Helmkamp
d8edd410f3
feat(workflow): bridge git and gh to the shared token
Carry the resolved GitHub integration (permissions plus declared
additional repositories) as one value from run materialization into
workflow startup, and make the sandbox environment reach every declared
repository through the single managed GITHUB_TOKEN.

- `StartServices.github_permissions` becomes
  `github_integration: ResolvedGithubIntegration`; CLI and server
  workers build it with `resolve_integration()` after interpolation and
  pass it through `SandboxEnvSpec` as one unit.
- `build_sandbox_env` constructs the validated
  `GitHubRepositoryAccess` and scopes the App token source to the whole
  effective set. Missing credentials or a missing origin are hard
  initialization errors when additional repositories are declared;
  legacy permissions-only configuration keeps its best-effort behavior.
- When additional repositories are declared, initialization eagerly
  resolves each repository's App installation (naming any repository
  the App cannot see) and the token itself, so an inaccessible declared
  repository fails before the first workflow stage.
- A new `git_bridge` module injects secret-free `GIT_CONFIG_*` entries
  into the stage environment: a github.com credential helper that reads
  `$GITHUB_TOKEN` at invocation time, per-repository SSH-to-HTTPS
  `insteadOf` rewrites, and `GIT_TERMINAL_PROMPT=0`. Entries append
  after a valid user-provided Git config overlay and fail clearly on a
  malformed one. Contract tests drive the installed git binary against
  local fixtures for the rewrite, credential, prefix-collision, and
  overlay-preservation behaviors.
- The long-running ACP notice now says all declared repository access
  expires together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 14:47:31 -04:00
Bryan Helmkamp
e5e7875274
Merge pull request #774 from fabro-sh/github-app-packages-read
Request Packages read permission in the GitHub App manifest
2026-08-21 14:29:08 -04:00
Bryan Helmkamp
7bfed23153
feat(github): mint one installation token for the effective repository set
Add `GitHubRepositoryAccess`, the secret-free validated value describing a
run's effective GitHub repository set: the primary origin repository plus
the declared additional repositories with the shared permission map.

- The constructor normalizes HTTPS and both SSH origin spellings to one
  primary slug, rejects a missing or non-GitHub origin when additional
  repositories are declared, rejects primary duplication and cross-owner
  additional repositories, and re-checks that interpolated permissions
  carry `contents = "read"|"write"` — exposing targets in deterministic
  primary-first order.
- `resolve_shared_installation` resolves every target's App installation
  with the App JWT and requires one shared installation ID, naming the
  repository the App cannot see before any mint.
- The installation-token mint now accepts a repository-name list; the
  single-repository entry points delegate to it, and the request body
  lists every projected name with the shared permissions.
- `InstallationTokenSource::for_access` builds a source over the access
  value; caching, refresh margin, and single-flight are unchanged.
- The scripted `MockHttpClient` and test RSA key move to a shared
  crate-internal `tests_mock` module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 14:24:24 -04:00
Bryan Helmkamp
f2047ad9a9
feat(config): add validated additional github repositories
Add `additional_repositories` to `[run.integrations.github]`: a list of
full `owner/repository` slugs, beyond the implicit run origin, that the
minted GITHUB_TOKEN must cover.

- `GitHubRepositorySlug` gains FromStr, Display, string serde, and
  case-insensitive Eq/Ord/Hash identity while preserving the submitted
  spelling for display and serialization.
- The config layer keeps raw strings; the higher-precedence list
  replaces the lower one wholesale, with `[]` as an explicit clear,
  resolving independently from the `permissions` map.
- Resolution validates each entry with indexed error paths: slug
  grammar, case-insensitive duplicates, one shared owner, the
  499-repository cap, and a required `contents = "read"|"write"`
  permission (templated values are re-checked at the runtime boundary).
- `RunIntegrationsGithubSettings` resolves permissions and repositories
  together through `resolve_integration()` so consumers cannot pick up
  one without the other; the field is omitted from serialization when
  empty, keeping single-repository settings byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 14:15:46 -04:00
Bryan Helmkamp
7de3b409ed
Request Packages read permission in the GitHub App manifest
Fabro can mint a scoped sandbox GITHUB_TOKEN via
[run.integrations.github.permissions], but apps registered through the
manifest flow could not grant packages = "read" because the manifest
never requested it. Add Packages (read-only) so freshly registered apps
can download private GitHub Packages (for example npm registry
dependencies) inside sandboxes, mirroring how GitHub Actions workflows
use their built-in GITHUB_TOKEN for registry reads.

Existing apps still need the permission added manually in the app's
settings, as the docs already describe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 14:12:32 -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
b964602b0b Harden legacy blob import cleanup 2026-08-21 13:58:04 -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
Scott Werner
6e65e93a2f
Merge pull request #749 from fabro-sh/codex/persist-workflow-version-lineage
Persist workflow version lineage on runs
2026-08-21 13:07:40 -04:00
Scott Werner
9d3aa7a4d4 Consolidate workflow-version lineage test coverage
The lineage field's `skip_serializing_if` behavior was asserted five times
across three crates. Keep the two assertions in fabro-types, which owns the
attribute, and drop the duplicates:

- Delete `run_created_omits_absent_workflow_version_id` from event/convert.rs,
  a copy of the test above it that re-checked another crate's serde attribute.
  convert.rs's own responsibility is covered by the existing field assertion.
- Delete `legacy_create_input_persists_without_workflow_version_id`, which ran
  the full create() pipeline to prove a hardcoded `None` literal is `None`.
  `CreateRunInput` has no such field, so no input could change the result.
- Fold `run_spec_omits_absent_workflow_version_id` into the adjacent legacy-spec
  test, which already holds an all-`None` record.
- Drop the off-topic spec re-serialization from run_state.rs's retried_from test.

Add `test_support::test_workflow_version_id()` alongside `test_run_provenance()`
and use it everywhere, replacing eight copies of the same magic seed across five
crates plus two assertion sites that recomputed the hash inline. This also
subsumes retry.rs's private helper of the same shape.

Revert the `run_spec_json` parameterization in the projection round-trip test:
`RunProjection` is a `with_replacement` alias for the canonical type, so the
`Some` and `None` call sites exercise identical code.

Have the two run.created literals that mirror a `RunSpec` read the spec's
lineage field instead of hardcoding `None`, so the mirrors stay accurate once a
producer populates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 12:39:02 -04:00
Scott Werner
27fd48c603 Persist workflow version lineage on runs 2026-08-21 12:39:02 -04:00
Scott Werner
2752a30450
Merge pull request #750 from fabro-sh/codex/exact-target-checkout
Check out exact admitted commits in clone-based sandboxes
2026-08-21 12:28:57 -04:00
Scott Werner
75fa8eca8b Merge remote-tracking branch 'origin/main' into codex/exact-target-checkout
# Conflicts:
#	lib/components/fabro-sandbox/src/clone_retry.rs
#	lib/components/fabro-sandbox/src/daytona/mod.rs
#	lib/components/fabro-sandbox/src/docker.rs
#	lib/components/fabro-sandbox/src/provider/docker.rs
2026-08-21 12:15:59 -04:00
Scott Werner
5104a787ce Bound Daytona post-clone setup 2026-08-21 12:08:23 -04:00
Bryan Helmkamp
57026cd4da
Merge pull request #772 from fabro-sh/brynary/venice-model-catalog
Update Venice model catalog
2026-08-21 11:35:22 -04:00
Bryan Helmkamp
db1faf02ec
Fix catalog dispatch invariant for shared models 2026-08-21 11:19:28 -04:00
Bryan Helmkamp
ff1ca976c3
Document Venice model integration 2026-08-21 10:48:03 -04:00
Bryan Helmkamp
ded92a215d
Update Venice model catalog 2026-08-21 10:37:04 -04:00
Bryan Helmkamp
a611e00fe6
Rescue release pushes when origin/main moves mid-release
The release push raced any commit that landed on main while the release
smoke ran (~15 minutes): git push was rejected as non-fast-forward and
the whole release failed, as seen on the v0.332.0-nightly.1 attempt.
Worse, the push was not atomic — if the tag ref had been accepted while
the main ref was rejected, the release would have shipped from an
orphan commit and main would never have received the version bump.

Make the push atomic (both refs or neither) and add a bounded rescue
loop: on rejection, drop the bump commit and tag this run created,
fast-forward onto the updated origin/main, recompute the version
against freshly fetched tags, and rebuild the bump commit on the new
tip. The fast-forward uses --ff-only so a genuinely diverged local main
(unpushed commits) fails loudly instead of being reset away.

The retried tag can include commits the smoke did not test; those
commits passed CI to land on main, and the Release workflow re-runs the
full test suite on the tagged commit before publishing anything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 07:40:13 -04:00
fabro-releases[bot]
346e81f400 Bump version to 0.332.0-nightly.1 2026-08-21 11:40:10 +00:00
Bryan Helmkamp
e179fd02d0
Pin release workflow runners to ubuntu-24.04
The release, docker, and Homebrew jobs ran on ubuntu-latest, which
migrates across Ubuntu major versions on GitHub's schedule. Pin to
ubuntu-24.04, the image ubuntu-latest resolved to in the last green
release run, matching the explicit runner labels used elsewhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 06:43:52 -04:00
Bryan Helmkamp
103cbb419e
Pin Bun to 1.3.14 in CI and release workflows
setup-bun installed the latest Bun at run time, so every job floated to
new Bun releases the day they shipped. Bun bundles the SPA embedded in
release binaries, so an unvetted Bun release could break or silently
change shipped artifacts. Pin to 1.3.14, the version the last green
nightly used, and hold off on the day-old 1.4.0 until it has soaked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 06:43:38 -04:00
Bryan Helmkamp
a8aba80950
Pin CI Rust toolchain to 1.97.1
Rust 1.98.0 (released 2026-08-20) passes --fix-cortex-a53-843419 to the
linker for aarch64-unknown-linux-musl, which the zig cc wrapper used by
cargo-zigbuild rejects, breaking the release build for that target. Pin
all workflows that installed unpinned stable to 1.97.1 until the zig
toolchain handles the new flag. The nightly-2026-04-14 fmt/clippy
toolchains are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 06:21:40 -04:00
Bryan Helmkamp
e89f03b316
Fix flaky run id vs variable timestamp assertion
RunId is a ULID, so its embedded timestamp is truncated to whole
milliseconds, while Variable.updated_at comes from Utc::now() with
sub-millisecond precision. When the variable write and the run creation
landed in the same millisecond, the run id compared as earlier and the
assertion failed. Truncate the variable timestamp to milliseconds so
both sides use the same precision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 06:21:33 -04:00
Scott Werner
ebf6f92724 Harden exact-commit checkout in clone-based sandboxes
Run the local git steps of the Docker exact checkout under the shared
clone deadline instead of a fixed 10s timeout, so materializing a large
working tree cannot time out and abandon a running checkout in the
container.

Check the admitted commit out onto the admitted branch rather than
detaching. A detached HEAD makes `rev-parse --abbrev-ref HEAD` return
"HEAD", which the git setup helper maps to no base branch, silently
dropping it for callers that rely on it. Daytona does the same after its
native clone and now verifies the resulting HEAD the way Docker does.

Fetch the exact commit at the same depth a branch clone uses, so both
paths can reach the same number of parent commits, and stop suggesting
GitHub App credentials when a purely local git step fails.

Document that reachability of the commit from the branch is an
admission-time invariant that the sandbox layer does not re-verify.
2026-08-20 13:41:54 -04:00
Scott Werner
e7a32d12d5 Use native Daytona exact commit checkout 2026-08-20 12:16:12 -04:00
Scott Werner
a35dacd46c Import legacy blobs strictly into SQLite 2026-08-19 15:45:41 -04:00
Scott Werner
65616b4557 Simplify exact-commit checkout across sandbox providers
- Fold Docker's exact-checkout path into clone_github_repo so the auth,
  retry, symlink, and bookkeeping skeleton is shared with branch clones
- Skip the Daytona SDK clone for exact checkouts: init and shallow-fetch
  the admitted commit directly instead of cloning the default branch and
  discarding it
- Combine the detach checkout and HEAD verification into one shell
  command, saving an exec round trip per init
- Drop the spec-level decide_clone pre-checks that duplicated the
  constructors' fail-fast validation
- Share a CloneAttemptFailure struct in clone_retry and the GIT command
  prefix constant across git command builders

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 15:10:44 -04:00
Scott Werner
8c45b870b4 Check out exact sandbox commits 2026-08-14 17:10:46 -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
352 changed files with 29818 additions and 5072 deletions

View file

@ -1 +1 @@
678e75e2f35ae90e70c4b369f74ec202b168982d
2bf86327c0afbc8a708e02c3fab58981ad53ad60

View file

@ -1 +1 @@
7ad164c45de64e7bafdadb26f519f6e0c8a00a42
de29af0a30362c70c42f426e457e8a6d269534b2

View file

@ -45,6 +45,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
if: steps.skip.outputs.skip != 'true'
with:
bun-version: 1.3.14
no-cache: true
- name: Install bun deps (for SPA verify)
@ -54,6 +55,8 @@ jobs:
- name: Set up Rust
if: steps.skip.outputs.skip != 'true'
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
if: steps.skip.outputs.skip != 'true'

View file

@ -49,6 +49,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
no-cache: true
- name: Install bun deps
@ -67,6 +68,7 @@ jobs:
- name: Set up Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
targets: ${{ matrix.target }}
- name: Set up zig
@ -136,7 +138,7 @@ jobs:
release:
name: Release
needs: compile
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
@ -164,7 +166,7 @@ jobs:
docker:
name: Docker image
needs: compile
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
@ -257,7 +259,7 @@ jobs:
name: Update Homebrew Formula
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
environment: release
permissions:
contents: read
@ -306,7 +308,7 @@ jobs:
name: Update Homebrew Nightly Formula
needs: release
if: ${{ contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
environment: release
permissions:
contents: read

View file

@ -101,6 +101,8 @@ jobs:
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
@ -116,6 +118,8 @@ jobs:
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
@ -140,6 +144,8 @@ jobs:
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true

View file

@ -44,6 +44,8 @@ jobs:
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: cd apps/fabro-web && bun run typecheck
- run: cd lib/packages/fabro-api-client && bun run typecheck
@ -58,6 +60,8 @@ jobs:
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: cd apps/fabro-web && bun run test
@ -71,7 +75,11 @@ jobs:
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
- run: cargo --locked dev build -- --locked -p fabro-cli --release
- run: wc -c < target/release/fabro

View file

@ -30,7 +30,19 @@ 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
clone, and checks it out; Daytona uses its official SDK clone with both
`branch` and `commit_id`. Both providers then point the admitted branch at
the commit and verify HEAD, so the workspace still reports the admitted
branch name. Keep those provider transports distinct, never fall back to a
newer branch HEAD, and do not wire this capability directly from legacy
`GitContext.sha`. The sandbox layer does not verify that the commit is
reachable from the branch; admission owns that check. Current production
callers remain branch-only until the RunIntent admission cutover supplies a
validated branch/SHA pair.
### Release automation
- `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.

187
Cargo.lock generated
View file

@ -1870,7 +1870,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "daytona-api-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
dependencies = [
"reqwest 0.13.2",
"reqwest-middleware",
@ -1884,7 +1884,7 @@ dependencies = [
[[package]]
name = "daytona-sdk"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
dependencies = [
"daytona-api-client",
"daytona-toolbox-client",
@ -1904,13 +1904,15 @@ dependencies = [
[[package]]
name = "daytona-toolbox-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
dependencies = [
"reqwest 0.13.2",
"reqwest-middleware",
"serde",
"serde_json",
"serde_repr",
"tokio",
"tokio-util",
"url",
]
@ -2072,7 +2074,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2199,7 +2201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2255,7 +2257,7 @@ dependencies = [
[[package]]
name = "fabro-acp"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-tokio",
@ -2274,7 +2276,7 @@ dependencies = [
[[package]]
name = "fabro-agent"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2298,6 +2300,7 @@ dependencies = [
"futures",
"glob",
"htmd",
"httpmock",
"insta",
"jsonschema",
"libc",
@ -2320,7 +2323,7 @@ dependencies = [
[[package]]
name = "fabro-api"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"fabro-automation",
@ -2343,7 +2346,7 @@ dependencies = [
[[package]]
name = "fabro-auth"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2368,7 +2371,7 @@ dependencies = [
[[package]]
name = "fabro-automation"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2388,11 +2391,11 @@ dependencies = [
[[package]]
name = "fabro-build-support"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
[[package]]
name = "fabro-checkpoint"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -2408,7 +2411,7 @@ dependencies = [
[[package]]
name = "fabro-cli"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2510,7 +2513,7 @@ dependencies = [
[[package]]
name = "fabro-client"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2539,7 +2542,7 @@ dependencies = [
[[package]]
name = "fabro-config"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2569,13 +2572,14 @@ dependencies = [
[[package]]
name = "fabro-core"
version = "0.332.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",
@ -2584,19 +2588,20 @@ dependencies = [
[[package]]
name = "fabro-db"
version = "0.332.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.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2615,7 +2620,7 @@ dependencies = [
[[package]]
name = "fabro-dump"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2629,7 +2634,7 @@ dependencies = [
[[package]]
name = "fabro-environment"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2651,7 +2656,7 @@ dependencies = [
[[package]]
name = "fabro-github"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2663,6 +2668,7 @@ dependencies = [
"fabro-static",
"fabro-test",
"fabro-types",
"futures",
"jsonwebtoken",
"serde",
"serde_json",
@ -2675,7 +2681,7 @@ dependencies = [
[[package]]
name = "fabro-graphviz"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -2690,7 +2696,7 @@ dependencies = [
[[package]]
name = "fabro-hooks"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"fabro-agent",
@ -2713,7 +2719,7 @@ dependencies = [
[[package]]
name = "fabro-http"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-static",
"http 1.4.0",
@ -2723,7 +2729,7 @@ dependencies = [
[[package]]
name = "fabro-install"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2742,7 +2748,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"dialoguer",
@ -2757,7 +2763,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2799,7 +2805,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"clap",
"fabro-options-metadata",
@ -2810,7 +2816,7 @@ dependencies = [
[[package]]
name = "fabro-manifest"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"fabro-api",
@ -2831,7 +2837,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2851,7 +2857,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-server"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2879,7 +2885,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-store"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"fabro-db",
@ -2897,8 +2903,9 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"clap",
"fabro-static",
"http 1.4.0",
"insta",
@ -2913,7 +2920,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2935,7 +2942,7 @@ dependencies = [
[[package]]
name = "fabro-options-metadata"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"serde",
"serde_json",
@ -2943,7 +2950,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"cc",
"libc",
@ -2952,7 +2959,7 @@ dependencies = [
[[package]]
name = "fabro-redact"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"aho-corasick",
"ref-cast",
@ -2968,7 +2975,7 @@ dependencies = [
[[package]]
name = "fabro-sandbox"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3012,7 +3019,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3064,6 +3071,7 @@ dependencies = [
"fabro-workflow",
"fabro-workflow-version",
"futures-util",
"git2",
"globset",
"hex",
"hkdf 0.12.4",
@ -3107,7 +3115,7 @@ dependencies = [
[[package]]
name = "fabro-slack"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-interview",
@ -3129,18 +3137,18 @@ dependencies = [
[[package]]
name = "fabro-spa"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"rust-embed",
]
[[package]]
name = "fabro-static"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
[[package]]
name = "fabro-store"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"bytes",
@ -3156,6 +3164,7 @@ dependencies = [
"percent-encoding",
"serde",
"serde_json",
"sha2 0.10.9",
"slatedb",
"sqlx",
"strum 0.28.0",
@ -3170,7 +3179,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -3196,7 +3205,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -3210,7 +3219,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -3235,7 +3244,7 @@ dependencies = [
[[package]]
name = "fabro-tool"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3256,7 +3265,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3270,7 +3279,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"clap",
@ -3293,7 +3302,7 @@ dependencies = [
[[package]]
name = "fabro-util"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"console 0.15.11",
@ -3316,7 +3325,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-acp",
"fabro-graphviz",
@ -3329,7 +3338,7 @@ dependencies = [
[[package]]
name = "fabro-variable"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3346,7 +3355,7 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3365,7 +3374,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -3435,7 +3444,7 @@ dependencies = [
[[package]]
name = "fabro-workflow-version"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-config",
"fabro-graphviz",
@ -4388,6 +4397,22 @@ dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@ -4440,7 +4465,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.61.2",
]
[[package]]
@ -5315,6 +5340,23 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.2.1",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@ -5389,7 +5431,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6386,7 +6428,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6671,11 +6713,13 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@ -6687,6 +6731,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@ -6860,7 +6905,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6919,7 +6964,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -7443,7 +7488,7 @@ version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno 0.3.14",
"errno 0.2.8",
"libc",
]
@ -8033,7 +8078,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -8079,7 +8124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -8231,6 +8276,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@ -8546,7 +8601,7 @@ dependencies = [
[[package]]
name = "twin-github"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"axum",
"base64",
@ -8565,7 +8620,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-stream",
@ -9132,7 +9187,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]

View file

@ -11,7 +11,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.332.0-nightly.0"
version = "0.338.0-nightly.0"
license = "MIT"
[workspace.dependencies]
@ -97,8 +97,8 @@ twin-openai = { path = "test/twin/openai" }
twin-github = { path = "test/twin/github" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-api-client" }
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-api-client" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

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

@ -186,4 +186,8 @@ Do not rebuild or mutate the `RunEvent` in downstream listeners.
Any JSONL sink, the run store, and SSE should reflect the same canonical envelope bytes after redaction.
An active workflow treats any run-event sink write failure as fatal. It cancels execution and
attempts to persist `run.failed` through the direct sink path. Persistence-error logs must include
the full source chain so an HTTP status or transport failure remains visible.
`status.json` remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished.

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

@ -39,6 +39,7 @@ the vault:
- `FABRO_SLACK_BOT_TOKEN`
- `DAYTONA_API_KEY`
- `BRAVE_SEARCH_API_KEY`
- `VENICE_API_KEY`
`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root.

View file

@ -41,7 +41,7 @@ Add variables in **Service → Variables** as needed. The [Server Configuration]
| `SESSION_SECRET` | 64-character hex string; required when the web UI is enabled |
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | Optional static S3 object-store credentials |
Do not put optional integration secrets in Railway variables for server runtime. After the server is running, add LLM provider keys, Slack, Daytona, Brave Search, `GITHUB_TOKEN`, and GitHub App secrets to the server vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
Do not put optional integration secrets in Railway variables for server runtime. After the server is running, add LLM provider keys, Slack, Daytona, Brave Search, Venice Search, `GITHUB_TOKEN`, and GitHub App secrets to the server vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
No `.env` file is auto-loaded inside the container; bootstrap variables come from Railway's environment.

View file

@ -38,7 +38,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following
### Secrets
- **Keep API keys out of sandboxes.** The local sandbox strips environment variables ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL`, but Docker and Daytona sandboxes provide stronger isolation — only explicitly configured variables are passed through.
- **Use the server vault for optional integration credentials.** For server-backed workflows, persist LLM provider keys, Slack, Daytona, Brave Search, GitHub token, and GitHub App secrets with `fabro provider login`, `fabro secret set`, or `fabro install`. Process env and `server.env` are reserved for bootstrap secrets such as `SESSION_SECRET`, `FABRO_DEV_TOKEN`, and object-store credentials. Do not commit secrets to version control.
- **Use the server vault for optional integration credentials.** For server-backed workflows, persist LLM provider keys, Slack, Daytona, Brave Search, Venice Search, GitHub token, and GitHub App secrets with `fabro provider login`, `fabro secret set`, or `fabro install`. Process env and `server.env` are reserved for bootstrap secrets such as `SESSION_SECRET`, `FABRO_DEV_TOKEN`, and object-store credentials. Do not commit secrets to version control.
- **Rotate the session secret.** The `SESSION_SECRET` environment variable encrypts web app sessions. Rotate it periodically and use a strong random value.
### Execution

View file

@ -108,7 +108,7 @@ Generate one with `openssl rand -hex 32`.
| `FABRO_DEV_TOKEN` | Optional — pre-set the dev token instead of reading the one written to `/storage` on first boot |
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | Optional static S3 object-store credentials |
Do not put optional integration secrets in `.env` for server runtime. Configure LLM provider keys, Slack, Daytona, Brave Search, `GITHUB_TOKEN`, and GitHub App secrets in the vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
Do not put optional integration secrets in `.env` for server runtime. Configure LLM provider keys, Slack, Daytona, Brave Search, Venice Search, `GITHUB_TOKEN`, and GitHub App secrets in the vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
Optional:

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.
@ -360,7 +362,7 @@ Fabro splits server-runtime secrets into two scopes:
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` when a manual config uses
static S3 object-store credentials
`server.env` is not used for Slack, Daytona, Brave Search, LLM provider keys, `GITHUB_TOKEN`, or GitHub App private key/client secret/webhook secret. Configure those optional integrations with `fabro secret set`, `fabro provider login`, or `fabro install`.
`server.env` is not used for Slack, Daytona, Brave Search, Venice Search, LLM provider keys, `GITHUB_TOKEN`, or GitHub App private key/client secret/webhook secret. Configure those optional integrations with `fabro secret set`, `fabro provider login`, or `fabro install`.
During startup, Fabro temporarily migrates recognized legacy optional integration secrets from process env or `server.env` into the vault. When a matching `server.env` entry can be safely removed, Fabro writes a hidden backup beside `server.env` first. Process env values cannot be cleaned up automatically, so remove those from your deployment environment after the vault contains the secret.
@ -402,12 +404,16 @@ These optional server integrations are vault-only:
```bash
fabro secret set DAYTONA_API_KEY dtn_...
fabro secret set BRAVE_SEARCH_API_KEY BSA...
fabro secret set VENICE_API_KEY venice-...
```
The built-in [`web_search`](/agents/tools#web_search) tool selects its backend from these credentials. It uses direct Brave Search when `BRAVE_SEARCH_API_KEY` exists. Otherwise it uses Venice Search when `VENICE_API_KEY` exists. When neither exists, the tool is not registered.
| Variable | Description |
|---|---|
| `DAYTONA_API_KEY` | Daytona cloud sandbox API key |
| `BRAVE_SEARCH_API_KEY` | Brave Search API key (for the `web_search` tool) |
| `BRAVE_SEARCH_API_KEY` | Brave Search API key; the preferred `web_search` backend when present |
| `VENICE_API_KEY` | Venice API key; used by the Venice LLM provider and by `web_search` when no Brave key exists |
### Server authentication

View file

@ -17,7 +17,7 @@ It checks:
- Local user config and storage directory health
- Server-reported LLM provider connectivity, with configured providers probed concurrently
- GitHub App, sandbox, and Brave Search credentials, plus Docker daemon reachability when the Docker sandbox provider is enabled
- GitHub App, sandbox, and web search credentials (Brave or Venice), plus Docker daemon reachability when the Docker sandbox provider is enabled
- Server authentication and crypto configuration
LLM provider probe failures are reported as errors. Use `--verbose` to see the underlying provider error chain when a key, network route, or model endpoint fails.

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

@ -145,7 +145,7 @@ The system prompt varies by LLM provider. Each provider has its own identity tex
<Accordion title="Example system prompt (Anthropic provider)">
This is the full system prompt sent to Claude as the LLM system message. The `<environment>` block is filled in at runtime.
Tool guidance tracks the tools actually registered for the session. The `web_search` section shown below is present only when a [Brave Search API key](/integrations/brave-search) is configured; without one, both the tool and its guidance are omitted.
Tool guidance tracks the tools actually registered for the session. The `web_search` section shown below is present when a [Brave Search API key](/integrations/brave-search) or [Venice API key](/integrations/venice-search) is configured. Without either key, both the tool and its guidance are omitted.
```
You are Claude, an AI coding assistant made by Anthropic. You help users with
@ -232,7 +232,7 @@ first). Use this for finding files rather than using shell find or ls
commands.
## web_search
Search the web using Brave Search. Returns titles, URLs, and descriptions.
Search the web. Returns titles, URLs, and descriptions.
## web_fetch
Fetch content from a URL and optionally summarize it. Pass a prompt to
@ -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

@ -20,7 +20,7 @@ These tools are registered for every provider profile:
| `write_file` | write | Create or overwrite a file |
| `grep` | read | Search file contents with regex patterns |
| `glob` | read | Find files by name pattern |
| `web_search` | shell | Search the web via Brave Search |
| `web_search` | shell | Search the web via Brave or Venice |
| `web_fetch` | shell | Fetch and optionally summarize a URL |
## Provider-specific tools
@ -123,14 +123,18 @@ Patterns are case-sensitive and relative to `path`: `*` and `?` stay within one
### web_search
Searches the web using the Brave Search API.
Searches the web using Brave Search or Venice Search. Fabro selects the backend automatically from the available credentials.
| Parameter | Type | Required | Description |
|---|---|---|---|
| `query` | string | yes | Search query |
| `query` | string | yes | Search query. Venice rejects queries longer than 400 characters before the HTTP call. |
| `max_results` | integer | no | Maximum results (default: 5, max: 20) |
Requires `BRAVE_SEARCH_API_KEY` to be configured for the current runtime. Runs read it from the server vault (`fabro secret set BRAVE_SEARCH_API_KEY <key>`) — workers start from a cleared environment and this key is not inherited, so exporting it in the server's shell has no effect. The standalone agent CLI reads it from the invoking shell instead. Returns numbered results with title, URL, and description.
Fabro uses direct [Brave Search](/integrations/brave-search) when `BRAVE_SEARCH_API_KEY` is present. Otherwise it uses [Venice Search](/integrations/venice-search) when `VENICE_API_KEY` is present. If both credentials are present, Brave wins. Venice always uses its Brave search engine.
Runs read both keys from the server vault. Workers start from a cleared environment, so exporting a key in the server's shell has no effect. The standalone agent CLI reads the keys from the invoking shell instead.
The tool is registered when either credential is available. Once Fabro selects a backend, a failed call returns an error; it does not retry through the other backend. Results contain numbered titles, URLs, and descriptions. Venice includes `date` on a fourth line when present.
### web_fetch

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
@ -11434,6 +11608,16 @@ components:
type: ["string", "null"]
workflow_slug:
type: ["string", "null"]
workflow_version_id:
description: Exact immutable root workflow version from which the run was admitted, when applicable.
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"
@ -14442,6 +14626,17 @@ components:
type: object
additionalProperties:
type: string
additional_repositories:
type: array
description: |
Additional GitHub repositories, beyond the implicit run origin,
that the minted GITHUB_TOKEN must cover. Each entry is a full
`owner/repository` slug; every repository must share one owner
with the run origin. Omitted when empty; settings persisted
before this field existed deserialize to an empty set.
items:
type: string
uniqueItems: true
RunGoal:
oneOf:
@ -14628,6 +14823,12 @@ components:
properties:
enabled:
type: boolean
depth:
type: integer
format: int32
minimum: 0
default: 100
description: Git history depth. Set to 0 to clone full history.
RunBranchSettings:
type: object

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

@ -0,0 +1,65 @@
---
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`:
```toml
[run.integrations.github]
additional_repositories = ["fabro-sh/keystone"]
permissions = { contents = "read" }
```
The run origin stays implicit, and Fabro mints one installation token scoped to the origin plus every declared repository with the shared permission map. Inside stages, `gh` commands, raw GitHub API calls, plain Git over HTTPS, and the common SSH URL spellings (`git@github.com:owner/repo` and `ssh://git@github.com/owner/repo`) all work against the declared set — the SSH forms are transparently rewritten to authenticated HTTPS with no secret placed in Git configuration.
Every repository must share one owner and be reachable by the origin's GitHub App installation. Preflight resolves each repository's installation, mints the scoped token once, and probes every repository with `git ls-remote`, naming the exact repository when something is not accessible; run initialization enforces the same checks. A declared-but-inaccessible repository fails the run before its first stage.
Declaring additional repositories requires `contents = "read"` or `contents = "write"`. With `contents = "write"`, any stage can push to any declared repository — declare the smallest set and weakest permissions that work. See [Additional repositories](/integrations/github#additional-repositories) for details, including layering rules and `GH_TOKEN` precedence.
## Venice search backend for `web_search`
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

@ -59,13 +59,18 @@ Fabro performs this selection once when creating a run and persists the chosen p
| `gemini-3.1-flash-lite` | gemini | `gemini-flash-lite`, `gemini-3.1-flash-lite-preview` | 1M | $0.25 / $1.50 | 200 tok/s |
| `kimi-k2.5` | moonshot | | 262K | $0.60 / $3.00 | 50 tok/s |
| `kimi-k3` | moonshot | `kimi` | 1M | $3.00 / $15.00 | n/a |
| `kimi-k3-fast` | venice | `kimi-fast` | 1M | $4.50 / $22.50 | n/a |
| `deepseek-v4-flash` | deepseek | `deepseek`, `deepseek-v4`, `deepseek-flash` | 1,048,576 | $0.14 / $0.28 | n/a |
| `deepseek-v4-pro` | deepseek | | 1,048,576 | $0.435 / $0.87 | n/a |
| `grok-4.6` | venice | `grok`, `grok46`, `grok-46` | 500K | $2.27 / $6.80 | n/a |
| `laguna-s-2.1` | poolside | `laguna`, `laguna-s` | 1M | $0.10 / $0.20 | n/a |
| `laguna-xs-2.1` | poolside | `laguna-xs` | 262K | $0.10 / $0.20 | n/a |
| `glm-5.2` | zai | `glm`, `glm5`, `glm52`, `glm5.2` | 1M | $1.40 / $4.40 | n/a |
| `glm-5.3` | venice | `glm`, `glm5`, `glm53`, `glm5.3`, `glm-5-3` | 1M | $1.75 / $5.50 | n/a |
| `minimax-m2.5` | minimax | `minimax` | 197K | $0.30 / $1.20 | 45 tok/s |
| `mercury-2` | inception | `mercury` | 131K | $0.25 / $0.75 | 1000 tok/s |
| `qwen3.8-max` | venice | `qwen`, `qwen-max`, `qwen3.8`, `qwen-3.8`, `qwen38`, `qwen-3.8-max`, `qwen38-max` | 1M | $2.50 / $7.50 | n/a |
| `qwen3.8-27b` | venice | `qwen-27b`, `qwen-3.8-27b`, `qwen38-27b` | 262K | $0.45 / $3.20 | n/a |
Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup.
@ -161,7 +166,7 @@ Workflow runs also add `x-session-id: <run-id>` to every LLM request so compatib
Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, `gemini`, `kimi`, and `gpt56`; model-level values override provider-level values.
Two profiles are selected per model rather than per provider, because they follow the model wherever it is served: `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna). The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional Brave-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
Two profiles are selected per model rather than per provider, because they follow the model wherever it is served: `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna). The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional credential-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
Provider `billing_policy` defaults from `adapter` and controls usage-cost estimation. Use `openai`, `anthropic`, `gemini`, or `none`. Model rows may override it for models whose billing family differs from their provider's — for example, Claude models served through OpenRouter set `billing_policy = "anthropic"` so cache reads and writes price correctly.
@ -169,6 +174,10 @@ Provider `billing_policy` defaults from `adapter` and controls usage-cost estima
Provider fields in configuration, APIs, and model routing are provider ID strings. Built-in names like `anthropic`, `openai`, and `gemini` still work, but custom IDs like `proxy` work anywhere a provider ID is accepted.
</Note>
### Venice
Fabro ships a built-in [Venice](/integrations/venice) provider with a curated catalog of Venice-hosted Kimi, Grok, GLM, DeepSeek, and Qwen models. Store its API key with `fabro provider login --provider venice`. Pin `provider = "venice"` when a shared model slug must use Venice instead of a higher-priority direct provider.
### Poolside
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_id` values.
@ -232,6 +241,7 @@ When no model or provider is specified, Fabro chooses the default offering on th
| `moonshot` | `kimi-k3` |
| `poolside` | `laguna-s-2.1` |
| `zai` | `glm-5.2` |
| `venice` | `deepseek-v4-flash` |
| `minimax` | `minimax-m2.5` |
| `inception` | `mercury-2` |

View file

@ -97,12 +97,14 @@
"integrations/litellm",
"integrations/bedrock",
"integrations/deepseek",
"integrations/venice",
"integrations/poolside",
"integrations/openrouter",
"integrations/modal",
"integrations/fireworks",
"integrations/slack",
"integrations/brave-search"
"integrations/brave-search",
"integrations/venice-search"
]
},
{
@ -185,6 +187,13 @@
"api-reference/client-sdks"
]
},
{
"group": "Workflow Versions",
"icon": "code-branch",
"pages": [
"POST /api/v1/workflow-versions"
]
},
{
"group": "Runs",
"icon": "play",
@ -200,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"
]
@ -218,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",
@ -293,10 +305,38 @@
"tab": "Changelog",
"icon": "clock-rotate-left",
"groups": [
{
"group": "August 2026",
"icon": "clock-rotate-left",
"pages": [
"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. Set `[run.clone] enabled = false` to start with an empty workspace. 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

@ -241,10 +241,16 @@ Configure whether clone-based sandboxes clone the run's GitHub origin before exe
```toml title="run.toml"
[run.clone]
enabled = true
depth = 100
```
Set `enabled = false` to start Docker and Daytona runs with an empty provider workspace. Use [prepare steps](#runprepare) to clone or create any files the workflow needs.
| Field | Description |
|---|---|
| `enabled` | When `false`, Fabro skips the repository clone. Defaults to `true`. |
| `depth` | Git history depth for Docker and Daytona. Defaults to `100`. Set it to `0` to clone full history. |
### `[run.run_branch]`
Configure Fabro's managed `fabro/run/<id>` checkpoint branch.
@ -363,6 +369,24 @@ Only requested permissions are included. The upper bound is the permission set g
This table follows the normal settings precedence order. A higher-precedence layer can set `permissions = {}` to clear inherited permissions and run without a GitHub token.
### `[run.integrations.github].additional_repositories`
Declare extra GitHub repositories, beyond the implicit run origin, that the minted `GITHUB_TOKEN` must cover. The one `permissions` map applies to the origin and every declared repository.
```toml title="run.toml"
[run.integrations.github]
additional_repositories = ["fabro-sh/keystone"]
permissions = { contents = "read" }
```
Each entry is a full `owner/repository` slug. Every repository in the effective set must share one owner and be reachable by the origin repository's GitHub App installation. A non-empty list requires `contents = "read"` or `contents = "write"`. Malformed slugs, case-insensitive duplicates, cross-owner sets, and sets larger than 499 entries fail configuration validation with indexed error paths such as `run.integrations.github.additional_repositories[1]`.
Unlike permissions-only configuration, declared additional repositories are a hard requirement: missing credentials, a missing origin, or an inaccessible declared repository fails preflight and run initialization with the repository named.
The higher-precedence list replaces the lower one wholesale — no union and no `...` splice — and `additional_repositories = []` explicitly clears an inherited list. `additional_repositories` and `permissions` resolve independently; if layering leaves repositories declared while permissions were cleared, resolution reports the invalid combination instead of dropping either field.
See [Additional repositories](/integrations/github#additional-repositories) for what works inside stages (`gh`, GitHub API, plain Git over HTTPS and the common SSH spellings) and for the security boundary.
### `[run.notifications]`
Define named notification routes for run events. Slack lifecycle notifications are configured here, not in server config.
@ -420,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

@ -3,7 +3,9 @@ title: "Brave Search"
description: "Give Fabro agents web search capabilities via the Brave Search API"
---
Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. It uses the [Brave Search API](https://brave.com/search/api/) to return titles, URLs, and descriptions for any query. Setting the API key is the only configuration needed — the tool is then registered for all provider profiles (Anthropic, OpenAI, Gemini). Without a key the tool is not registered at all, so agents are never offered a search tool they cannot use.
Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. It uses the [Brave Search API](https://brave.com/search/api/) to return titles, URLs, and descriptions for any query.
Fabro selects the backend from the credentials in its vault. Direct Brave Search is preferred whenever `BRAVE_SEARCH_API_KEY` is present. When that key is absent, Fabro can use [Venice Search](/integrations/venice-search) with `VENICE_API_KEY` instead.
## Setup
@ -21,7 +23,7 @@ fabro secret set BRAVE_SEARCH_API_KEY BSA...
fabro doctor
```
The doctor output should show **Brave Search** as "connected". If the key is missing, web search is reported as a warning — workflows still run, but the `web_search` tool is omitted from the agent's tool set and its system prompt, so agents fall back to other tools.
The doctor output should show **Web Search** as `brave: configured and reachable`. If the Brave key is missing but a Venice key exists, Fabro checks Venice instead. If neither key exists, web search is reported as a warning. Workflows still run, but the `web_search` tool is omitted from the agent's tool set and its system prompt.
The Fabro server reads this key from the vault only. It does not read `BRAVE_SEARCH_API_KEY` from process env or `server.env`.
@ -39,7 +41,7 @@ Agents call the `web_search` tool with a query string. Fabro sends the query to
The Rust book
```
If `BRAVE_SEARCH_API_KEY` is not configured in the vault, the tool returns an error explaining that the key is required. The agent can then fall back to other approaches.
If `BRAVE_SEARCH_API_KEY` is not configured, Fabro uses Venice when `VENICE_API_KEY` is available. If neither key is configured, the tool is not registered.
See the [`web_search` tool reference](/agents/tools#web_search) for parameters and details.

View file

@ -141,6 +141,15 @@ provider = "daytona"
enabled = false
```
Daytona clones 100 commits by default. To keep only the newest commit, set a smaller clone depth:
```toml title="run.toml"
[run.clone]
depth = 1
```
Set `depth = 0` to clone the full repository history.
If the clone fails without GitHub access configured, Fabro suggests running the setup flow:
```
@ -222,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

@ -62,6 +62,7 @@ When you choose the GitHub App strategy, the CLI opens GitHub with a pre-filled
| Emails | Read | Read verified email for OAuth login |
| Dependabot alerts | Write | Read and manage repository vulnerability alerts |
| Organization projects | Write | Read and update organization Projects V2 |
| Packages | Read | Download private GitHub Packages (e.g. npm registry) with the sandbox `GITHUB_TOKEN` |
These permissions are included when Fabro registers a new app. For an existing GitHub App, add the missing permissions in the app's settings, then approve the permission update on each installation before workflows can use them.
@ -226,9 +227,33 @@ 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, cloning additional private repos, or pushing to branches. 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`.
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`.
```toml title="workflow.toml"
[run.integrations.github.permissions]
@ -238,6 +263,46 @@ pull_requests = "write"
Only the listed permissions are requested — the token is scoped to the minimum access needed. If the GitHub App isn't configured or the repository lacks an installation, the run logs a warning and continues without the token.
In App mode, the token covers only the run's origin repository unless the run declares [additional repositories](#additional-repositories). Injecting `GITHUB_TOKEN` alone does not make other private repositories reachable.
### Additional repositories
A run can declare extra GitHub repositories that its stages may access through the same `GITHUB_TOKEN`:
```toml title="workflow.toml"
[run.integrations.github]
additional_repositories = ["fabro-sh/keystone"]
permissions = { contents = "read" }
```
The run origin stays implicit — never list it. Each entry is a full `owner/repository` slug (no scheme, host, ref, or extra path component). Fabro mints **one** installation token scoped to the origin plus every declared repository, with the one shared `permissions` map applying to all of them.
What works against every declared repository, within the granted permissions:
- **`gh` CLI and raw GitHub API calls** through `GITHUB_TOKEN`.
- **Plain Git over HTTPS** (`git clone https://github.com/owner/repo`), through a secret-free credential helper that reads `$GITHUB_TOKEN` at invocation time.
- **The common SSH spellings** `git@github.com:owner/repo[.git]` and `ssh://git@github.com/owner/repo[.git]`, through per-repository SSH-to-HTTPS rewrites injected into the stage environment.
Fabro does not clone additional repositories for you; a workflow that needs one on disk adds its own clone step (`git clone https://github.com/owner/repo` or `gh repo clone owner/repo`).
Requirements and validation:
- Every repository in the effective set must share **one owner** and be reachable by the origin repository's GitHub App installation, because one App installation covers one account. Cross-owner declarations fail configuration validation; a same-owner repository outside the installation fails preflight and run initialization with the repository named.
- A non-empty `additional_repositories` requires `contents = "read"` or `contents = "write"` in the permission map.
- Malformed slugs, duplicates (repository identity is case-insensitive), and sets larger than 499 entries fail configuration validation with indexed error paths.
- Unlike permissions-only configuration, declared additional repositories are a hard requirement: missing GitHub credentials, a missing origin, or an inaccessible declared repository fails preflight and run initialization instead of continuing without the token.
- Layering: the higher-precedence `additional_repositories` list replaces the lower one wholesale (no union, no `...` splice), and `additional_repositories = []` explicitly clears an inherited list. `permissions` keeps its existing whole-map replacement behavior. If layering leaves repositories declared with permissions cleared, configuration resolution reports the invalid combination.
Behavior notes:
- **Token strategy (PAT):** the configured PAT is used as-is. The repository list drives validation and preflight probes, but it cannot narrow the PAT's inherent GitHub scope — App mode remains the least-authority option.
- **`GH_TOKEN` precedence:** `gh` checks `GH_TOKEN` before `GITHUB_TOKEN`. If the resolved run environment defines `GH_TOKEN`, `gh` uses it instead of the managed token; Fabro never sets or removes `GH_TOKEN`, and preflight warns when additional repositories are declared alongside one.
- **SSH rewrites match by prefix.** With `owner/repo` declared, the SSH spelling of `owner/repo-other` is also rewritten to HTTPS. The scoped token is invalid for undeclared repositories at GitHub, so authority is unchanged — but a private undeclared repository fails with a GitHub authorization error instead of a missing-credential or SSH error.
#### Security boundary
Workflow authors may name any repository reachable by the server's GitHub App installation; Fabro applies no second server-side repository intersection. The token is scoped server-side to exactly the declared set — a request to an undeclared repository fails at GitHub, and Fabro never mints an unscoped installation-wide token. With `contents = "write"`, **any stage can push to any declared repository**. Declare the smallest repository set and the weakest permissions that work.
Installation Access Tokens are short-lived. Fabro refreshes its own credentials before checkpoint pushes. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro also re-mints the token and rewrites the sandbox's `origin` URL before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage.
`FABRO_PUSH_CRED_REFRESH_AHEAD` defaults to enabled; set it to `0`, `false`, `off`, `no`, or an empty value to disable both turn-entry and background refresh. `FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS` overrides the background interval, and `0` disables only the background loop. This refresh loop is ACP-specific; command and native/API agent stages do not run it. Reconnected sandboxes for resumed or parked runs currently lack the App credentials needed for ACP refresh, so the refresh is skipped there.

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

@ -0,0 +1,79 @@
---
title: "Venice Search"
description: "Give Fabro agents web search capabilities via Venice's augment/search API"
---
Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. Fabro uses [Venice Search](https://docs.venice.ai/api-reference/endpoint/augment/search) automatically when `VENICE_API_KEY` is available and a direct [Brave Search](/integrations/brave-search) key is not.
Venice Search reuses the same `VENICE_API_KEY` as the Venice LLM provider. Agents keep calling `web_search`; only the HTTP backend changes.
## Setup
1. Store a Venice API key on the Fabro server (skip this if the Venice LLM provider is already logged in):
```bash
fabro provider login --provider venice
# or
fabro secret set VENICE_API_KEY venice-...
```
Fabro prefers direct Brave Search whenever `BRAVE_SEARCH_API_KEY` is also present. To select Venice, leave that key unset or remove it:
```bash
fabro secret rm BRAVE_SEARCH_API_KEY
```
2. Verify the key is working:
```bash
fabro doctor
```
The doctor output should show **Web Search** as `venice: configured and reachable`. If neither Venice nor Brave is configured, web search is reported as a warning. Workflows still run, but the `web_search` tool is omitted from the agent's tool set.
The Fabro server reads this key from the vault only. It does not read `VENICE_API_KEY` from process env or `server.env`.
## How it works
Agents call the `web_search` tool with a query string. Fabro `POST`s to Venice `https://api.venice.ai/api/v1/augment/search` with the Brave search engine and returns numbered results with title, URL, description, and date when Venice includes one:
```
1. Rust Lang
https://rust-lang.org
A systems language
2026-01-02
```
Venice Search is billed by Venice at $0.01 per request and is rate-limited to 20 requests per minute on the Venice side. Queries longer than 400 characters are rejected before the HTTP call.
If `VENICE_API_KEY` is absent but `BRAVE_SEARCH_API_KEY` exists, Fabro uses direct Brave Search. If neither key exists, the tool is not registered. After selecting Venice, a failed call returns an error; Fabro does not retry through direct Brave Search.
See the [`web_search` tool reference](/agents/tools#web_search) for parameters and details.
## Permissions
`web_search` is classified as a `shell` category tool, requiring the `full` [permission level](/agents/permissions) for auto-approval. At lower permission levels:
- **Interactive mode** — the user is prompted to approve each call
- **Non-interactive mode** (`--auto-approve`) — calls are denied
## Troubleshooting
**"VENICE_API_KEY is not configured"** — Add the key with `fabro secret set VENICE_API_KEY <key>` or `fabro provider login --provider venice`. Run `fabro doctor` to verify.
**"Venice Search API returned status 401"** — The API key is invalid or expired. Create a new key at [venice.ai](https://venice.ai).
**"Venice Search API returned status 402"** — The Venice account is out of credits. The error may include a remaining-balance hint.
**"Venice Search API returned status 429"** — Rate limit exceeded (20 requests per minute on Venice Search). Reduce the frequency of `web_search` calls.
## Further reading
<Columns cols={2}>
<Card title="Tools" icon="wrench" href="/agents/tools#web_search">
Full `web_search` tool reference — parameters, output format, and error handling.
</Card>
<Card title="Brave Search" icon="globe" href="/integrations/brave-search">
Direct Brave Search backend, preferred whenever its key is configured.
</Card>
</Columns>

View file

@ -0,0 +1,125 @@
---
title: "Venice"
description: "Run Kimi, Grok, GLM, DeepSeek, and Qwen models through Venice"
---
[Venice](https://venice.ai/) provides an OpenAI-compatible API for hosted text models. Fabro enables the `venice` provider in its built-in catalog and maps stable Fabro model slugs to Venice's API model IDs.
## Prerequisites
- A Venice account
- An inference API key from [venice.ai/settings/api](https://venice.ai/settings/api)
- A running Fabro server
## Configure credentials
Store the API key in the target Fabro server vault:
```bash
fabro provider login --provider venice
# For a non-default remote server:
fabro provider login --server https://your-fabro.example --provider venice
# Or set the vault token directly:
fabro secret set VENICE_API_KEY
fabro secret --server https://your-fabro.example set VENICE_API_KEY
```
Standalone SDK usage outside a Fabro server can use an env-backed credential source explicitly:
```bash
export VENICE_API_KEY=<api-key>
```
Fabro sends bearer-authenticated Chat Completions requests to `https://api.venice.ai/api/v1`.
## Included models
| Fabro model slug | Venice API ID | Context | Max output | Role and aliases |
|---|---|---:|---:|---|
| `kimi-k3` | `kimi-k3` | 1,000,000 | 131,072 | Alias `kimi` |
| `kimi-k3-fast` | `kimi-k3-fast-api` | 1,000,000 | 131,072 | Alias `kimi-fast` |
| `grok-4.6` | `grok-4-6` | 500,000 | 32,000 | Aliases `grok`, `grok46`, `grok-46` |
| `glm-5.3` | `z-ai-glm-5-3` | 1,000,000 | 131,072 | Aliases `glm`, `glm5`, `glm53`, `glm5.3`, `glm-5-3` |
| `deepseek-v4-flash` | `deepseek-v4-flash-0731` | 1,000,000 | 32,768 | Provider default; aliases `deepseek`, `deepseek-v4`, `deepseek-flash` |
| `deepseek-v4-pro` | `deepseek-v4-pro-0813` | 1,000,000 | 32,768 | Alias `deepseek-pro` |
| `qwen3.8-max` | `qwen-3-8-max` | 1,000,000 | 131,072 | Aliases `qwen`, `qwen-max`, `qwen3.8`, `qwen-3.8`, `qwen38`, `qwen-3.8-max`, `qwen38-max` |
| `qwen3.8-27b` | `qwen-3-8-27b` | 262,144 | 131,072 | Aliases `qwen-27b`, `qwen-3.8-27b`, `qwen38-27b` |
Venice API IDs are also valid provider-scoped selectors. Fabro persists the stable Fabro slug and the selected provider when it creates a run.
## Select Venice explicitly
Some Venice models use the same stable slugs as direct providers. An unqualified selector chooses the highest-priority ready provider. For example, `deepseek` can select the direct DeepSeek provider when both API keys are configured.
Pin Venice when the run must use Venice:
```bash
fabro model list --provider venice
fabro model test --provider venice --model deepseek-v4-flash --tools
fabro run workflow.fabro --provider venice --model deepseek-v4-flash
```
In a workflow stylesheet:
```dot title="workflow.fabro"
digraph Example {
graph [
model_stylesheet="
* { provider: venice; model: deepseek-v4-flash; }
.complex { provider: venice; model: qwen; }
.fast { provider: venice; model: kimi-fast; }
"
]
start [shape=Mdiamond, label="Start"]
work [label="Implement", class="complex"]
check [label="Check", class="fast"]
exit [shape=Msquare, label="Exit"]
start -> work -> check -> exit
}
```
The generic Qwen aliases `qwen` and `qwen3.8` select Qwen 3.8 Max. Use a size-specific alias such as `qwen-27b` to select Qwen 3.8 27B.
## Capabilities and reasoning
All included models support tool calling and reasoning. Kimi K3, Kimi K3 Fast, Grok 4.6, Qwen 3.8 Max, and Qwen 3.8 27B also accept image input.
Fabro exposes native reasoning-effort controls only when Venice supports them:
| Model | Reasoning effort values |
|---|---|
| `grok-4.6` | `low`, `medium`, `high`, `xhigh` |
| `glm-5.3` | `low`, `high`, `max` |
| `deepseek-v4-flash` | `low`, `high`, `max` |
| `qwen3.8-27b` | `low`, `medium`, `xhigh` |
The other models reason by default but do not expose a Venice reasoning-effort control. Fabro omits sampling parameters for Kimi and DeepSeek because those routes do not use them with their configured reasoning behavior.
## Pricing and prompt caching
The built-in catalog uses Venice's published prices per million tokens:
| Model | Uncached input | Cache hit | Output |
|---|---:|---:|---:|
| `kimi-k3` | $3.75 | $0.375 | $18.75 |
| `kimi-k3-fast` | $4.50 | $0.45 | $22.50 |
| `grok-4.6` | $2.27 | $0.57 | $6.80 |
| `glm-5.3` | $1.75 | $0.325 | $5.50 |
| `deepseek-v4-flash` | $0.175 | $0.035 | $0.35 |
| `deepseek-v4-pro` | $1.65 | $0.165 | $4.95 |
| `qwen3.8-max` | $2.50 | $0.3125 | $7.50 |
| `qwen3.8-27b` | $0.45 | n/a | $3.20 |
Fabro reports cached input separately when Venice returns cache usage for the selected model. Prices and model availability can change upstream; use `fabro model list --provider venice` to inspect the catalog shipped with your Fabro version and the [Venice model catalog](https://docs.venice.ai/models/overview) for the current upstream service.
## Troubleshooting
**"No credential was found for provider 'venice'"** — Store `VENICE_API_KEY` in the server vault with `fabro provider login --provider venice`. Pass `--server` when configuring a remote Fabro server.
**A shared model used another provider** — Pin Venice with `--provider venice` or `provider: venice` in the workflow stylesheet. Unqualified selectors use provider priority.
**A Venice API model ID is rejected without a provider** — Use the stable Fabro slug for portable selection, or qualify the API ID with the provider, such as `venice:qwen-3-8-max`.

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

@ -34,7 +34,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
| Scope | Examples |
|---|---|
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm.providers.<id>]`, provider-scoped `[llm.providers.<id>.models.<slug>]` offerings, limits, features, controls, and costs |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |

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

@ -950,7 +950,8 @@ fn build_github_app_manifest(app_name: &str, port: u16, web_url: &str) -> serde_
"issues": "write",
"emails": "read",
"vulnerability_alerts": "write",
"organization_projects": "write"
"organization_projects": "write",
"packages": "read"
},
"default_events": []
})
@ -2682,6 +2683,10 @@ client_id = "client-id"
manifest["default_permissions"]["organization_projects"],
serde_json::json!("write"),
);
assert_eq!(
manifest["default_permissions"]["packages"],
serde_json::json!("read"),
);
}
#[test]

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

@ -843,6 +843,8 @@ mod tests {
graph: fabro_types::Graph::new("test"),
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

@ -161,13 +161,13 @@ pub(crate) async fn execute(
artifact_sink,
run_control: Some(run_control),
github_app,
github_permissions: run_spec
github_integration: run_spec
.settings
.run
.integrations
.github
.resolve_permissions()
.context("failed to resolve github permissions")?,
.resolve_integration()
.context("failed to resolve github integration")?,
vault,
catalog,
on_node: None,
@ -1762,7 +1762,10 @@ mod tests {
.parse::<EnvironmentProvider>()
.expect("test provider should parse");
run.integrations = RunIntegrationsSettings {
github: RunIntegrationsGithubSettings { permissions },
github: RunIntegrationsGithubSettings {
permissions,
..RunIntegrationsGithubSettings::default()
},
};
run
}

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

@ -924,6 +924,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
"skip_git_hooks": false
},
"clone": {
"depth": 100,
"enabled": true
},
"environment": {

View file

@ -152,7 +152,8 @@ fn inspect_resolves_selector_via_server_endpoint() {
"commit_timeout_ms": 30000
},
"clone": {
"enabled": true
"enabled": true,
"depth": 100
},
"run_branch": {
"enabled": true,

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

@ -47,6 +47,8 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
graph: Graph::new("Remote Workflow"),
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()

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