mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Merge main into feat/live-run-billing-totals
This commit is contained in:
commit
e688ee59a7
643 changed files with 6528 additions and 2200 deletions
121
Cargo.lock
generated
121
Cargo.lock
generated
|
|
@ -2255,7 +2255,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-acp"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-tokio",
|
||||
|
|
@ -2274,7 +2274,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2320,7 +2320,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-automation",
|
||||
|
|
@ -2343,7 +2343,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2368,7 +2368,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-automation"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2388,11 +2388,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -2408,7 +2408,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2510,7 +2510,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2539,7 +2539,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2569,7 +2569,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
|
|
@ -2584,7 +2584,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-db"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2596,7 +2596,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2615,7 +2615,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2629,7 +2629,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-environment"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2651,7 +2651,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2673,7 +2673,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2688,7 +2688,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -2711,7 +2711,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2721,7 +2721,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2740,7 +2740,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2755,7 +2755,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2797,7 +2797,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2808,7 +2808,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2816,9 +2816,11 @@ dependencies = [
|
|||
"fabro-github",
|
||||
"fabro-graphviz",
|
||||
"fabro-template",
|
||||
"fabro-test",
|
||||
"fabro-types",
|
||||
"fabro-workflow",
|
||||
"git2",
|
||||
"insta",
|
||||
"serde_json",
|
||||
"temp-env",
|
||||
"tempfile",
|
||||
|
|
@ -2827,7 +2829,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2847,7 +2849,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2875,7 +2877,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-store"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-db",
|
||||
|
|
@ -2893,7 +2895,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2909,7 +2911,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2931,7 +2933,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2939,7 +2941,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2948,7 +2950,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2964,7 +2966,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3008,7 +3010,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3058,6 +3060,7 @@ dependencies = [
|
|||
"fabro-variable",
|
||||
"fabro-vault",
|
||||
"fabro-workflow",
|
||||
"fabro-workflow-version",
|
||||
"futures-util",
|
||||
"globset",
|
||||
"hex",
|
||||
|
|
@ -3102,7 +3105,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -3124,18 +3127,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -3165,7 +3168,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -3191,7 +3194,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -3205,7 +3208,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3230,7 +3233,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3251,7 +3254,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3265,7 +3268,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -3288,7 +3291,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -3311,7 +3314,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-acp",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -3324,7 +3327,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-variable"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3341,7 +3344,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3360,7 +3363,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3428,6 +3431,22 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-workflow-version"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-config",
|
||||
"fabro-graphviz",
|
||||
"fabro-store",
|
||||
"fabro-template",
|
||||
"fabro-types",
|
||||
"object_store",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fail-parallel"
|
||||
version = "0.5.1"
|
||||
|
|
@ -8525,7 +8544,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -8544,7 +8563,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ Emitted when the run record is created.
|
|||
| `source_directory` | string? | Submitter-side source directory |
|
||||
| `workflow_slug` | string? | Workflow slug |
|
||||
| `provenance` | object | Actor and request provenance |
|
||||
| `manifest_blob` | string? | Blob id for the submitted manifest |
|
||||
| `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 |
|
||||
| `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) |
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ when does it resolve** — see [Which process resolves what](#which-process-reso
|
|||
- Resolution is snapshot-based: env and file are read once at construction, then treated as immutable for the life of the process.
|
||||
- `process env` wins over `server.env` on conflicts.
|
||||
- Optional integration secrets are vault-only in the **server process**. Do not add optional server integrations to `ServerSecrets`, and do not add bespoke env fallback paths to it.
|
||||
- Not every credential is a `ServerSecrets` or vault lookup. A third mechanism exists: **settings-declared credentials** in `InterpString` fields, resolved at consumption time from `{{ env.NAME }}` or `{{ secrets.NAME }}`. See [Settings-declared credentials](#settings-declared-credentials).
|
||||
- Not every credential is a `ServerSecrets` or vault lookup. A third mechanism exists: **settings-declared credentials** in `InterpString` fields, resolved at consumption time from `{{ secrets.NAME }}`. See [Settings-declared credentials](#settings-declared-credentials).
|
||||
- `fabro server start` never generates secrets. Missing required secrets are a startup error.
|
||||
- `std::env::set_var` and `std::env::remove_var` are banned workspace-wide. Tests are not exempt. Enforced by clippy via `disallowed_methods` in `clippy.toml`; intentional exceptions must be annotated with a scoped `#[expect(clippy::disallowed_methods, reason = "...")]` at the call site.
|
||||
|
||||
|
|
@ -70,7 +70,6 @@ than saying "server runtime", which is ambiguous.
|
|||
| Bootstrap server secret | Server process, via `ServerSecrets` | Once at construction, then immutable |
|
||||
| Optional integration secret | Server process or worker, via the vault | At use |
|
||||
| `{{ vars.NAME }}` | Server process | When the run is created, from that run's variable snapshot |
|
||||
| `{{ env.NAME }}` | The process that owns the value (usually the worker) | At consumption time |
|
||||
| `{{ secrets.NAME }}` | The process that owns the value, against the server vault | At consumption time |
|
||||
|
||||
`docs/public/agents/mcp.mdx` documents the same split for MCP server configuration and is a good
|
||||
|
|
@ -80,18 +79,17 @@ worked example of the shape.
|
|||
|
||||
Some credentials are declared in settings rather than looked up by name. Those fields are
|
||||
`InterpString` (`lib/foundation/fabro-types/src/settings/interp.rs`), which supports narrow
|
||||
`{{ namespace.NAME }}` tokens with no template logic. Three namespaces resolve: `env` (process
|
||||
environment, consumption time), `secrets` (vault, consumption time), and `vars` (non-sensitive run
|
||||
variables, substituted early at run creation). A token whose namespace is unavailable in the
|
||||
resolution context fails loudly.
|
||||
`{{ namespace.NAME }}` tokens with no template logic. Two namespaces resolve: `secrets` (vault,
|
||||
consumption time) and `vars` (non-sensitive run variables, substituted early at run creation).
|
||||
`{{ env.NAME }}` tokens still parse but never resolve; they fail loudly with a migration message. A
|
||||
token whose namespace is unavailable in the resolution context also fails loudly.
|
||||
|
||||
The reference implementation is LLM provider `extra_headers`, resolved against env plus vault at
|
||||
The reference implementation is LLM provider `extra_headers`, resolved against the vault at
|
||||
`lib/foundation/fabro-auth/src/resolve.rs:376-378`:
|
||||
|
||||
```toml
|
||||
[llm.providers.example.extra_headers]
|
||||
authorization = "Bearer {{ secrets.EXAMPLE_TOKEN }}"
|
||||
x-tenant = "{{ env.EXAMPLE_TENANT }}"
|
||||
```
|
||||
|
||||
Use this mechanism when the credential belongs to an operator-configured integration declared in
|
||||
|
|
@ -149,7 +147,7 @@ First pick the mechanism. These are the only three:
|
|||
|---|---|---|
|
||||
| Bootstrap server secret | Platform env or install-written `server.env` | `state.server_secret(...)` |
|
||||
| Optional integration secret | Vault (`fabro secret set`, `fabro install`) | `state.vault_secret(...)` |
|
||||
| Settings-declared credential | `{{ secrets.* }}` or `{{ env.* }}` in an `InterpString` settings field | Resolved at consumption time by the owning process |
|
||||
| Settings-declared credential | `{{ secrets.* }}` in an `InterpString` settings field | Resolved at consumption time by the owning process |
|
||||
|
||||
Then:
|
||||
|
||||
|
|
|
|||
|
|
@ -219,10 +219,10 @@ When Fabro builds a [preamble](/execution/context#preamble-construction) for a d
|
|||
- **plan**: success
|
||||
- Model: claude-sonnet-4-5, 12.4k tokens in / 3.2k out
|
||||
- Files: src/main.rs, tests/api_test.rs
|
||||
- Response: See: /path/to/runtime/blobs/<blob_id>.json
|
||||
- Response: See: /path/to/runtime/blobs/<blob_hash>.json
|
||||
- **test**: success
|
||||
- Script: `cargo test 2>&1 || true`
|
||||
- Stdout: See: /path/to/runtime/blobs/<blob_id>.json
|
||||
- Stdout: See: /path/to/runtime/blobs/<blob_hash>.json
|
||||
```
|
||||
|
||||
This keeps preambles concise while still giving agents a path to read the full output if needed.
|
||||
|
|
@ -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_id}.json`
|
||||
- Blob refs are materialized into `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
openapi: "3.1.0"
|
||||
info:
|
||||
title: Fabro Run API
|
||||
version: "0.1.0"
|
||||
version: "0.2.0"
|
||||
description: HTTP API for managing Fabro workflow run executions.
|
||||
|
||||
tags:
|
||||
|
|
@ -33,6 +33,8 @@ tags:
|
|||
description: Internal run details (stages, turns, context, configuration)
|
||||
- name: Workflows
|
||||
description: Workflow definitions and execution
|
||||
- name: Workflow Versions
|
||||
description: Immutable, content-addressed workflow packages
|
||||
- name: Billing
|
||||
description: Token counts and billed totals
|
||||
- name: Insights
|
||||
|
|
@ -1065,6 +1067,69 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Workflow Versions ─────────────────────────────────────────────────
|
||||
|
||||
/api/v1/workflow-versions:
|
||||
post:
|
||||
operationId: createWorkflowVersion
|
||||
tags: [Workflow Versions]
|
||||
summary: Create Workflow Version
|
||||
description: >-
|
||||
Validates and stores an immutable workflow package in content-addressed
|
||||
storage. Repeating the same canonical content returns the same identifier.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WorkflowVersion"
|
||||
responses:
|
||||
"201":
|
||||
description: Workflow version stored or already present
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateWorkflowVersionResponse"
|
||||
"400":
|
||||
description: Malformed JSON (`invalid_json`)
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"413":
|
||||
description: Request body exceeds 2 MiB (`workflow_version_too_large`)
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: >-
|
||||
Invalid workflow content (`workflow_version_invalid`) or an absent,
|
||||
invalid, or non-canonical dependency
|
||||
(`workflow_version_dependency_not_found`)
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Workflow version storage failed
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Runs ──────────────────────────────────────────────────────────────
|
||||
|
||||
/api/v1/runs:
|
||||
|
|
@ -3027,7 +3092,7 @@ paths:
|
|||
operationId: writeRunBlob
|
||||
tags: [Run Internals]
|
||||
summary: Write Run Blob
|
||||
description: Writes an opaque binary blob and returns its content-addressed blob identifier.
|
||||
description: Writes an opaque binary blob and returns its content-addressed blob hash.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
requestBody:
|
||||
|
|
@ -3072,15 +3137,15 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/blobs/{blobId}:
|
||||
/api/v1/runs/{id}/blobs/{blobHash}:
|
||||
get:
|
||||
operationId: readRunBlob
|
||||
tags: [Run Internals]
|
||||
summary: Read Run Blob
|
||||
description: Reads a previously stored blob by identifier.
|
||||
description: Reads a previously stored blob by hash.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
- $ref: "#/components/parameters/BlobId"
|
||||
- $ref: "#/components/parameters/BlobHash"
|
||||
responses:
|
||||
"200":
|
||||
description: Blob contents
|
||||
|
|
@ -5909,14 +5974,13 @@ components:
|
|||
default: 65536
|
||||
example: 65536
|
||||
|
||||
BlobId:
|
||||
name: blobId
|
||||
BlobHash:
|
||||
name: blobHash
|
||||
in: path
|
||||
required: true
|
||||
description: Content-addressed blob identifier.
|
||||
description: Content-addressed blob hash.
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{64}$'
|
||||
$ref: "#/components/schemas/BlobHash"
|
||||
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
||||
|
||||
ArtifactFilename:
|
||||
|
|
@ -9074,6 +9138,73 @@ components:
|
|||
detail:
|
||||
$ref: "#/components/schemas/FailureDetail"
|
||||
|
||||
WorkflowPath:
|
||||
description: >-
|
||||
Canonical portable path inside one workflow version. Paths are UTF-8,
|
||||
relative, at most 240 bytes and 16 components, and cannot contain empty,
|
||||
dot, parent, backslash, control, tilde-root, or drive-letter segments.
|
||||
Map keys receive stricter byte and structural validation in the domain
|
||||
model than OpenAPI can express.
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 240
|
||||
example: graphs/main.fabro
|
||||
|
||||
WorkflowVersionId:
|
||||
description: >-
|
||||
SHA-256 identity of validated canonical workflow-version bytes. Hex input is
|
||||
case-insensitive; Fabro emits the canonical lowercase form.
|
||||
type: string
|
||||
pattern: "^[0-9A-Fa-f]{64}$"
|
||||
example: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
WorkflowVersion:
|
||||
description: >-
|
||||
Complete immutable package for one rooted workflow. It contains at most
|
||||
512 files and 512 workflow dependencies, each file is at most 512 KiB
|
||||
of UTF-8 content, and its compact canonical JSON representation is at
|
||||
most 2 MiB.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- entrypoint
|
||||
- files
|
||||
- workflow_dependencies
|
||||
properties:
|
||||
entrypoint:
|
||||
$ref: "#/components/schemas/WorkflowPath"
|
||||
files:
|
||||
type: object
|
||||
description: >-
|
||||
Workflow-local text files keyed by canonical path. Keys receive
|
||||
stricter domain validation than OpenAPI can express; each value is
|
||||
limited to 512 KiB of UTF-8 bytes.
|
||||
maxProperties: 512
|
||||
propertyNames:
|
||||
$ref: "#/components/schemas/WorkflowPath"
|
||||
additionalProperties:
|
||||
type: string
|
||||
workflow_dependencies:
|
||||
type: object
|
||||
description: >-
|
||||
Exact stored workflow-version IDs keyed by resolved child-workflow
|
||||
path. Keys receive stricter domain validation than OpenAPI can express.
|
||||
maxProperties: 512
|
||||
propertyNames:
|
||||
$ref: "#/components/schemas/WorkflowPath"
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/WorkflowVersionId"
|
||||
|
||||
CreateWorkflowVersionResponse:
|
||||
description: Identity of the stored immutable workflow version.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workflow_version_id
|
||||
properties:
|
||||
workflow_version_id:
|
||||
$ref: "#/components/schemas/WorkflowVersionId"
|
||||
|
||||
RunManifest:
|
||||
description: Self-contained workflow run manifest.
|
||||
type: object
|
||||
|
|
@ -10153,16 +10284,22 @@ components:
|
|||
description: Assigned event sequence number.
|
||||
example: 42
|
||||
|
||||
BlobHash:
|
||||
description: >-
|
||||
Content-addressed SHA-256 hash of a stored blob. Hex input is case-insensitive;
|
||||
Fabro emits the canonical lowercase form.
|
||||
type: string
|
||||
pattern: "^[0-9A-Fa-f]{64}$"
|
||||
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
||||
|
||||
WriteBlobResponse:
|
||||
description: Content-addressed identifier for a stored blob.
|
||||
description: Content-addressed hash of a stored blob.
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- hash
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Blob identifier.
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
hash:
|
||||
$ref: "#/components/schemas/BlobHash"
|
||||
|
||||
CommandTermination:
|
||||
description: Terminal state for a command execution.
|
||||
|
|
@ -10271,7 +10408,7 @@ components:
|
|||
example: src/lib.rs
|
||||
sha256:
|
||||
type: ["string", "null"]
|
||||
description: Optional lowercase hex SHA-256 checksum for the file contents.
|
||||
description: Optional SHA-256 checksum for the file contents; hex input is case-insensitive.
|
||||
example: 3f785df4c5b7d3f1f4c1f0ecb0f55f1d9f6f6a3d9f0a8a98f7a74f29d1f81a2c
|
||||
expected_bytes:
|
||||
type: ["integer", "null"]
|
||||
|
|
|
|||
|
|
@ -243,8 +243,8 @@ 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_id}.json`
|
||||
- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_id}.json`
|
||||
- Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_hash}.json`
|
||||
- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
|
||||
These materialized `file://` paths are runtime-only. They are not written back into durable context snapshots.
|
||||
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ memory = "8GB"
|
|||
| `network.allow` | CIDRs for `cidr_allow_list`; entries are validated as CIDRs. |
|
||||
| `lifecycle.preserve` | Keep the created sandbox after the run finishes. |
|
||||
| `lifecycle.stop_on_terminal` | Stop the sandbox when the run reaches a terminal state. |
|
||||
| `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. |
|
||||
| `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. Defaults to `"120m"`; `"0s"` disables auto-stop. |
|
||||
| `labels` | Provider labels. Merge by key across layers. |
|
||||
| `env` | Environment variables passed to command and agent execution. Merge by key across layers. |
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,10 @@ The `lifecycle.auto_stop` setting tells Daytona to stop the sandbox after a peri
|
|||
auto_stop = "30m"
|
||||
```
|
||||
|
||||
When `auto_stop` is unset, Fabro applies a default of 120 minutes so a sandbox leaked by an interrupted run is still reclaimed. Set `auto_stop = "0s"` to disable auto-stop and let the sandbox run indefinitely.
|
||||
|
||||
Daytona counts inactivity from the last sandbox interaction (a command, file operation, or other API call). Time an agent spends on LLM inference does not touch the sandbox, so intervals shorter than your longest inference call risk stopping the sandbox mid-run.
|
||||
|
||||
## Server defaults
|
||||
|
||||
When running via `fabro server start`, the server config at `~/.fabro/settings.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely).
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ async fn write_run_dump(
|
|||
dump.add_file_bytes("run.log", log);
|
||||
}
|
||||
|
||||
dump.hydrate_referenced_blobs_with_reader(|blob_id| {
|
||||
Box::pin(async move { client.read_run_blob(run_id, &blob_id).await })
|
||||
dump.hydrate_referenced_blobs_with_reader(|blob_hash| {
|
||||
Box::pin(async move { client.read_run_blob(run_id, &blob_hash).await })
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use anyhow::{Context as _, Result};
|
|||
use cli_table::format::{Border, Justify, Separator};
|
||||
use cli_table::{Cell, CellStruct, Style, Table};
|
||||
use fabro_api::types;
|
||||
use fabro_types::{PullRequestLink, RunBlobId, RunId, StageId, parse_blob_ref};
|
||||
use fabro_types::{PullRequestLink, RunId, StageId, parse_blob_ref};
|
||||
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
|
||||
use fabro_util::error::render_with_causes;
|
||||
use fabro_util::printer::Printer;
|
||||
|
|
@ -325,11 +325,11 @@ async fn resolve_response_string(
|
|||
run_id: &RunId,
|
||||
response: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(blob_id) = blob_id_from_response(response) else {
|
||||
let Some(blob_hash) = parse_blob_ref(response) else {
|
||||
return Ok(Some(response.to_string()));
|
||||
};
|
||||
|
||||
let Some(bytes) = client.read_run_blob(run_id, &blob_id).await? else {
|
||||
let Some(bytes) = client.read_run_blob(run_id, &blob_hash).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value: serde_json::Value =
|
||||
|
|
@ -341,10 +341,6 @@ async fn resolve_response_string(
|
|||
}))
|
||||
}
|
||||
|
||||
fn blob_id_from_response(response: &str) -> Option<RunBlobId> {
|
||||
parse_blob_ref(response)
|
||||
}
|
||||
|
||||
async fn list_artifact_display_entries_with_client(
|
||||
client: &server_client::Client,
|
||||
run_id: &RunId,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
|||
use fabro_tool::fabro_client::ClientBackend;
|
||||
use fabro_types::settings::run::{RunMode, RunNamespace};
|
||||
use fabro_types::{
|
||||
ArtifactUpload, EventBody, FailureReason, Principal, RunBlobId, RunEvent, RunId,
|
||||
ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId,
|
||||
WorkflowSettings,
|
||||
};
|
||||
use fabro_vault::{SecretStore, Vault};
|
||||
|
|
@ -1008,7 +1008,7 @@ impl RunStoreBackend for HttpRunStore {
|
|||
self.apply_acknowledged_event(seq, event).await
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
self.with_retries("write run blob", || {
|
||||
let client = self.client.clone_for_reuse();
|
||||
let run_id = self.run_id;
|
||||
|
|
@ -1018,12 +1018,12 @@ impl RunStoreBackend for HttpRunStore {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn read_blob(&self, id: &RunBlobId) -> Result<Option<bytes::Bytes>> {
|
||||
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<bytes::Bytes>> {
|
||||
self.with_retries("read run blob", || {
|
||||
let client = self.client.clone_for_reuse();
|
||||
let run_id = self.run_id;
|
||||
let blob_id = *id;
|
||||
async move { client.read_run_blob(&run_id, &blob_id).await }
|
||||
let blob_hash = *blob_hash;
|
||||
async move { client.read_run_blob(&run_id, &blob_hash).await }
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,20 +66,8 @@ fn format_output_snapshot(output: &Output, filters: &[(String, String)]) -> Stri
|
|||
}
|
||||
|
||||
fn normalize_attach_json_progress_event(mut event: Value) -> Value {
|
||||
if let Some(properties) = event.get_mut("properties").and_then(Value::as_object_mut) {
|
||||
if properties.contains_key("manifest_blob") {
|
||||
properties.insert(
|
||||
"manifest_blob".to_string(),
|
||||
Value::String("[BLOB_ID]".to_string()),
|
||||
);
|
||||
}
|
||||
if properties.contains_key("definition_blob") {
|
||||
properties.insert(
|
||||
"definition_blob".to_string(),
|
||||
Value::String("[BLOB_ID]".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// manifest_blob/definition_blob hashes are already rewritten to
|
||||
// [BLOB_HASH] by the shared json_snapshot_filters regexes.
|
||||
// Strip v2-shape server/version fields that the bridge emits,
|
||||
// since the test fixture's socket path is randomised per run.
|
||||
if let Some(settings) = event
|
||||
|
|
@ -896,7 +884,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
}
|
||||
}
|
||||
},
|
||||
"manifest_blob": "[BLOB_ID]",
|
||||
"manifest_blob": "[BLOB_HASH]",
|
||||
"provenance": {
|
||||
"client": {
|
||||
"name": "fabro-cli",
|
||||
|
|
@ -1036,7 +1024,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"event": "run.submitted",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
"definition_blob": "[BLOB_ID]"
|
||||
"definition_blob": "[BLOB_HASH]"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"ts": "[TIMESTAMP]"
|
||||
|
|
|
|||
|
|
@ -314,7 +314,8 @@ fn validate_reports_missing_template_dependency() {
|
|||
exit_code: 1
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
× failed to discover template dependencies: missing template dependency `missing.tpl.md` from `[FIXTURES]/templates/missing_dependency/workflow.fabro`
|
||||
× failed to discover template dependencies
|
||||
╰─▶ missing template dependency `missing.tpl.md` from `[FIXTURES]/templates/missing_dependency/workflow.fabro`
|
||||
");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ fabro-hooks = { path = "../../components/fabro-hooks" }
|
|||
fabro-interview = { path = "../../components/fabro-interview" }
|
||||
fabro-slack = { path = "../../components/fabro-slack" }
|
||||
fabro-workflow = { path = "../../components/fabro-workflow" }
|
||||
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
|
||||
fabro-validate = { path = "../../components/fabro-validate" }
|
||||
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona", "docker"] }
|
||||
fabro-github = { path = "../../components/fabro-github" }
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ use axum::http::StatusCode;
|
|||
use axum::http::request::Parts;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use fabro_types::{AuthMethod, IdpIdentity, Principal, RunBlobId, RunId, StageId, UserPrincipal};
|
||||
use fabro_types::{AuthMethod, BlobHash, IdpIdentity, Principal, RunId, StageId, UserPrincipal};
|
||||
use jsonwebtoken::decode_header;
|
||||
use strum::IntoStaticStr;
|
||||
|
||||
use crate::auth::{AuthErrorCode, JwtError, REFRESH_TOKEN_PREFIX};
|
||||
use crate::error::ApiError;
|
||||
use crate::jwt_auth::{self, AuthMode, ConfiguredAuth};
|
||||
use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path};
|
||||
use crate::server::{AppState, parse_blob_hash_path, parse_run_id_path, parse_stage_id_path};
|
||||
use crate::worker_token::{self, WORKER_TOKEN_KID, WorkerScopeSet};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -61,7 +61,7 @@ pub(crate) struct RequiredRunToolActor(pub(crate) Principal);
|
|||
pub(crate) struct RequireRunScoped(pub(crate) RunId);
|
||||
pub(crate) struct RequireWorkerRunScoped(pub(crate) RunId);
|
||||
pub(crate) struct RequireRunManagementTarget(pub(crate) RunId, pub(crate) Principal);
|
||||
pub(crate) struct RequireRunBlob(pub(crate) RunId, pub(crate) RunBlobId);
|
||||
pub(crate) struct RequireRunBlob(pub(crate) RunId, pub(crate) BlobHash);
|
||||
pub(crate) struct RequireRunStageScoped(pub(crate) RunId, pub(crate) String);
|
||||
pub(crate) struct RequireStageArtifact(pub(crate) RunId, pub(crate) StageId);
|
||||
pub(crate) struct RequireCommandLog(pub(crate) RunId, pub(crate) StageId);
|
||||
|
|
@ -295,14 +295,14 @@ impl FromRequestParts<Arc<AppState>> for RequireRunBlob {
|
|||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Path((id, blob_id)): Path<(String, String)> = Path::from_request_parts(parts, state)
|
||||
let Path((id, blob_hash)): Path<(String, String)> = Path::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let run_id = parse_run_id_path(&id)?;
|
||||
let blob_id = parse_blob_id_path(&blob_id)?;
|
||||
let blob_hash = parse_blob_hash_path(&blob_hash)?;
|
||||
require_worker_or_user_for_run(&auth_slot_from_parts(parts), &run_id)
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
Ok(Self(run_id, blob_id))
|
||||
Ok(Self(run_id, blob_hash))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -586,17 +586,7 @@ fn resolve_dockerfiles(
|
|||
config_path: &ManifestPath,
|
||||
files: &HashMap<ManifestPath, String>,
|
||||
) -> Result<()> {
|
||||
for environment in layer.environments.values_mut() {
|
||||
if let Some(image) = environment.image.as_mut() {
|
||||
resolve_dockerfile(image, config_path, files)?;
|
||||
}
|
||||
}
|
||||
if let Some(image) = layer
|
||||
.run
|
||||
.as_mut()
|
||||
.and_then(|run| run.environment.as_mut())
|
||||
.and_then(|environment| environment.image.as_mut())
|
||||
{
|
||||
for image in layer.environment_images_mut() {
|
||||
resolve_dockerfile(image, config_path, files)?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -96,10 +96,10 @@ use fabro_types::settings::server::{
|
|||
GithubIntegrationSettings, GithubIntegrationStrategy, LogDestination,
|
||||
};
|
||||
use fabro_types::{
|
||||
AgentBackend, AskFabro, AskFabroUnavailableReason, EventBody, InterviewQuestionRecord, PairId,
|
||||
PairMessageId, PairTarget, PendingReason, Principal, PullRequestLink, QuestionType, RunBlobId,
|
||||
RunControlAction, RunEvent, RunId, RunRunnableSource, SandboxProviderKind, ServerSettings,
|
||||
SessionCapability,
|
||||
AgentBackend, AskFabro, AskFabroUnavailableReason, BlobHash, EventBody,
|
||||
InterviewQuestionRecord, PairId, PairMessageId, PairTarget, PendingReason, Principal,
|
||||
PullRequestLink, QuestionType, RunControlAction, RunEvent, RunId, RunRunnableSource,
|
||||
SandboxProviderKind, ServerSettings, SessionCapability,
|
||||
};
|
||||
use fabro_util::error::{
|
||||
SharedError, collect_causes, render_compact_with_causes, render_with_causes,
|
||||
|
|
@ -2889,11 +2889,11 @@ pub(crate) fn parse_stage_id_path(stage_id: &str) -> Result<StageId, Response> {
|
|||
|
||||
#[allow(
|
||||
clippy::result_large_err,
|
||||
reason = "Blob ID parsing returns HTTP 400 responses directly."
|
||||
reason = "Blob hash parsing returns HTTP 400 responses directly."
|
||||
)]
|
||||
pub(crate) fn parse_blob_id_path(blob_id: &str) -> Result<RunBlobId, Response> {
|
||||
RunBlobId::from_str(blob_id)
|
||||
.map_err(|_| ApiError::bad_request("Invalid blob ID.").into_response())
|
||||
pub(crate) fn parse_blob_hash_path(blob_hash: &str) -> Result<BlobHash, Response> {
|
||||
BlobHash::from_str(blob_hash)
|
||||
.map_err(|_| ApiError::bad_request("Invalid blob hash.").into_response())
|
||||
}
|
||||
|
||||
#[allow(
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
Router::new()
|
||||
.route("/runs/{id}/checkpoint", get(get_checkpoint))
|
||||
.route("/runs/{id}/blobs", post(write_run_blob))
|
||||
.route("/runs/{id}/blobs/{blobId}", get(read_run_blob))
|
||||
.route("/runs/{id}/blobs/{blobHash}", get(read_run_blob))
|
||||
.route("/runs/{id}/artifacts", get(list_run_artifacts))
|
||||
.route("/runs/{id}/artifacts/download", get(download_run_artifacts))
|
||||
.route(
|
||||
|
|
@ -105,10 +105,7 @@ async fn write_run_blob(
|
|||
}
|
||||
match state.stores.runs.open_run(&id).await {
|
||||
Ok(run_store) => match run_store.write_blob(&body).await {
|
||||
Ok(blob_id) => Json(WriteBlobResponse {
|
||||
id: blob_id.to_string(),
|
||||
})
|
||||
.into_response(),
|
||||
Ok(blob_hash) => Json(WriteBlobResponse { hash: blob_hash }).into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
}
|
||||
|
|
@ -118,11 +115,11 @@ async fn write_run_blob(
|
|||
}
|
||||
|
||||
async fn read_run_blob(
|
||||
RequireRunBlob(id, blob_id): RequireRunBlob,
|
||||
RequireRunBlob(id, blob_hash): RequireRunBlob,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Response {
|
||||
match state.stores.runs.open_run_reader(&id).await {
|
||||
Ok(run_store) => match run_store.read_blob(&blob_id).await {
|
||||
Ok(run_store) => match run_store.read_blob(&blob_hash).await {
|
||||
Ok(Some(bytes)) => octet_stream_response(bytes),
|
||||
Ok(None) => ApiError::not_found("Blob not found.").into_response(),
|
||||
Err(err) => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ mod steer;
|
|||
pub(in crate::server) mod system;
|
||||
mod variables;
|
||||
mod worker_control;
|
||||
mod workflow_versions;
|
||||
|
||||
pub(super) use system::{health, openapi_spec};
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
|
|||
)
|
||||
.route("/runs/{id}/attach", get(demo::run_events_stub))
|
||||
.route("/runs/{id}/blobs", post(not_implemented))
|
||||
.route("/runs/{id}/blobs/{blobId}", get(not_implemented))
|
||||
.route("/runs/{id}/blobs/{blobHash}", get(not_implemented))
|
||||
.route(
|
||||
"/runs/{id}/stages/{stageId}/logs/output",
|
||||
get(not_implemented),
|
||||
|
|
@ -226,6 +227,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
|
|||
.merge(secrets::routes())
|
||||
.merge(variables::routes())
|
||||
.merge(worker_control::routes())
|
||||
.merge(workflow_versions::routes())
|
||||
.merge(sessions::routes())
|
||||
.merge(system::routes())
|
||||
.merge(completions::routes())
|
||||
|
|
|
|||
392
lib/apps/fabro-server/src/server/handler/workflow_versions.rs
Normal file
392
lib/apps/fabro-server/src/server/handler/workflow_versions.rs
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use fabro_api::types::{CreateWorkflowVersionResponse, WorkflowVersion};
|
||||
use fabro_types::MAX_WORKFLOW_VERSION_BYTES;
|
||||
use fabro_util::error;
|
||||
use fabro_workflow_version::{
|
||||
ValidatedWorkflowVersion, WorkflowVersionStore, WorkflowVersionStoreError,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, IntoResponse, Json, RequiredUser, Response, Router, State, StatusCode, post,
|
||||
};
|
||||
|
||||
const INVALID_JSON_CODE: &str = "invalid_json";
|
||||
const INVALID_VERSION_CODE: &str = "workflow_version_invalid";
|
||||
const DEPENDENCY_NOT_FOUND_CODE: &str = "workflow_version_dependency_not_found";
|
||||
const VERSION_TOO_LARGE_CODE: &str = "workflow_version_too_large";
|
||||
|
||||
pub(super) fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route(
|
||||
"/workflow-versions",
|
||||
post(create_workflow_version).layer(DefaultBodyLimit::max(MAX_WORKFLOW_VERSION_BYTES)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn create_workflow_version(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
payload: Result<Json<WorkflowVersion>, JsonRejection>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let Json(version) = payload.map_err(json_rejection)?;
|
||||
let version = ValidatedWorkflowVersion::new(version).map_err(|err| {
|
||||
ApiError::with_code(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
err.to_string(),
|
||||
INVALID_VERSION_CODE,
|
||||
)
|
||||
})?;
|
||||
let blobs = state.store_ref().blobs().await.map_err(|err| {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
error_chain = ?error::collect_chain(&err),
|
||||
"Failed to open workflow version storage"
|
||||
);
|
||||
internal_store_error()
|
||||
})?;
|
||||
let store = WorkflowVersionStore::new(blobs);
|
||||
let workflow_version_id = store.put(&version).await.map_err(store_error)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreateWorkflowVersionResponse {
|
||||
workflow_version_id,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn json_rejection(rejection: JsonRejection) -> ApiError {
|
||||
if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
|
||||
return ApiError::with_code(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"workflow version request exceeds 2 MiB",
|
||||
VERSION_TOO_LARGE_CODE,
|
||||
);
|
||||
}
|
||||
|
||||
match rejection {
|
||||
JsonRejection::JsonDataError(err) => ApiError::with_code(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
err.body_text(),
|
||||
INVALID_VERSION_CODE,
|
||||
),
|
||||
other => ApiError::with_code(
|
||||
StatusCode::BAD_REQUEST,
|
||||
other.body_text(),
|
||||
INVALID_JSON_CODE,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_error(err: WorkflowVersionStoreError) -> ApiError {
|
||||
match err {
|
||||
// The top-level message names the offending dependency without its
|
||||
// internal source chain, so it is safe to surface to the caller.
|
||||
err @ (WorkflowVersionStoreError::DependencyNotFound { .. }
|
||||
| WorkflowVersionStoreError::DependencyInvalid { .. }) => ApiError::with_code(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
err.to_string(),
|
||||
DEPENDENCY_NOT_FOUND_CODE,
|
||||
),
|
||||
err => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
error_chain = ?error::collect_chain(&err),
|
||||
"Workflow version store operation failed"
|
||||
);
|
||||
internal_store_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn internal_store_error() -> ApiError {
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"workflow version store operation failed",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Method, Request, StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
use fabro_types::{BlobHash, WorkflowVersion, WorkflowVersionId};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{
|
||||
DEPENDENCY_NOT_FOUND_CODE, INVALID_JSON_CODE, INVALID_VERSION_CODE,
|
||||
MAX_WORKFLOW_VERSION_BYTES, VERSION_TOO_LARGE_CODE, store_error,
|
||||
};
|
||||
use crate::server;
|
||||
use crate::test_support::{self, TestAppStateBuilder};
|
||||
|
||||
const GRAPH: &str = "digraph W { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
|
||||
|
||||
fn request(body: impl Into<Body>) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/v1/workflow-versions")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body.into())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn version(graph: &str) -> Value {
|
||||
json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": { "workflow.fabro": graph },
|
||||
"workflow_dependencies": {}
|
||||
})
|
||||
}
|
||||
|
||||
async fn response_json(response: axum::response::Response) -> Value {
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
fn error_code(body: &Value) -> &str {
|
||||
body["errors"][0]["code"].as_str().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_requires_authenticated_user() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = server::build_router(state, test_support::test_auth_mode());
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&version(GRAPH)).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_and_equivalent_requests_return_the_same_id() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = test_support::build_test_router(Arc::clone(&state));
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(request(serde_json::to_vec(&version(GRAPH)).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
let first = response_json(first).await;
|
||||
assert_eq!(first.as_object().unwrap().len(), 1);
|
||||
|
||||
let reordered = format!(
|
||||
r#"{{"workflow_dependencies":{{}},"files":{{"workflow.fabro":{}}},"entrypoint":"workflow.fabro"}}"#,
|
||||
serde_json::to_string(GRAPH).unwrap()
|
||||
);
|
||||
let second = app.oneshot(request(reordered)).await.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::CREATED);
|
||||
assert_eq!(response_json(second).await, first);
|
||||
|
||||
let id = first["workflow_version_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<WorkflowVersionId>()
|
||||
.unwrap();
|
||||
assert!(
|
||||
state
|
||||
.store_ref()
|
||||
.blobs()
|
||||
.await
|
||||
.unwrap()
|
||||
.read(&id.into())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_json_and_domain_content_have_distinct_codes() {
|
||||
let app = test_support::build_test_router(TestAppStateBuilder::new().build());
|
||||
let malformed = app.clone().oneshot(request("{")).await.unwrap();
|
||||
assert_eq!(malformed.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
error_code(&response_json(malformed).await),
|
||||
INVALID_JSON_CODE
|
||||
);
|
||||
|
||||
let unknown = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": { "workflow.fabro": GRAPH },
|
||||
"workflow_dependencies": {},
|
||||
"metadata": {}
|
||||
});
|
||||
let invalid = app
|
||||
.oneshot(request(serde_json::to_vec(&unknown).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invalid.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
error_code(&response_json(invalid).await),
|
||||
INVALID_VERSION_CODE
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_workflow_config_with_missing_goal_file_before_storage() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = test_support::build_test_router(Arc::clone(&state));
|
||||
let payload = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": GRAPH,
|
||||
"workflow.toml": "_version = 1\n[run.goal]\nfile = \"prompts/goal.md\"\n"
|
||||
},
|
||||
"workflow_dependencies": {}
|
||||
});
|
||||
let version = serde_json::from_value::<WorkflowVersion>(payload.clone()).unwrap();
|
||||
let id = WorkflowVersionId::from(BlobHash::new(&version.canonical_bytes().unwrap()));
|
||||
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&payload).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = fabro_test::expect_axum_json(
|
||||
response,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"POST /api/v1/workflow-versions with missing run goal file",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(error_code(&body), INVALID_VERSION_CODE);
|
||||
assert!(
|
||||
!state
|
||||
.store_ref()
|
||||
.blobs()
|
||||
.await
|
||||
.unwrap()
|
||||
.exists(&id.into())
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_dependency_has_specific_code() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = test_support::build_test_router(Arc::clone(&state));
|
||||
let missing_id = WorkflowVersionId::from(fabro_types::BlobHash::new(b"missing"));
|
||||
let root = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": "digraph W { child [stack.child_workflow=\"child.fabro\"] }"
|
||||
},
|
||||
"workflow_dependencies": { "child.fabro": missing_id }
|
||||
});
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&root).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
error_code(&response_json(response).await),
|
||||
DEPENDENCY_NOT_FOUND_CODE
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_stored_dependency_is_a_client_error_without_internals() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = test_support::build_test_router(Arc::clone(&state));
|
||||
let dependency_id = WorkflowVersionId::from(
|
||||
state
|
||||
.store_ref()
|
||||
.blobs()
|
||||
.await
|
||||
.unwrap()
|
||||
.write(b"not a workflow version")
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let root = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": "digraph W { child [stack.child_workflow=\"child.fabro\"] }"
|
||||
},
|
||||
"workflow_dependencies": { "child.fabro": dependency_id }
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&root).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(error_code(&body), DEPENDENCY_NOT_FOUND_CODE);
|
||||
assert!(
|
||||
body["errors"][0]["detail"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("child.fabro")
|
||||
);
|
||||
assert!(!body.to_string().contains("cannot be decoded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stored_child_can_be_pinned_as_a_dependency() {
|
||||
let app = test_support::build_test_router(TestAppStateBuilder::new().build());
|
||||
let child = app
|
||||
.clone()
|
||||
.oneshot(request(serde_json::to_vec(&version(GRAPH)).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(child.status(), StatusCode::CREATED);
|
||||
let child_id = response_json(child).await["workflow_version_id"].clone();
|
||||
let root = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": "digraph W { child [stack.child_workflow=\"child.fabro\"] }"
|
||||
},
|
||||
"workflow_dependencies": { "child.fabro": child_id }
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&root).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
assert_eq!(response_json(response).await.as_object().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn body_limit_has_specific_code() {
|
||||
let app = test_support::build_test_router(TestAppStateBuilder::new().build());
|
||||
let response = app
|
||||
.oneshot(request(vec![b' '; MAX_WORKFLOW_VERSION_BYTES + 1]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert_eq!(
|
||||
error_code(&response_json(response).await),
|
||||
VERSION_TOO_LARGE_CODE
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn storage_fault_response_is_curated() {
|
||||
let response = store_error(fabro_workflow_version::WorkflowVersionStoreError::Storage {
|
||||
source: fabro_store::Error::Other("private persistence detail".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(
|
||||
body["errors"][0]["detail"],
|
||||
"workflow version store operation failed"
|
||||
);
|
||||
assert!(!body.to_string().contains("private persistence detail"));
|
||||
}
|
||||
}
|
||||
|
|
@ -26,12 +26,12 @@ use fabro_model::{Catalog, ModelRef, ProviderId, ReasoningEffort, Speed};
|
|||
use fabro_types::settings::ServerAuthMethod;
|
||||
use fabro_types::settings::run::EnvironmentProvider;
|
||||
use fabro_types::{
|
||||
AgentBackend, AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph,
|
||||
InterviewQuestionRecord, Node, Outcome, ParallelBranchId, QuestionType, RunBlobId, RunId,
|
||||
RunSpec, SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory,
|
||||
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
|
||||
StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind,
|
||||
WorkflowSettings, fixtures, test_support,
|
||||
AgentBackend, AttrValue, AuthMethod, BlobHash, CommandTermination, FailureCategory,
|
||||
FailureDetail, Graph, InterviewQuestionRecord, Node, Outcome, ParallelBranchId, QuestionType,
|
||||
RunId, RunSpec, SandboxProviderKind, StageContextWindowBreakdownItem,
|
||||
StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
|
||||
StageContextWindowStaleness, StageContextWindowWarning, StageModelUsage, StageTiming,
|
||||
SuccessReason, SystemActorKind, WorkflowSettings, fixtures, test_support,
|
||||
};
|
||||
use fabro_util::check_report::CheckStatus;
|
||||
use fabro_workflow::records::CheckpointExt;
|
||||
|
|
@ -3890,7 +3890,7 @@ layer = "project"
|
|||
let manifest_blob = created["properties"]["manifest_blob"]
|
||||
.as_str()
|
||||
.expect("run.created should carry the submitted source blob")
|
||||
.parse::<RunBlobId>()
|
||||
.parse::<BlobHash>()
|
||||
.unwrap();
|
||||
let persisted_manifest = run_store
|
||||
.read_blob(&manifest_blob)
|
||||
|
|
@ -10786,12 +10786,12 @@ async fn create_run_persists_manifest_and_definition_blobs_without_bundle_file()
|
|||
let manifest_blob = created["properties"]["manifest_blob"]
|
||||
.as_str()
|
||||
.expect("run.created should carry manifest_blob")
|
||||
.parse::<RunBlobId>()
|
||||
.parse::<BlobHash>()
|
||||
.unwrap();
|
||||
let definition_blob = submitted["properties"]["definition_blob"]
|
||||
.as_str()
|
||||
.expect("run.submitted should carry definition_blob")
|
||||
.parse::<RunBlobId>()
|
||||
.parse::<BlobHash>()
|
||||
.unwrap();
|
||||
|
||||
let submitted_manifest_bytes = run_store
|
||||
|
|
@ -11034,7 +11034,7 @@ async fn get_checkpoint_returns_null_initially() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_and_read_run_blob_round_trip() {
|
||||
async fn write_and_read_run_blob_accepts_uppercase_hash() {
|
||||
let state = test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
|
||||
|
|
@ -11057,11 +11057,14 @@ async fn write_and_read_run_blob_round_trip() {
|
|||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
let blob_id = body["id"].as_str().unwrap();
|
||||
let blob_hash = body["hash"].as_str().unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/runs/{run_id}/blobs/{blob_id}")))
|
||||
.uri(api(&format!(
|
||||
"/runs/{run_id}/blobs/{}",
|
||||
blob_hash.to_uppercase()
|
||||
)))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
|
|
@ -11459,7 +11462,7 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
|
|||
let worker_token = issue_test_worker_token(&run_id);
|
||||
let other_run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let other_worker_token = issue_test_worker_token(&other_run_id);
|
||||
let blob_id = state
|
||||
let blob_hash = state
|
||||
.stores
|
||||
.runs
|
||||
.open_run(&run_id)
|
||||
|
|
@ -11553,7 +11556,7 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
|
|||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/blobs/{blob_id}"),
|
||||
&format!("/runs/{run_id}/blobs/{blob_hash}"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
|
|
@ -12058,7 +12061,7 @@ async fn worker_token_is_rejected_on_user_only_routes() {
|
|||
let user_jwt = issue_test_user_jwt();
|
||||
let run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let worker_token = issue_test_worker_token(&run_id);
|
||||
let blob_id = RunBlobId::new(b"blob");
|
||||
let blob_hash = BlobHash::new(b"blob");
|
||||
let user_only_routes = vec![
|
||||
(Method::GET, "/runs".to_string()),
|
||||
(Method::POST, "/runs".to_string()),
|
||||
|
|
@ -12121,7 +12124,7 @@ async fn worker_token_is_rejected_on_user_only_routes() {
|
|||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/blobs/{blob_id}"),
|
||||
&format!("/runs/{run_id}/blobs/{blob_hash}"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "in-memory Vec<u8>::write_all for jsonl serialization; no filesystem or network I/O"
|
||||
|
|
@ -16,10 +17,10 @@ use bytes::Bytes;
|
|||
use fabro_store::{
|
||||
EventEnvelope, RunProjection, SerializableProjection, StageId, retry_storage_segment,
|
||||
};
|
||||
use fabro_types::{RunBlobId, parse_blob_ref};
|
||||
use fabro_types::{BlobHash, parse_blob_ref};
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
pub type BlobReader = Box<dyn FnMut(RunBlobId) -> BoxFuture<'static, Result<Option<Bytes>>> + Send>;
|
||||
pub type BlobReader = Box<dyn FnMut(BlobHash) -> BoxFuture<'static, Result<Option<Bytes>>> + Send>;
|
||||
|
||||
const STAGE_RANK_WIDTH: usize = 3;
|
||||
const MAX_STAGES_IN_DUMP: usize = {
|
||||
|
|
@ -208,37 +209,48 @@ impl RunDump {
|
|||
mut read_blob: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnMut(RunBlobId) -> BoxFuture<'a, Result<Option<Bytes>>>,
|
||||
F: FnMut(BlobHash) -> BoxFuture<'a, Result<Option<Bytes>>>,
|
||||
{
|
||||
let mut cache = HashMap::new();
|
||||
for entry in &mut self.entries {
|
||||
match &mut entry.contents {
|
||||
RunDumpContents::Json(value) => {
|
||||
let mut blob_ids = Vec::new();
|
||||
collect_blob_refs_in_value(value, &mut blob_ids);
|
||||
for blob_id in blob_ids {
|
||||
if cache.contains_key(&blob_id) {
|
||||
let mut blob_hashes = Vec::new();
|
||||
collect_blob_refs_in_value(value, &mut blob_hashes);
|
||||
for blob_hash in blob_hashes {
|
||||
if cache.contains_key(&blob_hash) {
|
||||
continue;
|
||||
}
|
||||
let blob = read_blob(blob_id).await?.with_context(|| {
|
||||
format!("blob {blob_id:?} is missing from the store")
|
||||
let blob = read_blob(blob_hash).await?.with_context(|| {
|
||||
format!("blob {blob_hash:?} is missing from the store")
|
||||
})?;
|
||||
let hydrated: serde_json::Value = serde_json::from_slice(&blob)
|
||||
.with_context(|| format!("blob {blob_id:?} is not valid JSON"))?;
|
||||
cache.insert(blob_id, hydrated);
|
||||
.with_context(|| format!("blob {blob_hash:?} is not valid JSON"))?;
|
||||
cache.insert(blob_hash, hydrated);
|
||||
}
|
||||
replace_blob_refs_in_value(value, &cache)?;
|
||||
}
|
||||
RunDumpContents::Text(text) => {
|
||||
let Some(blob_id) = parse_blob_ref(text) else {
|
||||
let Some(blob_hash) = parse_blob_ref(text) else {
|
||||
continue;
|
||||
};
|
||||
let blob = read_blob(blob_id)
|
||||
.await?
|
||||
.with_context(|| format!("blob {blob_id:?} is missing from the store"))?;
|
||||
*text = serde_json::from_slice::<String>(&blob).with_context(|| {
|
||||
format!("blob {blob_id:?} is not a JSON string text log")
|
||||
})?;
|
||||
let hydrated = match cache.entry(blob_hash) {
|
||||
Entry::Occupied(entry) => entry.into_mut(),
|
||||
Entry::Vacant(entry) => {
|
||||
let blob = read_blob(blob_hash).await?.with_context(|| {
|
||||
format!("blob {blob_hash:?} is missing from the store")
|
||||
})?;
|
||||
let hydrated: serde_json::Value = serde_json::from_slice(&blob)
|
||||
.with_context(|| format!("blob {blob_hash:?} is not valid JSON"))?;
|
||||
entry.insert(hydrated)
|
||||
}
|
||||
};
|
||||
*text = hydrated
|
||||
.as_str()
|
||||
.with_context(|| {
|
||||
format!("blob {blob_hash:?} is not a JSON string text log")
|
||||
})?
|
||||
.to_string();
|
||||
}
|
||||
RunDumpContents::Bytes(_) => {}
|
||||
}
|
||||
|
|
@ -386,21 +398,21 @@ fn validate_relative_path(kind: &str, value: &str) -> Result<PathBuf> {
|
|||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn collect_blob_refs_in_value(value: &serde_json::Value, blob_ids: &mut Vec<RunBlobId>) {
|
||||
fn collect_blob_refs_in_value(value: &serde_json::Value, blob_hashes: &mut Vec<BlobHash>) {
|
||||
match value {
|
||||
serde_json::Value::String(current) => {
|
||||
if let Some(blob_id) = parse_blob_ref(current) {
|
||||
blob_ids.push(blob_id);
|
||||
if let Some(blob_hash) = parse_blob_ref(current) {
|
||||
blob_hashes.push(blob_hash);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_blob_refs_in_value(item, blob_ids);
|
||||
collect_blob_refs_in_value(item, blob_hashes);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for item in map.values() {
|
||||
collect_blob_refs_in_value(item, blob_ids);
|
||||
collect_blob_refs_in_value(item, blob_hashes);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
|
||||
|
|
@ -409,17 +421,16 @@ fn collect_blob_refs_in_value(value: &serde_json::Value, blob_ids: &mut Vec<RunB
|
|||
|
||||
fn replace_blob_refs_in_value(
|
||||
value: &mut serde_json::Value,
|
||||
cache: &HashMap<RunBlobId, serde_json::Value>,
|
||||
cache: &HashMap<BlobHash, serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
match value {
|
||||
serde_json::Value::String(current) => {
|
||||
let Some(blob_id) = parse_blob_ref(current) else {
|
||||
let Some(blob_hash) = parse_blob_ref(current) else {
|
||||
return Ok(());
|
||||
};
|
||||
let hydrated = cache
|
||||
.get(&blob_id)
|
||||
.cloned()
|
||||
.with_context(|| format!("blob {blob_id:?} is missing from the hydration cache"))?;
|
||||
let hydrated = cache.get(&blob_hash).cloned().with_context(|| {
|
||||
format!("blob {blob_hash:?} is missing from the hydration cache")
|
||||
})?;
|
||||
*value = hydrated;
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
|
|
@ -475,8 +486,7 @@ mod tests {
|
|||
use fabro_types::{
|
||||
Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunSandboxInstance,
|
||||
RunSandboxPlan, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,
|
||||
StageOutcome, StartRecord, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
|
||||
test_support,
|
||||
StageOutcome, StartRecord, SuccessReason, first_event_seq, fixtures, test_support,
|
||||
};
|
||||
use futures::executor;
|
||||
|
||||
|
|
@ -484,24 +494,18 @@ mod tests {
|
|||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("ship"),
|
||||
graph_source: Some("digraph Ship {}".to_string()),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
automation: None,
|
||||
graph: Graph::new("ship"),
|
||||
graph_source: Some("digraph Ship {}".to_string()),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
git: Some(fabro_types::GitContext {
|
||||
git: Some(fabro_types::GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
sha: None,
|
||||
dirty: fabro_types::DirtyStatus::Clean,
|
||||
}),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -724,8 +728,8 @@ mod tests {
|
|||
#[test]
|
||||
fn hydrate_referenced_blobs_ignores_legacy_artifact_file_refs() {
|
||||
let blob = serde_json::to_vec("hydrated legacy text").unwrap();
|
||||
let blob_id = fabro_types::RunBlobId::new(&blob);
|
||||
let legacy_ref = format!("file:///sandbox/.fabro/artifacts/{blob_id}.json");
|
||||
let blob_hash = fabro_types::BlobHash::new(&blob);
|
||||
let legacy_ref = format!("file:///sandbox/.fabro/artifacts/{blob_hash}.json");
|
||||
let mut dump = RunDump {
|
||||
entries: vec![RunDumpEntry::json(
|
||||
"run.json",
|
||||
|
|
@ -736,10 +740,10 @@ mod tests {
|
|||
};
|
||||
|
||||
executor::block_on(async {
|
||||
dump.hydrate_referenced_blobs_with_reader(|read_blob_id| {
|
||||
dump.hydrate_referenced_blobs_with_reader(|read_blob_hash| {
|
||||
let blob = blob.clone();
|
||||
Box::pin(async move {
|
||||
assert_eq!(read_blob_id, blob_id);
|
||||
assert_eq!(read_blob_hash, blob_hash);
|
||||
Ok(Some(bytes::Bytes::from(blob)))
|
||||
})
|
||||
})
|
||||
|
|
@ -752,4 +756,43 @@ mod tests {
|
|||
};
|
||||
assert_eq!(value["stdout"], legacy_ref);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydrate_referenced_blobs_fetches_shared_blobs_once() {
|
||||
let blob = serde_json::to_vec("offloaded response text").unwrap();
|
||||
let blob_hash = fabro_types::BlobHash::new(&blob);
|
||||
let blob_ref = fabro_types::format_blob_ref(&blob_hash);
|
||||
let mut dump = RunDump {
|
||||
entries: vec![
|
||||
RunDumpEntry::json("run.json", serde_json::json!({ "response": blob_ref })),
|
||||
RunDumpEntry::text("stages/001-demo@1/response.md", blob_ref.clone()),
|
||||
],
|
||||
stage_ranks: HashMap::new(),
|
||||
dump_log_index: None,
|
||||
};
|
||||
|
||||
let reads = std::cell::Cell::new(0);
|
||||
executor::block_on(async {
|
||||
dump.hydrate_referenced_blobs_with_reader(|read_blob_hash| {
|
||||
reads.set(reads.get() + 1);
|
||||
let blob = blob.clone();
|
||||
Box::pin(async move {
|
||||
assert_eq!(read_blob_hash, blob_hash);
|
||||
Ok(Some(bytes::Bytes::from(blob)))
|
||||
})
|
||||
})
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reads.get(), 1, "shared blob should be fetched once");
|
||||
let RunDumpContents::Json(value) = &dump.entries[0].contents else {
|
||||
panic!("entry should be JSON");
|
||||
};
|
||||
assert_eq!(value["response"], "offloaded response text");
|
||||
let RunDumpContents::Text(text) = &dump.entries[1].contents else {
|
||||
panic!("entry should be text");
|
||||
};
|
||||
assert_eq!(text, "offloaded response text");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -355,7 +355,14 @@ pub fn error_from_status_code(
|
|||
// error types
|
||||
let kind = match status_code {
|
||||
401 => ProviderErrorKind::Authentication,
|
||||
403 => ProviderErrorKind::AccessDenied,
|
||||
// A 412 is never about the request: no LLM request carries
|
||||
// conditional-request preconditions. Fireworks documents it as
|
||||
// "Account is suspended or there's an issue with account status",
|
||||
// also emitted for a LoRA model that failed to load
|
||||
// (https://docs.fireworks.ai/guides/inference-error-codes). The same
|
||||
// family as `account_deactivated`: deterministic here, but another
|
||||
// provider has independent billing and model inventory.
|
||||
403 | 412 => ProviderErrorKind::AccessDenied,
|
||||
404 => ProviderErrorKind::NotFound,
|
||||
408 => {
|
||||
return Error::RequestTimeout {
|
||||
|
|
@ -728,6 +735,53 @@ mod tests {
|
|||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded));
|
||||
}
|
||||
|
||||
/// Fireworks reports an account suspension (spending cap reached or
|
||||
/// unpaid invoices) as HTTP 412 with `code: "PRECONDITION_FAILED"` in
|
||||
/// the body. A chat completion carries no conditional-request
|
||||
/// preconditions, so a 412 is always an account-level lockout, never a
|
||||
/// defect in the request: it must not classify as `InvalidRequest`, and
|
||||
/// a fallback provider with independent billing must stay eligible.
|
||||
#[test]
|
||||
fn account_suspension_412_is_failover_eligible() {
|
||||
let err = error_from_status_code(
|
||||
412,
|
||||
"Account lithoscomputer is suspended, possibly due to reaching \
|
||||
the monthly spending limit or failure to pay past invoices."
|
||||
.into(),
|
||||
"fireworks".into(),
|
||||
// The openai_compatible dialect reads `error.type` as the code,
|
||||
// so the discriminating `PRECONDITION_FAILED` only reaches this
|
||||
// mapping through the status code.
|
||||
Some("error".into()),
|
||||
Some(serde_json::json!({
|
||||
"error": {
|
||||
"message": "Account lithoscomputer is suspended, possibly due to reaching the monthly spending limit or failure to pay past invoices. Please go to https://fireworks.ai/account/billing for more information.",
|
||||
"param": null,
|
||||
"code": "PRECONDITION_FAILED",
|
||||
"type": "error"
|
||||
},
|
||||
"request_id": "chatcmpl-d9652b89a6604931ac27dddd5ef5bdc0"
|
||||
})),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied));
|
||||
assert!(!err.retryable());
|
||||
assert!(err.failover_eligible());
|
||||
|
||||
// A bare 412 with no parseable body classifies the same way.
|
||||
let err = error_from_status_code(
|
||||
412,
|
||||
"Precondition Failed".into(),
|
||||
"fireworks".into(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied));
|
||||
assert!(err.failover_eligible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_from_error_code_covers_every_dialect() {
|
||||
for (code, expected) in [
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ git2.workspace = true
|
|||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
fabro-test.workspace = true
|
||||
insta.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
temp-env = "0.3"
|
||||
|
|
|
|||
|
|
@ -3,34 +3,32 @@
|
|||
reason = "CLI manifest builder: sync file I/O building install manifests"
|
||||
)]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
mod workflow_bundler;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use fabro_api::types;
|
||||
use fabro_config::project::{self, WorkflowLocation, discover_project_config};
|
||||
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
|
||||
use fabro_config::{
|
||||
CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
|
||||
EnvironmentLifecycleLayer, MergeMap, ReplaceMap, RunEnvironmentLayer, RunExecutionLayer,
|
||||
RunGoalLayer, RunLayer, RunModelLayer, SettingsLayer, WorkflowSettingsBuilder,
|
||||
CliLayer, EnvironmentLayer, EnvironmentLifecycleLayer, MergeMap, ReplaceMap,
|
||||
RunEnvironmentLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer,
|
||||
WorkflowSettingsBuilder,
|
||||
};
|
||||
use fabro_graphviz::graph::AttrValue;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::{
|
||||
BundleTemplateStore, FilesystemTemplateStore, RecordingTemplateStore, TemplateContext,
|
||||
TemplateRenderMode, TemplateSource, discover_static_dependency_closure, render_source,
|
||||
};
|
||||
use fabro_template::validate_static_reference;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
|
||||
use fabro_types::{DirtyStatus, GitContext, ManifestPath, WorkflowSettings};
|
||||
use fabro_workflow::git::{
|
||||
GitSyncStatus, branch_needs_push, head_sha, push_branch_noninteractive, sync_status,
|
||||
};
|
||||
use fabro_workflow::static_reference::{
|
||||
AttributeScope, ReferenceKind, reference_kind_for_attribute,
|
||||
};
|
||||
|
||||
use crate::workflow_bundler::WorkflowBundler;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ManifestBuildInput {
|
||||
|
|
@ -127,20 +125,6 @@ pub fn build_sparse_run_overrides(input: RunOverrideInput<'_>) -> Option<RunLaye
|
|||
.then_some(run)
|
||||
}
|
||||
|
||||
struct CollectContext<'a> {
|
||||
cwd: &'a Path,
|
||||
inputs: HashMap<String, toml::Value>,
|
||||
workflows: HashMap<String, types::ManifestWorkflow>,
|
||||
visited_workflows: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WorkflowScanInput {
|
||||
absolute_dot_path: PathBuf,
|
||||
dot_path: ManifestPath,
|
||||
source: String,
|
||||
}
|
||||
|
||||
pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
||||
let root_location = WorkflowLocation::resolve(&input.workflow, &input.cwd)?;
|
||||
if root_location.toml.is_none() && !root_location.graph.is_file() {
|
||||
|
|
@ -188,24 +172,12 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
|||
let target_path = root_location.graph.clone();
|
||||
let target_manifest_path = manifest_path_from_absolute(&target_path, &input.cwd)?;
|
||||
let target_key = target_manifest_path.to_string();
|
||||
|
||||
let mut context = CollectContext {
|
||||
cwd: &input.cwd,
|
||||
inputs: workflow_settings.run.inputs.clone(),
|
||||
workflows: HashMap::new(),
|
||||
visited_workflows: HashSet::new(),
|
||||
};
|
||||
collect_workflow_entry(&mut context, &input.workflow, &input.cwd)?;
|
||||
if let Some((_, config_path, source)) = project_config_source.as_ref() {
|
||||
let workflow = context
|
||||
.workflows
|
||||
.get_mut(&target_key)
|
||||
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
|
||||
collect_config_dockerfile(context.cwd, config_path, source, &mut workflow.files)?;
|
||||
}
|
||||
|
||||
let root_source = context
|
||||
.workflows
|
||||
let project_config_input = project_config_source
|
||||
.as_ref()
|
||||
.map(|(_, path, source)| (path, source.as_str()));
|
||||
let workflows = WorkflowBundler::new(&input.cwd, &workflow_settings.run.inputs)
|
||||
.bundle(&input.workflow, project_config_input)?;
|
||||
let root_source = workflows
|
||||
.get(&target_key)
|
||||
.map(|workflow| workflow.source.clone())
|
||||
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
|
||||
|
|
@ -218,7 +190,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
|||
type_: types::ManifestConfigType::Project,
|
||||
});
|
||||
}
|
||||
if let Some(path) = input.user_settings_path.filter(|p| p.is_file()) {
|
||||
if let Some(path) = input.user_settings_path.filter(|path| path.is_file()) {
|
||||
let source = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
configs.push(types::ManifestConfig {
|
||||
|
|
@ -254,415 +226,12 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
|||
title: None,
|
||||
target: types::ManifestTarget { path: target_key },
|
||||
version: 1,
|
||||
workflows: context.workflows,
|
||||
workflows,
|
||||
},
|
||||
target_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_workflow_entry(
|
||||
context: &mut CollectContext<'_>,
|
||||
workflow: &Path,
|
||||
resolve_from: &Path,
|
||||
) -> Result<()> {
|
||||
let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() {
|
||||
normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"unsupported manifest workflow reference: {}",
|
||||
workflow.display()
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
workflow.to_path_buf()
|
||||
};
|
||||
let location = WorkflowLocation::resolve(&normalized_workflow, resolve_from)?;
|
||||
let dot_path = manifest_path_from_absolute(&location.graph, context.cwd)?;
|
||||
let dot_key = dot_path.to_string();
|
||||
if !context.visited_workflows.insert(dot_key.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let source = std::fs::read_to_string(&location.graph)
|
||||
.with_context(|| format!("Failed to read {}", location.graph.display()))?;
|
||||
let config = if let Some(workflow_toml_path) = location.toml.as_ref() {
|
||||
Some(types::ManifestWorkflowConfig {
|
||||
path: manifest_path_from_absolute(workflow_toml_path, context.cwd)?.to_string(),
|
||||
source: std::fs::read_to_string(workflow_toml_path)
|
||||
.with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let scan = WorkflowScanInput {
|
||||
absolute_dot_path: location.graph,
|
||||
dot_path,
|
||||
source: source.clone(),
|
||||
};
|
||||
let mut files = HashMap::new();
|
||||
let mut visited_imports = HashSet::new();
|
||||
if let Some(config) = config.as_ref() {
|
||||
let config_path = ManifestPath::from_wire(&config.path)
|
||||
.ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?;
|
||||
collect_config_dockerfile(context.cwd, &config_path, &config.source, &mut files)?;
|
||||
}
|
||||
collect_workflow_files(context, &scan, &mut files, &mut visited_imports)?;
|
||||
|
||||
context.workflows.insert(dot_key, types::ManifestWorkflow {
|
||||
config,
|
||||
files,
|
||||
source,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_workflow_files(
|
||||
context: &mut CollectContext<'_>,
|
||||
workflow: &WorkflowScanInput,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
visited_imports: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let graph = parser::parse(&workflow.source).map_err(|err| {
|
||||
anyhow!(
|
||||
"Failed to parse {}: {err}",
|
||||
workflow.absolute_dot_path.display()
|
||||
)
|
||||
})?;
|
||||
let workflow_base_dir = workflow
|
||||
.absolute_dot_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let workflow_template_root = manifest_parent_or_dot(&workflow.dot_path)?;
|
||||
|
||||
if let Some(goal_ref) = graph.attrs.get("goal").and_then(AttrValue::as_str) {
|
||||
if goal_ref.starts_with('@') {
|
||||
let bundled = collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
context.cwd,
|
||||
goal_ref.trim_start_matches('@'),
|
||||
types::ManifestFileRefType::FileInline,
|
||||
manifest_attr_reference_kind(AttributeScope::Graph, "goal", goal_ref)?,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
let source = std::fs::read_to_string(&bundled.absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", bundled.absolute_path.display()))?;
|
||||
let template_root =
|
||||
template_root_for_bundled_file(&bundled.path, &workflow_template_root)?;
|
||||
collect_template_include_files(
|
||||
files,
|
||||
context.cwd,
|
||||
TemplateSource::new(bundled.path.clone(), template_root, source),
|
||||
Some(&bundled.path),
|
||||
&context.inputs,
|
||||
)?;
|
||||
} else {
|
||||
collect_template_include_files(
|
||||
files,
|
||||
context.cwd,
|
||||
TemplateSource::new(
|
||||
workflow.dot_path.clone(),
|
||||
workflow_template_root.clone(),
|
||||
goal_ref.to_owned(),
|
||||
),
|
||||
Some(&workflow.dot_path),
|
||||
&context.inputs,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
for node in graph.nodes.values() {
|
||||
if let Some(prompt_ref) = node.attrs.get("prompt").and_then(AttrValue::as_str) {
|
||||
if !prompt_ref.starts_with('@') {
|
||||
collect_template_include_files(
|
||||
files,
|
||||
context.cwd,
|
||||
TemplateSource::new(
|
||||
workflow.dot_path.clone(),
|
||||
workflow_template_root.clone(),
|
||||
prompt_ref.to_owned(),
|
||||
),
|
||||
Some(&workflow.dot_path),
|
||||
&context.inputs,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
for (name, value) in &node.attrs {
|
||||
let Some(value) = value.as_str() else {
|
||||
continue;
|
||||
};
|
||||
let Some(ReferenceKind::FileInline) =
|
||||
reference_kind_for_attribute(AttributeScope::Node, name, value)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let reference = value.strip_prefix('@').ok_or_else(|| {
|
||||
anyhow!("file inline reference must start with '@': {name}={value}")
|
||||
})?;
|
||||
let bundled = collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
context.cwd,
|
||||
reference,
|
||||
types::ManifestFileRefType::FileInline,
|
||||
ReferenceKind::FileInline,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
|
||||
if name == "prompt" {
|
||||
let source =
|
||||
std::fs::read_to_string(&bundled.absolute_path).with_context(|| {
|
||||
format!("Failed to read {}", bundled.absolute_path.display())
|
||||
})?;
|
||||
let template_root =
|
||||
template_root_for_bundled_file(&bundled.path, &workflow_template_root)?;
|
||||
collect_template_include_files(
|
||||
files,
|
||||
context.cwd,
|
||||
TemplateSource::new(bundled.path.clone(), template_root, source),
|
||||
Some(&bundled.path),
|
||||
&context.inputs,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(import_ref) = node.attrs.get("import").and_then(AttrValue::as_str) {
|
||||
let imported = collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
context.cwd,
|
||||
import_ref,
|
||||
types::ManifestFileRefType::Import,
|
||||
manifest_attr_reference_kind(AttributeScope::Node, "import", import_ref)?,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
let import_key = imported.path.to_string();
|
||||
if visited_imports.insert(import_key) {
|
||||
let imported_source = std::fs::read_to_string(&imported.absolute_path)
|
||||
.with_context(|| {
|
||||
format!("Failed to read {}", imported.absolute_path.display())
|
||||
})?;
|
||||
let imported_scan = WorkflowScanInput {
|
||||
absolute_dot_path: imported.absolute_path,
|
||||
dot_path: imported.path,
|
||||
source: imported_source,
|
||||
};
|
||||
collect_workflow_files(context, &imported_scan, files, visited_imports)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(child_ref) = node
|
||||
.attrs
|
||||
.get("stack.child_workflow")
|
||||
.and_then(AttrValue::as_str)
|
||||
{
|
||||
manifest_attr_reference_kind(AttributeScope::Node, "stack.child_workflow", child_ref)?
|
||||
.validate(child_ref)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
collect_workflow_entry(context, Path::new(child_ref), workflow_base_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_template_include_files(
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
cwd: &Path,
|
||||
source: TemplateSource,
|
||||
from: Option<&ManifestPath>,
|
||||
inputs: &HashMap<String, toml::Value>,
|
||||
) -> Result<()> {
|
||||
let source_path = source.path.clone();
|
||||
let store = FilesystemTemplateStore::new(cwd.to_path_buf());
|
||||
let closure = discover_static_dependency_closure([source], &store)
|
||||
.map_err(|err| anyhow!("failed to discover template dependencies: {err}"))?;
|
||||
verify_recorded_template_dependencies(&source_path, &closure, files, from, inputs)?;
|
||||
|
||||
for (path, source) in closure.sources {
|
||||
if path == source_path {
|
||||
continue;
|
||||
}
|
||||
let key = path.to_string();
|
||||
files
|
||||
.entry(key)
|
||||
.or_insert_with(|| types::ManifestFileEntry {
|
||||
content: source.content,
|
||||
ref_: types::ManifestFileRef {
|
||||
from: from.map(std::string::ToString::to_string),
|
||||
original: path.to_string(),
|
||||
type_: types::ManifestFileRefType::FileInline,
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn template_root_for_bundled_file(
|
||||
path: &ManifestPath,
|
||||
workflow_template_root: &ManifestPath,
|
||||
) -> Result<ManifestPath> {
|
||||
if manifest_path_is_within_root(path, workflow_template_root) {
|
||||
Ok(workflow_template_root.clone())
|
||||
} else {
|
||||
manifest_parent_or_dot(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_path_is_within_root(path: &ManifestPath, root: &ManifestPath) -> bool {
|
||||
if root.as_path().as_os_str().is_empty() {
|
||||
return !matches!(
|
||||
path.as_path().components().next(),
|
||||
Some(Component::ParentDir)
|
||||
);
|
||||
}
|
||||
path.starts_with(root)
|
||||
}
|
||||
|
||||
fn verify_recorded_template_dependencies(
|
||||
source_path: &ManifestPath,
|
||||
closure: &fabro_template::TemplateDependencyClosure,
|
||||
files: &HashMap<String, types::ManifestFileEntry>,
|
||||
from: Option<&ManifestPath>,
|
||||
inputs: &HashMap<String, toml::Value>,
|
||||
) -> Result<()> {
|
||||
let Some(source) = closure.sources.get(source_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut bundled_files = closure
|
||||
.sources
|
||||
.iter()
|
||||
.map(|(path, source)| (path.clone(), source.content.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
for (path, entry) in files {
|
||||
if let Some(path) = ManifestPath::from_wire(path) {
|
||||
bundled_files.insert(path, entry.content.clone());
|
||||
}
|
||||
}
|
||||
let allowed = bundled_files.keys().cloned().collect();
|
||||
let store =
|
||||
RecordingTemplateStore::with_allowed(BundleTemplateStore::new(bundled_files), allowed);
|
||||
let ctx = TemplateContext::for_input_scan(inputs.clone());
|
||||
render_source(source, &ctx, Arc::new(store), TemplateRenderMode::Lenient).with_context(
|
||||
|| {
|
||||
let from =
|
||||
from.map_or_else(|| source_path.to_string(), std::string::ToString::to_string);
|
||||
format!("failed to verify template dependencies for {from}")
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn manifest_attr_reference_kind(
|
||||
scope: AttributeScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<ReferenceKind> {
|
||||
reference_kind_for_attribute(scope, key, value)
|
||||
.ok_or_else(|| anyhow!("unsupported manifest reference attribute: {key}={value}"))
|
||||
}
|
||||
|
||||
fn collect_config_dockerfile(
|
||||
cwd: &Path,
|
||||
config_path: &ManifestPath,
|
||||
source: &str,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
) -> Result<()> {
|
||||
let layer = source
|
||||
.parse::<SettingsLayer>()
|
||||
.context("Failed to parse run config TOML")?;
|
||||
let absolute_config_path = cwd.join(config_path.as_path());
|
||||
let base_dir = absolute_config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
|
||||
for environment in layer.environments.values() {
|
||||
collect_environment_dockerfile(
|
||||
files,
|
||||
base_dir,
|
||||
cwd,
|
||||
config_path,
|
||||
environment.image.as_ref(),
|
||||
)?;
|
||||
}
|
||||
if let Some(run_environment) = layer.run.as_ref().and_then(|run| run.environment.as_ref()) {
|
||||
collect_environment_dockerfile(
|
||||
files,
|
||||
base_dir,
|
||||
cwd,
|
||||
config_path,
|
||||
run_environment.image.as_ref(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_environment_dockerfile(
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
base_dir: &Path,
|
||||
cwd: &Path,
|
||||
config_path: &ManifestPath,
|
||||
image: Option<&EnvironmentImageLayer>,
|
||||
) -> Result<()> {
|
||||
let dockerfile = image.and_then(|image| image.dockerfile.as_ref());
|
||||
let Some(EnvironmentDockerfileLayer::Path { path }) = dockerfile else {
|
||||
return Ok(());
|
||||
};
|
||||
collect_bundled_file(
|
||||
files,
|
||||
base_dir,
|
||||
cwd,
|
||||
path,
|
||||
types::ManifestFileRefType::Dockerfile,
|
||||
ReferenceKind::Dockerfile,
|
||||
Some(config_path.clone()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct BundledFile {
|
||||
absolute_path: PathBuf,
|
||||
path: ManifestPath,
|
||||
}
|
||||
|
||||
fn collect_bundled_file(
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
base_dir: &Path,
|
||||
cwd: &Path,
|
||||
reference: &str,
|
||||
ref_type: types::ManifestFileRefType,
|
||||
reference_kind: ReferenceKind,
|
||||
from: Option<ManifestPath>,
|
||||
) -> Result<BundledFile> {
|
||||
reference_kind
|
||||
.validate(reference)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
|
||||
let absolute_path = normalize_absolute_path(base_dir, reference)
|
||||
.ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?;
|
||||
let path = manifest_path_from_absolute(&absolute_path, cwd)?;
|
||||
let key = path.to_string();
|
||||
if !files.contains_key(&key) {
|
||||
let content = std::fs::read_to_string(&absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", absolute_path.display()))?;
|
||||
files.insert(key.clone(), types::ManifestFileEntry {
|
||||
content,
|
||||
ref_: types::ManifestFileRef {
|
||||
from: from.map(|value| value.to_string()),
|
||||
original: reference.to_string(),
|
||||
type_: ref_type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Ok(BundledFile {
|
||||
absolute_path,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_manifest_goal(
|
||||
run_overrides: Option<&RunLayer>,
|
||||
settings: &WorkflowSettings,
|
||||
|
|
@ -697,8 +266,7 @@ fn resolve_manifest_goal(
|
|||
return Ok(None);
|
||||
};
|
||||
if let Some(reference) = goal.strip_prefix('@') {
|
||||
ReferenceKind::GraphGoalFile
|
||||
.validate(reference)
|
||||
validate_static_reference(reference, ReferenceKind::GraphGoalFile)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let goal_path = normalize_absolute_path(
|
||||
root_dot_path.parent().unwrap_or_else(|| Path::new(".")),
|
||||
|
|
@ -854,12 +422,6 @@ fn manifest_path_from_absolute(path: &Path, cwd: &Path) -> Result<ManifestPath>
|
|||
.ok_or_else(|| anyhow!("Failed to compute manifest path for {}", path.display()))
|
||||
}
|
||||
|
||||
fn manifest_parent_or_dot(path: &ManifestPath) -> Result<ManifestPath> {
|
||||
let parent = path.parent_or_dot().to_string_lossy();
|
||||
ManifestPath::from_wire(&parent)
|
||||
.ok_or_else(|| anyhow!("invalid manifest parent path for {path}: {parent}"))
|
||||
}
|
||||
|
||||
pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
|
||||
args.auto_approve.is_none()
|
||||
&& args.dry_run.is_none()
|
||||
|
|
@ -872,6 +434,18 @@ pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
|
|||
&& args.verbose.is_none()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_fixtures {
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn write_file(path: &Path, source: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("fixture directory should be created");
|
||||
}
|
||||
std::fs::write(path, source).expect("fixture file should be written");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -932,6 +506,178 @@ mod tests {
|
|||
assert_eq!(schema.content, schema_source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_manifest_characterizes_the_complete_legacy_projection() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let root = project.join(".fabro/workflows/root");
|
||||
let child = project.join(".fabro/workflows/child");
|
||||
let user_config_path = temp.path().join("home/.fabro/config.toml");
|
||||
let project_config = r#"_version = 1
|
||||
|
||||
[environments.project]
|
||||
provider = "docker"
|
||||
|
||||
[environments.project.image]
|
||||
dockerfile = { path = "Project.Dockerfile" }
|
||||
"#;
|
||||
let root_config = r#"_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
"#;
|
||||
let child_config = root_config;
|
||||
let user_config = "_version = 1\n";
|
||||
let root_graph = r#"digraph Root {
|
||||
graph [goal="@goals/goal.md"]
|
||||
start [shape=Mdiamond]
|
||||
prompt [prompt="@prompts/plan.md"]
|
||||
schema [type="agent", prompt="schema", output_schema="@schemas/output.json"]
|
||||
imported [import="imports/shared.fabro"]
|
||||
child [shape=house, stack.child_workflow="../child/workflow.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> prompt -> schema -> imported -> child -> exit
|
||||
}"#;
|
||||
let child_graph =
|
||||
"digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
|
||||
let imported_graph = r#"digraph Shared {
|
||||
start [shape=Mdiamond]
|
||||
shared [prompt="@../prompts/shared.md"]
|
||||
exit [shape=Msquare]
|
||||
start -> shared -> exit
|
||||
}"#;
|
||||
let plan_prompt = "{% include \"partial.md\" %}\n{% from \"helpers.md\" import render %}";
|
||||
let helpers = "{% macro render() %}{% include \"deep.md\" %}{% endmacro %}";
|
||||
let output_schema = r#"{"type":"object"}"#;
|
||||
let write = test_fixtures::write_file;
|
||||
write(&project.join(".fabro/project.toml"), project_config);
|
||||
write(&project.join(".fabro/Project.Dockerfile"), "FROM project\n");
|
||||
write(&user_config_path, user_config);
|
||||
write(&root.join("workflow.toml"), root_config);
|
||||
write(&root.join("workflow.fabro"), root_graph);
|
||||
write(&root.join("goals/goal.md"), "ship it\n");
|
||||
write(&root.join("prompts/plan.md"), plan_prompt);
|
||||
write(&root.join("prompts/partial.md"), "partial\n");
|
||||
write(&root.join("prompts/helpers.md"), helpers);
|
||||
write(&root.join("prompts/deep.md"), "deep\n");
|
||||
write(&root.join("prompts/shared.md"), "shared\n");
|
||||
write(&root.join("schemas/output.json"), output_schema);
|
||||
write(&root.join("imports/shared.fabro"), imported_graph);
|
||||
write(&child.join("workflow.toml"), child_config);
|
||||
write(&child.join("workflow.fabro"), child_graph);
|
||||
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: PathBuf::from(".fabro/workflows/root/workflow.toml"),
|
||||
cwd: project.clone(),
|
||||
input_overrides: HashMap::from([("feature".to_owned(), toml::Value::Boolean(true))]),
|
||||
args: Some(types::ManifestArgs {
|
||||
dry_run: Some(true),
|
||||
input: vec!["feature=true".to_owned()],
|
||||
label: vec!["suite=characterization".to_owned()],
|
||||
..types::ManifestArgs::default()
|
||||
}),
|
||||
environment_defaults: test_environment_defaults(),
|
||||
user_settings_path: Some(user_config_path),
|
||||
..ManifestBuildInput::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut actual = serde_json::to_value(&built.manifest).unwrap();
|
||||
actual["cwd"] = serde_json::json!("<cwd>");
|
||||
actual["configs"][0]["path"] = serde_json::json!("<project-config>");
|
||||
actual["configs"][1]["path"] = serde_json::json!("<user-config>");
|
||||
fabro_test::fabro_json_snapshot!(sorted_json(actual));
|
||||
}
|
||||
|
||||
/// `serde_json` is built with `preserve_order`, so `HashMap`-backed
|
||||
/// manifest maps serialize in nondeterministic order; sort recursively
|
||||
/// for a stable snapshot.
|
||||
fn sorted_json(value: serde_json::Value) -> serde_json::Value {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut entries: Vec<_> = map.into_iter().collect();
|
||||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
serde_json::Value::Object(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, sorted_json(value)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
serde_json::Value::Array(items.into_iter().map(sorted_json).collect())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_manifest_keeps_legacy_parent_paths_for_external_siblings() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cwd = temp.path().join("checkout");
|
||||
let root = temp.path().join("user/workflows/root");
|
||||
let child = temp.path().join("user/workflows/child");
|
||||
std::fs::create_dir_all(&cwd).unwrap();
|
||||
for directory in [&root, &child] {
|
||||
std::fs::create_dir_all(directory.join("prompts")).unwrap();
|
||||
std::fs::write(
|
||||
directory.join("workflow.toml"),
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
root.join("workflow.fabro"),
|
||||
r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
prompt [prompt="@prompts/root.md"]
|
||||
child [shape=house, stack.child_workflow="../child/workflow.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> prompt -> child -> exit
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("prompts/root.md"), "root prompt\n").unwrap();
|
||||
std::fs::write(
|
||||
child.join("workflow.fabro"),
|
||||
r#"digraph Child {
|
||||
start [shape=Mdiamond]
|
||||
prompt [prompt="@prompts/child.md"]
|
||||
exit [shape=Msquare]
|
||||
start -> prompt -> exit
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(child.join("prompts/child.md"), "child prompt\n").unwrap();
|
||||
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: root.join("workflow.fabro"),
|
||||
cwd,
|
||||
environment_defaults: test_environment_defaults(),
|
||||
..ManifestBuildInput::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let root_key = "../user/workflows/root/workflow.fabro";
|
||||
let child_key = "../user/workflows/child/workflow.fabro";
|
||||
assert_eq!(built.manifest.target.path, root_key);
|
||||
let root_workflow = &built.manifest.workflows[root_key];
|
||||
assert_eq!(
|
||||
root_workflow.config.as_ref().unwrap().path,
|
||||
"../user/workflows/root/workflow.toml"
|
||||
);
|
||||
let root_prompt = &root_workflow.files["../user/workflows/root/prompts/root.md"];
|
||||
assert_eq!(root_prompt.ref_.from.as_deref(), Some(root_key));
|
||||
assert_eq!(root_prompt.ref_.original, "prompts/root.md");
|
||||
let child_workflow = &built.manifest.workflows[child_key];
|
||||
assert_eq!(
|
||||
child_workflow.config.as_ref().unwrap().path,
|
||||
"../user/workflows/child/workflow.toml"
|
||||
);
|
||||
let child_prompt = &child_workflow.files["../user/workflows/child/prompts/child.md"];
|
||||
assert_eq!(child_prompt.ref_.from.as_deref(), Some(child_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_overrides_sets_common_cli_and_mcp_layers() {
|
||||
let overrides = build_run_overrides(RunOverrideInput {
|
||||
|
|
@ -1181,17 +927,26 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
let root = &built.manifest.workflows[".fabro/workflows/demo/workflow.fabro"];
|
||||
assert!(
|
||||
root.files
|
||||
.contains_key(".fabro/workflows/demo/prompts/goal.tpl.md")
|
||||
assert_eq!(
|
||||
root.files[".fabro/workflows/demo/prompts/goal.tpl.md"]
|
||||
.ref_
|
||||
.from
|
||||
.as_deref(),
|
||||
Some(".fabro/workflows/demo/prompts/goal.md")
|
||||
);
|
||||
assert!(
|
||||
root.files
|
||||
.contains_key(".fabro/workflows/demo/prompts/plan.tpl.md")
|
||||
assert_eq!(
|
||||
root.files[".fabro/workflows/demo/prompts/plan.tpl.md"]
|
||||
.ref_
|
||||
.from
|
||||
.as_deref(),
|
||||
Some(".fabro/workflows/demo/prompts/plan.md")
|
||||
);
|
||||
assert!(
|
||||
root.files
|
||||
.contains_key(".fabro/workflows/demo/inline.tpl.md")
|
||||
assert_eq!(
|
||||
root.files[".fabro/workflows/demo/inline.tpl.md"]
|
||||
.ref_
|
||||
.from
|
||||
.as_deref(),
|
||||
Some(".fabro/workflows/demo/workflow.fabro")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1290,7 +1045,9 @@ mod tests {
|
|||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("dynamic template dependency"),
|
||||
err.chain().any(|cause| cause
|
||||
.downcast_ref::<fabro_template::TemplateDiscoveryError>()
|
||||
.is_some()),
|
||||
"unexpected error: {err:#}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
---
|
||||
source: lib/components/fabro-manifest/src/lib.rs
|
||||
expression: rendered
|
||||
---
|
||||
{
|
||||
"args": {
|
||||
"dry_run": true,
|
||||
"input": [
|
||||
"feature=true"
|
||||
],
|
||||
"label": [
|
||||
"suite=characterization"
|
||||
]
|
||||
},
|
||||
"configs": [
|
||||
{
|
||||
"path": "<project-config>",
|
||||
"source": "_version = 1/n/n[environments.project]/nprovider = \"docker\"/n/n[environments.project.image]/ndockerfile = { path = \"Project.Dockerfile\" }/n",
|
||||
"type": "project"
|
||||
},
|
||||
{
|
||||
"path": "<user-config>",
|
||||
"source": "_version = 1/n",
|
||||
"type": "user"
|
||||
}
|
||||
],
|
||||
"cwd": "<cwd>",
|
||||
"goal": {
|
||||
"text": "ship it/n",
|
||||
"type": "graph"
|
||||
},
|
||||
"target": {
|
||||
"path": ".fabro/workflows/root/workflow.fabro"
|
||||
},
|
||||
"version": 1,
|
||||
"workflows": {
|
||||
".fabro/workflows/child/workflow.fabro": {
|
||||
"config": {
|
||||
"path": ".fabro/workflows/child/workflow.toml",
|
||||
"source": "_version = 1/n/n[workflow]/ngraph = \"workflow.fabro\"/n"
|
||||
},
|
||||
"source": "digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }"
|
||||
},
|
||||
".fabro/workflows/root/workflow.fabro": {
|
||||
"config": {
|
||||
"path": ".fabro/workflows/root/workflow.toml",
|
||||
"source": "_version = 1/n/n[workflow]/ngraph = \"workflow.fabro\"/n"
|
||||
},
|
||||
"files": {
|
||||
".fabro/Project.Dockerfile": {
|
||||
"content": "FROM project/n",
|
||||
"ref": {
|
||||
"from": ".fabro/project.toml",
|
||||
"original": "Project.Dockerfile",
|
||||
"type": "dockerfile"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/goals/goal.md": {
|
||||
"content": "ship it/n",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/workflow.fabro",
|
||||
"original": "goals/goal.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/imports/shared.fabro": {
|
||||
"content": "digraph Shared {/n start [shape=Mdiamond]/n shared [prompt=\"@../prompts/shared.md\"]/n exit [shape=Msquare]/n start -> shared -> exit/n }",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/workflow.fabro",
|
||||
"original": "imports/shared.fabro",
|
||||
"type": "import"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/deep.md": {
|
||||
"content": "deep/n",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/prompts/plan.md",
|
||||
"original": ".fabro/workflows/root/prompts/deep.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/helpers.md": {
|
||||
"content": "{% macro render() %}{% include \"deep.md\" %}{% endmacro %}",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/prompts/plan.md",
|
||||
"original": ".fabro/workflows/root/prompts/helpers.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/partial.md": {
|
||||
"content": "partial/n",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/prompts/plan.md",
|
||||
"original": ".fabro/workflows/root/prompts/partial.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/plan.md": {
|
||||
"content": "{% include \"partial.md\" %}/n{% from \"helpers.md\" import render %}",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/workflow.fabro",
|
||||
"original": "prompts/plan.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/shared.md": {
|
||||
"content": "shared/n",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/imports/shared.fabro",
|
||||
"original": "../prompts/shared.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/schemas/output.json": {
|
||||
"content": "{\"type\":\"object\"}",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/workflow.fabro",
|
||||
"original": "schemas/output.json",
|
||||
"type": "file_inline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"source": "digraph Root {/n graph [goal=\"@goals/goal.md\"]/n start [shape=Mdiamond]/n prompt [prompt=\"@prompts/plan.md\"]/n schema [type=\"agent\", prompt=\"schema\", output_schema=\"@schemas/output.json\"]/n imported [import=\"imports/shared.fabro\"]/n child [shape=house, stack.child_workflow=\"../child/workflow.fabro\"]/n exit [shape=Msquare]/n start -> prompt -> schema -> imported -> child -> exit/n }"
|
||||
}
|
||||
}
|
||||
}
|
||||
558
lib/components/fabro-manifest/src/workflow_bundler.rs
Normal file
558
lib/components/fabro-manifest/src/workflow_bundler.rs
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use fabro_api::types;
|
||||
use fabro_config::project::WorkflowLocation;
|
||||
use fabro_config::{EnvironmentDockerfileLayer, EnvironmentImageLayer, SettingsLayer};
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::{
|
||||
BundleTemplateStore, FilesystemTemplateStore, GraphReference, GraphReferenceError,
|
||||
RecordingTemplateStore, TemplateContext, TemplateDependencyClosure, TemplateRenderMode,
|
||||
TemplateSource, validate_static_reference, visit_graph_references,
|
||||
};
|
||||
use fabro_types::ManifestPath;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
|
||||
use crate::{manifest_path_from_absolute, normalize_absolute_path};
|
||||
|
||||
pub(super) struct WorkflowBundler<'a> {
|
||||
cwd: &'a Path,
|
||||
inputs: &'a HashMap<String, toml::Value>,
|
||||
template_store: FilesystemTemplateStore,
|
||||
workflows: HashMap<String, types::ManifestWorkflow>,
|
||||
visited_workflows: HashSet<String>,
|
||||
}
|
||||
|
||||
impl<'a> WorkflowBundler<'a> {
|
||||
pub(super) fn new(cwd: &'a Path, inputs: &'a HashMap<String, toml::Value>) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
inputs,
|
||||
template_store: FilesystemTemplateStore::new(cwd),
|
||||
workflows: HashMap::new(),
|
||||
visited_workflows: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bundle(
|
||||
mut self,
|
||||
workflow: &Path,
|
||||
project_config: Option<(&ManifestPath, &str)>,
|
||||
) -> Result<HashMap<String, types::ManifestWorkflow>> {
|
||||
let root_key = self.collect_workflow_entry(workflow, self.cwd)?;
|
||||
|
||||
if let Some((config_path, source)) = project_config {
|
||||
let mut root = self
|
||||
.workflows
|
||||
.remove(&root_key)
|
||||
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
|
||||
self.collect_config_dockerfile(config_path, source, &mut root.files)?;
|
||||
self.workflows.insert(root_key, root);
|
||||
}
|
||||
|
||||
Ok(self.workflows)
|
||||
}
|
||||
|
||||
/// Collects the workflow at `location` and returns its manifest key.
|
||||
fn collect_workflow_location(&mut self, location: &WorkflowLocation) -> Result<String> {
|
||||
let dot_path = manifest_path_from_absolute(&location.graph, self.cwd)?;
|
||||
let dot_key = dot_path.to_string();
|
||||
if !self.visited_workflows.insert(dot_key.clone()) {
|
||||
return Ok(dot_key);
|
||||
}
|
||||
|
||||
let source = std::fs::read_to_string(&location.graph)
|
||||
.with_context(|| format!("Failed to read {}", location.graph.display()))?;
|
||||
let config = if let Some(workflow_toml_path) = location.toml.as_ref() {
|
||||
Some(types::ManifestWorkflowConfig {
|
||||
path: manifest_path_from_absolute(workflow_toml_path, self.cwd)?.to_string(),
|
||||
source: std::fs::read_to_string(workflow_toml_path)
|
||||
.with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let scan = WorkflowScanInput {
|
||||
absolute_dot_path: location.graph.clone(),
|
||||
dot_path,
|
||||
source: source.clone(),
|
||||
};
|
||||
let mut files = HashMap::new();
|
||||
let mut visited_imports = HashSet::new();
|
||||
if let Some(config) = config.as_ref() {
|
||||
let config_path = ManifestPath::from_wire(&config.path)
|
||||
.ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?;
|
||||
self.collect_config_dockerfile(&config_path, &config.source, &mut files)?;
|
||||
}
|
||||
self.collect_workflow_files(&scan, &mut files, &mut visited_imports)?;
|
||||
|
||||
self.workflows
|
||||
.insert(dot_key.clone(), types::ManifestWorkflow {
|
||||
config,
|
||||
files,
|
||||
source,
|
||||
});
|
||||
|
||||
Ok(dot_key)
|
||||
}
|
||||
|
||||
/// Relative workflow references with an extension are lexically
|
||||
/// normalized (`..` segments resolved without consulting the filesystem,
|
||||
/// `~` rejected) before resolution, so the file read matches the manifest
|
||||
/// key. Returns the collected workflow's manifest key.
|
||||
fn collect_workflow_entry(&mut self, workflow: &Path, resolve_from: &Path) -> Result<String> {
|
||||
let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() {
|
||||
normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"unsupported manifest workflow reference: {}",
|
||||
workflow.display()
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
workflow.to_path_buf()
|
||||
};
|
||||
let location = WorkflowLocation::resolve(&normalized_workflow, resolve_from)?;
|
||||
self.collect_workflow_location(&location)
|
||||
}
|
||||
|
||||
fn collect_workflow_files(
|
||||
&mut self,
|
||||
workflow: &WorkflowScanInput,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
visited_imports: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let graph = parser::parse(&workflow.source)
|
||||
.with_context(|| format!("Failed to parse {}", workflow.absolute_dot_path.display()))?;
|
||||
let workflow_base_dir = workflow
|
||||
.absolute_dot_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let workflow_template_root = manifest_parent_or_dot(&workflow.dot_path)?;
|
||||
|
||||
// Imports and child workflows require a mutable borrow of self, so
|
||||
// collect them during the walk and recurse after the visitor returns.
|
||||
let mut imports = Vec::new();
|
||||
let mut children = Vec::new();
|
||||
|
||||
visit_graph_references(&graph, |reference| -> Result<()> {
|
||||
match reference {
|
||||
GraphReference::GoalFile { reference } => {
|
||||
let bundled = self.collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
reference,
|
||||
types::ManifestFileRefType::FileInline,
|
||||
ReferenceKind::GraphGoalFile,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
self.collect_bundled_template_includes(files, &bundled, &workflow_template_root)
|
||||
}
|
||||
GraphReference::GoalInline { content }
|
||||
| GraphReference::InlinePrompt { content } => self.collect_template_include_files(
|
||||
files,
|
||||
TemplateSource::new(
|
||||
workflow.dot_path.clone(),
|
||||
workflow_template_root.clone(),
|
||||
content.to_owned(),
|
||||
),
|
||||
Some(&workflow.dot_path),
|
||||
),
|
||||
GraphReference::FileInline { key, reference } => {
|
||||
let bundled = self.collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
reference,
|
||||
types::ManifestFileRefType::FileInline,
|
||||
ReferenceKind::FileInline,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
if key == "prompt" {
|
||||
self.collect_bundled_template_includes(
|
||||
files,
|
||||
&bundled,
|
||||
&workflow_template_root,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::Import { reference } => {
|
||||
let imported = self.collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
reference,
|
||||
types::ManifestFileRefType::Import,
|
||||
ReferenceKind::Import,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
imports.push(imported);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::ChildWorkflow { reference } => {
|
||||
children.push(reference);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
GraphReferenceError::StaticReference(source) => anyhow::Error::new(source),
|
||||
GraphReferenceError::Visit(error) => error,
|
||||
})?;
|
||||
|
||||
for imported in imports {
|
||||
if visited_imports.insert(imported.path.to_string()) {
|
||||
let imported_source = std::fs::read_to_string(&imported.absolute_path)
|
||||
.with_context(|| {
|
||||
format!("Failed to read {}", imported.absolute_path.display())
|
||||
})?;
|
||||
let imported_scan = WorkflowScanInput {
|
||||
absolute_dot_path: imported.absolute_path,
|
||||
dot_path: imported.path,
|
||||
source: imported_source,
|
||||
};
|
||||
self.collect_workflow_files(&imported_scan, files, visited_imports)?;
|
||||
}
|
||||
}
|
||||
for child in children {
|
||||
self.collect_workflow_entry(Path::new(child), workflow_base_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_bundled_template_includes(
|
||||
&self,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
bundled: &BundledFile,
|
||||
workflow_template_root: &ManifestPath,
|
||||
) -> Result<()> {
|
||||
let source = std::fs::read_to_string(&bundled.absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", bundled.absolute_path.display()))?;
|
||||
let template_root = template_root_for_bundled_file(&bundled.path, workflow_template_root)?;
|
||||
self.collect_template_include_files(
|
||||
files,
|
||||
TemplateSource::new(bundled.path.clone(), template_root, source),
|
||||
Some(&bundled.path),
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_template_include_files(
|
||||
&self,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
source: TemplateSource,
|
||||
from: Option<&ManifestPath>,
|
||||
) -> Result<()> {
|
||||
let source_path = source.path.clone();
|
||||
let closure =
|
||||
fabro_template::discover_static_dependency_closure([source], &self.template_store)
|
||||
.context("failed to discover template dependencies")?;
|
||||
self.verify_recorded_template_dependencies(&source_path, &closure, files, from)?;
|
||||
|
||||
for (path, source) in closure.sources {
|
||||
if path == source_path {
|
||||
continue;
|
||||
}
|
||||
let key = path.to_string();
|
||||
files
|
||||
.entry(key)
|
||||
.or_insert_with(|| types::ManifestFileEntry {
|
||||
content: source.content,
|
||||
ref_: types::ManifestFileRef {
|
||||
from: from.map(std::string::ToString::to_string),
|
||||
original: path.to_string(),
|
||||
type_: types::ManifestFileRefType::FileInline,
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_recorded_template_dependencies(
|
||||
&self,
|
||||
source_path: &ManifestPath,
|
||||
closure: &TemplateDependencyClosure,
|
||||
files: &HashMap<String, types::ManifestFileEntry>,
|
||||
from: Option<&ManifestPath>,
|
||||
) -> Result<()> {
|
||||
let Some(source) = closure.sources.get(source_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut bundled_files = closure
|
||||
.sources
|
||||
.iter()
|
||||
.map(|(path, source)| (path.clone(), source.content.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
for (path, entry) in files {
|
||||
if let Some(path) = ManifestPath::from_wire(path) {
|
||||
bundled_files.insert(path, entry.content.clone());
|
||||
}
|
||||
}
|
||||
let allowed = bundled_files.keys().cloned().collect();
|
||||
let store =
|
||||
RecordingTemplateStore::with_allowed(BundleTemplateStore::new(bundled_files), allowed);
|
||||
let context = TemplateContext::for_input_scan(self.inputs.clone());
|
||||
fabro_template::render_source(
|
||||
source,
|
||||
&context,
|
||||
Arc::new(store),
|
||||
TemplateRenderMode::Lenient,
|
||||
)
|
||||
.with_context(|| {
|
||||
let from =
|
||||
from.map_or_else(|| source_path.to_string(), std::string::ToString::to_string);
|
||||
format!("failed to verify template dependencies for {from}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_config_dockerfile(
|
||||
&self,
|
||||
config_path: &ManifestPath,
|
||||
source: &str,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
) -> Result<()> {
|
||||
let layer = source
|
||||
.parse::<SettingsLayer>()
|
||||
.context("Failed to parse run config TOML")?;
|
||||
let absolute_config_path = self.cwd.join(config_path.as_path());
|
||||
let base_dir = absolute_config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
|
||||
for image in layer.environment_images() {
|
||||
self.collect_environment_dockerfile(files, base_dir, config_path, image)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_environment_dockerfile(
|
||||
&self,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
base_dir: &Path,
|
||||
config_path: &ManifestPath,
|
||||
image: &EnvironmentImageLayer,
|
||||
) -> Result<()> {
|
||||
let Some(EnvironmentDockerfileLayer::Path { path }) = image.dockerfile.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.collect_bundled_file(
|
||||
files,
|
||||
base_dir,
|
||||
path,
|
||||
types::ManifestFileRefType::Dockerfile,
|
||||
ReferenceKind::Dockerfile,
|
||||
Some(config_path.clone()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_bundled_file(
|
||||
&self,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
base_dir: &Path,
|
||||
reference: &str,
|
||||
ref_type: types::ManifestFileRefType,
|
||||
reference_kind: ReferenceKind,
|
||||
from: Option<ManifestPath>,
|
||||
) -> Result<BundledFile> {
|
||||
validate_static_reference(reference, reference_kind).map_err(anyhow::Error::new)?;
|
||||
|
||||
let absolute_path = normalize_absolute_path(base_dir, reference)
|
||||
.ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?;
|
||||
let path = manifest_path_from_absolute(&absolute_path, self.cwd)?;
|
||||
let key = path.to_string();
|
||||
if !files.contains_key(&key) {
|
||||
let content = std::fs::read_to_string(&absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", absolute_path.display()))?;
|
||||
files.insert(key.clone(), types::ManifestFileEntry {
|
||||
content,
|
||||
ref_: types::ManifestFileRef {
|
||||
from: from.map(|value| value.to_string()),
|
||||
original: reference.to_owned(),
|
||||
type_: ref_type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Ok(BundledFile {
|
||||
absolute_path,
|
||||
path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkflowScanInput {
|
||||
absolute_dot_path: PathBuf,
|
||||
dot_path: ManifestPath,
|
||||
source: String,
|
||||
}
|
||||
|
||||
struct BundledFile {
|
||||
absolute_path: PathBuf,
|
||||
path: ManifestPath,
|
||||
}
|
||||
|
||||
fn manifest_parent_or_dot(path: &ManifestPath) -> Result<ManifestPath> {
|
||||
let parent = path.parent_or_dot().to_string_lossy();
|
||||
ManifestPath::from_wire(&parent)
|
||||
.ok_or_else(|| anyhow!("invalid manifest parent path for {path}: {parent}"))
|
||||
}
|
||||
|
||||
fn template_root_for_bundled_file(
|
||||
path: &ManifestPath,
|
||||
workflow_template_root: &ManifestPath,
|
||||
) -> Result<ManifestPath> {
|
||||
if manifest_path_is_within_root(path, workflow_template_root) {
|
||||
Ok(workflow_template_root.clone())
|
||||
} else {
|
||||
manifest_parent_or_dot(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_path_is_within_root(path: &ManifestPath, root: &ManifestPath) -> bool {
|
||||
if root.as_path().as_os_str().is_empty() {
|
||||
return !matches!(
|
||||
path.as_path().components().next(),
|
||||
Some(Component::ParentDir)
|
||||
);
|
||||
}
|
||||
path.starts_with(root)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_fixtures::write_file;
|
||||
|
||||
fn bundle_graph(cwd: &Path, graph: &Path) -> Result<HashMap<String, types::ManifestWorkflow>> {
|
||||
let inputs = HashMap::new();
|
||||
WorkflowBundler::new(cwd, &inputs).bundle(graph, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_references_collect_one_file() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
first [prompt="@prompt.md"]
|
||||
second [prompt="@prompt.md"]
|
||||
exit [shape=Msquare]
|
||||
start -> first -> second -> exit
|
||||
}"#,
|
||||
);
|
||||
write_file(&temp.path().join("prompt.md"), "prompt\n");
|
||||
|
||||
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
|
||||
|
||||
assert_eq!(workflows["workflow.fabro"].files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_goal_bundles_filename_with_at_prefix() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
graph [goal="@@goal.md"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#,
|
||||
);
|
||||
write_file(&temp.path().join("@goal.md"), "goal\n");
|
||||
|
||||
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
|
||||
|
||||
let goal = &workflows["workflow.fabro"].files["@goal.md"];
|
||||
assert_eq!(goal.content, "goal\n");
|
||||
assert_eq!(goal.ref_.original, "@goal.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_errors_keep_the_graphviz_error_in_the_source_chain() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(&graph, "not a graph");
|
||||
|
||||
let error = bundle_graph(temp.path(), &graph).expect_err("invalid graph should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.chain()
|
||||
.any(|cause| cause.downcast_ref::<fabro_graphviz::Error>().is_some()),
|
||||
"unexpected error chain: {error:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_errors_keep_the_io_error_in_the_source_chain() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
work [prompt="@missing.md"]
|
||||
exit [shape=Msquare]
|
||||
start -> work -> exit
|
||||
}"#,
|
||||
);
|
||||
|
||||
let error = bundle_graph(temp.path(), &graph).expect_err("missing file should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.chain()
|
||||
.any(|cause| cause.downcast_ref::<std::io::Error>().is_some()),
|
||||
"unexpected error chain: {error:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn root_workflow_normalizes_parent_components_lexically_before_reading() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let cwd = temp.path();
|
||||
let lexical_graph =
|
||||
"digraph Lexical { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
|
||||
let symlinked_graph =
|
||||
"digraph Symlinked { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
|
||||
write_file(&cwd.join("wf/workflow.fabro"), lexical_graph);
|
||||
write_file(&cwd.join("nested/wf/workflow.fabro"), symlinked_graph);
|
||||
std::fs::create_dir_all(cwd.join("nested/elsewhere"))
|
||||
.expect("symlink target should be created");
|
||||
// `link` points into `nested/`, so OS resolution of `link/..` lands in
|
||||
// `nested/` while lexical resolution lands in the invocation directory.
|
||||
std::os::unix::fs::symlink(cwd.join("nested/elsewhere"), cwd.join("link"))
|
||||
.expect("symlink should be created");
|
||||
|
||||
let workflows = bundle_graph(cwd, Path::new("link/../wf/workflow.fabro"))
|
||||
.expect("workflow should bundle");
|
||||
|
||||
// `link/..` must resolve lexically to `wf/workflow.fabro`, not through
|
||||
// the symlink to `nested/wf/workflow.fabro`, so the bundled source
|
||||
// matches the file the manifest key names.
|
||||
assert_eq!(workflows["wf/workflow.fabro"].source, lexical_graph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_workflow_rejects_tilde_relative_references() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
|
||||
let error = bundle_graph(temp.path(), Path::new("~/workflow.fabro"))
|
||||
.expect_err("tilde reference should be rejected");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unsupported manifest workflow reference"),
|
||||
"unexpected error: {error:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,12 @@ const DAYTONA_START_TIMEOUT: Duration = Duration::from_mins(1);
|
|||
/// deletion, temporary stdin files) so a stalled REST call cannot block
|
||||
/// cancellation/timeout paths indefinitely.
|
||||
const DAYTONA_CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Auto-stop applied when `lifecycle.auto_stop` is unset. Omitting the field
|
||||
/// would inherit Daytona's server-side default of 15 idle minutes, which is
|
||||
/// shorter than a single long inference call and stops the sandbox mid-run;
|
||||
/// 120 minutes clears any realistic call while still reclaiming sandboxes
|
||||
/// leaked by a dead worker. An explicit `0` disables auto-stop entirely.
|
||||
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120;
|
||||
|
||||
/// Permissions a Daytona API key needs for Fabro's snapshot and sandbox flow.
|
||||
pub const REQUIRED_DAYTONA_PERMISSIONS: &[Permissions] = &[
|
||||
|
|
@ -727,7 +733,10 @@ impl DaytonaSandbox {
|
|||
daytona_sdk::SandboxBaseParams {
|
||||
name: Some(name),
|
||||
env_vars: Some(clean_bash_env(None)),
|
||||
auto_stop_interval: self.config.auto_stop_interval,
|
||||
auto_stop_interval: self
|
||||
.config
|
||||
.auto_stop_interval
|
||||
.or(Some(DEFAULT_AUTO_STOP_INTERVAL_MINUTES)),
|
||||
labels: Some(managed_labels::merge_for_run(
|
||||
self.config.labels.as_ref(),
|
||||
self.run_id.as_ref(),
|
||||
|
|
@ -2950,6 +2959,10 @@ mod tests {
|
|||
|
||||
assert_eq!(params.ephemeral, Some(false));
|
||||
assert_eq!(params.auto_delete_interval, Some(-1));
|
||||
assert_eq!(
|
||||
params.auto_stop_interval,
|
||||
Some(DEFAULT_AUTO_STOP_INTERVAL_MINUTES)
|
||||
);
|
||||
assert_eq!(
|
||||
params.env_vars,
|
||||
Some(HashMap::from([(BASH_ENV_VAR.to_string(), String::new())]))
|
||||
|
|
@ -2963,6 +2976,27 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_params_passes_explicit_auto_stop_through() {
|
||||
for interval in [0, 45] {
|
||||
let sandbox = DaytonaSandbox::new(
|
||||
DaytonaConfig {
|
||||
auto_stop_interval: Some(interval),
|
||||
..DaytonaConfig::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dtn_test".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("sandbox config should be valid");
|
||||
|
||||
assert_eq!(sandbox.base_params().auto_stop_interval, Some(interval));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activate_skips_start_when_daytona_reports_started() {
|
||||
let server = MockServer::start_async().await;
|
||||
|
|
|
|||
301
lib/components/fabro-store/src/blob_store.rs
Normal file
301
lib/components/fabro-store/src/blob_store.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use fabro_types::BlobHash;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::record::{RawBytesCodec, Record, Repository};
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Blob(pub Bytes);
|
||||
|
||||
impl AsRef<[u8]> for Blob {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Bytes> for Blob {
|
||||
fn from(value: Bytes) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for Blob {
|
||||
type Id = BlobHash;
|
||||
type Codec = RawBytesCodec;
|
||||
|
||||
const PREFIX: &'static str = "blobs/sha256";
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
BlobHash::new(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which storage engine holds the blobs.
|
||||
///
|
||||
/// This enum is a transition vehicle, not a permanent abstraction: `Slate`
|
||||
/// preserves current production behavior while the SQLite backend rolls out.
|
||||
/// Once runtime blob storage switches to SQLite and legacy blobs are
|
||||
/// imported, delete the `Slate` arm (and this enum) and inline the SQLite
|
||||
/// implementation into [`BlobStore`]. The SQLite arm's semantics — verified
|
||||
/// reads and loud failure on hash conflicts — are the intended end state.
|
||||
enum BlobBackend {
|
||||
Slate(Repository<Blob>),
|
||||
Sqlite(SqlitePool),
|
||||
}
|
||||
|
||||
pub struct BlobStore {
|
||||
backend: BlobBackend,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BlobStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let backend = match &self.backend {
|
||||
BlobBackend::Slate(_) => "slate",
|
||||
BlobBackend::Sqlite(_) => "sqlite",
|
||||
};
|
||||
f.debug_struct("BlobStore")
|
||||
.field("backend", &backend)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl BlobStore {
|
||||
/// Creates a blob store backed by a SQLite pool whose migrations have run.
|
||||
#[must_use]
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
backend: BlobBackend::Sqlite(pool),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_slate(db: Arc<slatedb::Db>) -> Self {
|
||||
Self {
|
||||
backend: BlobBackend::Slate(Repository::new(db)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(&self, bytes: &[u8]) -> Result<BlobHash> {
|
||||
match &self.backend {
|
||||
BlobBackend::Slate(repo) => {
|
||||
let blob = Blob(Bytes::copy_from_slice(bytes));
|
||||
let id = blob.id();
|
||||
repo.put(&blob).await?;
|
||||
Ok(id)
|
||||
}
|
||||
BlobBackend::Sqlite(pool) => {
|
||||
let blob_hash = BlobHash::new(bytes);
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO blobs (hash, data) VALUES (?, ?) \
|
||||
ON CONFLICT(hash) DO NOTHING",
|
||||
)
|
||||
.bind(blob_hash.to_string())
|
||||
.bind(bytes)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 1 {
|
||||
return Ok(blob_hash);
|
||||
}
|
||||
|
||||
let stored: Vec<u8> = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
|
||||
.bind(blob_hash.to_string())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if stored == bytes {
|
||||
Ok(blob_hash)
|
||||
} else {
|
||||
Err(Error::BlobHashConflict { blob_hash })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
match &self.backend {
|
||||
BlobBackend::Slate(repo) => Ok(repo.get(blob_hash).await?.map(|blob| blob.0)),
|
||||
BlobBackend::Sqlite(pool) => {
|
||||
let stored: Option<Vec<u8>> =
|
||||
sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
|
||||
.bind(blob_hash.to_string())
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let Some(stored) = stored else {
|
||||
return Ok(None);
|
||||
};
|
||||
if BlobHash::new(&stored) != *blob_hash {
|
||||
return Err(Error::BlobIntegrity {
|
||||
blob_hash: *blob_hash,
|
||||
});
|
||||
}
|
||||
Ok(Some(Bytes::from(stored)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exists(&self, blob_hash: &BlobHash) -> Result<bool> {
|
||||
match &self.backend {
|
||||
BlobBackend::Slate(repo) => repo.exists(blob_hash).await,
|
||||
BlobBackend::Sqlite(pool) => {
|
||||
let exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM blobs WHERE hash = ?)")
|
||||
.bind(blob_hash.to_string())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(exists)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use fabro_types::BlobHash;
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::BlobStore;
|
||||
use crate::keys::SlateKey;
|
||||
use crate::{Database, Error};
|
||||
|
||||
type TestResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
async fn slate_store() -> Arc<BlobStore> {
|
||||
let db = Database::new(
|
||||
Arc::new(InMemory::new()),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
db.blobs().await.unwrap()
|
||||
}
|
||||
|
||||
async fn raw_slate_store(name: &str) -> (Arc<slatedb::Db>, BlobStore) {
|
||||
let raw_db = Arc::new(
|
||||
slatedb::Db::open(name, Arc::new(InMemory::new()))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let store = BlobStore::from_slate(raw_db.clone());
|
||||
(raw_db, store)
|
||||
}
|
||||
|
||||
async fn sqlite_store() -> TestResult<(tempfile::TempDir, fabro_db::Database, BlobStore)> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let database = fabro_db::Database::connect(dir.path().join("fabro.sqlite3")).await?;
|
||||
database.migrate().await?;
|
||||
let store = BlobStore::new(database.clone_pool());
|
||||
Ok((dir, database, store))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slate_writes_reads_and_checks_existence() {
|
||||
let store = slate_store().await;
|
||||
let bytes = b"hello world";
|
||||
let id = store.write(bytes).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.read(&id).await.unwrap(),
|
||||
Some(Bytes::from_static(bytes))
|
||||
);
|
||||
assert_eq!(store.write(bytes).await.unwrap(), id);
|
||||
assert!(store.exists(&id).await.unwrap());
|
||||
assert!(!store.exists(&BlobHash::new(b"missing")).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slate_empty_blobs_round_trip() {
|
||||
let store = slate_store().await;
|
||||
let id = store.write(b"").await.unwrap();
|
||||
|
||||
assert_eq!(store.read(&id).await.unwrap(), Some(Bytes::new()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_slate_db_reads_exact_blob_bytes() {
|
||||
let (raw_db, store) = raw_slate_store("blob-store-tests").await;
|
||||
let bytes = b"{\"ok\":true}";
|
||||
let id = store.write(bytes).await.unwrap();
|
||||
|
||||
let saved = raw_db
|
||||
.get(SlateKey::new("blobs").with("sha256").with(id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(saved.as_ref(), bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_writes_reads_and_checks_existence() -> TestResult<()> {
|
||||
let (_dir, database, store) = sqlite_store().await?;
|
||||
let store = Arc::new(store);
|
||||
|
||||
let binary = [0_u8, 0xff, 0x80, b'a'];
|
||||
let (first_write, concurrent_write) =
|
||||
tokio::join!(store.write(&binary), store.write(&binary));
|
||||
let binary_hash = first_write?;
|
||||
assert_eq!(concurrent_write?, binary_hash);
|
||||
let empty_hash = store.write(b"").await?;
|
||||
|
||||
assert_eq!(store.write(&binary).await?, binary_hash);
|
||||
assert_eq!(
|
||||
store.read(&binary_hash).await?,
|
||||
Some(Bytes::copy_from_slice(&binary))
|
||||
);
|
||||
assert_eq!(store.read(&empty_hash).await?, Some(Bytes::new()));
|
||||
assert!(store.exists(&binary_hash).await?);
|
||||
let missing_hash = BlobHash::new(b"missing");
|
||||
assert_eq!(store.read(&missing_hash).await?, None);
|
||||
assert!(!store.exists(&missing_hash).await?);
|
||||
|
||||
let row_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blobs")
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
assert_eq!(row_count, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_write_rejects_conflicting_stored_bytes() -> TestResult<()> {
|
||||
let (_dir, database, store) = sqlite_store().await?;
|
||||
let expected = b"expected";
|
||||
let blob_hash = BlobHash::new(expected);
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(blob_hash.to_string())
|
||||
.bind(b"different".as_slice())
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
|
||||
let error = store
|
||||
.write(expected)
|
||||
.await
|
||||
.expect_err("conflicting bytes should fail");
|
||||
assert!(
|
||||
matches!(error, Error::BlobHashConflict { blob_hash: value } if value == blob_hash)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_read_rejects_bytes_that_do_not_match_hash() -> TestResult<()> {
|
||||
let (_dir, database, store) = sqlite_store().await?;
|
||||
let blob_hash = BlobHash::new(b"expected");
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(blob_hash.to_string())
|
||||
.bind(b"different".as_slice())
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
|
||||
let error = store
|
||||
.read(&blob_hash)
|
||||
.await
|
||||
.expect_err("mismatched stored bytes should fail");
|
||||
assert!(matches!(error, Error::BlobIntegrity { blob_hash: value } if value == blob_hash));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
use fabro_types::BlobHash;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -10,6 +12,10 @@ pub enum Error {
|
|||
Serde(#[from] serde_json::Error),
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(#[from] sqlx::Error),
|
||||
#[error("stored blob {blob_hash} has bytes that conflict with its hash")]
|
||||
BlobHashConflict { blob_hash: BlobHash },
|
||||
#[error("stored blob data does not match requested hash {blob_hash}")]
|
||||
BlobIntegrity { blob_hash: BlobHash },
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Invalid event payload: {0}")]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::fmt::{self, Write};
|
||||
use std::ops::Range;
|
||||
|
||||
use fabro_types::{RunBlobId, RunId, SessionId};
|
||||
use fabro_types::{RunId, SessionId};
|
||||
|
||||
pub(crate) const MAX_EVENT_SEQ: u32 = 999_999;
|
||||
|
||||
|
|
@ -91,10 +91,6 @@ pub(crate) fn run_events_range(run_id: &RunId, start_seq: u32) -> Range<SlateKey
|
|||
run_event_seq_prefix(run_id, start_seq)..end
|
||||
}
|
||||
|
||||
pub(crate) fn blobs_prefix() -> SlateKey {
|
||||
SlateKey::new("blobs").with("sha256").into_prefix()
|
||||
}
|
||||
|
||||
pub(crate) fn sessions_by_id_prefix() -> SlateKey {
|
||||
SlateKey::new("sessions").with("by-id").into_prefix()
|
||||
}
|
||||
|
|
@ -115,21 +111,6 @@ pub(crate) fn parse_event_seq(key: &str) -> Option<u32> {
|
|||
segments.next()?.split_once('-')?.0.parse().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_blob_id(key: &str) -> Option<RunBlobId> {
|
||||
let mut segments = SlateKey::segments(key);
|
||||
if segments.next()? != "blobs" {
|
||||
return None;
|
||||
}
|
||||
if segments.next()? != "sha256" {
|
||||
return None;
|
||||
}
|
||||
let id = segments.next()?;
|
||||
if segments.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
id.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::RunId;
|
||||
|
|
@ -161,14 +142,6 @@ mod tests {
|
|||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_key_segments() {
|
||||
let blob_id = RunBlobId::new(b"summary");
|
||||
let key = SlateKey::new("blobs").with("sha256").with(blob_id);
|
||||
let segments: Vec<&str> = SlateKey::segments(key.as_str()).collect();
|
||||
assert_eq!(segments, ["blobs", "sha256", &blob_id.to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_keys_are_zero_padded() {
|
||||
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
|
||||
|
|
@ -196,20 +169,16 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parse_helpers_roundtrip() {
|
||||
fn parse_event_seq_roundtrips() {
|
||||
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
|
||||
assert_eq!(
|
||||
parse_event_seq(run_event_key(&run_id, 7, 123).as_str()),
|
||||
Some(7)
|
||||
);
|
||||
|
||||
let blob_id = RunBlobId::new(b"summary");
|
||||
let key = SlateKey::new("blobs").with("sha256").with(blob_id);
|
||||
assert_eq!(parse_blob_id(key.as_str()), Some(blob_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_helpers_reject_invalid_keys() {
|
||||
fn parse_event_seq_rejects_invalid_keys() {
|
||||
assert_eq!(
|
||||
parse_event_seq(
|
||||
SlateKey::new("runs")
|
||||
|
|
@ -220,18 +189,5 @@ mod tests {
|
|||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_blob_id(SlateKey::new("blobs").with("not-a-uuid").as_str()),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_blob_id(
|
||||
SlateKey::new("blobs")
|
||||
.with("01JT56VE4Z5NZ814GZN2JZD65A")
|
||||
.with("not-a-blob")
|
||||
.as_str()
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
|
||||
mod artifact_store;
|
||||
mod blob_store;
|
||||
mod error;
|
||||
mod keyed_mutex;
|
||||
mod keys;
|
||||
|
|
@ -18,9 +19,10 @@ pub use artifact_store::{
|
|||
ArtifactKey, ArtifactStore, NodeArtifact, StageArtifactEntry, retry_storage_segment,
|
||||
stage_storage_segment,
|
||||
};
|
||||
pub use blob_store::{Blob, BlobStore};
|
||||
pub use error::{Error, Result};
|
||||
pub use fabro_types::{
|
||||
EventEnvelope, PendingInterviewRecord, Run, RunBlobId, RunProjection, StageId, StageProjection,
|
||||
BlobHash, EventEnvelope, PendingInterviewRecord, Run, RunProjection, StageId, StageProjection,
|
||||
};
|
||||
pub use keyed_mutex::{KeyedMutex, KeyedMutexGuard};
|
||||
pub use run_sessions::{
|
||||
|
|
@ -34,8 +36,8 @@ pub use run_summary_store::{
|
|||
};
|
||||
pub use serializable_projection::SerializableProjection;
|
||||
pub use slate::{
|
||||
AuthCode, AuthCodeStore, Blob, BlobStore, CachedRunProjection, ConsumeOutcome, Database,
|
||||
RefreshToken, RefreshTokenStore, RunCatalogIndex, RunDatabase, Runs, UnreadableRun,
|
||||
AuthCode, AuthCodeStore, CachedRunProjection, ConsumeOutcome, Database, RefreshToken,
|
||||
RefreshTokenStore, RunCatalogIndex, RunDatabase, Runs, UnreadableRun,
|
||||
};
|
||||
pub use types::EventPayload;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use fabro_types::{RunBlobId, RunId};
|
||||
use fabro_types::{BlobHash, RunId};
|
||||
|
||||
use super::RecordId;
|
||||
use crate::{Error, Result};
|
||||
|
|
@ -38,7 +38,7 @@ impl RecordId for String {
|
|||
}
|
||||
}
|
||||
|
||||
impl RecordId for RunBlobId {
|
||||
impl RecordId for BlobHash {
|
||||
fn key_segments(&self) -> Vec<String> {
|
||||
vec![self.to_string()]
|
||||
}
|
||||
|
|
@ -46,13 +46,13 @@ impl RecordId for RunBlobId {
|
|||
fn from_key_segments(segs: &[&str]) -> Result<Self> {
|
||||
let [segment] = segs else {
|
||||
return Err(Error::KeyParse(format!(
|
||||
"expected 1 segment for RunBlobId, got {}",
|
||||
"expected 1 segment for BlobHash, got {}",
|
||||
segs.len()
|
||||
)));
|
||||
};
|
||||
segment
|
||||
.parse()
|
||||
.map_err(|err| Error::KeyParse(format!("invalid RunBlobId segment {segment:?}: {err}")))
|
||||
.map_err(|err| Error::KeyParse(format!("invalid BlobHash segment {segment:?}: {err}")))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1681,15 +1681,16 @@ mod tests {
|
|||
use fabro_types::settings::run::{DockerfileSource, EnvironmentProvider};
|
||||
use fabro_types::{
|
||||
AgentBackend, AgentControlState, AttrValue, AutomationRef, BilledModelUsage,
|
||||
BilledTokenCounts, BlockedReason, Checkpoint, CheckpointRecord, CommandTermination,
|
||||
EventBody, FailureCategory, FailureDetail, FailureReason, Graph, McpServerStatus, Node,
|
||||
Outcome, ParallelBranchId, PendingReason, PermissionLevel, PullRequestCreationStatus,
|
||||
PullRequestLink, QuestionType, ReasoningEffort, RunApprovalState, RunBillingSummary,
|
||||
RunBlobId, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, RunStatus, Speed,
|
||||
StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
|
||||
StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning,
|
||||
StageHandler, StageModelUsage, StageOutcome, StageState, StageTiming, SubAgentStatus,
|
||||
SuccessReason, WorkflowSettings, first_event_seq, fixtures, test_support,
|
||||
BilledTokenCounts, BlobHash, BlockedReason, Checkpoint, CheckpointRecord,
|
||||
CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph,
|
||||
McpServerStatus, Node, Outcome, ParallelBranchId, PendingReason, PermissionLevel,
|
||||
PullRequestCreationStatus, PullRequestLink, QuestionType, ReasoningEffort,
|
||||
RunApprovalState, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec,
|
||||
RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory,
|
||||
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
|
||||
StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageState,
|
||||
StageTiming, SubAgentStatus, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
|
||||
test_support,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -2272,19 +2273,8 @@ mod tests {
|
|||
|
||||
fn test_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: Some("digraph test {}".to_string()),
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
graph_source: Some("digraph test {}".to_string()),
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4077,19 +4067,9 @@ mod tests {
|
|||
fn summary_synthesizes_submitted_when_run_exists_without_status() {
|
||||
let mut state = initialized_projection();
|
||||
state.spec = fabro_types::RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: fabro_types::Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
automation: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
source_directory: Some("/tmp/repo".to_string()),
|
||||
git: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
};
|
||||
|
||||
let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap();
|
||||
|
|
@ -4103,19 +4083,10 @@ mod tests {
|
|||
fn summary_preserves_absent_workflow_name_and_reports_graph_name() {
|
||||
let mut state = initialized_projection();
|
||||
state.spec = fabro_types::RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: fabro_types::Graph::new("GraphName"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("release-flow".to_string()),
|
||||
automation: None,
|
||||
graph: fabro_types::Graph::new("GraphName"),
|
||||
workflow_slug: Some("release-flow".to_string()),
|
||||
source_directory: Some("/tmp/repo".to_string()),
|
||||
git: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
};
|
||||
|
||||
let summary = build_summary(&state, &fixtures::RUN_1);
|
||||
|
|
@ -4229,9 +4200,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn projection_serialization_includes_manifest_and_definition_blob_refs() {
|
||||
let manifest_blob = RunBlobId::new(br#"{"version":1}"#).to_string();
|
||||
let manifest_blob = BlobHash::new(br#"{"version":1}"#).to_string();
|
||||
let definition_blob =
|
||||
RunBlobId::new(br#"{"version":1,"workflow_path":"workflow.fabro"}"#).to_string();
|
||||
BlobHash::new(br#"{"version":1,"workflow_path":"workflow.fabro"}"#).to_string();
|
||||
let events = vec![
|
||||
EventEnvelope {
|
||||
seq: 1,
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use fabro_types::RunBlobId;
|
||||
|
||||
use crate::Result;
|
||||
use crate::record::{RawBytesCodec, Record, Repository};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Blob(pub Bytes);
|
||||
|
||||
impl AsRef<[u8]> for Blob {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Bytes> for Blob {
|
||||
fn from(value: Bytes) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for Blob {
|
||||
type Id = RunBlobId;
|
||||
type Codec = RawBytesCodec;
|
||||
|
||||
const PREFIX: &'static str = "blobs/sha256";
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
RunBlobId::new(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BlobStore {
|
||||
repo: Repository<Blob>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BlobStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BlobStore").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl BlobStore {
|
||||
pub(crate) fn new(db: Arc<slatedb::Db>) -> Self {
|
||||
Self {
|
||||
repo: Repository::new(db),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(&self, bytes: &[u8]) -> Result<RunBlobId> {
|
||||
let blob = Blob(Bytes::copy_from_slice(bytes));
|
||||
let id = blob.id();
|
||||
self.repo.put(&blob).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn read(&self, id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
Ok(self.repo.get(id).await?.map(|blob| blob.0))
|
||||
}
|
||||
|
||||
pub async fn exists(&self, id: &RunBlobId) -> Result<bool> {
|
||||
self.repo.exists(id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use fabro_types::RunBlobId;
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::BlobStore;
|
||||
use crate::Database;
|
||||
use crate::keys::SlateKey;
|
||||
|
||||
async fn store() -> Arc<BlobStore> {
|
||||
let db = Database::new(
|
||||
Arc::new(InMemory::new()),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
db.blobs().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn writes_reads_and_checks_existence() {
|
||||
let store = store().await;
|
||||
let bytes = b"hello world";
|
||||
let id = store.write(bytes).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.read(&id).await.unwrap(),
|
||||
Some(Bytes::from_static(bytes))
|
||||
);
|
||||
assert_eq!(store.write(bytes).await.unwrap(), id);
|
||||
assert!(store.exists(&id).await.unwrap());
|
||||
assert!(!store.exists(&RunBlobId::new(b"missing")).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_blobs_round_trip() {
|
||||
let store = store().await;
|
||||
let id = store.write(b"").await.unwrap();
|
||||
|
||||
assert_eq!(store.read(&id).await.unwrap(), Some(Bytes::new()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_db_reads_exact_blob_bytes() {
|
||||
let raw_db = Arc::new(
|
||||
slatedb::Db::open("blob-store-tests", Arc::new(InMemory::new()))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let store = BlobStore::new(Arc::clone(&raw_db));
|
||||
let bytes = b"{\"ok\":true}";
|
||||
let id = store.write(bytes).await.unwrap();
|
||||
|
||||
let saved = raw_db
|
||||
.get(SlateKey::new("blobs").with("sha256").with(id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(saved.as_ref(), bytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
mod auth_codes;
|
||||
mod auth_tokens;
|
||||
mod blob_store;
|
||||
mod projection_cache;
|
||||
mod run_catalog_index;
|
||||
mod run_store;
|
||||
|
|
@ -12,7 +11,6 @@ use std::time::Duration;
|
|||
|
||||
pub use auth_codes::{AuthCode, AuthCodeStore};
|
||||
pub use auth_tokens::{ConsumeOutcome, RefreshToken, RefreshTokenStore};
|
||||
pub use blob_store::{Blob, BlobStore};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::{Run, RunId, SessionId};
|
||||
use object_store::ObjectStore;
|
||||
|
|
@ -25,7 +23,7 @@ use slatedb::config::{CompressionCodec, Settings};
|
|||
use tokio::sync::{Mutex, OnceCell};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{Error, ListRunsQuery, Result, RunProjection, RunSummaryStore, keys};
|
||||
use crate::{BlobStore, Error, ListRunsQuery, Result, RunProjection, RunSummaryStore, keys};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnreadableRun {
|
||||
|
|
@ -143,18 +141,23 @@ impl Database {
|
|||
.map(RunDatabase::from_inner)
|
||||
}
|
||||
|
||||
pub async fn create_run(&self, run_id: &RunId) -> Result<RunDatabase> {
|
||||
self.warm_projection_cache().await?;
|
||||
let db = self.open_db().await?;
|
||||
|
||||
self.catalog_index().await?.add(run_id).await?;
|
||||
let run_store = RunDatabase::open_writer(
|
||||
/// Builds a run handle wired to the Database-owned shared stores.
|
||||
async fn open_run_database(&self, run_id: &RunId, read_only: bool) -> Result<RunDatabase> {
|
||||
RunDatabase::build(
|
||||
*run_id,
|
||||
db,
|
||||
self.open_db().await?,
|
||||
read_only,
|
||||
self.blobs().await?,
|
||||
Arc::clone(&self.projection_cache),
|
||||
Arc::clone(&self.run_summary_store),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_run(&self, run_id: &RunId) -> Result<RunDatabase> {
|
||||
self.warm_projection_cache().await?;
|
||||
self.catalog_index().await?.add(run_id).await?;
|
||||
let run_store = self.open_run_database(run_id, false).await?;
|
||||
let mut active_runs = self.active_runs.lock().await;
|
||||
Self::cache_active_run(&mut active_runs, &run_store);
|
||||
Ok(run_store)
|
||||
|
|
@ -178,13 +181,7 @@ impl Database {
|
|||
if !RunDatabase::has_any_events(&db, run_id).await? {
|
||||
return Err(Error::RunNotFound(run_id.to_string()));
|
||||
}
|
||||
let run_store = RunDatabase::open_writer(
|
||||
*run_id,
|
||||
db,
|
||||
Arc::clone(&self.projection_cache),
|
||||
Arc::clone(&self.run_summary_store),
|
||||
)
|
||||
.await?;
|
||||
let run_store = self.open_run_database(run_id, false).await?;
|
||||
Self::cache_active_run(&mut active_runs, &run_store);
|
||||
Ok(run_store)
|
||||
}
|
||||
|
|
@ -202,13 +199,7 @@ impl Database {
|
|||
if !RunDatabase::has_any_events(&db, run_id).await? {
|
||||
return Err(Error::RunNotFound(run_id.to_string()));
|
||||
}
|
||||
RunDatabase::open_reader(
|
||||
*run_id,
|
||||
db,
|
||||
Arc::clone(&self.projection_cache),
|
||||
Arc::clone(&self.run_summary_store),
|
||||
)
|
||||
.await
|
||||
self.open_run_database(run_id, true).await
|
||||
}
|
||||
|
||||
pub async fn list_runs(&self, query: &ListRunsQuery, now: DateTime<Utc>) -> Result<Vec<Run>> {
|
||||
|
|
@ -456,7 +447,7 @@ impl Database {
|
|||
.blobs
|
||||
.get_or_try_init(|| async {
|
||||
let db = Arc::new(self.open_db().await?);
|
||||
Ok::<_, Error>(Arc::new(BlobStore::new(db)))
|
||||
Ok::<_, Error>(Arc::new(BlobStore::from_slate(db)))
|
||||
})
|
||||
.await?;
|
||||
Ok(Arc::clone(store))
|
||||
|
|
@ -843,12 +834,12 @@ mod tests {
|
|||
append_created(&run_2, "run-2", dt("2026-03-27T12:00:10Z")).await;
|
||||
|
||||
let shared_blob = br#"{"summary":"shared"}"#;
|
||||
let shared_blob_id = run_1.write_blob(shared_blob).await.unwrap();
|
||||
let shared_blob_hash = run_1.write_blob(shared_blob).await.unwrap();
|
||||
|
||||
store.delete_run(&test_run_id("run-1")).await.unwrap();
|
||||
|
||||
let reopened = store.open_run(&test_run_id("run-2")).await.unwrap();
|
||||
let read = reopened.read_blob(&shared_blob_id).await.unwrap();
|
||||
let read = reopened.read_blob(&shared_blob_hash).await.unwrap();
|
||||
assert_eq!(read.as_deref(), Some(shared_blob.as_slice()));
|
||||
}
|
||||
|
||||
|
|
@ -857,8 +848,21 @@ mod tests {
|
|||
let (_object_store, store) = make_store();
|
||||
let run = store.create_run(&test_run_id("run-1")).await.unwrap();
|
||||
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
|
||||
let blob = br#"{"summary":"readable"}"#;
|
||||
let blob_hash = run.write_blob(blob).await.unwrap();
|
||||
|
||||
// Evict the cached writer so the reader is built through the real
|
||||
// `open_run_reader` construction path, not a clone of the writer.
|
||||
let _ = store.remove_active_run(&test_run_id("run-1")).await;
|
||||
|
||||
let reader = store.open_run_reader(&test_run_id("run-1")).await.unwrap();
|
||||
assert_eq!(
|
||||
reader.read_blob(&blob_hash).await.unwrap().as_deref(),
|
||||
Some(blob.as_slice())
|
||||
);
|
||||
let err = reader.write_blob(b"blocked").await.unwrap_err();
|
||||
assert!(matches!(err, Error::ReadOnly));
|
||||
|
||||
let err = reader
|
||||
.append_event(&event_payload(
|
||||
"run-1",
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ use std::sync::{Arc, OnceLock};
|
|||
|
||||
use bytes::Bytes;
|
||||
use chrono::Utc;
|
||||
use fabro_types::{RunBlobId, RunEvent, RunId, SessionId};
|
||||
use fabro_types::{BlobHash, RunEvent, RunId, SessionId};
|
||||
use futures::Stream;
|
||||
use slatedb::{Db, DbIterator, DbRead};
|
||||
use tokio::sync::{Mutex, broadcast, mpsc};
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
use tracing::warn;
|
||||
|
||||
use super::blob_store::BlobStore;
|
||||
use super::projection_cache::{CachedRunProjection, RunProjectionCache};
|
||||
use crate::run_state::{EventProjectionCache, RunProjectionReducer};
|
||||
use crate::{
|
||||
Error, EventEnvelope, EventPayload, Result, RunProjection, RunSummaryStore, StageId, keys,
|
||||
BlobStore, Error, EventEnvelope, EventPayload, Result, RunProjection, RunSummaryStore, StageId,
|
||||
keys,
|
||||
};
|
||||
|
||||
const DEFAULT_EVENT_TAIL_LIMIT: usize = 1024;
|
||||
|
|
@ -37,7 +37,7 @@ impl std::fmt::Debug for RunDatabase {
|
|||
pub(crate) struct RunDatabaseInner {
|
||||
run_id: RunId,
|
||||
db: Db,
|
||||
blob_store: BlobStore,
|
||||
blob_store: Arc<BlobStore>,
|
||||
// `None` for reader-built inners: readers never append, so they carry no
|
||||
// next-write sequence and any append through them fails as read-only.
|
||||
event_seq: Option<AtomicU32>,
|
||||
|
|
@ -54,35 +54,11 @@ pub(crate) struct RunDatabaseInner {
|
|||
}
|
||||
|
||||
impl RunDatabase {
|
||||
pub(crate) async fn open_writer(
|
||||
run_id: RunId,
|
||||
db: Db,
|
||||
shared_projection_cache: Arc<RunProjectionCache>,
|
||||
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
|
||||
) -> Result<Self> {
|
||||
Self::build(
|
||||
run_id,
|
||||
db,
|
||||
false,
|
||||
shared_projection_cache,
|
||||
run_summary_store,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn open_reader(
|
||||
run_id: RunId,
|
||||
db: Db,
|
||||
shared_projection_cache: Arc<RunProjectionCache>,
|
||||
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
|
||||
) -> Result<Self> {
|
||||
Self::build(run_id, db, true, shared_projection_cache, run_summary_store).await
|
||||
}
|
||||
|
||||
async fn build(
|
||||
pub(crate) async fn build(
|
||||
run_id: RunId,
|
||||
db: Db,
|
||||
read_only: bool,
|
||||
blob_store: Arc<BlobStore>,
|
||||
shared_projection_cache: Arc<RunProjectionCache>,
|
||||
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
|
||||
) -> Result<Self> {
|
||||
|
|
@ -106,7 +82,6 @@ impl RunDatabase {
|
|||
Some(AtomicU32::new(next_seq))
|
||||
};
|
||||
let (event_tx, _) = broadcast::channel(DEFAULT_EVENT_TAIL_LIMIT.max(16));
|
||||
let blob_store = BlobStore::new(Arc::new(db.clone()));
|
||||
Ok(Self {
|
||||
inner: Arc::new(RunDatabaseInner {
|
||||
run_id,
|
||||
|
|
@ -579,19 +554,15 @@ impl RunDatabase {
|
|||
Ok(Box::pin(UnboundedReceiverStream::new(receiver)))
|
||||
}
|
||||
|
||||
pub async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
pub async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
if self.read_only {
|
||||
return Err(Error::ReadOnly);
|
||||
}
|
||||
self.inner.blob_store.write(data).await
|
||||
}
|
||||
|
||||
pub async fn read_blob(&self, id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
self.inner.blob_store.read(id).await
|
||||
}
|
||||
|
||||
pub async fn list_blobs(&self) -> Result<Vec<RunBlobId>> {
|
||||
list_blobs(&self.inner.db).await
|
||||
pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.inner.blob_store.read(blob_hash).await
|
||||
}
|
||||
|
||||
pub async fn state(&self) -> Result<RunProjection> {
|
||||
|
|
@ -904,23 +875,6 @@ where
|
|||
Ok(events)
|
||||
}
|
||||
|
||||
async fn list_blobs<R>(db: &R) -> Result<Vec<RunBlobId>>
|
||||
where
|
||||
R: DbRead + Sync,
|
||||
{
|
||||
let mut iter = db.scan_prefix(keys::blobs_prefix()).await?;
|
||||
let mut blob_ids = Vec::new();
|
||||
while let Some(entry) = iter.next().await? {
|
||||
let key = key_to_str(&entry.key)?;
|
||||
let Some(blob_id) = keys::parse_blob_id(key) else {
|
||||
continue;
|
||||
};
|
||||
blob_ids.push(blob_id);
|
||||
}
|
||||
blob_ids.sort();
|
||||
Ok(blob_ids)
|
||||
}
|
||||
|
||||
fn key_to_str(key: &Bytes) -> Result<&str> {
|
||||
std::str::from_utf8(key)
|
||||
.map_err(|err| Error::Other(format!("stored key is not valid UTF-8: {err}")))
|
||||
|
|
@ -938,23 +892,6 @@ mod tests {
|
|||
|
||||
use crate::{Database, Error, EventPayload, keys};
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_blobs_reads_global_cas_namespace() {
|
||||
let object_store = Arc::new(InMemory::new());
|
||||
let store = Database::new(object_store, "", Duration::from_millis(1), None);
|
||||
let run_id = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
|
||||
let run = store.create_run(&run_id).await.unwrap();
|
||||
let first_blob = br#"{"a":1}"#;
|
||||
let second_blob = br#"{"b":2}"#;
|
||||
|
||||
let first_id = run.write_blob(first_blob).await.unwrap();
|
||||
let second_id = run.write_blob(second_blob).await.unwrap();
|
||||
let mut blob_ids = run.list_blobs().await.unwrap();
|
||||
blob_ids.sort();
|
||||
|
||||
assert_eq!(blob_ids, vec![first_id, second_id]);
|
||||
}
|
||||
|
||||
fn stage_prompt_payload(run_id: &RunId, idx: u32, node_id: Option<&str>) -> EventPayload {
|
||||
stage_prompt_payload_for_stage(run_id, idx, node_id, None)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,30 +8,23 @@ use fabro_types::{
|
|||
BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord,
|
||||
ParallelBranchResult, QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan,
|
||||
RunSandboxRuntime, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,
|
||||
StageOutcome, StartRecord, WorkflowSettings, first_event_seq, fixtures, test_support,
|
||||
StageOutcome, StartRecord, first_event_seq, fixtures, test_support,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("ship"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
automation: None,
|
||||
graph: Graph::new("ship"),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: Some(fabro_types::GitContext {
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
git: Some(fabro_types::GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
sha: None,
|
||||
dirty: fabro_types::DirtyStatus::Clean,
|
||||
}),
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
27
lib/components/fabro-workflow-version/Cargo.toml
Normal file
27
lib/components/fabro-workflow-version/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "fabro-workflow-version"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "Semantic validation and storage for immutable workflow versions"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
fabro-config = { path = "../../foundation/fabro-config" }
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
fabro-store = { path = "../fabro-store" }
|
||||
fabro-template = { path = "../../foundation/fabro-template" }
|
||||
fabro-types = { path = "../../foundation/fabro-types" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
object_store.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
857
lib/components/fabro-workflow-version/src/lib.rs
Normal file
857
lib/components/fabro-workflow-version/src/lib.rs
Normal file
|
|
@ -0,0 +1,857 @@
|
|||
//! Semantic validation for immutable workflow versions.
|
||||
//!
|
||||
//! The wire type ([`fabro_types::WorkflowVersion`]) enforces structural
|
||||
//! invariants at construction. This crate owns the expensive semantic
|
||||
//! validation — graph closure, config, and template checks — behind the
|
||||
//! [`ValidatedWorkflowVersion`] newtype, and the content-addressed
|
||||
//! [`WorkflowVersionStore`] that only accepts and returns validated versions.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
|
||||
use fabro_config::parse::{SettingsSource, validate_settings_source};
|
||||
use fabro_config::{
|
||||
EnvironmentDockerfileLayer, EnvironmentImageLayer, RunGoalLayer, SettingsLayer,
|
||||
};
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::{
|
||||
BundleTemplateStore, GraphReference, GraphReferenceError, StaticReferenceError,
|
||||
TemplateDiscoveryError, TemplateSource, discover_static_dependency_closure,
|
||||
validate_static_reference, visit_graph_references,
|
||||
};
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::{ManifestPath, WorkflowPath, WorkflowPathParseError, WorkflowVersion};
|
||||
use thiserror::Error;
|
||||
|
||||
mod store;
|
||||
|
||||
pub use store::{LoadedWorkflowVersionClosure, WorkflowVersionStore, WorkflowVersionStoreError};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorkflowVersionError {
|
||||
#[error("workflow graph `{path}` is invalid")]
|
||||
GraphParse {
|
||||
path: WorkflowPath,
|
||||
#[source]
|
||||
source: fabro_graphviz::Error,
|
||||
},
|
||||
#[error("invalid {kind} in `{path}`: `{reference}`")]
|
||||
InvalidReference {
|
||||
path: WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
reference: String,
|
||||
#[source]
|
||||
source: WorkflowPathParseError,
|
||||
},
|
||||
#[error("invalid static reference in `{path}`")]
|
||||
StaticReference {
|
||||
path: WorkflowPath,
|
||||
#[source]
|
||||
source: StaticReferenceError,
|
||||
},
|
||||
#[error("{kind} in `{path}` references missing file `{target}`")]
|
||||
MissingFile {
|
||||
path: WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
target: WorkflowPath,
|
||||
},
|
||||
#[error("template dependencies for `{path}` are invalid")]
|
||||
Template {
|
||||
path: WorkflowPath,
|
||||
#[source]
|
||||
source: Box<TemplateDiscoveryError>,
|
||||
},
|
||||
#[error("workflow.toml is invalid")]
|
||||
Config {
|
||||
#[source]
|
||||
source: fabro_config::ParseError,
|
||||
},
|
||||
#[error(
|
||||
"workflow.toml selects graph `{configured}`, but the version entrypoint is `{entrypoint}`"
|
||||
)]
|
||||
ConfigEntrypointMismatch {
|
||||
configured: WorkflowPath,
|
||||
entrypoint: WorkflowPath,
|
||||
},
|
||||
#[error("workflow dependencies do not match child workflow references")]
|
||||
DependencyMismatch {
|
||||
missing: Vec<WorkflowPath>,
|
||||
unused: Vec<WorkflowPath>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A workflow version whose graph, config, and template content passed
|
||||
/// semantic validation.
|
||||
///
|
||||
/// This is the only door: functions that require a semantically valid
|
||||
/// version take this type, and the only way to obtain one is [`Self::new`]
|
||||
/// (or loading through [`WorkflowVersionStore`], which validates on read).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ValidatedWorkflowVersion(WorkflowVersion);
|
||||
|
||||
impl ValidatedWorkflowVersion {
|
||||
pub fn new(version: WorkflowVersion) -> Result<Self, WorkflowVersionError> {
|
||||
let mut template_roots = TemplateRoots::new();
|
||||
validate_config(&version, &mut template_roots)?;
|
||||
validate_graph_closure(&version, &mut template_roots)?;
|
||||
validate_template_closure(&version, template_roots.sources)?;
|
||||
Ok(Self(version))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn version(&self) -> &WorkflowVersion {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_version(self) -> WorkflowVersion {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Template sources that anchor static dependency discovery, all rooted at
|
||||
/// the workflow package root.
|
||||
struct TemplateRoots {
|
||||
package_root: ManifestPath,
|
||||
sources: Vec<TemplateSource>,
|
||||
}
|
||||
|
||||
impl TemplateRoots {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
package_root: ManifestPath::from_wire(".")
|
||||
.expect("the template package root must be a valid manifest path"),
|
||||
sources: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, path: &WorkflowPath, content: impl Into<String>) {
|
||||
self.sources.push(TemplateSource::new(
|
||||
manifest_path(path),
|
||||
self.package_root.clone(),
|
||||
content,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config(
|
||||
version: &WorkflowVersion,
|
||||
template_roots: &mut TemplateRoots,
|
||||
) -> Result<(), WorkflowVersionError> {
|
||||
let config_path =
|
||||
WorkflowPath::new("workflow.toml").expect("the static workflow config path must be valid");
|
||||
let Some(source) = version.files().get(&config_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let layer = source
|
||||
.parse::<SettingsLayer>()
|
||||
.map_err(|source| WorkflowVersionError::Config { source })?;
|
||||
validate_settings_source(&layer, SettingsSource::Workflow)
|
||||
.map_err(|source| WorkflowVersionError::Config { source })?;
|
||||
|
||||
if let Some(configured) = layer
|
||||
.workflow
|
||||
.as_ref()
|
||||
.and_then(|workflow| workflow.graph.as_deref())
|
||||
{
|
||||
let configured = resolve_reference(&config_path, ReferenceKind::FileInline, configured)?;
|
||||
if configured != *version.entrypoint() {
|
||||
return Err(WorkflowVersionError::ConfigEntrypointMismatch {
|
||||
configured,
|
||||
entrypoint: version.entrypoint().clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for image in layer.environment_images() {
|
||||
validate_dockerfile(version, &config_path, image)?;
|
||||
}
|
||||
|
||||
// The run engine inlines the effective goal (file contents included) into
|
||||
// the entrypoint graph and renders it under the entrypoint's template
|
||||
// source, so goal includes anchor at the entrypoint for both goal forms.
|
||||
match layer.run.as_ref().and_then(|run| run.goal.as_ref()) {
|
||||
Some(RunGoalLayer::Inline(goal)) => {
|
||||
template_roots.push(version.entrypoint(), unresolved_source(goal));
|
||||
}
|
||||
Some(RunGoalLayer::File { file }) => {
|
||||
let (_, content) = validate_config_file_reference(
|
||||
version,
|
||||
&config_path,
|
||||
ReferenceKind::RunGoalFile,
|
||||
&unresolved_source(file),
|
||||
)?;
|
||||
template_roots.push(version.entrypoint(), content);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "workflow-version validation preserves authored template source for dependency discovery"
|
||||
)]
|
||||
fn unresolved_source(value: &InterpString) -> String {
|
||||
value.as_source()
|
||||
}
|
||||
|
||||
fn validate_dockerfile(
|
||||
version: &WorkflowVersion,
|
||||
config_path: &WorkflowPath,
|
||||
image: &EnvironmentImageLayer,
|
||||
) -> Result<(), WorkflowVersionError> {
|
||||
let Some(EnvironmentDockerfileLayer::Path { path }) = image.dockerfile.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
validate_config_file_reference(version, config_path, ReferenceKind::Dockerfile, path)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Validate a static file reference in `workflow.toml` and require its target
|
||||
/// to exist in the version, returning the target path and its content.
|
||||
fn validate_config_file_reference<'version>(
|
||||
version: &'version WorkflowVersion,
|
||||
config_path: &WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
reference: &str,
|
||||
) -> Result<(WorkflowPath, &'version str), WorkflowVersionError> {
|
||||
validate_static_reference(reference, kind).map_err(|source| {
|
||||
WorkflowVersionError::StaticReference {
|
||||
path: config_path.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let target = resolve_reference(config_path, kind, reference)?;
|
||||
let content = require_file(version, config_path, kind, target.clone())?;
|
||||
Ok((target, content))
|
||||
}
|
||||
|
||||
fn validate_graph_closure(
|
||||
version: &WorkflowVersion,
|
||||
template_roots: &mut TemplateRoots,
|
||||
) -> Result<(), WorkflowVersionError> {
|
||||
let mut queue = VecDeque::from([version.entrypoint().clone()]);
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut child_workflows = BTreeSet::new();
|
||||
|
||||
while let Some(path) = queue.pop_front() {
|
||||
if !visited.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
let source =
|
||||
version
|
||||
.files()
|
||||
.get(&path)
|
||||
.ok_or_else(|| WorkflowVersionError::MissingFile {
|
||||
path: path.clone(),
|
||||
kind: ReferenceKind::Import,
|
||||
target: path.clone(),
|
||||
})?;
|
||||
let graph = parser::parse(source).map_err(|source| WorkflowVersionError::GraphParse {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
visit_graph_references(&graph, |reference| match reference {
|
||||
GraphReference::GoalFile { reference } => {
|
||||
let target = resolve_reference(&path, ReferenceKind::GraphGoalFile, reference)?;
|
||||
let content =
|
||||
require_file(version, &path, ReferenceKind::GraphGoalFile, target.clone())?;
|
||||
template_roots.push(&target, content);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::GoalInline { content } | GraphReference::InlinePrompt { content } => {
|
||||
template_roots.push(&path, content);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::Import { reference } => {
|
||||
let target = resolve_reference(&path, ReferenceKind::Import, reference)?;
|
||||
require_file(version, &path, ReferenceKind::Import, target.clone())?;
|
||||
queue.push_back(target);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::ChildWorkflow { reference } => {
|
||||
let target = resolve_reference(&path, ReferenceKind::ChildWorkflow, reference)?;
|
||||
child_workflows.insert(target);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::FileInline { key, reference } => {
|
||||
let target = resolve_reference(&path, ReferenceKind::FileInline, reference)?;
|
||||
let content =
|
||||
require_file(version, &path, ReferenceKind::FileInline, target.clone())?;
|
||||
if key == "prompt" {
|
||||
template_roots.push(&target, content);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
GraphReferenceError::StaticReference(source) => WorkflowVersionError::StaticReference {
|
||||
path: path.clone(),
|
||||
source,
|
||||
},
|
||||
GraphReferenceError::Visit(error) => error,
|
||||
})?;
|
||||
}
|
||||
|
||||
let configured = version
|
||||
.workflow_dependencies()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if child_workflows != configured {
|
||||
return Err(WorkflowVersionError::DependencyMismatch {
|
||||
missing: child_workflows.difference(&configured).cloned().collect(),
|
||||
unused: configured.difference(&child_workflows).cloned().collect(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_template_closure(
|
||||
version: &WorkflowVersion,
|
||||
roots: Vec<TemplateSource>,
|
||||
) -> Result<(), WorkflowVersionError> {
|
||||
discover_static_dependency_closure(roots, &template_store(version)).map_err(|source| {
|
||||
WorkflowVersionError::Template {
|
||||
path: template_discovery_path(&source),
|
||||
source: Box::new(source),
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn template_discovery_path(error: &TemplateDiscoveryError) -> WorkflowPath {
|
||||
WorkflowPath::new(error.source_path().to_string())
|
||||
.expect("template paths sourced from a workflow version must be valid")
|
||||
}
|
||||
|
||||
fn template_store(version: &WorkflowVersion) -> BundleTemplateStore {
|
||||
BundleTemplateStore::new(
|
||||
version
|
||||
.files()
|
||||
.iter()
|
||||
.map(|(path, content)| (manifest_path(path), content.clone()))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_reference(
|
||||
path: &WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
reference: &str,
|
||||
) -> Result<WorkflowPath, WorkflowVersionError> {
|
||||
path.resolve_reference(reference)
|
||||
.map_err(|source| WorkflowVersionError::InvalidReference {
|
||||
path: path.clone(),
|
||||
kind,
|
||||
reference: reference.to_owned(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_file<'version>(
|
||||
version: &'version WorkflowVersion,
|
||||
path: &WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
target: WorkflowPath,
|
||||
) -> Result<&'version str, WorkflowVersionError> {
|
||||
version
|
||||
.files()
|
||||
.get(&target)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| WorkflowVersionError::MissingFile {
|
||||
path: path.clone(),
|
||||
kind,
|
||||
target,
|
||||
})
|
||||
}
|
||||
|
||||
fn manifest_path(path: &WorkflowPath) -> ManifestPath {
|
||||
ManifestPath::from_wire(path.as_str())
|
||||
.expect("validated workflow paths must also be valid manifest paths")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fabro_template::{TemplateDiscoveryError, TemplateLoadError};
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_types::{BlobHash, WorkflowPath, WorkflowVersion, WorkflowVersionId};
|
||||
|
||||
use super::{ValidatedWorkflowVersion, WorkflowVersionError};
|
||||
|
||||
fn path(value: &str) -> WorkflowPath {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn dependency_id(value: &[u8]) -> WorkflowVersionId {
|
||||
BlobHash::new(value).into()
|
||||
}
|
||||
|
||||
fn version_with(
|
||||
files: impl IntoIterator<Item = (&'static str, &'static str)>,
|
||||
dependencies: impl IntoIterator<Item = (&'static str, WorkflowVersionId)>,
|
||||
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {
|
||||
ValidatedWorkflowVersion::new(
|
||||
WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
files
|
||||
.into_iter()
|
||||
.map(|(path_value, content)| (path(path_value), content.to_owned()))
|
||||
.collect(),
|
||||
dependencies
|
||||
.into_iter()
|
||||
.map(|(path_value, id)| (path(path_value), id))
|
||||
.collect(),
|
||||
)
|
||||
.expect("test fixtures must be structurally valid"),
|
||||
)
|
||||
}
|
||||
|
||||
fn version_with_config(
|
||||
config: impl Into<String>,
|
||||
extra_files: impl IntoIterator<Item = (&'static str, &'static str)>,
|
||||
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {
|
||||
let mut files = extra_files
|
||||
.into_iter()
|
||||
.map(|(path_value, content)| (path(path_value), content.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
files.insert(path("workflow.fabro"), "digraph W {}".to_owned());
|
||||
files.insert(path("workflow.toml"), config.into());
|
||||
ValidatedWorkflowVersion::new(
|
||||
WorkflowVersion::new(path("workflow.fabro"), files, BTreeMap::default())
|
||||
.expect("test fixtures must be structurally valid"),
|
||||
)
|
||||
}
|
||||
|
||||
fn version_with_goal_file(
|
||||
reference: &str,
|
||||
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {
|
||||
let reference = serde_json::to_string(reference).unwrap();
|
||||
let config = format!("_version = 1\n[run.goal]\nfile = {reference}\n");
|
||||
version_with_config(config, [])
|
||||
}
|
||||
|
||||
fn version_with_inline_goal(
|
||||
goal: &str,
|
||||
extra_files: impl IntoIterator<Item = (&'static str, &'static str)>,
|
||||
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {
|
||||
let goal = serde_json::to_string(goal).unwrap();
|
||||
version_with_config(format!("_version = 1\n[run]\ngoal = {goal}\n"), extra_files)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_imports_templates_file_refs_and_dependencies() {
|
||||
let version = version_with(
|
||||
[
|
||||
(
|
||||
"workflow.fabro",
|
||||
r#"digraph W {
|
||||
graph [goal="@prompts/goal.md"]
|
||||
start [shape=Mdiamond]
|
||||
imported [import="graphs/imported.fabro"]
|
||||
child [stack.child_workflow="children/check.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> imported -> child -> exit
|
||||
}"#,
|
||||
),
|
||||
(
|
||||
"graphs/imported.fabro",
|
||||
r#"digraph I { step [prompt="{% include \"../prompts/partial.md\" %}"] }"#,
|
||||
),
|
||||
("prompts/goal.md", "{% include \"partial.md\" %}"),
|
||||
("prompts/partial.md", "Do the work"),
|
||||
],
|
||||
[("children/check.fabro", dependency_id(b"child"))],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().workflow_dependencies().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_and_unused_dependencies() {
|
||||
let error = version_with(
|
||||
[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { child [stack.child_workflow="child.fabro"] }"#,
|
||||
)],
|
||||
[("unused.fabro", dependency_id(b"unused"))],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
let WorkflowVersionError::DependencyMismatch { missing, unused } = error else {
|
||||
panic!("expected dependency mismatch");
|
||||
};
|
||||
assert_eq!(missing, vec![path("child.fabro")]);
|
||||
assert_eq!(unused, vec![path("unused.fabro")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_config_entrypoint_and_missing_dockerfile() {
|
||||
let error = version_with(
|
||||
[
|
||||
(
|
||||
"workflow.fabro",
|
||||
"digraph W { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
|
||||
),
|
||||
(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[workflow]\ngraph = \"other.fabro\"\n",
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::ConfigEntrypointMismatch { .. }
|
||||
));
|
||||
|
||||
let missing_dockerfile = version_with(
|
||||
[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[run.environment.image]\ndockerfile = { path = \"docker/Dockerfile\" }\n",
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
missing_dockerfile,
|
||||
WorkflowVersionError::MissingFile { .. }
|
||||
));
|
||||
|
||||
let invalid_config = version_with(
|
||||
[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
("workflow.toml", "not valid toml = ["),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
invalid_config,
|
||||
WorkflowVersionError::Config { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_workflow_goal_file() {
|
||||
let error = version_with_goal_file("prompts/goal.md").unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::MissingFile {
|
||||
path: source_path,
|
||||
kind,
|
||||
target,
|
||||
}
|
||||
if source_path == path("workflow.toml")
|
||||
&& kind == ReferenceKind::RunGoalFile
|
||||
&& target == path("prompts/goal.md")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_inline_workflow_goal_with_static_template_closure() {
|
||||
let version = version_with_inline_goal(
|
||||
r#"Review {{ vars.target }} with {{ inputs.mode }} after {{ goal }}. {% include "prompts/shared.md" %}"#,
|
||||
[("prompts/shared.md", "Use {{ vars.detail }}")],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().files().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_file_workflow_goal_with_transitive_template_closure() {
|
||||
// The goal file's own includes anchor at the entrypoint's directory
|
||||
// (the package root here), not at the goal file's directory; loaded
|
||||
// dependencies then anchor at their own directories as usual.
|
||||
let version =
|
||||
version_with_config("_version = 1\n[run.goal]\nfile = \"prompts/goal.md\"\n", [
|
||||
("prompts/goal.md", r#"{% include "prompts/partial.md" %}"#),
|
||||
("prompts/partial.md", r#"{% include "nested/detail.md" %}"#),
|
||||
("prompts/nested/detail.md", "Use {{ vars.detail }}"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().files().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_broken_transitive_includes_under_a_workflow_goal_file() {
|
||||
// Guards the root push for file goals: without it the goal file is
|
||||
// never parsed and the broken include below is silently accepted.
|
||||
let error =
|
||||
version_with_config("_version = 1\n[run.goal]\nfile = \"prompts/goal.md\"\n", [
|
||||
("prompts/goal.md", r#"{% include "prompts/partial.md" %}"#),
|
||||
("prompts/partial.md", r#"{% include "missing.md" %}"#),
|
||||
])
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::Template { path: source_path, source }
|
||||
if source_path == path("prompts/partial.md")
|
||||
&& matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Missing { reference, .. } if reference == "missing.md"
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_workflow_goal_includes_at_the_entrypoint() {
|
||||
let version_with_entrypoint = |goal_include_target: &'static str| {
|
||||
ValidatedWorkflowVersion::new(
|
||||
WorkflowVersion::new(
|
||||
path("graphs/main.fabro"),
|
||||
BTreeMap::from([
|
||||
(path("graphs/main.fabro"), "digraph W {}".to_owned()),
|
||||
(
|
||||
path("workflow.toml"),
|
||||
"_version = 1\n[run]\ngoal = \"{% include \\\"shared.md\\\" %}\"\n"
|
||||
.to_owned(),
|
||||
),
|
||||
(path(goal_include_target), "shared".to_owned()),
|
||||
]),
|
||||
BTreeMap::default(),
|
||||
)
|
||||
.expect("test fixtures must be structurally valid"),
|
||||
)
|
||||
};
|
||||
|
||||
// The include resolves beside the entrypoint graph, matching where
|
||||
// the run engine renders the inlined goal.
|
||||
version_with_entrypoint("graphs/shared.md").unwrap();
|
||||
|
||||
let error = version_with_entrypoint("shared.md").unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::Template { path: source_path, source }
|
||||
if source_path == path("graphs/main.fabro")
|
||||
&& matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Missing { reference, .. } if reference == "shared.md"
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_static_or_nonportable_workflow_goal_file_references() {
|
||||
for reference in ["{{ vars.NAME }}", "{% include \"goal.md\" %}"] {
|
||||
let error = version_with_goal_file(reference).unwrap_err();
|
||||
let WorkflowVersionError::StaticReference {
|
||||
path: source_path,
|
||||
source,
|
||||
} = error
|
||||
else {
|
||||
panic!("expected static-reference error for {reference:?}");
|
||||
};
|
||||
assert_eq!(source_path, path("workflow.toml"));
|
||||
assert_eq!(source.kind(), ReferenceKind::RunGoalFile);
|
||||
}
|
||||
|
||||
for reference in [
|
||||
"",
|
||||
"/absolute.md",
|
||||
"../outside.md",
|
||||
"~/goal.md",
|
||||
"C:/goal.md",
|
||||
"prompts\\goal.md",
|
||||
"prompts//goal.md",
|
||||
"prompts/",
|
||||
"prompts/goal\n.md",
|
||||
] {
|
||||
let error = version_with_goal_file(reference).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
&error,
|
||||
WorkflowVersionError::InvalidReference {
|
||||
path: source_path,
|
||||
kind: ReferenceKind::RunGoalFile,
|
||||
..
|
||||
} if *source_path == path("workflow.toml")
|
||||
),
|
||||
"expected invalid-reference error for {reference:?}, got {error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_workflow_goal_template_closure() {
|
||||
let missing = version_with_inline_goal(r#"{% include "missing.md" %}"#, []).unwrap_err();
|
||||
let WorkflowVersionError::Template {
|
||||
path: source_path,
|
||||
source,
|
||||
} = missing
|
||||
else {
|
||||
panic!("expected missing template dependency");
|
||||
};
|
||||
assert_eq!(source_path, path("workflow.fabro"));
|
||||
assert!(matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Missing { parent, reference }
|
||||
if parent.to_string() == "workflow.fabro" && reference == "missing.md"
|
||||
));
|
||||
|
||||
let dynamic = version_with_inline_goal(r"{% include inputs.partial %}", []).unwrap_err();
|
||||
let WorkflowVersionError::Template { source, .. } = dynamic else {
|
||||
panic!("expected dynamic template dependency");
|
||||
};
|
||||
assert!(matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Dynamic { parent }
|
||||
if parent.to_string() == "workflow.fabro"
|
||||
));
|
||||
|
||||
let escaping =
|
||||
version_with_inline_goal(r#"{% include "../outside.md" %}"#, []).unwrap_err();
|
||||
let WorkflowVersionError::Template { source, .. } = escaping else {
|
||||
panic!("expected escaping template dependency");
|
||||
};
|
||||
assert!(matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Load {
|
||||
source: TemplateLoadError::EscapesRoot { parent, .. },
|
||||
..
|
||||
} if parent.to_string() == "workflow.fabro"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_all_inline_graph_roots_that_share_the_graph_path() {
|
||||
let error = version_with(
|
||||
[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W {
|
||||
graph [goal="valid"]
|
||||
step [prompt="{% include inputs.partial %}"]
|
||||
}"#,
|
||||
)],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::Template {
|
||||
source,
|
||||
..
|
||||
} if matches!(source.as_ref(), TemplateDiscoveryError::Dynamic { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_graph_files_included_from_goal_templates() {
|
||||
// The graph file's inline prompt anchors a template root at the graph
|
||||
// path; that root must not shadow the raw graph content when a goal
|
||||
// template includes the graph file itself.
|
||||
let error = version_with(
|
||||
[
|
||||
(
|
||||
"workflow.fabro",
|
||||
r#"digraph W {
|
||||
graph [goal="@goal.md"]
|
||||
step [prompt="hello", note="{% include 'missing.md' %}"]
|
||||
}"#,
|
||||
),
|
||||
("goal.md", r#"{% include "workflow.fabro" %}"#),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::Template { path: source_path, source }
|
||||
if source_path == path("workflow.fabro")
|
||||
&& matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Missing { reference, .. } if reference == "missing.md"
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_root_config_and_all_dockerfile_path_sources() {
|
||||
let version = version_with(
|
||||
[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
(
|
||||
"workflow.toml",
|
||||
r#"_version = 1
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[environments.cloud]
|
||||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
dockerfile = { path = "docker/named.Dockerfile" }
|
||||
|
||||
[run.environment.image]
|
||||
dockerfile = { path = "docker/run.Dockerfile" }
|
||||
"#,
|
||||
),
|
||||
("docker/named.Dockerfile", "FROM alpine\n"),
|
||||
("docker/run.Dockerfile", "FROM ubuntu\n"),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().entrypoint(), &path("workflow.fabro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_server_managed_environment_cwd_in_workflow_config() {
|
||||
let error = version_with(
|
||||
[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[environments.local]\nprovider = \"local\"\ncwd = \"/tmp\"\n",
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, WorkflowVersionError::Config { .. }));
|
||||
assert!(error.to_string().contains("workflow.toml is invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_escaping_and_dynamic_template_references() {
|
||||
let escaping = version_with(
|
||||
[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { imported [import="../outside.fabro"] }"#,
|
||||
)],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
escaping,
|
||||
WorkflowVersionError::InvalidReference { .. }
|
||||
));
|
||||
|
||||
let dynamic = version_with(
|
||||
[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { step [prompt="{% include template_name %}"] }"#,
|
||||
)],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(dynamic, WorkflowVersionError::Template { .. }));
|
||||
}
|
||||
}
|
||||
495
lib/components/fabro-workflow-version/src/store.rs
Normal file
495
lib/components/fabro-workflow-version/src/store.rs
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
use std::collections::{BTreeMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_store::BlobStore;
|
||||
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId, WorkflowVersionShapeError};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{ValidatedWorkflowVersion, WorkflowVersionError};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorkflowVersionStoreError {
|
||||
#[error(transparent)]
|
||||
InvalidVersion(#[from] WorkflowVersionError),
|
||||
#[error(transparent)]
|
||||
InvalidShape(#[from] WorkflowVersionShapeError),
|
||||
#[error("workflow-version dependency `{id}` at `{path}` is not stored")]
|
||||
DependencyNotFound {
|
||||
path: WorkflowPath,
|
||||
id: WorkflowVersionId,
|
||||
},
|
||||
#[error("workflow-version dependency `{id}` at `{path}` is invalid")]
|
||||
DependencyInvalid {
|
||||
path: WorkflowPath,
|
||||
id: WorkflowVersionId,
|
||||
#[source]
|
||||
source: Box<Self>,
|
||||
},
|
||||
#[error("workflow-version blob `{id}` cannot be decoded as a valid workflow version")]
|
||||
Decode {
|
||||
id: WorkflowVersionId,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("workflow-version blob `{id}` is not canonical")]
|
||||
NonCanonical { id: WorkflowVersionId },
|
||||
#[error("workflow-version storage operation failed")]
|
||||
Storage {
|
||||
#[source]
|
||||
source: fabro_store::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// A fully loaded and validated workflow-version dependency graph: the
|
||||
/// requested root alongside every unique transitive dependency, keyed by
|
||||
/// canonical content ID.
|
||||
///
|
||||
/// Deliberately not `Clone`: a closure owns the full file contents of every
|
||||
/// version in the graph, so copies should be explicit and deliberate.
|
||||
#[derive(Debug)]
|
||||
pub struct LoadedWorkflowVersionClosure {
|
||||
root_id: WorkflowVersionId,
|
||||
root: ValidatedWorkflowVersion,
|
||||
dependencies: BTreeMap<WorkflowVersionId, ValidatedWorkflowVersion>,
|
||||
}
|
||||
|
||||
impl LoadedWorkflowVersionClosure {
|
||||
#[must_use]
|
||||
pub fn root_id(&self) -> WorkflowVersionId {
|
||||
self.root_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &WorkflowVersion {
|
||||
self.root.version()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &WorkflowVersionId) -> Option<&WorkflowVersion> {
|
||||
if *id == self.root_id {
|
||||
return Some(self.root.version());
|
||||
}
|
||||
self.dependencies
|
||||
.get(id)
|
||||
.map(ValidatedWorkflowVersion::version)
|
||||
}
|
||||
|
||||
pub fn versions(&self) -> impl Iterator<Item = (WorkflowVersionId, &WorkflowVersion)> + '_ {
|
||||
std::iter::once((self.root_id, self.root.version())).chain(
|
||||
self.dependencies
|
||||
.iter()
|
||||
.map(|(id, version)| (*id, version.version())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-addressed storage for validated workflow versions.
|
||||
///
|
||||
/// `put` only accepts semantically validated versions; `get` re-validates
|
||||
/// blobs on read because the blob namespace is shared and storage is not
|
||||
/// trusted to contain only canonical versions.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkflowVersionStore {
|
||||
blobs: Arc<BlobStore>,
|
||||
}
|
||||
|
||||
impl WorkflowVersionStore {
|
||||
#[must_use]
|
||||
pub fn new(blobs: Arc<BlobStore>) -> Self {
|
||||
Self { blobs }
|
||||
}
|
||||
|
||||
pub async fn put(
|
||||
&self,
|
||||
version: &ValidatedWorkflowVersion,
|
||||
) -> Result<WorkflowVersionId, WorkflowVersionStoreError> {
|
||||
let canonical = version.version().canonical_bytes()?;
|
||||
self.walk_dependency_closure(version.version().workflow_dependencies(), |_, _| ())
|
||||
.await?;
|
||||
self.blobs
|
||||
.write(&canonical)
|
||||
.await
|
||||
.map(WorkflowVersionId::from)
|
||||
.map_err(|source| WorkflowVersionStoreError::Storage { source })
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
&self,
|
||||
id: &WorkflowVersionId,
|
||||
) -> Result<Option<ValidatedWorkflowVersion>, WorkflowVersionStoreError> {
|
||||
let Some(version) = self.load_one(id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.walk_dependency_closure(version.version().workflow_dependencies(), |_, _| ())
|
||||
.await?;
|
||||
Ok(Some(version))
|
||||
}
|
||||
|
||||
pub async fn get_closure(
|
||||
&self,
|
||||
root_id: &WorkflowVersionId,
|
||||
) -> Result<Option<LoadedWorkflowVersionClosure>, WorkflowVersionStoreError> {
|
||||
let Some(root) = self.load_one(root_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut dependencies = BTreeMap::new();
|
||||
self.walk_dependency_closure(root.version().workflow_dependencies(), |id, version| {
|
||||
dependencies.insert(id, version);
|
||||
})
|
||||
.await?;
|
||||
Ok(Some(LoadedWorkflowVersionClosure {
|
||||
root_id: *root_id,
|
||||
root,
|
||||
dependencies,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn load_one(
|
||||
&self,
|
||||
id: &WorkflowVersionId,
|
||||
) -> Result<Option<ValidatedWorkflowVersion>, WorkflowVersionStoreError> {
|
||||
let blob_hash = (*id).into();
|
||||
let Some(bytes) = self
|
||||
.blobs
|
||||
.read(&blob_hash)
|
||||
.await
|
||||
.map_err(|source| WorkflowVersionStoreError::Storage { source })?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let version = serde_json::from_slice::<WorkflowVersion>(&bytes)
|
||||
.map_err(|source| WorkflowVersionStoreError::Decode { id: *id, source })?;
|
||||
let validated = ValidatedWorkflowVersion::new(version)?;
|
||||
let canonical = validated.version().canonical_bytes()?;
|
||||
if canonical.as_slice() != bytes.as_ref() {
|
||||
return Err(WorkflowVersionStoreError::NonCanonical { id: *id });
|
||||
}
|
||||
Ok(Some(validated))
|
||||
}
|
||||
|
||||
/// Walk the transitive dependency closure, validating every dependency
|
||||
/// and handing each loaded version to `visit` exactly once.
|
||||
async fn walk_dependency_closure(
|
||||
&self,
|
||||
dependencies: &BTreeMap<WorkflowPath, WorkflowVersionId>,
|
||||
mut visit: impl FnMut(WorkflowVersionId, ValidatedWorkflowVersion),
|
||||
) -> Result<(), WorkflowVersionStoreError> {
|
||||
let mut pending = dependencies
|
||||
.iter()
|
||||
.map(|(path, id)| (path.clone(), *id))
|
||||
.collect::<VecDeque<_>>();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
while let Some((path, id)) = pending.pop_front() {
|
||||
if !visited.insert(id) {
|
||||
continue;
|
||||
}
|
||||
match self.load_one(&id).await {
|
||||
Ok(Some(dependency)) => {
|
||||
pending.extend(
|
||||
dependency
|
||||
.version()
|
||||
.workflow_dependencies()
|
||||
.iter()
|
||||
.map(|(path, id)| (path.clone(), *id)),
|
||||
);
|
||||
visit(id, dependency);
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(WorkflowVersionStoreError::DependencyNotFound { path, id });
|
||||
}
|
||||
// Persistence failures are server faults, not evidence that
|
||||
// the caller supplied an invalid dependency.
|
||||
Err(source @ WorkflowVersionStoreError::Storage { .. }) => return Err(source),
|
||||
Err(source) => {
|
||||
return Err(WorkflowVersionStoreError::DependencyInvalid {
|
||||
path,
|
||||
id,
|
||||
source: Box::new(source),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_store::{BlobStore, Database};
|
||||
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::{WorkflowVersionStore, WorkflowVersionStoreError};
|
||||
use crate::ValidatedWorkflowVersion;
|
||||
|
||||
fn path(value: &str) -> WorkflowPath {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn version(
|
||||
graph: &str,
|
||||
dependencies: BTreeMap<WorkflowPath, WorkflowVersionId>,
|
||||
) -> ValidatedWorkflowVersion {
|
||||
ValidatedWorkflowVersion::new(
|
||||
WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
BTreeMap::from([(path("workflow.fabro"), graph.to_owned())]),
|
||||
dependencies,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn version_id(version: &ValidatedWorkflowVersion) -> WorkflowVersionId {
|
||||
WorkflowVersionId::from(fabro_types::BlobHash::new(
|
||||
&version.version().canonical_bytes().unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn stores() -> (Arc<BlobStore>, WorkflowVersionStore) {
|
||||
let database = Database::new(
|
||||
Arc::new(InMemory::new()),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
let blobs = database.blobs().await.unwrap();
|
||||
let versions = WorkflowVersionStore::new(Arc::clone(&blobs));
|
||||
(blobs, versions)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_get_reuses_exact_blob_digest() {
|
||||
let (blobs, store) = stores().await;
|
||||
let version = version("digraph W {}", BTreeMap::new());
|
||||
let expected_bytes = version.version().canonical_bytes().unwrap();
|
||||
let expected_id = version_id(&version);
|
||||
|
||||
let id = store.put(&version).await.unwrap();
|
||||
assert_eq!(id, expected_id);
|
||||
let blob_hash = id.into();
|
||||
assert_eq!(
|
||||
blobs.read(&blob_hash).await.unwrap().unwrap(),
|
||||
expected_bytes
|
||||
);
|
||||
assert_eq!(store.get(&id).await.unwrap(), Some(version));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identical_content_is_idempotent() {
|
||||
let (_, store) = stores().await;
|
||||
let original = version("digraph W {}", BTreeMap::new());
|
||||
|
||||
assert_eq!(
|
||||
store.put(&original).await.unwrap(),
|
||||
store.put(&original).await.unwrap()
|
||||
);
|
||||
|
||||
let changed = version("digraph W { changed [label=\"yes\"] }", BTreeMap::new());
|
||||
assert_ne!(
|
||||
store.put(&original).await.unwrap(),
|
||||
store.put(&changed).await.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dependency_must_be_stored_first() {
|
||||
let (blobs, store) = stores().await;
|
||||
let child = version("digraph Child {}", BTreeMap::new());
|
||||
let child_id = version_id(&child);
|
||||
let root = version(
|
||||
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
|
||||
BTreeMap::from([(path("child.fabro"), child_id)]),
|
||||
);
|
||||
let root_id = version_id(&root);
|
||||
|
||||
let error = store.put(&root).await.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionStoreError::DependencyNotFound { .. }
|
||||
));
|
||||
assert!(!blobs.exists(&root_id.into()).await.unwrap());
|
||||
assert_eq!(store.put(&child).await.unwrap(), child_id);
|
||||
assert!(store.put(&root).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dependency_closure_must_be_complete_before_root_write() {
|
||||
let (blobs, store) = stores().await;
|
||||
let missing_grandchild_id = WorkflowVersionId::from(fabro_types::BlobHash::new(b"missing"));
|
||||
let child = version(
|
||||
r#"digraph Child { grandchild [stack.child_workflow="grandchild.fabro"] }"#,
|
||||
BTreeMap::from([(path("grandchild.fabro"), missing_grandchild_id)]),
|
||||
);
|
||||
let child_bytes = child.version().canonical_bytes().unwrap();
|
||||
let child_id = WorkflowVersionId::from(blobs.write(&child_bytes).await.unwrap());
|
||||
let root = version(
|
||||
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
|
||||
BTreeMap::from([(path("child.fabro"), child_id)]),
|
||||
);
|
||||
let root_id = version_id(&root);
|
||||
|
||||
assert!(matches!(
|
||||
store.put(&root).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::DependencyNotFound { id, .. }
|
||||
if id == missing_grandchild_id
|
||||
));
|
||||
assert!(!blobs.exists(&root_id.into()).await.unwrap());
|
||||
assert!(matches!(
|
||||
store.get_closure(&child_id).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::DependencyNotFound { id, .. }
|
||||
if id == missing_grandchild_id
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_closure_returns_root_and_transitive_dependencies() {
|
||||
let (_, store) = stores().await;
|
||||
let grandchild = version("digraph Grandchild {}", BTreeMap::new());
|
||||
let grandchild_id = store.put(&grandchild).await.unwrap();
|
||||
let child = version(
|
||||
r#"digraph Child { grandchild [stack.child_workflow="grandchild.fabro"] }"#,
|
||||
BTreeMap::from([(path("grandchild.fabro"), grandchild_id)]),
|
||||
);
|
||||
let child_id = store.put(&child).await.unwrap();
|
||||
let root = version(
|
||||
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
|
||||
BTreeMap::from([(path("child.fabro"), child_id)]),
|
||||
);
|
||||
let root_id = store.put(&root).await.unwrap();
|
||||
|
||||
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(closure.root_id(), root_id);
|
||||
assert_eq!(closure.root(), root.version());
|
||||
assert_eq!(closure.get(&child_id), Some(child.version()));
|
||||
assert_eq!(closure.get(&grandchild_id), Some(grandchild.version()));
|
||||
assert_eq!(
|
||||
closure
|
||||
.versions()
|
||||
.map(|(id, version)| (id, version.clone()))
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
BTreeMap::from([
|
||||
(root_id, root.version().clone()),
|
||||
(child_id, child.version().clone()),
|
||||
(grandchild_id, grandchild.version().clone()),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_closure_deduplicates_a_diamond() {
|
||||
let (_, store) = stores().await;
|
||||
let leaf = version("digraph Leaf {}", BTreeMap::new());
|
||||
let leaf_id = store.put(&leaf).await.unwrap();
|
||||
let left = version(
|
||||
r#"digraph Left { leaf [stack.child_workflow="leaf.fabro"] }"#,
|
||||
BTreeMap::from([(path("leaf.fabro"), leaf_id)]),
|
||||
);
|
||||
let left_id = store.put(&left).await.unwrap();
|
||||
let right = version(
|
||||
r#"digraph Right { leaf [stack.child_workflow="leaf.fabro"] }"#,
|
||||
BTreeMap::from([(path("leaf.fabro"), leaf_id)]),
|
||||
);
|
||||
let right_id = store.put(&right).await.unwrap();
|
||||
let root = version(
|
||||
r#"digraph Root {
|
||||
left [stack.child_workflow="left.fabro"]
|
||||
right [stack.child_workflow="right.fabro"]
|
||||
}"#,
|
||||
BTreeMap::from([
|
||||
(path("left.fabro"), left_id),
|
||||
(path("right.fabro"), right_id),
|
||||
]),
|
||||
);
|
||||
let root_id = store.put(&root).await.unwrap();
|
||||
|
||||
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
|
||||
let ids = closure.versions().map(|(id, _)| id).collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(ids.len(), 4);
|
||||
assert_eq!(ids.iter().filter(|&&id| id == leaf_id).count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_closure_preserves_noncanonical_dependency_errors() {
|
||||
let (blobs, store) = stores().await;
|
||||
let dependency = version("digraph Dependency {}", BTreeMap::new());
|
||||
let pretty = serde_json::to_vec_pretty(dependency.version()).unwrap();
|
||||
let dependency_id = WorkflowVersionId::from(blobs.write(&pretty).await.unwrap());
|
||||
let root = version(
|
||||
r#"digraph Root { dependency [stack.child_workflow="dependency.fabro"] }"#,
|
||||
BTreeMap::from([(path("dependency.fabro"), dependency_id)]),
|
||||
);
|
||||
let root_id = WorkflowVersionId::from(
|
||||
blobs
|
||||
.write(&root.version().canonical_bytes().unwrap())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let error = store.get_closure(&root_id).await.unwrap_err();
|
||||
let WorkflowVersionStoreError::DependencyInvalid { source, .. } = error else {
|
||||
panic!("expected invalid dependency error");
|
||||
};
|
||||
assert!(matches!(
|
||||
source.as_ref(),
|
||||
WorkflowVersionStoreError::NonCanonical { id } if *id == dependency_id
|
||||
));
|
||||
assert!(matches!(
|
||||
store.get_closure(&dependency_id).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::NonCanonical { id } if id == dependency_id
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_projects_the_same_validated_root_as_get_closure() {
|
||||
let (_, store) = stores().await;
|
||||
let child = version("digraph Child {}", BTreeMap::new());
|
||||
let child_id = store.put(&child).await.unwrap();
|
||||
let root = version(
|
||||
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
|
||||
BTreeMap::from([(path("child.fabro"), child_id)]),
|
||||
);
|
||||
let root_id = store.put(&root).await.unwrap();
|
||||
|
||||
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
|
||||
let projected = store.get(&root_id).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(projected.version(), closure.root());
|
||||
let absent = version_id(&version("digraph Absent {}", BTreeMap::new()));
|
||||
assert!(store.get_closure(&absent).await.unwrap().is_none());
|
||||
assert!(store.get(&absent).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_rejects_arbitrary_and_noncanonical_blobs() {
|
||||
let (blobs, store) = stores().await;
|
||||
let arbitrary = WorkflowVersionId::from(blobs.write(b"not json").await.unwrap());
|
||||
assert!(matches!(
|
||||
store.get(&arbitrary).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::Decode { .. }
|
||||
));
|
||||
|
||||
let invalid_bytes = br#"{"entrypoint":"missing.fabro","files":{"workflow.fabro":"digraph W {}"},"workflow_dependencies":{}}"#;
|
||||
let invalid = WorkflowVersionId::from(blobs.write(invalid_bytes).await.unwrap());
|
||||
assert!(matches!(
|
||||
store.get(&invalid).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::Decode { .. }
|
||||
));
|
||||
|
||||
let version = version("digraph W {}", BTreeMap::new());
|
||||
let pretty = serde_json::to_vec_pretty(version.version()).unwrap();
|
||||
let noncanonical = WorkflowVersionId::from(blobs.write(&pretty).await.unwrap());
|
||||
assert!(matches!(
|
||||
store.get(&noncanonical).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::NonCanonical { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
|||
use fabro_agent::Sandbox;
|
||||
use fabro_config::RunScratch;
|
||||
use fabro_types::{
|
||||
ParallelBranchResult, RunBlobId, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref,
|
||||
BlobHash, ParallelBranchResult, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref,
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::Value;
|
||||
|
|
@ -26,7 +26,7 @@ const ARTIFACT_POINTER_PREFIX: &str = "file://";
|
|||
///
|
||||
/// For each entry in `updates` whose serialized JSON exceeds
|
||||
/// `BLOB_OFFLOAD_THRESHOLD`, the value is persisted as a blob in `run_store`
|
||||
/// and replaced with a `"blob://sha256/{blob_id}"` reference.
|
||||
/// and replaced with a `"blob://sha256/{blob_hash}"` reference.
|
||||
/// Small values are left untouched.
|
||||
///
|
||||
/// `parallel.results` is offloaded at each branch context-update boundary
|
||||
|
|
@ -102,11 +102,11 @@ async fn offload_value(value: &mut Value, run_store: &RunStoreHandle) -> Result<
|
|||
.map_err(|e| Error::engine_with_source("artifact serialize failed", e))?;
|
||||
|
||||
if bytes.len() > BLOB_OFFLOAD_THRESHOLD {
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&bytes)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_anyhow("artifact blob write failed", e))?;
|
||||
*value = Value::String(format_blob_ref(&blob_id));
|
||||
*value = Value::String(format_blob_ref(&blob_hash));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -204,8 +204,16 @@ pub async fn resolve_outcomes_for_execution(
|
|||
run_dir: &Path,
|
||||
) -> Result<HashMap<String, Outcome>> {
|
||||
let mut resolved = node_outcomes.clone();
|
||||
let mut locality = SandboxLocality::default();
|
||||
for outcome in resolved.values_mut() {
|
||||
resolve_execution_values(&mut outcome.context_updates, run_store, env, run_dir).await?;
|
||||
resolve_execution_values(
|
||||
&mut outcome.context_updates,
|
||||
run_store,
|
||||
env,
|
||||
run_dir,
|
||||
&mut locality,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
|
@ -217,7 +225,8 @@ pub async fn resolved_context_snapshot(
|
|||
run_dir: &Path,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
let mut values = context.snapshot();
|
||||
resolve_execution_values(&mut values, run_store, env, run_dir).await?;
|
||||
let mut locality = SandboxLocality::default();
|
||||
resolve_execution_values(&mut values, run_store, env, run_dir, &mut locality).await?;
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
|
|
@ -232,17 +241,17 @@ pub async fn resolve_text_or_blob_ref(value: &Value, run_store: &RunStoreHandle)
|
|||
/// blob reference.
|
||||
///
|
||||
/// Managed `file://` references are normalized through their content-addressed
|
||||
/// blob id instead of reading an execution-local path. Ordinary strings and
|
||||
/// blob hash instead of reading an execution-local path. Ordinary strings and
|
||||
/// ordinary file references remain unchanged for the caller to validate.
|
||||
pub(crate) async fn resolve_json_value(value: Value, run_store: &RunStoreHandle) -> Result<Value> {
|
||||
let blob_id = value.as_str().and_then(|reference| {
|
||||
let blob_hash = value.as_str().and_then(|reference| {
|
||||
parse_blob_ref(reference).or_else(|| parse_managed_blob_file_ref(reference))
|
||||
});
|
||||
let Some(blob_id) = blob_id else {
|
||||
let Some(blob_hash) = blob_hash else {
|
||||
return Ok(value);
|
||||
};
|
||||
|
||||
let bytes = read_required_blob(&blob_id, run_store).await?;
|
||||
let bytes = read_required_blob(&blob_hash, run_store).await?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|err| Error::engine_with_source("artifact blob was not valid JSON", err))
|
||||
}
|
||||
|
|
@ -267,14 +276,14 @@ pub async fn resolve_text_or_blob_ref_str(
|
|||
current: &str,
|
||||
run_store: &RunStoreHandle,
|
||||
) -> Result<String> {
|
||||
let Some(blob_id) = parse_blob_ref(current) else {
|
||||
let Some(blob_hash) = parse_blob_ref(current) else {
|
||||
return Ok(current.to_string());
|
||||
};
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.read_blob(&blob_hash)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_anyhow("text blob read failed", e))?
|
||||
.ok_or_else(|| Error::engine(format!("text blob missing: {blob_id}")))?;
|
||||
.ok_or_else(|| Error::engine(format!("text blob missing: {blob_hash}")))?;
|
||||
serde_json::from_slice::<String>(&bytes)
|
||||
.map_err(|e| Error::engine_with_source("text blob was not a JSON string", e))
|
||||
}
|
||||
|
|
@ -334,8 +343,8 @@ pub async fn sync_artifacts_to_env(
|
|||
fn normalize_durable_value(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(current) => {
|
||||
if let Some(blob_id) = parse_managed_blob_file_ref(current) {
|
||||
*current = format_blob_ref(&blob_id);
|
||||
if let Some(blob_hash) = parse_managed_blob_file_ref(current) {
|
||||
*current = format_blob_ref(&blob_hash);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
|
|
@ -357,10 +366,12 @@ fn resolve_execution_values<'a>(
|
|||
run_store: &'a RunStoreHandle,
|
||||
env: &'a dyn Sandbox,
|
||||
run_dir: &'a Path,
|
||||
locality: &'a mut SandboxLocality,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
for (key, value) in values.iter_mut() {
|
||||
resolve_execution_value(Some(key.as_str()), value, run_store, env, run_dir).await?;
|
||||
resolve_execution_value(Some(key.as_str()), value, run_store, env, run_dir, locality)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -376,14 +387,16 @@ fn resolve_execution_value<'a>(
|
|||
run_store: &'a RunStoreHandle,
|
||||
env: &'a dyn Sandbox,
|
||||
run_dir: &'a Path,
|
||||
locality: &'a mut SandboxLocality,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
match value {
|
||||
Value::String(current) => {
|
||||
if key.is_some_and(is_text_context_key) {
|
||||
*current = resolve_text_or_blob_ref_str(current, run_store).await?;
|
||||
} else if let Some(blob_id) = parse_blob_ref(current) {
|
||||
*current = materialize_blob_ref(&blob_id, run_store, env, run_dir).await?;
|
||||
} else if let Some(blob_hash) = parse_blob_ref(current) {
|
||||
*current =
|
||||
materialize_blob_ref(&blob_hash, run_store, env, run_dir, locality).await?;
|
||||
} else if current.starts_with(ARTIFACT_POINTER_PREFIX)
|
||||
&& parse_managed_blob_file_ref(current).is_none()
|
||||
{
|
||||
|
|
@ -392,7 +405,7 @@ fn resolve_execution_value<'a>(
|
|||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
resolve_execution_value(key, item, run_store, env, run_dir).await?;
|
||||
resolve_execution_value(key, item, run_store, env, run_dir, locality).await?;
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
|
|
@ -402,8 +415,15 @@ fn resolve_execution_value<'a>(
|
|||
} else {
|
||||
Some(child_key.as_str())
|
||||
};
|
||||
resolve_execution_value(child_context_key, item, run_store, env, run_dir)
|
||||
.await?;
|
||||
resolve_execution_value(
|
||||
child_context_key,
|
||||
item,
|
||||
run_store,
|
||||
env,
|
||||
run_dir,
|
||||
locality,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => {}
|
||||
|
|
@ -413,17 +433,18 @@ fn resolve_execution_value<'a>(
|
|||
}
|
||||
|
||||
async fn materialize_blob_ref(
|
||||
blob_id: &RunBlobId,
|
||||
blob_hash: &BlobHash,
|
||||
run_store: &RunStoreHandle,
|
||||
env: &dyn Sandbox,
|
||||
run_dir: &Path,
|
||||
locality: &mut SandboxLocality,
|
||||
) -> Result<String> {
|
||||
// Blobs are content-addressed, so an existing materialized file is always
|
||||
// current — check before paying for the store read.
|
||||
if is_local_execution(env, run_dir).await? {
|
||||
let path = local_materialized_blob_path(run_dir, blob_id);
|
||||
if locality.is_local(env, run_dir).await? {
|
||||
let path = local_materialized_blob_path(run_dir, blob_hash);
|
||||
if !path.exists() {
|
||||
let bytes = read_required_blob(blob_id, run_store).await?;
|
||||
let bytes = read_required_blob(blob_hash, run_store).await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await.map_err(|err| {
|
||||
Error::Io(format!(
|
||||
|
|
@ -439,13 +460,13 @@ async fn materialize_blob_ref(
|
|||
return Ok(format!("{ARTIFACT_POINTER_PREFIX}{}", path.display()));
|
||||
}
|
||||
|
||||
let remote_path = format!("{}/.fabro/blobs/{blob_id}.json", env.working_directory());
|
||||
let remote_path = format!("{}/.fabro/blobs/{blob_hash}.json", env.working_directory());
|
||||
if !env
|
||||
.file_exists(&remote_path)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("failed to check blob existence", e))?
|
||||
{
|
||||
let bytes = read_required_blob(blob_id, run_store).await?;
|
||||
let bytes = read_required_blob(blob_hash, run_store).await?;
|
||||
let content = String::from_utf8(bytes.to_vec())
|
||||
.map_err(|e| Error::engine_with_source("artifact blob was not valid UTF-8 JSON", e))?;
|
||||
env.write_file(&remote_path, &content).await.map_err(|e| {
|
||||
|
|
@ -457,14 +478,14 @@ async fn materialize_blob_ref(
|
|||
}
|
||||
|
||||
async fn read_required_blob(
|
||||
blob_id: &RunBlobId,
|
||||
blob_hash: &BlobHash,
|
||||
run_store: &RunStoreHandle,
|
||||
) -> Result<bytes::Bytes> {
|
||||
run_store
|
||||
.read_blob(blob_id)
|
||||
.read_blob(blob_hash)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_anyhow("artifact blob read failed", e))?
|
||||
.ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_id}")))
|
||||
.ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_hash}")))
|
||||
}
|
||||
|
||||
async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result<String> {
|
||||
|
|
@ -502,17 +523,33 @@ async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result<Str
|
|||
Ok(format!("{ARTIFACT_POINTER_PREFIX}{remote_path}"))
|
||||
}
|
||||
|
||||
async fn is_local_execution(env: &dyn Sandbox, run_dir: &Path) -> Result<bool> {
|
||||
env.file_exists(&run_dir.to_string_lossy())
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("failed to inspect sandbox locality", e))
|
||||
/// Memoized sandbox locality for one resolution pass. The sandbox and run
|
||||
/// directory are invariant across a pass, so the (possibly remote) probe is
|
||||
/// paid at most once instead of once per blob reference.
|
||||
#[derive(Default)]
|
||||
struct SandboxLocality {
|
||||
cached: Option<bool>,
|
||||
}
|
||||
|
||||
fn local_materialized_blob_path(run_dir: &Path, blob_id: &RunBlobId) -> PathBuf {
|
||||
impl SandboxLocality {
|
||||
async fn is_local(&mut self, env: &dyn Sandbox, run_dir: &Path) -> Result<bool> {
|
||||
if let Some(local) = self.cached {
|
||||
return Ok(local);
|
||||
}
|
||||
let local = env
|
||||
.file_exists(&run_dir.to_string_lossy())
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("failed to inspect sandbox locality", e))?;
|
||||
self.cached = Some(local);
|
||||
Ok(local)
|
||||
}
|
||||
}
|
||||
|
||||
fn local_materialized_blob_path(run_dir: &Path, blob_hash: &BlobHash) -> PathBuf {
|
||||
RunScratch::new(run_dir)
|
||||
.runtime_dir()
|
||||
.join("blobs")
|
||||
.join(format!("{blob_id}.json"))
|
||||
.join(format!("{blob_hash}.json"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -549,7 +586,7 @@ mod tests {
|
|||
|
||||
let large_string = "x".repeat(BLOB_OFFLOAD_THRESHOLD + 1);
|
||||
let serialized = serde_json::to_vec(&serde_json::json!(large_string.clone())).unwrap();
|
||||
let expected_blob_id = fabro_types::RunBlobId::new(&serialized);
|
||||
let expected_blob_hash = fabro_types::BlobHash::new(&serialized);
|
||||
|
||||
let mut updates = HashMap::new();
|
||||
updates.insert("response.plan".to_string(), serde_json::json!(large_string));
|
||||
|
|
@ -561,11 +598,11 @@ mod tests {
|
|||
let pointer = updates.get("response.plan").unwrap();
|
||||
assert_eq!(
|
||||
pointer,
|
||||
&serde_json::json!(fabro_types::format_blob_ref(&expected_blob_id))
|
||||
&serde_json::json!(fabro_types::format_blob_ref(&expected_blob_hash))
|
||||
);
|
||||
|
||||
let blob = run_store
|
||||
.read_blob(&expected_blob_id)
|
||||
.read_blob(&expected_blob_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("blob should exist");
|
||||
|
|
@ -591,21 +628,21 @@ mod tests {
|
|||
async fn resolve_json_value_hydrates_blob_and_managed_file_references() {
|
||||
let run_store = make_run_store("structured-json-resolution").await;
|
||||
let value = serde_json::json!([{"name": "api"}, {"name": "web"}]);
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&serde_json::to_vec(&value).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let handle = run_store.clone().into();
|
||||
|
||||
assert_eq!(
|
||||
resolve_json_value(serde_json::json!(format_blob_ref(&blob_id)), &handle)
|
||||
resolve_json_value(serde_json::json!(format_blob_ref(&blob_hash)), &handle)
|
||||
.await
|
||||
.unwrap(),
|
||||
value
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_json_value(
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_hash}.json")),
|
||||
&handle,
|
||||
)
|
||||
.await
|
||||
|
|
@ -636,7 +673,7 @@ mod tests {
|
|||
Value::String("small".to_string());
|
||||
BLOB_OFFLOAD_THRESHOLD / 4
|
||||
]);
|
||||
let expected_report_blob = RunBlobId::new(&serde_json::to_vec(&large_report).unwrap());
|
||||
let expected_report_blob = BlobHash::new(&serde_json::to_vec(&large_report).unwrap());
|
||||
let mut typed_results = vec![ParallelBranchResult {
|
||||
id: "branch_a".to_string(),
|
||||
index: Some(0),
|
||||
|
|
@ -787,15 +824,43 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_context_probes_sandbox_locality_once_per_pass() {
|
||||
let run_store = make_run_store("locality-probe-memoization").await;
|
||||
let first_blob = run_store
|
||||
.write_blob(&serde_json::to_vec(&serde_json::json!({"a": 1})).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let second_blob = run_store
|
||||
.write_blob(&serde_json::to_vec(&serde_json::json!({"b": 2})).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let context = Context::new();
|
||||
context.set("first", fabro_types::format_blob_ref(&first_blob).into());
|
||||
context.set("second", fabro_types::format_blob_ref(&second_blob).into());
|
||||
let env = TestSyncEnv::new(true, "/workspace");
|
||||
let run_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
resolved_context_snapshot(&context, &run_store.clone().into(), &env, run_dir.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
*env.exists_calls.lock().unwrap(),
|
||||
1,
|
||||
"sandbox locality should be probed once per resolution pass"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_durable_updates_rewrites_managed_blob_file_refs_recursively() {
|
||||
let blob_id = fabro_types::RunBlobId::new(b"hello");
|
||||
let blob_hash = fabro_types::BlobHash::new(b"hello");
|
||||
let mut updates = HashMap::from([(
|
||||
"nested".to_string(),
|
||||
serde_json::json!({
|
||||
"items": [
|
||||
format!("file:///tmp/run/runtime/blobs/{blob_id}.json"),
|
||||
format!("file:///sandbox/.fabro/blobs/{blob_id}.json"),
|
||||
format!("file:///tmp/run/runtime/blobs/{blob_hash}.json"),
|
||||
format!("file:///sandbox/.fabro/blobs/{blob_hash}.json"),
|
||||
"file:///tmp/report.json",
|
||||
]
|
||||
}),
|
||||
|
|
@ -807,8 +872,8 @@ mod tests {
|
|||
updates["nested"],
|
||||
serde_json::json!({
|
||||
"items": [
|
||||
fabro_types::format_blob_ref(&blob_id),
|
||||
fabro_types::format_blob_ref(&blob_id),
|
||||
fabro_types::format_blob_ref(&blob_hash),
|
||||
fabro_types::format_blob_ref(&blob_hash),
|
||||
"file:///tmp/report.json",
|
||||
]
|
||||
})
|
||||
|
|
@ -870,7 +935,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn normalize_checkpoint_for_resume_converts_managed_blob_file_refs_and_drops_preamble() {
|
||||
let blob_id = fabro_types::RunBlobId::new(b"managed");
|
||||
let blob_hash = fabro_types::BlobHash::new(b"managed");
|
||||
let mut checkpoint = crate::records::Checkpoint {
|
||||
timestamp: chrono::Utc::now(),
|
||||
current_node: "work".to_string(),
|
||||
|
|
@ -883,7 +948,7 @@ mod tests {
|
|||
),
|
||||
(
|
||||
"response.work".to_string(),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_hash}.json")),
|
||||
),
|
||||
]),
|
||||
node_outcomes: HashMap::from([(
|
||||
|
|
@ -891,7 +956,7 @@ mod tests {
|
|||
crate::outcome::Outcome {
|
||||
context_updates: HashMap::from([(
|
||||
"response.work".to_string(),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_hash}.json")),
|
||||
)]),
|
||||
..crate::outcome::Outcome::success()
|
||||
},
|
||||
|
|
@ -912,14 +977,14 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
checkpoint.context_values.get("response.work"),
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_id)))
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_hash)))
|
||||
);
|
||||
assert_eq!(
|
||||
checkpoint
|
||||
.node_outcomes
|
||||
.get("work")
|
||||
.and_then(|outcome| outcome.context_updates.get("response.work")),
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_id)))
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_hash)))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -928,9 +993,10 @@ mod tests {
|
|||
use std::sync::Mutex;
|
||||
|
||||
struct TestSyncEnv {
|
||||
accessible: bool,
|
||||
written: Mutex<Vec<(String, String)>>,
|
||||
working_dir: String,
|
||||
accessible: bool,
|
||||
written: Mutex<Vec<(String, String)>>,
|
||||
working_dir: String,
|
||||
exists_calls: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl TestSyncEnv {
|
||||
|
|
@ -939,6 +1005,7 @@ mod tests {
|
|||
accessible,
|
||||
written: Mutex::new(Vec::new()),
|
||||
working_dir: working_dir.to_string(),
|
||||
exists_calls: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -962,6 +1029,7 @@ mod tests {
|
|||
}
|
||||
|
||||
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
|
||||
*self.exists_calls.lock().unwrap() += 1;
|
||||
Ok(self.accessible)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,12 +126,10 @@ pub fn billing_rollup_from_projection(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fabro_model::{Catalog, ModelRef, ProviderId};
|
||||
use fabro_types::{
|
||||
AttrValue, BilledTokenCounts, Graph, Node, RunProjection, RunSpec, StageCompletion,
|
||||
StageOutcome, WorkflowSettings, first_event_seq, fixtures, test_support,
|
||||
StageOutcome, first_event_seq, test_support,
|
||||
};
|
||||
|
||||
use super::billing_rollup_from_projection;
|
||||
|
|
@ -311,19 +309,8 @@ mod tests {
|
|||
});
|
||||
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph,
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,14 +109,14 @@ pub async fn read_json_string_blob(
|
|||
run_store: &RunStoreHandle,
|
||||
blob_ref: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(blob_id) = fabro_types::parse_blob_ref(blob_ref) else {
|
||||
let Some(blob_hash) = fabro_types::parse_blob_ref(blob_ref) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.read_blob(&blob_hash)
|
||||
.await
|
||||
.map_err(|err| Error::engine_with_anyhow("command log blob read failed", err))?
|
||||
.ok_or_else(|| Error::engine(format!("command log blob missing: {blob_id}")))?;
|
||||
.ok_or_else(|| Error::engine(format!("command log blob missing: {blob_hash}")))?;
|
||||
let text = serde_json::from_slice::<String>(&bytes)
|
||||
.map_err(|err| Error::engine_with_source("command log blob was not a JSON string", err))?;
|
||||
Ok(Some(text))
|
||||
|
|
@ -155,9 +155,9 @@ async fn write_json_string_blob(run_store: &RunStoreHandle, text: &str) -> Resul
|
|||
let value = Value::String(text.to_string());
|
||||
let bytes = serde_json::to_vec(&value)
|
||||
.map_err(|err| Error::engine_with_source("command log JSON serialization failed", err))?;
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&bytes)
|
||||
.await
|
||||
.map_err(|err| Error::engine_with_anyhow("command log blob write failed", err))?;
|
||||
Ok(format_blob_ref(&blob_id))
|
||||
Ok(format_blob_ref(&blob_hash))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ const TRANSIENT_INFRA_HINTS: &[&str] = &[
|
|||
"cross-device link",
|
||||
"invalid cross-device link",
|
||||
"os error 18",
|
||||
"state change in progress",
|
||||
"sandbox stop still in progress",
|
||||
];
|
||||
|
||||
const BUDGET_EXHAUSTED_HINTS: &[&str] = &[
|
||||
|
|
@ -807,6 +809,18 @@ mod tests {
|
|||
assert_eq!(err.failure_category(), FailureCategory::TransientInfra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_error_with_sandbox_state_change_cause_classifies_transient() {
|
||||
let source = TestOuterError {
|
||||
message: "Failed to start Daytona sandbox",
|
||||
source: TestCause("Sandbox state change in progress"),
|
||||
};
|
||||
let err = Error::engine_with_source("Pipeline lifecycle operation failed", source);
|
||||
|
||||
assert_eq!(err.failure_category(), FailureCategory::TransientInfra);
|
||||
assert!(err.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_error_display() {
|
||||
let err = Error::handler("LLM call failed");
|
||||
|
|
@ -1281,7 +1295,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn transient_infra_hints_count() {
|
||||
assert_eq!(TRANSIENT_INFRA_HINTS.len(), 38);
|
||||
assert_eq!(TRANSIENT_INFRA_HINTS.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1450,6 +1464,25 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reason_sandbox_state_change_in_progress() {
|
||||
assert_eq!(
|
||||
classify_failure_reason(
|
||||
"Pipeline lifecycle operation failed: failed to activate sandbox after node \
|
||||
attempt survey: Failed to start Daytona sandbox: Sandbox state change in progress"
|
||||
),
|
||||
FailureCategory::TransientInfra
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reason_sandbox_stop_still_in_progress() {
|
||||
assert_eq!(
|
||||
classify_failure_reason("Daytona sandbox stop still in progress after 120s"),
|
||||
FailureCategory::TransientInfra
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reason_500() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use ::fabro_types::{
|
||||
AutomationRef, BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary,
|
||||
AutomationRef, BilledTokenCounts, BlobHash, BlockedReason, CommandTermination, DiffSummary,
|
||||
FailureReason, ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind,
|
||||
PairTarget, ParallelBranchId, ParallelBranchResult, PendingReason, PermissionLevel, Principal,
|
||||
PullRequestCreationId, PullRequestLink, ReviewTarget, RunBlobId, RunFailure, RunId,
|
||||
RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource,
|
||||
RunTiming, SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason,
|
||||
PullRequestCreationId, PullRequestLink, ReviewTarget, RunFailure, RunId, RunNoticeLevel,
|
||||
RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, RunTiming,
|
||||
SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason,
|
||||
run_event as fabro_types,
|
||||
};
|
||||
use fabro_agent::{AgentEvent, SandboxEvent};
|
||||
|
|
@ -39,7 +39,7 @@ pub enum Event {
|
|||
automation: Option<AutomationRef>,
|
||||
provenance: RunProvenance,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
manifest_blob: Option<RunBlobId>,
|
||||
manifest_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
git: Option<GitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -67,7 +67,7 @@ pub enum Event {
|
|||
},
|
||||
RunSubmitted {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
definition_blob: Option<RunBlobId>,
|
||||
definition_blob: Option<BlobHash>,
|
||||
},
|
||||
RunStartRequested {
|
||||
resume: bool,
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ mod tests {
|
|||
|
||||
#[derive(Default)]
|
||||
struct MemoryRunStoreBackend {
|
||||
blobs: Mutex<std::collections::HashMap<fabro_types::RunBlobId, Bytes>>,
|
||||
blobs: Mutex<std::collections::HashMap<fabro_types::BlobHash, Bytes>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -389,17 +389,20 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> anyhow::Result<fabro_types::RunBlobId> {
|
||||
let blob_id = fabro_types::RunBlobId::new(data);
|
||||
async fn write_blob(&self, data: &[u8]) -> anyhow::Result<fabro_types::BlobHash> {
|
||||
let blob_hash = fabro_types::BlobHash::new(data);
|
||||
self.blobs
|
||||
.lock()
|
||||
.await
|
||||
.insert(blob_id, Bytes::copy_from_slice(data));
|
||||
Ok(blob_id)
|
||||
.insert(blob_hash, Bytes::copy_from_slice(data));
|
||||
Ok(blob_hash)
|
||||
}
|
||||
|
||||
async fn read_blob(&self, id: &fabro_types::RunBlobId) -> anyhow::Result<Option<Bytes>> {
|
||||
Ok(self.blobs.lock().await.get(id).cloned())
|
||||
async fn read_blob(
|
||||
&self,
|
||||
blob_hash: &fabro_types::BlobHash,
|
||||
) -> anyhow::Result<Option<Bytes>> {
|
||||
Ok(self.blobs.lock().await.get(blob_hash).cloned())
|
||||
}
|
||||
|
||||
async fn read_run_log(&self) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use std::time::Duration;
|
|||
use async_trait::async_trait;
|
||||
use fabro_graphviz::graph::{AttrValue, Graph, Node};
|
||||
use fabro_store::{ArtifactStore, Database};
|
||||
use fabro_template::validate_static_reference;
|
||||
use fabro_types::WorkflowSettings;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use object_store::memory::InMemory;
|
||||
use tokio::fs;
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
|
@ -20,7 +22,6 @@ use crate::operations::{ValidateInput, WorkflowInput, validate_with_catalog};
|
|||
use crate::outcome::{Outcome, OutcomeExt, StageOutcome};
|
||||
use crate::pipeline::types::Initialized;
|
||||
use crate::run_options::RunOptions;
|
||||
use crate::static_reference::{ReferenceKind, validate_static_reference};
|
||||
use crate::{ManifestPath, pipeline, stage_scope};
|
||||
|
||||
/// Orchestrates a child workflow engine, polling for completion or stop
|
||||
|
|
|
|||
|
|
@ -1847,7 +1847,7 @@ mod tests {
|
|||
Some(serde_json::json!({"not": "an array"})),
|
||||
Some(serde_json::json!("ordinary string")),
|
||||
Some(serde_json::json!(format_blob_ref(
|
||||
&fabro_types::RunBlobId::new(b"missing")
|
||||
&fabro_types::BlobHash::new(b"missing")
|
||||
))),
|
||||
] {
|
||||
let (handler, calls) = ScriptedHandler::new(Scripted::Succeed);
|
||||
|
|
@ -1917,7 +1917,7 @@ mod tests {
|
|||
"name": "large-item",
|
||||
"body": "x".repeat(101 * 1024)
|
||||
}]);
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&serde_json::to_vec(&items).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1933,7 +1933,7 @@ mod tests {
|
|||
)));
|
||||
let (node, graph) = for_each_graph("items", 1);
|
||||
let context = test_context();
|
||||
context.set("items", serde_json::json!(format_blob_ref(&blob_id)));
|
||||
context.set("items", serde_json::json!(format_blob_ref(&blob_hash)));
|
||||
|
||||
let outcome = ParallelHandler
|
||||
.execute(&node, &context, &graph, sandbox_dir.path(), &services)
|
||||
|
|
|
|||
|
|
@ -332,7 +332,6 @@ pub(crate) mod sandbox_git_runtime;
|
|||
pub mod services;
|
||||
pub(crate) mod stage_execution;
|
||||
mod stage_scope;
|
||||
pub mod static_reference;
|
||||
pub mod steering_hub;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
|
|
|
|||
|
|
@ -613,7 +613,7 @@ mod tests {
|
|||
use fabro_model::Catalog;
|
||||
use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection};
|
||||
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
|
||||
use fabro_types::{EventBody, RunBlobId, RunEvent, WorkflowSettings, fixtures, test_support};
|
||||
use fabro_types::{BlobHash, EventBody, RunEvent, WorkflowSettings, fixtures, test_support};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -1324,11 +1324,11 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
Ok(RunBlobId::new(data))
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
Ok(BlobHash::new(data))
|
||||
}
|
||||
|
||||
async fn read_blob(&self, _id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
async fn read_blob(&self, _blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,8 +117,8 @@ mod tests {
|
|||
|
||||
use fabro_store::{Database, RunProjectionReducer};
|
||||
use fabro_types::{
|
||||
AuthMethod, DirtyStatus, FailureReason, ForkSourceRef, GitContext, Graph, IdpIdentity,
|
||||
Principal, PullRequestLink, RunBlobId, RunRunnableSource, RunServerProvenance, RunTiming,
|
||||
AuthMethod, BlobHash, DirtyStatus, FailureReason, ForkSourceRef, GitContext, Graph,
|
||||
IdpIdentity, Principal, PullRequestLink, RunRunnableSource, RunServerProvenance, RunTiming,
|
||||
WorkflowSettings, fixtures,
|
||||
};
|
||||
use object_store::memory::InMemory;
|
||||
|
|
@ -164,7 +164,7 @@ mod tests {
|
|||
async fn append_created(
|
||||
store: &fabro_store::RunDatabase,
|
||||
run_id: RunId,
|
||||
manifest_blob: Option<RunBlobId>,
|
||||
manifest_blob: Option<BlobHash>,
|
||||
fork_source_ref: Option<ForkSourceRef>,
|
||||
) {
|
||||
let mut settings = WorkflowSettings::default();
|
||||
|
|
@ -248,7 +248,7 @@ mod tests {
|
|||
async fn seed_retryable_failed_source(
|
||||
store: &Database,
|
||||
source_run_id: RunId,
|
||||
) -> (Option<RunBlobId>, Option<RunBlobId>, ForkSourceRef) {
|
||||
) -> (Option<BlobHash>, Option<BlobHash>, ForkSourceRef) {
|
||||
let source_store = store.create_run(&source_run_id).await.unwrap();
|
||||
let manifest_blob = Some(
|
||||
source_store
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result<
|
|||
|
||||
/// Resolve the `run.goal` override for a direct (non-manifest) workflow
|
||||
/// run. Reads the file from disk if the goal layer is the `file` variant.
|
||||
/// Relative paths that survived config load (e.g. env-interpolated ones)
|
||||
/// are anchored at `working_directory`.
|
||||
/// Relative paths that survived config load are anchored at
|
||||
/// `working_directory`.
|
||||
fn resolve_goal_override(
|
||||
settings: &WorkflowSettings,
|
||||
working_directory: &Path,
|
||||
|
|
|
|||
|
|
@ -359,8 +359,8 @@ impl RunSession {
|
|||
let git = git_checkpoint_options_from_start(settings, &record.run_id, state.start);
|
||||
let definition_blob = state.spec.definition_blob;
|
||||
let accepted_definition = match definition_blob {
|
||||
Some(blob_id) => {
|
||||
Some(load_accepted_run_definition(&services.run_store, blob_id).await?)
|
||||
Some(blob_hash) => {
|
||||
Some(load_accepted_run_definition(&services.run_store, blob_hash).await?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
|
@ -570,15 +570,15 @@ fn vault_token_lookup(vault: &Vault, name: &str) -> Option<String> {
|
|||
|
||||
async fn load_accepted_run_definition(
|
||||
run_store: &RunStoreHandle,
|
||||
blob_id: fabro_types::RunBlobId,
|
||||
blob_hash: fabro_types::BlobHash,
|
||||
) -> Result<RunDefinition, Error> {
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.read_blob(&blob_hash)
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
Error::engine(format!(
|
||||
"run definition blob is missing from the run store: {blob_id}"
|
||||
"run definition blob is missing from the run store: {blob_hash}"
|
||||
))
|
||||
})?;
|
||||
serde_json::from_slice(&bytes).map_err(|err| Error::Parse(err.to_string()))
|
||||
|
|
|
|||
|
|
@ -751,11 +751,11 @@ impl HandlerTrait for BlobCommandOutputHandler {
|
|||
services: &crate::handler::EngineServices,
|
||||
) -> std::result::Result<Outcome, Error> {
|
||||
let blob = serde_json::to_vec("routed-ok").unwrap();
|
||||
let blob_id = services.run.run_store.write_blob(&blob).await.unwrap();
|
||||
let blob_hash = services.run.run_store.write_blob(&blob).await.unwrap();
|
||||
let mut outcome = Outcome::success();
|
||||
outcome.context_updates.insert(
|
||||
context::keys::COMMAND_OUTPUT.to_string(),
|
||||
serde_json::json!(format_blob_ref(&blob_id)),
|
||||
serde_json::json!(format_blob_ref(&blob_hash)),
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -684,7 +684,7 @@ mod tests {
|
|||
use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection};
|
||||
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
|
||||
use fabro_types::{
|
||||
BilledTokenCounts, EventBody, RunBlobId, RunEvent, RunId, RunSpec, StageCompletion,
|
||||
BilledTokenCounts, BlobHash, EventBody, RunEvent, RunId, RunSpec, StageCompletion,
|
||||
WorkflowSettings, first_event_seq, fixtures, test_support,
|
||||
};
|
||||
use object_store::memory::InMemory;
|
||||
|
|
@ -1819,11 +1819,11 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
Ok(RunBlobId::new(data))
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
Ok(BlobHash::new(data))
|
||||
}
|
||||
|
||||
async fn read_blob(&self, _id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
async fn read_blob(&self, _blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -445,13 +445,11 @@ fn run_id_matches(run_id: RunId, prefix: &str) -> bool {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_graphviz::graph::Graph;
|
||||
use fabro_store::Database;
|
||||
use fabro_types::{RunStatus, WorkflowSettings, fixtures, test_support};
|
||||
use fabro_types::{RunStatus, fixtures, test_support};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::scan_runs_combined;
|
||||
|
|
@ -470,24 +468,15 @@ mod tests {
|
|||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
automation: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
git: Some(fabro_types::GitContext {
|
||||
git: Some(fabro_types::GitContext {
|
||||
origin_url: String::new(),
|
||||
branch: "main".to_string(),
|
||||
sha: None,
|
||||
dirty: fabro_types::DirtyStatus::Clean,
|
||||
}),
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ pub struct LifecycleOptions {
|
|||
}
|
||||
|
||||
/// A single setup (prepare) command and the per-step environment it runs with.
|
||||
/// Both the command string and the env values are already fully resolved (their
|
||||
/// `{{ env.* }}` tokens replaced at the run boundary) by the time they reach
|
||||
/// Both the command string and the env values are already fully resolved
|
||||
/// (interpolation tokens replaced at the run boundary) by the time they reach
|
||||
/// the sandbox.
|
||||
pub struct SetupCommand {
|
||||
pub command: String,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use anyhow::Result;
|
|||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use fabro_store::{EventEnvelope, RunDatabase, RunProjection};
|
||||
use fabro_types::{RunBlobId, RunEvent};
|
||||
use fabro_types::{BlobHash, RunEvent};
|
||||
|
||||
use crate::event::build_redacted_event_payload;
|
||||
|
||||
|
|
@ -13,8 +13,8 @@ pub trait RunStoreBackend: Send + Sync {
|
|||
async fn load_state(&self) -> Result<RunProjection>;
|
||||
async fn list_events(&self) -> Result<Vec<EventEnvelope>>;
|
||||
async fn append_run_event(&self, event: &RunEvent) -> Result<()>;
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId>;
|
||||
async fn read_blob(&self, id: &RunBlobId) -> Result<Option<Bytes>>;
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash>;
|
||||
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>>;
|
||||
async fn read_run_log(&self) -> Result<Option<Vec<u8>>>;
|
||||
}
|
||||
|
||||
|
|
@ -46,12 +46,12 @@ impl RunStoreHandle {
|
|||
self.backend.append_run_event(event).await
|
||||
}
|
||||
|
||||
pub async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
pub async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
self.backend.write_blob(data).await
|
||||
}
|
||||
|
||||
pub async fn read_blob(&self, id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
self.backend.read_blob(id).await
|
||||
pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.backend.read_blob(blob_hash).await
|
||||
}
|
||||
|
||||
pub async fn read_run_log(&self) -> Result<Option<Vec<u8>>> {
|
||||
|
|
@ -91,16 +91,16 @@ impl RunStoreBackend for LocalRunStoreBackend {
|
|||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
self.run_store
|
||||
.write_blob(data)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
async fn read_blob(&self, id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.run_store
|
||||
.read_blob(id)
|
||||
.read_blob(blob_hash)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
|
@ -112,15 +112,13 @@ impl RunStoreBackend for LocalRunStoreBackend {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use fabro_graphviz::graph::Graph;
|
||||
use fabro_store::Database;
|
||||
use fabro_types::run_event::RunSubmittedProps;
|
||||
use fabro_types::{EventBody, RunEvent, WorkflowSettings, fixtures, test_support};
|
||||
use fabro_types::{EventBody, RunEvent, fixtures, test_support};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::RunStoreHandle;
|
||||
|
|
@ -139,19 +137,9 @@ mod tests {
|
|||
|
||||
fn test_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
automation: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
source_directory: Some("/tmp/test".to_string()),
|
||||
git: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,8 +205,8 @@ mod tests {
|
|||
};
|
||||
handle.append_run_event(&event).await.unwrap();
|
||||
|
||||
let blob_id = handle.write_blob(br#"{"ok":true}"#).await.unwrap();
|
||||
let blob = handle.read_blob(&blob_id).await.unwrap().unwrap();
|
||||
let blob_hash = handle.write_blob(br#"{"ok":true}"#).await.unwrap();
|
||||
let blob = handle.read_blob(&blob_hash).await.unwrap().unwrap();
|
||||
let events = handle.list_events().await.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 2);
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
use std::fmt;
|
||||
|
||||
use fabro_template::contains_template_syntax;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ReferenceKind {
|
||||
FileInline,
|
||||
Import,
|
||||
ChildWorkflow,
|
||||
Dockerfile,
|
||||
GraphGoalFile,
|
||||
}
|
||||
|
||||
impl fmt::Display for ReferenceKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let label = match self {
|
||||
Self::FileInline => "file inline reference",
|
||||
Self::Import => "import reference",
|
||||
Self::ChildWorkflow => "child workflow reference",
|
||||
Self::Dockerfile => "Dockerfile reference",
|
||||
Self::GraphGoalFile => "graph goal file reference",
|
||||
};
|
||||
f.write_str(label)
|
||||
}
|
||||
}
|
||||
|
||||
impl ReferenceKind {
|
||||
pub fn validate(self, value: &str) -> Result<(), StaticReferenceError> {
|
||||
validate_static_reference(value, self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AttributeScope {
|
||||
Graph,
|
||||
Node,
|
||||
Edge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("templates are not supported in {kind}s: {value}")]
|
||||
pub struct StaticReferenceError {
|
||||
kind: ReferenceKind,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl StaticReferenceError {
|
||||
#[must_use]
|
||||
pub fn new(kind: ReferenceKind, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> ReferenceKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_static_reference(
|
||||
value: &str,
|
||||
kind: ReferenceKind,
|
||||
) -> Result<(), StaticReferenceError> {
|
||||
if contains_template_syntax(value) {
|
||||
return Err(StaticReferenceError::new(kind, value));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn reference_kind_for_attribute(
|
||||
scope: AttributeScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Option<ReferenceKind> {
|
||||
match key {
|
||||
"import" => Some(ReferenceKind::Import),
|
||||
"stack.child_workflow" | "stack.child_dotfile" => Some(ReferenceKind::ChildWorkflow),
|
||||
"goal" if matches!(scope, AttributeScope::Graph) && value.starts_with('@') => {
|
||||
Some(ReferenceKind::GraphGoalFile)
|
||||
}
|
||||
"prompt" | "output_schema"
|
||||
if matches!(scope, AttributeScope::Node) && value.starts_with('@') =>
|
||||
{
|
||||
Some(ReferenceKind::FileInline)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn output_schema_at_value_is_file_inline_reference() {
|
||||
assert_eq!(
|
||||
reference_kind_for_attribute(
|
||||
AttributeScope::Node,
|
||||
"output_schema",
|
||||
"@schemas/result.schema.json",
|
||||
),
|
||||
Some(ReferenceKind::FileInline),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_builtin_keyword_is_not_file_inline_reference() {
|
||||
assert_eq!(
|
||||
reference_kind_for_attribute(AttributeScope::Node, "output_schema", "routing"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_reference_rejects_template_syntax() {
|
||||
let error = reference_kind_for_attribute(
|
||||
AttributeScope::Node,
|
||||
"output_schema",
|
||||
"@schemas/{{ inputs.schema }}.json",
|
||||
)
|
||||
.expect("output_schema @ references should be static references")
|
||||
.validate("@schemas/{{ inputs.schema }}.json")
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.kind(), ReferenceKind::FileInline);
|
||||
assert_eq!(error.value(), "@schemas/{{ inputs.schema }}.json");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("templates are not supported in file inline references"),
|
||||
"unexpected error: {error}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,14 +4,14 @@ use std::sync::Arc;
|
|||
|
||||
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::TemplateContext;
|
||||
use fabro_template::{TemplateContext, validate_static_reference};
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_validate::Diagnostic;
|
||||
|
||||
use super::file_inlining::template_render_store;
|
||||
use super::{FileInliningTransform, Transform};
|
||||
use crate::error::Error;
|
||||
use crate::file_resolver::{FileResolver, ResolvedFile};
|
||||
use crate::static_reference::{ReferenceKind, validate_static_reference};
|
||||
use crate::transforms::variable_expansion::{
|
||||
RenderMode, TemplateRenderTarget, TemplateTransform, render_template_for_target,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@
|
|||
//! [`super::file_inlining`], where the `FileResolver` and current-dir context
|
||||
//! live.
|
||||
|
||||
use fabro_template::validate_static_reference;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::static_reference::{ReferenceKind, validate_static_reference};
|
||||
|
||||
/// A field value that is either inline content or an `@path` file import.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ use std::sync::Arc;
|
|||
use fabro_graphviz::graph::{AttrValue, Graph, Node};
|
||||
use fabro_template::{
|
||||
TemplateContext, TemplateError, TemplateRenderMode, TemplateSource, TemplateSourceOrigin,
|
||||
TemplateStore,
|
||||
TemplateStore, validate_static_reference,
|
||||
};
|
||||
use fabro_types::graph::{AttributeScope, ReferenceKind, reference_kind_for_attribute};
|
||||
use fabro_types::settings::interp::Namespace;
|
||||
use fabro_types::settings::{InterpString, ResolveCtx, ResolveError, ResolveErrorKind};
|
||||
use fabro_util::error::collect_chain;
|
||||
|
|
@ -17,9 +18,6 @@ use fabro_validate::{Diagnostic, Severity};
|
|||
use super::Transform;
|
||||
use crate::error::Error;
|
||||
use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE};
|
||||
use crate::static_reference::{
|
||||
AttributeScope, ReferenceKind, reference_kind_for_attribute, validate_static_reference,
|
||||
};
|
||||
|
||||
/// How the template-expansion pass should treat undefined input variables.
|
||||
///
|
||||
|
|
@ -521,7 +519,7 @@ impl TemplateTransform {
|
|||
continue;
|
||||
}
|
||||
if let Some(kind) = reference_kind_for_attribute(scope, attr_name, text) {
|
||||
validate_static_reference(text, kind)
|
||||
validate_static_reference(text, kind.into())
|
||||
.map_err(|error| Error::Validation(error.to_string()))?;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
|||
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::{ArtifactKey, ArtifactStore, Database};
|
||||
use fabro_types::{RunId, StageId, WorkflowSettings};
|
||||
use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref};
|
||||
use fabro_util::shell;
|
||||
use fabro_workflow::artifact::sync_artifacts_to_env;
|
||||
use fabro_workflow::artifact;
|
||||
use fabro_workflow::context::Context;
|
||||
use fabro_workflow::error::Error;
|
||||
use fabro_workflow::event::Emitter;
|
||||
|
|
@ -39,6 +39,7 @@ use fabro_workflow::handler::{Handler, HandlerRegistry};
|
|||
use fabro_workflow::outcome::{Outcome, StageOutcome};
|
||||
use fabro_workflow::records::Checkpoint;
|
||||
use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions};
|
||||
use fabro_workflow::runtime_store::RunStoreHandle;
|
||||
use fabro_workflow::test_support::{WorkflowRunner, test_store_dir};
|
||||
use object_store::local::LocalFileSystem;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
|
@ -159,6 +160,25 @@ fn load_run_checkpoint(run_dir: &Path) -> Result<Checkpoint, Box<dyn std::error:
|
|||
.ok_or_else(|| "checkpoint should exist in run store".into())
|
||||
}
|
||||
|
||||
async fn resolve_checkpoint_text(
|
||||
run_dir: &Path,
|
||||
run_id: &RunId,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let Some(current) = value.as_str() else {
|
||||
return Ok(value.to_string());
|
||||
};
|
||||
if parse_blob_ref(current).is_none() {
|
||||
return Ok(current.to_string());
|
||||
}
|
||||
|
||||
let object_store = Arc::new(LocalFileSystem::new_with_prefix(test_store_dir(run_dir))?);
|
||||
let store = Database::new(object_store, "", std::time::Duration::from_millis(1), None);
|
||||
let run = store.open_run_reader(run_id).await?;
|
||||
let run_store = RunStoreHandle::from(run);
|
||||
Ok(artifact::resolve_text_or_blob_ref_str(current, &run_store).await?)
|
||||
}
|
||||
|
||||
async fn create_env() -> DaytonaSandbox {
|
||||
let creds = load_github_app_credentials();
|
||||
create_env_with_github_app(Some(creds)).await
|
||||
|
|
@ -419,7 +439,9 @@ async fn daytona_artifact_sync_uploads_and_rewrites_pointer() {
|
|||
|
||||
// Sync — the local file doesn't exist in the Daytona sandbox, so it should
|
||||
// upload
|
||||
sync_artifacts_to_env(&mut updates, &env).await.unwrap();
|
||||
artifact::sync_artifacts_to_env(&mut updates, &env)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Pointer should be rewritten to the Daytona working directory
|
||||
let new_pointer = updates["response.plan"].as_str().unwrap();
|
||||
|
|
@ -544,15 +566,18 @@ async fn daytona_pipeline_artifact_offload_and_sync() {
|
|||
.get("response.big_output")
|
||||
.expect("context should have response.big_output");
|
||||
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
||||
let expected_blob_id = fabro_types::RunBlobId::new(
|
||||
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
|
||||
.expect("large value should serialize"),
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_str,
|
||||
fabro_types::format_blob_ref(&expected_blob_id),
|
||||
assert!(
|
||||
parse_blob_ref(pointer_str).is_some(),
|
||||
"checkpoint should persist a blob ref"
|
||||
);
|
||||
let resolved = resolve_checkpoint_text(dir.path(), &run_options.run_id, pointer_value)
|
||||
.await
|
||||
.expect("offloaded value should resolve through the run store");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
"x".repeat(150 * 1024),
|
||||
"offloaded value should round-trip through the run store"
|
||||
);
|
||||
|
||||
env.cleanup().await.unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ use fabro_model::{Catalog, ProviderId};
|
|||
use fabro_store::{ArtifactKey, ArtifactStore, Database};
|
||||
use fabro_types::{EventBody, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref};
|
||||
use fabro_validate::{Severity, validate, validate_or_raise};
|
||||
use fabro_workflow::artifact;
|
||||
use fabro_workflow::context::Context;
|
||||
use fabro_workflow::error::{Error, FailureSignatureExt};
|
||||
use fabro_workflow::event::{Emitter, Event};
|
||||
|
|
@ -54,6 +55,7 @@ use fabro_workflow::model_fallback::ModelFallbackPolicy;
|
|||
use fabro_workflow::outcome::{Outcome, OutcomeExt, StageOutcome};
|
||||
use fabro_workflow::records::{Checkpoint, CheckpointExt};
|
||||
use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions};
|
||||
use fabro_workflow::runtime_store::RunStoreHandle;
|
||||
use fabro_workflow::test_support::{
|
||||
WorkflowRunner, collect_events, run_graph_with_hooks, test_store_dir,
|
||||
};
|
||||
|
|
@ -233,10 +235,11 @@ fn resolve_checkpoint_text(
|
|||
let Some(current) = value.as_str() else {
|
||||
return Ok(value.to_string());
|
||||
};
|
||||
let Some(blob_id) = parse_blob_ref(current) else {
|
||||
if parse_blob_ref(current).is_none() {
|
||||
return Ok(current.to_string());
|
||||
};
|
||||
}
|
||||
|
||||
let current = current.to_string();
|
||||
let run_dir = run_dir.to_path_buf();
|
||||
let (store_dir, uses_shared_store) = run_store_dir_and_mode(&run_dir)?;
|
||||
std::thread::spawn(
|
||||
|
|
@ -271,10 +274,8 @@ fn resolve_checkpoint_text(
|
|||
.id
|
||||
};
|
||||
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
||||
let bytes = runtime
|
||||
.block_on(run.read_blob(&blob_id))?
|
||||
.ok_or("checkpoint blob should exist")?;
|
||||
Ok(serde_json::from_slice::<String>(&bytes)?)
|
||||
let run_store = RunStoreHandle::from(run);
|
||||
Ok(runtime.block_on(artifact::resolve_text_or_blob_ref_str(¤t, &run_store))?)
|
||||
},
|
||||
)
|
||||
.join()
|
||||
|
|
@ -10059,15 +10060,17 @@ async fn large_context_values_are_offloaded_to_artifact_store() {
|
|||
.expect("context should have response.big_output");
|
||||
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
||||
|
||||
let expected_blob_id = fabro_types::RunBlobId::new(
|
||||
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
|
||||
.expect("large value should serialize"),
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_str,
|
||||
fabro_types::format_blob_ref(&expected_blob_id),
|
||||
assert!(
|
||||
parse_blob_ref(pointer_str).is_some(),
|
||||
"value should be a durable blob ref"
|
||||
);
|
||||
let resolved = resolve_checkpoint_text(dir.path(), pointer_value)
|
||||
.expect("offloaded value should resolve through the run store");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
"x".repeat(150 * 1024),
|
||||
"offloaded value should round-trip through the run store"
|
||||
);
|
||||
|
||||
// WorkflowRunCompleted artifact_count now tracks captured artifacts, not
|
||||
// offloaded values.
|
||||
|
|
@ -10258,15 +10261,17 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() {
|
|||
.get("response.big_output")
|
||||
.expect("context should have response.big_output");
|
||||
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
||||
let expected_blob_id = fabro_types::RunBlobId::new(
|
||||
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
|
||||
.expect("large value should serialize"),
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_str,
|
||||
fabro_types::format_blob_ref(&expected_blob_id),
|
||||
assert!(
|
||||
parse_blob_ref(pointer_str).is_some(),
|
||||
"checkpoint should persist a blob ref"
|
||||
);
|
||||
let resolved = resolve_checkpoint_text(dir.path(), pointer_value)
|
||||
.expect("offloaded value should resolve through the run store");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
"x".repeat(150 * 1024),
|
||||
"offloaded value should round-trip through the run store"
|
||||
);
|
||||
|
||||
let written = remote_env.written.lock().unwrap();
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -722,6 +722,10 @@ fn main() {
|
|||
("CompletionMessage", "fabro_types::Message", &[]),
|
||||
("CompletionMessageRole", "fabro_types::Role", &[]),
|
||||
("CompletionContentPart", "fabro_types::ContentPart", &[]),
|
||||
("WorkflowVersion", "fabro_types::WorkflowVersion", &[]),
|
||||
("WorkflowPath", "fabro_types::WorkflowPath", &[]),
|
||||
("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]),
|
||||
("BlobHash", "fabro_types::BlobHash", &[]),
|
||||
("CostSource", "fabro_model::CostSource", &[]),
|
||||
];
|
||||
for (name, path, impls) in replacements {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ pub mod types {
|
|||
pub use fabro_types::{
|
||||
ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource,
|
||||
AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary,
|
||||
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts,
|
||||
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
|
||||
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
|
||||
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
|
||||
FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,
|
||||
|
|
@ -73,7 +73,8 @@ pub mod types {
|
|||
StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection,
|
||||
SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus,
|
||||
SystemIntegrationsResponse, TodoListProjection, TurnId, UpdateVariableRequest,
|
||||
UserPrincipal, Variable, VariableListResponse, WorkflowSettings,
|
||||
UserPrincipal, Variable, VariableListResponse, WorkflowPath, WorkflowSettings,
|
||||
WorkflowVersion, WorkflowVersionId,
|
||||
};
|
||||
|
||||
pub use crate::generated::types::*;
|
||||
|
|
|
|||
61
lib/foundation/fabro-api/tests/blob_hash_round_trip.rs
Normal file
61
lib/foundation/fabro-api/tests/blob_hash_round_trip.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::{BlobHash as ApiBlobHash, WriteBlobResponse};
|
||||
use fabro_types::BlobHash;
|
||||
use serde_json::json;
|
||||
|
||||
const BLOB_HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
|
||||
#[test]
|
||||
fn blob_hash_schema_reuses_domain_type() {
|
||||
assert_same_type::<ApiBlobHash, BlobHash>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_blob_response_round_trips_exact_wire_shape() {
|
||||
let value = json!({ "hash": BLOB_HASH });
|
||||
|
||||
let response: WriteBlobResponse = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&response).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_hash_accepts_any_case_and_emits_lowercase() {
|
||||
for input in [
|
||||
BLOB_HASH.to_string(),
|
||||
BLOB_HASH.to_uppercase(),
|
||||
alternating_hex_case(BLOB_HASH),
|
||||
] {
|
||||
let hash: ApiBlobHash = serde_json::from_value(json!(input)).unwrap();
|
||||
assert_eq!(serde_json::to_value(hash).unwrap(), json!(BLOB_HASH));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_hash_rejects_non_hex_values() {
|
||||
assert!(serde_json::from_value::<ApiBlobHash>(json!("not-a-blob-hash")).is_err());
|
||||
}
|
||||
|
||||
fn assert_same_type<Api: 'static, Domain: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<Api>(),
|
||||
TypeId::of::<Domain>(),
|
||||
"{} must be the domain type {}",
|
||||
type_name::<Api>(),
|
||||
type_name::<Domain>()
|
||||
);
|
||||
}
|
||||
|
||||
fn alternating_hex_case(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, character)| {
|
||||
if index % 2 == 0 {
|
||||
character.to_ascii_uppercase()
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::RunProjection as ApiRunProjection;
|
||||
use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings, test_support};
|
||||
use fabro_types::{RunProjection, RunSpec, test_support};
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn run_projection_reuses_canonical_type() {
|
||||
|
|
@ -129,19 +129,8 @@ fn run_projection_round_trips_with_pending_control_unset() {
|
|||
|
||||
fn run_spec_json() -> serde_json::Value {
|
||||
serde_json::to_value(RunSpec {
|
||||
run_id: fabro_types::fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: Some("digraph test {}".to_string()),
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: std::collections::HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
graph_source: Some("digraph test {}".to_string()),
|
||||
..test_support::test_run_spec()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::{
|
||||
CreateWorkflowVersionResponse, WorkflowPath as ApiWorkflowPath,
|
||||
WorkflowVersion as ApiWorkflowVersion, WorkflowVersionId as ApiWorkflowVersionId,
|
||||
};
|
||||
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId};
|
||||
use serde_json::json;
|
||||
|
||||
const DEPENDENCY_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
#[test]
|
||||
fn workflow_version_schemas_reuse_domain_types() {
|
||||
assert_same_type::<ApiWorkflowPath, WorkflowPath>();
|
||||
assert_same_type::<ApiWorkflowVersionId, WorkflowVersionId>();
|
||||
assert_same_type::<ApiWorkflowVersion, WorkflowVersion>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_round_trips_exact_wire_shape() {
|
||||
let value = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"prompts/goal.md": "Ship it",
|
||||
"workflow.fabro": "digraph W { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }"
|
||||
},
|
||||
"workflow_dependencies": { "children/check.fabro": DEPENDENCY_ID }
|
||||
});
|
||||
|
||||
let version: ApiWorkflowVersion = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(version).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_workflow_version_response_round_trips_exact_wire_shape() {
|
||||
let value = json!({ "workflow_version_id": DEPENDENCY_ID });
|
||||
|
||||
let response: CreateWorkflowVersionResponse = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&response).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_id_accepts_any_case_and_emits_lowercase() {
|
||||
let id = serde_json::from_value::<ApiWorkflowVersionId>(json!(DEPENDENCY_ID.to_uppercase()))
|
||||
.unwrap();
|
||||
let emitted = serde_json::to_value(id).unwrap();
|
||||
assert_eq!(emitted, json!(DEPENDENCY_ID));
|
||||
|
||||
let text = emitted.as_str().unwrap();
|
||||
assert_eq!(text.len(), 64);
|
||||
assert!(
|
||||
text.bytes()
|
||||
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_replacement_rejects_unknown_fields() {
|
||||
let value = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": "digraph W {}"
|
||||
},
|
||||
"workflow_dependencies": {},
|
||||
"metadata": {}
|
||||
});
|
||||
|
||||
assert!(serde_json::from_value::<ApiWorkflowVersion>(value).is_err());
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} and {} should be the same type",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -13,8 +13,8 @@ use fabro_http::multipart::{Form, Part};
|
|||
use fabro_model::{Model, ModelTestMode, ProviderId};
|
||||
use fabro_types::settings::run::MergeStrategy;
|
||||
use fabro_types::{
|
||||
ArtifactUpload, EventEnvelope, PairId, PairMessageRecord, PairMessageRequest, PairRecord,
|
||||
PairStartRequest, PairTranscriptResponse, Run, RunBlobId, RunEvent, RunEventDetailResponse,
|
||||
ArtifactUpload, BlobHash, EventEnvelope, PairId, PairMessageRecord, PairMessageRequest,
|
||||
PairRecord, PairStartRequest, PairTranscriptResponse, Run, RunEvent, RunEventDetailResponse,
|
||||
RunId, RunPairStatusResponse, RunProjection, SessionId, SessionRecord, StageId,
|
||||
};
|
||||
use fabro_util::exit::{ErrorExt, ExitClass};
|
||||
|
|
@ -1828,7 +1828,7 @@ impl Client {
|
|||
u32::try_from(response.into_inner().seq).context("append_run_event returned invalid seq")
|
||||
}
|
||||
|
||||
pub async fn write_run_blob(&self, run_id: &RunId, data: &[u8]) -> Result<RunBlobId> {
|
||||
pub async fn write_run_blob(&self, run_id: &RunId, data: &[u8]) -> Result<BlobHash> {
|
||||
let response = self
|
||||
.send_api(|client| async move {
|
||||
client
|
||||
|
|
@ -1839,24 +1839,20 @@ impl Client {
|
|||
.await
|
||||
})
|
||||
.await?;
|
||||
response
|
||||
.into_inner()
|
||||
.id
|
||||
.parse()
|
||||
.context("write_run_blob returned invalid blob id")
|
||||
Ok(response.into_inner().hash)
|
||||
}
|
||||
|
||||
pub async fn read_run_blob(
|
||||
&self,
|
||||
run_id: &RunId,
|
||||
blob_id: &RunBlobId,
|
||||
blob_hash: &BlobHash,
|
||||
) -> Result<Option<Bytes>> {
|
||||
let response = self
|
||||
.current_state()
|
||||
.client
|
||||
.read_run_blob()
|
||||
.id(run_id.to_string())
|
||||
.blob_id(blob_id.to_string())
|
||||
.blob_hash(*blob_hash)
|
||||
.send()
|
||||
.await;
|
||||
match response {
|
||||
|
|
|
|||
|
|
@ -116,10 +116,8 @@ impl Combine for RunIntegrationsGithubLayer {
|
|||
///
|
||||
/// Relative paths inside the `file` variant are resolved against the
|
||||
/// directory of the config file that declared them at load time (see
|
||||
/// `fabro_config::resolve_goal_file_paths`). `{{ env.NAME }}` interpolation is
|
||||
/// supported inside the `file` path; env-tokenized relative paths stay
|
||||
/// unresolved until consume time and are then resolved against the run's
|
||||
/// effective working directory.
|
||||
/// `fabro_config::resolve_goal_file_paths`). Interpolation tokens are not
|
||||
/// supported inside the `file` path; a tokenized path fails to resolve.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged, deny_unknown_fields)]
|
||||
pub enum RunGoalLayer {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use std::str::FromStr;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::cli::CliLayer;
|
||||
use super::environment::EnvironmentLayer;
|
||||
use super::environment::{EnvironmentImageLayer, EnvironmentLayer};
|
||||
use super::llm::LlmLayer;
|
||||
use super::maps::MergeMap;
|
||||
use super::project::ProjectLayer;
|
||||
|
|
@ -102,6 +102,40 @@ impl From<WorkflowLayer> for SettingsLayer {
|
|||
}
|
||||
}
|
||||
|
||||
impl SettingsLayer {
|
||||
/// Every environment image a settings layer can carry: the image of each
|
||||
/// named `[environments.*]` entry plus the `[run.environment]` image.
|
||||
///
|
||||
/// This is the single definition of "where images live in a settings
|
||||
/// layer". The dockerfile walkers (run compilation, manifest bundling,
|
||||
/// workflow-version validation) all iterate through here so a new
|
||||
/// image-bearing location only needs to be added once.
|
||||
pub fn environment_images(&self) -> impl Iterator<Item = &EnvironmentImageLayer> {
|
||||
self.environments
|
||||
.values()
|
||||
.filter_map(|environment| environment.image.as_ref())
|
||||
.chain(
|
||||
self.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.environment.as_ref())
|
||||
.and_then(|environment| environment.image.as_ref()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Mutable variant of [`Self::environment_images`].
|
||||
pub fn environment_images_mut(&mut self) -> impl Iterator<Item = &mut EnvironmentImageLayer> {
|
||||
self.environments
|
||||
.values_mut()
|
||||
.filter_map(|environment| environment.image.as_mut())
|
||||
.chain(
|
||||
self.run
|
||||
.as_mut()
|
||||
.and_then(|run| run.environment.as_mut())
|
||||
.and_then(|environment| environment.image.as_mut()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl SettingsLayer {
|
||||
/// A default layer that resolves cleanly: populates `server.auth.methods`
|
||||
|
|
|
|||
7
lib/foundation/fabro-db/migrations/2026081301_blobs.sql
Normal file
7
lib/foundation/fabro-db/migrations/2026081301_blobs.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE blobs (
|
||||
hash TEXT PRIMARY KEY NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
CHECK (length(hash) = 64),
|
||||
CHECK (hash = lower(hash)),
|
||||
CHECK (hash NOT GLOB '*[^0-9a-f]*')
|
||||
);
|
||||
|
|
@ -73,6 +73,13 @@ async fn connect_creates_parent_directory_and_migrate_is_idempotent() -> anyhow:
|
|||
.await?;
|
||||
assert_eq!(runs_table_count, 1);
|
||||
|
||||
let blobs_table_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'blobs'",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
assert_eq!(blobs_table_count, 1);
|
||||
|
||||
let legacy_import_table_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'legacy_imports'",
|
||||
)
|
||||
|
|
@ -89,6 +96,93 @@ async fn connect_creates_parent_directory_and_migrate_is_idempotent() -> anyhow:
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blobs_schema_enforces_canonical_hashes_and_required_data() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let database = fabro_db::Database::connect(dir.path().join("fabro.sqlite3")).await?;
|
||||
database.migrate().await?;
|
||||
|
||||
let columns = sqlx::query("PRAGMA table_info(blobs)")
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
assert_eq!(columns.len(), 2);
|
||||
|
||||
assert_eq!(columns[0].get::<String, _>("name"), "hash");
|
||||
assert_eq!(columns[0].get::<String, _>("type"), "TEXT");
|
||||
assert_eq!(columns[0].get::<i64, _>("notnull"), 1);
|
||||
assert_eq!(columns[0].get::<i64, _>("pk"), 1);
|
||||
assert_eq!(columns[0].get::<Option<String>, _>("dflt_value"), None);
|
||||
|
||||
assert_eq!(columns[1].get::<String, _>("name"), "data");
|
||||
assert_eq!(columns[1].get::<String, _>("type"), "BLOB");
|
||||
assert_eq!(columns[1].get::<i64, _>("notnull"), 1);
|
||||
assert_eq!(columns[1].get::<i64, _>("pk"), 0);
|
||||
assert_eq!(columns[1].get::<Option<String>, _>("dflt_value"), None);
|
||||
|
||||
let binary_hash = "0".repeat(64);
|
||||
let binary_data = vec![0, 0xff, 0x80, b'a'];
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(&binary_hash)
|
||||
.bind(&binary_data)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
let stored_binary: Vec<u8> = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
|
||||
.bind(&binary_hash)
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
assert_eq!(stored_binary, binary_data);
|
||||
|
||||
let empty_hash = "1".repeat(64);
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(&empty_hash)
|
||||
.bind(Vec::<u8>::new())
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
let stored_empty: Vec<u8> = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
|
||||
.bind(&empty_hash)
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
assert!(stored_empty.is_empty());
|
||||
|
||||
for invalid_hash in [
|
||||
"a".repeat(63),
|
||||
"a".repeat(65),
|
||||
"A".repeat(64),
|
||||
"g".repeat(64),
|
||||
] {
|
||||
let result = sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(&invalid_hash)
|
||||
.bind(Vec::<u8>::new())
|
||||
.execute(database.pool())
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"invalid blob hash should be rejected: {invalid_hash:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let null_hash = sqlx::query("INSERT INTO blobs (hash, data) VALUES (NULL, ?)")
|
||||
.bind(Vec::<u8>::new())
|
||||
.execute(database.pool())
|
||||
.await;
|
||||
assert!(null_hash.is_err());
|
||||
|
||||
let null_data = sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, NULL)")
|
||||
.bind("2".repeat(64))
|
||||
.execute(database.pool())
|
||||
.await;
|
||||
assert!(null_data.is_err());
|
||||
|
||||
let duplicate_hash = sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(&binary_hash)
|
||||
.bind(vec![1_u8])
|
||||
.execute(database.pool())
|
||||
.await;
|
||||
assert!(duplicate_hash.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_servers_schema_rejects_invalid_transport_rows() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ pub struct ProviderCatalogSettings {
|
|||
pub api_key_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
/// Unresolved interpolation source strings (literal text, `{{ env.NAME }}`,
|
||||
/// or `{{ secrets.NAME }}` tokens), resolved at the credential boundary in
|
||||
/// Unresolved interpolation source strings (literal text or
|
||||
/// `{{ secrets.NAME }}` tokens), resolved at the credential boundary in
|
||||
/// `fabro-auth`.
|
||||
#[serde(default)]
|
||||
pub extra_headers: Option<HashMap<String, String>>,
|
||||
|
|
@ -438,8 +438,8 @@ pub struct CatalogProvider {
|
|||
pub billing_policy: BillingPolicy,
|
||||
pub api_key_url: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
/// Unresolved interpolation source strings (literal text, `{{ env.NAME }}`,
|
||||
/// or `{{ secrets.NAME }}` tokens), resolved at the credential boundary in
|
||||
/// Unresolved interpolation source strings (literal text or
|
||||
/// `{{ secrets.NAME }}` tokens), resolved at the credential boundary in
|
||||
/// `fabro-auth`.
|
||||
pub extra_headers: HashMap<String, String>,
|
||||
pub priority: i32,
|
||||
|
|
|
|||
|
|
@ -32,10 +32,18 @@ pub struct ExtractedTemplateDependencies {
|
|||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TemplateDiscoveryError {
|
||||
#[error(transparent)]
|
||||
Parse(#[from] TemplateError),
|
||||
#[error(transparent)]
|
||||
Load(#[from] TemplateLoadError),
|
||||
#[error("invalid template `{parent}`")]
|
||||
Parse {
|
||||
parent: ManifestPath,
|
||||
#[source]
|
||||
source: Box<TemplateError>,
|
||||
},
|
||||
#[error("failed to load a template dependency of `{parent}`")]
|
||||
Load {
|
||||
parent: ManifestPath,
|
||||
#[source]
|
||||
source: TemplateLoadError,
|
||||
},
|
||||
#[error("missing template dependency `{reference}` from `{parent}`")]
|
||||
Missing {
|
||||
parent: ManifestPath,
|
||||
|
|
@ -45,6 +53,19 @@ pub enum TemplateDiscoveryError {
|
|||
Dynamic { parent: ManifestPath },
|
||||
}
|
||||
|
||||
impl TemplateDiscoveryError {
|
||||
/// Path of the template source this error is attributed to.
|
||||
#[must_use]
|
||||
pub fn source_path(&self) -> &ManifestPath {
|
||||
match self {
|
||||
Self::Parse { parent, .. }
|
||||
| Self::Load { parent, .. }
|
||||
| Self::Missing { parent, .. }
|
||||
| Self::Dynamic { parent } => parent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TemplateDependencyClosure {
|
||||
pub sources: HashMap<ManifestPath, TemplateSource>,
|
||||
|
|
@ -80,38 +101,55 @@ pub fn discover_static_dependency_closure(
|
|||
store: &dyn TemplateStore,
|
||||
) -> Result<TemplateDependencyClosure, TemplateDiscoveryError> {
|
||||
let mut sources = HashMap::new();
|
||||
// A root and a loaded file can collide on `path` while carrying different
|
||||
// content (an inline prompt is anchored at its graph file's path), so
|
||||
// traversal dedup keys on the full occurrence rather than the path: a
|
||||
// path-keyed check would leave the collided occurrence unparsed. The
|
||||
// result map stays path-keyed, with the last distinct occurrence winning.
|
||||
let mut parsed = HashSet::new();
|
||||
let mut queue = VecDeque::new();
|
||||
|
||||
for source in roots {
|
||||
if sources
|
||||
.insert(source.path.clone(), source.clone())
|
||||
.is_none()
|
||||
{
|
||||
let mut enqueue = |source: TemplateSource,
|
||||
sources: &mut HashMap<ManifestPath, TemplateSource>,
|
||||
queue: &mut VecDeque<TemplateSource>| {
|
||||
let occurrence = (
|
||||
source.path.clone(),
|
||||
source.root.clone(),
|
||||
source.content.clone(),
|
||||
);
|
||||
if parsed.insert(occurrence) {
|
||||
sources.insert(source.path.clone(), source.clone());
|
||||
queue.push_back(source);
|
||||
}
|
||||
};
|
||||
|
||||
for source in roots {
|
||||
enqueue(source, &mut sources, &mut queue);
|
||||
}
|
||||
|
||||
while let Some(source) = queue.pop_front() {
|
||||
let dependencies =
|
||||
extract_template_dependencies(&source.path.to_string(), &source.content)?;
|
||||
let dependencies = extract_template_dependencies(&source.path.to_string(), &source.content)
|
||||
.map_err(|error| TemplateDiscoveryError::Parse {
|
||||
parent: source.path.clone(),
|
||||
source: Box::new(error),
|
||||
})?;
|
||||
if !dependencies.dynamic_references.is_empty() {
|
||||
return Err(TemplateDiscoveryError::Dynamic {
|
||||
parent: source.path,
|
||||
});
|
||||
}
|
||||
for dependency in dependencies.static_references {
|
||||
let loaded = store.load(&source, &dependency.reference)?.ok_or_else(|| {
|
||||
TemplateDiscoveryError::Missing {
|
||||
let loaded = store
|
||||
.load(&source, &dependency.reference)
|
||||
.map_err(|error| TemplateDiscoveryError::Load {
|
||||
parent: source.path.clone(),
|
||||
source: error,
|
||||
})?
|
||||
.ok_or_else(|| TemplateDiscoveryError::Missing {
|
||||
parent: source.path.clone(),
|
||||
reference: dependency.reference.clone(),
|
||||
}
|
||||
})?;
|
||||
if sources
|
||||
.insert(loaded.path.clone(), loaded.clone())
|
||||
.is_none()
|
||||
{
|
||||
queue.push_back(loaded);
|
||||
}
|
||||
})?;
|
||||
enqueue(loaded, &mut sources, &mut queue);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use minijinja::value::{Object, Value};
|
|||
use minijinja::{AutoEscape, Environment, ErrorKind, UndefinedBehavior};
|
||||
|
||||
mod dependency;
|
||||
mod static_reference;
|
||||
mod store;
|
||||
|
||||
pub use dependency::{
|
||||
|
|
@ -15,6 +16,10 @@ pub use dependency::{
|
|||
TemplateDependencyKind, TemplateDiscoveryError, discover_static_dependency_closure,
|
||||
extract_template_dependencies,
|
||||
};
|
||||
pub use static_reference::{
|
||||
GraphReference, GraphReferenceError, StaticReferenceError, validate_static_reference,
|
||||
visit_graph_references,
|
||||
};
|
||||
pub use store::{
|
||||
BundleTemplateStore, CachedTemplateStore, FilesystemTemplateStore, RecordingTemplateStore,
|
||||
TemplateIncludeResolver, TemplateLoadError, TemplateSource, TemplateSourceOrigin,
|
||||
|
|
@ -1383,6 +1388,123 @@ mod tests {
|
|||
assert!(matches!(err, TemplateDiscoveryError::Dynamic { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_dependency_closure_visits_colliding_root_occurrences() {
|
||||
let roots = [
|
||||
TemplateSource::new(manifest_path("workflow.fabro"), manifest_path("."), "valid"),
|
||||
TemplateSource::new(
|
||||
manifest_path("workflow.fabro"),
|
||||
manifest_path("."),
|
||||
r"{% include inputs.partial %}",
|
||||
),
|
||||
];
|
||||
|
||||
let error =
|
||||
discover_static_dependency_closure(roots, bundle_store(&[]).as_ref()).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
TemplateDiscoveryError::Dynamic { parent }
|
||||
if parent == manifest_path("workflow.fabro")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_dependency_closure_parses_dependencies_shadowed_by_root_paths() {
|
||||
// An inline root anchored at its graph file's path must not shadow the
|
||||
// file itself when another template includes it: the loaded file
|
||||
// content still gets parsed.
|
||||
let roots = [
|
||||
TemplateSource::new(manifest_path("workflow.fabro"), manifest_path("."), "valid"),
|
||||
TemplateSource::new(
|
||||
manifest_path("goal.md"),
|
||||
manifest_path("."),
|
||||
r#"{% include "workflow.fabro" %}"#,
|
||||
),
|
||||
];
|
||||
|
||||
let error = discover_static_dependency_closure(
|
||||
roots,
|
||||
bundle_store(&[("workflow.fabro", r#"{% include "missing.md" %}"#)]).as_ref(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
TemplateDiscoveryError::Missing { parent, reference }
|
||||
if parent == manifest_path("workflow.fabro") && reference == "missing.md"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_dependency_closure_parses_identical_root_occurrences_once() {
|
||||
struct CountingStore {
|
||||
inner: Arc<dyn TemplateStore>,
|
||||
loads: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl TemplateStore for CountingStore {
|
||||
fn load(
|
||||
&self,
|
||||
parent: &TemplateSource,
|
||||
reference: &str,
|
||||
) -> Result<Option<TemplateSource>, TemplateLoadError> {
|
||||
self.loads
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.inner.load(parent, reference)
|
||||
}
|
||||
}
|
||||
|
||||
let root = TemplateSource::new(
|
||||
manifest_path("main.md"),
|
||||
manifest_path("."),
|
||||
r#"{% include "shared.md" %}"#,
|
||||
);
|
||||
let store = CountingStore {
|
||||
inner: bundle_store(&[("shared.md", "shared")]),
|
||||
loads: std::sync::atomic::AtomicUsize::new(0),
|
||||
};
|
||||
|
||||
let closure = discover_static_dependency_closure([root.clone(), root], &store).unwrap();
|
||||
|
||||
assert!(closure.sources.contains_key(&manifest_path("shared.md")));
|
||||
assert_eq!(store.loads.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_dependency_closure_deduplicates_loaded_dependencies_across_roots() {
|
||||
let roots = [
|
||||
TemplateSource::new(
|
||||
manifest_path("first.md"),
|
||||
manifest_path("."),
|
||||
r#"{% include "shared.md" %}"#,
|
||||
),
|
||||
TemplateSource::new(
|
||||
manifest_path("second.md"),
|
||||
manifest_path("."),
|
||||
r#"{% include "shared.md" %}"#,
|
||||
),
|
||||
];
|
||||
|
||||
let closure = discover_static_dependency_closure(
|
||||
roots,
|
||||
bundle_store(&[
|
||||
("shared.md", r#"{% include "nested.md" %}"#),
|
||||
("nested.md", "nested"),
|
||||
])
|
||||
.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
closure.paths(),
|
||||
["first.md", "second.md", "shared.md", "nested.md"]
|
||||
.into_iter()
|
||||
.map(manifest_path)
|
||||
.collect()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_lenient_named_preserves_source_name_for_syntax_errors() {
|
||||
let ctx = TemplateContext::new();
|
||||
|
|
|
|||
249
lib/foundation/fabro-template/src/static_reference.rs
Normal file
249
lib/foundation/fabro-template/src/static_reference.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
//! Static file references in workflow graphs.
|
||||
//!
|
||||
//! Workflow graphs name other files through a fixed attribute vocabulary
|
||||
//! (`import`, `stack.child_workflow`, `@`-prefixed `prompt`/`output_schema`
|
||||
//! values, and the graph `goal`). These references are *static*: they may not
|
||||
//! contain template syntax, because they are resolved before any template
|
||||
//! rendering happens.
|
||||
//!
|
||||
//! [`visit_graph_references`] is the one walker over that vocabulary. The
|
||||
//! manifest bundler and workflow-version validation both consume it, so a new
|
||||
//! reference-bearing attribute is added here once instead of drifting between
|
||||
//! per-crate walkers.
|
||||
|
||||
use fabro_types::graph::{
|
||||
AttributeScope, Graph, GraphReferenceKind, ReferenceKind, reference_kind_for_attribute,
|
||||
};
|
||||
|
||||
use crate::contains_template_syntax;
|
||||
|
||||
/// A static file reference that unexpectedly contains template syntax.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("templates are not supported in {kind}s: {value}")]
|
||||
pub struct StaticReferenceError {
|
||||
kind: ReferenceKind,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl StaticReferenceError {
|
||||
#[must_use]
|
||||
pub fn new(kind: ReferenceKind, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> ReferenceKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject static file references (imports, child workflows, `@` file values)
|
||||
/// that contain template syntax.
|
||||
pub fn validate_static_reference(
|
||||
value: &str,
|
||||
kind: ReferenceKind,
|
||||
) -> Result<(), StaticReferenceError> {
|
||||
if contains_template_syntax(value) {
|
||||
return Err(StaticReferenceError::new(kind, value));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One file reference or inline template discovered in a workflow graph.
|
||||
///
|
||||
/// `@` prefixes are already stripped from file references; inline variants
|
||||
/// carry template content that the consumer should feed to template-dependency
|
||||
/// discovery.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum GraphReference<'graph> {
|
||||
/// `graph [goal="@<reference>"]`.
|
||||
GoalFile { reference: &'graph str },
|
||||
/// A non-`@` graph `goal`: inline template content.
|
||||
GoalInline { content: &'graph str },
|
||||
/// `node [import="<reference>"]` — another graph file to walk.
|
||||
Import { reference: &'graph str },
|
||||
/// `node [stack.child_workflow="<reference>"]`.
|
||||
ChildWorkflow { reference: &'graph str },
|
||||
/// `node [<key>="@<reference>"]` for file-inlined attributes
|
||||
/// (`prompt`, `output_schema`).
|
||||
FileInline {
|
||||
key: &'graph str,
|
||||
reference: &'graph str,
|
||||
},
|
||||
/// A non-`@` node prompt: inline template content.
|
||||
InlinePrompt { content: &'graph str },
|
||||
}
|
||||
|
||||
/// Error from [`visit_graph_references`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GraphReferenceError<E> {
|
||||
#[error(transparent)]
|
||||
StaticReference(StaticReferenceError),
|
||||
#[error(transparent)]
|
||||
Visit(E),
|
||||
}
|
||||
|
||||
/// Walk every static file reference and inline template in one parsed graph,
|
||||
/// validating that file references are template-free before emitting them.
|
||||
///
|
||||
/// The walker covers a single graph; recursion into `Import` targets and
|
||||
/// resolution of references against a file source are the consumer's job.
|
||||
pub fn visit_graph_references<'graph, E>(
|
||||
graph: &'graph Graph,
|
||||
mut visit: impl FnMut(GraphReference<'graph>) -> Result<(), E>,
|
||||
) -> Result<(), GraphReferenceError<E>> {
|
||||
let goal = graph.goal();
|
||||
if !goal.is_empty() {
|
||||
if let Some(reference) = goal.strip_prefix('@') {
|
||||
validate_static_reference(reference, ReferenceKind::GraphGoalFile)
|
||||
.map_err(GraphReferenceError::StaticReference)?;
|
||||
visit(GraphReference::GoalFile { reference }).map_err(GraphReferenceError::Visit)?;
|
||||
} else {
|
||||
visit(GraphReference::GoalInline { content: goal })
|
||||
.map_err(GraphReferenceError::Visit)?;
|
||||
}
|
||||
}
|
||||
|
||||
for node in graph.nodes.values() {
|
||||
for (key, value) in &node.attrs {
|
||||
let Some(value) = value.as_str() else {
|
||||
continue;
|
||||
};
|
||||
let Some(kind) = reference_kind_for_attribute(AttributeScope::Node, key, value) else {
|
||||
continue;
|
||||
};
|
||||
let reference = match kind {
|
||||
GraphReferenceKind::Import | GraphReferenceKind::ChildWorkflow => value,
|
||||
// Classification only yields these kinds for `@` values.
|
||||
GraphReferenceKind::FileInline | GraphReferenceKind::GraphGoalFile => value
|
||||
.strip_prefix('@')
|
||||
.expect("file reference classification requires a leading '@'"),
|
||||
};
|
||||
validate_static_reference(reference, kind.into())
|
||||
.map_err(GraphReferenceError::StaticReference)?;
|
||||
let event = match kind {
|
||||
GraphReferenceKind::Import => GraphReference::Import { reference },
|
||||
GraphReferenceKind::ChildWorkflow => GraphReference::ChildWorkflow { reference },
|
||||
GraphReferenceKind::FileInline => GraphReference::FileInline { key, reference },
|
||||
GraphReferenceKind::GraphGoalFile => GraphReference::GoalFile { reference },
|
||||
};
|
||||
visit(event).map_err(GraphReferenceError::Visit)?;
|
||||
}
|
||||
|
||||
if let Some(prompt) = node.prompt().filter(|prompt| !prompt.starts_with('@')) {
|
||||
visit(GraphReference::InlinePrompt { content: prompt })
|
||||
.map_err(GraphReferenceError::Visit)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use fabro_types::graph::{AttrValue, Graph, Node, ReferenceKind};
|
||||
|
||||
use super::{GraphReference, GraphReferenceError, validate_static_reference};
|
||||
|
||||
#[test]
|
||||
fn static_reference_rejects_template_syntax() {
|
||||
let error = validate_static_reference(
|
||||
"@schemas/{{ inputs.schema }}.json",
|
||||
ReferenceKind::FileInline,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.kind(), ReferenceKind::FileInline);
|
||||
assert_eq!(error.value(), "@schemas/{{ inputs.schema }}.json");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("templates are not supported in file inline references"),
|
||||
"unexpected error: {error}",
|
||||
);
|
||||
assert!(
|
||||
validate_static_reference("@schemas/result.json", ReferenceKind::FileInline).is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
fn node_with(id: &str, attrs: &[(&str, &str)]) -> Node {
|
||||
let mut node = Node::new(id);
|
||||
for (key, value) in attrs {
|
||||
node.attrs
|
||||
.insert((*key).to_string(), AttrValue::String((*value).to_string()));
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visits_every_reference_kind_once() {
|
||||
let mut graph = Graph::new("test");
|
||||
graph.attrs.insert(
|
||||
"goal".to_string(),
|
||||
AttrValue::String("@goal.md".to_string()),
|
||||
);
|
||||
for node in [
|
||||
node_with("imported", &[("import", "graphs/child.fabro")]),
|
||||
node_with("child", &[("stack.child_workflow", "children/check.fabro")]),
|
||||
node_with("file_prompt", &[("prompt", "@prompts/task.md")]),
|
||||
node_with("inline", &[("prompt", "Do the {{ thing }}")]),
|
||||
] {
|
||||
graph.nodes.insert(node.id.clone(), node);
|
||||
}
|
||||
|
||||
let mut seen = BTreeSet::new();
|
||||
super::visit_graph_references(
|
||||
&graph,
|
||||
|reference| -> Result<(), std::convert::Infallible> {
|
||||
seen.insert(match reference {
|
||||
GraphReference::GoalFile { reference } => format!("goal-file:{reference}"),
|
||||
GraphReference::GoalInline { content } => format!("goal-inline:{content}"),
|
||||
GraphReference::Import { reference } => format!("import:{reference}"),
|
||||
GraphReference::ChildWorkflow { reference } => format!("child:{reference}"),
|
||||
GraphReference::FileInline { key, reference } => {
|
||||
format!("file:{key}:{reference}")
|
||||
}
|
||||
GraphReference::InlinePrompt { content } => format!("inline:{content}"),
|
||||
});
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
seen,
|
||||
BTreeSet::from([
|
||||
"goal-file:goal.md".to_string(),
|
||||
"import:graphs/child.fabro".to_string(),
|
||||
"child:children/check.fabro".to_string(),
|
||||
"file:prompt:prompts/task.md".to_string(),
|
||||
"inline:Do the {{ thing }}".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_template_syntax_in_references_before_visiting() {
|
||||
let mut graph = Graph::new("test");
|
||||
graph.nodes.insert(
|
||||
"imported".to_string(),
|
||||
node_with("imported", &[("import", "graphs/{{ name }}.fabro")]),
|
||||
);
|
||||
|
||||
let error =
|
||||
super::visit_graph_references(&graph, |_| -> Result<(), std::convert::Infallible> {
|
||||
panic!("references with template syntax must not be visited")
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, GraphReferenceError::StaticReference(_)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1955,14 +1955,12 @@ pub fn json_snapshot_filters(mut filters: Vec<(String, String)>) -> Vec<(String,
|
|||
r#""id": "[EVENT_ID]""#.to_string(),
|
||||
));
|
||||
filters = json_elapsed_ms_snapshot_filters(filters);
|
||||
filters.push((
|
||||
r#""manifest_blob":\s*"[0-9a-f]{64}""#.to_string(),
|
||||
r#""manifest_blob": "[BLOB_ID]""#.to_string(),
|
||||
));
|
||||
filters.push((
|
||||
r#""definition_blob":\s*"[0-9a-f]{64}""#.to_string(),
|
||||
r#""definition_blob": "[BLOB_ID]""#.to_string(),
|
||||
));
|
||||
for field in ["manifest_blob", "definition_blob"] {
|
||||
filters.push((
|
||||
format!(r#""{field}":\s*"[0-9a-f]{{64}}""#),
|
||||
format!(r#""{field}": "[BLOB_HASH]""#),
|
||||
));
|
||||
}
|
||||
filters.push((
|
||||
r#""run_dir":\s*"\[STORAGE_DIR\]/scratch/\d{8}-\[ULID\]""#.to_string(),
|
||||
r#""run_dir": "[RUN_DIR]""#.to_string(),
|
||||
|
|
@ -2562,8 +2560,8 @@ mod tests {
|
|||
"inference_time_ms": "[INFERENCE_TIME_MS]",
|
||||
"tool_time_ms": "[TOOL_TIME_MS]",
|
||||
"active_time_ms": "[ACTIVE_TIME_MS]",
|
||||
"manifest_blob": "[BLOB_ID]",
|
||||
"definition_blob": "[BLOB_ID]",
|
||||
"manifest_blob": "[BLOB_HASH]",
|
||||
"definition_blob": "[BLOB_HASH]",
|
||||
"run_dir": "[RUN_DIR]",
|
||||
"message": "[CUSTOM]"
|
||||
}"#
|
||||
|
|
|
|||
131
lib/foundation/fabro-types/src/blob_hash.rs
Normal file
131
lib/foundation/fabro-types/src/blob_hash.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use hex::FromHexError;
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// SHA-256 content identity.
|
||||
///
|
||||
/// Parsing accepts exactly 64 hexadecimal digits case-insensitively. Display
|
||||
/// and serialization emit the canonical lowercase form.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct BlobHash([u8; 32]);
|
||||
|
||||
impl BlobHash {
|
||||
pub fn new(content: &[u8]) -> Self {
|
||||
let hash = Sha256::digest(content);
|
||||
let mut bytes = [0_u8; 32];
|
||||
bytes.copy_from_slice(&hash);
|
||||
Self(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BlobHash {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&hex::encode(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for BlobHash {
|
||||
type Err = FromHexError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
hex::decode_to_slice(s, &mut bytes)?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for BlobHash {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for BlobHash {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::BlobHash;
|
||||
|
||||
#[test]
|
||||
fn same_content_produces_same_blob_hash() {
|
||||
assert_eq!(BlobHash::new(b"hello"), BlobHash::new(b"hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_is_lowercase_sha256_hex() {
|
||||
assert_eq!(
|
||||
BlobHash::new(b"hello").to_string(),
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_content_produces_different_blob_hashes() {
|
||||
assert_ne!(BlobHash::new(b"hello"), BlobHash::new(b"world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_any_case_and_display_normalizes_to_lowercase() {
|
||||
let blob_hash = BlobHash::new(b"hello");
|
||||
let lowercase = blob_hash.to_string();
|
||||
let uppercase = lowercase.to_uppercase();
|
||||
let mixed_case = alternating_hex_case(&lowercase);
|
||||
|
||||
for value in [&lowercase, &uppercase, &mixed_case] {
|
||||
let parsed: BlobHash = value.parse().unwrap();
|
||||
assert_eq!(parsed, blob_hash);
|
||||
assert_eq!(parsed.to_string(), lowercase);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let blob_hash = BlobHash::new(b"hello");
|
||||
let value = serde_json::to_value(blob_hash).unwrap();
|
||||
let parsed: BlobHash = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(parsed, blob_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_invalid_shapes() {
|
||||
for value in [
|
||||
String::new(),
|
||||
"0".repeat(63),
|
||||
"0".repeat(65),
|
||||
"g".repeat(64),
|
||||
format!("0x{}", "0".repeat(64)),
|
||||
format!(" {}", "0".repeat(64)),
|
||||
] {
|
||||
assert!(value.parse::<BlobHash>().is_err(), "accepted {value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn alternating_hex_case(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, character)| {
|
||||
if index % 2 == 0 {
|
||||
character.to_ascii_uppercase()
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,35 @@
|
|||
use std::path::Path;
|
||||
|
||||
use crate::RunBlobId;
|
||||
use crate::BlobHash;
|
||||
|
||||
const BLOB_REF_PREFIX: &str = "blob://sha256/";
|
||||
|
||||
#[must_use]
|
||||
pub fn format_blob_ref(blob_id: &RunBlobId) -> String {
|
||||
format!("{BLOB_REF_PREFIX}{blob_id}")
|
||||
pub fn format_blob_ref(blob_hash: &BlobHash) -> String {
|
||||
format!("{BLOB_REF_PREFIX}{blob_hash}")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse_blob_ref(value: &str) -> Option<RunBlobId> {
|
||||
pub fn parse_blob_ref(value: &str) -> Option<BlobHash> {
|
||||
value.strip_prefix(BLOB_REF_PREFIX)?.parse().ok()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse_managed_blob_file_ref(value: &str) -> Option<RunBlobId> {
|
||||
pub fn parse_managed_blob_file_ref(value: &str) -> Option<BlobHash> {
|
||||
let path = value.strip_prefix("file://")?;
|
||||
let blob_id = parse_blob_file_name(path)?;
|
||||
let blob_hash = parse_blob_file_name(path)?;
|
||||
|
||||
if has_path_suffix(path, &["runtime", "blobs"]) || has_path_suffix(path, &[".fabro", "blobs"]) {
|
||||
Some(blob_id)
|
||||
Some(blob_hash)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_blob_file_name(path: &str) -> Option<RunBlobId> {
|
||||
fn parse_blob_file_name(path: &str) -> Option<BlobHash> {
|
||||
let file_name = Path::new(path).file_name()?.to_str()?;
|
||||
let blob_id = file_name.strip_suffix(".json")?;
|
||||
blob_id.parse().ok()
|
||||
let blob_hash = file_name.strip_suffix(".json")?;
|
||||
blob_hash.parse().ok()
|
||||
}
|
||||
|
||||
fn has_path_suffix(path: &str, suffix: &[&str]) -> bool {
|
||||
|
|
@ -46,30 +46,30 @@ fn has_path_suffix(path: &str, suffix: &[&str]) -> bool {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref};
|
||||
use crate::RunBlobId;
|
||||
use crate::BlobHash;
|
||||
|
||||
#[test]
|
||||
fn blob_ref_round_trips() {
|
||||
let blob_id = RunBlobId::new(br#"{"kind":"summary"}"#);
|
||||
let formatted = format_blob_ref(&blob_id);
|
||||
let blob_hash = BlobHash::new(br#"{"kind":"summary"}"#);
|
||||
let formatted = format_blob_ref(&blob_hash);
|
||||
|
||||
assert_eq!(parse_blob_ref(&formatted), Some(blob_id));
|
||||
assert_eq!(parse_blob_ref(&formatted), Some(blob_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_local_blob_file_ref_is_recognized() {
|
||||
let blob_id = RunBlobId::new(b"hello");
|
||||
let value = format!("file:///tmp/run/runtime/blobs/{blob_id}.json");
|
||||
let blob_hash = BlobHash::new(b"hello");
|
||||
let value = format!("file:///tmp/run/runtime/blobs/{blob_hash}.json");
|
||||
|
||||
assert_eq!(parse_managed_blob_file_ref(&value), Some(blob_id));
|
||||
assert_eq!(parse_managed_blob_file_ref(&value), Some(blob_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_remote_blob_file_ref_is_recognized() {
|
||||
let blob_id = RunBlobId::new(b"hello");
|
||||
let value = format!("file:///sandbox/.fabro/blobs/{blob_id}.json");
|
||||
let blob_hash = BlobHash::new(b"hello");
|
||||
let value = format!("file:///sandbox/.fabro/blobs/{blob_hash}.json");
|
||||
|
||||
assert_eq!(parse_managed_blob_file_ref(&value), Some(blob_id));
|
||||
assert_eq!(parse_managed_blob_file_ref(&value), Some(blob_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -590,6 +590,78 @@ impl Graph {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where an attribute appears in a workflow graph.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AttributeScope {
|
||||
Graph,
|
||||
Node,
|
||||
Edge,
|
||||
}
|
||||
|
||||
/// Kinds of static (non-templated) workflow-owned file references.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, strum::Display)]
|
||||
pub enum ReferenceKind {
|
||||
#[strum(to_string = "file inline reference")]
|
||||
FileInline,
|
||||
#[strum(to_string = "import reference")]
|
||||
Import,
|
||||
#[strum(to_string = "child workflow reference")]
|
||||
ChildWorkflow,
|
||||
#[strum(to_string = "Dockerfile reference")]
|
||||
Dockerfile,
|
||||
#[strum(to_string = "graph goal file reference")]
|
||||
GraphGoalFile,
|
||||
#[strum(to_string = "run goal file reference")]
|
||||
RunGoalFile,
|
||||
}
|
||||
|
||||
/// Kinds of static file references that graph attributes can carry: the
|
||||
/// subset of [`ReferenceKind`] that [`reference_kind_for_attribute`] can
|
||||
/// classify. Config-sourced kinds (Dockerfiles, run goal files) are
|
||||
/// unrepresentable here by construction.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum GraphReferenceKind {
|
||||
FileInline,
|
||||
Import,
|
||||
ChildWorkflow,
|
||||
GraphGoalFile,
|
||||
}
|
||||
|
||||
impl From<GraphReferenceKind> for ReferenceKind {
|
||||
fn from(kind: GraphReferenceKind) -> Self {
|
||||
match kind {
|
||||
GraphReferenceKind::FileInline => Self::FileInline,
|
||||
GraphReferenceKind::Import => Self::Import,
|
||||
GraphReferenceKind::ChildWorkflow => Self::ChildWorkflow,
|
||||
GraphReferenceKind::GraphGoalFile => Self::GraphGoalFile,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a graph attribute as a static file reference, if it is one.
|
||||
#[must_use]
|
||||
pub fn reference_kind_for_attribute(
|
||||
scope: AttributeScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Option<GraphReferenceKind> {
|
||||
match key {
|
||||
"import" if matches!(scope, AttributeScope::Node) => Some(GraphReferenceKind::Import),
|
||||
"stack.child_workflow" if matches!(scope, AttributeScope::Node) => {
|
||||
Some(GraphReferenceKind::ChildWorkflow)
|
||||
}
|
||||
"goal" if matches!(scope, AttributeScope::Graph) && value.starts_with('@') => {
|
||||
Some(GraphReferenceKind::GraphGoalFile)
|
||||
}
|
||||
"prompt" | "output_schema"
|
||||
if matches!(scope, AttributeScope::Node) && value.starts_with('@') =>
|
||||
{
|
||||
Some(GraphReferenceKind::FileInline)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1111,4 +1183,24 @@ mod tests {
|
|||
);
|
||||
assert_eq!(g.loop_restart_signature_limit(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_at_value_is_file_inline_reference() {
|
||||
assert_eq!(
|
||||
reference_kind_for_attribute(
|
||||
AttributeScope::Node,
|
||||
"output_schema",
|
||||
"@schemas/result.schema.json",
|
||||
),
|
||||
Some(GraphReferenceKind::FileInline),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_builtin_keyword_is_not_file_inline_reference() {
|
||||
assert_eq!(
|
||||
reference_kind_for_attribute(AttributeScope::Node, "output_schema", "routing"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ extern crate self as fabro_types;
|
|||
pub mod artifact;
|
||||
pub mod auth;
|
||||
pub mod billing;
|
||||
pub mod blob_hash;
|
||||
pub mod blob_ref;
|
||||
pub mod checkpoint;
|
||||
pub mod command_output;
|
||||
|
|
@ -26,7 +27,6 @@ pub mod pull_request;
|
|||
pub mod reasoning;
|
||||
pub mod repository;
|
||||
pub mod run;
|
||||
pub mod run_blob_id;
|
||||
pub mod run_event;
|
||||
pub mod run_failure;
|
||||
pub mod run_id;
|
||||
|
|
@ -54,6 +54,9 @@ pub mod timing;
|
|||
pub mod todo;
|
||||
pub mod transcript;
|
||||
pub mod variable;
|
||||
pub mod workflow_path;
|
||||
pub mod workflow_version;
|
||||
pub mod workflow_version_id;
|
||||
|
||||
pub use artifact::ArtifactUpload;
|
||||
pub use auth::{IdpIdentity, IdpIdentityError};
|
||||
|
|
@ -63,6 +66,7 @@ pub use billing::{
|
|||
ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage,
|
||||
OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros,
|
||||
};
|
||||
pub use blob_hash::BlobHash;
|
||||
pub use blob_ref::{format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref};
|
||||
pub use checkpoint::Checkpoint;
|
||||
pub use command_output::{CommandOutputStream, CommandTermination};
|
||||
|
|
@ -113,7 +117,6 @@ pub use run::{
|
|||
DirtyStatus, ForkSourceRef, GitContext, RunClientProvenance, RunProvenance,
|
||||
RunServerProvenance, RunSpec,
|
||||
};
|
||||
pub use run_blob_id::RunBlobId;
|
||||
pub use run_event::{
|
||||
AgentMcpToolSummary, AgentMemoryFileProps, AgentSkillActivationSource, AgentSkillSummary,
|
||||
AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, EventBody,
|
||||
|
|
@ -183,3 +186,11 @@ pub use transcript::{
|
|||
pub use variable::{
|
||||
CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse, is_env_style_name,
|
||||
};
|
||||
pub use workflow_path::{
|
||||
MAX_WORKFLOW_PATH_BYTES, MAX_WORKFLOW_PATH_COMPONENTS, WorkflowPath, WorkflowPathParseError,
|
||||
};
|
||||
pub use workflow_version::{
|
||||
MAX_WORKFLOW_VERSION_BYTES, MAX_WORKFLOW_VERSION_DEPENDENCIES, MAX_WORKFLOW_VERSION_FILE_BYTES,
|
||||
MAX_WORKFLOW_VERSION_FILES, WorkflowVersion, WorkflowVersionShapeError,
|
||||
};
|
||||
pub use workflow_version_id::{WorkflowVersionId, WorkflowVersionIdParseError};
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ use std::collections::HashMap;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::WorkflowSettings;
|
||||
use crate::blob_hash::BlobHash;
|
||||
use crate::graph::Graph;
|
||||
use crate::principal::Principal;
|
||||
use crate::run_blob_id::RunBlobId;
|
||||
use crate::run_id::RunId;
|
||||
use crate::run_summary::AutomationRef;
|
||||
|
||||
|
|
@ -73,9 +73,9 @@ pub struct RunSpec {
|
|||
pub labels: HashMap<String, String>,
|
||||
pub provenance: RunProvenance,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manifest_blob: Option<RunBlobId>,
|
||||
pub manifest_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub definition_blob: Option<RunBlobId>,
|
||||
pub definition_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub git: Option<GitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use hex::FromHexError;
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct RunBlobId([u8; 32]);
|
||||
|
||||
impl RunBlobId {
|
||||
pub fn new(content: &[u8]) -> Self {
|
||||
let hash = Sha256::digest(content);
|
||||
let mut bytes = [0_u8; 32];
|
||||
bytes.copy_from_slice(&hash);
|
||||
Self(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RunBlobId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&hex::encode(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RunBlobId {
|
||||
type Err = FromHexError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
hex::decode_to_slice(s, &mut bytes)?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for RunBlobId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RunBlobId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::RunBlobId;
|
||||
|
||||
#[test]
|
||||
fn same_content_produces_same_blob_id() {
|
||||
assert_eq!(RunBlobId::new(b"hello"), RunBlobId::new(b"hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_is_lowercase_sha256_hex() {
|
||||
assert_eq!(
|
||||
RunBlobId::new(b"hello").to_string(),
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_content_produces_different_blob_ids() {
|
||||
assert_ne!(RunBlobId::new(b"hello"), RunBlobId::new(b"world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_parse_round_trip() {
|
||||
let blob_id = RunBlobId::new(b"hello");
|
||||
let parsed: RunBlobId = blob_id.to_string().parse().unwrap();
|
||||
assert_eq!(parsed, blob_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let blob_id = RunBlobId::new(b"hello");
|
||||
let value = serde_json::to_value(blob_id).unwrap();
|
||||
let parsed: RunBlobId = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(parsed, blob_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_non_hex_blob_ids() {
|
||||
let parsed = "not-a-blob-id".parse::<RunBlobId>();
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -937,7 +937,7 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
use crate::{
|
||||
AuthMethod, CommandTermination, Edge, Graph, IdpIdentity, Node, PendingReason, RunBlobId,
|
||||
AuthMethod, BlobHash, CommandTermination, Edge, Graph, IdpIdentity, Node, PendingReason,
|
||||
WorkflowSettings, fixtures, test_support,
|
||||
};
|
||||
|
||||
|
|
@ -1059,7 +1059,7 @@ mod tests {
|
|||
"labels": {},
|
||||
"source_directory": "/tmp/run",
|
||||
"provenance": test_support::test_run_provenance(),
|
||||
"manifest_blob": RunBlobId::new(br#"{"version":1}"#).to_string()
|
||||
"manifest_blob": BlobHash::new(br#"{"version":1}"#).to_string()
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1337,7 +1337,7 @@ mod tests {
|
|||
"run_id": fixtures::RUN_1,
|
||||
"event": "run.submitted",
|
||||
"properties": {
|
||||
"definition_blob": RunBlobId::new(br#"{"workflow_path":"workflow.fabro"}"#).to_string()
|
||||
"definition_blob": BlobHash::new(br#"{"workflow_path":"workflow.fabro"}"#).to_string()
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||
use super::{BilledTokenCounts, ExecOutputTail, RunNoticeLevel};
|
||||
use crate::status::{BlockedReason, PendingReason, SuccessReason};
|
||||
use crate::{
|
||||
AutomationRef, DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId,
|
||||
AutomationRef, BlobHash, DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget,
|
||||
RunControlAction, RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings,
|
||||
};
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ pub struct RunCreatedProps {
|
|||
pub automation: Option<AutomationRef>,
|
||||
pub provenance: RunProvenance,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manifest_blob: Option<RunBlobId>,
|
||||
pub manifest_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub git: Option<GitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -132,7 +132,7 @@ pub struct RunPairFailedProps {
|
|||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunSubmittedProps {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub definition_blob: Option<RunBlobId>,
|
||||
pub definition_blob: Option<BlobHash>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1061,11 +1061,9 @@ impl RunProjection {
|
|||
|
||||
#[cfg(test)]
|
||||
mod title_tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{AttrValue, Graph, RunId, RunProjection, RunSpec, WorkflowSettings, test_support};
|
||||
use crate::{AttrValue, Graph, RunProjection, RunSpec, test_support};
|
||||
|
||||
fn projection_with_goal(goal: Option<&str>) -> RunProjection {
|
||||
let mut graph = Graph::new("test");
|
||||
|
|
@ -1076,19 +1074,8 @@ mod title_tests {
|
|||
}
|
||||
|
||||
let spec = RunSpec {
|
||||
run_id: RunId::new(),
|
||||
settings: WorkflowSettings::default(),
|
||||
graph,
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
};
|
||||
RunProjection::new(String::new(), spec, Utc::now())
|
||||
}
|
||||
|
|
@ -1129,7 +1116,6 @@ mod title_tests {
|
|||
|
||||
#[cfg(test)]
|
||||
mod iter_stages_tests {
|
||||
use std::collections::HashMap;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use chrono::Utc;
|
||||
|
|
@ -1137,10 +1123,7 @@ mod iter_stages_tests {
|
|||
use serde_json::json;
|
||||
|
||||
use super::RunProjection;
|
||||
use crate::{
|
||||
AgentControlState, BilledTokenCounts, Graph, RunId, RunSpec, StageProjection,
|
||||
WorkflowSettings, test_support,
|
||||
};
|
||||
use crate::{AgentControlState, BilledTokenCounts, StageProjection, test_support};
|
||||
|
||||
fn seq(n: u32) -> NonZeroU32 {
|
||||
NonZeroU32::new(n).unwrap()
|
||||
|
|
@ -1149,21 +1132,7 @@ mod iter_stages_tests {
|
|||
fn projection() -> RunProjection {
|
||||
RunProjection::new(
|
||||
"Test run".to_string(),
|
||||
RunSpec {
|
||||
run_id: RunId::new(),
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::default(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
test_support::test_run_spec(),
|
||||
Utc::now(),
|
||||
)
|
||||
}
|
||||
|
|
@ -1336,14 +1305,12 @@ mod iter_stages_tests {
|
|||
|
||||
#[cfg(test)]
|
||||
mod live_timing_tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
use super::{RunProjection, StageToolBatchProjection};
|
||||
use crate::{
|
||||
Graph, ModelRef, RunId, RunSpec, StageHandler, StageInferenceProjection, StageProjection,
|
||||
StageState, StageTiming, StartRecord, WorkflowSettings, first_event_seq, test_support,
|
||||
ModelRef, StageHandler, StageInferenceProjection, StageProjection, StageState, StageTiming,
|
||||
StartRecord, first_event_seq, test_support,
|
||||
};
|
||||
|
||||
fn at(seconds: i64) -> DateTime<Utc> {
|
||||
|
|
@ -1351,25 +1318,7 @@ mod live_timing_tests {
|
|||
}
|
||||
|
||||
fn projection() -> RunProjection {
|
||||
RunProjection::new(
|
||||
"Test run".to_string(),
|
||||
RunSpec {
|
||||
run_id: RunId::new(),
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::default(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
at(0),
|
||||
)
|
||||
RunProjection::new("Test run".to_string(), test_support::test_run_spec(), at(0))
|
||||
}
|
||||
|
||||
/// In-flight stage that started at `at(0)`.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
use crate::{AuthMethod, IdpIdentity, Principal, RunProvenance};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
AuthMethod, Graph, IdpIdentity, Principal, RunProvenance, RunSpec, WorkflowSettings, fixtures,
|
||||
};
|
||||
|
||||
#[must_use]
|
||||
pub fn test_principal() -> Principal {
|
||||
|
|
@ -17,3 +21,35 @@ pub fn test_run_provenance() -> RunProvenance {
|
|||
subject: test_principal(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Neutral [`RunSpec`] for tests: a fixed run id, default settings, a minimal
|
||||
/// `test` graph, and every optional field unset.
|
||||
///
|
||||
/// Spread it so a test only spells out the fields it actually asserts on:
|
||||
///
|
||||
/// ```
|
||||
/// # use fabro_types::{RunSpec, test_support};
|
||||
/// let spec = RunSpec {
|
||||
/// workflow_slug: Some("release-flow".to_string()),
|
||||
/// ..test_support::test_run_spec()
|
||||
/// };
|
||||
/// # assert_eq!(spec.workflow_slug.as_deref(), Some("release-flow"));
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn test_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
280
lib/foundation/fabro-types/src/workflow_path.rs
Normal file
280
lib/foundation/fabro-types/src/workflow_path.rs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
pub const MAX_WORKFLOW_PATH_BYTES: usize = 240;
|
||||
pub const MAX_WORKFLOW_PATH_COMPONENTS: usize = 16;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
||||
#[error("invalid workflow path `{value}`: {reason}")]
|
||||
pub struct WorkflowPathParseError {
|
||||
value: String,
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
impl WorkflowPathParseError {
|
||||
fn new(value: &str, reason: &'static str) -> Self {
|
||||
Self {
|
||||
value: value.to_owned(),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn reason(&self) -> &'static str {
|
||||
self.reason
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(into = "String", try_from = "String")]
|
||||
pub struct WorkflowPath(String);
|
||||
|
||||
impl WorkflowPath {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, WorkflowPathParseError> {
|
||||
let value = value.into();
|
||||
validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn resolve_reference(&self, reference: &str) -> Result<Self, WorkflowPathParseError> {
|
||||
validate_reference_shape(reference)?;
|
||||
let mut components = self
|
||||
.0
|
||||
.rsplit_once('/')
|
||||
.map_or_else(Vec::new, |(parent, _)| {
|
||||
parent.split('/').collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
for component in reference.split('/') {
|
||||
match component {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
if components.pop().is_none() {
|
||||
return Err(WorkflowPathParseError::new(
|
||||
reference,
|
||||
"reference escapes the workflow root",
|
||||
));
|
||||
}
|
||||
}
|
||||
value => components.push(value),
|
||||
}
|
||||
}
|
||||
|
||||
Self::new(components.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for WorkflowPath {
|
||||
type Err = WorkflowPathParseError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for WorkflowPath {
|
||||
type Error = WorkflowPathParseError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkflowPath> for String {
|
||||
fn from(value: WorkflowPath) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WorkflowPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(value: &str) -> Result<(), WorkflowPathParseError> {
|
||||
validate_reference_shape(value)?;
|
||||
if value
|
||||
.split('/')
|
||||
.any(|component| matches!(component, "." | ".."))
|
||||
{
|
||||
return Err(WorkflowPathParseError::new(
|
||||
value,
|
||||
"dot segments are not allowed in stored paths",
|
||||
));
|
||||
}
|
||||
if value.split('/').count() > MAX_WORKFLOW_PATH_COMPONENTS {
|
||||
return Err(WorkflowPathParseError::new(
|
||||
value,
|
||||
"path has too many components",
|
||||
));
|
||||
}
|
||||
if value.len() > MAX_WORKFLOW_PATH_BYTES {
|
||||
return Err(WorkflowPathParseError::new(value, "path is too long"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_reference_shape(value: &str) -> Result<(), WorkflowPathParseError> {
|
||||
if value.is_empty() {
|
||||
return Err(WorkflowPathParseError::new(value, "path is empty"));
|
||||
}
|
||||
if value.starts_with('/') {
|
||||
return Err(WorkflowPathParseError::new(
|
||||
value,
|
||||
"absolute paths are not allowed",
|
||||
));
|
||||
}
|
||||
if value.starts_with('~') {
|
||||
return Err(WorkflowPathParseError::new(
|
||||
value,
|
||||
"tilde-prefixed paths are not allowed",
|
||||
));
|
||||
}
|
||||
if value.contains('\\') {
|
||||
return Err(WorkflowPathParseError::new(
|
||||
value,
|
||||
"backslashes are not allowed",
|
||||
));
|
||||
}
|
||||
if value.ends_with('/') {
|
||||
return Err(WorkflowPathParseError::new(
|
||||
value,
|
||||
"trailing slashes are not allowed",
|
||||
));
|
||||
}
|
||||
if value.contains("//") {
|
||||
return Err(WorkflowPathParseError::new(
|
||||
value,
|
||||
"repeated slashes are not allowed",
|
||||
));
|
||||
}
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
|
||||
return Err(WorkflowPathParseError::new(
|
||||
value,
|
||||
"Windows drive paths are not allowed",
|
||||
));
|
||||
}
|
||||
if value.bytes().any(|byte| byte.is_ascii_control()) {
|
||||
return Err(WorkflowPathParseError::new(
|
||||
value,
|
||||
"control characters are not allowed",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{MAX_WORKFLOW_PATH_BYTES, MAX_WORKFLOW_PATH_COMPONENTS, WorkflowPath};
|
||||
|
||||
#[test]
|
||||
fn accepts_canonical_portable_paths() {
|
||||
for value in ["workflow.fabro", "graphs/main.fabro", "prompts/日本語.md"] {
|
||||
let path: WorkflowPath = value.parse().expect("path should parse");
|
||||
assert_eq!(path.as_str(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_canonical_or_unsafe_paths() {
|
||||
for value in [
|
||||
"", "/root", "root/", "a//b", "a\\b", "~/a", "C:/a", ".", "..", "a/./b", "a/../b",
|
||||
"a\nb",
|
||||
] {
|
||||
assert!(value.parse::<WorkflowPath>().is_err(), "accepted {value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_byte_and_component_limits() {
|
||||
assert!(
|
||||
"a".repeat(MAX_WORKFLOW_PATH_BYTES)
|
||||
.parse::<WorkflowPath>()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
"a".repeat(MAX_WORKFLOW_PATH_BYTES + 1)
|
||||
.parse::<WorkflowPath>()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
vec!["a"; MAX_WORKFLOW_PATH_COMPONENTS]
|
||||
.join("/")
|
||||
.parse::<WorkflowPath>()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
vec!["a"; MAX_WORKFLOW_PATH_COMPONENTS + 1]
|
||||
.join("/")
|
||||
.parse::<WorkflowPath>()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_references_without_escaping_root() {
|
||||
let graph: WorkflowPath = "graphs/nested/main.fabro".parse().unwrap();
|
||||
assert_eq!(
|
||||
graph.resolve_reference("../prompts/plan.md").unwrap(),
|
||||
"graphs/prompts/plan.md".parse().unwrap()
|
||||
);
|
||||
assert!(graph.resolve_reference("../../../outside.md").is_err());
|
||||
assert!(graph.resolve_reference("prompts//plan.md").is_err());
|
||||
assert!(graph.resolve_reference("prompts/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_and_ordered_map_keys_preserve_canonical_text() {
|
||||
let paths = BTreeMap::from([
|
||||
("z/last.md".parse::<WorkflowPath>().unwrap(), 2),
|
||||
("a/first.md".parse::<WorkflowPath>().unwrap(), 1),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&paths).unwrap(),
|
||||
json!({"a/first.md": 1, "z/last.md": 2})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<BTreeMap<WorkflowPath, i32>>(json!({
|
||||
"a/first.md": 1,
|
||||
"z/last.md": 2
|
||||
}))
|
||||
.unwrap(),
|
||||
paths
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_limit_counts_utf8_bytes() {
|
||||
assert!(
|
||||
"é".repeat(MAX_WORKFLOW_PATH_BYTES / 2)
|
||||
.parse::<WorkflowPath>()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
"é".repeat(MAX_WORKFLOW_PATH_BYTES / 2 + 1)
|
||||
.parse::<WorkflowPath>()
|
||||
.is_err()
|
||||
);
|
||||
assert!("notes/\u{85}.md".parse::<WorkflowPath>().is_ok());
|
||||
}
|
||||
}
|
||||
495
lib/foundation/fabro-types/src/workflow_version.rs
Normal file
495
lib/foundation/fabro-types/src/workflow_version.rs
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use serde::de::{Error as _, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{WorkflowPath, WorkflowVersionId};
|
||||
|
||||
pub const MAX_WORKFLOW_VERSION_FILES: usize = 512;
|
||||
pub const MAX_WORKFLOW_VERSION_DEPENDENCIES: usize = 512;
|
||||
pub const MAX_WORKFLOW_VERSION_FILE_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_WORKFLOW_VERSION_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorkflowVersionShapeError {
|
||||
#[error("workflow version has {actual} files; maximum is {maximum}")]
|
||||
TooManyFiles { actual: usize, maximum: usize },
|
||||
#[error("workflow version has {actual} workflow dependencies; maximum is {maximum}")]
|
||||
TooManyWorkflowDependencies { actual: usize, maximum: usize },
|
||||
#[error("workflow file `{path}` is {actual} bytes; maximum is {maximum}")]
|
||||
FileTooLarge {
|
||||
path: WorkflowPath,
|
||||
actual: usize,
|
||||
maximum: usize,
|
||||
},
|
||||
#[error("workflow version is {actual} canonical bytes; maximum is {maximum}")]
|
||||
VersionTooLarge { actual: usize, maximum: usize },
|
||||
#[error("entrypoint `{path}` is not present in workflow files")]
|
||||
MissingEntrypoint { path: WorkflowPath },
|
||||
#[error("workflow paths collide: `{first}` and `{second}`")]
|
||||
PathCollision {
|
||||
first: WorkflowPath,
|
||||
second: WorkflowPath,
|
||||
},
|
||||
#[error("failed to serialize canonical workflow version")]
|
||||
Serialization {
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Canonical wire form of an immutable workflow version.
|
||||
///
|
||||
/// Construction (and therefore deserialization) enforces the structural
|
||||
/// invariants: file-count and byte-size limits, entrypoint presence, unique
|
||||
/// map keys, and collision-free paths. Semantic validation of graph, config,
|
||||
/// and template content is a separate concern owned by
|
||||
/// `fabro-workflow-version`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct WorkflowVersion {
|
||||
entrypoint: WorkflowPath,
|
||||
files: BTreeMap<WorkflowPath, String>,
|
||||
workflow_dependencies: BTreeMap<WorkflowPath, WorkflowVersionId>,
|
||||
}
|
||||
|
||||
impl WorkflowVersion {
|
||||
pub fn new(
|
||||
entrypoint: WorkflowPath,
|
||||
files: BTreeMap<WorkflowPath, String>,
|
||||
workflow_dependencies: BTreeMap<WorkflowPath, WorkflowVersionId>,
|
||||
) -> Result<Self, WorkflowVersionShapeError> {
|
||||
let version = Self {
|
||||
entrypoint,
|
||||
files,
|
||||
workflow_dependencies,
|
||||
};
|
||||
version.validate_shape()?;
|
||||
version.canonical_bytes()?;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn entrypoint(&self) -> &WorkflowPath {
|
||||
&self.entrypoint
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn files(&self) -> &BTreeMap<WorkflowPath, String> {
|
||||
&self.files
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflow_dependencies(&self) -> &BTreeMap<WorkflowPath, WorkflowVersionId> {
|
||||
&self.workflow_dependencies
|
||||
}
|
||||
|
||||
/// Serialize to the canonical wire form.
|
||||
///
|
||||
/// Structural validity is guaranteed by construction, so this only
|
||||
/// serializes and enforces the canonical size limit.
|
||||
pub fn canonical_bytes(&self) -> Result<Vec<u8>, WorkflowVersionShapeError> {
|
||||
let bytes = serde_json::to_vec(self)
|
||||
.map_err(|source| WorkflowVersionShapeError::Serialization { source })?;
|
||||
if bytes.len() > MAX_WORKFLOW_VERSION_BYTES {
|
||||
return Err(WorkflowVersionShapeError::VersionTooLarge {
|
||||
actual: bytes.len(),
|
||||
maximum: MAX_WORKFLOW_VERSION_BYTES,
|
||||
});
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn validate_shape(&self) -> Result<(), WorkflowVersionShapeError> {
|
||||
if self.files.len() > MAX_WORKFLOW_VERSION_FILES {
|
||||
return Err(WorkflowVersionShapeError::TooManyFiles {
|
||||
actual: self.files.len(),
|
||||
maximum: MAX_WORKFLOW_VERSION_FILES,
|
||||
});
|
||||
}
|
||||
if self.workflow_dependencies.len() > MAX_WORKFLOW_VERSION_DEPENDENCIES {
|
||||
return Err(WorkflowVersionShapeError::TooManyWorkflowDependencies {
|
||||
actual: self.workflow_dependencies.len(),
|
||||
maximum: MAX_WORKFLOW_VERSION_DEPENDENCIES,
|
||||
});
|
||||
}
|
||||
for (path, content) in &self.files {
|
||||
if content.len() > MAX_WORKFLOW_VERSION_FILE_BYTES {
|
||||
return Err(WorkflowVersionShapeError::FileTooLarge {
|
||||
path: path.clone(),
|
||||
actual: content.len(),
|
||||
maximum: MAX_WORKFLOW_VERSION_FILE_BYTES,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !self.files.contains_key(&self.entrypoint) {
|
||||
return Err(WorkflowVersionShapeError::MissingEntrypoint {
|
||||
path: self.entrypoint.clone(),
|
||||
});
|
||||
}
|
||||
self.validate_path_collisions()
|
||||
}
|
||||
|
||||
fn validate_path_collisions(&self) -> Result<(), WorkflowVersionShapeError> {
|
||||
// Keys are unique within each map, so equality can only collide
|
||||
// across files and workflow dependencies.
|
||||
let mut by_text =
|
||||
HashMap::with_capacity(self.files.len() + self.workflow_dependencies.len());
|
||||
for path in self.files.keys().chain(self.workflow_dependencies.keys()) {
|
||||
if let Some(existing) = by_text.insert(path.as_str(), path) {
|
||||
return Err(WorkflowVersionShapeError::PathCollision {
|
||||
first: existing.clone(),
|
||||
second: path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for path in self.files.keys().chain(self.workflow_dependencies.keys()) {
|
||||
let text = path.as_str();
|
||||
for (index, _) in text.match_indices('/') {
|
||||
if let Some(ancestor) = by_text.get(&text[..index]) {
|
||||
return Err(WorkflowVersionShapeError::PathCollision {
|
||||
first: (*ancestor).clone(),
|
||||
second: path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for WorkflowVersion {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Wire {
|
||||
entrypoint: WorkflowPath,
|
||||
files: UniqueBTreeMap<WorkflowPath, String>,
|
||||
workflow_dependencies: UniqueBTreeMap<WorkflowPath, WorkflowVersionId>,
|
||||
}
|
||||
|
||||
let wire = Wire::deserialize(deserializer)?;
|
||||
Self::new(wire.entrypoint, wire.files.0, wire.workflow_dependencies.0)
|
||||
.map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
struct UniqueBTreeMap<K, V>(BTreeMap<K, V>);
|
||||
|
||||
impl<'de, K, V> Deserialize<'de> for UniqueBTreeMap<K, V>
|
||||
where
|
||||
K: Deserialize<'de> + Ord + fmt::Display,
|
||||
V: Deserialize<'de>,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct MapVisitor<K, V>(PhantomData<(K, V)>);
|
||||
|
||||
impl<'de, K, V> Visitor<'de> for MapVisitor<K, V>
|
||||
where
|
||||
K: Deserialize<'de> + Ord + fmt::Display,
|
||||
V: Deserialize<'de>,
|
||||
{
|
||||
type Value = UniqueBTreeMap<K, V>;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("a map with unique keys")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: MapAccess<'de>,
|
||||
{
|
||||
let mut values = BTreeMap::new();
|
||||
while let Some((key, value)) = access.next_entry::<K, V>()? {
|
||||
if values.insert(key, value).is_some() {
|
||||
return Err(A::Error::custom("duplicate workflow map key"));
|
||||
}
|
||||
}
|
||||
Ok(UniqueBTreeMap(values))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(MapVisitor(PhantomData))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{
|
||||
MAX_WORKFLOW_VERSION_BYTES, MAX_WORKFLOW_VERSION_DEPENDENCIES,
|
||||
MAX_WORKFLOW_VERSION_FILE_BYTES, MAX_WORKFLOW_VERSION_FILES, WorkflowVersion,
|
||||
WorkflowVersionShapeError,
|
||||
};
|
||||
use crate::{BlobHash, WorkflowPath, WorkflowVersionId};
|
||||
|
||||
fn path(value: &str) -> WorkflowPath {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_have_fixed_field_and_map_order() {
|
||||
let version = WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
BTreeMap::from([
|
||||
(path("z.txt"), "Z".to_string()),
|
||||
(path("workflow.fabro"), "digraph W {}".to_string()),
|
||||
(path("a.txt"), "A".to_string()),
|
||||
]),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(version.canonical_bytes().unwrap()).unwrap(),
|
||||
r#"{"entrypoint":"workflow.fabro","files":{"a.txt":"A","workflow.fabro":"digraph W {}","z.txt":"Z"},"workflow_dependencies":{}}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_entrypoint() {
|
||||
let error = WorkflowVersion::new(
|
||||
path("missing.fabro"),
|
||||
BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_string())]),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionShapeError::MissingEntrypoint { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_collisions_and_large_files() {
|
||||
let collision = WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
BTreeMap::from([
|
||||
(path("workflow.fabro"), "digraph W {}".to_string()),
|
||||
(path("assets"), "file".to_string()),
|
||||
(path("assets/item.txt"), "nested".to_string()),
|
||||
]),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
collision,
|
||||
WorkflowVersionShapeError::PathCollision { .. }
|
||||
));
|
||||
|
||||
let mut files = BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_string())]);
|
||||
files.insert(
|
||||
path("large.txt"),
|
||||
"x".repeat(MAX_WORKFLOW_VERSION_FILE_BYTES + 1),
|
||||
);
|
||||
let large =
|
||||
WorkflowVersion::new(path("workflow.fabro"), files, BTreeMap::new()).unwrap_err();
|
||||
assert!(matches!(
|
||||
large,
|
||||
WorkflowVersionShapeError::FileTooLarge { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ancestor_collisions_hidden_by_sort_order() {
|
||||
// `assets.txt` sorts between `assets` and `assets/item.txt` because
|
||||
// '.' precedes '/', so an adjacent-pair scan over the sorted list
|
||||
// would miss this collision.
|
||||
let error = WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
BTreeMap::from([
|
||||
(path("workflow.fabro"), "digraph W {}".to_string()),
|
||||
(path("assets"), "file".to_string()),
|
||||
(path("assets.txt"), "sibling".to_string()),
|
||||
(path("assets/item.txt"), "nested".to_string()),
|
||||
]),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionShapeError::PathCollision { first, second }
|
||||
if first.as_str() == "assets" && second.as_str() == "assets/item.txt"
|
||||
));
|
||||
|
||||
assert!(
|
||||
WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
BTreeMap::from([
|
||||
(path("workflow.fabro"), "digraph W {}".to_string()),
|
||||
(path("assets.txt"), "sibling".to_string()),
|
||||
(path("assets/item.txt"), "nested".to_string()),
|
||||
]),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_collisions_across_files_and_workflow_dependencies() {
|
||||
let dependency_id = WorkflowVersionId::from(BlobHash::new(b"child"));
|
||||
|
||||
let equal = WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
BTreeMap::from([
|
||||
(path("workflow.fabro"), "digraph W {}".to_string()),
|
||||
(path("child.fabro"), "digraph C {}".to_string()),
|
||||
]),
|
||||
BTreeMap::from([(path("child.fabro"), dependency_id)]),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
equal,
|
||||
WorkflowVersionShapeError::PathCollision { first, second }
|
||||
if first == second && first.as_str() == "child.fabro"
|
||||
));
|
||||
|
||||
let ancestor = WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
BTreeMap::from([
|
||||
(path("workflow.fabro"), "digraph W {}".to_string()),
|
||||
(path("libs"), "file".to_string()),
|
||||
(path("libs.md"), "sibling".to_string()),
|
||||
]),
|
||||
BTreeMap::from([(path("libs/child.fabro"), dependency_id)]),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
ancestor,
|
||||
WorkflowVersionShapeError::PathCollision { first, second }
|
||||
if first.as_str() == "libs" && second.as_str() == "libs/child.fabro"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_file_count_file_size_and_canonical_size_boundaries() {
|
||||
let mut files = BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_string())]);
|
||||
for index in 0..MAX_WORKFLOW_VERSION_FILES - 1 {
|
||||
files.insert(path(&format!("file-{index:03}.txt")), String::new());
|
||||
}
|
||||
assert!(
|
||||
WorkflowVersion::new(path("workflow.fabro"), files.clone(), BTreeMap::new()).is_ok()
|
||||
);
|
||||
files.insert(path("too-many.txt"), String::new());
|
||||
assert!(matches!(
|
||||
WorkflowVersion::new(path("workflow.fabro"), files, BTreeMap::new()).unwrap_err(),
|
||||
WorkflowVersionShapeError::TooManyFiles { .. }
|
||||
));
|
||||
|
||||
let exact_file = BTreeMap::from([
|
||||
(path("workflow.fabro"), "digraph W {}".to_string()),
|
||||
(
|
||||
path("payload.txt"),
|
||||
"x".repeat(MAX_WORKFLOW_VERSION_FILE_BYTES),
|
||||
),
|
||||
]);
|
||||
assert!(
|
||||
WorkflowVersion::new(path("workflow.fabro"), exact_file.clone(), BTreeMap::new())
|
||||
.is_ok()
|
||||
);
|
||||
let mut oversized_file = exact_file;
|
||||
oversized_file
|
||||
.get_mut(&path("payload.txt"))
|
||||
.unwrap()
|
||||
.push('x');
|
||||
assert!(matches!(
|
||||
WorkflowVersion::new(path("workflow.fabro"), oversized_file, BTreeMap::new())
|
||||
.unwrap_err(),
|
||||
WorkflowVersionShapeError::FileTooLarge { .. }
|
||||
));
|
||||
|
||||
let mut exact_version_files =
|
||||
BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_string())]);
|
||||
for index in 0..4 {
|
||||
exact_version_files.insert(path(&format!("payload-{index}.txt")), String::new());
|
||||
}
|
||||
let empty = WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
exact_version_files.clone(),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let remaining = MAX_WORKFLOW_VERSION_BYTES - empty.canonical_bytes().unwrap().len();
|
||||
let per_file = remaining / 4;
|
||||
let remainder = remaining % 4;
|
||||
for index in 0..4 {
|
||||
let length = per_file + usize::from(index < remainder);
|
||||
assert!(length <= MAX_WORKFLOW_VERSION_FILE_BYTES);
|
||||
exact_version_files.insert(path(&format!("payload-{index}.txt")), "x".repeat(length));
|
||||
}
|
||||
let exact_version = WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
exact_version_files.clone(),
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
exact_version.canonical_bytes().unwrap().len(),
|
||||
MAX_WORKFLOW_VERSION_BYTES
|
||||
);
|
||||
exact_version_files
|
||||
.get_mut(&path("payload-0.txt"))
|
||||
.unwrap()
|
||||
.push('x');
|
||||
assert!(matches!(
|
||||
WorkflowVersion::new(path("workflow.fabro"), exact_version_files, BTreeMap::new())
|
||||
.unwrap_err(),
|
||||
WorkflowVersionShapeError::VersionTooLarge { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_workflow_dependency_count_boundary() {
|
||||
let dependencies = (0..MAX_WORKFLOW_VERSION_DEPENDENCIES)
|
||||
.map(|index| {
|
||||
(
|
||||
path(&format!("dependency-{index:03}.fabro")),
|
||||
WorkflowVersionId::from(BlobHash::new(index.to_string().as_bytes())),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let files = BTreeMap::from([(path("workflow.fabro"), "digraph W {}".to_owned())]);
|
||||
assert!(
|
||||
WorkflowVersion::new(path("workflow.fabro"), files.clone(), dependencies.clone())
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let mut oversized = dependencies;
|
||||
oversized.insert(
|
||||
path("too-many.fabro"),
|
||||
WorkflowVersionId::from(BlobHash::new(b"too many")),
|
||||
);
|
||||
assert!(matches!(
|
||||
WorkflowVersion::new(path("workflow.fabro"), files, oversized).unwrap_err(),
|
||||
WorkflowVersionShapeError::TooManyWorkflowDependencies { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_unknown_fields_and_duplicate_keys() {
|
||||
let unknown = r#"{
|
||||
"entrypoint":"workflow.fabro",
|
||||
"files":{"workflow.fabro":"digraph W {}"},
|
||||
"workflow_dependencies":{},
|
||||
"metadata":{}
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<WorkflowVersion>(unknown).is_err());
|
||||
|
||||
let duplicate = r#"{
|
||||
"entrypoint":"workflow.fabro",
|
||||
"files":{"workflow.fabro":"digraph W {}","workflow.fabro":"digraph X {}"},
|
||||
"workflow_dependencies":{}
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<WorkflowVersion>(duplicate).is_err());
|
||||
}
|
||||
}
|
||||
96
lib/foundation/fabro-types/src/workflow_version_id.rs
Normal file
96
lib/foundation/fabro-types/src/workflow_version_id.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::BlobHash;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(into = "String", try_from = "String")]
|
||||
pub struct WorkflowVersionId(BlobHash);
|
||||
|
||||
impl From<BlobHash> for WorkflowVersionId {
|
||||
fn from(value: BlobHash) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkflowVersionId> for BlobHash {
|
||||
fn from(value: WorkflowVersionId) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WorkflowVersionId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkflowVersionId> for String {
|
||||
fn from(value: WorkflowVersionId) -> Self {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
|
||||
#[error("workflow version ID must be exactly 64 hexadecimal characters")]
|
||||
pub struct WorkflowVersionIdParseError;
|
||||
|
||||
impl FromStr for WorkflowVersionId {
|
||||
type Err = WorkflowVersionIdParseError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
value
|
||||
.parse::<BlobHash>()
|
||||
.map(Self)
|
||||
.map_err(|_| WorkflowVersionIdParseError)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for WorkflowVersionId {
|
||||
type Error = WorkflowVersionIdParseError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
value.parse()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{BlobHash, WorkflowVersionId};
|
||||
|
||||
#[test]
|
||||
fn conversion_preserves_digest_and_display() {
|
||||
let blob_hash = BlobHash::new(b"workflow");
|
||||
let version_id = WorkflowVersionId::from(blob_hash);
|
||||
assert_eq!(version_id.to_string(), blob_hash.to_string());
|
||||
assert_eq!(BlobHash::from(version_id), blob_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_any_case_and_serializes_lowercase() {
|
||||
let value = BlobHash::new(b"workflow").to_string();
|
||||
let id: WorkflowVersionId = value.parse().unwrap();
|
||||
assert_eq!(serde_json::to_value(id).unwrap(), value);
|
||||
assert_eq!(
|
||||
value.to_uppercase().parse::<WorkflowVersionId>().unwrap(),
|
||||
id
|
||||
);
|
||||
for invalid in [
|
||||
String::new(),
|
||||
"0".repeat(63),
|
||||
"0".repeat(65),
|
||||
"g".repeat(64),
|
||||
] {
|
||||
assert!(invalid.parse::<WorkflowVersionId>().is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkflowVersionId>(serde_json::json!(value.to_uppercase()))
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ use std::collections::HashMap;
|
|||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::{DirtyStatus, GitContext, RunSpec};
|
||||
use fabro_types::settings::{ProjectNamespace, WorkflowNamespace};
|
||||
use fabro_types::test_support::test_run_provenance;
|
||||
use fabro_types::test_support::test_run_spec;
|
||||
use fabro_types::{WorkflowSettings, fixtures};
|
||||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
|
|
@ -20,24 +20,18 @@ fn sample_run_spec() -> RunSpec {
|
|||
};
|
||||
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings,
|
||||
graph: Graph::new("ship"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
automation: None,
|
||||
source_directory: Some("/Users/client/project".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: Some(GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
sha: Some("abc123".to_string()),
|
||||
dirty: DirtyStatus::Dirty,
|
||||
}),
|
||||
fork_source_ref: None,
|
||||
..test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ api/sessions-api.ts
|
|||
api/settings-api.ts
|
||||
api/system-api.ts
|
||||
api/variables-api.ts
|
||||
api/workflow-versions-api.ts
|
||||
api/workflows-api.ts
|
||||
base.ts
|
||||
common.ts
|
||||
|
|
@ -105,6 +106,7 @@ models/create-run-pull-request-request.ts
|
|||
models/create-run-session-request.ts
|
||||
models/create-secret-request.ts
|
||||
models/create-variable-request.ts
|
||||
models/create-workflow-version-response.ts
|
||||
models/delete-run-response.ts
|
||||
models/delete-run-sandbox.ts
|
||||
models/delete-secret-request.ts
|
||||
|
|
@ -541,4 +543,5 @@ models/workflow-ref.ts
|
|||
models/workflow-reference.ts
|
||||
models/workflow-schedule-summary.ts
|
||||
models/workflow-settings.ts
|
||||
models/workflow-version.ts
|
||||
models/write-blob-response.ts
|
||||
|
|
|
|||
3
lib/packages/fabro-api-client/src/api.ts
generated
3
lib/packages/fabro-api-client/src/api.ts
generated
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
@ -37,4 +37,5 @@ export * from './api/sessions-api';
|
|||
export * from './api/settings-api';
|
||||
export * from './api/system-api';
|
||||
export * from './api/variables-api';
|
||||
export * from './api/workflow-versions-api';
|
||||
export * from './api/workflows-api';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue