Compare commits

..

50 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
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
Scott Werner
2f3b6477f2 Add empty workspace run target 2026-08-24 14:49:03 -04:00
166 changed files with 10303 additions and 1791 deletions

108
Cargo.lock generated
View file

@ -2257,7 +2257,7 @@ dependencies = [
[[package]]
name = "fabro-acp"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-tokio",
@ -2276,7 +2276,7 @@ dependencies = [
[[package]]
name = "fabro-agent"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2323,7 +2323,7 @@ dependencies = [
[[package]]
name = "fabro-api"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"fabro-automation",
@ -2346,7 +2346,7 @@ dependencies = [
[[package]]
name = "fabro-auth"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2371,7 +2371,7 @@ dependencies = [
[[package]]
name = "fabro-automation"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2391,11 +2391,11 @@ dependencies = [
[[package]]
name = "fabro-build-support"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
[[package]]
name = "fabro-checkpoint"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -2411,7 +2411,7 @@ dependencies = [
[[package]]
name = "fabro-cli"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2513,7 +2513,7 @@ dependencies = [
[[package]]
name = "fabro-client"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2542,7 +2542,7 @@ dependencies = [
[[package]]
name = "fabro-config"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2572,13 +2572,14 @@ dependencies = [
[[package]]
name = "fabro-core"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"fabro-types",
"fabro-util",
"serde",
"serde_json",
"strum 0.28.0",
"thiserror 2.0.18",
"tokio",
"tokio-util",
@ -2587,7 +2588,7 @@ dependencies = [
[[package]]
name = "fabro-db"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2600,7 +2601,7 @@ dependencies = [
[[package]]
name = "fabro-dev"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -2619,7 +2620,7 @@ dependencies = [
[[package]]
name = "fabro-dump"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -2633,7 +2634,7 @@ dependencies = [
[[package]]
name = "fabro-environment"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2655,7 +2656,7 @@ dependencies = [
[[package]]
name = "fabro-github"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2680,7 +2681,7 @@ dependencies = [
[[package]]
name = "fabro-graphviz"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -2695,7 +2696,7 @@ dependencies = [
[[package]]
name = "fabro-hooks"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"fabro-agent",
@ -2718,7 +2719,7 @@ dependencies = [
[[package]]
name = "fabro-http"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-static",
"http 1.4.0",
@ -2728,7 +2729,7 @@ dependencies = [
[[package]]
name = "fabro-install"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2747,7 +2748,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"dialoguer",
@ -2762,7 +2763,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2804,7 +2805,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"clap",
"fabro-options-metadata",
@ -2815,7 +2816,7 @@ dependencies = [
[[package]]
name = "fabro-manifest"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"fabro-api",
@ -2836,7 +2837,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2856,7 +2857,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-server"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2884,7 +2885,7 @@ dependencies = [
[[package]]
name = "fabro-mcp-store"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"fabro-db",
@ -2902,8 +2903,9 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"clap",
"fabro-static",
"http 1.4.0",
"insta",
@ -2918,7 +2920,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"axum",
@ -2940,7 +2942,7 @@ dependencies = [
[[package]]
name = "fabro-options-metadata"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"serde",
"serde_json",
@ -2948,7 +2950,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"cc",
"libc",
@ -2957,7 +2959,7 @@ dependencies = [
[[package]]
name = "fabro-redact"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"aho-corasick",
"ref-cast",
@ -2973,7 +2975,7 @@ dependencies = [
[[package]]
name = "fabro-sandbox"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3017,7 +3019,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3069,6 +3071,7 @@ dependencies = [
"fabro-workflow",
"fabro-workflow-version",
"futures-util",
"git2",
"globset",
"hex",
"hkdf 0.12.4",
@ -3112,7 +3115,7 @@ dependencies = [
[[package]]
name = "fabro-slack"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-interview",
@ -3134,18 +3137,18 @@ dependencies = [
[[package]]
name = "fabro-spa"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"rust-embed",
]
[[package]]
name = "fabro-static"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
[[package]]
name = "fabro-store"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"async-trait",
"bytes",
@ -3161,6 +3164,7 @@ dependencies = [
"percent-encoding",
"serde",
"serde_json",
"sha2 0.10.9",
"slatedb",
"sqlx",
"strum 0.28.0",
@ -3175,7 +3179,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -3201,7 +3205,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -3215,7 +3219,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -3240,7 +3244,7 @@ dependencies = [
[[package]]
name = "fabro-tool"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3261,7 +3265,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -3275,7 +3279,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"chrono",
"clap",
@ -3298,7 +3302,7 @@ dependencies = [
[[package]]
name = "fabro-util"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"console 0.15.11",
@ -3321,7 +3325,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-acp",
"fabro-graphviz",
@ -3334,7 +3338,7 @@ dependencies = [
[[package]]
name = "fabro-variable"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3351,7 +3355,7 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -3370,7 +3374,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -3440,7 +3444,7 @@ dependencies = [
[[package]]
name = "fabro-workflow-version"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"fabro-config",
"fabro-graphviz",
@ -8597,7 +8601,7 @@ dependencies = [
[[package]]
name = "twin-github"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"axum",
"base64",
@ -8616,7 +8620,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.336.0-nightly.1"
version = "0.338.0-nightly.0"
dependencies = [
"anyhow",
"async-stream",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -259,7 +259,7 @@ honors those hand-edited values even though the browser wizard does not manage t
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`. Revoking a session from **Settings → Sessions**, or with `DELETE /api/v1/auth/sessions/{id}`, deletes the session row and its tokens together.
CLI auth sessions are stored as an `auth_sessions` row per signed-in CLI, with the rotating refresh tokens for that session in `refresh_tokens`. Pending browser-to-CLI handoffs live briefly in `oauth_authorization_codes`; the table contains a SHA-256 hash of each one-time code, never the raw bearer value. Revoking a session from **Settings → Sessions**, or with `DELETE /api/v1/auth/sessions/{id}`, deletes the session row and its tokens together.
Before applying pending SQLite migrations, Fabro creates `<storage_root>/db/fabro.sqlite3.pre-migration.bak` with SQLite's `VACUUM INTO`. Each migration run replaces the previous snapshot, so only the most recent pre-migration backup is retained.

View file

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

View file

@ -5692,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
@ -5700,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"
@ -6234,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: >
@ -6728,6 +6738,7 @@ components:
- name
- description
- target
- workflow
- triggers
properties:
id:
@ -6746,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
@ -6840,6 +6833,7 @@ components:
- id
- name
- target
- workflow
- triggers
properties:
id:
@ -6853,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:
@ -6866,6 +6864,7 @@ components:
required:
- name
- target
- workflow
- triggers
properties:
name:
@ -6875,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:
@ -9298,13 +9301,20 @@ components:
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.
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:
@ -9321,14 +9331,62 @@ components:
example: acme/my-app
branch:
type: string
description: Required branch name, preserved exactly.
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 or prove branch ancestry.
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.

View file

@ -15,6 +15,10 @@ Two dates on **Settings → Sessions** were wrong as a result and are now correc
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.

View file

@ -1,8 +1,25 @@
---
title: "More reliable Daytona snapshot activation"
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">

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

@ -309,6 +309,8 @@
"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",

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -227,18 +227,29 @@ 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.
#### Exact commits for run intents
#### Git targets for run intents
The `RunIntent` create body names a required Git branch and may also pin a full
40-character commit SHA. Creating the run validates and lowercase-normalizes
the SHA, but it does not contact GitHub, resolve the commit, or prove that the
commit belongs to the submitted branch.
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:
At sandbox setup, Docker fetches the submitted commit directly and Daytona
receives it as `commit_id`; the submitted branch remains the working branch.
If the exact commit is unavailable, setup fails. Fabro never substitutes the
branch's newer HEAD. When the request omits `sha`, the sandbox resolves the
branch at materialization time instead.
| 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

@ -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

@ -113,6 +113,7 @@ 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"

View file

@ -125,12 +125,14 @@ pub(crate) async fn activate_blob_storage(
);
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

View file

@ -28,8 +28,8 @@ use url::{Host, Url};
use crate::auth::browser_shell::browser_shell;
use crate::auth::{
self, AuthCode, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, JwtSubject,
REFRESH_TOKEN_PREFIX, RotateOutcome,
self, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, JwtSubject,
PendingCliAuthorization, REFRESH_TOKEN_PREFIX, RotateOutcome,
};
use crate::jwt_auth::{AuthMode, ConfiguredAuth, bearer_token_from_headers};
use crate::principal_middleware::{
@ -390,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");
@ -1117,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(),
@ -1129,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,
@ -1185,7 +1165,9 @@ 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, AuthSessionRecord, InitialRefreshToken};
use crate::auth::{
self, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, PendingCliAuthorization,
};
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
use crate::principal_middleware::{AuthStatus, RequestAuthContext};
use crate::server::AppState;
@ -1329,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(),
@ -1721,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");
@ -2078,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"));

View file

@ -30,7 +30,7 @@ pub(crate) const REFRESH_TOKEN_PREFIX: &str = "fabro_refresh_";
pub(crate) use browser_shell::browser_shell;
pub(crate) use cli_flow::web_routes;
pub(crate) use fabro_store::AuthCode;
pub(crate) use fabro_store::PendingCliAuthorization;
pub(crate) use fabro_store::auth_session_store::{
AuthSessionRecord, InitialRefreshToken, RotateOutcome,
};

View file

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

View file

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

View file

@ -171,6 +171,16 @@ impl PreparedRun {
&self.layered.settings
}
pub(crate) fn with_target_and_git(
mut self,
target: RunTarget,
git: Option<GitContext>,
) -> Self {
self.layered.metadata.target = Some(target);
self.layered.metadata.git = git;
self
}
pub(crate) fn with_identity(
mut self,
run_id: Option<RunId>,

View file

@ -6,11 +6,14 @@ use fabro_config::{EnvironmentLayer, RunEnvironmentLayer, RunGoalLayer, Settings
use fabro_environment::{EnvironmentId, EnvironmentValidationError};
use fabro_types::settings::InterpString;
use fabro_types::{
ManifestPath, SandboxProviderKind, TargetValidationError, WorkflowPath, WorkflowVersionId,
GitContext, ManifestPath, RunTarget, SandboxProviderKind, TargetValidationError, WorkflowPath,
WorkflowVersionId,
};
use fabro_workflow::git;
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
use fabro_workflow_version::{LoadedWorkflowVersionClosure, ValidatedWorkflowVersion};
use thiserror::Error;
use tokio::{fs, task};
use crate::run_compiler::{RunCompilerError, settings_layer_with_resolved_dockerfiles};
@ -26,6 +29,8 @@ pub(crate) enum RunIntentAdmissionError {
#[error(transparent)]
Target(#[from] TargetValidationError),
#[error(transparent)]
FolderTarget(#[from] FolderTargetValidationError),
#[error(transparent)]
Environment(#[from] EnvironmentSelectionError),
#[error(transparent)]
Compiler(#[from] RunCompilerError),
@ -36,6 +41,85 @@ pub(crate) enum RunIntentAdmissionError {
},
}
#[derive(Debug, Error)]
pub(crate) enum FolderTargetValidationError {
#[error("folder target path must be absolute")]
Relative,
#[error("folder target path does not name an accessible filesystem entry")]
Canonicalize {
#[source]
source: std::io::Error,
},
#[error("folder target path must name a directory")]
NotDirectory,
#[error("folder target canonical path must be valid UTF-8")]
NonUtf8,
}
#[derive(Debug)]
pub(crate) struct PreparedIntentTarget {
pub(crate) target: RunTarget,
pub(crate) git: Option<GitContext>,
}
/// Materialize filesystem-backed target facts after the effective environment
/// has been admitted as Local and before run allocation. Folder targets are
/// canonicalized once for durable identity and their optional Git metadata is
/// observed under the same provider gate, so rejected requests never scan host
/// repositories. Other targets pass through with their validated projection.
pub(crate) async fn prepare_intent_target(
target: RunTarget,
git: Option<GitContext>,
) -> Result<PreparedIntentTarget, FolderTargetValidationError> {
let RunTarget::Folder { path } = target else {
return Ok(PreparedIntentTarget { target, git });
};
let submitted = PathBuf::from(path);
if !submitted.is_absolute() {
return Err(FolderTargetValidationError::Relative);
}
let canonical = fs::canonicalize(&submitted)
.await
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
let metadata = fs::metadata(&canonical)
.await
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
if !metadata.is_dir() {
return Err(FolderTargetValidationError::NotDirectory);
}
let path = canonical_folder_text(&canonical)?;
let git = task::spawn_blocking(move || {
git::observe_git_context(&canonical).unwrap_or_else(|error| {
tracing::warn!(
error = ?error,
path = %canonical.display(),
"failed to observe optional git metadata for folder target"
);
None
})
})
.await
.unwrap_or_else(|error| {
tracing::warn!(
error = ?error,
path,
"folder target git observation task failed"
);
None
});
Ok(PreparedIntentTarget {
target: RunTarget::Folder { path },
git,
})
}
fn canonical_folder_text(path: &Path) -> Result<String, FolderTargetValidationError> {
path.to_str()
.map(str::to_string)
.ok_or(FolderTargetValidationError::NonUtf8)
}
#[derive(Debug, Error)]
pub(crate) enum EnvironmentSelectionError {
#[error("invalid environment ID `{value}`")]
@ -46,8 +130,8 @@ pub(crate) enum EnvironmentSelectionError {
},
#[error("environment `{id}` not found")]
NotFound { id: EnvironmentId },
#[error("Git targets require a compatible clone-enabled Docker or Daytona environment")]
TargetUnsupported,
#[error("{detail}")]
TargetUnsupported { detail: &'static str },
#[error("{detail}")]
ProviderDisabled {
provider: SandboxProviderKind,
@ -377,6 +461,81 @@ mod tests {
.unwrap()
}
#[tokio::test]
async fn prepares_a_canonical_folder_target_without_git_projection() {
let dir = tempfile::tempdir().unwrap();
let target_dir = dir.path().join("target");
std::fs::create_dir(&target_dir).unwrap();
std::fs::create_dir(dir.path().join("nested")).unwrap();
let submitted = dir.path().join("nested").join("..").join("target");
let prepared = prepare_intent_target(
RunTarget::Folder {
path: submitted.to_string_lossy().to_string(),
},
None,
)
.await
.unwrap();
let canonical = target_dir.canonicalize().unwrap();
assert_eq!(prepared.git, None);
assert_eq!(prepared.target, RunTarget::Folder {
path: canonical.to_string_lossy().to_string(),
});
}
#[tokio::test]
async fn rejects_relative_missing_and_file_folder_targets() {
let relative = prepare_intent_target(
RunTarget::Folder {
path: "relative/path".to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(relative, FolderTargetValidationError::Relative));
let dir = tempfile::tempdir().unwrap();
let missing = prepare_intent_target(
RunTarget::Folder {
path: dir.path().join("missing").to_string_lossy().to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(
missing,
FolderTargetValidationError::Canonicalize { .. }
));
let file = dir.path().join("file");
fs::write(&file, "not a directory").await.unwrap();
let file = prepare_intent_target(
RunTarget::Folder {
path: file.to_string_lossy().to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(file, FolderTargetValidationError::NotDirectory));
}
#[cfg(unix)]
#[test]
fn rejects_a_non_utf8_canonical_folder_target() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let path = PathBuf::from(OsString::from_vec(vec![b'f', b'o', 0x80]));
let error = canonical_folder_text(&path).unwrap_err();
assert!(matches!(error, FolderTargetValidationError::NonUtf8));
}
#[tokio::test]
async fn lowers_nested_entrypoints_and_inlines_goal_files() {
let (database, _) = crate::test_support::test_store_bundle();

View file

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

View file

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

View file

@ -783,10 +783,12 @@ where
)
.await
.context("activating SQLite blob storage")?;
let auth_code_store = store.auth_codes().await?;
// Refresh tokens now live in SQLite. Nothing reads the old records and no
// reaper collects them any more, so clear them out once rather than
// leaving them in the object store forever.
// leaving them in the object store forever. Pending authorization codes
// also moved to SQLite, but their old records are left in place: at most a
// handful exist at cutover, every binary rejects them within 60 seconds of
// issue, and nothing reads their keyspace again.
match store.retire_refresh_token_keyspace().await {
Ok(0) => {}
Ok(removed) => info!(removed, "Removed retired SlateDB refresh token records"),
@ -857,7 +859,7 @@ where
.await?;
spawn_auth_store_reapers(
Arc::clone(&auth_code_store),
Arc::clone(&state.stores.auth_codes),
Arc::clone(&state.stores.auth_sessions),
shutdown.clone(),
);

View file

@ -85,8 +85,8 @@ use fabro_slack::threads::ThreadRegistry;
use fabro_slack::{blocks as slack_blocks, connection as slack_connection};
use fabro_static::EnvVars;
use fabro_store::{
ArtifactKey, ArtifactStore, AuthSessionStore, CachedRunProjection, Database, EventEnvelope,
EventPayload, KeyedMutex, NodeArtifact, PendingInterviewRecord, RunSummaryStore,
ArtifactKey, ArtifactStore, AuthCodeStore, AuthSessionStore, CachedRunProjection, Database,
EventEnvelope, EventPayload, KeyedMutex, NodeArtifact, PendingInterviewRecord, RunSummaryStore,
StageArtifactEntry, StageId,
};
#[cfg(test)]
@ -1154,6 +1154,7 @@ pub struct AppState {
pub(crate) struct AppStores {
pub(crate) runs: Arc<Database>,
pub(crate) run_summaries: Arc<RunSummaryStore>,
pub(crate) auth_codes: Arc<AuthCodeStore>,
pub(crate) auth_sessions: Arc<AuthSessionStore>,
pub(crate) automations: Arc<AutomationStore>,
pub(crate) environments: Arc<EnvironmentStore>,
@ -1170,6 +1171,12 @@ impl AppState {
pub fn test_auth_session_store(&self) -> &Arc<AuthSessionStore> {
&self.stores.auth_sessions
}
/// Access the auth-code store used by this router.
#[must_use]
pub fn test_auth_code_store(&self) -> &Arc<AuthCodeStore> {
&self.stores.auth_codes
}
}
impl AppState {
@ -1198,7 +1205,7 @@ impl AppState {
let credentials = self
.github_credentials(&settings.server.integrations.github)
.await
.map_err(|err| RunMaterializeError::Credentials(err.to_string()))?;
.map_err(|source| RunMaterializeError::Credentials { source })?;
ProductionAutomationRunMaterializer::new(
credentials,
self.github_api_base_url.clone(),
@ -2440,8 +2447,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
})
.context("load environments")?,
);
let run_summaries =
store.attach_run_summary_store(Arc::new(RunSummaryStore::new(db_pool.clone())));
let run_summaries = store.run_summary_store();
let auth_codes = Arc::new(AuthCodeStore::new(db_pool.clone()));
let auth_sessions = Arc::new(AuthSessionStore::new(db_pool.clone()));
let mcp_server_dir = mcp_server_dir_for_active_config(&active_config_path);
let mcp_server_pool = db_pool.clone();
@ -2549,6 +2556,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
stores: AppStores {
runs: store,
run_summaries,
auth_codes,
auth_sessions,
automations: automation_store,
environments: environment_store,

View file

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

View file

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

View file

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

View file

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

View file

@ -28,9 +28,10 @@ use fabro_store::{
};
use fabro_types::{
AutomationRef, ManifestPath, Principal, Run, RunClientProvenance, RunId, RunProvenance,
RunServerProvenance, RunStatusKind, SandboxProviderKind, StageContextWindow,
RunServerProvenance, RunStatusKind, RunTarget, SandboxProviderKind, StageContextWindow,
StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler,
StageModelUsage, StageProjection, SystemActorKind, json_scalar_to_toml_value, parse_blob_ref,
StageModelUsage, StageProjection, SystemActorKind, ValidatedRunTarget,
json_scalar_to_toml_value, parse_blob_ref,
};
use fabro_util::error as error_util;
use fabro_util::version::FABRO_VERSION;
@ -59,8 +60,8 @@ use crate::run_compiler::{
};
use crate::run_files::{list_run_commits, list_run_files};
use crate::run_intent::{
EnvironmentSelectionError, RunIntentAdmissionError, lower_workflow_closure,
pin_workflow_environment_authority,
EnvironmentSelectionError, PreparedIntentTarget, RunIntentAdmissionError,
lower_workflow_closure, pin_workflow_environment_authority, prepare_intent_target,
};
use crate::run_manifest;
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
@ -561,6 +562,7 @@ async fn create_run(
actor,
headers,
automation: None,
target: None,
},
))
.await
@ -610,8 +612,8 @@ async fn create_run_from_intent(
) -> Response {
// Validate the pure, in-memory request facts before paying for
// blob-store reads and closure lowering.
let validated_target = match intent.target.validate() {
Ok(target) => target,
let ValidatedRunTarget { target, git } = match intent.target.validate() {
Ok(validated) => validated,
Err(error) => return run_intent_admission_error(error.into()),
};
let environment_id = match select_intent_environment_id(
@ -696,6 +698,9 @@ async fn create_run_from_intent(
let raw_compiler_input = RawRunCompilerInput {
workflow_bundle: lowered.workflow_bundle,
entrypoint: lowered.entrypoint,
// Intent compilation is isolated from target-project content. Folder
// identity is projected to `source_directory` during persistence and
// must never become a compiler lookup root.
cwd: PathBuf::from("/workspace"),
server_run_defaults: state.manifest_run_defaults().as_ref().clone(),
server_environment_defaults: state.environment_store().catalog_layer().as_ref().clone(),
@ -711,11 +716,13 @@ async fn create_run_from_intent(
run_id: None,
title,
parent_id: intent.parent_id,
git: Some(validated_target.git),
// Target identity and its Git projection are attached after provider
// admission via `with_target_and_git`; the compiler never reads them.
git: None,
storage_root: state.server_storage_dir(),
workflow_slug: None,
workflow_version_id: Some(intent.workflow_version_id),
target: Some(validated_target.target),
target: None,
provenance: run_provenance(&headers, &actor),
web_url: None,
submitted_manifest_bytes: None,
@ -737,13 +744,18 @@ async fn create_run_from_intent(
});
}
};
let prepared = match run_compiler::apply_run_variables(layered, vars) {
let mut prepared = match run_compiler::apply_run_variables(layered, vars) {
Ok(prepared) => prepared,
Err(error) => return run_intent_admission_error(error.into()),
};
if let Err(error) = validate_intent_environment(&state, prepared.settings()).await {
if let Err(error) = validate_intent_environment(&state, prepared.settings(), &target).await {
return run_intent_admission_error(error.into());
}
let PreparedIntentTarget { target, git } = match prepare_intent_target(target, git).await {
Ok(prepared) => prepared,
Err(error) => return run_intent_admission_error(error.into()),
};
prepared = prepared.with_target_and_git(target, git);
let (prepared, run_id) = prepared.resolve_run_id();
if let Err(response) = validate_optional_parent(&state, run_id, prepared.parent_id()).await {
return response;
@ -977,7 +989,9 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
"Run intent admission rejected"
);
}
RunIntentAdmissionError::Target(_) | RunIntentAdmissionError::Environment(_) => {}
RunIntentAdmissionError::Target(_)
| RunIntentAdmissionError::FolderTarget(_)
| RunIntentAdmissionError::Environment(_) => {}
}
match error {
@ -996,6 +1010,11 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
error.to_string(),
"target_invalid",
),
RunIntentAdmissionError::FolderTarget(error) => intent_error(
StatusCode::UNPROCESSABLE_ENTITY,
error.to_string(),
"target_invalid",
),
RunIntentAdmissionError::Environment(error) => match error {
EnvironmentSelectionError::InvalidId { source, .. } => intent_error(
StatusCode::UNPROCESSABLE_ENTITY,
@ -1007,7 +1026,7 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
error.to_string(),
"environment_not_found",
),
EnvironmentSelectionError::TargetUnsupported => intent_error(
EnvironmentSelectionError::TargetUnsupported { .. } => intent_error(
StatusCode::UNPROCESSABLE_ENTITY,
error.to_string(),
"target_environment_unsupported",
@ -1063,16 +1082,31 @@ fn select_intent_environment_id(
async fn validate_intent_environment(
state: &AppState,
settings: &fabro_types::WorkflowSettings,
target: &RunTarget,
) -> Result<(), EnvironmentSelectionError> {
let provider = run_manifest::effective_sandbox_provider(&settings.run);
let image = &settings.run.environment.image;
let incompatible = match provider {
SandboxProviderKind::Local => true,
let image_incompatible = match provider {
SandboxProviderKind::Local => false,
SandboxProviderKind::Docker => image.docker.is_none() && image.dockerfile.is_some(),
SandboxProviderKind::Daytona => image.docker.is_some(),
};
if incompatible || !settings.run.clone.enabled {
return Err(EnvironmentSelectionError::TargetUnsupported);
let (target_incompatible, detail) = match target {
RunTarget::Git(_) => (
provider == SandboxProviderKind::Local || !settings.run.clone.enabled,
"Git targets require a compatible clone-enabled Docker or Daytona environment",
),
RunTarget::None {} => (
provider == SandboxProviderKind::Local,
"none targets require a compatible Docker or Daytona environment",
),
RunTarget::Folder { .. } => (
provider != SandboxProviderKind::Local,
"folder targets require a Local environment",
),
};
if image_incompatible || target_incompatible {
return Err(EnvironmentSelectionError::TargetUnsupported { detail });
}
if let Some(detail) =
run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
@ -1109,6 +1143,9 @@ pub(crate) struct CreateRunFromManifestRequest {
pub(crate) actor: Principal,
pub(crate) headers: HeaderMap,
pub(crate) automation: Option<AutomationRef>,
/// Trusted canonical target supplied by an internal manifest producer.
/// Public legacy manifest requests always leave this absent.
pub(crate) target: Option<RunTarget>,
}
struct ManifestRunCompilerAdapter {
@ -1254,6 +1291,7 @@ pub(crate) async fn create_run_from_manifest(
actor,
headers,
automation,
target,
} = request;
let manifest_run_defaults = state.manifest_run_defaults();
let manifest_environment_defaults = state.environment_store().catalog_layer();
@ -1285,7 +1323,7 @@ pub(crate) async fn create_run_from_manifest(
storage_root: state.server_storage_dir(),
workflow_slug: None,
workflow_version_id: None,
target: None,
target,
provenance: run_provenance(&headers, &actor),
web_url: None,
submitted_manifest_bytes: Some(submitted_manifest_bytes),
@ -1372,7 +1410,7 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) {
let run_store = match task.state.stores.runs.open_run(&task.run_id).await {
Ok(store) => store,
Err(err) => {
tracing::debug!(run_id = %task.run_id, error = %err, "Failed to open run store for title update");
tracing::warn!(run_id = %task.run_id, error = %err, "Failed to open run store for title update");
return;
}
};
@ -1390,7 +1428,7 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) {
)
.await
{
tracing::debug!(run_id = %task.run_id, error = %err, "Failed to append generated run title event");
tracing::warn!(run_id = %task.run_id, error = %err, "Failed to append generated run title event");
}
});
}

View file

@ -1169,7 +1169,10 @@ async fn drive_agent_session(
result = &mut process => {
while let Ok(event) = receiver.try_recv() {
record_turn_output(output, &event);
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
Box::pin(persist_agent_event(
run_store, run_id, session_id, turn_id, event, sender,
))
.await?;
}
return Ok(result);
}
@ -1177,7 +1180,10 @@ async fn drive_agent_session(
match event {
Ok(event) => {
record_turn_output(output, &event);
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
Box::pin(persist_agent_event(
run_store, run_id, session_id, turn_id, event, sender,
))
.await?;
}
Err(RecvError::Lagged(_) | RecvError::Closed) => {}
}

View file

@ -11,7 +11,7 @@ use async_zip::base::read::mem::ZipFileReader;
use axum::body::Body;
use axum::http::{Method, Request, header};
use chrono::{Duration as ChronoDuration, SubsecRound as _, Utc};
use fabro_automation::{AutomationId, AutomationTarget};
use fabro_automation::AutomationId;
use fabro_config::bind::Bind;
use fabro_config::{
EnvironmentLayer, MergeMap, RunLayer, ServerSettingsBuilder, WorkflowSettingsBuilder,
@ -27,8 +27,8 @@ use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{
AgentBackend, AttrValue, AuthMethod, BlobHash, CommandTermination, FailureCategory,
FailureDetail, Graph, InterviewQuestionRecord, Node, Outcome, ParallelBranchId, QuestionType,
RunId, RunSpec, SandboxProviderKind, StageContextWindowBreakdownItem,
FailureDetail, GitRunTarget, Graph, InterviewQuestionRecord, Node, Outcome, ParallelBranchId,
QuestionType, RunId, RunSpec, RunTarget, SandboxProviderKind, StageContextWindowBreakdownItem,
StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
StageContextWindowStaleness, StageContextWindowWarning, StageModelUsage, StageTiming,
SuccessReason, SystemActorKind, WorkflowSettings, fixtures, test_support,
@ -3529,21 +3529,37 @@ async fn generated_title_does_not_overwrite_user_title_edit() {
}
async fn post_run_manifest(app: &Router, manifest: serde_json::Value) -> serde_json::Value {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(manifest.to_string()))
.unwrap(),
)
.await
.unwrap();
let response = post_run_intent_response(app, manifest).await;
response_json!(response, StatusCode::CREATED).await
}
async fn post_run_intent_response(app: &Router, intent: serde_json::Value) -> Response {
app.clone()
.oneshot(json_request(Method::POST, "/runs", &intent))
.await
.unwrap()
}
/// App state whose default environment runs in place on the server, which is
/// the only placement folder targets admit.
fn local_test_app_state() -> Arc<AppState> {
TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Local))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build()
}
fn folder_intent(
workflow_version_id: fabro_types::WorkflowVersionId,
path: impl serde::Serialize,
) -> serde_json::Value {
json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "folder", "path": path },
"args": {}
})
}
async fn store_workflow_version(
state: &AppState,
graph: &str,
@ -3582,7 +3598,7 @@ async fn store_workflow_version(
}
#[tokio::test]
async fn post_runs_run_intent_creates_submitted_version_backed_git_target_without_starting() {
async fn post_runs_run_intent_persists_tagged_exact_git_target_without_starting() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(
@ -3622,6 +3638,7 @@ docker = "workflow-owned:latest"
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "feature/run-intent",
"tag": "v1.2.3",
"sha": submitted_sha
},
"args": {
@ -3655,11 +3672,12 @@ docker = "workflow-owned:latest"
);
assert_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::Git {
Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
tag: Some("v1.2.3".to_string()),
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
})
}))
);
assert_eq!(
projection
@ -3717,6 +3735,307 @@ docker = "workflow-owned:latest"
);
}
#[tokio::test]
async fn post_runs_run_intent_creates_submitted_none_target_without_git_projection() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let body = post_run_manifest(
&app,
json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {}
}),
)
.await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let events = run_store.list_events().await.unwrap();
assert_eq!(
events
.iter()
.map(|event| event.event.event_name())
.collect::<Vec<_>>(),
vec!["run.created", "run.submitted"]
);
let projection = run_store.state().await.unwrap();
assert_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::None {})
);
assert_eq!(
projection.spec.workflow_version_id,
Some(workflow_version_id)
);
assert_eq!(projection.spec.source_directory, None);
assert_eq!(projection.spec.git, None);
assert!(projection.spec.settings.run.clone.enabled);
assert_eq!(projection.spec.manifest_blob, None);
assert!(projection.spec.definition_blob.is_some());
}
#[tokio::test]
async fn post_runs_run_intent_canonicalizes_and_persists_a_local_folder_target() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
let hop = dir.path().join("hop");
std::fs::create_dir(&workspace).unwrap();
std::fs::create_dir(&hop).unwrap();
// Target-project files are not compiler inputs for a version-backed run.
std::fs::write(workspace.join("workflow.toml"), "not valid TOML").unwrap();
std::fs::write(workspace.join("goal.md"), "Goal from target folder").unwrap();
let submitted = hop.join("..").join("workspace");
let canonical = workspace
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
let state = local_test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(
&state,
MINIMAL_DOT,
Some("_version = 1\n[run.goal]\nfile = \"goal.md\"\n"),
)
.await;
let body = post_run_manifest(
&app,
folder_intent(workflow_version_id, submitted.to_string_lossy()),
)
.await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let events = run_store.list_events().await.unwrap();
assert_eq!(
events
.iter()
.map(|event| event.event.event_name())
.collect::<Vec<_>>(),
vec!["run.created", "run.submitted"]
);
let projection = run_store.state().await.unwrap();
assert_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::Folder {
path: canonical.clone(),
})
);
assert_eq!(
projection.spec.source_directory.as_deref(),
Some(canonical.as_str())
);
assert_eq!(projection.spec.git, None);
assert_eq!(
projection.spec.graph.goal(),
"Goal loaded from immutable version bytes"
);
assert_eq!(
projection.spec.settings.run.environment.provider,
EnvironmentProvider::Local
);
assert_eq!(projection.spec.manifest_blob, None);
assert!(projection.spec.definition_blob.is_some());
}
#[tokio::test]
async fn post_runs_run_intent_observes_folder_git_metadata_without_a_remote_call() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let mut index = repo.index().unwrap();
let tree_id = index.write_tree().unwrap();
drop(index);
let tree = repo.find_tree(tree_id).unwrap();
let signature = git2::Signature::now("Fabro Test", "fabro@example.com").unwrap();
let commit = repo
.commit(Some("HEAD"), &signature, &signature, "initial", &tree, &[])
.unwrap();
let commit = commit.to_string();
drop(tree);
repo.remote("origin", "https://github.com/acme/widgets.git")
.unwrap();
drop(repo);
let canonical = dir
.path()
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
let state = local_test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let body = post_run_manifest(&app, folder_intent(workflow_version_id, canonical)).await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let projection = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
let git = projection.spec.git.unwrap();
assert_eq!(git.origin_url, "https://github.com/acme/widgets");
assert!(!git.branch.is_empty());
assert_eq!(git.sha.as_deref(), Some(commit.as_str()));
assert_eq!(git.dirty, fabro_types::DirtyStatus::Clean);
}
#[tokio::test]
async fn post_runs_run_intent_rejects_invalid_folder_paths_before_persistence() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("file");
std::fs::write(&file, "not a directory").unwrap();
let state = local_test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let invalid_paths = [
String::new(),
"relative/path".to_string(),
dir.path().join("missing").to_string_lossy().to_string(),
file.to_string_lossy().to_string(),
];
for path in invalid_paths {
let response =
post_run_intent_response(&app, folder_intent(workflow_version_id, path)).await;
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
assert_eq!(body["errors"][0]["code"], "target_invalid");
}
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
assert!(
state
.stores
.run_summaries
.list_identities()
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn post_runs_run_intent_applies_the_folder_target_environment_matrix() {
let dir = tempfile::tempdir().unwrap();
// A missing path proves provider admission wins over filesystem
// materialization. Touching the path first would return `target_invalid`
// instead of the provider-specific errors asserted below.
let target = dir.path().join("missing").to_string_lossy().to_string();
for state in [
test_app_state(),
TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build(),
] {
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let response =
post_run_intent_response(&app, folder_intent(workflow_version_id, &target)).await;
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
assert_eq!(body["errors"][0]["code"], "target_environment_unsupported");
assert!(
state
.stores
.run_summaries
.list_identities()
.await
.unwrap()
.is_empty()
);
}
let disabled_state = TestAppStateBuilder::new()
.runtime_settings(
server_settings_from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.local]
enabled = false
"#,
),
RunLayer::default(),
)
.default_environment_provider(Some(EnvironmentProvider::Local))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let app = crate::test_support::build_test_router(Arc::clone(&disabled_state));
let workflow_version_id = store_workflow_version(&disabled_state, MINIMAL_DOT, None).await;
let response =
post_run_intent_response(&app, folder_intent(workflow_version_id, &target)).await;
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
assert!(
disabled_state
.stores
.run_summaries
.list_identities()
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment() {
let state = TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.vault_entries([
(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key"),
(
fabro_static::EnvVars::DAYTONA_API_KEY,
"test-daytona-api-key",
),
])
.build();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let body = post_run_manifest(
&app,
json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {}
}),
)
.await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let projection = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
assert_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::None {})
);
assert_eq!(
projection.spec.settings.run.environment.provider,
EnvironmentProvider::Daytona
);
assert_eq!(projection.spec.source_directory, None);
assert_eq!(projection.spec.git, None);
}
#[tokio::test]
async fn post_runs_run_intent_dispatches_errors_without_changing_legacy_lane() {
let state = test_app_state();
@ -3875,6 +4194,89 @@ async fn post_runs_run_intent_maps_missing_version_environment_and_target_errors
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
}
#[tokio::test]
async fn post_runs_run_intent_rejects_none_target_with_local_environment_before_persistence() {
let state = local_test_app_state();
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let app = crate::test_support::build_test_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
assert_eq!(body["errors"][0]["code"], "target_environment_unsupported");
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
assert!(
state
.stores
.run_summaries
.list_identities()
.await
.unwrap()
.is_empty()
);
}
/// Posts a Git and a `none` run intent against `state` and asserts both are
/// rejected as `integration_unavailable` without persisting anything.
async fn assert_run_intent_targets_unavailable(state: &Arc<AppState>) {
let version_id = store_workflow_version(state, MINIMAL_DOT, None).await;
let app = crate::test_support::build_test_router(Arc::clone(state));
for target in [
json!({
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "feature/run-intent"
}),
json!({ "kind": "none" }),
] {
let intent = json!({
"workflow_version_id": version_id,
"target": target,
"args": {}
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(intent.to_string()))
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
}
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
assert!(
state
.stores
.run_summaries
.list_identities()
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn post_runs_run_intent_rejects_disabled_or_unready_sandbox_integrations() {
let disabled_state = test_app_state_with_options(
@ -3892,73 +4294,13 @@ enabled = false
RunLayer::default(),
5,
);
let disabled_version_id = store_workflow_version(&disabled_state, MINIMAL_DOT, None).await;
let disabled_app = crate::test_support::build_test_router(Arc::clone(&disabled_state));
let intent = json!({
"workflow_version_id": disabled_version_id,
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "feature/run-intent"
},
"args": {}
});
let response = disabled_app
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(intent.to_string()))
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
assert!(
disabled_state
.runs
.lock()
.expect("runs lock poisoned")
.is_empty()
);
assert_run_intent_targets_unavailable(&disabled_state).await;
let daytona_state = TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let daytona_version_id = store_workflow_version(&daytona_state, MINIMAL_DOT, None).await;
let daytona_app = crate::test_support::build_test_router(Arc::clone(&daytona_state));
let intent = json!({
"workflow_version_id": daytona_version_id,
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "feature/run-intent"
},
"args": {}
});
let response = daytona_app
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(intent.to_string()))
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
assert!(
daytona_state
.runs
.lock()
.expect("runs lock poisoned")
.is_empty()
);
assert_run_intent_targets_unavailable(&daytona_state).await;
}
#[tokio::test]
@ -4080,6 +4422,7 @@ async fn create_run_from_manifest_helper_persists_without_automation_metadata()
},
headers: HeaderMap::new(),
automation: None,
target: None,
},
))
.await;
@ -4095,10 +4438,12 @@ async fn create_run_from_manifest_helper_persists_without_automation_metadata()
.unwrap()
.unwrap();
assert!(summary.automation.is_none());
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
assert!(run_store.state().await.unwrap().spec.target.is_none());
}
#[tokio::test]
async fn create_run_from_manifest_helper_persists_automation_metadata() {
async fn create_run_from_manifest_helper_persists_automation_metadata_and_exact_target() {
let state = TestAppStateBuilder::new()
.env_lookup(|_| None)
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
@ -4111,6 +4456,12 @@ async fn create_run_from_manifest_helper_persists_automation_metadata() {
name: Some("Nightly".to_string()),
trigger_id: Some("schedule".to_string()),
};
let target = RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: Some("v1.2.3".to_string()),
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
});
let response = Box::pin(handler::runs::create_run_from_manifest(
Arc::clone(&state),
@ -4124,6 +4475,7 @@ async fn create_run_from_manifest_helper_persists_automation_metadata() {
},
headers: HeaderMap::new(),
automation: Some(automation.clone()),
target: Some(target.clone()),
},
))
.await;
@ -4146,6 +4498,8 @@ async fn create_run_from_manifest_helper_persists_automation_metadata() {
.unwrap()
.unwrap();
assert_eq!(summary.automation, Some(automation));
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
assert_eq!(run_store.state().await.unwrap().spec.target, Some(target));
}
#[tokio::test]
@ -4223,6 +4577,7 @@ layer = "project"
},
headers,
automation: None,
target: None,
},
))
.await;
@ -4243,6 +4598,10 @@ layer = "project"
);
let run_state = run_store.state().await.unwrap();
let spec = &run_state.spec;
assert!(
spec.target.is_none(),
"legacy manifest GitContext must not become canonical target authority"
);
assert_eq!(spec.run_id, run_id);
assert_eq!(spec.graph.goal(), "Inline release goal");
assert_eq!(
@ -4381,6 +4740,7 @@ async fn create_run_from_manifest_pins_compiler_http_error_mappings() {
},
headers: HeaderMap::new(),
automation: None,
target: None,
},
))
.await;
@ -4435,6 +4795,7 @@ async fn create_run_from_manifest_preserves_competing_preparation_error_preceden
},
headers: HeaderMap::new(),
automation: None,
target: None,
},
))
.await;
@ -4480,6 +4841,7 @@ async fn create_run_from_manifest_resolves_generated_id_after_variable_snapshot(
},
headers: HeaderMap::new(),
automation: None,
target: None,
},
))
.await;
@ -4498,6 +4860,12 @@ async fn fake_automation_materializer_injection_captures_input_and_returns_manif
let fake = TestAutomationRunMaterializer::succeed(
materialized_manifest.clone(),
b"{\"fake\":true}".to_vec(),
GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
},
);
let state = TestAppStateBuilder::new()
.automation_materializer(fake.clone())
@ -4505,16 +4873,18 @@ async fn fake_automation_materializer_injection_captures_input_and_returns_manif
let run_id = RunId::new();
let user_settings_path = PathBuf::from("/tmp/fabro/settings.toml");
let temp_root = PathBuf::from("/tmp/fabro/automation");
let target = AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main".to_string(),
workflow: "demo".to_string(),
let target = GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
};
let output = state
.materialize_automation_run(AutomationRunMaterializeInput {
automation_id: AutomationId::new("nightly").unwrap(),
target: target.clone(),
workflow: "demo".to_string(),
run_id,
user_settings_path: user_settings_path.clone(),
temp_root: temp_root.clone(),
@ -4531,6 +4901,7 @@ async fn fake_automation_materializer_injection_captures_input_and_returns_manif
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].automation_id.as_str(), "nightly");
assert_eq!(captured[0].target, target);
assert_eq!(captured[0].workflow, "demo");
assert_eq!(captured[0].run_id, run_id);
assert_eq!(captured[0].user_settings_path, user_settings_path);
assert_eq!(captured[0].temp_root, temp_root);
@ -7859,6 +8230,117 @@ async fn test_model_invalid_mode_returns_400() {
assert_status!(response, StatusCode::BAD_REQUEST).await;
}
#[tokio::test]
async fn test_model_invalid_reasoning_effort_returns_400() {
let state = test_app_state_with_env_lookup(
default_test_server_settings(),
RunLayer::default(),
5,
|_| None,
);
let app = crate::test_support::build_test_router(state);
let req = Request::builder()
.method("POST")
.uri(api("/models/claude-opus-4-6/test?reasoning_effort=bogus"))
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::BAD_REQUEST).await;
}
#[tokio::test]
async fn test_model_forwards_and_validates_reasoning_effort() {
let upstream = MockServer::start();
let completion = upstream.mock(|when, then| {
when.method(POST)
.path("/chat/completions")
.json_body_includes(r#"{"model":"acme-reasoner","reasoning_effort":"low"}"#);
then.status(200)
.header("content-type", "application/json")
.json_body(json!({
"id": "chatcmpl-test",
"model": "acme-reasoner",
"choices": [{
"message": {"role": "assistant", "content": "OK"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2
}
}));
});
let settings: LlmCatalogSettings = toml::from_str(&format!(
r#"
[providers.acme]
display_name = "Acme"
adapter = "openai_compatible"
agent_profile = "openai"
base_url = "{}"
priority = 120
[providers.acme.auth]
credentials = ["vault:ACME_API_KEY"]
[providers.acme.models.acme-reasoner]
display_name = "Acme Reasoner"
family = "acme"
default = true
[providers.acme.models.acme-reasoner.limits]
context_window = 128000
[providers.acme.models.acme-reasoner.features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
[providers.acme.models.acme-reasoner.controls]
reasoning_effort = ["low", "high"]
"#,
upstream.base_url()
))
.expect("catalog fixture should parse");
let state = TestAppStateBuilder::new()
.llm_catalog_settings(settings)
.vault_entries([("ACME_API_KEY", "acme-test-key")])
.build();
let app = crate::test_support::build_test_router(state);
let req = Request::builder()
.method("POST")
.uri(api(
"/models/acme-reasoner/test?provider=acme&reasoning_effort=low",
))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["status"], "ok");
let unsupported = Request::builder()
.method("POST")
.uri(api(
"/models/acme-reasoner/test?provider=acme&reasoning_effort=medium",
))
.body(Body::empty())
.unwrap();
let response = app.oneshot(unsupported).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["status"], "error");
assert_eq!(
body["error_message"],
"Invalid request: model 'acme-reasoner' does not support reasoning_effort 'medium'; allowed values: low, high"
);
completion.assert_calls(1);
}
#[tokio::test]
async fn test_provider_credentials_uses_app_state_catalog() {
let upstream = MockServer::start();

View file

@ -8,6 +8,7 @@ use fabro_server::test_support::{
TestAppStateBuilder, TestAutomationRunMaterializer, build_test_router, test_auth_mode,
};
use fabro_static::EnvVars;
use fabro_types::GitRunTarget;
use serde_json::{Value, json};
use sqlx::Row as _;
use tower::ServiceExt;
@ -23,10 +24,11 @@ fn automation_body(id: &str, name: &str) -> Value {
"name": name,
"description": "Runs on a schedule.",
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
"workflow": "release"
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "main"
},
"workflow": "release",
"triggers": [
{
"type": "api",
@ -48,10 +50,11 @@ fn replacement_body(name: &str) -> Value {
"name": name,
"description": null,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
"workflow": "release"
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "main"
},
"workflow": "release",
"triggers": [
{
"type": "api",
@ -91,6 +94,12 @@ fn automation_app_with_fake_materializer() -> (axum::Router, tempfile::TempDir,
.automation_materializer(TestAutomationRunMaterializer::succeed(
materialized_manifest,
submitted_manifest_bytes,
GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
},
))
.build();
(build_test_router(state), temp_dir, sqlite_path)

View file

@ -8,7 +8,7 @@ use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
use fabro_server::server::{AppState, RouterOptions, build_router_with_options};
use fabro_server::test_support::test_app_state_with_store_and_runtime_settings;
use fabro_store::auth_session_store::{AuthSessionRecord, InitialRefreshToken};
use fabro_store::{ArtifactStore, AuthCode, Database};
use fabro_store::{ArtifactStore, PendingCliAuthorization};
use object_store::memory::InMemory;
use sha2::{Digest, Sha256};
use tower::ServiceExt;
@ -16,7 +16,7 @@ use uuid::Uuid;
use crate::helpers::{body_json, settings_from_toml};
fn test_app(source: &str) -> (axum::Router, Arc<Database>, Arc<AppState>) {
fn test_app(source: &str) -> (axum::Router, Arc<AppState>) {
let settings = settings_from_toml(source);
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let store = Arc::new(fabro_store::test_support::test_database(
@ -41,7 +41,7 @@ fn test_app(source: &str) -> (axum::Router, Arc<Database>, Arc<AppState>) {
artifact_store,
);
let app = build_router_with_options(Arc::clone(&state), &auth_mode, RouterOptions::default());
(app, store, state)
(app, state)
}
fn pkce_challenge(verifier: &str) -> String {
@ -54,7 +54,7 @@ fn hash_refresh_secret(secret: &str) -> [u8; 32] {
#[tokio::test]
async fn cli_auth_token_exchanges_code_over_public_router() {
let (app, store, _state) = test_app(
let (app, state) = test_app(
r#"
_version = 1
@ -71,10 +71,9 @@ url = "https://fabro.example"
client_id = "Iv1.test"
"#,
);
let auth_codes = store.auth_codes().await.unwrap();
auth_codes
.insert(AuthCode {
code: "integration-code".to_string(),
state
.test_auth_code_store()
.issue("integration-code", &PendingCliAuthorization {
identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
login: "octocat".to_string(),
name: "The Octocat".to_string(),
@ -122,7 +121,7 @@ client_id = "Iv1.test"
#[tokio::test]
async fn cli_auth_refresh_replay_revokes_chain_over_public_router() {
let (app, _store, state) = test_app(
let (app, state) = test_app(
r#"
_version = 1

View file

@ -29,6 +29,7 @@ async fn shell_reports_real_docker_process_outcome() {
None,
None,
None,
None,
) else {
return;
};

View file

@ -6,10 +6,15 @@ use std::path::{Path, PathBuf};
use chrono::Utc;
use fabro_db::{DbPool, ImportReport};
use fabro_types::{GitRunTarget, RunTarget, repository};
use serde::Deserialize;
use tokio::fs;
use tracing::info;
use crate::{Automation, AutomationId, AutomationStoreError, store};
use crate::{
Automation, AutomationId, AutomationReplace, AutomationRevision, AutomationStoreError,
AutomationTrigger, store,
};
pub(crate) const REMOVAL_DEADLINE: &str = "2026-10-11";
@ -26,7 +31,7 @@ pub async fn import_legacy_directory_once(
let bytes = fs::read(&path)
.await
.map_err(|source| AutomationStoreError::io(&path, source))?;
automations.push(Automation::from_persisted_path(id, &bytes, path)?);
automations.push(parse_legacy_automation(id, &bytes, &path)?);
}
let mut transaction = pool.begin().await?;
@ -61,6 +66,88 @@ pub async fn import_legacy_directory_once(
Ok(Some(report))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyPersistedAutomation {
name: String,
#[serde(default)]
description: Option<String>,
target: LegacyAutomationTarget,
#[serde(default)]
triggers: Vec<AutomationTrigger>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyAutomationTarget {
repository: String,
#[serde(rename = "ref")]
selector: String,
workflow: String,
}
fn parse_legacy_automation(
id: AutomationId,
bytes: &[u8],
path: &Path,
) -> Result<Automation, AutomationStoreError> {
let revision = AutomationRevision::from_bytes(bytes);
let content = std::str::from_utf8(bytes)
.map_err(|source| AutomationStoreError::invalid_utf8(path, source))?;
let legacy: LegacyPersistedAutomation =
toml::from_str(content).map_err(|source| AutomationStoreError::parse(path, source))?;
let LegacyAutomationTarget {
repository,
selector,
workflow,
} = legacy.target;
let target = legacy_target(repository, &selector, path)?;
Automation::from_stored(id.clone(), revision, AutomationReplace {
name: legacy.name,
description: legacy.description,
target,
workflow,
triggers: legacy.triggers,
})
.map_err(|source| AutomationStoreError::StoredValidation { id, source })
}
fn legacy_target(
repository: String,
selector: &str,
path: &Path,
) -> Result<RunTarget, AutomationStoreError> {
let (branch, tag, sha) = if let Some(sha) = repository::normalize_git_commit_sha(selector) {
("main".to_string(), None, Some(sha))
} else if let Some(tag) = selector
.strip_prefix("refs/tags/")
.or_else(|| selector.strip_prefix("tags/"))
{
("main".to_string(), Some(tag.to_string()), None)
} else if let Some(branch) = selector
.strip_prefix("refs/heads/")
.or_else(|| selector.strip_prefix("heads/"))
{
(branch.to_string(), None, None)
} else if selector == "HEAD" {
("main".to_string(), None, None)
} else {
(selector.to_string(), None, None)
};
RunTarget::Git(GitRunTarget {
repo: repository,
branch,
tag,
sha,
})
.validate()
.map(|validated| validated.target)
.map_err(|source| AutomationStoreError::LegacyTarget {
path: path.to_path_buf(),
source,
})
}
async fn legacy_automation_paths(
source_dir: &Path,
) -> Result<Option<Vec<(AutomationId, PathBuf)>>, AutomationStoreError> {

View file

@ -1,6 +1,7 @@
use std::path::PathBuf;
use croner::errors::CronError;
use fabro_types::TargetValidationError;
use toml::de::Error as TomlDeError;
use toml::ser::Error as TomlSerError;
@ -14,10 +15,13 @@ pub enum AutomationValidationError {
InvalidAutomationTriggerId { value: String },
#[error("automation name must not be empty")]
EmptyName,
#[error("repository slug {value:?} must be a GitHub owner/repo slug")]
InvalidRepositorySlug { value: String },
#[error("git ref selector {value:?} is not safe")]
InvalidGitRefSelector { value: String },
#[error("automation target kind {kind:?} is not supported; only Git targets are accepted")]
UnsupportedTarget { kind: String },
#[error("automation Git target is invalid")]
InvalidTarget {
#[source]
source: TargetValidationError,
},
#[error("workflow selector {value:?} is not safe")]
InvalidWorkflowSelector { value: String },
#[error("duplicate automation trigger id {id:?}")]
@ -114,6 +118,14 @@ pub enum AutomationStoreError {
#[source]
source: std::io::Error,
},
#[error(
"legacy automation target at {path:?} cannot be migrated; edit target.ref to a branch, supported heads/tags selector, HEAD, or 40-hex SHA and restart"
)]
LegacyTarget {
path: PathBuf,
#[source]
source: TargetValidationError,
},
}
impl AutomationStoreError {
@ -156,6 +168,7 @@ impl AutomationStoreError {
Self::Serialize { .. } => "serialize",
Self::Io { .. } => "io",
Self::LegacyBackup { .. } => "legacy_backup",
Self::LegacyTarget { .. } => "legacy_target",
}
}
}

View file

@ -9,7 +9,7 @@ pub use fabro_types::GitHubRepositorySlug;
pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, AutomationTriggerId};
pub use migrations::{ImportReport, import_legacy_directory_once};
pub use model::{
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTarget,
AutomationTrigger, ScheduleTrigger, parse_github_repository_slug, parse_schedule_expression,
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTrigger, ScheduleTrigger,
parse_schedule_expression,
};
pub use store::AutomationStore;

View file

@ -4,7 +4,7 @@ use std::sync::LazyLock;
use croner::Cron;
use croner::errors::CronError;
use croner::parser::{CronParser, Seconds, Year};
use fabro_types::{GitHubRepositorySlug, repository};
use fabro_types::{GitRunTarget, RunTarget};
use serde::{Deserialize, Serialize};
use crate::{
@ -38,7 +38,8 @@ pub struct Automation {
pub revision: AutomationRevision,
pub name: String,
pub description: Option<String>,
pub target: AutomationTarget,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
}
@ -49,17 +50,6 @@ impl Automation {
Self::from_persisted(id, revision, persisted).map_err(AutomationStoreError::from)
}
pub(crate) fn from_persisted_path(
id: AutomationId,
bytes: &[u8],
path: impl Into<std::path::PathBuf>,
) -> Result<Self, AutomationStoreError> {
let path = path.into();
let revision = AutomationRevision::from_bytes(bytes);
let persisted = parse_persisted(bytes, Some(path))?;
Self::from_persisted(id, revision, persisted).map_err(AutomationStoreError::from)
}
pub(crate) fn from_replace(
id: AutomationId,
draft: AutomationReplace,
@ -112,6 +102,15 @@ impl Automation {
self.enabled_api_trigger().is_some()
}
/// Returns the validated Git target owned by this automation.
#[must_use]
pub fn git_target(&self) -> Option<&GitRunTarget> {
match &self.target {
RunTarget::Git(target) => Some(target),
RunTarget::None {} | RunTarget::Folder { .. } => None,
}
}
fn from_persisted(
id: AutomationId,
revision: AutomationRevision,
@ -132,20 +131,12 @@ impl Automation {
name: replace.name,
description: replace.description,
target: replace.target,
workflow: replace.workflow,
triggers: replace.triggers,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomationTarget {
pub repository: String,
#[serde(rename = "ref")]
pub ref_selector: String,
pub workflow: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum AutomationTrigger {
@ -205,7 +196,8 @@ pub struct AutomationDraft {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub target: AutomationTarget,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
}
@ -215,6 +207,7 @@ impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
name: value.name,
description: value.description,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
})
}
@ -226,7 +219,8 @@ pub struct AutomationReplace {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub target: AutomationTarget,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
}
@ -236,7 +230,8 @@ pub(crate) struct PersistedAutomation {
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
target: AutomationTarget,
target: RunTarget,
workflow: String,
#[serde(default)]
triggers: Vec<AutomationTrigger>,
}
@ -247,6 +242,7 @@ impl From<AutomationReplace> for PersistedAutomation {
name: value.name,
description: value.description,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
}
}
@ -258,6 +254,7 @@ impl From<PersistedAutomation> for AutomationReplace {
name: value.name,
description: value.description,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
}
}
@ -288,15 +285,14 @@ fn validate_fields(value: &AutomationReplace) -> Result<(), AutomationValidation
if value.name.trim().is_empty() {
return Err(AutomationValidationError::EmptyName);
}
validate_repository_slug(&value.target.repository)?;
validate_git_ref_selector(&value.target.ref_selector)?;
validate_workflow_selector(&value.target.workflow)?;
validate_workflow_selector(&value.workflow)?;
validate_triggers(&value.triggers)
}
fn normalize_replace(
mut value: AutomationReplace,
) -> Result<AutomationReplace, AutomationValidationError> {
value.target = validate_target(value.target)?;
validate_fields(&value)?;
let api_enabled = value
@ -334,28 +330,16 @@ fn normalize_replace(
Ok(value)
}
pub fn parse_github_repository_slug(
value: &str,
) -> Result<GitHubRepositorySlug, AutomationValidationError> {
GitHubRepositorySlug::try_new(value).ok_or_else(|| {
AutomationValidationError::InvalidRepositorySlug {
value: value.to_string(),
}
})
}
fn validate_repository_slug(value: &str) -> Result<(), AutomationValidationError> {
parse_github_repository_slug(value).map(|_| ())
}
fn validate_git_ref_selector(value: &str) -> Result<(), AutomationValidationError> {
if repository::is_valid_github_ref_selector(value) {
Ok(())
} else {
Err(AutomationValidationError::InvalidGitRefSelector {
value: value.to_string(),
})
fn validate_target(target: RunTarget) -> Result<RunTarget, AutomationValidationError> {
if !matches!(&target, RunTarget::Git(_)) {
return Err(AutomationValidationError::UnsupportedTarget {
kind: target.kind_name().to_string(),
});
}
target
.validate()
.map(|validated| validated.target)
.map_err(|source| AutomationValidationError::InvalidTarget { source })
}
fn validate_workflow_selector(value: &str) -> Result<(), AutomationValidationError> {
@ -419,17 +403,20 @@ fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationVal
#[cfg(test)]
mod tests {
use fabro_types::{GitRunTarget, RunTarget, TargetValidationError};
use crate::{
ApiTrigger, Automation, AutomationId, AutomationReplace, AutomationTarget,
AutomationTrigger, AutomationTriggerId, AutomationValidationError, ScheduleTrigger,
ApiTrigger, Automation, AutomationId, AutomationReplace, AutomationTrigger,
AutomationTriggerId, AutomationValidationError, ScheduleTrigger,
};
fn target() -> AutomationTarget {
AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main".to_string(),
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
}
fn target() -> RunTarget {
RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
})
}
fn api_trigger(id: &str) -> AutomationTrigger {
@ -455,11 +442,12 @@ mod tests {
fn persisted_toml_applies_defaults_and_canonicalizes_without_id_or_revision() {
let bytes = br#"
name = "Nightly"
workflow = "release"
[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "release"
kind = "git"
repo = "fabro-sh/fabro"
branch = "main"
[[triggers]]
type = "api"
@ -492,6 +480,7 @@ expression = "0 0 * * *"
let bytes = br#"
name = "Legacy"
enabled = false
workflow = "release"
[target]
repository = "fabro-sh/fabro"
@ -516,6 +505,7 @@ enabled = true
name: "Nightly".to_string(),
description: None,
target: target(),
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
triggers: vec![
api_trigger("manual"),
schedule_trigger_with_enabled("nightly", "0 0 * * *", true),
@ -533,41 +523,29 @@ enabled = true
}
#[test]
fn repository_slug_parser_returns_the_shared_type() {
let slug: fabro_types::GitHubRepositorySlug =
crate::parse_github_repository_slug("owner/.github").unwrap();
fn invalid_git_target_preserves_the_shared_validation_error() {
let error = super::validate_target(RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main;rm".to_string(),
tag: None,
sha: None,
}))
.unwrap_err();
assert_eq!(slug.owner(), "owner");
assert_eq!(slug.repo(), ".github");
assert!(matches!(&error, AutomationValidationError::InvalidTarget {
source: TargetValidationError::Branch,
}));
assert_eq!(error.to_string(), "automation Git target is invalid");
}
#[test]
fn invalid_repository_slug_preserves_the_automation_error() {
let error = crate::parse_github_repository_slug("not/github/slug").unwrap_err();
fn non_git_targets_are_rejected_with_their_kind() {
let error = super::validate_target(RunTarget::None {}).unwrap_err();
assert!(matches!(
&error,
AutomationValidationError::InvalidRepositorySlug { value }
if value == "not/github/slug"
error,
AutomationValidationError::UnsupportedTarget { kind } if kind == "none"
));
assert_eq!(
error.to_string(),
"repository slug \"not/github/slug\" must be a GitHub owner/repo slug"
);
}
#[test]
fn invalid_git_ref_selector_preserves_the_automation_error() {
let error = super::validate_git_ref_selector("main;rm").unwrap_err();
assert!(matches!(
&error,
AutomationValidationError::InvalidGitRefSelector { value } if value == "main;rm"
));
assert_eq!(
error.to_string(),
"git ref selector \"main;rm\" is not safe"
);
}
#[test]
@ -577,42 +555,45 @@ enabled = true
name: " ".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad repo".to_string(),
description: None,
target: AutomationTarget {
repository: "not/github/slug".to_string(),
ref_selector: "main".to_string(),
workflow: "release".to_string(),
},
target: RunTarget::Git(GitRunTarget {
repo: "not/github/slug".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
}),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad ref".to_string(),
description: None,
target: AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main;rm".to_string(),
workflow: "release".to_string(),
},
target: RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main;rm".to_string(),
tag: None,
sha: None,
}),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad workflow".to_string(),
description: None,
target: AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main".to_string(),
workflow: "../release".to_string(),
},
target: target(),
workflow: "../release".to_string(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Duplicate trigger".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![
api_trigger("manual"),
schedule_trigger("manual", "0 0 * * *"),
@ -622,18 +603,21 @@ enabled = true
name: "Two API triggers".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![api_trigger("one"), api_trigger("two")],
},
AutomationReplace {
name: "Six field cron".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
},
AutomationReplace {
name: "Bad cron".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
},
];

View file

@ -1,13 +1,13 @@
use std::str::FromStr as _;
use fabro_db::DbPool;
use fabro_types::{GitRunTarget, RunTarget};
use sqlx::sqlite::SqliteRow;
use sqlx::{Row as _, Sqlite, Transaction};
use crate::{
ApiTrigger, Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
AutomationStoreError, AutomationTarget, AutomationTrigger, AutomationTriggerId,
ScheduleTrigger,
AutomationStoreError, AutomationTrigger, AutomationTriggerId, ScheduleTrigger,
};
/// Shared projection for loading automations with their schedule triggers.
@ -22,7 +22,9 @@ macro_rules! select_automations_sql {
a.description,
a.api_enabled,
a.target_repository,
a.target_ref,
a.target_branch,
a.target_tag,
a.target_sha,
a.target_workflow,
t.id AS trigger_id,
t.enabled AS trigger_enabled,
@ -93,6 +95,7 @@ impl AutomationStore {
draft: AutomationReplace,
) -> Result<Automation, AutomationStoreError> {
let (automation, _) = Automation::from_replace(id.clone(), draft)?;
let target = stored_git_target(&automation);
let mut transaction = self.pool.begin().await?;
let result = sqlx::query(
r"
@ -102,7 +105,9 @@ impl AutomationStore {
description = ?,
api_enabled = ?,
target_repository = ?,
target_ref = ?,
target_branch = ?,
target_tag = ?,
target_sha = ?,
target_workflow = ?
WHERE id = ? AND revision = ?
",
@ -111,9 +116,11 @@ impl AutomationStore {
.bind(&automation.name)
.bind(automation.description.as_deref())
.bind(automation.api_enabled())
.bind(&automation.target.repository)
.bind(&automation.target.ref_selector)
.bind(&automation.target.workflow)
.bind(&target.repo)
.bind(&target.branch)
.bind(target.tag.as_deref())
.bind(target.sha.as_deref())
.bind(&automation.workflow)
.bind(id.as_str())
.bind(expected.as_str())
.execute(&mut *transaction)
@ -156,7 +163,8 @@ struct StoredAutomation {
name: String,
description: Option<String>,
api_enabled: bool,
target: AutomationTarget,
target: RunTarget,
workflow: String,
schedule_triggers: Vec<ScheduleTrigger>,
}
@ -180,11 +188,13 @@ impl StoredAutomation {
name: row.try_get("name")?,
description: row.try_get("description")?,
api_enabled: row.try_get("api_enabled")?,
target: AutomationTarget {
repository: row.try_get("target_repository")?,
ref_selector: row.try_get("target_ref")?,
workflow: row.try_get("target_workflow")?,
},
target: RunTarget::Git(GitRunTarget {
repo: row.try_get("target_repository")?,
branch: row.try_get("target_branch")?,
tag: row.try_get("target_tag")?,
sha: row.try_get("target_sha")?,
}),
workflow: row.try_get("target_workflow")?,
schedule_triggers: Vec::new(),
})
}
@ -230,6 +240,7 @@ impl StoredAutomation {
name: self.name,
description: self.description,
target: self.target,
workflow: self.workflow,
triggers,
})
.map_err(|source| AutomationStoreError::StoredValidation { id, source })
@ -272,6 +283,7 @@ pub(crate) async fn insert_automation_ignoring_conflict(
transaction: &mut Transaction<'_, Sqlite>,
automation: &Automation,
) -> Result<bool, AutomationStoreError> {
let target = stored_git_target(automation);
let result = sqlx::query(
r"
INSERT INTO automations (
@ -281,9 +293,11 @@ pub(crate) async fn insert_automation_ignoring_conflict(
description,
api_enabled,
target_repository,
target_ref,
target_branch,
target_tag,
target_sha,
target_workflow
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
",
)
@ -292,9 +306,11 @@ pub(crate) async fn insert_automation_ignoring_conflict(
.bind(&automation.name)
.bind(automation.description.as_deref())
.bind(automation.api_enabled())
.bind(&automation.target.repository)
.bind(&automation.target.ref_selector)
.bind(&automation.target.workflow)
.bind(&target.repo)
.bind(&target.branch)
.bind(target.tag.as_deref())
.bind(target.sha.as_deref())
.bind(&automation.workflow)
.execute(&mut **transaction)
.await?;
if result.rows_affected() == 0 {
@ -304,6 +320,12 @@ pub(crate) async fn insert_automation_ignoring_conflict(
Ok(true)
}
fn stored_git_target(automation: &Automation) -> &GitRunTarget {
automation
.git_target()
.expect("stored automations have already passed Git-only validation")
}
async fn insert_schedule_triggers(
transaction: &mut Transaction<'_, Sqlite>,
automation: &Automation,

View file

@ -6,11 +6,11 @@
use std::path::Path;
use fabro_automation::{
ApiTrigger, AutomationDraft, AutomationId, AutomationReplace, AutomationStore,
AutomationStoreError, AutomationTarget, AutomationTrigger, AutomationTriggerId,
ScheduleTrigger,
ApiTrigger, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
AutomationStore, AutomationStoreError, AutomationTrigger, AutomationTriggerId, ScheduleTrigger,
};
use fabro_db::Database;
use fabro_types::{GitRunTarget, RunTarget};
use tokio::fs;
async fn test_database() -> (tempfile::TempDir, Database) {
@ -22,12 +22,13 @@ async fn test_database() -> (tempfile::TempDir, Database) {
(dir, database)
}
fn target() -> AutomationTarget {
AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main".to_string(),
workflow: "release".to_string(),
}
fn target() -> RunTarget {
RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
})
}
fn schedule(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
@ -44,6 +45,7 @@ fn draft(id: &str, api_enabled: bool) -> AutomationDraft {
name: "Nightly".to_string(),
description: Some("Runs every night".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![
schedule("z-last", "0 2 * * *", false),
AutomationTrigger::Api(ApiTrigger {
@ -60,6 +62,7 @@ fn replacement(name: &str, expression: &str) -> AutomationReplace {
name: name.to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![
schedule("nightly", expression, true),
AutomationTrigger::Api(ApiTrigger {
@ -218,6 +221,7 @@ async fn failed_schedule_insert_rolls_back_parent_replace() {
name: "Should roll back".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule("blocked", "0 7 * * *", true)],
};
@ -254,7 +258,8 @@ async fn legacy_import_is_transactional_and_sql_wins() {
let source_dir = dir.path().join("automations");
fs::create_dir_all(&source_dir).await.unwrap();
write_legacy_automation(&source_dir, "existing", "Legacy existing").await;
write_legacy_automation(&source_dir, "imported", "Imported").await;
let imported_bytes = write_legacy_automation(&source_dir, "imported", "Imported").await;
let expected_revision = AutomationRevision::from_bytes(&imported_bytes);
fs::write(source_dir.join("notes.txt"), "ignored")
.await
.unwrap();
@ -278,15 +283,23 @@ async fn legacy_import_is_transactional_and_sql_wins() {
.name,
"Nightly"
);
assert_eq!(
store
.get(&AutomationId::new("imported").unwrap())
.await
.unwrap()
.unwrap()
.name,
"Imported"
);
let imported = store
.get(&AutomationId::new("imported").unwrap())
.await
.unwrap()
.unwrap();
assert_eq!(imported.name, "Imported");
assert_eq!(imported.revision, expected_revision);
assert_eq!(imported.workflow, "release");
assert!(matches!(
imported.target,
RunTarget::Git(GitRunTarget {
branch,
tag: None,
sha: None,
..
}) if branch == "main"
));
fs::create_dir_all(&source_dir).await.unwrap();
write_legacy_automation(&source_dir, "existing", "Legacy existing").await;
@ -340,15 +353,47 @@ async fn invalid_legacy_file_leaves_directory_and_database_unchanged() {
);
}
async fn write_legacy_automation(dir: &Path, id: &str, name: &str) {
fs::write(
dir.join(format!("{id}.toml")),
format!(
r#"name = "{name}"
#[tokio::test]
async fn unsupported_legacy_target_leaves_directory_and_database_unchanged() {
let (dir, database) = test_database().await;
let source_dir = dir.path().join("automations");
fs::create_dir_all(&source_dir).await.unwrap();
let bytes = legacy_automation_bytes("Unsupported", "refs/pull/123/head");
fs::write(source_dir.join("unsupported.toml"), bytes)
.await
.unwrap();
let err = fabro_automation::import_legacy_directory_once(database.pool(), &source_dir)
.await
.unwrap_err();
assert!(matches!(err, AutomationStoreError::LegacyTarget { .. }));
assert!(err.to_string().contains("edit target.ref"));
assert!(source_dir.exists());
assert!(
AutomationStore::new(database.clone_pool())
.list()
.await
.unwrap()
.is_empty()
);
}
async fn write_legacy_automation(dir: &Path, id: &str, name: &str) -> Vec<u8> {
let bytes = legacy_automation_bytes(name, "main");
fs::write(dir.join(format!("{id}.toml")), &bytes)
.await
.unwrap();
bytes
}
fn legacy_automation_bytes(name: &str, ref_selector: &str) -> Vec<u8> {
format!(
r#"name = "{name}"
[target]
repository = "fabro-sh/fabro"
ref = "main"
ref = "{ref_selector}"
workflow = "release"
[[triggers]]
@ -362,8 +407,6 @@ type = "schedule"
enabled = true
expression = "0 3 * * *"
"#
),
)
.await
.unwrap();
.into_bytes()
}

View file

@ -56,7 +56,7 @@ impl StreamState {
}
/// Process a parsed SSE chunk and return events to emit, if any.
fn process_chunk(&mut self, mut chunk: StreamChunk) -> Option<Vec<StreamEvent>> {
fn process_chunk(&mut self, mut chunk: StreamChunk) -> Result<Option<Vec<StreamEvent>>, Error> {
// Capture response metadata from the first chunk.
if let Some(id) = &chunk.id {
if self.response_id.is_empty() {
@ -80,8 +80,12 @@ impl StreamState {
.or_else(|| chunk.cost.as_ref().and_then(|cost| cost.usd));
self.cost_usd = cost_usd.or(self.cost_usd);
let choices = chunk.choices.as_mut()?;
let choice = choices.first_mut()?;
let Some(choices) = chunk.choices.as_mut() else {
return Ok(None);
};
let Some(choice) = choices.first_mut() else {
return Ok(None);
};
let mut events = Vec::new();
@ -90,7 +94,9 @@ impl StreamState {
self.finish_reason = map_finish_reason(Some(reason.as_str()));
}
let delta = choice.delta.as_mut()?;
let Some(delta) = choice.delta.as_mut() else {
return Ok(None);
};
// Accumulate reasoning/thinking content (Kimi, etc.).
if let Some(reasoning) = delta.reasoning() {
@ -121,8 +127,22 @@ impl StreamState {
for tc in tool_calls {
let index = tc.index;
// Grow the accumulated tool calls vector if needed.
while self.tool_calls.len() <= index {
// A delta may only continue an already-started tool call or
// open the next slot. Padding a skipped slot would materialize
// a phantom tool call with an empty id and name, which poisons
// the conversation once echoed back to the provider.
if index > self.tool_calls.len() {
return Err(Error::Stream {
message: format!(
"malformed tool call stream from {}: delta for tool_calls[{index}] \
arrived before tool_calls[{}] was started",
self.provider_name,
self.tool_calls.len()
),
source: None,
});
}
if index == self.tool_calls.len() {
self.tool_calls.push(AccumulatedToolCall {
id: String::new(),
name: String::new(),
@ -163,9 +183,9 @@ impl StreamState {
}
if events.is_empty() {
None
Ok(None)
} else {
Some(events)
Ok(Some(events))
}
}
@ -262,7 +282,7 @@ impl StreamDecoder for StreamState {
let chunk: StreamChunk = serde_json::from_str(ev.data)
.map_err(|e| Error::stream_error(format!("failed to parse SSE chunk: {e}"), e))?;
Ok(self.process_chunk(chunk).unwrap_or_default())
Ok(self.process_chunk(chunk)?.unwrap_or_default())
}
fn finish(&mut self) -> Vec<StreamEvent> {
@ -373,7 +393,7 @@ mod tests {
let chunk1: StreamChunk = serde_json::from_str(
r#"{"id":"c1","model":"m1","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}"#,
).unwrap();
let events1 = state.process_chunk(chunk1).unwrap();
let events1 = state.process_chunk(chunk1).unwrap().unwrap();
assert_eq!(events1.len(), 2);
assert!(matches!(events1[0], StreamEvent::TextStart { .. }));
assert!(matches!(events1[1], StreamEvent::TextDelta { .. }));
@ -381,7 +401,7 @@ mod tests {
let chunk2: StreamChunk = serde_json::from_str(
r#"{"id":"c1","model":"m1","choices":[{"delta":{"content":" world"},"finish_reason":null}]}"#,
).unwrap();
let events2 = state.process_chunk(chunk2).unwrap();
let events2 = state.process_chunk(chunk2).unwrap().unwrap();
assert_eq!(events2.len(), 1);
assert!(matches!(events2[0], StreamEvent::TextDelta { .. }));
@ -395,14 +415,14 @@ mod tests {
let chunk1: StreamChunk = serde_json::from_str(
r#"{"id":"c1","model":"m1","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"fn1","arguments":"{\"k"}}]},"finish_reason":null}]}"#,
).unwrap();
let events1 = state.process_chunk(chunk1).unwrap();
let events1 = state.process_chunk(chunk1).unwrap().unwrap();
assert_eq!(events1.len(), 1);
assert!(matches!(events1[0], StreamEvent::ToolCallStart { .. }));
let chunk2: StreamChunk = serde_json::from_str(
r#"{"id":"c1","model":"m1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ey\"}"}}]},"finish_reason":null}]}"#,
).unwrap();
let events2 = state.process_chunk(chunk2).unwrap();
let events2 = state.process_chunk(chunk2).unwrap().unwrap();
assert_eq!(events2.len(), 1);
assert!(matches!(events2[0], StreamEvent::ToolCallDelta { .. }));
@ -479,6 +499,37 @@ mod tests {
}
}
// Reproduces run 01M11JZVT7V507R56BCJJHZB1B: venice (proxying Anthropic)
// numbered tool_calls[].index by content block, so the first tool call
// arrived with index 1 when text preceded it. Padding the skipped slot
// used to materialize a phantom tool call with an empty id and name that
// the provider rejected once echoed back (tool_use.id must match
// '^[a-zA-Z0-9_-]+$'). A gap in the index sequence is indistinguishable
// from lost chunks, so the stream must fail instead.
#[test]
fn sparse_tool_call_index_is_a_stream_error() {
let mut state = test_state("venice", "claude-opus-5");
let text_chunk: StreamChunk = serde_json::from_str(
r#"{"id":"c1","model":"claude-opus-5","choices":[{"delta":{"content":"I'll start by reading the state file."},"finish_reason":null}]}"#,
)
.unwrap();
state.process_chunk(text_chunk).unwrap();
let tool_chunk: StreamChunk = serde_json::from_str(
r#"{"id":"c1","model":"claude-opus-5","choices":[{"delta":{"tool_calls":[{"index":1,"id":"toolu_01EgMidFVtGhitWE22jXQ9Eo","function":{"name":"Read","arguments":"{\"file_path\":\"state.json\"}"}}]},"finish_reason":null}]}"#,
)
.unwrap();
let err = state.process_chunk(tool_chunk).unwrap_err();
assert!(err.retryable(), "malformed stream should be retryable");
let message = err.to_string();
assert!(
message.contains("tool_calls[1]") && message.contains("venice"),
"unexpected error message: {message}"
);
}
#[test]
fn uses_request_model_as_fallback() {
let mut state = test_state("test", "fallback-model");

View file

@ -46,16 +46,32 @@ impl ModelTestOutcome {
pub async fn run_model_test(
info: &Model,
mode: ModelTestMode,
reasoning_effort: Option<ReasoningEffort>,
client: Arc<Client>,
) -> ModelTestOutcome {
match mode {
ModelTestMode::Basic => run_basic_test(info, client).await,
ModelTestMode::Deep => run_deep_test(info, client).await,
ModelTestMode::Basic => run_basic_test(info, reasoning_effort, client).await,
ModelTestMode::Deep => run_tools_test(info, reasoning_effort, client).await,
}
}
async fn run_basic_test(info: &Model, client: Arc<Client>) -> ModelTestOutcome {
run_basic_model_probe(info.id.as_str(), &info.provider, client).await
/// Output budget for tests where reasoning or tool rounds consume completion
/// tokens before the final answer.
const EXPANDED_MAX_TOKENS: i64 = 1024;
async fn run_basic_test(
info: &Model,
reasoning_effort: Option<ReasoningEffort>,
client: Arc<Client>,
) -> ModelTestOutcome {
basic_probe(
info.id.as_str(),
info.provider.to_string(),
reasoning_effort,
client,
Duration::from_secs(ModelTestMode::Basic.timeout_secs()),
)
.await
}
/// Run the cheap single-prompt model availability probe without requiring a
@ -80,14 +96,43 @@ pub async fn run_basic_model_probe_with_timeout(
client: Arc<Client>,
probe_timeout: Duration,
) -> ModelTestOutcome {
let params = GenerateParams::new(model_id, client)
.provider(provider.to_string())
.prompt("Say OK")
.max_tokens(16);
basic_probe(model_id, provider.to_string(), None, client, probe_timeout).await
}
async fn basic_probe(
model_id: &str,
provider: String,
reasoning_effort: Option<ReasoningEffort>,
client: Arc<Client>,
probe_timeout: Duration,
) -> ModelTestOutcome {
let params = build_basic_test_params(model_id, provider, reasoning_effort, client);
basic_model_probe_outcome(generate::generate(params), probe_timeout).await
}
fn build_basic_test_params(
model_id: &str,
provider: String,
reasoning_effort: Option<ReasoningEffort>,
client: Arc<Client>,
) -> GenerateParams {
let max_tokens = if reasoning_effort.is_some() {
EXPANDED_MAX_TOKENS
} else {
16
};
let mut params = GenerateParams::new(model_id, client)
.provider(provider)
.prompt("Say OK")
.max_tokens(max_tokens);
if let Some(reasoning_effort) = reasoning_effort {
params = params.reasoning_effort(reasoning_effort);
}
params
}
async fn basic_model_probe_outcome<F>(probe: F, probe_timeout: Duration) -> ModelTestOutcome
where
F: Future<Output = Result<GenerateResult, crate::Error>>,
@ -99,8 +144,12 @@ where
}
}
async fn run_deep_test(info: &Model, client: Arc<Client>) -> ModelTestOutcome {
let Some(params) = build_deep_test_params(info, client) else {
async fn run_tools_test(
info: &Model,
reasoning_effort: Option<ReasoningEffort>,
client: Arc<Client>,
) -> ModelTestOutcome {
let Some(params) = build_tools_test_params(info, reasoning_effort, client) else {
return ModelTestOutcome::error("model does not support tools");
};
@ -111,7 +160,7 @@ async fn run_deep_test(info: &Model, client: Arc<Client>) -> ModelTestOutcome {
.await;
match result {
Ok(Ok(gen_result)) => match validate_deep_result(&gen_result) {
Ok(Ok(gen_result)) => match validate_tools_result(&gen_result) {
Ok(()) => ModelTestOutcome::ok(),
Err(message) => ModelTestOutcome::error(message),
},
@ -120,7 +169,11 @@ async fn run_deep_test(info: &Model, client: Arc<Client>) -> ModelTestOutcome {
}
}
fn build_deep_test_params(info: &Model, client: Arc<Client>) -> Option<GenerateParams> {
fn build_tools_test_params(
info: &Model,
reasoning_effort: Option<ReasoningEffort>,
client: Arc<Client>,
) -> Option<GenerateParams> {
if !info.features.tools {
return None;
}
@ -157,16 +210,16 @@ fn build_deep_test_params(info: &Model, client: Arc<Client>) -> Option<GenerateP
)
.tools(vec![add_tool])
.max_tool_rounds(5)
.max_tokens(1024);
.max_tokens(EXPANDED_MAX_TOKENS);
if info.supports_reasoning_effort() {
params = params.reasoning_effort(ReasoningEffort::High);
if let Some(reasoning_effort) = reasoning_effort {
params = params.reasoning_effort(reasoning_effort);
}
Some(params)
}
fn validate_deep_result(result: &GenerateResult) -> Result<(), String> {
fn validate_tools_result(result: &GenerateResult) -> Result<(), String> {
if result.steps.len() < 2 {
return Err("model did not call tool".to_string());
}
@ -241,7 +294,7 @@ mod tests {
}
#[tokio::test]
async fn run_model_test_deep_errors_when_model_lacks_tools() {
async fn run_model_test_tools_errors_when_model_lacks_tools() {
let info = test_model_with(ModelFeatures {
tools: false,
vision: false,
@ -252,7 +305,7 @@ mod tests {
sampling_params: true,
});
let outcome = run_model_test(&info, ModelTestMode::Deep, empty_test_client()).await;
let outcome = run_model_test(&info, ModelTestMode::Deep, None, empty_test_client()).await;
assert_eq!(outcome.status, ModelTestStatus::Error);
assert_eq!(
@ -274,25 +327,20 @@ mod tests {
}
#[test]
fn deep_test_omits_effort_for_reasoning_without_effort_controls() {
let info = test_model_with(ModelFeatures {
tools: true,
vision: false,
reasoning: true,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: true,
cache_control_breakpoints: false,
sampling_params: true,
});
fn basic_test_expands_output_budget_for_reasoning() {
let params = build_basic_test_params(
"test-model",
"anthropic".to_string(),
Some(ReasoningEffort::Max),
empty_test_client(),
);
let params = build_deep_test_params(&info, empty_test_client())
.expect("tool-capable model should produce deep-test params");
assert_eq!(params.reasoning_effort, None);
assert_eq!(params.reasoning_effort, Some(ReasoningEffort::Max));
assert_eq!(params.max_tokens, Some(1024));
}
#[test]
fn deep_test_uses_high_effort_when_supported() {
fn tools_test_omits_effort_when_not_requested() {
let info = test_model_with(ModelFeatures {
tools: true,
vision: false,
@ -303,14 +351,33 @@ mod tests {
sampling_params: true,
});
let params = build_deep_test_params(&info, empty_test_client())
.expect("tool-capable model should produce deep-test params");
let params = build_tools_test_params(&info, None, empty_test_client())
.expect("tool-capable model should produce tools-test params");
assert_eq!(params.reasoning_effort, Some(ReasoningEffort::High));
assert_eq!(params.reasoning_effort, None);
}
#[test]
fn validate_deep_result_does_not_fail_only_for_missing_reasoning() {
fn tools_test_uses_requested_effort() {
let info = test_model_with(ModelFeatures {
tools: true,
vision: false,
reasoning: true,
reasoning_effort: ReasoningEffortFeature::Levels,
prompt_cache: true,
cache_control_breakpoints: false,
sampling_params: true,
});
let params =
build_tools_test_params(&info, Some(ReasoningEffort::Low), empty_test_client())
.expect("tool-capable model should produce tools-test params");
assert_eq!(params.reasoning_effort, Some(ReasoningEffort::Low));
}
#[test]
fn validate_tools_result_does_not_fail_only_for_missing_reasoning() {
let tool_results = vec![ToolResult::success("call_1", serde_json::json!(42))];
let first_step = StepResult {
response: response_with_text("tool step"),
@ -328,6 +395,6 @@ mod tests {
output: None,
};
assert_eq!(validate_deep_result(&result), Ok(()));
assert_eq!(validate_tools_result(&result), Ok(()));
}
}

View file

@ -73,7 +73,7 @@ async fn assert_deep_tool_round_trip(
.get_on_provider(provider, model_id)
.unwrap_or_else(|| panic!("{provider} {model_id} should be present"));
let outcome = run_model_test(model, ModelTestMode::Deep, client).await;
let outcome = run_model_test(model, ModelTestMode::Deep, None, client).await;
assert_eq!(
outcome.status,
ModelTestStatus::Ok,

View file

@ -8,9 +8,9 @@ use fabro_config::project::WorkflowLocation;
use fabro_config::{EnvironmentDockerfileLayer, EnvironmentImageLayer, SettingsLayer};
use fabro_graphviz::parser;
use fabro_template::{
BundleTemplateStore, FilesystemTemplateStore, GraphReference, GraphReferenceError,
RecordingTemplateStore, TemplateContext, TemplateDependencyClosure, TemplateRenderMode,
TemplateSource, validate_static_reference, visit_graph_references,
BundleTemplateStore, FilesystemTemplateStore, GraphPosition, GraphReference,
GraphReferenceError, RecordingTemplateStore, TemplateContext, TemplateDependencyClosure,
TemplateRenderMode, TemplateSource, validate_static_reference, visit_graph_references,
};
use fabro_types::ManifestPath;
use fabro_types::graph::ReferenceKind;
@ -87,7 +87,12 @@ impl<'a> WorkflowBundler<'a> {
.ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?;
self.collect_config_dockerfile(&config_path, &config.source, &mut files)?;
}
self.collect_workflow_files(&scan, &mut files, &mut visited_imports)?;
self.collect_workflow_files(
&scan,
&mut files,
&mut visited_imports,
GraphPosition::Entrypoint,
)?;
self.workflows
.insert(dot_key.clone(), types::ManifestWorkflow {
@ -123,6 +128,7 @@ impl<'a> WorkflowBundler<'a> {
workflow: &WorkflowScanInput,
files: &mut HashMap<String, types::ManifestFileEntry>,
visited_imports: &mut HashSet<String>,
position: GraphPosition,
) -> Result<()> {
let graph = parser::parse(&workflow.source)
.with_context(|| format!("Failed to parse {}", workflow.absolute_dot_path.display()))?;
@ -137,7 +143,7 @@ impl<'a> WorkflowBundler<'a> {
let mut imports = Vec::new();
let mut children = Vec::new();
visit_graph_references(&graph, |reference| -> Result<()> {
visit_graph_references(&graph, position, |reference| -> Result<()> {
match reference {
GraphReference::GoalFile { reference } => {
let bundled = self.collect_bundled_file(
@ -151,15 +157,17 @@ impl<'a> WorkflowBundler<'a> {
self.collect_bundled_template_includes(files, &bundled, &workflow_template_root)
}
GraphReference::GoalInline { content }
| GraphReference::InlinePrompt { content } => self.collect_template_include_files(
files,
TemplateSource::new(
workflow.dot_path.clone(),
workflow_template_root.clone(),
content.to_owned(),
| GraphReference::InlinePrompt { content }
| GraphReference::ModelStylesheetInline { content } => self
.collect_template_include_files(
files,
TemplateSource::new(
workflow.dot_path.clone(),
workflow_template_root.clone(),
content.to_owned(),
),
Some(&workflow.dot_path),
),
Some(&workflow.dot_path),
),
GraphReference::FileInline { key, reference } => {
let bundled = self.collect_bundled_file(
files,
@ -212,7 +220,12 @@ impl<'a> WorkflowBundler<'a> {
dot_path: imported.path,
source: imported_source,
};
self.collect_workflow_files(&imported_scan, files, visited_imports)?;
self.collect_workflow_files(
&imported_scan,
files,
visited_imports,
GraphPosition::Imported,
)?;
}
}
for child in children {
@ -474,6 +487,101 @@ mod tests {
assert_eq!(goal.ref_.original, "@goal.md");
}
#[test]
fn root_model_stylesheet_bundles_nested_static_includes() {
let temp = tempfile::tempdir().expect("temp directory should be created");
let graph = temp.path().join("workflow.fabro");
write_file(
&graph,
r#"digraph Root {
graph [model_stylesheet="{% include 'styles/base.css' %}"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#,
);
write_file(
&temp.path().join("styles/base.css"),
"{% include 'nested.css' %}",
);
write_file(
&temp.path().join("styles/nested.css"),
"* { reasoning_effort: low; }",
);
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
let files = &workflows["workflow.fabro"].files;
assert_eq!(
files["styles/base.css"].content,
"{% include 'nested.css' %}"
);
assert_eq!(
files["styles/nested.css"].content,
"* { reasoning_effort: low; }"
);
}
#[test]
fn root_model_stylesheet_rejects_invalid_includes() {
for template in [
"{% include 'missing.css' %}",
"{% include inputs.stylesheet %}",
"{% include '../outside.css' %}",
] {
let temp = tempfile::tempdir().expect("temp directory should be created");
let graph = temp.path().join("workflow.fabro");
write_file(
&graph,
&format!(
r#"digraph Root {{
graph [model_stylesheet="{template}"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}}"#,
),
);
let error = bundle_graph(temp.path(), &graph)
.expect_err("invalid stylesheet include should fail bundling");
assert!(
error.to_string().contains("template dependencies"),
"template: {template}; error: {error:#}"
);
}
}
#[test]
fn imported_model_stylesheet_includes_are_not_bundled() {
let temp = tempfile::tempdir().expect("temp directory should be created");
let graph = temp.path().join("workflow.fabro");
write_file(
&graph,
r#"digraph Root {
start [shape=Mdiamond]
child [import="child.fabro"]
exit [shape=Msquare]
start -> child -> exit
}"#,
);
write_file(
&temp.path().join("child.fabro"),
r#"digraph Child {
graph [model_stylesheet="{% include 'missing.css' %}"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#,
);
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
let files = &workflows["workflow.fabro"].files;
assert!(files.contains_key("child.fabro"));
assert!(!files.contains_key("missing.css"));
}
#[test]
fn parse_errors_keep_the_graphviz_error_in_the_source_chain() {
let temp = tempfile::tempdir().expect("temp directory should be created");

View file

@ -8,6 +8,7 @@ pub(crate) enum CloneDecision {
GitHub {
origin_url: String,
branch: Option<String>,
tag: Option<String>,
commit_sha: Option<String>,
},
}
@ -82,17 +83,87 @@ pub(crate) fn exact_repository_init_command(clone_url: &str, checkout_path: &str
)
}
/// Fetch a single admitted commit with the same history depth a branch clone
/// A revision the checkout is pinned to instead of the branch's current HEAD.
///
/// The working branch names the checkout the run works on; it never constrains
/// which revision is fetched. No layer proves branch/revision ancestry, and an
/// unavailable revision fails without falling back to branch HEAD.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PinnedRevision {
/// An exact commit SHA, already normalized by
/// [`normalize_exact_commit_sha`].
Commit(String),
/// A bare tag name, fetched as `refs/tags/<tag>` so a same-named branch is
/// never consulted.
Tag(String),
}
impl PinnedRevision {
/// An exact commit is authoritative over a tag; the tag stays on the run
/// target as durable identity but does not drive the checkout.
pub(crate) fn from_selectors(tag: Option<&str>, commit_sha: Option<&str>) -> Option<Self> {
match (commit_sha, tag) {
(Some(sha), _) => Some(Self::Commit(sha.to_string())),
(None, Some(tag)) => Some(Self::Tag(tag.to_string())),
(None, None) => None,
}
}
/// Human-readable prefix for error messages.
pub(crate) fn label(&self) -> &'static str {
match self {
Self::Commit(_) => "Exact commit checkout",
Self::Tag(_) => "Tag checkout",
}
}
/// The refspec handed to `git fetch`.
pub(crate) fn fetch_refspec(&self) -> String {
match self {
Self::Commit(sha) => sha.clone(),
Self::Tag(tag) => tag_ref(tag),
}
}
/// The commit HEAD must resolve to after checkout, when one is known.
pub(crate) fn expected_sha(&self) -> Option<&str> {
match self {
Self::Commit(sha) => Some(sha),
Self::Tag(_) => None,
}
}
/// Validate the `rev-parse HEAD` output of a pinned checkout and return the
/// resolved commit ID.
pub(crate) fn verify_head(&self, output: &str) -> crate::Result<String> {
let actual_sha = verify_resolved_head(output)?;
if self
.expected_sha()
.is_some_and(|expected| expected != actual_sha)
{
return Err(crate::Error::message(
"Exact checkout HEAD did not match the requested commit",
));
}
Ok(actual_sha)
}
}
/// Fully-qualified ref for a bare tag name.
pub(crate) fn tag_ref(tag: &str) -> String {
format!("refs/tags/{tag}")
}
/// Fetch a single pinned refspec with the same history depth a branch clone
/// gets, so both paths can reach the same number of parent commits.
///
/// The fetch names the commit directly rather than the branch. No layer proves
/// that the submitted commit belongs to the submitted branch: the branch names
/// the working branch, while a fetchable exact commit is checked out as-is.
/// The fetch names the revision directly rather than the branch, and
/// `--no-tags` keeps unrelated tags from being pulled alongside it.
#[cfg(any(feature = "docker", test))]
pub(crate) fn exact_fetch_command(
pub(crate) fn pinned_fetch_command(
checkout_path: &str,
fetch_source: &str,
commit_sha: &str,
refspec: &str,
depth: Option<usize>,
) -> String {
let depth_arg = depth_argument(depth);
@ -100,7 +171,7 @@ pub(crate) fn exact_fetch_command(
"{git} -C {} fetch{depth_arg} --no-tags {} -- {}",
sandbox::shell_quote(checkout_path),
sandbox::shell_quote(fetch_source),
sandbox::shell_quote(commit_sha),
sandbox::shell_quote(refspec),
git = sandbox::GIT,
)
}
@ -131,7 +202,8 @@ pub(crate) fn exact_branch_checkout_command(
)
}
/// Print the current HEAD commit and nothing else, for [`verify_exact_head`].
/// Print the current HEAD commit and nothing else, for
/// [`PinnedRevision::verify_head`].
pub(crate) fn exact_head_revision_command(checkout_path: &str) -> String {
format!(
"{git} -C {path} rev-parse HEAD",
@ -141,7 +213,8 @@ pub(crate) fn exact_head_revision_command(checkout_path: &str) -> String {
}
/// Check out the admitted branch and print the resulting HEAD in one shell
/// command; stdout is the `rev-parse HEAD` output for [`verify_exact_head`].
/// command; stdout is the `rev-parse HEAD` output for
/// [`PinnedRevision::verify_head`].
#[cfg(any(feature = "docker", test))]
pub(crate) fn exact_checkout_verify_command(
checkout_path: &str,
@ -155,17 +228,17 @@ pub(crate) fn exact_checkout_verify_command(
)
}
pub(crate) fn verify_exact_head(output: &str, expected_sha: &str) -> crate::Result<()> {
let actual_sha = output.trim();
let actual_sha = normalize_exact_commit_sha(actual_sha).map_err(|err| {
crate::Error::context("Exact checkout produced an invalid HEAD commit ID", err)
})?;
if actual_sha != expected_sha {
return Err(crate::Error::message(
"Exact checkout HEAD did not match the requested commit",
));
}
Ok(())
/// The peeled commit behind whatever `git fetch` just wrote to `FETCH_HEAD`;
/// a commit peels to itself, an annotated tag to the commit it points at.
#[cfg(any(feature = "docker", test))]
pub(crate) const FETCH_HEAD_COMMIT: &str = "FETCH_HEAD^{commit}";
/// Validate that a `rev-parse HEAD` output is a single commit ID and return it
/// normalized.
pub(crate) fn verify_resolved_head(output: &str) -> crate::Result<String> {
normalize_exact_commit_sha(output.trim()).map_err(|err| {
crate::Error::context("Pinned checkout produced an invalid HEAD commit ID", err)
})
}
fn trim_root(root: &str) -> &str {
@ -194,31 +267,39 @@ pub(crate) fn decide_clone(
skip_clone: bool,
clone_origin_url: Option<&str>,
clone_branch: Option<&str>,
clone_tag: Option<&str>,
clone_commit_sha: Option<&str>,
) -> crate::Result<CloneDecision> {
if clone_tag.is_some_and(|tag| tag.trim().is_empty()) {
return Err(crate::Error::message(
"Tag checkout requires a non-empty tag",
));
}
let tag = clone_tag.map(str::to_string);
let commit_sha = clone_commit_sha
.map(normalize_exact_commit_sha)
.transpose()?;
if commit_sha.is_some() {
if let Some(pin) = PinnedRevision::from_selectors(tag.as_deref(), commit_sha.as_deref()) {
let selector = pin.label();
if skip_clone {
return Err(crate::Error::message(
"Exact commit checkout requires cloning to be enabled",
));
return Err(crate::Error::message(format!(
"{selector} requires cloning to be enabled"
)));
}
if clone_origin_url.is_none_or(|url| url.trim().is_empty()) {
return Err(crate::Error::message(
"Exact commit checkout requires a repository origin",
));
return Err(crate::Error::message(format!(
"{selector} requires a repository origin"
)));
}
// The branch names the checkout the run works on; it is not used to
// constrain which commits may be fetched. No layer proves branch/SHA
// ancestry, and an unavailable exact commit fails without falling back
// to branch HEAD.
if clone_branch.is_none_or(|branch| branch.trim().is_empty()) {
return Err(crate::Error::message(
"Exact commit checkout requires a repository branch",
));
return Err(crate::Error::message(format!(
"{selector} requires a repository branch"
)));
}
}
@ -246,6 +327,7 @@ pub(crate) fn decide_clone(
branch: clone_branch
.filter(|branch| !branch.trim().is_empty())
.map(str::to_string),
tag,
commit_sha,
})
}
@ -267,7 +349,7 @@ pub(crate) fn repo_cloned_for_record(
clone_origin_url: Option<&str>,
) -> Option<bool> {
Some(matches!(
decide_clone(skip_clone, clone_origin_url, None, None).ok()?,
decide_clone(skip_clone, clone_origin_url, None, None, None).ok()?,
CloneDecision::GitHub { .. }
))
}
@ -307,17 +389,8 @@ mod tests {
String::from_utf8(output.stdout).expect("git output should be UTF-8")
}
#[expect(
clippy::disallowed_methods,
reason = "hermetic command-builder proof intentionally runs local Bash synchronously"
)]
fn run_shell(cwd: &Path, command: &str) -> String {
let output = isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
"--noprofile",
"--norc",
"-c",
command,
]));
let output = run_shell_output(cwd, command);
assert!(
output.status.success(),
"command failed: {}",
@ -326,6 +399,19 @@ mod tests {
String::from_utf8(output.stdout).expect("command output should be UTF-8")
}
#[expect(
clippy::disallowed_methods,
reason = "hermetic command-builder proof intentionally runs local Bash synchronously"
)]
fn run_shell_output(cwd: &Path, command: &str) -> Output {
isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
"--noprofile",
"--norc",
"-c",
command,
]))
}
#[test]
fn skip_clone_overrides_present_origin() {
assert_eq!(
@ -334,6 +420,7 @@ mod tests {
Some("https://gitlab.com/acme/widgets.git"),
Some("main"),
None,
None,
)
.unwrap(),
CloneDecision::EmptyWorkspace {
@ -345,7 +432,7 @@ mod tests {
#[test]
fn missing_origin_creates_empty_workspace() {
assert_eq!(
decide_clone(false, None, None, None).unwrap(),
decide_clone(false, None, None, None, None).unwrap(),
CloneDecision::EmptyWorkspace {
reason: EmptyWorkspaceReason::MissingOrigin,
}
@ -360,16 +447,63 @@ mod tests {
Some("git@github.com:acme/widgets.git"),
Some("feature/work"),
None,
None,
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("feature/work".to_string()),
tag: None,
commit_sha: None,
}
);
}
#[test]
fn tag_clone_keeps_working_branch_and_bare_tag_distinct() {
assert_eq!(
decide_clone(
false,
Some("https://github.com/acme/widgets"),
Some("release"),
Some("v1.2.3"),
None,
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("release".to_string()),
tag: Some("v1.2.3".to_string()),
commit_sha: None,
}
);
}
#[test]
fn pinned_revision_prefers_exact_commit_and_qualifies_tags() {
let sha = "0123456789abcdef0123456789abcdef01234567";
assert_eq!(PinnedRevision::from_selectors(None, None), None);
let tag = PinnedRevision::from_selectors(Some("release/v1"), None).unwrap();
assert_eq!(tag.fetch_refspec(), "refs/tags/release/v1");
assert_eq!(tag.expected_sha(), None);
let commit = PinnedRevision::from_selectors(Some("release/v1"), Some(sha)).unwrap();
assert_eq!(commit.fetch_refspec(), sha);
assert_eq!(commit.expected_sha(), Some(sha));
assert_eq!(
pinned_fetch_command(
"/repos/acme/widgets",
"origin",
&tag.fetch_refspec(),
Some(10)
),
"git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets fetch --depth 10 --no-tags origin -- refs/tags/release/v1"
);
assert_eq!(
exact_checkout_verify_command("/repos/acme/widgets", "release", FETCH_HEAD_COMMIT),
"git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets checkout -B release FETCH_HEAD'^{commit}' && git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets rev-parse HEAD"
);
}
#[test]
fn non_github_origin_fails_without_skip_clone() {
let error = decide_clone(
@ -377,6 +511,7 @@ mod tests {
Some("https://gitlab.com/acme/widgets.git"),
None,
None,
None,
)
.expect_err("non-GitHub origins should fail");
assert!(error.to_string().contains("GitHub repository origins only"));
@ -392,12 +527,14 @@ mod tests {
false,
Some("https://github.com/acme/widgets"),
Some("moving-branch"),
Some("release"),
Some(lowercase),
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("moving-branch".to_string()),
tag: Some("release".to_string()),
commit_sha: Some(lowercase.to_string()),
}
);
@ -406,12 +543,14 @@ mod tests {
false,
Some("https://github.com/acme/widgets"),
Some("main"),
None,
Some(uppercase),
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("main".to_string()),
tag: None,
commit_sha: Some(uppercase.to_ascii_lowercase()),
}
);
@ -432,6 +571,7 @@ mod tests {
false,
Some("https://github.com/acme/widgets"),
None,
None,
Some(sha),
)
.expect_err("invalid exact commit SHA should fail");
@ -443,33 +583,50 @@ mod tests {
}
#[test]
fn exact_checkout_requires_clone_origin_and_branch() {
fn pinned_checkout_requires_clone_origin_and_branch() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let skip_error = decide_clone(
true,
for (tag, commit_sha) in [(None, Some(sha)), (Some("v1"), None)] {
let skip_error = decide_clone(
true,
Some("https://github.com/acme/widgets"),
Some("main"),
tag,
commit_sha,
)
.expect_err("pinned checkout with skip-clone should fail");
assert!(skip_error.to_string().contains("requires cloning"));
for origin in [None, Some(""), Some(" ")] {
let error = decide_clone(false, origin, Some("main"), tag, commit_sha)
.expect_err("pinned checkout without an origin should fail");
assert!(error.to_string().contains("requires a repository origin"));
}
for branch in [None, Some(""), Some(" ")] {
let error = decide_clone(
false,
Some("https://github.com/acme/widgets"),
branch,
tag,
commit_sha,
)
.expect_err("pinned checkout without a branch should fail");
assert!(error.to_string().contains("requires a repository branch"));
}
}
}
#[test]
fn tag_checkout_rejects_empty_tag() {
let empty_tag = decide_clone(
false,
Some("https://github.com/acme/widgets"),
Some("main"),
Some(sha),
Some(""),
None,
)
.expect_err("exact checkout with skip-clone should fail");
assert!(skip_error.to_string().contains("requires cloning"));
for origin in [None, Some(""), Some(" ")] {
let error = decide_clone(false, origin, Some("main"), Some(sha))
.expect_err("exact checkout without an origin should fail");
assert!(error.to_string().contains("requires a repository origin"));
}
for branch in [None, Some(""), Some(" ")] {
let error = decide_clone(
false,
Some("https://github.com/acme/widgets"),
branch,
Some(sha),
)
.expect_err("exact checkout without a branch should fail");
assert!(error.to_string().contains("requires a repository branch"));
}
.expect_err("empty tags should fail");
assert!(empty_tag.to_string().contains("non-empty tag"));
}
#[test]
@ -479,7 +636,7 @@ mod tests {
"https://token@example.com/acme/widgets.git?x=a b",
"/repos/acme's widgets",
);
let fetch = exact_fetch_command(
let fetch = pinned_fetch_command(
"/repos/acme's widgets",
"https://token@example.com/acme/widgets.git?x=a b",
sha,
@ -503,9 +660,9 @@ mod tests {
}
#[test]
fn exact_fetch_omits_depth_for_full_history() {
fn pinned_fetch_omits_depth_for_full_history() {
assert_eq!(
exact_fetch_command(
pinned_fetch_command(
"/repos/acme/widgets",
"origin",
"0123456789abcdef0123456789abcdef01234567",
@ -518,15 +675,18 @@ mod tests {
#[test]
fn exact_checkout_verification_rejects_invalid_or_mismatched_head() {
let expected = "0123456789abcdef0123456789abcdef01234567";
verify_exact_head("0123456789ABCDEF0123456789ABCDEF01234567\n", expected)
let pin = PinnedRevision::Commit(expected.to_string());
pin.verify_head("0123456789ABCDEF0123456789ABCDEF01234567\n")
.expect("uppercase command output should normalize");
let invalid = verify_exact_head("fatal: not a revision", expected)
let invalid = pin
.verify_head("fatal: not a revision")
.expect_err("non-SHA output should fail verification");
assert!(invalid.to_string().contains("invalid HEAD commit ID"));
assert!(!invalid.to_string().contains("fatal: not a revision"));
let mismatched = verify_exact_head("1123456789abcdef0123456789abcdef01234567", expected)
let mismatched = pin
.verify_head("1123456789abcdef0123456789abcdef01234567")
.expect_err("mismatched SHA should fail verification");
assert!(mismatched.to_string().contains("did not match"));
}
@ -536,7 +696,7 @@ mod tests {
clippy::disallowed_methods,
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
)]
fn exact_checkout_fetches_admitted_commit_after_branch_advances() {
fn exact_checkout_fetches_admitted_commit_after_branch_and_tag_advance() {
let temp = tempfile::tempdir().expect("tempdir");
let remote = temp.path().join("remote.git");
let source = temp.path().join("source");
@ -561,10 +721,14 @@ mod tests {
]);
run_git(&source, &["push", "-u", "origin", "main"]);
let admitted_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
run_git(&source, &["tag", "release"]);
run_git(&source, &["push", "origin", "refs/tags/release"]);
fs::write(source.join("revision.txt"), "B\n").expect("write commit B");
run_git(&source, &["commit", "-am", "commit B"]);
run_git(&source, &["push", "origin", "main"]);
run_git(&source, &["tag", "-f", "release"]);
run_git(&source, &["push", "--force", "origin", "refs/tags/release"]);
let advanced_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
assert_ne!(admitted_sha, advanced_sha);
@ -576,11 +740,11 @@ mod tests {
);
run_shell(
temp.path(),
&exact_fetch_command(checkout_path, remote_path, &admitted_sha, Some(10)),
&pinned_fetch_command(checkout_path, remote_path, &admitted_sha, Some(10)),
);
let checked_out_sha = run_shell(
temp.path(),
&exact_checkout_verify_command(checkout_path, "main", "FETCH_HEAD"),
&exact_checkout_verify_command(checkout_path, "main", FETCH_HEAD_COMMIT),
);
assert_eq!(checked_out_sha.trim(), admitted_sha);
@ -609,6 +773,79 @@ mod tests {
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
)]
fn tag_checkout_peels_lightweight_and_annotated_tags_without_branch_fallback() {
let temp = tempfile::tempdir().expect("tempdir");
let remote = temp.path().join("remote.git");
let source = temp.path().join("source");
fs::create_dir(&source).expect("source directory");
run_git(temp.path(), &[
"init",
"--bare",
remote.to_str().expect("UTF-8 remote path"),
]);
run_git(&source, &["init"]);
fs::write(source.join("revision.txt"), "release\n").expect("write release commit");
run_git(&source, &["add", "revision.txt"]);
run_git(&source, &["commit", "-m", "release"]);
run_git(&source, &["branch", "-M", "main"]);
let release_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
run_git(&source, &["tag", "lightweight"]);
run_git(&source, &["tag", "-a", "annotated", "-m", "annotated"]);
run_git(&source, &[
"remote",
"add",
"origin",
remote.to_str().expect("UTF-8 remote path"),
]);
run_git(&source, &["push", "origin", "main", "--tags"]);
let remote_path = remote.to_str().expect("UTF-8 remote path");
for tag in ["lightweight", "annotated"] {
let checkout = temp.path().join(format!("checkout-{tag}"));
let checkout_path = checkout.to_str().expect("UTF-8 checkout path");
run_shell(
temp.path(),
&exact_repository_init_command(remote_path, checkout_path),
);
run_shell(
temp.path(),
&pinned_fetch_command(checkout_path, "origin", &tag_ref(tag), Some(10)),
);
let head = run_shell(
temp.path(),
&exact_checkout_verify_command(checkout_path, "release-work", FETCH_HEAD_COMMIT),
);
assert_eq!(verify_resolved_head(&head).unwrap(), release_sha);
assert_eq!(
run_git(&checkout, &["symbolic-ref", "HEAD"]).trim(),
"refs/heads/release-work"
);
}
let missing = temp.path().join("missing");
let missing_path = missing.to_str().expect("UTF-8 checkout path");
run_shell(
temp.path(),
&exact_repository_init_command(remote_path, missing_path),
);
let output = run_shell_output(
temp.path(),
&pinned_fetch_command(missing_path, "origin", &tag_ref("main"), Some(10)),
);
assert!(
!output.status.success(),
"a branch must not satisfy a tag fetch"
);
assert!(!missing.join("revision.txt").exists());
}
#[test]
fn github_layout_maps_ssh_origin_to_repos_checkout_and_workspace_link() {
let layout = github_repo_layout(

View file

@ -27,7 +27,7 @@ use tokio::task::JoinHandle;
use tokio::{fs, time};
use tokio_util::sync::CancellationToken;
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason};
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason, PinnedRevision};
use crate::git_retry::{self, CredentialContext, GitRetryReason};
use crate::push_credentials::{self, PushCredentialState};
use crate::redact::redact_auth_url;
@ -109,6 +109,16 @@ const DAYTONA_STATE_CHANGE_POLL_INTERVAL: Duration = Duration::from_secs(1);
/// leaked by a dead worker. An explicit `0` disables auto-stop entirely.
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120;
/// The ref Daytona's native clone checks out. A pinned tag is fetched by its
/// fully-qualified ref so a same-named branch is never consulted; with an
/// exact commit, `commit_id` drives the checkout and the branch is only a name.
fn git_clone_selector(branch: Option<&str>, pin: Option<&PinnedRevision>) -> Option<String> {
match pin {
Some(PinnedRevision::Tag(tag)) => Some(clone_source::tag_ref(tag)),
Some(PinnedRevision::Commit(_)) | None => branch.map(str::to_string),
}
}
pub(crate) fn daytona_not_found(err: &DaytonaError) -> bool {
matches!(err, DaytonaError::NotFound { .. }) || err.status_code() == Some(404)
}
@ -464,6 +474,7 @@ pub struct DaytonaSandbox {
/// Explicit branch to clone. When set, overrides the branch detected by
/// the submitted run spec.
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
}
@ -478,14 +489,16 @@ impl DaytonaSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
api_key: Option<String>,
) -> crate::Result<Self> {
if clone_commit_sha.is_some() {
if clone_tag.is_some() || clone_commit_sha.is_some() {
clone_source::decide_clone(
config.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
clone_tag.as_deref(),
clone_commit_sha.as_deref(),
)?;
}
@ -512,6 +525,7 @@ impl DaytonaSandbox {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
})
}
@ -565,6 +579,7 @@ impl DaytonaSandbox {
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
})
}
@ -650,25 +665,29 @@ impl DaytonaSandbox {
self.fail_init(init_start, err)
}
/// Point the admitted branch at the exact commit and verify the resulting
/// HEAD.
/// Point the admitted branch at the pinned revision and verify the
/// resulting HEAD.
///
/// Daytona's native clone honors `commit_id`, but leaves the workspace on
/// whatever ref its own checkout produced. Re-pointing the branch keeps the
/// admitted branch name readable back out of the workspace, matching what
/// the Docker provider produces for the same inputs.
async fn attach_exact_commit_branch(
/// whatever ref its own checkout produced (a detached tag, or the exact
/// commit). Re-pointing the branch keeps the admitted branch name readable
/// back out of the workspace, matching what the Docker provider produces
/// for the same inputs.
async fn attach_pinned_branch(
process_svc: &daytona_sdk::ProcessService,
checkout_path: &str,
branch: &str,
expected_sha: &str,
pin: &PinnedRevision,
deadline: time::Instant,
) -> crate::Result<()> {
// An exact commit is named directly; a tag clone is already sitting on
// the tag, so peel whatever HEAD points at to its commit.
let revision = pin.expected_sha().unwrap_or("HEAD^{commit}");
Self::run_required_post_clone_command(
process_svc,
&clone_source::exact_branch_checkout_command(checkout_path, branch, expected_sha),
&clone_source::exact_branch_checkout_command(checkout_path, branch, revision),
"/",
"git checkout exact commit",
"git checkout pinned revision",
deadline,
)
.await?;
@ -676,11 +695,12 @@ impl DaytonaSandbox {
process_svc,
&clone_source::exact_head_revision_command(checkout_path),
"/",
"git rev-parse HEAD after exact checkout",
"git rev-parse HEAD after pinned checkout",
deadline,
)
.await?;
clone_source::verify_exact_head(&head, expected_sha)
pin.verify_head(&head)?;
Ok(())
}
/// Execute one post-clone command under the shared setup deadline.
@ -1521,6 +1541,7 @@ impl Sandbox for DaytonaSandbox {
self.config.skip_clone,
self.clone_origin_url.as_deref(),
self.clone_branch.as_deref(),
self.clone_tag.as_deref(),
self.clone_commit_sha.as_deref(),
)
.map_err(|e| self.fail_init(init_start, e))?;
@ -1549,6 +1570,7 @@ impl Sandbox for DaytonaSandbox {
CloneDecision::GitHub {
origin_url,
branch,
tag,
commit_sha,
} => {
let layout =
@ -1647,6 +1669,8 @@ impl Sandbox for DaytonaSandbox {
self.fail_init(init_start, err)
})?;
let pin = PinnedRevision::from_selectors(tag.as_deref(), commit_sha.as_deref());
let clone_selector = git_clone_selector(branch.as_deref(), pin.as_ref());
let clone_plan = git_retry::RetryPlan::clone_default(None);
let clone_result = git_retry::retry_git_operation(
SandboxProviderKind::Daytona,
@ -1657,7 +1681,7 @@ impl Sandbox for DaytonaSandbox {
let origin = origin_url.as_str();
let target = layout.primary_repo_path.as_str();
let options = GitCloneOptions {
branch: branch.clone(),
branch: clone_selector.clone(),
commit_id: commit_sha.clone(),
username: username.clone(),
password: password.clone(),
@ -1702,21 +1726,22 @@ impl Sandbox for DaytonaSandbox {
}
};
if let Some(expected_sha) = commit_sha.as_deref() {
if let Some(pin) = &pin {
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty())
else {
let err = crate::Error::message(
"Exact commit checkout requires a repository branch",
);
let err = crate::Error::message(format!(
"{} requires a repository branch",
pin.label()
));
return Err(self
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
.await);
};
if let Err(err) = Self::attach_exact_commit_branch(
if let Err(err) = Self::attach_pinned_branch(
&process_svc,
&layout.primary_repo_path,
branch,
expected_sha,
pin,
post_clone_deadline,
)
.await
@ -3193,6 +3218,25 @@ mod tests {
use super::*;
use crate::sandbox::BASH_PROBE_MARKER;
#[test]
fn daytona_clone_selector_uses_fully_qualified_tag_unless_sha_is_exact() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let tag = PinnedRevision::from_selectors(Some("v1.2.3"), None);
assert_eq!(
git_clone_selector(Some("release-work"), tag.as_ref()).as_deref(),
Some("refs/tags/v1.2.3")
);
let commit = PinnedRevision::from_selectors(Some("v1.2.3"), Some(sha));
assert_eq!(
git_clone_selector(Some("release-work"), commit.as_ref()).as_deref(),
Some("release-work")
);
assert_eq!(
git_clone_selector(Some("release-work"), None).as_deref(),
Some("release-work")
);
}
#[tokio::test]
async fn invalid_exact_sha_fails_before_daytona_client_construction() {
let error = DaytonaSandbox::new(
@ -3201,6 +3245,7 @@ mod tests {
None,
Some("https://github.com/acme/widgets".to_string()),
Some("main".to_string()),
None,
Some("not-a-sha".to_string()),
Some("dtn_not_used".to_string()),
)
@ -3220,6 +3265,7 @@ mod tests {
None,
Some("https://github.com/acme/widgets".to_string()),
None,
None,
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
Some("dtn_not_used".to_string()),
)
@ -3547,6 +3593,7 @@ mod tests {
run_id: None,
clone_origin_url: None,
clone_branch: None,
clone_tag: None,
clone_commit_sha: None,
}
}
@ -3726,6 +3773,7 @@ mod tests {
None,
None,
None,
None,
Some("dtn_test".to_string()),
)
.await
@ -3765,6 +3813,7 @@ mod tests {
None,
None,
None,
None,
Some("dtn_test".to_string()),
)
.await
@ -4093,6 +4142,7 @@ mod tests {
None,
None,
None,
None,
Some("dtn_test".to_string()),
)
.await

View file

@ -156,6 +156,7 @@ pub struct DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
container_id: OnceCell<String>,
repo_cloned: OnceCell<bool>,
@ -187,13 +188,15 @@ impl DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
) -> crate::Result<Self> {
if clone_commit_sha.is_some() {
if clone_tag.is_some() || clone_commit_sha.is_some() {
clone_source::decide_clone(
config.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
clone_tag.as_deref(),
clone_commit_sha.as_deref(),
)?;
}
@ -205,6 +208,7 @@ impl DockerSandbox {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
)
}
@ -216,6 +220,7 @@ impl DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
) -> crate::Result<Self> {
let push_credentials = PushCredentialState::new(push_credentials::build_token_source(
@ -229,6 +234,7 @@ impl DockerSandbox {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
container_id: OnceCell::new(),
repo_cloned: OnceCell::new(),
@ -256,6 +262,7 @@ impl DockerSandbox {
clone_origin_url.clone(),
clone_branch,
None,
None,
)?;
sandbox.validate_managed_container(container_id).await?;
sandbox
@ -903,6 +910,7 @@ impl DockerSandbox {
&self,
origin_url: String,
branch: Option<String>,
tag: Option<String>,
commit_sha: Option<String>,
) -> crate::Result<()> {
self.verify_git_available().await?;
@ -965,13 +973,15 @@ impl DockerSandbox {
}
let clone_deadline = time::Instant::now() + GIT_CLONE_TIMEOUT;
if let Some(expected_sha) = commit_sha.as_deref() {
// `decide_clone` already rejects an exact commit without a branch;
// re-check here so the checkout can never silently drop the branch
// name callers read back out of the workspace.
if let Some(pin) =
clone_source::PinnedRevision::from_selectors(tag.as_deref(), commit_sha.as_deref())
{
// `decide_clone` already rejects a pinned revision without a
// branch; re-check here so the checkout can never silently drop the
// branch name callers read back out of the workspace.
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty()) else {
let error =
crate::Error::message("Exact commit checkout requires a repository branch");
crate::Error::message(format!("{} requires a repository branch", pin.label()));
return Err(self.report_clone_failure(&origin_url, error));
};
@ -980,7 +990,7 @@ impl DockerSandbox {
if let Err(error) = self
.run_exact_local_git_command(
&init_command,
"initialize Docker exact repository checkout",
"initialize Docker pinned repository checkout",
clone_deadline,
auth_url.as_ref(),
)
@ -989,18 +999,18 @@ impl DockerSandbox {
return Err(self.report_clone_failure(&origin_url, error));
}
let fetch_command = clone_source::exact_fetch_command(
let fetch_command = clone_source::pinned_fetch_command(
&layout.primary_repo_path,
"origin",
expected_sha,
&pin.fetch_refspec(),
self.config.clone_depth,
);
if let Err(failure) = self
.retry_git_transfer(
&fetch_command,
"fetch",
"Docker exact fetch",
"git fetch exact commit",
"Docker pinned fetch",
"git fetch pinned revision",
clone_deadline,
clone_credential_context,
auth_url.as_ref(),
@ -1013,12 +1023,12 @@ impl DockerSandbox {
let checkout_command = clone_source::exact_checkout_verify_command(
&layout.primary_repo_path,
branch,
"FETCH_HEAD",
clone_source::FETCH_HEAD_COMMIT,
);
let head = match self
.run_exact_local_git_command(
&checkout_command,
"git checkout exact commit",
"git checkout pinned revision",
clone_deadline,
auth_url.as_ref(),
)
@ -1027,7 +1037,7 @@ impl DockerSandbox {
Ok(result) => result,
Err(error) => return Err(self.report_clone_failure(&origin_url, error)),
};
if let Err(error) = clone_source::verify_exact_head(&head.stdout, expected_sha) {
if let Err(error) = pin.verify_head(&head.stdout) {
return Err(self.report_clone_failure(&origin_url, error));
}
} else {
@ -1826,6 +1836,7 @@ impl Sandbox for DockerSandbox {
self.config.skip_clone,
self.clone_origin_url.as_deref(),
self.clone_branch.as_deref(),
self.clone_tag.as_deref(),
self.clone_commit_sha.as_deref(),
)
.map_err(|e| self.fail_init(init_start, e))?;
@ -1847,9 +1858,13 @@ impl Sandbox for DockerSandbox {
CloneDecision::GitHub {
origin_url,
branch,
tag,
commit_sha,
} => {
if let Err(e) = self.clone_github_repo(origin_url, branch, commit_sha).await {
if let Err(e) = self
.clone_github_repo(origin_url, branch, tag, commit_sha)
.await
{
return Err(self.fail_init(init_start, e));
}
}
@ -2668,6 +2683,7 @@ mod tests {
None,
Some("https://github.com/acme/widgets".to_string()),
Some("main".to_string()),
None,
Some("not-a-sha".to_string()),
)
.err()
@ -2685,6 +2701,7 @@ mod tests {
None,
Some("https://github.com/acme/widgets".to_string()),
None,
None,
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
)
.err()
@ -3072,6 +3089,7 @@ mod tests {
None,
None,
None,
None,
)
.expect("test sandbox should build");
sandbox

View file

@ -130,6 +130,7 @@ impl SandboxProvider for DaytonaSandboxProvider {
clone_origin_url,
clone_branch,
None,
None,
Some(api_key),
)
.await?;

View file

@ -105,6 +105,7 @@ impl SandboxProvider for DockerSandboxProvider {
clone_origin_url,
clone_branch,
None,
None,
)?;
sandbox.initialize().await?;
let container_id = sandbox.container_identifier()?.to_string();

View file

@ -32,6 +32,7 @@ pub enum SandboxSpec {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
},
#[cfg(feature = "daytona")]
@ -41,6 +42,7 @@ pub enum SandboxSpec {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
api_key: Option<String>,
},
@ -204,6 +206,7 @@ impl SandboxSpec {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
} => {
let mut sandbox = DockerSandbox::new(
@ -212,6 +215,7 @@ impl SandboxSpec {
*run_id,
clone_origin_url.clone(),
clone_branch.clone(),
clone_tag.clone(),
clone_commit_sha.clone(),
)
.context("Failed to create Docker sandbox")?;
@ -227,6 +231,7 @@ impl SandboxSpec {
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
api_key,
} => {
@ -236,6 +241,7 @@ impl SandboxSpec {
*run_id,
clone_origin_url.clone(),
clone_branch.clone(),
clone_tag.clone(),
clone_commit_sha.clone(),
api_key.clone(),
)
@ -282,6 +288,7 @@ mod tests {
run_id: None,
clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()),
clone_branch: Some("main".to_string()),
clone_tag: None,
clone_commit_sha: None,
};
let mut sandbox = MockSandbox::linux();
@ -320,6 +327,7 @@ mod tests {
run_id: None,
clone_origin_url: Some("https://github.com/acme/widgets".to_string()),
clone_branch: Some("main".to_string()),
clone_tag: None,
clone_commit_sha: Some("not-a-sha".to_string()),
};
@ -349,6 +357,7 @@ mod tests {
run_id: None,
clone_origin_url: Some("https://gitlab.com/acme/widgets".to_string()),
clone_branch: None,
clone_tag: None,
clone_commit_sha: None,
};
let mut sandbox = MockSandbox::linux();

View file

@ -38,6 +38,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?,
);
@ -76,6 +77,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;
sandbox.initialize().await?;
@ -182,6 +184,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;
@ -232,6 +235,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;
@ -300,6 +304,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;

View file

@ -41,6 +41,7 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -114,6 +115,7 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -176,6 +178,7 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
Some("https://github.com/brynary/rack-test".to_string()),
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -243,6 +246,7 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -337,6 +341,7 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -422,6 +427,7 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox

View file

@ -28,6 +28,7 @@ tokio-stream.workspace = true
dashmap.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
strum.workspace = true
chrono = { workspace = true, features = ["serde"] }

View file

@ -0,0 +1,370 @@
//! SQLite-backed storage for pending CLI authorizations.
//!
//! The raw authorization code is a one-time bearer credential. It remains at
//! the HTTP boundary and is hashed before every database operation; the
//! stored domain type owns only the approved authorization the code unlocks.
use chrono::{DateTime, Utc};
use fabro_types::IdpIdentity;
use sha2::{Digest as _, Sha256};
use sqlx::sqlite::SqliteRow;
use sqlx::{Row as _, SqlitePool};
use crate::{Result, sqlite_row};
const RECORD_NAME: &str = "pending CLI authorization";
/// Approved identity and OAuth context waiting for a CLI code exchange.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingCliAuthorization {
pub identity: IdpIdentity,
pub login: String,
pub name: String,
pub email: String,
pub avatar_url: String,
pub code_challenge: String,
pub redirect_uri: String,
pub expires_at: DateTime<Utc>,
}
/// Issues, consumes, and expires pending CLI authorizations in SQLite.
pub struct AuthCodeStore {
pool: SqlitePool,
}
impl std::fmt::Debug for AuthCodeStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthCodeStore").finish_non_exhaustive()
}
}
impl AuthCodeStore {
#[must_use]
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
/// Persist a pending authorization under the SHA-256 digest of `code`.
pub async fn issue(&self, code: &str, pending: &PendingCliAuthorization) -> Result<()> {
let code_hash = hash_code(code);
sqlx::query(
r"
INSERT INTO oauth_authorization_codes (
code_hash, identity_issuer, identity_subject, login, name, email,
avatar_url, code_challenge, redirect_uri, expires_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
",
)
.bind(code_hash.as_slice())
.bind(pending.identity.issuer())
.bind(pending.identity.subject())
.bind(&pending.login)
.bind(&pending.name)
.bind(&pending.email)
.bind(&pending.avatar_url)
.bind(&pending.code_challenge)
.bind(&pending.redirect_uri)
.bind(pending.expires_at.timestamp_millis())
.execute(&self.pool)
.await?;
Ok(())
}
/// Atomically remove the authorization for `code` and return it if live.
///
/// Expiry is checked after deletion so every exchange attempt burns a
/// matching code, including an expired one.
pub async fn consume(
&self,
code: &str,
now: DateTime<Utc>,
) -> Result<Option<PendingCliAuthorization>> {
let code_hash = hash_code(code);
let row = sqlx::query(
r"
DELETE FROM oauth_authorization_codes
WHERE code_hash = ?
RETURNING identity_issuer, identity_subject, login, name, email, avatar_url,
code_challenge, redirect_uri, expires_at_ms
",
)
.bind(code_hash.as_slice())
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else {
return Ok(None);
};
let pending = pending_from_row(&row)?;
if pending.expires_at <= now {
return Ok(None);
}
Ok(Some(pending))
}
/// Delete authorizations expiring at or before `cutoff`.
pub async fn gc_expired(&self, cutoff: DateTime<Utc>) -> Result<u64> {
let result = sqlx::query("DELETE FROM oauth_authorization_codes WHERE expires_at_ms <= ?")
.bind(cutoff.timestamp_millis())
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
/// Close the shared pool to exercise storage-failure paths in consumers.
#[cfg(any(test, feature = "test-support"))]
pub async fn test_close(&self) {
self.pool.close().await;
}
}
fn hash_code(code: &str) -> [u8; 32] {
Sha256::digest(code.as_bytes()).into()
}
fn pending_from_row(row: &SqliteRow) -> Result<PendingCliAuthorization> {
Ok(PendingCliAuthorization {
identity: sqlite_row::identity_from_row(row, RECORD_NAME)?,
login: row.try_get("login")?,
name: row.try_get("name")?,
email: row.try_get("email")?,
avatar_url: row.try_get("avatar_url")?,
code_challenge: row.try_get("code_challenge")?,
redirect_uri: row.try_get("redirect_uri")?,
expires_at: sqlite_row::timestamp_from_row(row, RECORD_NAME, "expires_at_ms")?,
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use chrono::{Duration, Utc};
use fabro_types::IdpIdentity;
use sha2::{Digest as _, Sha256};
use tokio::fs;
use tokio::task::JoinSet;
use super::{AuthCodeStore, PendingCliAuthorization};
use crate::{Error, test_support};
fn pending(expires_at: chrono::DateTime<Utc>) -> PendingCliAuthorization {
PendingCliAuthorization {
identity: IdpIdentity::new("https://github.com", "12345").unwrap(),
login: "octocat".to_string(),
name: "The Octocat".to_string(),
email: "octocat@example.com".to_string(),
avatar_url: "https://example.com/octocat.png".to_string(),
code_challenge: "challenge".to_string(),
redirect_uri: "http://127.0.0.1:4444/callback".to_string(),
expires_at,
}
}
fn now() -> chrono::DateTime<Utc> {
chrono::DateTime::from_timestamp_millis(Utc::now().timestamp_millis()).unwrap()
}
#[tokio::test]
async fn issue_and_consume_round_trips_once() {
let (_directory, store) = test_support::sqlite_auth_code_store().await;
let now = now();
let expected = pending(now + Duration::seconds(60));
store.issue("one-time-code", &expected).await.unwrap();
assert_eq!(
store.consume("one-time-code", now).await.unwrap(),
Some(expected)
);
assert!(store.consume("one-time-code", now).await.unwrap().is_none());
}
#[tokio::test]
async fn concurrent_consume_has_one_winner_across_store_instances() {
let (_directory, store) = test_support::sqlite_auth_code_store().await;
let now = now();
store
.issue("contended-code", &pending(now + Duration::seconds(60)))
.await
.unwrap();
let stores = [
Arc::new(AuthCodeStore::new(store.pool.clone())),
Arc::new(AuthCodeStore::new(store.pool.clone())),
];
let mut tasks = JoinSet::new();
for index in 0..16 {
let store = Arc::clone(&stores[index % stores.len()]);
tasks.spawn(async move {
store
.consume("contended-code", now)
.await
.unwrap()
.is_some()
});
}
let mut winners = 0;
while let Some(result) = tasks.join_next().await {
if result.unwrap() {
winners += 1;
}
}
assert_eq!(winners, 1);
}
#[tokio::test]
async fn expired_consume_deletes_the_row() {
let (_directory, store) = test_support::sqlite_auth_code_store().await;
let now = now();
store
.issue("expired-code", &pending(now - Duration::seconds(1)))
.await
.unwrap();
assert!(store.consume("expired-code", now).await.unwrap().is_none());
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_authorization_codes")
.fetch_one(&store.pool)
.await
.unwrap();
assert_eq!(rows, 0);
}
#[tokio::test]
async fn gc_removes_only_rows_at_or_before_cutoff() {
let (_directory, store) = test_support::sqlite_auth_code_store().await;
let now = now();
for (code, expiry) in [
("before", now - Duration::seconds(1)),
("at", now),
("after", now + Duration::seconds(1)),
] {
store.issue(code, &pending(expiry)).await.unwrap();
}
assert_eq!(store.gc_expired(now).await.unwrap(), 2);
assert!(store.consume("before", now).await.unwrap().is_none());
assert!(store.consume("at", now).await.unwrap().is_none());
assert!(store.consume("after", now).await.unwrap().is_some());
}
#[tokio::test]
async fn survives_reopening_the_sqlite_pool() {
let (directory, store) = test_support::sqlite_auth_code_store().await;
let now = now();
let expected = pending(now + Duration::seconds(60));
store.issue("durable-code", &expected).await.unwrap();
store.pool.close().await;
let database = fabro_db::Database::connect(directory.path().join("fabro.sqlite3"))
.await
.unwrap();
database.migrate().await.unwrap();
let reopened = AuthCodeStore::new(database.clone_pool());
assert_eq!(
reopened.consume("durable-code", now).await.unwrap(),
Some(expected)
);
}
#[tokio::test]
async fn duplicate_hash_fails_without_overwriting() {
let (_directory, store) = test_support::sqlite_auth_code_store().await;
let now = now();
let first = pending(now + Duration::seconds(60));
let mut second = pending(now + Duration::seconds(120));
second.login = "different-login".to_string();
store.issue("duplicate-code", &first).await.unwrap();
assert!(store.issue("duplicate-code", &second).await.is_err());
assert_eq!(
store.consume("duplicate-code", now).await.unwrap(),
Some(first)
);
}
#[tokio::test]
async fn errors_do_not_expose_sensitive_fields() {
let (_directory, store) = test_support::sqlite_auth_code_store().await;
let raw_code = "raw-authorization-code";
let entry = pending(now() + Duration::seconds(60));
store.issue(raw_code, &entry).await.unwrap();
let err = store.issue(raw_code, &entry).await.unwrap_err();
let rendered = err.to_string();
let code_hash = hex::encode(super::hash_code(raw_code));
for sensitive in [
raw_code,
code_hash.as_str(),
entry.code_challenge.as_str(),
entry.redirect_uri.as_str(),
entry.login.as_str(),
entry.name.as_str(),
entry.email.as_str(),
entry.avatar_url.as_str(),
] {
assert!(
!rendered.contains(sensitive),
"storage error exposed sensitive field {sensitive:?}: {rendered}"
);
}
}
#[tokio::test]
async fn persistence_contains_hash_but_not_raw_code() {
let (directory, store) = test_support::sqlite_auth_code_store().await;
let raw_code = "raw-authorization-code-that-must-never-be-persisted";
store
.issue(raw_code, &pending(Utc::now() + Duration::seconds(60)))
.await
.unwrap();
let persisted_hash: Vec<u8> =
sqlx::query_scalar("SELECT code_hash FROM oauth_authorization_codes")
.fetch_one(&store.pool)
.await
.unwrap();
let expected_hash: [u8; 32] = Sha256::digest(raw_code.as_bytes()).into();
assert_eq!(persisted_hash, expected_hash);
sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
.execute(&store.pool)
.await
.unwrap();
store.pool.close().await;
let bytes = fs::read(directory.path().join("fabro.sqlite3"))
.await
.unwrap();
assert!(
!bytes
.windows(raw_code.len())
.any(|window| window == raw_code.as_bytes())
);
}
#[tokio::test]
async fn invalid_stored_timestamp_is_typed() {
let (_directory, store) = test_support::sqlite_auth_code_store().await;
store
.issue(
"invalid-timestamp-code",
&pending(Utc::now() + Duration::seconds(60)),
)
.await
.unwrap();
sqlx::query("UPDATE oauth_authorization_codes SET expires_at_ms = ?")
.bind(i64::MAX)
.execute(&store.pool)
.await
.unwrap();
let err = store
.consume("invalid-timestamp-code", Utc::now())
.await
.unwrap_err();
assert!(matches!(err, Error::InvalidStoredTimestamp {
record: "pending CLI authorization",
field: "expires_at_ms",
value: i64::MAX,
}));
}
}

View file

@ -11,7 +11,9 @@ use sqlx::sqlite::SqliteRow;
use sqlx::{Row as _, SqlitePool};
use uuid::Uuid;
use crate::{Error, Result};
use crate::{Error, Result, sqlite_row};
const RECORD_NAME: &str = "auth session";
/// A CLI auth session: one rotation chain, owned by one identity.
#[derive(Debug, Clone, PartialEq, Eq)]
@ -176,7 +178,8 @@ INSERT INTO auth_sessions (
.await?
.into_iter()
.map(|row| {
let expires_at = timestamp_from_row(&row, "expires_at_ms")?;
let expires_at =
sqlite_row::timestamp_from_row(&row, RECORD_NAME, "expires_at_ms")?;
Ok(ActiveCliSession {
session: session_from_row(&row)?,
expires_at,
@ -376,34 +379,19 @@ async fn load_session(
}
fn session_from_row(row: &SqliteRow) -> Result<AuthSessionRecord> {
let identity = IdpIdentity::new(
row.try_get::<String, _>("identity_issuer")?,
row.try_get::<String, _>("identity_subject")?,
)
.map_err(|err| {
Error::Other(format!(
"stored auth session has an invalid identity: {err}"
))
})?;
Ok(AuthSessionRecord {
id: parse_uuid(&row.try_get::<String, _>("id")?)?,
identity,
login: row.try_get("login")?,
name: row.try_get("name")?,
email: row.try_get("email")?,
avatar_url: row.try_get("avatar_url")?,
user_agent: row.try_get("user_agent")?,
created_at: timestamp_from_row(row, "created_at_ms")?,
last_used_at: timestamp_from_row(row, "last_used_at_ms")?,
id: parse_uuid(&row.try_get::<String, _>("id")?)?,
identity: sqlite_row::identity_from_row(row, RECORD_NAME)?,
login: row.try_get("login")?,
name: row.try_get("name")?,
email: row.try_get("email")?,
avatar_url: row.try_get("avatar_url")?,
user_agent: row.try_get("user_agent")?,
created_at: sqlite_row::timestamp_from_row(row, RECORD_NAME, "created_at_ms")?,
last_used_at: sqlite_row::timestamp_from_row(row, RECORD_NAME, "last_used_at_ms")?,
})
}
fn timestamp_from_row(row: &SqliteRow, column: &str) -> Result<DateTime<Utc>> {
let millis: i64 = row.try_get(column)?;
DateTime::from_timestamp_millis(millis)
.ok_or_else(|| Error::Other(format!("stored auth session has an invalid {column}")))
}
fn parse_uuid(value: &str) -> Result<Uuid> {
Uuid::parse_str(value)
.map_err(|err| Error::Other(format!("stored auth session has an invalid id: {err}")))

View file

@ -31,6 +31,7 @@ impl Record for Blob {
const PREFIX: &'static str = "blobs/sha256";
#[cfg(test)]
fn id(&self) -> Self::Id {
BlobHash::new(&self.0)
}

View file

@ -1,4 +1,4 @@
use fabro_types::BlobHash;
use fabro_types::{BlobHash, IdpIdentityError};
pub type Result<T> = std::result::Result<T, Error>;
@ -12,6 +12,18 @@ pub enum Error {
Serde(#[from] serde_json::Error),
#[error("SQLite error: {0}")]
Sqlite(#[from] sqlx::Error),
#[error("stored {record} has an invalid identity")]
InvalidStoredIdentity {
record: &'static str,
#[source]
source: IdpIdentityError,
},
#[error("stored {record} has an invalid {field} timestamp: {value}")]
InvalidStoredTimestamp {
record: &'static str,
field: &'static str,
value: i64,
},
#[error("stored blob {blob_hash} has bytes that conflict with its hash")]
BlobHashConflict { blob_hash: BlobHash },
#[error("stored blob data does not match requested hash {blob_hash}")]
@ -44,6 +56,18 @@ pub enum Error {
run_id: String,
field: &'static str,
},
#[error("run {run_id} head mismatch: expected {expected_last_seq}, stored {actual_last_seq:?}")]
RunHeadMismatch {
run_id: String,
expected_last_seq: u32,
actual_last_seq: Option<u32>,
},
#[error("stored run event {run_id} sequence {seq} has inconsistent field {field}")]
RunEventMismatch {
run_id: String,
seq: u32,
field: &'static str,
},
#[error(transparent)]
InvalidTransition(#[from] fabro_types::InvalidTransition),
#[error("{0}")]

View file

@ -1128,7 +1128,7 @@ mod tests {
PASSIVE_CHECKPOINT_BYTES, set_automatic_checkpoint,
};
use crate::keys::SlateKey;
use crate::{BlobStore, Database};
use crate::{BlobStore, Database, test_support as store_test_support};
type TestResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@ -1153,6 +1153,7 @@ mod tests {
Duration::from_millis(1),
None,
Arc::clone(&target),
store_test_support::test_run_summary_store(),
);
let source_db = source.open_db().await?;
Ok(Self {
@ -1853,6 +1854,7 @@ mod tests {
Duration::from_millis(1),
None,
Arc::clone(&target),
store_test_support::test_run_summary_store(),
);
let mut connection = pool.acquire().await?;

View file

@ -1,6 +1,7 @@
use chrono::{DateTime, Utc};
mod artifact_store;
mod auth_code_store;
pub mod auth_session_store;
mod blob_store;
mod error;
@ -13,6 +14,7 @@ mod run_state;
mod run_summary_store;
mod serializable_projection;
mod slate;
mod sqlite_row;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
mod types;
@ -21,6 +23,7 @@ pub use artifact_store::{
ArtifactKey, ArtifactStore, NodeArtifact, StageArtifactEntry, retry_storage_segment,
stage_storage_segment,
};
pub use auth_code_store::{AuthCodeStore, PendingCliAuthorization};
pub use auth_session_store::{
ActiveCliSession, AuthSessionRecord, AuthSessionStore, InitialRefreshToken, RotateOutcome,
};
@ -44,10 +47,7 @@ pub use run_summary_store::{
RunSummarySortDirection, RunSummaryStore, RunSummaryVisibility,
};
pub use serializable_projection::SerializableProjection;
pub use slate::{
AuthCode, AuthCodeStore, CachedRunProjection, Database, RunCatalogIndex, RunDatabase, Runs,
UnreadableRun,
};
pub use slate::{CachedRunProjection, Database, RunCatalogIndex, RunDatabase, Runs, UnreadableRun};
pub use types::EventPayload;
#[derive(Debug, Default, Clone, PartialEq, Eq)]

View file

@ -1,5 +1,7 @@
use bytes::Bytes;
#[cfg(test)]
use serde::Serialize;
#[cfg(test)]
use serde::de::DeserializeOwned;
use crate::{Error, Result};
@ -10,8 +12,10 @@ pub(crate) trait Codec<R>: Send + Sync + 'static {
fn decode(bytes: &[u8]) -> Result<R>;
}
#[cfg(test)]
pub(crate) struct JsonCodec;
#[cfg(test)]
impl<R> Codec<R> for JsonCodec
where
R: Serialize + DeserializeOwned,

View file

@ -4,18 +4,19 @@
//! - [`Record`]: declares the key prefix, id type, and codec for one persisted
//! type.
//! - [`RecordId`]: converts the typed id to and from key segments.
//! - [`Repository`]: performs the generic get/put/delete/scan/gc operations.
//! - [`Repository`]: performs the generic get/put/delete/scan operations.
//!
//! Production callers should add a named domain store on top of this layer
//! rather than exposing `Repository<R>` directly. See `slate/auth_codes.rs`,
//! `slate/blob_store.rs`, and `slate/run_catalog_index.rs` for the intended
//! pattern.
//! rather than exposing `Repository<R>` directly. See `slate/blob_store.rs`
//! and `slate/run_catalog_index.rs` for the intended pattern.
mod codec;
mod record_id;
mod repository;
pub(crate) use codec::{Codec, JsonCodec, MarkerCodec, RawBytesCodec};
#[cfg(test)]
pub(crate) use codec::JsonCodec;
pub(crate) use codec::{Codec, MarkerCodec, RawBytesCodec};
pub(crate) use repository::Repository;
use crate::Result;
@ -26,6 +27,7 @@ pub(crate) trait Record: Sized + Send + Sync + 'static {
const PREFIX: &'static str;
#[cfg(test)]
fn id(&self) -> Self::Id;
}

View file

@ -52,13 +52,12 @@
//! async fn get(&self, id: &str) -> Result<Option<Session>> {
//! self.repo.get(&id.to_string()).await
//! }
//!
//! async fn gc_expired(&self, now: DateTime<Utc>) -> Result<u64> {
//! self.repo.gc(|session| session.expires_at <= now).await
//! }
//! }
//! ```
//!
//! `JsonCodec` is currently compiled only for tests; un-gate it when the
//! first production JSON-encoded record type appears.
//!
//! Keep `Repository<R>` internal. Domain-specific invariants such as consume
//! locks, token rotation, or marker-only behavior belong in the named store
//! that wraps it, not in this generic layer.
@ -69,7 +68,7 @@ use std::sync::Arc;
use futures::stream::{self};
use futures::{Stream, StreamExt};
use slatedb::{Db, KeyValue, WriteBatch};
use slatedb::{Db, KeyValue};
use super::{Codec, Record, RecordId};
use crate::{Error, Result, keys};
@ -78,7 +77,7 @@ use crate::{Error, Result, keys};
/// stores.
///
/// This type is intentionally `pub(crate)`: callers should interact through a
/// named store such as `AuthCodeStore` or `BlobStore`, which can add
/// named store such as `RunCatalogIndex` or `BlobStore`, which can add
/// domain-specific behavior on top of the generic storage primitives here.
pub(crate) struct Repository<R: Record> {
db: Arc<Db>,
@ -94,6 +93,7 @@ impl<R: Record> Repository<R> {
}
}
#[cfg(test)]
pub(crate) async fn get(&self, id: &R::Id) -> Result<Option<R>> {
self.db
.get(key_for_id::<R>(id)?)
@ -102,6 +102,7 @@ impl<R: Record> Repository<R> {
.transpose()
}
#[cfg(test)]
pub(crate) async fn put(&self, record: &R) -> Result<()> {
let id = record.id();
self.put_at(&id, record).await
@ -160,29 +161,6 @@ impl<R: Record> Repository<R> {
Err(err) => Box::pin(stream::once(async move { Err(err) })),
}
}
pub(crate) async fn gc<F>(&self, predicate: F) -> Result<u64>
where
F: Fn(&R) -> bool + Send + Sync,
{
let mut iter = self.db.scan_prefix(prefix_key::<R>(&[])?).await?;
let mut batch = WriteBatch::new();
let mut deletes = 0_u64;
while let Some(entry) = iter.next().await? {
let value = R::Codec::decode(&entry.value)?;
if predicate(&value) {
batch.delete(entry.key);
deletes += 1;
}
}
if deletes > 0 {
self.db.write(batch).await?;
}
Ok(deletes)
}
}
pub(crate) type RepositoryStream<'a, T> = Pin<Box<dyn Stream<Item = Result<T>> + Send + 'a>>;
@ -467,25 +445,6 @@ mod tests {
assert!(repo.get(&saved.id()).await.unwrap().is_none());
}
#[tokio::test]
async fn gc_deletes_matching_records() {
let repo = Repository::<TestRecord>::new(db().await);
for record in [
record("bucket-a", "keep", false),
record("bucket-a", "delete", true),
record("bucket-b", "keep", false),
record("bucket-b", "delete", true),
] {
repo.put(&record).await.unwrap();
}
assert_eq!(repo.gc(|record| record.delete_me).await.unwrap(), 2);
let remaining = repo.scan_stream().try_collect::<Vec<_>>().await.unwrap();
assert_eq!(remaining.len(), 2);
assert!(remaining.iter().all(|(_, record)| !record.delete_me));
}
#[tokio::test]
async fn marker_records_use_put_at_exists_and_scan_ids() {
let repo = Repository::<TestMarker>::new(db().await);

File diff suppressed because it is too large Load diff

View file

@ -1,214 +0,0 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use fabro_types::IdpIdentity;
use serde::{Deserialize, Serialize};
use crate::record::{JsonCodec, Record, Repository};
use crate::{KeyedMutex, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthCode {
pub code: String,
pub identity: IdpIdentity,
pub login: String,
pub name: String,
pub email: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub avatar_url: String,
pub code_challenge: String,
pub redirect_uri: String,
pub expires_at: DateTime<Utc>,
}
impl Record for AuthCode {
type Id = String;
type Codec = JsonCodec;
const PREFIX: &'static str = "auth/code";
fn id(&self) -> Self::Id {
self.code.clone()
}
}
pub struct AuthCodeStore {
repo: Repository<AuthCode>,
consume_locks: KeyedMutex<String>,
}
impl std::fmt::Debug for AuthCodeStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthCodeStore").finish_non_exhaustive()
}
}
impl AuthCodeStore {
pub(crate) fn new(db: Arc<slatedb::Db>) -> Self {
Self {
repo: Repository::new(db),
consume_locks: KeyedMutex::new(),
}
}
pub async fn insert(&self, entry: AuthCode) -> Result<()> {
self.repo.put(&entry).await
}
pub async fn consume(&self, code: &str) -> Result<Option<AuthCode>> {
let code = code.to_string();
let _guard = self.consume_locks.lock(code.clone()).await;
let entry = self.repo.get(&code).await?;
let result = match entry {
Some(entry) if entry.expires_at > Utc::now() => {
self.repo.delete(&code).await?;
Some(entry)
}
Some(_) => {
self.repo.delete(&code).await?;
None
}
None => None,
};
Ok(result)
}
pub async fn gc_expired(&self, cutoff: DateTime<Utc>) -> Result<u64> {
self.repo
.gc(|auth_code| auth_code.expires_at <= cutoff)
.await
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use chrono::Duration as ChronoDuration;
use object_store::memory::InMemory;
use tokio::task::JoinSet;
use super::{AuthCode, AuthCodeStore};
use crate::test_support;
async fn store() -> Arc<AuthCodeStore> {
let db = test_support::test_database(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
);
db.auth_codes().await.unwrap()
}
fn auth_code(code: &str, expires_at: chrono::DateTime<chrono::Utc>) -> AuthCode {
AuthCode {
code: code.to_string(),
identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
login: "octocat".to_string(),
name: "The Octocat".to_string(),
email: "octocat@example.com".to_string(),
avatar_url: String::new(),
code_challenge: "challenge".to_string(),
redirect_uri: "http://127.0.0.1/callback".to_string(),
expires_at,
}
}
#[tokio::test]
async fn insert_and_consume_is_single_use() {
let store = store().await;
store
.insert(auth_code(
"code-1",
chrono::Utc::now() + ChronoDuration::seconds(60),
))
.await
.unwrap();
assert!(store.consume("code-1").await.unwrap().is_some());
assert!(store.consume("code-1").await.unwrap().is_none());
}
#[test]
fn deserializes_legacy_json_without_avatar_url() {
let entry: AuthCode = serde_json::from_value(serde_json::json!({
"code": "legacy-code",
"identity": {
"issuer": "https://github.com",
"subject": "12345"
},
"login": "octocat",
"name": "The Octocat",
"email": "octocat@example.com",
"code_challenge": "challenge",
"redirect_uri": "http://127.0.0.1/callback",
"expires_at": "2026-01-01T00:00:00Z"
}))
.unwrap();
assert_eq!(entry.avatar_url, "");
}
#[test]
fn serializes_avatar_url_when_present() {
let mut entry = auth_code("avatar-code", chrono::Utc::now());
entry.avatar_url = "https://example.com/octocat.png".to_string();
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["avatar_url"], "https://example.com/octocat.png");
}
#[tokio::test]
async fn concurrent_consume_has_one_winner() {
let store = store().await;
store
.insert(auth_code(
"code-2",
chrono::Utc::now() + ChronoDuration::seconds(60),
))
.await
.unwrap();
let mut tasks = JoinSet::new();
for _ in 0..16 {
let store = Arc::clone(&store);
tasks.spawn(async move { store.consume("code-2").await.unwrap().is_some() });
}
let mut successes = 0;
while let Some(result) = tasks.join_next().await {
if result.unwrap() {
successes += 1;
}
}
assert_eq!(successes, 1);
}
#[tokio::test]
async fn gc_expired_removes_only_expired_codes() {
let store = store().await;
store
.insert(auth_code(
"expired",
chrono::Utc::now() - ChronoDuration::seconds(1),
))
.await
.unwrap();
store
.insert(auth_code(
"live",
chrono::Utc::now() + ChronoDuration::seconds(60),
))
.await
.unwrap();
assert_eq!(store.gc_expired(chrono::Utc::now()).await.unwrap(), 1);
assert!(store.consume("expired").await.unwrap().is_none());
assert!(store.consume("live").await.unwrap().is_some());
}
}

View file

@ -1,14 +1,12 @@
mod auth_codes;
mod projection_cache;
mod run_catalog_index;
mod run_store;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::sync::Arc;
use std::time::Duration;
pub use auth_codes::{AuthCode, AuthCodeStore};
use chrono::{DateTime, Utc};
use fabro_types::{Run, RunId, SessionId};
use object_store::ObjectStore;
@ -45,10 +43,9 @@ pub struct Database {
active_runs: Arc<Mutex<HashMap<RunId, Arc<RunDatabaseInner>>>>,
blobs: Arc<BlobStore>,
catalog_index: Arc<OnceCell<Arc<RunCatalogIndex>>>,
auth_codes: Arc<OnceCell<Arc<AuthCodeStore>>>,
projection_cache: Arc<RunProjectionCache>,
projection_cache_warmed: Arc<OnceCell<()>>,
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
run_summary_store: Arc<RunSummaryStore>,
}
impl std::fmt::Debug for Database {
@ -68,6 +65,7 @@ impl Database {
flush_interval: Duration,
cache_path: Option<PathBuf>,
blobs: Arc<BlobStore>,
run_summary_store: Arc<RunSummaryStore>,
) -> Self {
Self {
object_store,
@ -78,19 +76,15 @@ impl Database {
active_runs: Arc::new(Mutex::new(HashMap::new())),
blobs,
catalog_index: Arc::new(OnceCell::new()),
auth_codes: Arc::new(OnceCell::new()),
projection_cache: Arc::new(RunProjectionCache::default()),
projection_cache_warmed: Arc::new(OnceCell::new()),
run_summary_store: Arc::new(OnceLock::new()),
run_summary_store,
}
}
pub fn attach_run_summary_store(&self, store: Arc<RunSummaryStore>) -> Arc<RunSummaryStore> {
Arc::clone(self.run_summary_store.get_or_init(|| store))
}
fn run_summary_store(&self) -> Option<Arc<RunSummaryStore>> {
self.run_summary_store.get().cloned()
#[must_use]
pub fn run_summary_store(&self) -> Arc<RunSummaryStore> {
Arc::clone(&self.run_summary_store)
}
fn shared_db_prefix(&self) -> String {
@ -146,7 +140,7 @@ impl Database {
read_only,
self.blobs(),
Arc::clone(&self.projection_cache),
Arc::clone(&self.run_summary_store),
self.run_summary_store(),
)
.await
}
@ -244,9 +238,7 @@ impl Database {
}
}
}
if let Some(store) = self.run_summary_store() {
store.reconcile(&entries).await?;
}
self.run_summary_store.reconcile(&entries).await?;
self.projection_cache.replace_all(entries).await;
Ok::<_, Error>(())
})
@ -393,9 +385,7 @@ impl Database {
self.delete_session_indexes_for_run(run_id).await?;
self.catalog_index().await?.remove(run_id).await?;
self.remove_cached_run(run_id).await;
if let Some(store) = self.run_summary_store() {
store.delete(run_id).await?;
}
self.run_summary_store.delete(run_id).await?;
Ok(())
}
@ -417,17 +407,6 @@ impl Database {
Ok(())
}
pub async fn auth_codes(&self) -> Result<Arc<AuthCodeStore>> {
let store = self
.auth_codes
.get_or_try_init(|| async {
let db = Arc::new(self.open_db().await?);
Ok::<_, Error>(Arc::new(AuthCodeStore::new(db)))
})
.await?;
Ok(Arc::clone(store))
}
pub async fn catalog_index(&self) -> Result<Arc<RunCatalogIndex>> {
let store = self
.catalog_index
@ -573,6 +552,21 @@ mod tests {
(object_store, store)
}
fn make_store_with_run_summaries(
run_summaries: Arc<RunSummaryStore>,
) -> (Arc<dyn ObjectStore>, Database) {
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let store = store_test_support::test_database_with_stores(
object_store.clone(),
"runs/",
Duration::from_millis(1),
None,
store_test_support::test_blob_store(),
run_summaries,
);
(object_store, store)
}
#[tokio::test]
async fn retire_refresh_token_keyspace_clears_the_prefix_and_is_idempotent() {
let (_object_store, store) = make_store();
@ -585,8 +579,8 @@ mod tests {
.as_ref()
.to_vec()
});
// "auth/code" sorts adjacent to "auth/refresh" and is still live, so
// it is the neighbour a too-wide prefix delete would take with it.
// "auth/code" sorts adjacent to "auth/refresh", so it is the
// neighbour a too-wide prefix delete would take with it.
let auth_code_key = keys::SlateKey::new("auth")
.with("code")
.with("keep")
@ -611,8 +605,8 @@ mod tests {
);
}
async fn make_summary_store() -> (tempfile::TempDir, Arc<RunSummaryStore>) {
let (directory, store) = store_test_support::sqlite_summary_store().await;
async fn make_run_summary_store() -> (tempfile::TempDir, Arc<RunSummaryStore>) {
let (directory, store) = store_test_support::sqlite_run_summary_store().await;
(directory, Arc::new(store))
}
@ -987,9 +981,8 @@ mod tests {
#[tokio::test]
async fn rejected_transition_leaves_reconciled_summary_present() {
let (_object_store, store) = make_store();
let (_directory, summaries) = make_summary_store().await;
store.attach_run_summary_store(Arc::clone(&summaries));
let (_directory, summaries) = make_run_summary_store().await;
let (_object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries));
let run_id = test_run_id("run-1");
let run = store.create_run(&run_id).await.unwrap();
append_runnable(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
@ -1010,10 +1003,9 @@ mod tests {
}
#[tokio::test]
async fn committed_append_succeeds_when_summary_update_fails_and_is_repairable() {
let (object_store, store) = make_store();
let (directory, summaries) = make_summary_store().await;
store.attach_run_summary_store(Arc::clone(&summaries));
async fn best_effort_run_summary_update_failure_keeps_slate_append_repairable() {
let (directory, summaries) = make_run_summary_store().await;
let (object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries));
let run_id = test_run_id("run-1");
let run = store.create_run(&run_id).await.unwrap();
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
@ -1037,7 +1029,7 @@ mod tests {
assert_eq!(stored.event, result.unwrap().event);
let repaired_summaries =
Arc::new(store_test_support::sqlite_summary_store_at(directory.path()).await);
Arc::new(store_test_support::sqlite_run_summary_store_at(directory.path()).await);
let stale = repaired_summaries
.get(&run_id, Utc::now())
.await
@ -1045,13 +1037,14 @@ mod tests {
.unwrap();
assert_ne!(stale.title, "Committed title");
let reopened = store_test_support::test_database(
let reopened = store_test_support::test_database_with_stores(
object_store,
"runs/",
Duration::from_millis(1),
None,
store_test_support::test_blob_store(),
Arc::clone(&repaired_summaries),
);
reopened.attach_run_summary_store(Arc::clone(&repaired_summaries));
reopened.warm_projection_cache().await.unwrap();
let repaired = repaired_summaries
.get(&run_id, Utc::now())
@ -1672,10 +1665,9 @@ mod tests {
}
#[tokio::test]
async fn append_event_refreshes_projection_cache_and_delete_removes_it() {
let (_object_store, store) = make_store();
let (_directory, summaries) = make_summary_store().await;
store.attach_run_summary_store(Arc::clone(&summaries));
async fn required_run_summary_append_refreshes_cache_and_delete_removes_rows() {
let (_directory, summaries) = make_run_summary_store().await;
let (_object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries));
let run = store.create_run(&test_run_id("run-1")).await.unwrap();
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
store.warm_projection_cache().await.unwrap();
@ -1849,15 +1841,20 @@ mod tests {
}
#[tokio::test]
async fn projection_cache_warmup_backfills_sqlite_run_summaries() {
async fn required_run_summary_warmup_backfills_sqlite_run_summaries() {
let (object_store, store) = make_store();
let run = store.create_run(&test_run_id("run-1")).await.unwrap();
append_completed(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
let reopened =
store_test_support::test_database(object_store, "runs", Duration::from_millis(1), None);
let (_directory, summaries) = make_summary_store().await;
reopened.attach_run_summary_store(Arc::clone(&summaries));
let (_directory, summaries) = make_run_summary_store().await;
let reopened = store_test_support::test_database_with_stores(
object_store,
"runs",
Duration::from_millis(1),
None,
store_test_support::test_blob_store(),
Arc::clone(&summaries),
);
reopened.warm_projection_cache().await.unwrap();
let summary = summaries

View file

@ -16,6 +16,7 @@ impl Record for RunCatalogEntry {
const PREFIX: &'static str = "runs/_index/by-start";
#[cfg(test)]
fn id(&self) -> Self::Id {
unreachable!("marker records must use put_at")
}

View file

@ -1,6 +1,6 @@
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, OnceLock};
use bytes::Bytes;
use chrono::Utc;
@ -45,9 +45,7 @@ pub(crate) struct RunDatabaseInner {
state_lock: Mutex<()>,
projection_cache: Mutex<EventProjectionCache>,
shared_projection_cache: Arc<RunProjectionCache>,
// Shared cell rather than a snapshot so a summary store attached after
// this writer opened is still picked up by later appends.
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
run_summary_store: Arc<RunSummaryStore>,
recent_events: Mutex<VecDeque<EventEnvelope>>,
recent_event_limit: usize,
event_tx: broadcast::Sender<EventEnvelope>,
@ -60,7 +58,7 @@ impl RunDatabase {
read_only: bool,
blob_store: Arc<BlobStore>,
shared_projection_cache: Arc<RunProjectionCache>,
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
run_summary_store: Arc<RunSummaryStore>,
) -> Result<Self> {
let cached_projection = shared_projection_cache.projection_snapshot(&run_id).await;
let projection_cache = cached_projection.as_ref().map_or_else(
@ -231,15 +229,13 @@ impl RunDatabase {
}
async fn update_summary_after_committed_append(&self, cached: &CachedRunProjection) {
if let Some(store) = self.inner.run_summary_store.get() {
if let Err(err) = store.upsert_projection(cached).await {
warn!(
run_id = %self.inner.run_id,
source_last_seq = cached.last_seq,
error = ?err,
"failed to update SQLite run summary after committed append"
);
}
if let Err(err) = self.inner.run_summary_store.upsert_projection(cached).await {
warn!(
run_id = %self.inner.run_id,
source_last_seq = cached.last_seq,
error = ?err,
"failed to update SQLite run summary after committed append"
);
}
}

View file

@ -0,0 +1,31 @@
//! Shared decoding helpers for columns the SQLite-backed stores have in
//! common. `record` names the stored domain type (e.g. "auth session") so
//! corruption errors say which table failed without repeating the schema.
use chrono::{DateTime, Utc};
use fabro_types::IdpIdentity;
use sqlx::Row as _;
use sqlx::sqlite::SqliteRow;
use crate::{Error, Result};
pub(crate) fn identity_from_row(row: &SqliteRow, record: &'static str) -> Result<IdpIdentity> {
IdpIdentity::new(
row.try_get::<String, _>("identity_issuer")?,
row.try_get::<String, _>("identity_subject")?,
)
.map_err(|source| Error::InvalidStoredIdentity { record, source })
}
pub(crate) fn timestamp_from_row(
row: &SqliteRow,
record: &'static str,
field: &'static str,
) -> Result<DateTime<Utc>> {
let value: i64 = row.try_get(field)?;
DateTime::from_timestamp_millis(value).ok_or(Error::InvalidStoredTimestamp {
record,
field,
value,
})
}

View file

@ -8,8 +8,8 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use crate::keys::SlateKey;
#[cfg(test)]
use crate::{AuthSessionStore, RunSummaryStore};
use crate::{BlobStore, Database, Result};
use crate::{AuthCodeStore, AuthSessionStore};
use crate::{BlobStore, Database, Result, RunSummaryStore};
/// Returns an isolated SQLite blob authority backed by its own in-memory
/// database.
@ -18,32 +18,48 @@ use crate::{BlobStore, Database, Result};
/// by other tests in the same process. Reopen-style tests that model one
/// process-wide blob authority across several store handles should call this
/// once and share the result through [`test_database_with_blobs`].
///
/// The pool connects lazily so synchronous fixture builders can remain
/// synchronous. Its single connection installs the production blob schema on
/// first use.
#[must_use]
pub fn test_blob_store() -> Arc<BlobStore> {
Arc::new(BlobStore::new(lazy_in_memory_pool(&[
fabro_db::BLOBS_MIGRATION_SQL,
])))
}
/// Returns an isolated SQLite run-summary store backed by its own in-memory
/// database and the production `runs` and `run_events` schemas.
#[must_use]
pub fn test_run_summary_store() -> Arc<RunSummaryStore> {
Arc::new(RunSummaryStore::new(lazy_in_memory_pool(&[
fabro_db::RUNS_MIGRATION_SQL,
fabro_db::RUN_EVENTS_MIGRATION_SQL,
])))
}
/// Builds a single-connection in-memory SQLite pool that installs
/// `migrations` on first use.
///
/// The pool connects lazily so synchronous fixture builders can remain
/// synchronous.
fn lazy_in_memory_pool(migrations: &'static [&'static str]) -> sqlx::SqlitePool {
let options = SqliteConnectOptions::new()
.filename(":memory:")
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
SqlitePoolOptions::new()
.max_connections(1)
// A single in-memory test connection never needs reaping. Disabling
// both timers also keeps this lazy fixture constructible from sync
// tests, where SQLx has no Tokio runtime for maintenance tasks.
.max_lifetime(None)
.idle_timeout(None)
.after_connect(|connection, _metadata| {
.after_connect(move |connection, _metadata| {
Box::pin(async move {
sqlx::query(fabro_db::BLOBS_MIGRATION_SQL)
.execute(&mut *connection)
.await?;
for migration in migrations {
sqlx::raw_sql(*migration).execute(&mut *connection).await?;
}
Ok(())
})
})
.connect_lazy_with(options);
Arc::new(BlobStore::new(pool))
.connect_lazy_with(options)
}
/// Returns the SQLite file backing [`test_blob_store_at`] for `store_dir`.
@ -119,7 +135,37 @@ pub fn test_database_with_blobs(
cache_path: Option<PathBuf>,
blobs: Arc<BlobStore>,
) -> Database {
Database::new(object_store, base_prefix, flush_interval, cache_path, blobs)
test_database_with_stores(
object_store,
base_prefix,
flush_interval,
cache_path,
blobs,
test_run_summary_store(),
)
}
/// Builds a Slate-backed run database with explicit shared SQLite stores.
///
/// Use this only when a test needs a failing, persistent, or shared store;
/// ordinary fixtures should use [`test_database`].
#[must_use]
pub fn test_database_with_stores(
object_store: Arc<dyn ObjectStore>,
base_prefix: impl Into<String>,
flush_interval: Duration,
cache_path: Option<PathBuf>,
blobs: Arc<BlobStore>,
run_summaries: Arc<RunSummaryStore>,
) -> Database {
Database::new(
object_store,
base_prefix,
flush_interval,
cache_path,
blobs,
run_summaries,
)
}
/// Seeds one canonical row in the legacy SlateDB blob keyspace.
@ -146,28 +192,38 @@ pub async fn put_unvalidated_run_event(
.await
}
/// Connects to a migrated `fabro.sqlite3` in `directory` and returns its pool.
#[cfg(test)]
pub(crate) async fn sqlite_auth_session_store() -> (tempfile::TempDir, AuthSessionStore) {
let directory = tempfile::tempdir().unwrap();
let database = fabro_db::Database::connect(directory.path().join("fabro.sqlite3"))
.await
.unwrap();
database.migrate().await.unwrap();
(directory, AuthSessionStore::new(database.clone_pool()))
}
#[cfg(test)]
pub(crate) async fn sqlite_summary_store() -> (tempfile::TempDir, RunSummaryStore) {
let directory = tempfile::tempdir().unwrap();
let store = sqlite_summary_store_at(directory.path()).await;
(directory, store)
}
#[cfg(test)]
pub(crate) async fn sqlite_summary_store_at(directory: &Path) -> RunSummaryStore {
async fn sqlite_test_pool(directory: &Path) -> sqlx::SqlitePool {
let database = fabro_db::Database::connect(directory.join("fabro.sqlite3"))
.await
.unwrap();
database.migrate().await.unwrap();
RunSummaryStore::new(database.clone_pool())
database.clone_pool()
}
#[cfg(test)]
pub(crate) async fn sqlite_auth_session_store() -> (tempfile::TempDir, AuthSessionStore) {
let directory = tempfile::tempdir().unwrap();
let store = AuthSessionStore::new(sqlite_test_pool(directory.path()).await);
(directory, store)
}
#[cfg(test)]
pub(crate) async fn sqlite_auth_code_store() -> (tempfile::TempDir, AuthCodeStore) {
let directory = tempfile::tempdir().unwrap();
let store = AuthCodeStore::new(sqlite_test_pool(directory.path()).await);
(directory, store)
}
#[cfg(test)]
pub(crate) async fn sqlite_run_summary_store() -> (tempfile::TempDir, RunSummaryStore) {
let directory = tempfile::tempdir().unwrap();
let store = sqlite_run_summary_store_at(directory.path()).await;
(directory, store)
}
#[cfg(test)]
pub(crate) async fn sqlite_run_summary_store_at(directory: &Path) -> RunSummaryStore {
RunSummaryStore::new(sqlite_test_pool(directory).await)
}

View file

@ -0,0 +1,163 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
/// `auto_status=true` is the deprecated spelling of `on_failure="succeed"`.
/// The runtime still honors it as an alias; this rule points workflows at the
/// explicit policy.
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"auto_status_deprecated"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut nodes: Vec<_> = graph
.nodes
.values()
.filter(|node| node.attrs.contains_key("auto_status"))
.collect();
nodes.sort_unstable_by(|a, b| a.id.cmp(&b.id));
nodes
.into_iter()
.map(|node| {
let (message, fix) = if node.attrs.contains_key("on_failure") {
(
format!(
"Node '{}' sets deprecated 'auto_status', which is ignored because \
'on_failure' is set",
node.id
),
"Remove 'auto_status'",
)
} else if node.auto_status() {
(
format!("Node '{}' sets deprecated 'auto_status=true'", node.id),
"Use on_failure=\"succeed\" instead",
)
} else {
(
format!(
"Node '{}' sets deprecated 'auto_status', which has no effect \
unless it is true",
node.id
),
"Remove 'auto_status'",
)
};
Diagnostic {
rule: self.name().to_string(),
severity: Severity::Warning,
message,
node_id: Some(node.id.clone()),
fix: Some(fix.to_string()),
..Diagnostic::default()
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Graph};
use super::Rule;
use crate::rules::test_support::{minimal_graph, node_with_attrs};
use crate::{LintRule, Severity};
fn graph_with_auto_status(value: AttrValue, on_failure: Option<&str>) -> Graph {
let mut graph = minimal_graph();
let mut node = match on_failure {
Some(policy) => node_with_attrs("work", &[("on_failure", policy)]),
None => node_with_attrs("work", &[]),
};
node.attrs.insert("auto_status".to_string(), value);
graph.nodes.insert("work".to_string(), node);
graph
}
#[test]
fn accepts_graphs_without_auto_status() {
let mut graph = minimal_graph();
graph.nodes.insert(
"work".to_string(),
node_with_attrs("work", &[("on_failure", "succeed")]),
);
assert!(Rule.apply(&graph).is_empty());
}
#[test]
fn warns_for_auto_status_true_and_suggests_succeed_policy() {
let graph = graph_with_auto_status(AttrValue::Boolean(true), None);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Warning);
assert_eq!(
diagnostics[0].message,
"Node 'work' sets deprecated 'auto_status=true'"
);
assert_eq!(diagnostics[0].node_id.as_deref(), Some("work"));
assert_eq!(
diagnostics[0].fix.as_deref(),
Some("Use on_failure=\"succeed\" instead")
);
}
#[test]
fn warns_that_auto_status_is_ignored_when_on_failure_is_set() {
let graph = graph_with_auto_status(AttrValue::Boolean(true), Some("exit"));
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Warning);
assert_eq!(
diagnostics[0].message,
"Node 'work' sets deprecated 'auto_status', which is ignored because 'on_failure' is set"
);
assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'"));
}
#[test]
fn warns_for_auto_status_false() {
let graph = graph_with_auto_status(AttrValue::Boolean(false), None);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Warning);
assert_eq!(
diagnostics[0].message,
"Node 'work' sets deprecated 'auto_status', which has no effect unless it is true"
);
assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'"));
}
#[test]
fn reports_nodes_in_id_order() {
let mut graph = minimal_graph();
for id in ["zeta", "alpha"] {
let mut node = node_with_attrs(id, &[]);
node.attrs
.insert("auto_status".to_string(), AttrValue::Boolean(true));
graph.nodes.insert(id.to_string(), node);
}
let ids: Vec<_> = Rule
.apply(&graph)
.into_iter()
.map(|diagnostic| diagnostic.node_id.unwrap())
.collect();
assert_eq!(ids, ["alpha", "zeta"]);
}
}

View file

@ -1,4 +1,5 @@
mod all_conditional_edges;
mod auto_status_deprecated;
mod backend_valid;
mod command_requires_script;
mod condition_syntax;
@ -14,6 +15,7 @@ mod inert_attribute;
mod join_policy_removed;
mod model_support;
mod node_model_known;
mod on_failure_valid;
mod orphan_custom_outcome;
mod parallel_branch;
mod parallel_branch_inert_attribute;
@ -63,6 +65,8 @@ pub fn built_in_rules() -> Vec<Box<dyn LintRule>> {
reserved_keyword_node_id::rule(),
all_conditional_edges::rule(),
orphan_custom_outcome::rule(),
on_failure_valid::rule(),
auto_status_deprecated::rule(),
script_absolute_cd::rule(),
command_requires_script::rule(),
import_error::rule(),

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