mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Compare commits
170 commits
v0.332.0-n
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd499cdbe | ||
|
|
57bbb923c2 | ||
|
|
ff2aef4564 | ||
|
|
dc55183468 | ||
|
|
b62e458289 | ||
|
|
039517a6a5 | ||
|
|
5ad2817da8 | ||
|
|
88185f0bd9 | ||
|
|
50fed849f3 | ||
|
|
a65c4ff779 | ||
|
|
56e759d470 | ||
|
|
8ed79b2a70 | ||
|
|
eaba019acb | ||
|
|
ce640b6ad3 | ||
|
|
fbba98defd | ||
|
|
34014e6dce | ||
|
|
ff29795dd8 | ||
|
|
6ac769c4cd | ||
|
|
2134f550a4 | ||
|
|
d8b28dfafd | ||
|
|
b3d112b206 | ||
|
|
e5046d8b1b | ||
|
|
a049f94042 | ||
|
|
24165b10f5 | ||
|
|
c90d195c2f | ||
|
|
1289144f03 | ||
|
|
105f180d3d | ||
|
|
69f9d40499 | ||
|
|
491babe5da | ||
|
|
74e2c3597c | ||
|
|
a522414bdc | ||
|
|
ca9fb1d262 | ||
|
|
9223349101 | ||
|
|
fb1ecfe23f | ||
|
|
9dc39ce9fa | ||
|
|
32d6be7ea5 | ||
|
|
15d1ef5b2c | ||
|
|
b5d517e4b5 | ||
|
|
b4092af89f | ||
|
|
5ead0145b9 | ||
|
|
c396a6cf6f | ||
|
|
679d20cb52 | ||
|
|
0001cfba02 | ||
|
|
e74f3c6c4b | ||
|
|
1465082447 | ||
|
|
4015754f15 | ||
|
|
f322025b3d | ||
|
|
b4fd7ae00b | ||
|
|
7ae7ca9ead | ||
|
|
bc7635dbd1 | ||
|
|
dc1f235c48 | ||
|
|
f3ff7f27a4 | ||
|
|
68ef8c7e89 | ||
|
|
a2f0167844 | ||
|
|
05999036aa | ||
|
|
3e0adde73d | ||
|
|
79168d3a27 | ||
|
|
300aec7f1c | ||
|
|
2d292c28f8 | ||
|
|
2f3b6477f2 | ||
|
|
330d0f8984 | ||
|
|
fb833294bf | ||
|
|
8a24046b94 | ||
|
|
c042b9abdc | ||
|
|
0e580f0a43 | ||
|
|
776e719383 | ||
|
|
f9f19213e6 | ||
|
|
f71d077221 | ||
|
|
5629dcd7d0 | ||
|
|
e067de9382 | ||
|
|
31c7a670d5 | ||
|
|
e1abecc9f4 | ||
|
|
bccc5750a4 | ||
|
|
94e3d46754 | ||
|
|
b56aee570c | ||
|
|
21421dce78 | ||
|
|
eb54a8d0f8 | ||
|
|
d65785d888 | ||
|
|
fafb1ed7cc | ||
|
|
2814c1fd45 | ||
|
|
1e284c625e | ||
|
|
2626e5ab4a | ||
|
|
533e1f4471 | ||
|
|
856e2fadd8 | ||
|
|
26b2c091ca | ||
|
|
2c7f46ab64 | ||
|
|
087d48f0c7 | ||
|
|
5606940aaf | ||
|
|
6e19fb2eec | ||
|
|
a28a0378dc | ||
|
|
401acb6cdf | ||
|
|
ddcdafa06b | ||
|
|
b51b80e16e | ||
|
|
fc822ab9b0 | ||
|
|
e2db53011e | ||
|
|
d978a6c89d | ||
|
|
96c6de0ab9 | ||
|
|
6b65f2a6af | ||
|
|
18d98794ae | ||
|
|
040bc6c043 | ||
|
|
b3f602f6e9 | ||
|
|
87b49a8527 | ||
|
|
4e31b79be0 | ||
|
|
de29af0a30 | ||
|
|
2bf86327c0 | ||
|
|
5878723723 | ||
|
|
901bb7a6a8 | ||
|
|
7200d437e9 | ||
|
|
a22d8f48a9 | ||
|
|
0c50ce641b | ||
|
|
ec5aeeb5c2 | ||
|
|
58f00c85c8 | ||
|
|
3217a05aad | ||
|
|
88ed2ac9a3 | ||
|
|
15862aac70 | ||
|
|
261dad0d58 | ||
|
|
d80dde2320 | ||
|
|
ab900485b6 | ||
|
|
cfa8ae92c0 | ||
|
|
61394ba2f1 | ||
|
|
0d028f9b1e | ||
|
|
45e06d2a6e | ||
|
|
ad4f9decb8 | ||
|
|
868d8857bf | ||
|
|
09f5bb0f84 | ||
|
|
129fa0ea0c | ||
|
|
fe1d9dc691 | ||
|
|
1669791956 | ||
|
|
6179470eb2 | ||
|
|
4c467cd6ba | ||
|
|
438bab29f0 | ||
|
|
47954f731e | ||
|
|
891eb43e9a | ||
|
|
68e3cb8419 | ||
|
|
53efde3930 | ||
|
|
84b75f29f1 | ||
|
|
d95b6cace1 | ||
|
|
d8edd410f3 | ||
|
|
e5e7875274 | ||
|
|
7bfed23153 | ||
|
|
f2047ad9a9 | ||
|
|
7de3b409ed | ||
|
|
3872c04430 | ||
|
|
b964602b0b | ||
|
|
7edef76d77 | ||
|
|
a08dff1ce8 | ||
|
|
6e65e93a2f | ||
|
|
9d3aa7a4d4 | ||
|
|
27fd48c603 | ||
|
|
2752a30450 | ||
|
|
75fa8eca8b | ||
|
|
5104a787ce | ||
|
|
57026cd4da | ||
|
|
db1faf02ec | ||
|
|
ff1ca976c3 | ||
|
|
ded92a215d | ||
|
|
a611e00fe6 | ||
|
|
ebf6f92724 | ||
|
|
e7a32d12d5 | ||
|
|
a35dacd46c | ||
|
|
65616b4557 | ||
|
|
8c45b870b4 | ||
|
|
e1a1ee6e05 | ||
|
|
7d48c88d61 | ||
|
|
644a598526 | ||
|
|
33268479d3 | ||
|
|
be6dd7df97 | ||
|
|
701708b32d | ||
|
|
c69a47c4e6 | ||
|
|
7b77011896 |
348 changed files with 29791 additions and 5066 deletions
|
|
@ -1 +1 @@
|
|||
678e75e2f35ae90e70c4b369f74ec202b168982d
|
||||
2bf86327c0afbc8a708e02c3fab58981ad53ad60
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
7ad164c45de64e7bafdadb26f519f6e0c8a00a42
|
||||
de29af0a30362c70c42f426e457e8a6d269534b2
|
||||
|
|
|
|||
14
AGENTS.md
14
AGENTS.md
|
|
@ -30,7 +30,19 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
|
|||
### Docker sandbox provider
|
||||
- Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities).
|
||||
- The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs.
|
||||
- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files.
|
||||
- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. For an exact commit, the submitted branch names the working branch and the syntactically valid SHA is requested directly. No layer proves branch/SHA ancestry: a fetchable commit is checked out, an unavailable commit fails setup, and branch HEAD is never substituted.
|
||||
- The sandbox layer also accepts an optional exact commit for future admitted
|
||||
runs. An exact commit always requires a non-empty branch. Docker initializes
|
||||
an empty repository, shallow-fetches the SHA at the same depth as a branch
|
||||
clone, and checks it out; Daytona uses its official SDK clone with both
|
||||
`branch` and `commit_id`. Both providers then point the admitted branch at
|
||||
the commit and verify HEAD, so the workspace still reports the admitted
|
||||
branch name. Keep those provider transports distinct, never fall back to a
|
||||
newer branch HEAD, and do not wire this capability directly from legacy
|
||||
`GitContext.sha`. The sandbox layer does not verify that the commit is
|
||||
reachable from the branch; admission owns that check. Current production
|
||||
callers remain branch-only until the RunIntent admission cutover supplies a
|
||||
validated branch/SHA pair.
|
||||
|
||||
### Release automation
|
||||
- `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.
|
||||
|
|
|
|||
187
Cargo.lock
generated
187
Cargo.lock
generated
|
|
@ -1870,7 +1870,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
|||
[[package]]
|
||||
name = "daytona-api-client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
|
||||
dependencies = [
|
||||
"reqwest 0.13.2",
|
||||
"reqwest-middleware",
|
||||
|
|
@ -1884,7 +1884,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "daytona-sdk"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
|
||||
dependencies = [
|
||||
"daytona-api-client",
|
||||
"daytona-toolbox-client",
|
||||
|
|
@ -1904,13 +1904,15 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "daytona-toolbox-client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
|
||||
dependencies = [
|
||||
"reqwest 0.13.2",
|
||||
"reqwest-middleware",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
|
@ -2072,7 +2074,7 @@ dependencies = [
|
|||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2199,7 +2201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2255,7 +2257,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-acp"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-tokio",
|
||||
|
|
@ -2274,7 +2276,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2298,6 +2300,7 @@ dependencies = [
|
|||
"futures",
|
||||
"glob",
|
||||
"htmd",
|
||||
"httpmock",
|
||||
"insta",
|
||||
"jsonschema",
|
||||
"libc",
|
||||
|
|
@ -2320,7 +2323,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-automation",
|
||||
|
|
@ -2343,7 +2346,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2368,7 +2371,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-automation"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2388,11 +2391,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -2408,7 +2411,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2510,7 +2513,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2539,7 +2542,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2569,13 +2572,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.332.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",
|
||||
|
|
@ -2584,19 +2588,20 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-db"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2615,7 +2620,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2629,7 +2634,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-environment"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2651,7 +2656,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2663,6 +2668,7 @@ dependencies = [
|
|||
"fabro-static",
|
||||
"fabro-test",
|
||||
"fabro-types",
|
||||
"futures",
|
||||
"jsonwebtoken",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2675,7 +2681,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2690,7 +2696,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -2713,7 +2719,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2723,7 +2729,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2742,7 +2748,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2757,7 +2763,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2799,7 +2805,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2810,7 +2816,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2831,7 +2837,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2851,7 +2857,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2879,7 +2885,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-store"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-db",
|
||||
|
|
@ -2897,8 +2903,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
"insta",
|
||||
|
|
@ -2913,7 +2920,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2935,7 +2942,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2943,7 +2950,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2952,7 +2959,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2968,7 +2975,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3012,7 +3019,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3064,6 +3071,7 @@ dependencies = [
|
|||
"fabro-workflow",
|
||||
"fabro-workflow-version",
|
||||
"futures-util",
|
||||
"git2",
|
||||
"globset",
|
||||
"hex",
|
||||
"hkdf 0.12.4",
|
||||
|
|
@ -3107,7 +3115,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -3129,18 +3137,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -3156,6 +3164,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"slatedb",
|
||||
"sqlx",
|
||||
"strum 0.28.0",
|
||||
|
|
@ -3170,7 +3179,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -3196,7 +3205,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -3210,7 +3219,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3235,7 +3244,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3256,7 +3265,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3270,7 +3279,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -3293,7 +3302,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -3316,7 +3325,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-acp",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -3329,7 +3338,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-variable"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3346,7 +3355,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3365,7 +3374,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3435,7 +3444,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow-version"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-config",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -4388,6 +4397,22 @@ dependencies = [
|
|||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
|
|
@ -4440,7 +4465,7 @@ dependencies = [
|
|||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5315,6 +5340,23 @@ dependencies = [
|
|||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe 0.2.1",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
|
|
@ -5389,7 +5431,7 @@ version = "0.50.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6386,7 +6428,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6671,11 +6713,13 @@ dependencies = [
|
|||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
|
|
@ -6687,6 +6731,7 @@ dependencies = [
|
|||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
|
|
@ -6860,7 +6905,7 @@ dependencies = [
|
|||
"errno 0.3.14",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6919,7 +6964,7 @@ dependencies = [
|
|||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7443,7 +7488,7 @@ version = "1.4.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno 0.3.14",
|
||||
"errno 0.2.8",
|
||||
"libc",
|
||||
]
|
||||
|
||||
|
|
@ -8033,7 +8078,7 @@ dependencies = [
|
|||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -8079,7 +8124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -8231,6 +8276,16 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
|
|
@ -8546,7 +8601,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -8565,7 +8620,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
@ -9132,7 +9187,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.332.0-nightly.1"
|
||||
version = "0.338.0-nightly.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
@ -97,8 +97,8 @@ twin-openai = { path = "test/twin/openai" }
|
|||
twin-github = { path = "test/twin/github" }
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-sdk" }
|
||||
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-api-client" }
|
||||
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-sdk" }
|
||||
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-api-client" }
|
||||
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
|
||||
fork = "0.2"
|
||||
exec = "0.3"
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -186,4 +186,8 @@ Do not rebuild or mutate the `RunEvent` in downstream listeners.
|
|||
|
||||
Any JSONL sink, the run store, and SSE should reflect the same canonical envelope bytes after redaction.
|
||||
|
||||
An active workflow treats any run-event sink write failure as fatal. It cancels execution and
|
||||
attempts to persist `run.failed` through the direct sink path. Persistence-error logs must include
|
||||
the full source chain so an HTTP status or transport failure remains visible.
|
||||
|
||||
`status.json` remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished.
|
||||
|
|
|
|||
|
|
@ -44,11 +44,18 @@ Emitted when the run record is created.
|
|||
"event": "run.created",
|
||||
"properties": {
|
||||
"workflow_slug": "my-workflow",
|
||||
"workflow_version_id": "wv_...",
|
||||
"target": {
|
||||
"kind": "git",
|
||||
"repo": "acme/my-project",
|
||||
"branch": "main",
|
||||
"sha": "0123456789abcdef0123456789abcdef01234567"
|
||||
},
|
||||
"source_directory": "/home/user/src/my-project",
|
||||
"git": {
|
||||
"origin_url": "https://github.com/acme/my-project",
|
||||
"branch": "main",
|
||||
"sha": "abc123",
|
||||
"sha": "0123456789abcdef0123456789abcdef01234567",
|
||||
"dirty": "clean"
|
||||
},
|
||||
"fork_source_ref": null,
|
||||
|
|
@ -76,9 +83,11 @@ Emitted when the run record is created.
|
|||
| `labels` | object | Run labels |
|
||||
| `source_directory` | string? | Submitter-side source directory |
|
||||
| `workflow_slug` | string? | Workflow slug |
|
||||
| `workflow_version_id` | string? | Exact immutable root workflow version used for admission |
|
||||
| `target` | object? | Canonical accepted workspace target. Version-backed Git intent runs persist `kind`, `repo`, required `branch`, and optional normalized `sha`; legacy manifest runs omit it |
|
||||
| `provenance` | object | Actor and request provenance |
|
||||
| `manifest_blob` | string? | Blob hash for the submitted manifest |
|
||||
| `git` | object? | Git provenance observed before the run: normalized `origin_url`, `branch`, optional `sha`, and `dirty` status |
|
||||
| `git` | object? | Operational Git projection: normalized `origin_url`, `branch`, optional `sha`, and `dirty` status. For Git intent runs, `branch` is the submitted working branch and `sha` is the optional lowercase-normalized submitted commit; admission does not resolve it or prove branch ancestry. Legacy runs retain their observed optional-SHA semantics |
|
||||
| `fork_source_ref` | object? | Source run/checkpoint reference when this run was forked |
|
||||
| `in_place` | boolean | Whether the run was created with `--in-place` (no git checkpoints) |
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ the vault:
|
|||
- `FABRO_SLACK_BOT_TOKEN`
|
||||
- `DAYTONA_API_KEY`
|
||||
- `BRAVE_SEARCH_API_KEY`
|
||||
- `VENICE_API_KEY`
|
||||
|
||||
`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root.
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ Add variables in **Service → Variables** as needed. The [Server Configuration]
|
|||
| `SESSION_SECRET` | 64-character hex string; required when the web UI is enabled |
|
||||
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | Optional static S3 object-store credentials |
|
||||
|
||||
Do not put optional integration secrets in Railway variables for server runtime. After the server is running, add LLM provider keys, Slack, Daytona, Brave Search, `GITHUB_TOKEN`, and GitHub App secrets to the server vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
|
||||
Do not put optional integration secrets in Railway variables for server runtime. After the server is running, add LLM provider keys, Slack, Daytona, Brave Search, Venice Search, `GITHUB_TOKEN`, and GitHub App secrets to the server vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
|
||||
|
||||
No `.env` file is auto-loaded inside the container; bootstrap variables come from Railway's environment.
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following
|
|||
### Secrets
|
||||
|
||||
- **Keep API keys out of sandboxes.** The local sandbox strips environment variables ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL`, but Docker and Daytona sandboxes provide stronger isolation — only explicitly configured variables are passed through.
|
||||
- **Use the server vault for optional integration credentials.** For server-backed workflows, persist LLM provider keys, Slack, Daytona, Brave Search, GitHub token, and GitHub App secrets with `fabro provider login`, `fabro secret set`, or `fabro install`. Process env and `server.env` are reserved for bootstrap secrets such as `SESSION_SECRET`, `FABRO_DEV_TOKEN`, and object-store credentials. Do not commit secrets to version control.
|
||||
- **Use the server vault for optional integration credentials.** For server-backed workflows, persist LLM provider keys, Slack, Daytona, Brave Search, Venice Search, GitHub token, and GitHub App secrets with `fabro provider login`, `fabro secret set`, or `fabro install`. Process env and `server.env` are reserved for bootstrap secrets such as `SESSION_SECRET`, `FABRO_DEV_TOKEN`, and object-store credentials. Do not commit secrets to version control.
|
||||
- **Rotate the session secret.** The `SESSION_SECRET` environment variable encrypts web app sessions. Rotate it periodically and use a strong random value.
|
||||
|
||||
### Execution
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ Generate one with `openssl rand -hex 32`.
|
|||
| `FABRO_DEV_TOKEN` | Optional — pre-set the dev token instead of reading the one written to `/storage` on first boot |
|
||||
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | Optional static S3 object-store credentials |
|
||||
|
||||
Do not put optional integration secrets in `.env` for server runtime. Configure LLM provider keys, Slack, Daytona, Brave Search, `GITHUB_TOKEN`, and GitHub App secrets in the vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
|
||||
Do not put optional integration secrets in `.env` for server runtime. Configure LLM provider keys, Slack, Daytona, Brave Search, Venice Search, `GITHUB_TOKEN`, and GitHub App secrets in the vault with `fabro secret set`, `fabro provider login`, or `fabro install`.
|
||||
|
||||
Optional:
|
||||
|
||||
|
|
|
|||
|
|
@ -257,7 +257,9 @@ honors those hand-edited values even though the browser wizard does not manage t
|
|||
|
||||
### SQLite state and migration backups
|
||||
|
||||
Shared relational state, including vault entries and server-managed definitions, lives at `<storage_root>/db/fabro.sqlite3`. Run events continue to use the `[server.slatedb]` object store.
|
||||
Shared relational state, including vault entries, server-managed definitions, and CLI auth sessions, lives at `<storage_root>/db/fabro.sqlite3`. Run events continue to use the `[server.slatedb]` object store.
|
||||
|
||||
CLI auth sessions are stored as an `auth_sessions` row per signed-in CLI, with the rotating refresh tokens for that session in `refresh_tokens`. Pending browser-to-CLI handoffs live briefly in `oauth_authorization_codes`; the table contains a SHA-256 hash of each one-time code, never the raw bearer value. Revoking a session from **Settings → Sessions**, or with `DELETE /api/v1/auth/sessions/{id}`, deletes the session row and its tokens together.
|
||||
|
||||
Before applying pending SQLite migrations, Fabro creates `<storage_root>/db/fabro.sqlite3.pre-migration.bak` with SQLite's `VACUUM INTO`. Each migration run replaces the previous snapshot, so only the most recent pre-migration backup is retained.
|
||||
|
||||
|
|
@ -360,7 +362,7 @@ Fabro splits server-runtime secrets into two scopes:
|
|||
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` when a manual config uses
|
||||
static S3 object-store credentials
|
||||
|
||||
`server.env` is not used for Slack, Daytona, Brave Search, LLM provider keys, `GITHUB_TOKEN`, or GitHub App private key/client secret/webhook secret. Configure those optional integrations with `fabro secret set`, `fabro provider login`, or `fabro install`.
|
||||
`server.env` is not used for Slack, Daytona, Brave Search, Venice Search, LLM provider keys, `GITHUB_TOKEN`, or GitHub App private key/client secret/webhook secret. Configure those optional integrations with `fabro secret set`, `fabro provider login`, or `fabro install`.
|
||||
|
||||
During startup, Fabro temporarily migrates recognized legacy optional integration secrets from process env or `server.env` into the vault. When a matching `server.env` entry can be safely removed, Fabro writes a hidden backup beside `server.env` first. Process env values cannot be cleaned up automatically, so remove those from your deployment environment after the vault contains the secret.
|
||||
|
||||
|
|
@ -402,12 +404,16 @@ These optional server integrations are vault-only:
|
|||
```bash
|
||||
fabro secret set DAYTONA_API_KEY dtn_...
|
||||
fabro secret set BRAVE_SEARCH_API_KEY BSA...
|
||||
fabro secret set VENICE_API_KEY venice-...
|
||||
```
|
||||
|
||||
The built-in [`web_search`](/agents/tools#web_search) tool selects its backend from these credentials. It uses direct Brave Search when `BRAVE_SEARCH_API_KEY` exists. Otherwise it uses Venice Search when `VENICE_API_KEY` exists. When neither exists, the tool is not registered.
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DAYTONA_API_KEY` | Daytona cloud sandbox API key |
|
||||
| `BRAVE_SEARCH_API_KEY` | Brave Search API key (for the `web_search` tool) |
|
||||
| `BRAVE_SEARCH_API_KEY` | Brave Search API key; the preferred `web_search` backend when present |
|
||||
| `VENICE_API_KEY` | Venice API key; used by the Venice LLM provider and by `web_search` when no Brave key exists |
|
||||
|
||||
### Server authentication
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ It checks:
|
|||
|
||||
- Local user config and storage directory health
|
||||
- Server-reported LLM provider connectivity, with configured providers probed concurrently
|
||||
- GitHub App, sandbox, and Brave Search credentials, plus Docker daemon reachability when the Docker sandbox provider is enabled
|
||||
- GitHub App, sandbox, and web search credentials (Brave or Venice), plus Docker daemon reachability when the Docker sandbox provider is enabled
|
||||
- Server authentication and crypto configuration
|
||||
|
||||
LLM provider probe failures are reported as errors. Use `--verbose` to see the underlying provider error chain when a key, network route, or model endpoint fails.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ Captured stage artifacts such as screenshots, videos, reports, and traces still
|
|||
|
||||
For remote sandboxes (Docker, Daytona), execution-time file access happens inside the sandbox filesystem.
|
||||
|
||||
- Blob refs are materialized into `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
- Blob refs are materialized into the sandbox runtime directory, `{runtime_directory}/blobs/{blob_hash}.json`. This directory lives outside the repository checkout, so materialized blobs never show up in `git status` or in checkpoint commits.
|
||||
- Explicit non-blob `file://` refs keep the existing copy-on-demand behavior and are copied into `{working_directory}/.fabro/artifacts/{filename}` when needed
|
||||
|
||||
In both cases, downstream handlers and agents continue to consume ordinary `file://` pointers during execution.
|
||||
|
|
@ -277,6 +277,10 @@ Patterns are rooted at the sandbox working directory. `*` and `?` stay within on
|
|||
|
||||
Fabro prunes dependency, cache, and build directories including `.git`, `node_modules`, `target`, `.venv`, `.cache`, and `dist`.
|
||||
|
||||
### Browse and download captures
|
||||
|
||||
The Artifacts page groups captures by file path. Expand a file to see and download earlier versions. **Download all** creates a ZIP archive with the latest captured version of each path. Fabro uses stage order, retry number, and stage ID to choose the latest version, and it excludes captures from the graph's start and exit nodes.
|
||||
|
||||
## Observability
|
||||
|
||||
Outputs and artifacts appear in several observability surfaces:
|
||||
|
|
@ -285,6 +289,6 @@ Outputs and artifacts appear in several observability surfaces:
|
|||
|---|---|
|
||||
| `StageCompleted` event | `files_touched` list for the stage |
|
||||
| `WorkflowRunCompleted` event | `artifact_count` -- total number of offloaded artifacts across the run |
|
||||
| Web UI | Run stage output, stage artifacts, and downloadable artifact files |
|
||||
| Web UI | Run stage output, artifact version history, individual downloads, and a ZIP of the latest files |
|
||||
| [Preambles](/execution/context#preamble-construction) | File list and artifact pointer references for completed stages |
|
||||
| Stage logs | `status.json` in each stage's run directory contains the full outcome including `files_touched` |
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ The system prompt varies by LLM provider. Each provider has its own identity tex
|
|||
<Accordion title="Example system prompt (Anthropic provider)">
|
||||
This is the full system prompt sent to Claude as the LLM system message. The `<environment>` block is filled in at runtime.
|
||||
|
||||
Tool guidance tracks the tools actually registered for the session. The `web_search` section shown below is present only when a [Brave Search API key](/integrations/brave-search) is configured; without one, both the tool and its guidance are omitted.
|
||||
Tool guidance tracks the tools actually registered for the session. The `web_search` section shown below is present when a [Brave Search API key](/integrations/brave-search) or [Venice API key](/integrations/venice-search) is configured. Without either key, both the tool and its guidance are omitted.
|
||||
|
||||
```
|
||||
You are Claude, an AI coding assistant made by Anthropic. You help users with
|
||||
|
|
@ -232,7 +232,7 @@ first). Use this for finding files rather than using shell find or ls
|
|||
commands.
|
||||
|
||||
## web_search
|
||||
Search the web using Brave Search. Returns titles, URLs, and descriptions.
|
||||
Search the web. Returns titles, URLs, and descriptions.
|
||||
|
||||
## web_fetch
|
||||
Fetch content from a URL and optionally summarize it. Pass a prompt to
|
||||
|
|
@ -257,18 +257,19 @@ to load its instructions, then follow them.
|
|||
</Accordion>
|
||||
|
||||
<Note>
|
||||
OpenAI and Gemini providers have their own system prompts with different identity text, tool guidance (e.g. `apply_patch` instead of `edit_file` for OpenAI), and coding conventions. The overall structure is the same.
|
||||
Fabro selects an agent profile for the model. Anthropic, Claude 5, OpenAI, GPT-5.6, Gemini, and Kimi profiles can use different identity text, tool names, tool guidance, and coding conventions. The overall system-prompt structure is the same.
|
||||
</Note>
|
||||
|
||||
### Project docs
|
||||
|
||||
Fabro automatically discovers project instruction files by walking the directory hierarchy from the git root to the working directory. Which files are loaded depends on the provider:
|
||||
Fabro automatically discovers project instruction files by walking the directory hierarchy from the git root to the working directory. Which files are loaded depends on the agent profile:
|
||||
|
||||
| Provider | Files |
|
||||
| Agent profile | Files |
|
||||
|---|---|
|
||||
| Anthropic | `AGENTS.md`, `CLAUDE.md` |
|
||||
| OpenAI | `AGENTS.md`, `.codex/instructions.md` |
|
||||
| Anthropic and Claude 5 | `AGENTS.md`, `CLAUDE.md` |
|
||||
| OpenAI and GPT-5.6 | `AGENTS.md`, `.codex/instructions.md` |
|
||||
| Gemini | `AGENTS.md`, `GEMINI.md` |
|
||||
| Kimi | `AGENTS.md` |
|
||||
|
||||
Files are loaded in directory order (root first, deepest last) with a total budget of 32KB. If the combined content exceeds this budget, later files are truncated.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ Sub-agent management is exposed through four built-in tools:
|
|||
| Tool | Description |
|
||||
|---|---|
|
||||
| `spawn_agent` | Create a new sub-agent with a task prompt |
|
||||
| `send_input` | Send follow-up input to a running sub-agent |
|
||||
| `send_input` | Send follow-up input to a running or completed sub-agent |
|
||||
| `wait` | Block until a sub-agent completes and return its result |
|
||||
| `close_agent` | Cancel and remove a running sub-agent |
|
||||
| `close_agent` | Close a running or completed sub-agent |
|
||||
|
||||
These tools are registered automatically when the session starts. They inherit the parent's permissions.
|
||||
|
||||
|
|
@ -32,6 +32,12 @@ Each sub-agent runs in its own session:
|
|||
|
||||
The parent can spawn multiple sub-agents and synchronize with them later.
|
||||
|
||||
## Continue a completed session
|
||||
|
||||
A completed sub-agent remains available until the parent closes it. Calling `send_input` starts another turn in the same child session, so the child keeps its conversation history. A message sent while the child is still running is queued for a safe turn boundary instead.
|
||||
|
||||
Call `wait` again to receive the new turn's result. Call `close_agent` when the child is no longer needed; a closed child cannot accept more input.
|
||||
|
||||
## Depth limits
|
||||
|
||||
Sub-agents can themselves spawn sub-agents, creating a hierarchy. `max_subagent_depth` limits how deep that tree can grow. By default the depth limit is `1`.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ These tools are registered for every provider profile:
|
|||
| `write_file` | write | Create or overwrite a file |
|
||||
| `grep` | read | Search file contents with regex patterns |
|
||||
| `glob` | read | Find files by name pattern |
|
||||
| `web_search` | shell | Search the web via Brave Search |
|
||||
| `web_search` | shell | Search the web via Brave or Venice |
|
||||
| `web_fetch` | shell | Fetch and optionally summarize a URL |
|
||||
|
||||
## Provider-specific tools
|
||||
|
|
@ -123,14 +123,18 @@ Patterns are case-sensitive and relative to `path`: `*` and `?` stay within one
|
|||
|
||||
### web_search
|
||||
|
||||
Searches the web using the Brave Search API.
|
||||
Searches the web using Brave Search or Venice Search. Fabro selects the backend automatically from the available credentials.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `query` | string | yes | Search query |
|
||||
| `query` | string | yes | Search query. Venice rejects queries longer than 400 characters before the HTTP call. |
|
||||
| `max_results` | integer | no | Maximum results (default: 5, max: 20) |
|
||||
|
||||
Requires `BRAVE_SEARCH_API_KEY` to be configured for the current runtime. Runs read it from the server vault (`fabro secret set BRAVE_SEARCH_API_KEY <key>`) — workers start from a cleared environment and this key is not inherited, so exporting it in the server's shell has no effect. The standalone agent CLI reads it from the invoking shell instead. Returns numbered results with title, URL, and description.
|
||||
Fabro uses direct [Brave Search](/integrations/brave-search) when `BRAVE_SEARCH_API_KEY` is present. Otherwise it uses [Venice Search](/integrations/venice-search) when `VENICE_API_KEY` is present. If both credentials are present, Brave wins. Venice always uses its Brave search engine.
|
||||
|
||||
Runs read both keys from the server vault. Workers start from a cleared environment, so exporting a key in the server's shell has no effect. The standalone agent CLI reads the keys from the invoking shell instead.
|
||||
|
||||
The tool is registered when either credential is available. Once Fabro selects a backend, a failed call returns an error; it does not retry through the other backend. Results contain numbered titles, URLs, and descriptions. Venice includes `date` on a fourth line when present.
|
||||
|
||||
### web_fetch
|
||||
|
||||
|
|
|
|||
|
|
@ -1172,13 +1172,25 @@ paths:
|
|||
operationId: createRun
|
||||
tags: [Runs]
|
||||
summary: Create Run
|
||||
description: Creates a new workflow run in `submitted` status from a self-contained manifest.
|
||||
description: >-
|
||||
Creates a new workflow run in `submitted` status from either a
|
||||
self-contained legacy manifest or an immutable workflow-version intent.
|
||||
Creation does not start or schedule the run.
|
||||
|
||||
|
||||
Failures return the standard error body. The intent lane responds
|
||||
`404` (`workflow_version_not_found`, `environment_not_found`), `422`
|
||||
(`run_intent_invalid`, `target_invalid`,
|
||||
`target_environment_unsupported`, `workflow_version_unusable`,
|
||||
`run_compile_invalid`), `503` (`integration_unavailable`), or `500`
|
||||
(`workflow_version_store_error`, `credential_store_error`,
|
||||
`variable_store_error`, `run_persistence_failed`).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RunManifest"
|
||||
$ref: "#/components/schemas/CreateRunRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Run created
|
||||
|
|
@ -1186,8 +1198,14 @@ paths:
|
|||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Run"
|
||||
# Non-2xx statuses beyond 400 are deliberately documented in the
|
||||
# endpoint description instead of declared here: progenitor-generated
|
||||
# clients drop the HTTP status when a declared error response's body
|
||||
# is not valid JSON (e.g. a gateway's plain-text error), which breaks
|
||||
# the CLI's error display contract
|
||||
# (run_create_failure_shows_action_context_and_response_body).
|
||||
"400":
|
||||
description: Invalid Graphviz source
|
||||
description: Invalid JSON or legacy manifest
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
|
|
@ -5674,6 +5692,7 @@ paths:
|
|||
description: The canonical model ID or an alias.
|
||||
- $ref: "#/components/parameters/ModelTestProviderParam"
|
||||
- $ref: "#/components/parameters/ModelTestModeParam"
|
||||
- $ref: "#/components/parameters/ModelTestReasoningEffortParam"
|
||||
responses:
|
||||
"200":
|
||||
description: Test result
|
||||
|
|
@ -5682,7 +5701,7 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ModelTestResult"
|
||||
"400":
|
||||
description: Invalid test mode
|
||||
description: Invalid test mode or reasoning effort
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
|
|
@ -6216,6 +6235,15 @@ components:
|
|||
$ref: "#/components/schemas/ProviderId"
|
||||
example: openrouter
|
||||
|
||||
ModelTestReasoningEffortParam:
|
||||
name: reasoning_effort
|
||||
in: query
|
||||
required: false
|
||||
description: Optional native reasoning-effort level for the model test.
|
||||
schema:
|
||||
$ref: "#/components/schemas/ReasoningEffort"
|
||||
example: high
|
||||
|
||||
headers:
|
||||
XRequestId:
|
||||
description: >
|
||||
|
|
@ -6710,6 +6738,7 @@ components:
|
|||
- name
|
||||
- description
|
||||
- target
|
||||
- workflow
|
||||
- triggers
|
||||
properties:
|
||||
id:
|
||||
|
|
@ -6728,34 +6757,16 @@ components:
|
|||
type: ["string", "null"]
|
||||
example: Keeps dependencies fresh.
|
||||
target:
|
||||
$ref: "#/components/schemas/AutomationTarget"
|
||||
$ref: "#/components/schemas/RunTarget"
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the selected repository checkout.
|
||||
example: dependency-update
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AutomationTrigger"
|
||||
|
||||
AutomationTarget:
|
||||
description: Repository and workflow selected by an automation.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- repository
|
||||
- ref
|
||||
- workflow
|
||||
properties:
|
||||
repository:
|
||||
type: string
|
||||
description: GitHub repository slug in `owner/repo` form.
|
||||
example: fabro-sh/fabro
|
||||
ref:
|
||||
type: string
|
||||
description: Branch, tag, or SHA selector resolved when materializing a run.
|
||||
example: main
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the target repository.
|
||||
example: dependency-update
|
||||
|
||||
AutomationTrigger:
|
||||
description: |
|
||||
Automation trigger configuration. Unknown `type` discriminator values
|
||||
|
|
@ -6822,6 +6833,7 @@ components:
|
|||
- id
|
||||
- name
|
||||
- target
|
||||
- workflow
|
||||
- triggers
|
||||
properties:
|
||||
id:
|
||||
|
|
@ -6835,7 +6847,11 @@ components:
|
|||
type: ["string", "null"]
|
||||
example: Keeps dependencies fresh.
|
||||
target:
|
||||
$ref: "#/components/schemas/AutomationTarget"
|
||||
$ref: "#/components/schemas/RunTarget"
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the selected repository checkout.
|
||||
example: dependency-update
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
|
|
@ -6848,6 +6864,7 @@ components:
|
|||
required:
|
||||
- name
|
||||
- target
|
||||
- workflow
|
||||
- triggers
|
||||
properties:
|
||||
name:
|
||||
|
|
@ -6857,7 +6874,11 @@ components:
|
|||
type: ["string", "null"]
|
||||
example: Keeps dependencies fresh.
|
||||
target:
|
||||
$ref: "#/components/schemas/AutomationTarget"
|
||||
$ref: "#/components/schemas/RunTarget"
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the selected repository checkout.
|
||||
example: dependency-update
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
|
|
@ -9214,6 +9235,159 @@ components:
|
|||
workflow_version_id:
|
||||
$ref: "#/components/schemas/WorkflowVersionId"
|
||||
|
||||
CreateRunRequest:
|
||||
description: >-
|
||||
Transitional create body used while callers migrate independently from
|
||||
self-contained manifests to immutable workflow-version intents.
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/RunManifest"
|
||||
- $ref: "#/components/schemas/RunIntent"
|
||||
|
||||
RunIntent:
|
||||
description: >-
|
||||
A request to create, but not start, one run from an immutable workflow
|
||||
version and an explicit workspace target.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workflow_version_id
|
||||
- target
|
||||
- args
|
||||
properties:
|
||||
workflow_version_id:
|
||||
$ref: "#/components/schemas/WorkflowVersionId"
|
||||
target:
|
||||
$ref: "#/components/schemas/RunTarget"
|
||||
args:
|
||||
$ref: "#/components/schemas/RunIntentArgs"
|
||||
environment_id:
|
||||
type: string
|
||||
description: Server environment catalog ID. Omission selects `default`.
|
||||
parent_id:
|
||||
type: string
|
||||
description: Optional orchestration parent run ID.
|
||||
title:
|
||||
type: string
|
||||
maxLength: 100
|
||||
description: Optional explicit run title, normalized by the server.
|
||||
goal:
|
||||
type: string
|
||||
description: Optional inline goal override.
|
||||
|
||||
RunIntentArgs:
|
||||
description: Structured run overrides accepted by workflow-version creation.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
description: LLM provider; this does not select the sandbox environment.
|
||||
inputs:
|
||||
type: object
|
||||
additionalProperties:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: integer
|
||||
- type: boolean
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
|
||||
RunTarget:
|
||||
description: Workspace content and location requested for a run.
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/GitRunTarget"
|
||||
- $ref: "#/components/schemas/NoneRunTarget"
|
||||
- $ref: "#/components/schemas/FolderRunTarget"
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
git: "#/components/schemas/GitRunTarget"
|
||||
none: "#/components/schemas/NoneRunTarget"
|
||||
folder: "#/components/schemas/FolderRunTarget"
|
||||
|
||||
GitRunTarget:
|
||||
description: >-
|
||||
Public github.com repository target. The branch names the attached
|
||||
working branch. An optional tag selects a release at worker start, and
|
||||
an optional exact SHA is authoritative when both are present.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- kind
|
||||
- repo
|
||||
- branch
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [git]
|
||||
repo:
|
||||
type: string
|
||||
description: GitHub repository slug in `owner/name` form.
|
||||
example: acme/my-app
|
||||
branch:
|
||||
type: string
|
||||
description: Required attached working branch name, preserved exactly.
|
||||
example: feature/foo
|
||||
tag:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: >-
|
||||
Optional bare tag name. Prefixes such as `refs/tags/` and `tags/`
|
||||
are rejected. Without `sha`, the worker resolves this tag when the
|
||||
sandbox starts and fails if it is unavailable.
|
||||
example: v1.2.3
|
||||
sha:
|
||||
type: string
|
||||
pattern: "^[0-9A-Fa-f]{40}$"
|
||||
description: >-
|
||||
Optional exact commit. The server lowercase-normalizes its syntax
|
||||
but does not resolve it, prove branch ancestry, or prove that it
|
||||
matches an accompanying tag. When present, this exact commit wins.
|
||||
|
||||
NoneRunTarget:
|
||||
description: >-
|
||||
Empty workspace with no repository. Docker and Daytona accept this
|
||||
target and suppress cloning even when workflow settings enable it.
|
||||
Local environments reject it; Local scratch allocation is a separate
|
||||
future capability.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [none]
|
||||
|
||||
FolderRunTarget:
|
||||
description: >-
|
||||
Existing directory on the Fabro server, executed in place by a Local
|
||||
environment. The submitted path must be absolute and name an existing
|
||||
directory; Fabro resolves symlinks and persists its canonical UTF-8
|
||||
path. This target is intended for trusted single-tenant deployments.
|
||||
Docker and Daytona environments always reject it. This target does not
|
||||
add Local Git cloning or Local scratch workspaces. Folder runs execute
|
||||
in place without Fabro Git checkpoints, so fork and rewind are
|
||||
unavailable.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- kind
|
||||
- path
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [folder]
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Absolute path on the Fabro server, not on the API caller's machine.
|
||||
|
||||
RunManifest:
|
||||
description: Self-contained workflow run manifest.
|
||||
type: object
|
||||
|
|
@ -11434,6 +11608,16 @@ components:
|
|||
type: ["string", "null"]
|
||||
workflow_slug:
|
||||
type: ["string", "null"]
|
||||
workflow_version_id:
|
||||
description: Exact immutable root workflow version from which the run was admitted, when applicable.
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/WorkflowVersionId"
|
||||
- type: "null"
|
||||
target:
|
||||
description: Canonical workspace target accepted for a version-backed run. Absent for legacy manifest runs.
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/RunTarget"
|
||||
- type: "null"
|
||||
automation:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/AutomationRef"
|
||||
|
|
@ -14442,6 +14626,17 @@ components:
|
|||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
additional_repositories:
|
||||
type: array
|
||||
description: |
|
||||
Additional GitHub repositories, beyond the implicit run origin,
|
||||
that the minted GITHUB_TOKEN must cover. Each entry is a full
|
||||
`owner/repository` slug; every repository must share one owner
|
||||
with the run origin. Omitted when empty; settings persisted
|
||||
before this field existed deserialize to an empty set.
|
||||
items:
|
||||
type: string
|
||||
uniqueItems: true
|
||||
|
||||
RunGoal:
|
||||
oneOf:
|
||||
|
|
@ -14628,6 +14823,12 @@ components:
|
|||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
depth:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 0
|
||||
default: 100
|
||||
description: Git history depth. Set to 0 to clone full history.
|
||||
|
||||
RunBranchSettings:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ Paginated responses include a `meta` object alongside the `data` array:
|
|||
|
||||
When `has_more` is `true`, increment the offset by the limit to fetch the next page.
|
||||
|
||||
## Immutable workflow versions
|
||||
|
||||
`POST /api/v1/workflow-versions` validates and stores a complete workflow package. The package contains an entrypoint, its text files, and exact IDs for child-workflow dependencies. Its SHA-256 ID is based on canonical content, so submitting the same package again returns the same ID.
|
||||
|
||||
A package can contain at most 512 files and 512 workflow dependencies. Each file can contain at most 512 KiB, and the complete canonical package can contain at most 2 MiB. Invalid workflow content or missing dependencies return `422`.
|
||||
|
||||
## Versioning
|
||||
|
||||
The Fabro API is versioned under `/api/v1`. All versioned endpoints, including the OpenAPI document, live under that prefix. Future breaking changes can be introduced under a new versioned prefix while preserving existing clients.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: "Shared-checkout parallelism, Fireworks, and resume history"
|
||||
title: "Shared-checkout parallelism, Fireworks, and agent chat"
|
||||
date: "2026-07-24"
|
||||
---
|
||||
|
||||
|
|
@ -34,9 +34,9 @@ Fireworks AI is now an opt-in built-in provider with a curated serverless catalo
|
|||
enabled = true
|
||||
```
|
||||
|
||||
## Resumed stages keep their history
|
||||
## Model-native Kimi agents and agent chat
|
||||
|
||||
When a run resumes after a node was cancelled or lost mid-flight, the replay now starts a new stage execution such as `work@2` instead of clearing and reusing `work@1`. The earlier execution keeps its events, session, output, timing, billing, and terminal state, and the stage UI links the new execution back to the one it resumed from.
|
||||
Kimi K3 sessions now use a model-specific agent profile with Kimi Code tool names, descriptions, paging, and TODO behavior. The stage activity view also has a Chat tab that groups prompts, assistant messages, tool calls, and disclosed provider reasoning into a readable conversation.
|
||||
|
||||
## More
|
||||
|
||||
|
|
@ -51,9 +51,12 @@ When a run resumes after a node was cancelled or lost mid-flight, the replay now
|
|||
- Preflight now prefers providers that are actually ready while preserving explicit provider pins and useful diagnostics for unavailable offerings
|
||||
- OpenAI-compatible agent providers now receive compatible file-edit and tool schemas
|
||||
- Large values inside parallel branch results now stay available to fan-in prompts through normal artifact storage
|
||||
- Agent compaction now preserves history when a summary is empty or exceeds the model's reasoning budget
|
||||
- Agent tool secrets now reach model-specific profiles, and OpenAI-compatible streaming usage is requested consistently
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Resumed nodes now start a new execution such as `work@2`, preserving the earlier execution's events, output, timing, billing, and terminal state
|
||||
- Added `claude-opus-5` to the first-party Anthropic and optional OpenRouter model catalogs; the `opus` and `claude-opus` aliases now resolve to Opus 5
|
||||
- Added `gpt-sol`, `gpt-terra`, and `gpt-luna` aliases for GPT-5.6 offerings
|
||||
- Added portable `glm`, `glm52`, `glm5.2`, `deepseek`, and `deepseek-flash` aliases across direct and OpenRouter offerings
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: "Consistent workspace globs"
|
||||
title: "Consistent workspace globs and model-native agents"
|
||||
date: "2026-07-25"
|
||||
---
|
||||
|
||||
|
|
@ -21,3 +21,21 @@ Fabro now compiles workspace globs once and applies them to normalized relative
|
|||
Artifact traversal also uses structured provider metadata for file-size limits and prunes dependency, cache, and build directories before matching.
|
||||
|
||||
Artifact collection now treats each post-stage workspace state as authoritative instead of relying on modification timestamps. Fabro records the same path and content hash only once per run and captures the path again when its content changes.
|
||||
|
||||
## Model-native Claude 5 and GPT-5.6 agents
|
||||
|
||||
Claude 5 and GPT-5.6 Sol, Terra, and Luna now use profiles that match each model family's preferred tools and instructions. Child agents inherit the same task runtime, so background work and completion notifications behave consistently across profiles.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Fixed active-time totals for stages that are still running
|
||||
- Fixed background-agent completion notifications and event delivery
|
||||
- Fixed Daytona streaming completion and cleanup after failed probes
|
||||
- Removed the read-before-write guard that could reject valid agent edits
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- The stage activity panel now opens on Chat and shows disclosed reasoning in Thread details
|
||||
- Open browser tabs now report when a newer Fabro web build is available
|
||||
</Accordion>
|
||||
|
|
|
|||
31
docs/public/changelog/2026-07-26.mdx
Normal file
31
docs/public/changelog/2026-07-26.mdx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
title: "CLI auth sessions move to SQLite"
|
||||
date: "2026-07-26"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**Everyone signs in again after this upgrade.** Existing refresh tokens are not migrated, so every signed-in browser and CLI is logged out the moment the new server binary starts. Run `fabro auth login` again on each machine. There is no staged rollout for this — avoid upgrading mid-task.
|
||||
</Warning>
|
||||
|
||||
## Sessions are their own record
|
||||
|
||||
A CLI login is a chain of refresh tokens that rotate on every use. Fabro previously stored the identity and profile on each token in that chain, so the chain itself had no record of its own. It now does: an `auth_sessions` row per login, with its tokens in `refresh_tokens`, both in `<storage_root>/db/fabro.sqlite3`.
|
||||
|
||||
Two dates on **Settings → Sessions** were wrong as a result and are now correct. A session's **created** date came from its newest token, so it moved forward every time the CLI refreshed, and **last seen** showed the same value rather than the last time the session was actually used.
|
||||
|
||||
Listing and revoking sessions no longer reads every refresh token the server has ever issued, so both stay fast as a workspace accumulates logins. Revoking a session removes its tokens in the same operation.
|
||||
|
||||
## Pending CLI logins
|
||||
|
||||
Pending CLI authorization codes now live in SQLite as SHA-256 hashes and are consumed atomically on the first exchange attempt. A login that is already between browser approval and token exchange when the server upgrades cannot carry across the storage cutover; run `fabro auth login` again. These codes expire after 60 seconds, and completed logins are unaffected.
|
||||
|
||||
## Refresh token replay
|
||||
|
||||
Replaying a refresh token still revokes its whole chain immediately. One detail changed: when several requests present the same already-rotated token at once, later ones now report `refresh_token_expired` where they previously reported `refresh_token_revoked`. The CLI treats both the same way — it discards the stored credentials and prompts you to sign in again.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- `fabro validate` now stays offline and no longer requires a server model catalog
|
||||
- Child agents now share the parent task runtime across every model profile
|
||||
</Accordion>
|
||||
38
docs/public/changelog/2026-07-27.mdx
Normal file
38
docs/public/changelog/2026-07-27.mdx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
title: "Runtime fan-out and live parallel runs"
|
||||
date: "2026-07-27"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**Run configuration no longer reads `{{ env.NAME }}` from the server process.** Replace environment interpolation with `{{ vars.NAME }}` for non-sensitive values or `{{ secrets.NAME }}` for vault-backed values. Hook `allowed_env_vars` was also removed.
|
||||
</Warning>
|
||||
|
||||
## Runtime arrays with `for_each`
|
||||
|
||||
Parallel workflows can now create one branch per item in a runtime context array. Each branch receives its item and index, while the fan-in receives the ordered results without merging branch updates into top-level context.
|
||||
|
||||
```dot
|
||||
batch [shape=component, for_each="context.candidates"]
|
||||
batch -> reviewer
|
||||
```
|
||||
|
||||
## Live parallel status and artifact history
|
||||
|
||||
The run view now shows parallel branches as they start and finish. The artifacts view groups captures by file and shows version history, while stage model popovers report token usage and cost.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="CLI">
|
||||
- Validation diagnostics now include their suggested fixes
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workflows">
|
||||
- Command `script` values now interpolate `{{ goal }}`, `{{ inputs.NAME }}`, and `{{ vars.NAME }}`
|
||||
- A node with `script` now infers the command handler when `shape` and `type` are omitted
|
||||
- Validation now rejects edge targets that were never declared as nodes
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Pull request publish failures now fail the run instead of leaving a successful terminal state
|
||||
- Default small-model selection now skips providers that do not offer a small model
|
||||
</Accordion>
|
||||
36
docs/public/changelog/2026-07-28.mdx
Normal file
36
docs/public/changelog/2026-07-28.mdx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
title: "Modal inference and structured review targets"
|
||||
date: "2026-07-28"
|
||||
---
|
||||
|
||||
## Run Kimi K3 through Modal
|
||||
|
||||
Fabro now includes an optional Modal provider for Kimi K3 Shared API and Auto Endpoints. It supports Modal's endpoint URL, two-part proxy-token authentication, model capabilities, and estimated token costs.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[llm.providers.modal]
|
||||
enabled = true
|
||||
base_url = "https://your-endpoint.modal.run/v1"
|
||||
```
|
||||
|
||||
## Structured review targets
|
||||
|
||||
Human gates can now present an external document as the primary review link. Set `review_target=true` on the gate and provide a validated `review_target` object in workflow context.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Workflows">
|
||||
- `fabro run --dry-run` now walks runtime `for_each` fan-out, and fan-out memory is bounded
|
||||
- Provider-qualified fallback selectors such as `openrouter:kimi-k3` are now supported
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Fixed live parallel branches appearing late or failing to refresh
|
||||
- Fixed Docker and Daytona runs failing to reactivate stopped or paused sandboxes
|
||||
- Fixed a pipe character inside a Slack link label being parsed as a table separator
|
||||
- Fixed structured-output validation so it selects the outermost final JSON object
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Run-side panels now use a shared collapsible layout
|
||||
</Accordion>
|
||||
34
docs/public/changelog/2026-07-29.mdx
Normal file
34
docs/public/changelog/2026-07-29.mdx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
title: "Context-fed commands and portable fallbacks"
|
||||
date: "2026-07-29"
|
||||
---
|
||||
|
||||
## Feed workflow context to command input
|
||||
|
||||
Command nodes can now read a flat runtime context value through `stdin_source`. Strings pass through unchanged, while other values use compact JSON, which makes deterministic fan-in commands easier to build.
|
||||
|
||||
```dot
|
||||
merge [script="python3 scripts/merge.py", stdin_source="context.parallel.results"]
|
||||
```
|
||||
|
||||
## Portable model fallback chains
|
||||
|
||||
Fallback chains can now mix bare providers, model aliases, and provider-qualified selectors. Model stylesheets also accept comments, so routing policy can stay readable next to the workflow.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Workflows">
|
||||
- Raised the `stdin_source` value limit from 10 MiB to 30 MiB for wide fan-in results
|
||||
- Node class lists now accept whitespace-separated names consistently
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Fixed artifacts from repeated visits to the same stage
|
||||
- Fixed parent tool hooks not reaching child agent sessions
|
||||
- Fixed concurrent CLI token refreshes racing across processes
|
||||
- Fixed command-node inference when legacy attributes are present
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Removed the nonfunctional run agent permissions setting from run configuration and the API schema
|
||||
</Accordion>
|
||||
22
docs/public/changelog/2026-07-30.mdx
Normal file
22
docs/public/changelog/2026-07-30.mdx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
title: "Model-keyed fallback policies"
|
||||
date: "2026-07-30"
|
||||
---
|
||||
|
||||
## Fallback policy follows the requested model
|
||||
|
||||
You can now define a separate ordered fallback chain for each requested model. Fabro selects the chain once from the original request and does not jump into another model's policy while failing over.
|
||||
|
||||
```toml title="run.toml"
|
||||
[run.model.fallbacks]
|
||||
"kimi-k3" = ["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"]
|
||||
```
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Forked runs now create a fresh sandbox when they resume
|
||||
- Stored run events from older Fabro releases remain readable after event schema changes
|
||||
- Modal reasoning-token usage is now included in billing totals
|
||||
- Fallback resolution errors now report the underlying unavailable model or provider
|
||||
</Accordion>
|
||||
39
docs/public/changelog/2026-07-31.mdx
Normal file
39
docs/public/changelog/2026-07-31.mdx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
title: "DeepSeek, reusable subagents, and artifact downloads"
|
||||
date: "2026-07-31"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**The built-in `kimi` provider is now named `moonshot`.** Rename provider pins and `[llm.providers.kimi]` configuration to `moonshot`. Use `MOONSHOT_API_KEY`; `KIMI_API_KEY` remains a legacy credential fallback.
|
||||
</Warning>
|
||||
|
||||
## Direct DeepSeek support
|
||||
|
||||
Fabro now has an opt-in direct DeepSeek provider with DeepSeek V4 Flash, model aliases, reasoning-effort controls, and catalog pricing. DeepSeek aliases also work through Fireworks and OpenRouter when those providers are enabled.
|
||||
|
||||
## Reuse completed subagent sessions
|
||||
|
||||
Parent agents can now resume a completed child session instead of starting a new one. The run keeps the child history and continues forwarding its events to the stage view.
|
||||
|
||||
## Download current artifacts as a ZIP
|
||||
|
||||
The artifacts page can now download the latest version of every run artifact in one ZIP archive. The server builds the archive from the current file set and tolerates artifacts that disappear during collection.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Workflows">
|
||||
- Human-input waits now pause workflow stall timeouts
|
||||
- Output schemas are now included in agent task instructions
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Fixed stale MCP server processes after Fabro upgrades
|
||||
- Fixed Bedrock tool identifiers that contain unsupported characters
|
||||
- Structured-output repair failures now include actionable validation details
|
||||
- Provider quota errors now enter the correct failover path
|
||||
- Fixed canceled parallel branch durations
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- The steering bar now starts collapsed and expands when you select it
|
||||
</Accordion>
|
||||
26
docs/public/changelog/2026-08-01.mdx
Normal file
26
docs/public/changelog/2026-08-01.mdx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
title: "Durable pull request creation"
|
||||
date: "2026-08-01"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**Pull request creation is now asynchronous, and run IDs are server-owned.** `POST /api/v1/runs/{id}/pull_request` now returns `202` with a `PullRequestCreation` record and a `Location` to poll. Remove `run_id` from run manifests and stop using the removed CLI run-ID override; the server always allocates the run ID.
|
||||
</Warning>
|
||||
|
||||
## Pull request creation survives restarts
|
||||
|
||||
Explicit pull request creation is now stored before GitHub work begins. The request returns immediately, and you can poll its durable `pending`, `succeeded`, or `failed` state even if the server restarts.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="API">
|
||||
- New `GET /api/v1/runs/{id}/pull_request/creation` endpoint returns the latest explicit pull request request
|
||||
- Run projections now include the latest `pull_request_creation` state
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Runs now fail when routing bypasses a required goal gate
|
||||
- Reused child agents continue forwarding events after broadcast lag
|
||||
- Artifact archives batch storage reads and tolerate files that vanish during download
|
||||
- Structured-output repair errors now identify unexpected properties in a stable order
|
||||
</Accordion>
|
||||
15
docs/public/changelog/2026-08-03.mdx
Normal file
15
docs/public/changelog/2026-08-03.mdx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Leaner run manifests and Qwen3.8 Max"
|
||||
date: "2026-08-03"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**Unused run-manifest fields were removed from the API schema.** `GitContext.push_outcome`, `ManifestGoal.path`, and `ManifestTarget.identifier` are no longer generated in clients. Servers still accept older request bodies that contain these fields.
|
||||
</Warning>
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Added Qwen3.8 Max to the optional OpenRouter model catalog
|
||||
- Agent task reminders now remain consistent when a turn is interrupted
|
||||
</Accordion>
|
||||
15
docs/public/changelog/2026-08-04.mdx
Normal file
15
docs/public/changelog/2026-08-04.mdx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Faster Daytona edits and Kimi K3 Fast"
|
||||
date: "2026-08-04"
|
||||
---
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="CLI">
|
||||
- Increased the `fabro doctor` server health-check timeout to avoid false failures on slower local servers
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Agent file edits in Daytona now skip an unnecessary folder lookup
|
||||
- Added Kimi K3 Fast to the optional Fireworks model catalog
|
||||
</Accordion>
|
||||
14
docs/public/changelog/2026-08-05.mdx
Normal file
14
docs/public/changelog/2026-08-05.mdx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
title: "Correct node visit limits"
|
||||
date: "2026-08-05"
|
||||
---
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Workflows">
|
||||
- A node may now execute exactly `max_visits` times before the cycle guard stops the run
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Server diagnostics now stay within the client timeout instead of leaving `fabro doctor` waiting on a late response
|
||||
</Accordion>
|
||||
14
docs/public/changelog/2026-08-06.mdx
Normal file
14
docs/public/changelog/2026-08-06.mdx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
title: "Run filters and live billing totals"
|
||||
date: "2026-08-06"
|
||||
---
|
||||
|
||||
## Faster run browsing
|
||||
|
||||
The runs list now fills its repository and workflow filters from available runs. Billing totals for active runs also update from live events instead of waiting for the run to finish.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Event redaction no longer mutates the executable run specification used to resume, retry, or fork a run
|
||||
</Accordion>
|
||||
10
docs/public/changelog/2026-08-12.mdx
Normal file
10
docs/public/changelog/2026-08-12.mdx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
title: "Safer workflow root paths"
|
||||
date: "2026-08-12"
|
||||
---
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Workflow bundling once again normalizes relative root paths before reading them, rejects `~`-prefixed roots, and prevents `..` segments from resolving through a symlink to a different file
|
||||
</Accordion>
|
||||
23
docs/public/changelog/2026-08-13.mdx
Normal file
23
docs/public/changelog/2026-08-13.mdx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
title: "Immutable workflow versions"
|
||||
date: "2026-08-13"
|
||||
---
|
||||
|
||||
## Store validated workflow packages by content
|
||||
|
||||
The API can now validate and store an immutable workflow package with its entrypoint, files, and exact child-workflow dependencies. Repeating the same canonical package returns the same SHA-256 workflow version ID.
|
||||
|
||||
```http
|
||||
POST /api/v1/workflow-versions
|
||||
```
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="API">
|
||||
- Invalid workflow-version content and missing or non-canonical dependencies return `422` errors
|
||||
- Workflow version IDs are accepted case-insensitively and returned in canonical lowercase form
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Workflow-version validation now detects file path collisions that were previously hidden by sort order
|
||||
</Accordion>
|
||||
15
docs/public/changelog/2026-08-14.mdx
Normal file
15
docs/public/changelog/2026-08-14.mdx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Exact workflow lineage"
|
||||
date: "2026-08-14"
|
||||
---
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="API">
|
||||
- Run specifications and projections now preserve the exact immutable `workflow_version_id` used at admission
|
||||
- Workflow-version goals now include and validate the complete closure of child workflow dependencies
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Clone-based sandboxes now verify an admitted exact commit instead of silently using a newer branch head
|
||||
</Accordion>
|
||||
18
docs/public/changelog/2026-08-16.mdx
Normal file
18
docs/public/changelog/2026-08-16.mdx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
title: "Content-addressed blob hashes"
|
||||
date: "2026-08-16"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**The blob write response field changed from `id` to `hash`.** Regenerate API clients from version `0.2.0` of the OpenAPI spec. Older generated clients fail when the response no longer contains `id`.
|
||||
</Warning>
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="API">
|
||||
- Blob read routes now name the path parameter `blobHash`, and blob write responses return the SHA-256 value in `hash`
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Run dump hydration now shares a blob cache across JSON and text output
|
||||
</Accordion>
|
||||
16
docs/public/changelog/2026-08-17.mdx
Normal file
16
docs/public/changelog/2026-08-17.mdx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
title: "Reliable workflow dependency discovery"
|
||||
date: "2026-08-17"
|
||||
---
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="API">
|
||||
- SHA-256 blob and workflow-version hashes now use canonical lowercase values across API responses and generated clients
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Template dependencies are now found when their paths overlap workflow discovery roots
|
||||
- Template discovery errors now identify the source file that introduced the invalid dependency
|
||||
- File-based workflow-version goals now reject broken transitive includes before a run starts
|
||||
</Accordion>
|
||||
10
docs/public/changelog/2026-08-18.mdx
Normal file
10
docs/public/changelog/2026-08-18.mdx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
title: "Fireworks account failover"
|
||||
date: "2026-08-18"
|
||||
---
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Fireworks HTTP `412` account suspension responses now activate configured model fallbacks instead of being treated as invalid requests
|
||||
</Accordion>
|
||||
20
docs/public/changelog/2026-08-20.mdx
Normal file
20
docs/public/changelog/2026-08-20.mdx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
title: "Reliable sandbox pushes and Daytona recovery"
|
||||
date: "2026-08-20"
|
||||
---
|
||||
|
||||
## Git pushes survive token rotation
|
||||
|
||||
Sandbox pushes now use a cached GitHub installation-token source and retry with pinned credentials. Long runs can refresh expired tokens without changing credentials in the middle of one push attempt.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Daytona activation, start, and stop now wait and retry during provider state transitions
|
||||
- Clone-based sandboxes now verify exact commits after checkout
|
||||
- Sandbox state-change conflicts are now treated as transient infrastructure failures
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Daytona sandboxes now default to a 120-minute auto-stop interval; set `auto_stop = "0s"` to disable it
|
||||
</Accordion>
|
||||
65
docs/public/changelog/2026-08-21.mdx
Normal file
65
docs/public/changelog/2026-08-21.mdx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
title: "Run intents, additional repositories, and Venice search"
|
||||
date: "2026-08-21"
|
||||
---
|
||||
|
||||
## Version-backed run creation
|
||||
|
||||
Fabro's canonical `POST /api/v1/runs` endpoint now also accepts a strict
|
||||
`RunIntent` body that creates a submitted run from an immutable workflow
|
||||
version, a named server environment, and an explicit public GitHub repository
|
||||
target. Creation remains separate from execution; call the existing start
|
||||
endpoint when the run should begin.
|
||||
|
||||
Git targets require a branch and may pin a full commit SHA. Fabro normalizes a
|
||||
submitted SHA to lowercase and uses that exact commit during sandbox setup,
|
||||
without resolving it during admission or falling back to a newer branch HEAD.
|
||||
The environment ID defaults to `default`; this first target slice supports
|
||||
clone-enabled Docker and Daytona environments.
|
||||
|
||||
Existing manifest callers continue to work unchanged and can migrate
|
||||
independently. Intent runs do not consume repository `project.toml` settings:
|
||||
the selected server environment owns provider, working directory, and image,
|
||||
while workflow versions may still overlay resources, network, lifecycle,
|
||||
labels, and environment variables. Project metadata therefore no longer adds
|
||||
run labels on this path, and repository-wide settings must move into workflow
|
||||
versions or server defaults before a caller migrates.
|
||||
|
||||
## One token for the whole repository set
|
||||
|
||||
A run can now declare additional GitHub repositories that its stages may access through the managed `GITHUB_TOKEN`:
|
||||
|
||||
```toml
|
||||
[run.integrations.github]
|
||||
additional_repositories = ["fabro-sh/keystone"]
|
||||
permissions = { contents = "read" }
|
||||
```
|
||||
|
||||
The run origin stays implicit, and Fabro mints one installation token scoped to the origin plus every declared repository with the shared permission map. Inside stages, `gh` commands, raw GitHub API calls, plain Git over HTTPS, and the common SSH URL spellings (`git@github.com:owner/repo` and `ssh://git@github.com/owner/repo`) all work against the declared set — the SSH forms are transparently rewritten to authenticated HTTPS with no secret placed in Git configuration.
|
||||
|
||||
Every repository must share one owner and be reachable by the origin's GitHub App installation. Preflight resolves each repository's installation, mints the scoped token once, and probes every repository with `git ls-remote`, naming the exact repository when something is not accessible; run initialization enforces the same checks. A declared-but-inaccessible repository fails the run before its first stage.
|
||||
|
||||
Declaring additional repositories requires `contents = "read"` or `contents = "write"`. With `contents = "write"`, any stage can push to any declared repository — declare the smallest set and weakest permissions that work. See [Additional repositories](/integrations/github#additional-repositories) for details, including layering rules and `GH_TOKEN` precedence.
|
||||
|
||||
## Venice search backend for `web_search`
|
||||
|
||||
The built-in `web_search` tool now supports Venice as an automatic alternative to direct Brave Search. Fabro uses `BRAVE_SEARCH_API_KEY` when present. Otherwise it uses `VENICE_API_KEY` with Venice's Brave search engine. If neither key is present, the tool is not registered. Failed calls do not fall back between providers.
|
||||
|
||||
See [Venice Search](/integrations/venice-search) and [Brave Search](/integrations/brave-search).
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Workflows">
|
||||
- Docker and Daytona clones now default to 100 commits; set `[run.clone] depth = 0` for full history or a smaller positive depth for a shallower clone
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Runs now fail if durable event persistence is lost instead of continuing with incomplete history
|
||||
- Venice model responses now include provider-reported top-level costs in billing totals
|
||||
- Daytona post-clone setup now has a bounded wait
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Large context values moved out of agent prompt preambles now render as concise file references with bounded previews
|
||||
- Added the Venice built-in model provider catalog and integration guide
|
||||
</Accordion>
|
||||
27
docs/public/changelog/2026-08-23.mdx
Normal file
27
docs/public/changelog/2026-08-23.mdx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
title: "Empty run workspaces and more reliable Daytona activation"
|
||||
date: "2026-08-23"
|
||||
---
|
||||
|
||||
Version-backed run intents can now use `{ "kind": "none" }` when a workflow
|
||||
should start without a repository. The target creates an empty Docker or
|
||||
Daytona workspace and suppresses cloning even when the resolved workflow
|
||||
settings enable it.
|
||||
|
||||
Local environments reject the `none` target. Server-managed Local scratch
|
||||
workspaces remain a separate future capability.
|
||||
|
||||
Run intents can also use
|
||||
`{ "kind": "folder", "path": "/absolute/server/path" }` with a Local
|
||||
environment to execute in an existing server directory. Fabro resolves the
|
||||
submitted path to an existing canonical directory, persists that path, and
|
||||
uses it instead of the environment's `cwd`. Folder targets are intended for
|
||||
trusted single-tenant deployments and are rejected by Docker and Daytona.
|
||||
They execute in place without Fabro Git checkpoints, so retries retain the
|
||||
folder target while fork and rewind remain unavailable.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Daytona snapshot activation now allows the provider more time to finish before Fabro reports a timeout
|
||||
</Accordion>
|
||||
37
docs/public/changelog/2026-08-25.mdx
Normal file
37
docs/public/changelog/2026-08-25.mdx
Normal 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.
|
||||
34
docs/public/changelog/2026-08-26.mdx
Normal file
34
docs/public/changelog/2026-08-26.mdx
Normal 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.
|
||||
|
|
@ -59,13 +59,18 @@ Fabro performs this selection once when creating a run and persists the chosen p
|
|||
| `gemini-3.1-flash-lite` | gemini | `gemini-flash-lite`, `gemini-3.1-flash-lite-preview` | 1M | $0.25 / $1.50 | 200 tok/s |
|
||||
| `kimi-k2.5` | moonshot | | 262K | $0.60 / $3.00 | 50 tok/s |
|
||||
| `kimi-k3` | moonshot | `kimi` | 1M | $3.00 / $15.00 | n/a |
|
||||
| `kimi-k3-fast` | venice | `kimi-fast` | 1M | $4.50 / $22.50 | n/a |
|
||||
| `deepseek-v4-flash` | deepseek | `deepseek`, `deepseek-v4`, `deepseek-flash` | 1,048,576 | $0.14 / $0.28 | n/a |
|
||||
| `deepseek-v4-pro` | deepseek | | 1,048,576 | $0.435 / $0.87 | n/a |
|
||||
| `grok-4.6` | venice | `grok`, `grok46`, `grok-46` | 500K | $2.27 / $6.80 | n/a |
|
||||
| `laguna-s-2.1` | poolside | `laguna`, `laguna-s` | 1M | $0.10 / $0.20 | n/a |
|
||||
| `laguna-xs-2.1` | poolside | `laguna-xs` | 262K | $0.10 / $0.20 | n/a |
|
||||
| `glm-5.2` | zai | `glm`, `glm5`, `glm52`, `glm5.2` | 1M | $1.40 / $4.40 | n/a |
|
||||
| `glm-5.3` | venice | `glm`, `glm5`, `glm53`, `glm5.3`, `glm-5-3` | 1M | $1.75 / $5.50 | n/a |
|
||||
| `minimax-m2.5` | minimax | `minimax` | 197K | $0.30 / $1.20 | 45 tok/s |
|
||||
| `mercury-2` | inception | `mercury` | 131K | $0.25 / $0.75 | 1000 tok/s |
|
||||
| `qwen3.8-max` | venice | `qwen`, `qwen-max`, `qwen3.8`, `qwen-3.8`, `qwen38`, `qwen-3.8-max`, `qwen38-max` | 1M | $2.50 / $7.50 | n/a |
|
||||
| `qwen3.8-27b` | venice | `qwen-27b`, `qwen-3.8-27b`, `qwen38-27b` | 262K | $0.45 / $3.20 | n/a |
|
||||
|
||||
Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup.
|
||||
|
||||
|
|
@ -161,7 +166,7 @@ Workflow runs also add `x-session-id: <run-id>` to every LLM request so compatib
|
|||
|
||||
Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, `gemini`, `kimi`, and `gpt56`; model-level values override provider-level values.
|
||||
|
||||
Two profiles are selected per model rather than per provider, because they follow the model wherever it is served: `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna). The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional Brave-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
|
||||
Two profiles are selected per model rather than per provider, because they follow the model wherever it is served: `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna). The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional credential-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
|
||||
|
||||
Provider `billing_policy` defaults from `adapter` and controls usage-cost estimation. Use `openai`, `anthropic`, `gemini`, or `none`. Model rows may override it for models whose billing family differs from their provider's — for example, Claude models served through OpenRouter set `billing_policy = "anthropic"` so cache reads and writes price correctly.
|
||||
|
||||
|
|
@ -169,6 +174,10 @@ Provider `billing_policy` defaults from `adapter` and controls usage-cost estima
|
|||
Provider fields in configuration, APIs, and model routing are provider ID strings. Built-in names like `anthropic`, `openai`, and `gemini` still work, but custom IDs like `proxy` work anywhere a provider ID is accepted.
|
||||
</Note>
|
||||
|
||||
### Venice
|
||||
|
||||
Fabro ships a built-in [Venice](/integrations/venice) provider with a curated catalog of Venice-hosted Kimi, Grok, GLM, DeepSeek, and Qwen models. Store its API key with `fabro provider login --provider venice`. Pin `provider = "venice"` when a shared model slug must use Venice instead of a higher-priority direct provider.
|
||||
|
||||
### Poolside
|
||||
|
||||
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_id` values.
|
||||
|
|
@ -232,6 +241,7 @@ When no model or provider is specified, Fabro chooses the default offering on th
|
|||
| `moonshot` | `kimi-k3` |
|
||||
| `poolside` | `laguna-s-2.1` |
|
||||
| `zai` | `glm-5.2` |
|
||||
| `venice` | `deepseek-v4-flash` |
|
||||
| `minimax` | `minimax-m2.5` |
|
||||
| `inception` | `mercury-2` |
|
||||
|
||||
|
|
|
|||
|
|
@ -97,12 +97,14 @@
|
|||
"integrations/litellm",
|
||||
"integrations/bedrock",
|
||||
"integrations/deepseek",
|
||||
"integrations/venice",
|
||||
"integrations/poolside",
|
||||
"integrations/openrouter",
|
||||
"integrations/modal",
|
||||
"integrations/fireworks",
|
||||
"integrations/slack",
|
||||
"integrations/brave-search"
|
||||
"integrations/brave-search",
|
||||
"integrations/venice-search"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -185,6 +187,13 @@
|
|||
"api-reference/client-sdks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Workflow Versions",
|
||||
"icon": "code-branch",
|
||||
"pages": [
|
||||
"POST /api/v1/workflow-versions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Runs",
|
||||
"icon": "play",
|
||||
|
|
@ -200,6 +209,8 @@
|
|||
"POST /api/v1/runs/{id}/cancel",
|
||||
"POST /api/v1/runs/{id}/pause",
|
||||
"POST /api/v1/runs/{id}/unpause",
|
||||
"POST /api/v1/runs/{id}/pull_request",
|
||||
"GET /api/v1/runs/{id}/pull_request/creation",
|
||||
"GET /api/v1/runs/{id}/graph",
|
||||
"GET /api/v1/runs/{id}/events"
|
||||
]
|
||||
|
|
@ -218,6 +229,7 @@
|
|||
"icon": "file-export",
|
||||
"pages": [
|
||||
"GET /api/v1/runs/{id}/artifacts",
|
||||
"GET /api/v1/runs/{id}/artifacts/download",
|
||||
"GET /api/v1/runs/{id}/billing",
|
||||
{
|
||||
"group": "Run Internals",
|
||||
|
|
@ -293,10 +305,38 @@
|
|||
"tab": "Changelog",
|
||||
"icon": "clock-rotate-left",
|
||||
"groups": [
|
||||
{
|
||||
"group": "August 2026",
|
||||
"icon": "clock-rotate-left",
|
||||
"pages": [
|
||||
"changelog/2026-08-26",
|
||||
"changelog/2026-08-25",
|
||||
"changelog/2026-08-23",
|
||||
"changelog/2026-08-21",
|
||||
"changelog/2026-08-20",
|
||||
"changelog/2026-08-18",
|
||||
"changelog/2026-08-17",
|
||||
"changelog/2026-08-16",
|
||||
"changelog/2026-08-14",
|
||||
"changelog/2026-08-13",
|
||||
"changelog/2026-08-12",
|
||||
"changelog/2026-08-06",
|
||||
"changelog/2026-08-05",
|
||||
"changelog/2026-08-04",
|
||||
"changelog/2026-08-03",
|
||||
"changelog/2026-08-01"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "July 2026",
|
||||
"icon": "clock-rotate-left",
|
||||
"pages": [
|
||||
"changelog/2026-07-31",
|
||||
"changelog/2026-07-30",
|
||||
"changelog/2026-07-29",
|
||||
"changelog/2026-07-28",
|
||||
"changelog/2026-07-27",
|
||||
"changelog/2026-07-26",
|
||||
"changelog/2026-07-25",
|
||||
"changelog/2026-07-24",
|
||||
"changelog/2026-07-23",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -230,6 +230,12 @@ The preamble includes:
|
|||
|
||||
Internal keys (prefixed with `internal.`, `current`, `graph.`, `thread.`, `response.`) are excluded from preambles to avoid noise.
|
||||
|
||||
### Large preamble values
|
||||
|
||||
In fidelity modes that render context or completed-stage output, one value can contribute at most 8 KiB of serialized JSON inline. Fabro stores larger values as content-addressed blobs, materializes them as readable files, and puts the size, file path, and a 300-character preview in the preamble. The agent can read the file when it needs the full value.
|
||||
|
||||
This prompt limit is separate from durable artifact offloading. If Fabro cannot demote a value, it logs a warning and keeps that value inline so the stage can continue.
|
||||
|
||||
## Artifact offloading
|
||||
|
||||
When a stage produces a large output (over 100KB of serialized JSON), Fabro stores the serialized bytes in a global content-addressed blob store and replaces the context value with a durable blob ref. Command output is always finalized into a blob ref after command completion, even when it is small or empty:
|
||||
|
|
@ -244,7 +250,7 @@ Checkpoints and checkpoint-completed events persist these `blob://` refs, not ho
|
|||
Before Fabro builds a preamble or starts the next stage, it resolves any blob refs into execution-local files so handlers and agents still see normal `file://` references:
|
||||
|
||||
- Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_hash}.json`
|
||||
- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
- Remote sandboxes materialize blobs under the sandbox runtime directory, `{runtime_directory}/blobs/{blob_hash}.json`. This directory lives outside the repository checkout, so materialized blobs never appear in `git status` and are never committed by a checkpoint.
|
||||
|
||||
These materialized `file://` paths are runtime-only. They are not written back into durable context snapshots.
|
||||
|
||||
|
|
|
|||
|
|
@ -231,6 +231,18 @@ Install seeds a `default` environment into SQLite. It is a normal persisted envi
|
|||
|
||||
Create a server-managed local-provider environment through the environments API when you need a host `cwd`.
|
||||
|
||||
A version-backed run intent can submit
|
||||
`{ "kind": "folder", "path": "/absolute/server/path" }` to run in an existing
|
||||
server directory. Fabro accepts this target only with a Local environment,
|
||||
resolves symlinks and `..`, requires an existing directory, and persists the
|
||||
canonical UTF-8 path. The target path takes precedence over the environment's
|
||||
`cwd`. Because the run executes in place with the Local provider's unrestricted
|
||||
host access, use folder targets only in trusted single-tenant deployments.
|
||||
Docker and Daytona always reject folder targets. This does not add Local Git
|
||||
cloning or Local scratch workspaces for the `none` target. Local folder runs
|
||||
execute in place without Fabro Git checkpoints: retries retain the canonical
|
||||
folder target, but fork and rewind are unavailable for these runs.
|
||||
|
||||
When `cwd` is set, local runs execute commands from that absolute server-side
|
||||
path. When it is unset, Fabro keeps same-host compatibility by using the
|
||||
submitted source directory only if that path exists on the server. If neither is
|
||||
|
|
@ -258,7 +270,7 @@ memory = "4GB"
|
|||
mode = "block"
|
||||
```
|
||||
|
||||
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace. Set `[run.clone] enabled = false` to start with an empty workspace. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
|
||||
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
|
||||
|
||||
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ When running workflows through the API server, subscribe to the [run events endp
|
|||
|
||||
### Web UI
|
||||
|
||||
The web frontend consumes the SSE stream automatically and shows stage progress, tool calls, command output, and human interaction as they happen. Use the stage `Thread` and `Debug` views for per-stage activity, or the run-level `Run Events` page when you need the full event stream with search and category filters. The `Run Events` page also includes a Waterfall view for comparing stage durations and inspecting timing details from hover popovers.
|
||||
The web frontend consumes the SSE stream automatically and shows stage progress, tool calls, command output, and human interaction as they happen. Use the stage `Chat` view for a readable conversation, `Thread` for the detailed agent transcript and disclosed provider reasoning, and `Debug` for raw stage events. Use the run-level `Run Events` page when you need the full event stream with search and category filters. The `Run Events` page also includes a Waterfall view for comparing stage durations and inspecting timing details from hover popovers.
|
||||
|
||||
<Frame caption="The Stages tab shows the full agent conversation including tool calls and responses.">
|
||||
<img src="/images/web/run-stages.png" alt="Fabro web UI run stages showing agent conversation with tool calls" />
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -241,10 +241,16 @@ Configure whether clone-based sandboxes clone the run's GitHub origin before exe
|
|||
```toml title="run.toml"
|
||||
[run.clone]
|
||||
enabled = true
|
||||
depth = 100
|
||||
```
|
||||
|
||||
Set `enabled = false` to start Docker and Daytona runs with an empty provider workspace. Use [prepare steps](#runprepare) to clone or create any files the workflow needs.
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `enabled` | When `false`, Fabro skips the repository clone. Defaults to `true`. |
|
||||
| `depth` | Git history depth for Docker and Daytona. Defaults to `100`. Set it to `0` to clone full history. |
|
||||
|
||||
### `[run.run_branch]`
|
||||
|
||||
Configure Fabro's managed `fabro/run/<id>` checkpoint branch.
|
||||
|
|
@ -363,6 +369,24 @@ Only requested permissions are included. The upper bound is the permission set g
|
|||
|
||||
This table follows the normal settings precedence order. A higher-precedence layer can set `permissions = {}` to clear inherited permissions and run without a GitHub token.
|
||||
|
||||
### `[run.integrations.github].additional_repositories`
|
||||
|
||||
Declare extra GitHub repositories, beyond the implicit run origin, that the minted `GITHUB_TOKEN` must cover. The one `permissions` map applies to the origin and every declared repository.
|
||||
|
||||
```toml title="run.toml"
|
||||
[run.integrations.github]
|
||||
additional_repositories = ["fabro-sh/keystone"]
|
||||
permissions = { contents = "read" }
|
||||
```
|
||||
|
||||
Each entry is a full `owner/repository` slug. Every repository in the effective set must share one owner and be reachable by the origin repository's GitHub App installation. A non-empty list requires `contents = "read"` or `contents = "write"`. Malformed slugs, case-insensitive duplicates, cross-owner sets, and sets larger than 499 entries fail configuration validation with indexed error paths such as `run.integrations.github.additional_repositories[1]`.
|
||||
|
||||
Unlike permissions-only configuration, declared additional repositories are a hard requirement: missing credentials, a missing origin, or an inaccessible declared repository fails preflight and run initialization with the repository named.
|
||||
|
||||
The higher-precedence list replaces the lower one wholesale — no union and no `...` splice — and `additional_repositories = []` explicitly clears an inherited list. `additional_repositories` and `permissions` resolve independently; if layering leaves repositories declared while permissions were cleared, resolution reports the invalid combination instead of dropping either field.
|
||||
|
||||
See [Additional repositories](/integrations/github#additional-repositories) for what works inside stages (`gh`, GitHub API, plain Git over HTTPS and the common SSH spellings) and for the security boundary.
|
||||
|
||||
### `[run.notifications]`
|
||||
|
||||
Define named notification routes for run events. Slack lifecycle notifications are configured here, not in server config.
|
||||
|
|
@ -420,16 +444,19 @@ repo_url = "https://github.com/fabro-sh/fabro"
|
|||
language = "rust"
|
||||
```
|
||||
|
||||
Inputs can be used in graph `goal` and node `prompt` attributes with `{{ inputs.name }}` syntax:
|
||||
Inputs can be used in graph `goal`, root `model_stylesheet`, and node `prompt` attributes with `{{ inputs.name }}` syntax:
|
||||
|
||||
```dot title="c-i.fabro"
|
||||
digraph CI {
|
||||
graph [goal="Run tests for {{ inputs.repo_name }}"]
|
||||
graph [
|
||||
goal="Run tests for {{ inputs.repo_name }}",
|
||||
model_stylesheet="{% if inputs.language == 'rust' %}* { reasoning_effort: high; }{% endif %}"
|
||||
]
|
||||
test [label="Test", prompt="Clone {{ inputs.repo_url }} and run the {{ inputs.language }} test suite."]
|
||||
}
|
||||
```
|
||||
|
||||
Inputs cannot parameterize workflow structure, file references such as node IDs, edges, `import` paths, `@file` paths, or child workflow paths, or any attribute besides `prompt` and `goal` — other attributes such as `script` and `label` are literal text.
|
||||
Inputs cannot parameterize workflow structure, file references such as node IDs, edges, `import` paths, `@file` paths, or child workflow paths, or any full-template attribute besides `prompt`, `goal`, and the root `model_stylesheet`. Command `script` supports only simple value substitution. Other attributes such as `label` are literal text.
|
||||
|
||||
If a workflow template references an undefined input like `{{ inputs.langauge }}`, `fabro validate` reports a warning. Run-style commands promote that diagnostic to an error before creating or starting a run.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ title: "Brave Search"
|
|||
description: "Give Fabro agents web search capabilities via the Brave Search API"
|
||||
---
|
||||
|
||||
Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. It uses the [Brave Search API](https://brave.com/search/api/) to return titles, URLs, and descriptions for any query. Setting the API key is the only configuration needed — the tool is then registered for all provider profiles (Anthropic, OpenAI, Gemini). Without a key the tool is not registered at all, so agents are never offered a search tool they cannot use.
|
||||
Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. It uses the [Brave Search API](https://brave.com/search/api/) to return titles, URLs, and descriptions for any query.
|
||||
|
||||
Fabro selects the backend from the credentials in its vault. Direct Brave Search is preferred whenever `BRAVE_SEARCH_API_KEY` is present. When that key is absent, Fabro can use [Venice Search](/integrations/venice-search) with `VENICE_API_KEY` instead.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -21,7 +23,7 @@ fabro secret set BRAVE_SEARCH_API_KEY BSA...
|
|||
fabro doctor
|
||||
```
|
||||
|
||||
The doctor output should show **Brave Search** as "connected". If the key is missing, web search is reported as a warning — workflows still run, but the `web_search` tool is omitted from the agent's tool set and its system prompt, so agents fall back to other tools.
|
||||
The doctor output should show **Web Search** as `brave: configured and reachable`. If the Brave key is missing but a Venice key exists, Fabro checks Venice instead. If neither key exists, web search is reported as a warning. Workflows still run, but the `web_search` tool is omitted from the agent's tool set and its system prompt.
|
||||
|
||||
The Fabro server reads this key from the vault only. It does not read `BRAVE_SEARCH_API_KEY` from process env or `server.env`.
|
||||
|
||||
|
|
@ -39,7 +41,7 @@ Agents call the `web_search` tool with a query string. Fabro sends the query to
|
|||
The Rust book
|
||||
```
|
||||
|
||||
If `BRAVE_SEARCH_API_KEY` is not configured in the vault, the tool returns an error explaining that the key is required. The agent can then fall back to other approaches.
|
||||
If `BRAVE_SEARCH_API_KEY` is not configured, Fabro uses Venice when `VENICE_API_KEY` is available. If neither key is configured, the tool is not registered.
|
||||
|
||||
See the [`web_search` tool reference](/agents/tools#web_search) for parameters and details.
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,15 @@ provider = "daytona"
|
|||
enabled = false
|
||||
```
|
||||
|
||||
Daytona clones 100 commits by default. To keep only the newest commit, set a smaller clone depth:
|
||||
|
||||
```toml title="run.toml"
|
||||
[run.clone]
|
||||
depth = 1
|
||||
```
|
||||
|
||||
Set `depth = 0` to clone the full repository history.
|
||||
|
||||
If the clone fails without GitHub access configured, Fabro suggests running the setup flow:
|
||||
|
||||
```
|
||||
|
|
@ -222,7 +231,7 @@ Custom Daytona snapshot names are computed from the Dockerfile, resource hints,
|
|||
|
||||
### "Timed out waiting for snapshot to become active"
|
||||
|
||||
Snapshot creation took longer than 10 minutes. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active.
|
||||
Snapshot creation took longer than 30 minutes. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active.
|
||||
|
||||
### Git clone fails for private repositories
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ When you choose the GitHub App strategy, the CLI opens GitHub with a pre-filled
|
|||
| Emails | Read | Read verified email for OAuth login |
|
||||
| Dependabot alerts | Write | Read and manage repository vulnerability alerts |
|
||||
| Organization projects | Write | Read and update organization Projects V2 |
|
||||
| Packages | Read | Download private GitHub Packages (e.g. npm registry) with the sandbox `GITHUB_TOKEN` |
|
||||
|
||||
These permissions are included when Fabro registers a new app. For an existing GitHub App, add the missing permissions in the app's settings, then approve the permission update on each installation before workflows can use them.
|
||||
|
||||
|
|
@ -226,9 +227,33 @@ When a workflow runs in a remote sandbox (Daytona or Docker), Fabro clones the c
|
|||
|
||||
For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints.
|
||||
|
||||
#### Git targets for run intents
|
||||
|
||||
The `RunIntent` create body always names a GitHub repository and a working
|
||||
branch. It may also select a bare tag, pin a full 40-character commit SHA, or
|
||||
include both:
|
||||
|
||||
| Target fields | Revision selected when the worker starts |
|
||||
|---|---|
|
||||
| `branch` | The branch HEAD |
|
||||
| `branch` + `sha` | The exact commit |
|
||||
| `branch` + `tag` | The tag's peeled commit |
|
||||
| `branch` + `tag` + `sha` | The exact commit; the tag remains part of the run's identity |
|
||||
|
||||
`branch` is always the attached branch inside the sandbox. `tag` is a bare tag
|
||||
name such as `v1.2.3`; `refs/tags/v1.2.3` and `tags/v1.2.3` are rejected. An
|
||||
unpinned tag is resolved when the worker starts, so moving a tag before that
|
||||
point changes the selected commit.
|
||||
|
||||
Creating the run validates the selectors and lowercase-normalizes `sha`, but
|
||||
does not contact GitHub or prove ancestry. An exact SHA is authoritative:
|
||||
Fabro does not prove it belongs to the branch or matches the accompanying tag.
|
||||
If a requested tag or exact commit is unavailable, sandbox setup fails without
|
||||
falling back to a same-named branch or the branch's newer HEAD.
|
||||
|
||||
### GITHUB_TOKEN injection
|
||||
|
||||
When any settings layer declares `[run.integrations.github.permissions]`, Fabro prepares a scoped GitHub App token source and exposes it as the `GITHUB_TOKEN` environment variable in sandbox command and agent execution. Agents running inside the sandbox can use this token for GitHub API calls, cloning additional private repos, or pushing to branches. The GitHub CLI (`gh`) reads `GITHUB_TOKEN` automatically, so command stages can run `gh pr list`, `gh issue create`, and similar commands without an explicit `gh auth login`.
|
||||
When any settings layer declares `[run.integrations.github.permissions]`, Fabro prepares a scoped GitHub App token source and exposes it as the `GITHUB_TOKEN` environment variable in sandbox command and agent execution. Agents running inside the sandbox can use this token for GitHub API calls and pushes within the granted permissions. The GitHub CLI (`gh`) reads `GITHUB_TOKEN` automatically, so command stages can run `gh pr list`, `gh issue create`, and similar commands without an explicit `gh auth login`.
|
||||
|
||||
```toml title="workflow.toml"
|
||||
[run.integrations.github.permissions]
|
||||
|
|
@ -238,6 +263,46 @@ pull_requests = "write"
|
|||
|
||||
Only the listed permissions are requested — the token is scoped to the minimum access needed. If the GitHub App isn't configured or the repository lacks an installation, the run logs a warning and continues without the token.
|
||||
|
||||
In App mode, the token covers only the run's origin repository unless the run declares [additional repositories](#additional-repositories). Injecting `GITHUB_TOKEN` alone does not make other private repositories reachable.
|
||||
|
||||
### Additional repositories
|
||||
|
||||
A run can declare extra GitHub repositories that its stages may access through the same `GITHUB_TOKEN`:
|
||||
|
||||
```toml title="workflow.toml"
|
||||
[run.integrations.github]
|
||||
additional_repositories = ["fabro-sh/keystone"]
|
||||
permissions = { contents = "read" }
|
||||
```
|
||||
|
||||
The run origin stays implicit — never list it. Each entry is a full `owner/repository` slug (no scheme, host, ref, or extra path component). Fabro mints **one** installation token scoped to the origin plus every declared repository, with the one shared `permissions` map applying to all of them.
|
||||
|
||||
What works against every declared repository, within the granted permissions:
|
||||
|
||||
- **`gh` CLI and raw GitHub API calls** through `GITHUB_TOKEN`.
|
||||
- **Plain Git over HTTPS** (`git clone https://github.com/owner/repo`), through a secret-free credential helper that reads `$GITHUB_TOKEN` at invocation time.
|
||||
- **The common SSH spellings** `git@github.com:owner/repo[.git]` and `ssh://git@github.com/owner/repo[.git]`, through per-repository SSH-to-HTTPS rewrites injected into the stage environment.
|
||||
|
||||
Fabro does not clone additional repositories for you; a workflow that needs one on disk adds its own clone step (`git clone https://github.com/owner/repo` or `gh repo clone owner/repo`).
|
||||
|
||||
Requirements and validation:
|
||||
|
||||
- Every repository in the effective set must share **one owner** and be reachable by the origin repository's GitHub App installation, because one App installation covers one account. Cross-owner declarations fail configuration validation; a same-owner repository outside the installation fails preflight and run initialization with the repository named.
|
||||
- A non-empty `additional_repositories` requires `contents = "read"` or `contents = "write"` in the permission map.
|
||||
- Malformed slugs, duplicates (repository identity is case-insensitive), and sets larger than 499 entries fail configuration validation with indexed error paths.
|
||||
- Unlike permissions-only configuration, declared additional repositories are a hard requirement: missing GitHub credentials, a missing origin, or an inaccessible declared repository fails preflight and run initialization instead of continuing without the token.
|
||||
- Layering: the higher-precedence `additional_repositories` list replaces the lower one wholesale (no union, no `...` splice), and `additional_repositories = []` explicitly clears an inherited list. `permissions` keeps its existing whole-map replacement behavior. If layering leaves repositories declared with permissions cleared, configuration resolution reports the invalid combination.
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- **Token strategy (PAT):** the configured PAT is used as-is. The repository list drives validation and preflight probes, but it cannot narrow the PAT's inherent GitHub scope — App mode remains the least-authority option.
|
||||
- **`GH_TOKEN` precedence:** `gh` checks `GH_TOKEN` before `GITHUB_TOKEN`. If the resolved run environment defines `GH_TOKEN`, `gh` uses it instead of the managed token; Fabro never sets or removes `GH_TOKEN`, and preflight warns when additional repositories are declared alongside one.
|
||||
- **SSH rewrites match by prefix.** With `owner/repo` declared, the SSH spelling of `owner/repo-other` is also rewritten to HTTPS. The scoped token is invalid for undeclared repositories at GitHub, so authority is unchanged — but a private undeclared repository fails with a GitHub authorization error instead of a missing-credential or SSH error.
|
||||
|
||||
#### Security boundary
|
||||
|
||||
Workflow authors may name any repository reachable by the server's GitHub App installation; Fabro applies no second server-side repository intersection. The token is scoped server-side to exactly the declared set — a request to an undeclared repository fails at GitHub, and Fabro never mints an unscoped installation-wide token. With `contents = "write"`, **any stage can push to any declared repository**. Declare the smallest repository set and the weakest permissions that work.
|
||||
|
||||
Installation Access Tokens are short-lived. Fabro refreshes its own credentials before checkpoint pushes. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro also re-mints the token and rewrites the sandbox's `origin` URL before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage.
|
||||
|
||||
`FABRO_PUSH_CRED_REFRESH_AHEAD` defaults to enabled; set it to `0`, `false`, `off`, `no`, or an empty value to disable both turn-entry and background refresh. `FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS` overrides the background interval, and `0` disables only the background loop. This refresh loop is ACP-specific; command and native/API agent stages do not run it. Reconnected sandboxes for resumed or parked runs currently lack the App credentials needed for ACP refresh, so the refresh is skipped there.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
79
docs/public/integrations/venice-search.mdx
Normal file
79
docs/public/integrations/venice-search.mdx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
title: "Venice Search"
|
||||
description: "Give Fabro agents web search capabilities via Venice's augment/search API"
|
||||
---
|
||||
|
||||
Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. Fabro uses [Venice Search](https://docs.venice.ai/api-reference/endpoint/augment/search) automatically when `VENICE_API_KEY` is available and a direct [Brave Search](/integrations/brave-search) key is not.
|
||||
|
||||
Venice Search reuses the same `VENICE_API_KEY` as the Venice LLM provider. Agents keep calling `web_search`; only the HTTP backend changes.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Store a Venice API key on the Fabro server (skip this if the Venice LLM provider is already logged in):
|
||||
|
||||
```bash
|
||||
fabro provider login --provider venice
|
||||
# or
|
||||
fabro secret set VENICE_API_KEY venice-...
|
||||
```
|
||||
|
||||
Fabro prefers direct Brave Search whenever `BRAVE_SEARCH_API_KEY` is also present. To select Venice, leave that key unset or remove it:
|
||||
|
||||
```bash
|
||||
fabro secret rm BRAVE_SEARCH_API_KEY
|
||||
```
|
||||
|
||||
2. Verify the key is working:
|
||||
|
||||
```bash
|
||||
fabro doctor
|
||||
```
|
||||
|
||||
The doctor output should show **Web Search** as `venice: configured and reachable`. If neither Venice nor Brave is configured, web search is reported as a warning. Workflows still run, but the `web_search` tool is omitted from the agent's tool set.
|
||||
|
||||
The Fabro server reads this key from the vault only. It does not read `VENICE_API_KEY` from process env or `server.env`.
|
||||
|
||||
## How it works
|
||||
|
||||
Agents call the `web_search` tool with a query string. Fabro `POST`s to Venice `https://api.venice.ai/api/v1/augment/search` with the Brave search engine and returns numbered results with title, URL, description, and date when Venice includes one:
|
||||
|
||||
```
|
||||
1. Rust Lang
|
||||
https://rust-lang.org
|
||||
A systems language
|
||||
2026-01-02
|
||||
```
|
||||
|
||||
Venice Search is billed by Venice at $0.01 per request and is rate-limited to 20 requests per minute on the Venice side. Queries longer than 400 characters are rejected before the HTTP call.
|
||||
|
||||
If `VENICE_API_KEY` is absent but `BRAVE_SEARCH_API_KEY` exists, Fabro uses direct Brave Search. If neither key exists, the tool is not registered. After selecting Venice, a failed call returns an error; Fabro does not retry through direct Brave Search.
|
||||
|
||||
See the [`web_search` tool reference](/agents/tools#web_search) for parameters and details.
|
||||
|
||||
## Permissions
|
||||
|
||||
`web_search` is classified as a `shell` category tool, requiring the `full` [permission level](/agents/permissions) for auto-approval. At lower permission levels:
|
||||
|
||||
- **Interactive mode** — the user is prompted to approve each call
|
||||
- **Non-interactive mode** (`--auto-approve`) — calls are denied
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"VENICE_API_KEY is not configured"** — Add the key with `fabro secret set VENICE_API_KEY <key>` or `fabro provider login --provider venice`. Run `fabro doctor` to verify.
|
||||
|
||||
**"Venice Search API returned status 401"** — The API key is invalid or expired. Create a new key at [venice.ai](https://venice.ai).
|
||||
|
||||
**"Venice Search API returned status 402"** — The Venice account is out of credits. The error may include a remaining-balance hint.
|
||||
|
||||
**"Venice Search API returned status 429"** — Rate limit exceeded (20 requests per minute on Venice Search). Reduce the frequency of `web_search` calls.
|
||||
|
||||
## Further reading
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Tools" icon="wrench" href="/agents/tools#web_search">
|
||||
Full `web_search` tool reference — parameters, output format, and error handling.
|
||||
</Card>
|
||||
<Card title="Brave Search" icon="globe" href="/integrations/brave-search">
|
||||
Direct Brave Search backend, preferred whenever its key is configured.
|
||||
</Card>
|
||||
</Columns>
|
||||
125
docs/public/integrations/venice.mdx
Normal file
125
docs/public/integrations/venice.mdx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
---
|
||||
title: "Venice"
|
||||
description: "Run Kimi, Grok, GLM, DeepSeek, and Qwen models through Venice"
|
||||
---
|
||||
|
||||
[Venice](https://venice.ai/) provides an OpenAI-compatible API for hosted text models. Fabro enables the `venice` provider in its built-in catalog and maps stable Fabro model slugs to Venice's API model IDs.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Venice account
|
||||
- An inference API key from [venice.ai/settings/api](https://venice.ai/settings/api)
|
||||
- A running Fabro server
|
||||
|
||||
## Configure credentials
|
||||
|
||||
Store the API key in the target Fabro server vault:
|
||||
|
||||
```bash
|
||||
fabro provider login --provider venice
|
||||
|
||||
# For a non-default remote server:
|
||||
fabro provider login --server https://your-fabro.example --provider venice
|
||||
|
||||
# Or set the vault token directly:
|
||||
fabro secret set VENICE_API_KEY
|
||||
fabro secret --server https://your-fabro.example set VENICE_API_KEY
|
||||
```
|
||||
|
||||
Standalone SDK usage outside a Fabro server can use an env-backed credential source explicitly:
|
||||
|
||||
```bash
|
||||
export VENICE_API_KEY=<api-key>
|
||||
```
|
||||
|
||||
Fabro sends bearer-authenticated Chat Completions requests to `https://api.venice.ai/api/v1`.
|
||||
|
||||
## Included models
|
||||
|
||||
| Fabro model slug | Venice API ID | Context | Max output | Role and aliases |
|
||||
|---|---|---:|---:|---|
|
||||
| `kimi-k3` | `kimi-k3` | 1,000,000 | 131,072 | Alias `kimi` |
|
||||
| `kimi-k3-fast` | `kimi-k3-fast-api` | 1,000,000 | 131,072 | Alias `kimi-fast` |
|
||||
| `grok-4.6` | `grok-4-6` | 500,000 | 32,000 | Aliases `grok`, `grok46`, `grok-46` |
|
||||
| `glm-5.3` | `z-ai-glm-5-3` | 1,000,000 | 131,072 | Aliases `glm`, `glm5`, `glm53`, `glm5.3`, `glm-5-3` |
|
||||
| `deepseek-v4-flash` | `deepseek-v4-flash-0731` | 1,000,000 | 32,768 | Provider default; aliases `deepseek`, `deepseek-v4`, `deepseek-flash` |
|
||||
| `deepseek-v4-pro` | `deepseek-v4-pro-0813` | 1,000,000 | 32,768 | Alias `deepseek-pro` |
|
||||
| `qwen3.8-max` | `qwen-3-8-max` | 1,000,000 | 131,072 | Aliases `qwen`, `qwen-max`, `qwen3.8`, `qwen-3.8`, `qwen38`, `qwen-3.8-max`, `qwen38-max` |
|
||||
| `qwen3.8-27b` | `qwen-3-8-27b` | 262,144 | 131,072 | Aliases `qwen-27b`, `qwen-3.8-27b`, `qwen38-27b` |
|
||||
|
||||
Venice API IDs are also valid provider-scoped selectors. Fabro persists the stable Fabro slug and the selected provider when it creates a run.
|
||||
|
||||
## Select Venice explicitly
|
||||
|
||||
Some Venice models use the same stable slugs as direct providers. An unqualified selector chooses the highest-priority ready provider. For example, `deepseek` can select the direct DeepSeek provider when both API keys are configured.
|
||||
|
||||
Pin Venice when the run must use Venice:
|
||||
|
||||
```bash
|
||||
fabro model list --provider venice
|
||||
fabro model test --provider venice --model deepseek-v4-flash --tools
|
||||
fabro run workflow.fabro --provider venice --model deepseek-v4-flash
|
||||
```
|
||||
|
||||
In a workflow stylesheet:
|
||||
|
||||
```dot title="workflow.fabro"
|
||||
digraph Example {
|
||||
graph [
|
||||
model_stylesheet="
|
||||
* { provider: venice; model: deepseek-v4-flash; }
|
||||
.complex { provider: venice; model: qwen; }
|
||||
.fast { provider: venice; model: kimi-fast; }
|
||||
"
|
||||
]
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
work [label="Implement", class="complex"]
|
||||
check [label="Check", class="fast"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
start -> work -> check -> exit
|
||||
}
|
||||
```
|
||||
|
||||
The generic Qwen aliases `qwen` and `qwen3.8` select Qwen 3.8 Max. Use a size-specific alias such as `qwen-27b` to select Qwen 3.8 27B.
|
||||
|
||||
## Capabilities and reasoning
|
||||
|
||||
All included models support tool calling and reasoning. Kimi K3, Kimi K3 Fast, Grok 4.6, Qwen 3.8 Max, and Qwen 3.8 27B also accept image input.
|
||||
|
||||
Fabro exposes native reasoning-effort controls only when Venice supports them:
|
||||
|
||||
| Model | Reasoning effort values |
|
||||
|---|---|
|
||||
| `grok-4.6` | `low`, `medium`, `high`, `xhigh` |
|
||||
| `glm-5.3` | `low`, `high`, `max` |
|
||||
| `deepseek-v4-flash` | `low`, `high`, `max` |
|
||||
| `qwen3.8-27b` | `low`, `medium`, `xhigh` |
|
||||
|
||||
The other models reason by default but do not expose a Venice reasoning-effort control. Fabro omits sampling parameters for Kimi and DeepSeek because those routes do not use them with their configured reasoning behavior.
|
||||
|
||||
## Pricing and prompt caching
|
||||
|
||||
The built-in catalog uses Venice's published prices per million tokens:
|
||||
|
||||
| Model | Uncached input | Cache hit | Output |
|
||||
|---|---:|---:|---:|
|
||||
| `kimi-k3` | $3.75 | $0.375 | $18.75 |
|
||||
| `kimi-k3-fast` | $4.50 | $0.45 | $22.50 |
|
||||
| `grok-4.6` | $2.27 | $0.57 | $6.80 |
|
||||
| `glm-5.3` | $1.75 | $0.325 | $5.50 |
|
||||
| `deepseek-v4-flash` | $0.175 | $0.035 | $0.35 |
|
||||
| `deepseek-v4-pro` | $1.65 | $0.165 | $4.95 |
|
||||
| `qwen3.8-max` | $2.50 | $0.3125 | $7.50 |
|
||||
| `qwen3.8-27b` | $0.45 | n/a | $3.20 |
|
||||
|
||||
Fabro reports cached input separately when Venice returns cache usage for the selected model. Prices and model availability can change upstream; use `fabro model list --provider venice` to inspect the catalog shipped with your Fabro version and the [Venice model catalog](https://docs.venice.ai/models/overview) for the current upstream service.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"No credential was found for provider 'venice'"** — Store `VENICE_API_KEY` in the server vault with `fabro provider login --provider venice`. Pass `--server` when configuring a remote Fabro server.
|
||||
|
||||
**A shared model used another provider** — Pin Venice with `--provider venice` or `provider: venice` in the workflow stylesheet. Unqualified selectors use provider priority.
|
||||
|
||||
**A Venice API model ID is rejected without a provider** — Use the stable Fabro slug for portable selection, or qualify the API ID with the provider, such as `venice:qwen-3-8-max`.
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,49 @@ Common flags:
|
|||
|
||||
See [Server Configuration](/administration/server-configuration) for the full `settings.toml` reference.
|
||||
|
||||
### SQLite blob storage activation
|
||||
|
||||
On startup, Fabro activates SQLite as the only live content-addressed blob
|
||||
store before it opens routes, schedulers, workers, webhooks, reapers, or the
|
||||
ready callback. The activation inventories the exact legacy SlateDB blob
|
||||
prefix, checks disk headroom sized to the rows not yet imported (a warm
|
||||
restart with nothing left to import only needs a small fixed headroom; on
|
||||
filesystems whose free space cannot be determined the check is skipped with
|
||||
a warning), imports in bounded transactions, compares every legacy blob
|
||||
byte-for-byte with SQLite, runs a live SQLite integrity check, and attempts a
|
||||
final WAL truncate checkpoint. A busy final truncate logs a warning and startup
|
||||
continues so a later checkpoint can finish after the blocking reader exits.
|
||||
Boots that import new rows additionally re-verify every
|
||||
legacy blob against SQLite and validate every SQLite blob row independently.
|
||||
Any failure stops startup. Warm boots that import no rows skip that full target
|
||||
scan: the import pass has already byte-compared every retained legacy row, and
|
||||
SQLite-only blobs are hash-validated when read. Rows committed by an interrupted
|
||||
import are retained so the next startup can resume, but the legacy source is
|
||||
never modified and there is no fallback or dual read/write path.
|
||||
|
||||
For a non-empty legacy inventory, the first activation also creates the
|
||||
private sibling backup
|
||||
`fabro.sqlite3.pre-blob-activation.bak`. Fabro writes the staging database
|
||||
inside a private same-directory area, applies owner-only permissions, flushes
|
||||
and validates it, then publishes the backup without overwriting an existing file.
|
||||
A valid retained backup is revalidated on every warm restart and is preserved
|
||||
as the original pre-activation safety artifact. If any legacy row is already
|
||||
present in SQLite, a missing retained backup stops startup rather than silently
|
||||
moving that rollback boundary forward. It is not a promise that an
|
||||
older binary can safely resume after the activated server has accepted new
|
||||
work; recovery after that boundary is forward-only. Empty legacy inventories
|
||||
do not need this backup.
|
||||
|
||||
Keep both the unchanged legacy `blobs/sha256` prefix and the private activation
|
||||
backup for at least 30 consecutive calendar days after the first successful
|
||||
production activation. Cleanup is eligible only after a successful cold
|
||||
activation, a later warm restart that revalidates the backup and byte-compares
|
||||
every retained legacy blob against SQLite, and 30 days of production observation
|
||||
with no unresolved inventory, import, verification, integrity, backup, or
|
||||
checkpoint failure. Scott must review that evidence and explicitly authorize a
|
||||
separate cleanup change. Day 30 is only the earliest eligibility date; nothing
|
||||
is deleted automatically, and incomplete evidence extends the support window.
|
||||
|
||||
## Submitting runs
|
||||
|
||||
Workflows are submitted via the REST API and executed in the background. The exact request body is documented in the API reference:
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
|
|||
| Scope | Examples |
|
||||
|---|---|
|
||||
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
|
||||
| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` |
|
||||
| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` |
|
||||
| Shared LLM catalog | `[llm.providers.<id>]`, provider-scoped `[llm.providers.<id>.models.<slug>]` offerings, limits, features, controls, and costs |
|
||||
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ keeps the results in input order. The source lookup is flat:
|
|||
`context.candidates` checks that exact key and then `candidates`; it does not
|
||||
traverse nested objects.
|
||||
|
||||
Each item can contribute up to 64 KiB of serialized JSON to its branch prompt. Fabro stores larger items as content-addressed blobs and replaces the inline item with its size, a readable file path, and a 300-character preview. The preview remains inside the untrusted-data fence, and the branch agent can read the file for the full item.
|
||||
|
||||
The template target must be an agent or prompt node, and nested `for_each` is
|
||||
rejected. An empty source array succeeds with `parallel.results=[]` and skips
|
||||
straight to `aggregate`. Missing, invalid, non-array, or over-long sources fail
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -950,7 +950,8 @@ fn build_github_app_manifest(app_name: &str, port: u16, web_url: &str) -> serde_
|
|||
"issues": "write",
|
||||
"emails": "read",
|
||||
"vulnerability_alerts": "write",
|
||||
"organization_projects": "write"
|
||||
"organization_projects": "write",
|
||||
"packages": "read"
|
||||
},
|
||||
"default_events": []
|
||||
})
|
||||
|
|
@ -2682,6 +2683,10 @@ client_id = "client-id"
|
|||
manifest["default_permissions"]["organization_projects"],
|
||||
serde_json::json!("write"),
|
||||
);
|
||||
assert_eq!(
|
||||
manifest["default_permissions"]["packages"],
|
||||
serde_json::json!("read"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -843,6 +843,8 @@ mod tests {
|
|||
graph: fabro_types::Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
workflow_version_id: None,
|
||||
target: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: std::collections::HashMap::default(),
|
||||
|
|
|
|||
|
|
@ -1029,10 +1029,13 @@ mod tests {
|
|||
emit(
|
||||
&mut ui,
|
||||
agent_event("plan", AgentEvent::ToolCallCompleted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
output: serde_json::json!({"ok": true}),
|
||||
is_error: false,
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
output: serde_json::json!({"ok": true}),
|
||||
is_error: false,
|
||||
output_bytes_observed: 11,
|
||||
output_bytes_retained: 11,
|
||||
output_bytes_omitted: 0,
|
||||
}),
|
||||
);
|
||||
emit(&mut ui, stage_completed("plan", "Plan"));
|
||||
|
|
@ -1570,10 +1573,13 @@ mod tests {
|
|||
let tool_completed = serde_json::to_string(&to_run_event_at(
|
||||
&fixtures::RUN_1,
|
||||
&agent_event("code", AgentEvent::ToolCallCompleted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
output: serde_json::json!({"ok": true}),
|
||||
is_error: false,
|
||||
tool_name: "read_file".into(),
|
||||
tool_call_id: "tc1".into(),
|
||||
output: serde_json::json!({"ok": true}),
|
||||
is_error: false,
|
||||
output_bytes_observed: 11,
|
||||
output_bytes_retained: 11,
|
||||
output_bytes_omitted: 0,
|
||||
}),
|
||||
completed_ts,
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -161,13 +161,13 @@ pub(crate) async fn execute(
|
|||
artifact_sink,
|
||||
run_control: Some(run_control),
|
||||
github_app,
|
||||
github_permissions: run_spec
|
||||
github_integration: run_spec
|
||||
.settings
|
||||
.run
|
||||
.integrations
|
||||
.github
|
||||
.resolve_permissions()
|
||||
.context("failed to resolve github permissions")?,
|
||||
.resolve_integration()
|
||||
.context("failed to resolve github integration")?,
|
||||
vault,
|
||||
catalog,
|
||||
on_node: None,
|
||||
|
|
@ -1762,7 +1762,10 @@ mod tests {
|
|||
.parse::<EnvironmentProvider>()
|
||||
.expect("test provider should parse");
|
||||
run.integrations = RunIntegrationsSettings {
|
||||
github: RunIntegrationsGithubSettings { permissions },
|
||||
github: RunIntegrationsGithubSettings {
|
||||
permissions,
|
||||
..RunIntegrationsGithubSettings::default()
|
||||
},
|
||||
};
|
||||
run
|
||||
}
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ async fn main_inner(worker_token: Option<String>) -> (String, Result<()>) {
|
|||
commands::dump::run(&args, &base_ctx).await?;
|
||||
}
|
||||
Commands::RunsCmd(cmd) => {
|
||||
commands::runs::dispatch(cmd, &base_ctx).await?;
|
||||
Box::pin(commands::runs::dispatch(cmd, &base_ctx)).await?;
|
||||
}
|
||||
Commands::Model { command } => {
|
||||
commands::model::execute(command, &base_ctx).await?;
|
||||
|
|
|
|||
|
|
@ -924,6 +924,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"skip_git_hooks": false
|
||||
},
|
||||
"clone": {
|
||||
"depth": 100,
|
||||
"enabled": true
|
||||
},
|
||||
"environment": {
|
||||
|
|
|
|||
|
|
@ -152,7 +152,8 @@ fn inspect_resolves_selector_via_server_endpoint() {
|
|||
"commit_timeout_ms": 30000
|
||||
},
|
||||
"clone": {
|
||||
"enabled": true
|
||||
"enabled": true,
|
||||
"depth": 100
|
||||
},
|
||||
"run_branch": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
|
|||
graph: Graph::new("Remote Workflow"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("remote-workflow".to_string()),
|
||||
workflow_version_id: None,
|
||||
target: None,
|
||||
automation: None,
|
||||
source_directory: Some("/srv/repo".to_string()),
|
||||
labels: std::collections::HashMap::default(),
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ description = "HTTP server for Fabro pipelines"
|
|||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
test-support = ["fabro-store/test-support"]
|
||||
|
||||
[[test]]
|
||||
name = "it"
|
||||
|
|
@ -74,6 +74,7 @@ tokio.workspace = true
|
|||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml = "0.9"
|
||||
sqlx.workspace = true
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
async_zip.workspace = true
|
||||
|
|
@ -112,12 +113,12 @@ chrono = { workspace = true }
|
|||
|
||||
[dev-dependencies]
|
||||
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
|
||||
git2.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tower = "0.5"
|
||||
http-body-util = "0.1"
|
||||
httpmock = "0.8"
|
||||
serde_yaml = "0.9"
|
||||
sqlx.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,818 @@
|
|||
//! Fail-closed activation of SQLite blob storage.
|
||||
//!
|
||||
//! This compatibility bridge remains until at least 30 calendar days after
|
||||
//! the first successful production activation, and until the cold-start,
|
||||
//! warm-restart, production-observation, and backup-integrity evidence is
|
||||
//! complete and Scott explicitly approves its removal. The date is an
|
||||
//! eligibility floor, never an automatic deletion trigger.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::TryStreamExt as _;
|
||||
use object_store::ObjectStore;
|
||||
use sqlx::Connection as _;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection};
|
||||
use tokio::fs;
|
||||
use tokio::task::{JoinError, spawn_blocking};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::server::resource_sampler;
|
||||
|
||||
/// Earliest date this bridge becomes eligible for removal, assuming the first
|
||||
/// production activation happens no earlier than this change ships. Removal
|
||||
/// additionally requires the evidence and explicit approval described in the
|
||||
/// module docs; the date alone never triggers deletion.
|
||||
pub(crate) const REMOVAL_DEADLINE: &str = "2026-09-22";
|
||||
|
||||
const DISK_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const BACKUP_SUFFIX: &str = ".pre-blob-activation.bak";
|
||||
const STAGING_SUFFIX: &str = ".tmp";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum BlobActivationError {
|
||||
#[error("canonicalizing the SQLite database path {path}")]
|
||||
Canonicalize {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("inventorying the legacy blob source")]
|
||||
Inventory(#[source] fabro_store::LegacyBlobInventoryError),
|
||||
#[error("reading activation backup metadata at {path}")]
|
||||
BackupMetadata {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("activation backup is not a regular file at {path}")]
|
||||
BackupNotRegular { path: PathBuf },
|
||||
#[error("activation backup permissions are not private at {path}")]
|
||||
BackupNotPrivate { path: PathBuf },
|
||||
#[error(
|
||||
"activation backup is missing at {path} while {existing_rows} of {legacy_rows} legacy blob rows are already present in SQLite"
|
||||
)]
|
||||
MissingBackupAfterImport {
|
||||
path: PathBuf,
|
||||
legacy_rows: u64,
|
||||
existing_rows: u64,
|
||||
},
|
||||
#[error("opening or checking activation backup integrity at {path}")]
|
||||
BackupIntegrity {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: sqlx::Error,
|
||||
},
|
||||
#[error("activation backup integrity check did not return exactly one ok result at {path}")]
|
||||
BackupIntegrityFailed { path: PathBuf },
|
||||
#[error("reading SQLite file metadata at {path}")]
|
||||
SqliteMetadata {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("the blob activation disk requirement overflowed")]
|
||||
DiskRequirementOverflow,
|
||||
#[error(
|
||||
"insufficient disk space for blob activation: {available_bytes} bytes available, {required_bytes} required"
|
||||
)]
|
||||
InsufficientDisk {
|
||||
required_bytes: u64,
|
||||
available_bytes: u64,
|
||||
},
|
||||
#[error("staging the pre-activation SQLite backup")]
|
||||
StageBackup(#[source] fabro_db::SnapshotStagingError),
|
||||
#[error("joining the activation backup publication task")]
|
||||
JoinBackupPublication(#[source] JoinError),
|
||||
#[error("publishing the activation backup at {path} without overwriting")]
|
||||
PublishBackup {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("importing legacy blobs into SQLite")]
|
||||
Import(#[source] Box<fabro_store::LegacyBlobImportError>),
|
||||
#[error("verifying legacy and SQLite blobs")]
|
||||
Verification(#[source] Box<fabro_store::LegacyBlobVerificationError>),
|
||||
#[error("running the live SQLite integrity check")]
|
||||
LiveIntegrity(#[source] sqlx::Error),
|
||||
#[error("the live SQLite integrity check did not return exactly one ok result")]
|
||||
LiveIntegrityFailed,
|
||||
#[error("running the final SQLite WAL truncate checkpoint")]
|
||||
FinalCheckpoint(#[source] sqlx::Error),
|
||||
}
|
||||
|
||||
pub(crate) async fn activate_blob_storage(
|
||||
database: &fabro_db::Database,
|
||||
sqlite_path: &Path,
|
||||
object_store: Arc<dyn ObjectStore>,
|
||||
slatedb_prefix: String,
|
||||
flush_interval: Duration,
|
||||
cache_path: Option<PathBuf>,
|
||||
) -> Result<Arc<fabro_store::Database>, BlobActivationError> {
|
||||
let canonical_path = fs::canonicalize(sqlite_path).await.map_err(|source| {
|
||||
BlobActivationError::Canonicalize {
|
||||
path: sqlite_path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let backup_path = fabro_db::append_to_path(&canonical_path, BACKUP_SUFFIX);
|
||||
info!(
|
||||
database_path = %canonical_path.display(),
|
||||
backup_path = %backup_path.display(),
|
||||
"Starting SQLite blob storage activation"
|
||||
);
|
||||
|
||||
let blob_store = Arc::new(fabro_store::BlobStore::new(database.clone_pool()));
|
||||
let run_summary_store = Arc::new(fabro_store::RunSummaryStore::new(database.clone_pool()));
|
||||
let store = Arc::new(fabro_store::Database::new(
|
||||
object_store,
|
||||
slatedb_prefix,
|
||||
flush_interval,
|
||||
cache_path,
|
||||
Arc::clone(&blob_store),
|
||||
run_summary_store,
|
||||
));
|
||||
|
||||
let inventory = store
|
||||
.legacy_blob_inventory(database.pool())
|
||||
.await
|
||||
.map_err(BlobActivationError::Inventory)?;
|
||||
let backup_exists = backup_exists(&backup_path).await?;
|
||||
if backup_exists {
|
||||
validate_backup(&backup_path).await?;
|
||||
}
|
||||
if !backup_exists && inventory.pending_rows < inventory.rows {
|
||||
return Err(BlobActivationError::MissingBackupAfterImport {
|
||||
path: backup_path,
|
||||
legacy_rows: inventory.rows,
|
||||
existing_rows: inventory.rows - inventory.pending_rows,
|
||||
});
|
||||
}
|
||||
let backup_required = inventory.rows > 0 && !backup_exists;
|
||||
// The resource sampler treats a path with no matching mount as an
|
||||
// unsupported-but-benign condition (tmpfs or squashfs roots, network
|
||||
// filesystems, an unreadable mount table), so the preflight does too:
|
||||
// skipping the capacity check must not block a boot the import itself
|
||||
// could complete.
|
||||
if let Some(available_free_bytes) = resource_sampler::available_space_for_path(&canonical_path)
|
||||
{
|
||||
let backup_reserve = if backup_required {
|
||||
sqlite_file_set_bytes(&canonical_path).await?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Only the rows the import still has to copy need new space; rows
|
||||
// already present in SQLite cost nothing on a warm restart.
|
||||
let required_free_bytes = compute_disk_preflight(
|
||||
inventory.pending_bytes,
|
||||
backup_reserve,
|
||||
available_free_bytes,
|
||||
)?;
|
||||
debug!(
|
||||
legacy_rows = inventory.rows,
|
||||
legacy_bytes = inventory.bytes,
|
||||
pending_rows = inventory.pending_rows,
|
||||
pending_bytes = inventory.pending_bytes,
|
||||
backup_required,
|
||||
backup_reserve,
|
||||
required_free_bytes,
|
||||
available_free_bytes,
|
||||
"Checked SQLite blob activation disk capacity"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
database_path = %canonical_path.display(),
|
||||
"No filesystem mount matched the SQLite database path; skipping the blob activation disk preflight"
|
||||
);
|
||||
}
|
||||
|
||||
let retained_backup = if backup_exists {
|
||||
Some(backup_path)
|
||||
} else if backup_required {
|
||||
create_backup(database.pool(), &backup_path).await?;
|
||||
Some(backup_path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let import = store
|
||||
.import_legacy_blobs_into(database.pool())
|
||||
.await
|
||||
.map_err(|source| BlobActivationError::Import(Box::new(source)))?;
|
||||
// The import pass already validates every legacy digest and byte-compares
|
||||
// every already-present row on each boot, so the independent verification
|
||||
// sweep only needs to double-check boots that actually inserted rows.
|
||||
let verification = if import.imported_rows > 0 {
|
||||
Some(
|
||||
store
|
||||
.verify_legacy_blobs_in(database.pool())
|
||||
.await
|
||||
.map_err(|source| BlobActivationError::Verification(Box::new(source)))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
validate_live_integrity(database.pool()).await?;
|
||||
final_truncate_checkpoint(database.pool()).await?;
|
||||
|
||||
info!(
|
||||
legacy_rows = inventory.rows,
|
||||
legacy_bytes = inventory.bytes,
|
||||
imported_rows = import.imported_rows,
|
||||
existing_rows = import.existing_rows,
|
||||
matched_rows = verification.as_ref().map(|report| report.matched_rows),
|
||||
target_rows = verification.as_ref().map(|report| report.target_rows),
|
||||
passive_checkpoints = import.passive_checkpoints,
|
||||
backup_required,
|
||||
backup_path = ?retained_backup,
|
||||
removal_deadline = REMOVAL_DEADLINE,
|
||||
"Activated SQLite blob storage"
|
||||
);
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Fail-closed disk capacity check; returns the required free bytes.
|
||||
fn compute_disk_preflight(
|
||||
pending_bytes: u64,
|
||||
backup_reserve: u64,
|
||||
available_free_bytes: u64,
|
||||
) -> Result<u64, BlobActivationError> {
|
||||
let half = pending_bytes
|
||||
.checked_add(1)
|
||||
.ok_or(BlobActivationError::DiskRequirementOverflow)?
|
||||
/ 2;
|
||||
let required_free_bytes = backup_reserve
|
||||
.checked_add(pending_bytes)
|
||||
.and_then(|value| value.checked_add(half))
|
||||
.and_then(|value| value.checked_add(DISK_HEADROOM_BYTES))
|
||||
.ok_or(BlobActivationError::DiskRequirementOverflow)?;
|
||||
if available_free_bytes < required_free_bytes {
|
||||
return Err(BlobActivationError::InsufficientDisk {
|
||||
required_bytes: required_free_bytes,
|
||||
available_bytes: available_free_bytes,
|
||||
});
|
||||
}
|
||||
Ok(required_free_bytes)
|
||||
}
|
||||
|
||||
async fn backup_exists(path: &Path) -> Result<bool, BlobActivationError> {
|
||||
match fs::metadata(path).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(source) => Err(BlobActivationError::BackupMetadata {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sqlite_file_set_bytes(path: &Path) -> Result<u64, BlobActivationError> {
|
||||
let mut total = required_file_bytes(path).await?;
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let sibling = fabro_db::append_to_path(path, suffix);
|
||||
let bytes = optional_file_bytes(&sibling).await?;
|
||||
total = total
|
||||
.checked_add(bytes)
|
||||
.ok_or(BlobActivationError::DiskRequirementOverflow)?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
async fn required_file_bytes(path: &Path) -> Result<u64, BlobActivationError> {
|
||||
fs::metadata(path)
|
||||
.await
|
||||
.map(|metadata| metadata.len())
|
||||
.map_err(|source| BlobActivationError::SqliteMetadata {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
async fn optional_file_bytes(path: &Path) -> Result<u64, BlobActivationError> {
|
||||
match fs::metadata(path).await {
|
||||
Ok(metadata) => Ok(metadata.len()),
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(0),
|
||||
Err(source) => Err(BlobActivationError::SqliteMetadata {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_backup(
|
||||
pool: &sqlx::SqlitePool,
|
||||
backup_path: &Path,
|
||||
) -> Result<(), BlobActivationError> {
|
||||
let staging_path = fabro_db::append_to_path(backup_path, STAGING_SUFFIX);
|
||||
fabro_db::write_snapshot_to_staging(pool, &staging_path)
|
||||
.await
|
||||
.map_err(BlobActivationError::StageBackup)?;
|
||||
validate_backup(&staging_path).await?;
|
||||
|
||||
let publish_staging = staging_path.clone();
|
||||
let publish_backup = backup_path.to_path_buf();
|
||||
let already_exists = spawn_blocking(move || {
|
||||
let staging = tempfile::TempPath::from_path(publish_staging);
|
||||
match staging.persist_noclobber(&publish_backup) {
|
||||
Ok(()) => {
|
||||
// Make the rename's directory entry durable: the retained
|
||||
// backup is the documented rollback artifact, so it must not
|
||||
// vanish in a crash after the import has already committed.
|
||||
fabro_db::sync_parent_directory(&publish_backup)?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(true),
|
||||
Err(error) => Err(error.error),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(BlobActivationError::JoinBackupPublication)?
|
||||
.map_err(|source| BlobActivationError::PublishBackup {
|
||||
path: backup_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
// The staging copy was validated just before the atomic rename, so only a
|
||||
// concurrently published file still needs its own validation.
|
||||
if already_exists {
|
||||
debug!(
|
||||
backup_path = %backup_path.display(),
|
||||
"Reusing concurrently published SQLite blob activation backup"
|
||||
);
|
||||
validate_backup(backup_path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_backup(path: &Path) -> Result<(), BlobActivationError> {
|
||||
let metadata =
|
||||
fs::symlink_metadata(path)
|
||||
.await
|
||||
.map_err(|source| BlobActivationError::BackupMetadata {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
if !metadata.is_file() {
|
||||
return Err(BlobActivationError::BackupNotRegular {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
validate_private_permissions(path, &metadata)?;
|
||||
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.read_only(true)
|
||||
.immutable(true)
|
||||
.create_if_missing(false);
|
||||
let mut connection = SqliteConnection::connect_with(&options)
|
||||
.await
|
||||
.map_err(|source| BlobActivationError::BackupIntegrity {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let ok = integrity_check_is_ok(&mut connection)
|
||||
.await
|
||||
.map_err(|source| BlobActivationError::BackupIntegrity {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
if !ok {
|
||||
return Err(BlobActivationError::BackupIntegrityFailed {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns whether `PRAGMA integrity_check` reports exactly one `ok` row.
|
||||
async fn integrity_check_is_ok<'a, E>(executor: E) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Sqlite>,
|
||||
{
|
||||
let mut rows = sqlx::query_scalar::<_, String>("PRAGMA integrity_check").fetch(executor);
|
||||
let first = rows.try_next().await?;
|
||||
let second = rows.try_next().await?;
|
||||
Ok(first.as_deref() == Some("ok") && second.is_none())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_private_permissions(
|
||||
path: &Path,
|
||||
metadata: &std::fs::Metadata,
|
||||
) -> Result<(), BlobActivationError> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
if metadata.permissions().mode() & 0o077 != 0 {
|
||||
return Err(BlobActivationError::BackupNotPrivate {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn validate_private_permissions(
|
||||
_path: &Path,
|
||||
_metadata: &std::fs::Metadata,
|
||||
) -> Result<(), BlobActivationError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_live_integrity(pool: &sqlx::SqlitePool) -> Result<(), BlobActivationError> {
|
||||
let ok = integrity_check_is_ok(pool)
|
||||
.await
|
||||
.map_err(BlobActivationError::LiveIntegrity)?;
|
||||
if !ok {
|
||||
return Err(BlobActivationError::LiveIntegrityFailed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn final_truncate_checkpoint(pool: &sqlx::SqlitePool) -> Result<(), BlobActivationError> {
|
||||
let (busy, _, _): (i64, i64, i64) = sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(BlobActivationError::FinalCheckpoint)?;
|
||||
if busy != 0 {
|
||||
// A concurrent reader (a backup tool, a replication agent, an
|
||||
// operator shell) can keep the WAL from truncating. An untruncated
|
||||
// WAL threatens no data integrity, so it must not block startup; a
|
||||
// later checkpoint truncates once the reader is gone.
|
||||
warn!("The final SQLite WAL truncate checkpoint could not complete; continuing startup");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_db::append_to_path;
|
||||
use object_store::ObjectStore;
|
||||
use object_store::memory::InMemory;
|
||||
use tokio::fs;
|
||||
|
||||
use super::{
|
||||
BACKUP_SUFFIX, BlobActivationError, DISK_HEADROOM_BYTES, activate_blob_storage,
|
||||
compute_disk_preflight, create_backup, final_truncate_checkpoint, sqlite_file_set_bytes,
|
||||
validate_backup,
|
||||
};
|
||||
|
||||
type TestResult<T> = Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
#[test]
|
||||
fn disk_preflight_passes_at_equality_and_fails_one_byte_below() {
|
||||
let pending_bytes = 3;
|
||||
let backup_reserve = 10;
|
||||
let required = backup_reserve + pending_bytes + 2 + DISK_HEADROOM_BYTES;
|
||||
|
||||
let required_free_bytes = compute_disk_preflight(pending_bytes, backup_reserve, required)
|
||||
.expect("exact equality must pass");
|
||||
assert_eq!(required_free_bytes, required);
|
||||
|
||||
let error = compute_disk_preflight(pending_bytes, backup_reserve, required - 1)
|
||||
.expect_err("one byte below must fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
BlobActivationError::InsufficientDisk { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_preflight_requires_only_headroom_without_a_backup_reserve() {
|
||||
let required_free_bytes =
|
||||
compute_disk_preflight(2, 0, u64::MAX).expect("available capacity should pass");
|
||||
assert_eq!(required_free_bytes, 3 + DISK_HEADROOM_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_preflight_fails_closed_on_overflow() {
|
||||
let error =
|
||||
compute_disk_preflight(u64::MAX, 1, u64::MAX).expect_err("overflow must fail closed");
|
||||
assert!(matches!(
|
||||
error,
|
||||
BlobActivationError::DiskRequirementOverflow
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disk_preflight_counts_the_sqlite_file_set_for_a_required_backup() -> TestResult<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let sqlite_path = directory.path().join("fabro.sqlite3");
|
||||
fs::write(&sqlite_path, [0_u8; 3]).await?;
|
||||
fs::write(append_to_path(&sqlite_path, "-wal"), [0_u8; 5]).await?;
|
||||
fs::write(append_to_path(&sqlite_path, "-shm"), [0_u8; 7]).await?;
|
||||
|
||||
assert_eq!(sqlite_file_set_bytes(&sqlite_path).await?, 15);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_is_private_integrity_clean_and_does_not_create_journal_siblings()
|
||||
-> TestResult<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let sqlite_path = directory.path().join("fabro.sqlite3");
|
||||
let database = fabro_db::Database::connect(&sqlite_path).await?;
|
||||
database.migrate().await?;
|
||||
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
|
||||
|
||||
create_backup(database.pool(), &backup_path).await?;
|
||||
validate_backup(&backup_path).await?;
|
||||
|
||||
assert!(backup_path.is_file());
|
||||
assert!(!append_to_path(&backup_path, "-wal").exists());
|
||||
assert!(!append_to_path(&backup_path, "-shm").exists());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&backup_path)?.permissions().mode() & 0o077,
|
||||
0
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_publication_never_overwrites_an_existing_valid_backup() -> TestResult<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let sqlite_path = directory.path().join("fabro.sqlite3");
|
||||
let database = fabro_db::Database::connect(&sqlite_path).await?;
|
||||
database.migrate().await?;
|
||||
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
|
||||
create_backup(database.pool(), &backup_path).await?;
|
||||
let original = fs::read(&backup_path).await?;
|
||||
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(fabro_types::BlobHash::new(b"later").to_string())
|
||||
.bind(b"later".as_slice())
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
create_backup(database.pool(), &backup_path).await?;
|
||||
|
||||
assert_eq!(fs::read(&backup_path).await?, original);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_backup_copy_never_publishes_a_destination() -> TestResult<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let sqlite_path = directory.path().join("fabro.sqlite3");
|
||||
let database = fabro_db::Database::connect(&sqlite_path).await?;
|
||||
database.migrate().await?;
|
||||
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
|
||||
database.pool().close().await;
|
||||
|
||||
let error = create_backup(database.pool(), &backup_path)
|
||||
.await
|
||||
.expect_err("a closed pool must fail backup creation");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
BlobActivationError::StageBackup(fabro_db::SnapshotStagingError::Write { .. })
|
||||
));
|
||||
assert!(!backup_path.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_activation_and_warm_restart_share_verified_sqlite_blobs() -> TestResult<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let sqlite_path = directory.path().join("fabro.sqlite3");
|
||||
let database = fabro_db::Database::connect(&sqlite_path).await?;
|
||||
database.migrate().await?;
|
||||
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
||||
let source = fabro_store::test_support::test_database(
|
||||
Arc::clone(&object_store),
|
||||
"activation-test",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
let legacy_bytes = b"legacy-blob";
|
||||
let legacy_hash = fabro_store::test_support::put_legacy_blob(&source, legacy_bytes).await?;
|
||||
drop(source);
|
||||
|
||||
let store = activate_blob_storage(
|
||||
&database,
|
||||
&sqlite_path,
|
||||
Arc::clone(&object_store),
|
||||
"activation-test".to_string(),
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
store.blobs().read(&legacy_hash).await?.as_deref(),
|
||||
Some(legacy_bytes.as_slice())
|
||||
);
|
||||
|
||||
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
|
||||
let original_backup = fs::read(&backup_path).await?;
|
||||
let run_id = fabro_types::RunId::new();
|
||||
let writer = store.create_run(&run_id).await?;
|
||||
let reader = store.open_run_reader(&run_id).await?;
|
||||
let sqlite_only_bytes = b"written-after-activation";
|
||||
let sqlite_only_hash = writer.write_blob(sqlite_only_bytes).await?;
|
||||
assert_eq!(
|
||||
reader.read_blob(&sqlite_only_hash).await?.as_deref(),
|
||||
Some(sqlite_only_bytes.as_slice())
|
||||
);
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
drop(store);
|
||||
|
||||
let warm = activate_blob_storage(
|
||||
&database,
|
||||
&sqlite_path,
|
||||
object_store,
|
||||
"activation-test".to_string(),
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(fs::read(&backup_path).await?, original_backup);
|
||||
assert_eq!(
|
||||
warm.blobs().read(&sqlite_only_hash).await?.as_deref(),
|
||||
Some(sqlite_only_bytes.as_slice())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_backup_after_prior_import_fails_closed() -> TestResult<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let sqlite_path = directory.path().join("fabro.sqlite3");
|
||||
let database = fabro_db::Database::connect(&sqlite_path).await?;
|
||||
database.migrate().await?;
|
||||
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
||||
let source = fabro_store::test_support::test_database(
|
||||
Arc::clone(&object_store),
|
||||
"missing-backup-test",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
let bytes = b"already-imported";
|
||||
let hash = fabro_store::test_support::put_legacy_blob(&source, bytes).await?;
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(hash.to_string())
|
||||
.bind(bytes.as_slice())
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
drop(source);
|
||||
|
||||
let error = activate_blob_storage(
|
||||
&database,
|
||||
&sqlite_path,
|
||||
object_store,
|
||||
"missing-backup-test".to_string(),
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("startup must not move the pre-activation rollback boundary");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
BlobActivationError::MissingBackupAfterImport {
|
||||
legacy_rows: 1,
|
||||
existing_rows: 1,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(!append_to_path(&sqlite_path, BACKUP_SUFFIX).exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_inventory_skips_backup_and_serves_existing_sqlite_rows() -> TestResult<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let sqlite_path = directory.path().join("fabro.sqlite3");
|
||||
let database = fabro_db::Database::connect(&sqlite_path).await?;
|
||||
database.migrate().await?;
|
||||
let bytes = b"sqlite-only";
|
||||
let hash = fabro_types::BlobHash::new(bytes);
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(hash.to_string())
|
||||
.bind(bytes.as_slice())
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
|
||||
let activated = activate_blob_storage(
|
||||
&database,
|
||||
&sqlite_path,
|
||||
Arc::new(InMemory::new()),
|
||||
"empty-activation-test".to_string(),
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(!append_to_path(&sqlite_path, BACKUP_SUFFIX).exists());
|
||||
assert_eq!(
|
||||
activated.blobs().read(&hash).await?.as_deref(),
|
||||
Some(bytes.as_slice())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn busy_final_checkpoint_warns_and_does_not_fail_startup() -> TestResult<()> {
|
||||
use sqlx::Connection as _;
|
||||
use sqlx::sqlite::{
|
||||
SqliteConnectOptions, SqliteConnection, SqliteJournalMode, SqlitePoolOptions,
|
||||
};
|
||||
|
||||
let directory = tempfile::tempdir()?;
|
||||
let sqlite_path = directory.path().join("fabro.sqlite3");
|
||||
let database = fabro_db::Database::connect(&sqlite_path).await?;
|
||||
database.migrate().await?;
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(fabro_types::BlobHash::new(b"wal-content").to_string())
|
||||
.bind(b"wal-content".as_slice())
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
|
||||
// A reader holding an open snapshot models a backup tool or operator
|
||||
// shell that outlives the checkpoint's busy timeout.
|
||||
let reader_options = SqliteConnectOptions::new()
|
||||
.filename(&sqlite_path)
|
||||
.read_only(true)
|
||||
.create_if_missing(false);
|
||||
let mut reader = SqliteConnection::connect_with(&reader_options).await?;
|
||||
sqlx::query("BEGIN").execute(&mut reader).await?;
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM blobs")
|
||||
.fetch_one(&mut reader)
|
||||
.await?;
|
||||
|
||||
// A short busy timeout keeps the blocked truncate from stalling the
|
||||
// test for the production pool's full five seconds.
|
||||
let checkpoint_options = SqliteConnectOptions::new()
|
||||
.filename(&sqlite_path)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_millis(50))
|
||||
.create_if_missing(false);
|
||||
let checkpoint_pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(checkpoint_options)
|
||||
.await?;
|
||||
|
||||
final_truncate_checkpoint(&checkpoint_pool).await?;
|
||||
|
||||
// The reader really did block the truncate: the WAL was not reset.
|
||||
let wal_bytes = fs::metadata(append_to_path(&sqlite_path, "-wal"))
|
||||
.await?
|
||||
.len();
|
||||
assert!(wal_bytes > 0, "the WAL should remain untruncated");
|
||||
drop(reader);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_retained_backup_fails_before_importing() -> TestResult<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let sqlite_path = directory.path().join("fabro.sqlite3");
|
||||
let database = fabro_db::Database::connect(&sqlite_path).await?;
|
||||
database.migrate().await?;
|
||||
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
||||
let source = fabro_store::test_support::test_database(
|
||||
Arc::clone(&object_store),
|
||||
"invalid-backup-test",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
fabro_store::test_support::put_legacy_blob(&source, b"must-not-import").await?;
|
||||
drop(source);
|
||||
|
||||
let backup_path = append_to_path(&sqlite_path, BACKUP_SUFFIX);
|
||||
fs::write(&backup_path, b"not a database").await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::set_permissions(&backup_path, std::fs::Permissions::from_mode(0o600)).await?;
|
||||
}
|
||||
|
||||
let error = activate_blob_storage(
|
||||
&database,
|
||||
&sqlite_path,
|
||||
object_store,
|
||||
"invalid-backup-test".to_string(),
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("an invalid retained backup must fail closed");
|
||||
assert!(matches!(
|
||||
error,
|
||||
BlobActivationError::BackupIntegrity { .. }
|
||||
| BlobActivationError::BackupIntegrityFailed { .. }
|
||||
));
|
||||
let destination_rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blobs")
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
assert_eq!(destination_rows, 0);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,8 @@ use url::{Host, Url};
|
|||
|
||||
use crate::auth::browser_shell::browser_shell;
|
||||
use crate::auth::{
|
||||
self, AuthCode, AuthErrorCode, ConsumeOutcome, JwtSubject, REFRESH_TOKEN_PREFIX, RefreshToken,
|
||||
self, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, JwtSubject,
|
||||
PendingCliAuthorization, REFRESH_TOKEN_PREFIX, RotateOutcome,
|
||||
};
|
||||
use crate::jwt_auth::{AuthMode, ConfiguredAuth, bearer_token_from_headers};
|
||||
use crate::principal_middleware::{
|
||||
|
|
@ -389,18 +390,12 @@ async fn token(
|
|||
);
|
||||
}
|
||||
|
||||
let auth_codes = match state.store_ref().auth_codes().await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to open auth code store");
|
||||
return oauth_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Could not complete authentication",
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(entry) = (match auth_codes.consume(code).await {
|
||||
let Some(entry) = (match state
|
||||
.stores
|
||||
.auth_codes
|
||||
.consume(code, chrono::Utc::now())
|
||||
.await
|
||||
{
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to consume auth code");
|
||||
|
|
@ -466,32 +461,28 @@ async fn token(
|
|||
let refresh_expires_at = now + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS);
|
||||
let refresh_secret = random_secret();
|
||||
let refresh_token = format!("{REFRESH_TOKEN_PREFIX}{refresh_secret}");
|
||||
let refresh_row = RefreshToken {
|
||||
token_hash: hash_refresh_secret(&refresh_secret),
|
||||
chain_id: uuid::Uuid::new_v4(),
|
||||
let session = AuthSessionRecord {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
identity: entry.identity.clone(),
|
||||
login: entry.login.clone(),
|
||||
name: entry.name.clone(),
|
||||
email: entry.email.clone(),
|
||||
avatar_url: entry.avatar_url.clone(),
|
||||
issued_at: now,
|
||||
expires_at: refresh_expires_at,
|
||||
last_used_at: now,
|
||||
used: false,
|
||||
user_agent: sanitize_user_agent(request_user_agent(&headers)),
|
||||
created_at: now,
|
||||
last_used_at: now,
|
||||
};
|
||||
let auth_tokens = match state.store_ref().refresh_tokens().await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to open refresh token store");
|
||||
return oauth_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Could not complete authentication",
|
||||
);
|
||||
}
|
||||
let refresh_row = InitialRefreshToken {
|
||||
token_hash: hash_refresh_secret(&refresh_secret),
|
||||
issued_at: now,
|
||||
expires_at: refresh_expires_at,
|
||||
};
|
||||
if let Err(err) = auth_tokens.insert_refresh_token(refresh_row.clone()).await {
|
||||
if let Err(err) = state
|
||||
.stores
|
||||
.auth_sessions
|
||||
.create_session(&session, &refresh_row)
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "Failed to persist refresh token");
|
||||
return oauth_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -516,7 +507,7 @@ async fn token(
|
|||
);
|
||||
|
||||
log_cli_auth_tokens_issued(&entry.login, &entry.email);
|
||||
auth_slot.replace(refresh_user_context(&refresh_row));
|
||||
auth_slot.replace(refresh_user_context(&session));
|
||||
|
||||
Json(CliTokenResponse {
|
||||
access_token,
|
||||
|
|
@ -524,10 +515,10 @@ async fn token(
|
|||
refresh_token,
|
||||
refresh_token_expires_at: refresh_expires_at,
|
||||
subject: subject_response(
|
||||
&refresh_row.identity,
|
||||
&refresh_row.login,
|
||||
&refresh_row.name,
|
||||
&refresh_row.email,
|
||||
&session.identity,
|
||||
&session.login,
|
||||
&session.name,
|
||||
&session.email,
|
||||
),
|
||||
})
|
||||
.into_response()
|
||||
|
|
@ -570,37 +561,19 @@ async fn refresh(
|
|||
"Could not refresh authentication",
|
||||
);
|
||||
};
|
||||
let auth_tokens = match state.store_ref().refresh_tokens().await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to open refresh token store");
|
||||
return oauth_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Could not refresh authentication",
|
||||
);
|
||||
}
|
||||
};
|
||||
let auth_sessions = &state.stores.auth_sessions;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let secret_hash = hash_refresh_secret(&secret);
|
||||
let existing = match auth_tokens.find_refresh_token(&secret_hash).await {
|
||||
Ok(existing) => existing,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to load refresh token before rotation");
|
||||
return oauth_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Could not refresh authentication",
|
||||
);
|
||||
}
|
||||
};
|
||||
let next_secret = random_secret();
|
||||
let next_user_agent = sanitize_user_agent(request_user_agent(&headers));
|
||||
let outcome = match auth_tokens
|
||||
.consume_and_rotate(
|
||||
secret_hash,
|
||||
next_refresh_row(existing.as_ref(), &next_secret, &next_user_agent, now),
|
||||
let refresh_expires_at = now + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS);
|
||||
let outcome = match auth_sessions
|
||||
.rotate(
|
||||
&secret_hash,
|
||||
&hash_refresh_secret(&next_secret),
|
||||
refresh_expires_at,
|
||||
&next_user_agent,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
|
|
@ -616,42 +589,31 @@ async fn refresh(
|
|||
}
|
||||
};
|
||||
|
||||
let (old, new_row) = match outcome {
|
||||
ConsumeOutcome::NotFound | ConsumeOutcome::Expired => {
|
||||
let session = match outcome {
|
||||
RotateOutcome::NotFound | RotateOutcome::Expired => {
|
||||
auth_slot.replace(RequestAuthContext::invalid());
|
||||
if auth_tokens.was_recently_replay_revoked(&secret_hash, now) {
|
||||
return oauth_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"refresh_token_revoked",
|
||||
"Refresh token revoked",
|
||||
);
|
||||
}
|
||||
return oauth_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"refresh_token_expired",
|
||||
"Refresh token expired",
|
||||
);
|
||||
}
|
||||
ConsumeOutcome::Reused(old) => {
|
||||
RotateOutcome::ReplayedAndRevoked(session) => {
|
||||
auth_slot.replace(RequestAuthContext::invalid());
|
||||
auth_tokens.mark_refresh_token_replay(secret_hash, now);
|
||||
if let Err(err) = auth_tokens.delete_chain(old.chain_id).await {
|
||||
warn!(error = %err, chain_id = %old.chain_id, "Failed to revoke replayed refresh token chain");
|
||||
}
|
||||
log_refresh_token_replay(old.chain_id, old.identity.subject(), &next_user_agent);
|
||||
log_refresh_token_replay(session.id, session.identity.subject(), &next_user_agent);
|
||||
return oauth_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"refresh_token_revoked",
|
||||
"Refresh token revoked",
|
||||
);
|
||||
}
|
||||
ConsumeOutcome::Rotated(old, new_row) => (old, *new_row),
|
||||
RotateOutcome::Rotated(session) => session,
|
||||
};
|
||||
|
||||
if !login_allowed(state.as_ref(), &old.login) {
|
||||
if !login_allowed(state.as_ref(), &session.login) {
|
||||
auth_slot.replace(RequestAuthContext::invalid());
|
||||
if let Err(err) = auth_tokens.delete_chain(old.chain_id).await {
|
||||
warn!(error = %err, chain_id = %old.chain_id, "Failed to revoke deauthorized refresh token chain");
|
||||
if let Err(err) = auth_sessions.delete_session(session.id).await {
|
||||
warn!(error = %err, session_id = %session.id, "Failed to revoke deauthorized refresh token chain");
|
||||
}
|
||||
return oauth_error(StatusCode::FORBIDDEN, "unauthorized", "Login not permitted");
|
||||
}
|
||||
|
|
@ -661,24 +623,29 @@ async fn refresh(
|
|||
jwt_key,
|
||||
jwt_issuer,
|
||||
&JwtSubject {
|
||||
identity: old.identity.clone(),
|
||||
login: old.login.clone(),
|
||||
name: old.name.clone(),
|
||||
email: old.email.clone(),
|
||||
avatar_url: old.avatar_url.clone(),
|
||||
identity: session.identity.clone(),
|
||||
login: session.login.clone(),
|
||||
name: session.name.clone(),
|
||||
email: session.email.clone(),
|
||||
avatar_url: session.avatar_url.clone(),
|
||||
user_url: String::new(),
|
||||
auth_method: AuthMethod::Github,
|
||||
},
|
||||
chrono::Duration::minutes(ACCESS_TOKEN_TTL_MINUTES),
|
||||
);
|
||||
auth_slot.replace(refresh_user_context(&old));
|
||||
auth_slot.replace(refresh_user_context(&session));
|
||||
|
||||
Json(CliTokenResponse {
|
||||
access_token,
|
||||
access_token_expires_at: access_expires_at,
|
||||
refresh_token: format!("{REFRESH_TOKEN_PREFIX}{next_secret}"),
|
||||
refresh_token_expires_at: new_row.expires_at,
|
||||
subject: subject_response(&old.identity, &old.login, &old.name, &old.email),
|
||||
refresh_token_expires_at: refresh_expires_at,
|
||||
subject: subject_response(
|
||||
&session.identity,
|
||||
&session.login,
|
||||
&session.name,
|
||||
&session.email,
|
||||
),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
|
@ -701,20 +668,9 @@ async fn logout(
|
|||
}
|
||||
RefreshCredential::Present(secret) => secret,
|
||||
};
|
||||
let auth_tokens = match state.store_ref().refresh_tokens().await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to open refresh token store");
|
||||
return oauth_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Could not complete logout",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let existing = match auth_tokens
|
||||
.find_refresh_token(&hash_refresh_secret(&secret))
|
||||
let auth_sessions = &state.stores.auth_sessions;
|
||||
let existing = match auth_sessions
|
||||
.find_session_by_token_hash(&hash_refresh_secret(&secret))
|
||||
.await
|
||||
{
|
||||
Ok(existing) => existing,
|
||||
|
|
@ -728,17 +684,17 @@ async fn logout(
|
|||
}
|
||||
};
|
||||
|
||||
if let Some(refresh_token) = existing {
|
||||
auth_slot.replace(refresh_user_context(&refresh_token));
|
||||
if let Err(err) = auth_tokens.delete_chain(refresh_token.chain_id).await {
|
||||
warn!(error = %err, chain_id = %refresh_token.chain_id, "Failed to revoke refresh token chain during logout");
|
||||
if let Some(session) = existing {
|
||||
auth_slot.replace(refresh_user_context(&session));
|
||||
if let Err(err) = auth_sessions.delete_session(session.id).await {
|
||||
warn!(error = %err, session_id = %session.id, "Failed to revoke refresh token chain during logout");
|
||||
return oauth_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Could not complete logout",
|
||||
);
|
||||
}
|
||||
log_cli_refresh_chain_logged_out(&refresh_token.login, &refresh_token.email);
|
||||
log_cli_refresh_chain_logged_out(&session.login, &session.email);
|
||||
} else {
|
||||
auth_slot.replace(RequestAuthContext::invalid());
|
||||
}
|
||||
|
|
@ -956,13 +912,13 @@ fn refresh_credential_from_headers(headers: &HeaderMap) -> RefreshCredential {
|
|||
}
|
||||
}
|
||||
|
||||
fn refresh_user_context(refresh_token: &RefreshToken) -> RequestAuthContext {
|
||||
fn refresh_user_context(session: &AuthSessionRecord) -> RequestAuthContext {
|
||||
RequestAuthContext::authenticated(
|
||||
Principal::user_with_avatar(
|
||||
refresh_token.identity.clone(),
|
||||
refresh_token.login.clone(),
|
||||
session.identity.clone(),
|
||||
session.login.clone(),
|
||||
AuthMethod::Github,
|
||||
non_empty_avatar_url(&refresh_token.avatar_url),
|
||||
non_empty_avatar_url(&session.avatar_url),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
|
@ -972,31 +928,6 @@ fn hash_refresh_secret(secret: &str) -> [u8; 32] {
|
|||
Sha256::digest(secret.as_bytes()).into()
|
||||
}
|
||||
|
||||
fn next_refresh_row(
|
||||
existing: Option<&RefreshToken>,
|
||||
next_secret: &str,
|
||||
user_agent: &str,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> RefreshToken {
|
||||
let fallback_identity = fabro_types::IdpIdentity::new("https://github.com", "0")
|
||||
.expect("static identity should be valid");
|
||||
RefreshToken {
|
||||
token_hash: hash_refresh_secret(next_secret),
|
||||
chain_id: existing.map_or_else(uuid::Uuid::new_v4, |token| token.chain_id),
|
||||
identity: existing
|
||||
.map_or_else(|| fallback_identity.clone(), |token| token.identity.clone()),
|
||||
login: existing.map_or_else(String::new, |token| token.login.clone()),
|
||||
name: existing.map_or_else(String::new, |token| token.name.clone()),
|
||||
email: existing.map_or_else(String::new, |token| token.email.clone()),
|
||||
avatar_url: existing.map_or_else(String::new, |token| token.avatar_url.clone()),
|
||||
issued_at: now,
|
||||
expires_at: now + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS),
|
||||
last_used_at: now,
|
||||
used: false,
|
||||
user_agent: user_agent.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_agent_fingerprint(user_agent: &str) -> String {
|
||||
let digest = Sha256::digest(user_agent.as_bytes());
|
||||
hex::encode(&digest[..8])
|
||||
|
|
@ -1006,9 +937,9 @@ fn log_cli_auth_tokens_issued(login: &str, email: &str) {
|
|||
info!(login = %login, email = %email, "Issued CLI auth tokens");
|
||||
}
|
||||
|
||||
fn log_refresh_token_replay(chain_id: uuid::Uuid, idp_subject: &str, user_agent: &str) {
|
||||
fn log_refresh_token_replay(session_id: uuid::Uuid, idp_subject: &str, user_agent: &str) {
|
||||
warn!(
|
||||
chain_id = %chain_id,
|
||||
session_id = %session_id,
|
||||
idp_subject = %idp_subject,
|
||||
user_agent_fingerprint = %user_agent_fingerprint(user_agent),
|
||||
"Refresh token replay detected"
|
||||
|
|
@ -1180,8 +1111,7 @@ async fn issue_auth_code_response(
|
|||
let Some(redirect_uri) = canonical_loopback_redirect_uri(redirect_uri) else {
|
||||
return static_error_page(INVALID_REDIRECT_URI);
|
||||
};
|
||||
let entry = AuthCode {
|
||||
code: code.clone(),
|
||||
let entry = PendingCliAuthorization {
|
||||
identity,
|
||||
login: session.login.clone(),
|
||||
name: session.name.clone(),
|
||||
|
|
@ -1192,20 +1122,7 @@ async fn issue_auth_code_response(
|
|||
expires_at: chrono::Utc::now() + chrono::Duration::seconds(60),
|
||||
};
|
||||
|
||||
let store = match state.store_ref().auth_codes().await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to open auth code store");
|
||||
return redirect_with_error(
|
||||
&redirect_uri,
|
||||
state_token,
|
||||
"server_error",
|
||||
"Could not complete GitHub sign-in",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = store.insert(entry).await {
|
||||
if let Err(err) = state.stores.auth_codes.issue(&code, &entry).await {
|
||||
warn!(error = %err, "Failed to persist auth code");
|
||||
return redirect_with_error(
|
||||
&redirect_uri,
|
||||
|
|
@ -1248,9 +1165,12 @@ mod tests {
|
|||
CliFlowCookie, DEV_TOKEN_LOGIN_INSTRUCTIONS, add_cli_flow_cookie, read_private_cli_flow,
|
||||
user_agent_fingerprint, web_routes,
|
||||
};
|
||||
use crate::auth::{self, AuthCode, AuthErrorCode, RefreshToken};
|
||||
use crate::auth::{
|
||||
self, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, PendingCliAuthorization,
|
||||
};
|
||||
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
|
||||
use crate::principal_middleware::{AuthStatus, RequestAuthContext};
|
||||
use crate::server::AppState;
|
||||
use crate::web_auth::SessionCookie;
|
||||
|
||||
fn test_cookie_key() -> Key {
|
||||
|
|
@ -1391,10 +1311,10 @@ client_id = "github-client-id"
|
|||
}
|
||||
|
||||
async fn insert_auth_code(state: &crate::server::AppState, code: &str, verifier: &str) {
|
||||
let auth_codes = state.store_ref().auth_codes().await.unwrap();
|
||||
auth_codes
|
||||
.insert(AuthCode {
|
||||
code: code.to_string(),
|
||||
state
|
||||
.stores
|
||||
.auth_codes
|
||||
.issue(code, &PendingCliAuthorization {
|
||||
identity: fabro_types::IdpIdentity::new("https://github.com", "12345")
|
||||
.expect("identity should be valid"),
|
||||
login: "octocat".to_string(),
|
||||
|
|
@ -1413,23 +1333,37 @@ client_id = "github-client-id"
|
|||
Sha256::digest(secret.as_bytes()).into()
|
||||
}
|
||||
|
||||
fn refresh_row(secret: &str) -> RefreshToken {
|
||||
fn session_and_token(secret: &str) -> (AuthSessionRecord, InitialRefreshToken) {
|
||||
let now = chrono::Utc::now();
|
||||
RefreshToken {
|
||||
token_hash: hash_refresh_secret(secret),
|
||||
chain_id: Uuid::new_v4(),
|
||||
let session = AuthSessionRecord {
|
||||
id: Uuid::new_v4(),
|
||||
identity: fabro_types::IdpIdentity::new("https://github.com", "12345")
|
||||
.expect("identity should be valid"),
|
||||
login: "octocat".to_string(),
|
||||
name: "The Octocat".to_string(),
|
||||
email: "octocat@example.com".to_string(),
|
||||
avatar_url: "https://example.com/octocat.png".to_string(),
|
||||
issued_at: now,
|
||||
expires_at: now + chrono::Duration::days(30),
|
||||
last_used_at: now,
|
||||
used: false,
|
||||
user_agent: "fabro-test".to_string(),
|
||||
}
|
||||
created_at: now,
|
||||
last_used_at: now,
|
||||
};
|
||||
let token = InitialRefreshToken {
|
||||
token_hash: hash_refresh_secret(secret),
|
||||
issued_at: now,
|
||||
expires_at: now + chrono::Duration::days(30),
|
||||
};
|
||||
(session, token)
|
||||
}
|
||||
|
||||
async fn open_cli_session(state: &AppState, secret: &str) -> Uuid {
|
||||
let (session, token) = session_and_token(secret);
|
||||
state
|
||||
.stores
|
||||
.auth_sessions
|
||||
.create_session(&session, &token)
|
||||
.await
|
||||
.unwrap();
|
||||
session.id
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -1769,9 +1703,10 @@ client_id = "github-client-id"
|
|||
.nth(1)
|
||||
.and_then(|segment| segment.split('&').next())
|
||||
.expect("auth code should be present");
|
||||
let auth_codes = state.store_ref().auth_codes().await.unwrap();
|
||||
let entry = auth_codes
|
||||
.consume(code)
|
||||
let entry = state
|
||||
.stores
|
||||
.auth_codes
|
||||
.consume(code, chrono::Utc::now())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("code should exist");
|
||||
|
|
@ -2014,9 +1949,9 @@ client_id = "github-client-id"
|
|||
.unwrap()
|
||||
.strip_prefix("fabro_refresh_")
|
||||
.unwrap();
|
||||
let auth_tokens = state.store_ref().refresh_tokens().await.unwrap();
|
||||
let refresh = auth_tokens
|
||||
.find_refresh_token(&hash_refresh_secret(refresh_secret))
|
||||
let auth_sessions = &state.stores.auth_sessions;
|
||||
let refresh = auth_sessions
|
||||
.find_session_by_token_hash(&hash_refresh_secret(refresh_secret))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("refresh token should be stored");
|
||||
|
|
@ -2126,6 +2061,49 @@ client_id = "github-client-id"
|
|||
assert_eq!(body["error"], "invalid_code");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_storage_failure_returns_safe_oauth_error() {
|
||||
let (app, state) = test_router(github_settings("https://fabro.example"));
|
||||
state.stores.auth_codes.test_close().await;
|
||||
let raw_code = "raw-code-that-must-not-escape";
|
||||
let raw_verifier = "raw-verifier-that-must-not-escape";
|
||||
let redirect_uri = "http://127.0.0.1:4444/callback";
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/auth/cli/token")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"grant_type": "authorization_code",
|
||||
"code": raw_code,
|
||||
"code_verifier": raw_verifier,
|
||||
"redirect_uri": redirect_uri
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let rendered = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&rendered).unwrap(),
|
||||
json!({
|
||||
"error": "server_error",
|
||||
"error_description": "Could not complete authentication"
|
||||
})
|
||||
);
|
||||
for sensitive in [raw_code, raw_verifier, redirect_uri] {
|
||||
assert!(!rendered.contains(sensitive));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_rejects_userinfo_injected_redirect_uri() {
|
||||
let (app, state) = test_router(github_settings("https://fabro.example"));
|
||||
|
|
@ -2168,11 +2146,8 @@ client_id = "github-client-id"
|
|||
async fn refresh_rotates_tokens_and_replay_revokes_chain() {
|
||||
let (app, state) = test_router(github_settings("https://fabro.example"));
|
||||
let initial_secret = "refresh-secret-1";
|
||||
let auth_tokens = state.store_ref().refresh_tokens().await.unwrap();
|
||||
auth_tokens
|
||||
.insert_refresh_token(refresh_row(initial_secret))
|
||||
.await
|
||||
.unwrap();
|
||||
open_cli_session(&state, initial_secret).await;
|
||||
let auth_sessions = &state.stores.auth_sessions;
|
||||
|
||||
let refresh_request = || {
|
||||
Request::builder()
|
||||
|
|
@ -2211,15 +2186,15 @@ client_id = "github-client-id"
|
|||
|
||||
let new_secret = rotated.strip_prefix("fabro_refresh_").unwrap();
|
||||
assert!(
|
||||
auth_tokens
|
||||
.find_refresh_token(&hash_refresh_secret(initial_secret))
|
||||
auth_sessions
|
||||
.find_session_by_token_hash(&hash_refresh_secret(initial_secret))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
auth_tokens
|
||||
.find_refresh_token(&hash_refresh_secret(new_secret))
|
||||
auth_sessions
|
||||
.find_session_by_token_hash(&hash_refresh_secret(new_secret))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
|
|
@ -2233,14 +2208,7 @@ client_id = "github-client-id"
|
|||
github_auth_mode(),
|
||||
);
|
||||
let initial_secret = "refresh-secret-auth-context";
|
||||
state
|
||||
.store_ref()
|
||||
.refresh_tokens()
|
||||
.await
|
||||
.unwrap()
|
||||
.insert_refresh_token(refresh_row(initial_secret))
|
||||
.await
|
||||
.unwrap();
|
||||
open_cli_session(&state, initial_secret).await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
|
|
@ -2296,11 +2264,8 @@ client_id = "github-client-id"
|
|||
async fn concurrent_refresh_has_one_winner_and_revokes_chain() {
|
||||
let (app, state) = test_router(github_settings("https://fabro.example"));
|
||||
let initial_secret = "refresh-secret-concurrent";
|
||||
let auth_tokens = state.store_ref().refresh_tokens().await.unwrap();
|
||||
auth_tokens
|
||||
.insert_refresh_token(refresh_row(initial_secret))
|
||||
.await
|
||||
.unwrap();
|
||||
open_cli_session(&state, initial_secret).await;
|
||||
let auth_sessions = &state.stores.auth_sessions;
|
||||
|
||||
let barrier = Arc::new(Barrier::new(33));
|
||||
let mut tasks = JoinSet::new();
|
||||
|
|
@ -2348,7 +2313,11 @@ client_id = "github-client-id"
|
|||
.map(str::to_string);
|
||||
}
|
||||
StatusCode::UNAUTHORIZED => {
|
||||
assert_eq!(body["error"], "refresh_token_revoked");
|
||||
let error = body["error"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
error == "refresh_token_revoked" || error == "refresh_token_expired",
|
||||
"unexpected refresh error {error}"
|
||||
);
|
||||
revoked += 1;
|
||||
}
|
||||
other => panic!("unexpected refresh status {other}: {body}"),
|
||||
|
|
@ -2359,15 +2328,15 @@ client_id = "github-client-id"
|
|||
assert_eq!(revoked, 31);
|
||||
let rotated_secret = rotated_secret.expect("one refresh should rotate the token");
|
||||
assert!(
|
||||
auth_tokens
|
||||
.find_refresh_token(&hash_refresh_secret(initial_secret))
|
||||
auth_sessions
|
||||
.find_session_by_token_hash(&hash_refresh_secret(initial_secret))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
auth_tokens
|
||||
.find_refresh_token(&hash_refresh_secret(&rotated_secret))
|
||||
auth_sessions
|
||||
.find_session_by_token_hash(&hash_refresh_secret(&rotated_secret))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
|
|
@ -2378,17 +2347,24 @@ client_id = "github-client-id"
|
|||
async fn logout_deletes_refresh_token_chain_and_returns_no_content() {
|
||||
let (app, state) = test_router(github_settings("https://fabro.example"));
|
||||
let secret = "refresh-secret-logout";
|
||||
let token = refresh_row(secret);
|
||||
let chain_id = token.chain_id;
|
||||
let auth_tokens = state.store_ref().refresh_tokens().await.unwrap();
|
||||
auth_tokens.insert_refresh_token(token).await.unwrap();
|
||||
let (session, token) = session_and_token(secret);
|
||||
let auth_sessions = &state.stores.auth_sessions;
|
||||
auth_sessions
|
||||
.create_session(&session, &token)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sibling = RefreshToken {
|
||||
token_hash: hash_refresh_secret("refresh-secret-logout-2"),
|
||||
chain_id,
|
||||
..refresh_row("refresh-secret-logout-2")
|
||||
};
|
||||
auth_tokens.insert_refresh_token(sibling).await.unwrap();
|
||||
let now = chrono::Utc::now();
|
||||
auth_sessions
|
||||
.rotate(
|
||||
&hash_refresh_secret(secret),
|
||||
&hash_refresh_secret("refresh-secret-logout-2"),
|
||||
now + chrono::Duration::days(30),
|
||||
"fabro-test",
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
|
|
@ -2407,15 +2383,15 @@ client_id = "github-client-id"
|
|||
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
assert!(
|
||||
auth_tokens
|
||||
.find_refresh_token(&hash_refresh_secret(secret))
|
||||
auth_sessions
|
||||
.find_session_by_token_hash(&hash_refresh_secret(secret))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
auth_tokens
|
||||
.find_refresh_token(&hash_refresh_secret("refresh-secret-logout-2"))
|
||||
auth_sessions
|
||||
.find_session_by_token_hash(&hash_refresh_secret("refresh-secret-logout-2"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ pub(crate) const REFRESH_TOKEN_PREFIX: &str = "fabro_refresh_";
|
|||
|
||||
pub(crate) use browser_shell::browser_shell;
|
||||
pub(crate) use cli_flow::web_routes;
|
||||
pub(crate) use fabro_store::{AuthCode, ConsumeOutcome, RefreshToken};
|
||||
pub(crate) use fabro_store::PendingCliAuthorization;
|
||||
pub(crate) use fabro_store::auth_session_store::{
|
||||
AuthSessionRecord, InitialRefreshToken, RotateOutcome,
|
||||
};
|
||||
pub use github_endpoints::GithubEndpoints;
|
||||
pub(crate) use jwt::{JwtError, JwtSubject, issue, verify};
|
||||
pub(crate) use keys::{
|
||||
|
|
|
|||
|
|
@ -1,25 +1,23 @@
|
|||
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::{
|
||||
GitCheckoutError, GitRepoCache, WorktreePrepareInput, github_metadata_url,
|
||||
resolve_git_auth_config,
|
||||
GitCheckoutError, GitRepoCache, WorktreePrepareInput, resolve_git_auth_config,
|
||||
};
|
||||
|
||||
#[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,
|
||||
|
|
@ -29,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]
|
||||
|
|
@ -94,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-{}-{}-",
|
||||
|
|
@ -108,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(
|
||||
|
|
@ -122,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> {
|
||||
|
|
@ -185,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,
|
||||
|
|
@ -195,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: github_metadata_url(&git_context.repo),
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -234,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(),
|
||||
|
|
@ -284,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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,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(),
|
||||
})
|
||||
|
|
@ -366,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");
|
||||
|
|
|
|||
|
|
@ -672,7 +672,7 @@ pub(crate) async fn run_diagnostics(
|
|||
{ "name": "GitHub App", "status": "pass", "summary": "demo configured", "details": [], "remediation": null },
|
||||
{ "name": "Docker Sandbox", "status": "pass", "summary": "disabled", "details": [{ "text": "server.sandbox.providers.docker.enabled = false", "warn": false }], "remediation": null },
|
||||
{ "name": "Cloud Sandbox", "status": "warning", "summary": "not configured", "details": [], "remediation": "Set DAYTONA_API_KEY to enable cloud sandbox execution" },
|
||||
{ "name": "Brave Search", "status": "warning", "summary": "not configured", "details": [], "remediation": "Set BRAVE_SEARCH_API_KEY to enable web search" }
|
||||
{ "name": "Web Search", "status": "warning", "summary": "optional, not configured", "details": [], "remediation": "Set BRAVE_SEARCH_API_KEY or VENICE_API_KEY to enable web search" }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1531,6 +1531,9 @@ mod runs {
|
|||
output: serde_json::json!("[redis]\nhost = \"redis-prod.internal\"\nport = 6379"),
|
||||
is_error: false,
|
||||
visit: 1,
|
||||
output_bytes_observed: None,
|
||||
output_bytes_retained: None,
|
||||
output_bytes_omitted: None,
|
||||
tool_result: None,
|
||||
turn_id: None,
|
||||
}),
|
||||
|
|
@ -1557,6 +1560,9 @@ mod runs {
|
|||
output: serde_json::json!("[redis]\nhost = \"redis-staging.internal\"\nport = 6379"),
|
||||
is_error: false,
|
||||
visit: 1,
|
||||
output_bytes_observed: None,
|
||||
output_bytes_retained: None,
|
||||
output_bytes_omitted: None,
|
||||
tool_result: None,
|
||||
turn_id: None,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use std::time::Duration;
|
|||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_auth::auth_issue_message;
|
||||
use fabro_http::Response;
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe_with_timeout};
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
|
|
@ -19,6 +20,7 @@ use fabro_util::session_secret;
|
|||
use fabro_util::version::FABRO_VERSION;
|
||||
use futures_util::future::join_all;
|
||||
use serde::Serialize;
|
||||
use tokio::time::error::Elapsed;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::server::AppState;
|
||||
|
|
@ -92,12 +94,12 @@ fn validate_session_secret(value: &str) -> Result<(), String> {
|
|||
}
|
||||
|
||||
pub async fn run_all(state: &AppState) -> DiagnosticsReport {
|
||||
let (llm, github, docker_sandbox, cloud_sandbox, brave, crypto) = tokio::join!(
|
||||
let (llm, github, docker_sandbox, cloud_sandbox, web_search, crypto) = tokio::join!(
|
||||
check_llm_providers(state),
|
||||
check_github_app(state),
|
||||
check_docker_sandbox(state),
|
||||
check_cloud_sandbox(state),
|
||||
check_brave_search(state),
|
||||
check_web_search(state),
|
||||
check_crypto(state),
|
||||
);
|
||||
|
||||
|
|
@ -106,7 +108,7 @@ pub async fn run_all(state: &AppState) -> DiagnosticsReport {
|
|||
sections: vec![
|
||||
CheckSection {
|
||||
title: "Credentials".to_string(),
|
||||
checks: vec![llm, github, docker_sandbox, cloud_sandbox, brave],
|
||||
checks: vec![llm, github, docker_sandbox, cloud_sandbox, web_search],
|
||||
},
|
||||
CheckSection {
|
||||
title: "Configuration".to_string(),
|
||||
|
|
@ -755,25 +757,45 @@ fn check_storage_dir_path(path: &std::path::Path) -> CheckResult {
|
|||
}
|
||||
}
|
||||
|
||||
async fn check_brave_search(state: &AppState) -> CheckResult {
|
||||
let api_key =
|
||||
match diagnostic_secret(state, "Web Search (Brave)", EnvVars::BRAVE_SEARCH_API_KEY).await {
|
||||
async fn check_web_search(state: &AppState) -> CheckResult {
|
||||
let brave_api_key = match diagnostic_secret(
|
||||
state,
|
||||
WEB_SEARCH_CHECK_NAME,
|
||||
EnvVars::BRAVE_SEARCH_API_KEY,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(result) => return result,
|
||||
};
|
||||
if let Some(api_key) = brave_api_key {
|
||||
return check_brave_search(api_key).await;
|
||||
}
|
||||
|
||||
let venice_api_key =
|
||||
match diagnostic_secret(state, WEB_SEARCH_CHECK_NAME, EnvVars::VENICE_API_KEY).await {
|
||||
Ok(value) => value,
|
||||
Err(result) => return result,
|
||||
};
|
||||
let Some(api_key) = api_key else {
|
||||
return CheckResult {
|
||||
name: "Web Search (Brave)".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "optional, not configured".to_string(),
|
||||
details: Vec::new(),
|
||||
remediation: Some(
|
||||
"Run `fabro secret set BRAVE_SEARCH_API_KEY` to enable web search".to_string(),
|
||||
),
|
||||
};
|
||||
};
|
||||
if let Some(api_key) = venice_api_key {
|
||||
return check_venice_search(api_key).await;
|
||||
}
|
||||
|
||||
let http = match http_client_or_check("Web Search (Brave)", CheckStatus::Warning) {
|
||||
CheckResult {
|
||||
name: WEB_SEARCH_CHECK_NAME.to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "optional, not configured".to_string(),
|
||||
details: Vec::new(),
|
||||
remediation: Some(
|
||||
"Run `fabro secret set BRAVE_SEARCH_API_KEY` or `fabro secret set VENICE_API_KEY` to enable web search".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const WEB_SEARCH_CHECK_NAME: &str = "Web Search";
|
||||
|
||||
async fn check_brave_search(api_key: String) -> CheckResult {
|
||||
let http = match http_client_or_check(WEB_SEARCH_CHECK_NAME, CheckStatus::Warning) {
|
||||
Ok(http) => http,
|
||||
Err(result) => return result,
|
||||
};
|
||||
|
|
@ -787,36 +809,67 @@ async fn check_brave_search(state: &AppState) -> CheckResult {
|
|||
})
|
||||
.await;
|
||||
|
||||
match_web_search_probe(probe, "brave", "BRAVE_SEARCH_API_KEY")
|
||||
}
|
||||
|
||||
async fn check_venice_search(api_key: String) -> CheckResult {
|
||||
let http = match http_client_or_check(WEB_SEARCH_CHECK_NAME, CheckStatus::Warning) {
|
||||
Ok(http) => http,
|
||||
Err(result) => return result,
|
||||
};
|
||||
|
||||
let probe = timeout(EXTERNAL_SERVICE_PROBE_TIMEOUT, async move {
|
||||
http.post("https://api.venice.ai/api/v1/augment/search")
|
||||
.bearer_auth(api_key)
|
||||
.json(&serde_json::json!({
|
||||
"query": "test",
|
||||
"limit": 1,
|
||||
"search_provider": "brave",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(anyhow::Error::new)
|
||||
})
|
||||
.await;
|
||||
|
||||
match_web_search_probe(probe, "venice", "VENICE_API_KEY")
|
||||
}
|
||||
|
||||
fn match_web_search_probe(
|
||||
probe: Result<anyhow::Result<Response>, Elapsed>,
|
||||
provider: &str,
|
||||
secret_name: &str,
|
||||
) -> CheckResult {
|
||||
match probe {
|
||||
Ok(Ok(response)) if response.status().is_success() => CheckResult {
|
||||
name: "Web Search (Brave)".to_string(),
|
||||
name: WEB_SEARCH_CHECK_NAME.to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: "configured and reachable".to_string(),
|
||||
summary: format!("{provider}: configured and reachable"),
|
||||
details: Vec::new(),
|
||||
remediation: None,
|
||||
},
|
||||
Ok(Ok(response)) => CheckResult {
|
||||
name: "Web Search (Brave)".to_string(),
|
||||
name: WEB_SEARCH_CHECK_NAME.to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: format!("HTTP {}", response.status()),
|
||||
summary: format!("{provider}: HTTP {}", response.status()),
|
||||
details: Vec::new(),
|
||||
remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()),
|
||||
remediation: Some(format!("Check {secret_name} and network connectivity")),
|
||||
},
|
||||
Ok(Err(err)) => CheckResult {
|
||||
name: "Web Search (Brave)".to_string(),
|
||||
name: WEB_SEARCH_CHECK_NAME.to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "connectivity error".to_string(),
|
||||
summary: format!("{provider}: connectivity error"),
|
||||
details: vec![CheckDetail::new(format!("{err:#}"))],
|
||||
remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()),
|
||||
remediation: Some(format!("Check {secret_name} and network connectivity")),
|
||||
},
|
||||
Err(_) => CheckResult {
|
||||
name: "Web Search (Brave)".to_string(),
|
||||
name: WEB_SEARCH_CHECK_NAME.to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "timeout".to_string(),
|
||||
details: vec![CheckDetail::new(
|
||||
"Web Search (Brave) probe timed out".to_string(),
|
||||
)],
|
||||
remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()),
|
||||
summary: format!("{provider}: timeout"),
|
||||
details: vec![CheckDetail::new(format!(
|
||||
"Web Search ({provider}) probe timed out"
|
||||
))],
|
||||
remediation: Some(format!("Check {secret_name} and network connectivity")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1195,23 +1248,76 @@ enabled = false
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_brave_search_ignores_env_backed_api_key() {
|
||||
async fn check_web_search_ignores_env_backed_brave_api_key() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
.env_lookup(|name| {
|
||||
(name == EnvVars::BRAVE_SEARCH_API_KEY).then(|| "brave-from-env".to_string())
|
||||
})
|
||||
.build();
|
||||
|
||||
let result = check_brave_search(&state).await;
|
||||
let result = check_web_search(&state).await;
|
||||
|
||||
assert_eq!(result.name, "Web Search");
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert_eq!(result.summary, "optional, not configured");
|
||||
assert_eq!(
|
||||
result.remediation.as_deref(),
|
||||
Some("Run `fabro secret set BRAVE_SEARCH_API_KEY` to enable web search")
|
||||
Some(
|
||||
"Run `fabro secret set BRAVE_SEARCH_API_KEY` or `fabro secret set VENICE_API_KEY` to enable web search"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_web_search_ignores_env_backed_venice_api_key() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
.env_lookup(|name| {
|
||||
(name == EnvVars::VENICE_API_KEY).then(|| "venice-from-env".to_string())
|
||||
})
|
||||
.build();
|
||||
|
||||
let result = check_web_search(&state).await;
|
||||
|
||||
assert_eq!(result.name, "Web Search");
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert_eq!(result.summary, "optional, not configured");
|
||||
assert_eq!(
|
||||
result.remediation.as_deref(),
|
||||
Some(
|
||||
"Run `fabro secret set BRAVE_SEARCH_API_KEY` or `fabro secret set VENICE_API_KEY` to enable web search"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_web_search_prefers_brave_when_both_vault_keys_exist() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
.vault_entries([
|
||||
(EnvVars::BRAVE_SEARCH_API_KEY, "invalid\n"),
|
||||
(EnvVars::VENICE_API_KEY, "invalid\n"),
|
||||
])
|
||||
.build();
|
||||
|
||||
let result = check_web_search(&state).await;
|
||||
|
||||
assert_eq!(result.name, "Web Search");
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert_eq!(result.summary, "brave: connectivity error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_web_search_uses_venice_when_brave_vault_key_is_absent() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
.vault_entries([(EnvVars::VENICE_API_KEY, "invalid\n")])
|
||||
.build();
|
||||
|
||||
let result = check_web_search(&state).await;
|
||||
|
||||
assert_eq!(result.name, "Web Search");
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert_eq!(result.summary, "venice: connectivity error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_crypto_requires_github_client_secret_from_vault() {
|
||||
let settings = fabro_config::ServerSettingsBuilder::from_toml(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue