mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Compare commits
20 commits
8fc9f9910f
...
90f2293fe3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f2293fe3 | ||
|
|
56e759d470 | ||
|
|
8ed79b2a70 | ||
|
|
eaba019acb | ||
|
|
ce640b6ad3 | ||
|
|
fbba98defd | ||
|
|
34014e6dce | ||
|
|
ff29795dd8 | ||
|
|
6ac769c4cd | ||
|
|
2134f550a4 | ||
|
|
d8b28dfafd | ||
|
|
b3d112b206 | ||
|
|
e5046d8b1b | ||
|
|
a049f94042 | ||
|
|
24165b10f5 | ||
|
|
ca9fb1d262 | ||
|
|
9dc39ce9fa | ||
|
|
b5d517e4b5 | ||
|
|
c396a6cf6f | ||
|
|
bd7a255cb4 |
76 changed files with 3666 additions and 734 deletions
106
Cargo.lock
generated
106
Cargo.lock
generated
|
|
@ -2257,7 +2257,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-acp"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-tokio",
|
||||
|
|
@ -2276,7 +2276,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2323,7 +2323,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-automation",
|
||||
|
|
@ -2346,7 +2346,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2371,7 +2371,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-automation"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2391,11 +2391,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -2411,7 +2411,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2513,7 +2513,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2542,7 +2542,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2572,13 +2572,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum 0.28.0",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
|
@ -2587,7 +2588,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-db"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2600,7 +2601,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2619,7 +2620,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2633,7 +2634,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-environment"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2655,7 +2656,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2680,7 +2681,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2695,7 +2696,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -2718,7 +2719,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2728,7 +2729,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2747,7 +2748,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2762,7 +2763,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2804,7 +2805,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2815,7 +2816,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2836,7 +2837,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2856,7 +2857,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2884,7 +2885,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-store"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-db",
|
||||
|
|
@ -2902,7 +2903,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-static",
|
||||
|
|
@ -2919,7 +2920,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2941,7 +2942,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2949,7 +2950,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2958,7 +2959,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2974,7 +2975,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3018,7 +3019,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3070,6 +3071,7 @@ dependencies = [
|
|||
"fabro-workflow",
|
||||
"fabro-workflow-version",
|
||||
"futures-util",
|
||||
"git2",
|
||||
"globset",
|
||||
"hex",
|
||||
"hkdf 0.12.4",
|
||||
|
|
@ -3113,7 +3115,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -3135,18 +3137,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -3177,7 +3179,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -3203,7 +3205,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -3217,7 +3219,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3242,7 +3244,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3263,7 +3265,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3277,7 +3279,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -3300,7 +3302,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -3323,7 +3325,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"fabro-acp",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -3336,7 +3338,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-variable"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3353,7 +3355,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3372,7 +3374,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3442,7 +3444,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow-version"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"fabro-config",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -8599,7 +8601,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -8618,7 +8620,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.337.0-nightly.0"
|
||||
version = "0.337.0-nightly.1"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ fabro mcp start
|
|||
|
||||
Pass `--server` when the MCP client should connect to a specific Fabro server, or `--storage-dir` when it should use a non-default CLI storage directory.
|
||||
|
||||
Both commands register the entry under the `mcpServers` key `fabro` by default. Pass `--name` to choose a different key. Each named entry launches its own single-target `fabro mcp start` process, so you can register more than one Fabro server in the same MCP client:
|
||||
|
||||
```bash
|
||||
fabro mcp init claude --name fabro-production --server https://fabro.example.com
|
||||
fabro mcp init claude --name fabro-testing --server https://fabro-testing.example.com
|
||||
```
|
||||
|
||||
`fabro mcp init` keeps entries with other names and replaces only the entry that matches `--name`.
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `fabro_run_create` | Create one or more workflow runs, optionally under a parent run, starting them by default. |
|
||||
|
|
|
|||
|
|
@ -9309,14 +9309,19 @@ components:
|
|||
oneOf:
|
||||
- $ref: "#/components/schemas/GitRunTarget"
|
||||
- $ref: "#/components/schemas/NoneRunTarget"
|
||||
- $ref: "#/components/schemas/FolderRunTarget"
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
git: "#/components/schemas/GitRunTarget"
|
||||
none: "#/components/schemas/NoneRunTarget"
|
||||
folder: "#/components/schemas/FolderRunTarget"
|
||||
|
||||
GitRunTarget:
|
||||
description: Public github.com repository target.
|
||||
description: >-
|
||||
Public github.com repository target. The branch names the attached
|
||||
working branch. An optional tag selects a release at worker start, and
|
||||
an optional exact SHA is authoritative when both are present.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
|
|
@ -9333,14 +9338,23 @@ components:
|
|||
example: acme/my-app
|
||||
branch:
|
||||
type: string
|
||||
description: Required branch name, preserved exactly.
|
||||
description: Required attached working branch name, preserved exactly.
|
||||
example: feature/foo
|
||||
tag:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: >-
|
||||
Optional bare tag name. Prefixes such as `refs/tags/` and `tags/`
|
||||
are rejected. Without `sha`, the worker resolves this tag when the
|
||||
sandbox starts and fails if it is unavailable.
|
||||
example: v1.2.3
|
||||
sha:
|
||||
type: string
|
||||
pattern: "^[0-9A-Fa-f]{40}$"
|
||||
description: >-
|
||||
Optional exact commit. The server lowercase-normalizes its syntax
|
||||
but does not resolve it or prove branch ancestry.
|
||||
but does not resolve it, prove branch ancestry, or prove that it
|
||||
matches an accompanying tag. When present, this exact commit wins.
|
||||
|
||||
NoneRunTarget:
|
||||
description: >-
|
||||
|
|
@ -9357,6 +9371,30 @@ components:
|
|||
type: string
|
||||
enum: [none]
|
||||
|
||||
FolderRunTarget:
|
||||
description: >-
|
||||
Existing directory on the Fabro server, executed in place by a Local
|
||||
environment. The submitted path must be absolute and name an existing
|
||||
directory; Fabro resolves symlinks and persists its canonical UTF-8
|
||||
path. This target is intended for trusted single-tenant deployments.
|
||||
Docker and Daytona environments always reject it. This target does not
|
||||
add Local Git cloning or Local scratch workspaces. Folder runs execute
|
||||
in place without Fabro Git checkpoints, so fork and rewind are
|
||||
unavailable.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- kind
|
||||
- path
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [folder]
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Absolute path on the Fabro server, not on the API caller's machine.
|
||||
|
||||
RunManifest:
|
||||
description: Self-contained workflow run manifest.
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ settings enable it.
|
|||
Local environments reject the `none` target. Server-managed Local scratch
|
||||
workspaces remain a separate future capability.
|
||||
|
||||
Run intents can also use
|
||||
`{ "kind": "folder", "path": "/absolute/server/path" }` with a Local
|
||||
environment to execute in an existing server directory. Fabro resolves the
|
||||
submitted path to an existing canonical directory, persists that path, and
|
||||
uses it instead of the environment's `cwd`. Folder targets are intended for
|
||||
trusted single-tenant deployments and are rejected by Docker and Daytona.
|
||||
They execute in place without Fabro Git checkpoints, so retries retain the
|
||||
folder target while fork and rewind remain unavailable.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
|
|
|
|||
34
docs/public/changelog/2026-08-26.mdx
Normal file
34
docs/public/changelog/2026-08-26.mdx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
title: "Explicit succeed failure policy"
|
||||
date: "2026-08-26"
|
||||
---
|
||||
|
||||
`on_failure` now accepts a third policy, `succeed`, alongside `route` and
|
||||
`exit`. A failed node with an effective `succeed` policy and no explicit
|
||||
recovery route finishes as `succeeded` and follows normal success routing.
|
||||
The original failure details stay on the `stage.completed` event and in the
|
||||
checkpoint, and the outcome's notes record the promotion.
|
||||
|
||||
Set it on a node to mark a best-effort step inside a strict graph, or on the
|
||||
graph to apply it everywhere:
|
||||
|
||||
```dot
|
||||
digraph Review {
|
||||
graph [on_failure="exit"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
required_check [script="./required-check"]
|
||||
optional_scan [script="./optional-scan" on_failure="succeed"]
|
||||
|
||||
start -> required_check -> optional_scan -> exit
|
||||
}
|
||||
```
|
||||
|
||||
An explicit `condition="outcome=failed"` edge still takes priority over the
|
||||
promotion. A promoted outcome satisfies goal gates, and a failed parallel
|
||||
branch with a `succeed` policy counts as succeeded in its parent's result.
|
||||
|
||||
`auto_status=true` is now a deprecated alias for `on_failure="succeed"`.
|
||||
Existing workflows keep working, and validation reports a new
|
||||
`auto_status_deprecated` warning with the replacement. The alias no longer
|
||||
promotes `partially_succeeded` outcomes; only `failed` outcomes are affected.
|
||||
|
|
@ -309,6 +309,7 @@
|
|||
"group": "August 2026",
|
||||
"icon": "clock-rotate-left",
|
||||
"pages": [
|
||||
"changelog/2026-08-26",
|
||||
"changelog/2026-08-25",
|
||||
"changelog/2026-08-23",
|
||||
"changelog/2026-08-21",
|
||||
|
|
|
|||
|
|
@ -231,6 +231,18 @@ Install seeds a `default` environment into SQLite. It is a normal persisted envi
|
|||
|
||||
Create a server-managed local-provider environment through the environments API when you need a host `cwd`.
|
||||
|
||||
A version-backed run intent can submit
|
||||
`{ "kind": "folder", "path": "/absolute/server/path" }` to run in an existing
|
||||
server directory. Fabro accepts this target only with a Local environment,
|
||||
resolves symlinks and `..`, requires an existing directory, and persists the
|
||||
canonical UTF-8 path. The target path takes precedence over the environment's
|
||||
`cwd`. Because the run executes in place with the Local provider's unrestricted
|
||||
host access, use folder targets only in trusted single-tenant deployments.
|
||||
Docker and Daytona always reject folder targets. This does not add Local Git
|
||||
cloning or Local scratch workspaces for the `none` target. Local folder runs
|
||||
execute in place without Fabro Git checkpoints: retries retain the canonical
|
||||
folder target, but fork and rewind are unavailable for these runs.
|
||||
|
||||
When `cwd` is set, local runs execute commands from that absolute server-side
|
||||
path. When it is unset, Fabro keeps same-host compatibility by using the
|
||||
submitted source directory only if that path exists on the server. If neither is
|
||||
|
|
@ -258,7 +270,7 @@ memory = "4GB"
|
|||
mode = "block"
|
||||
```
|
||||
|
||||
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. The `none` target is not supported by Local environments. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
|
||||
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
|
||||
|
||||
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,27 @@ Set `on_failure` on a node to control that node alone. The node-level attribute
|
|||
|
||||
The policy applies only to `failed`. Other outcomes keep their normal routing behavior. For parallel nodes, the policy uses the completed parallel node's final outcome. It does not stop or cancel individual branches early.
|
||||
|
||||
## Treat a failed node as succeeded
|
||||
|
||||
Set `on_failure="succeed"` on a best-effort node so its failure never blocks the workflow. This pairs well with a strict graph default:
|
||||
|
||||
```dot title="best-effort-node.fabro"
|
||||
digraph Review {
|
||||
graph [on_failure="exit"]
|
||||
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
required_check [script="./required-check"]
|
||||
optional_scan [script="./optional-scan" on_failure="succeed"]
|
||||
|
||||
start -> required_check -> optional_scan -> exit
|
||||
}
|
||||
```
|
||||
|
||||
When `optional_scan` fails, Fabro first checks explicit recovery routes with the `failed` outcome. If none match, it rewrites the outcome to `succeeded` and routes the node as a success. Retries still run first; only the final outcome changes. The original failure stays on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion. A promoted outcome satisfies a goal gate. Setting `on_failure="succeed"` on the graph applies it to every node.
|
||||
|
||||
`succeed` applies only to `failed`. It does not change a `partially_succeeded` outcome. `auto_status=true` is the deprecated spelling of this policy; validation warns and suggests `on_failure="succeed"`.
|
||||
|
||||
## Retry layers
|
||||
|
||||
Fabro retries failures at three levels: **LLM retries** handle transient API errors inside a single model call, **turn-level retries** recover from dropped streams mid-response, and **node retries** re-execute the entire node handler when the first two levels aren't enough. These layers are independent — a node retry re-runs the full handler, which gets its own fresh set of LLM and turn-level retries.
|
||||
|
|
@ -332,8 +353,8 @@ A node failure does **not** automatically terminate the run. Fabro follows this
|
|||
4. **Node retries** — re-execute the entire handler (per the retry policy)
|
||||
5. **Direct jump** — use `jump_to_node` when the outcome supplies one
|
||||
6. **Explicit edge routing** — look for a matching condition, preferred label, or suggested next node
|
||||
7. **Failure policy** — with an effective `on_failure="exit"` (node-level `on_failure` first, then graph-level), skip the unconditional edge; with `route` or no attribute, keep normal fallback routing
|
||||
8. **Unconditional edge** — in `route` mode, use an edge without a condition as the fallback
|
||||
7. **Failure policy** — with no explicit route, apply the effective `on_failure` (node-level `on_failure` first, then graph-level): `exit` skips the unconditional edge, `succeed` promotes the outcome to `succeeded` and routes it as a success, and `route` (or no attribute) keeps normal fallback routing
|
||||
8. **Unconditional edge** — in `route` mode, or after a `succeed` promotion, use an edge without a condition as the fallback
|
||||
9. **Retry target** — if no edge was selected, check `retry_target` and `fallback_retry_target` on the node, then on the graph
|
||||
10. **Run failure** — if none of the above produces a path forward, the run terminates
|
||||
|
||||
|
|
|
|||
|
|
@ -83,24 +83,28 @@ In this example, if the agent returns a retryable failure and all 5 standard-pol
|
|||
|
||||
See [Retry policies](/execution/failures#retry-policies) for the available presets and backoff settings.
|
||||
|
||||
## `auto_status`
|
||||
## Succeed on failure
|
||||
|
||||
When `auto_status=true`, any non-`succeeded` and non-`skipped` outcome is silently overridden to `succeeded` after the handler completes. This is applied after the retry loop, so retries still happen normally — only the final outcome is overridden.
|
||||
When a node's effective `on_failure` policy is `succeed`, a `failed` outcome with no explicit recovery route is promoted to `succeeded`. This is applied after the retry loop, so retries still happen normally — only the final outcome changes. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion.
|
||||
|
||||
| Attribute | Type | Default |
|
||||
|---|---|---|
|
||||
| `auto_status` | Boolean | `false` |
|
||||
| `on_failure` | String | inherits the graph-level `on_failure` (default `route`) |
|
||||
|
||||
```dot
|
||||
scan [
|
||||
label="Scan",
|
||||
shape=parallelogram,
|
||||
auto_status=true,
|
||||
on_failure="succeed",
|
||||
script="find . -name '*.log' | head -20"
|
||||
]
|
||||
```
|
||||
|
||||
Use `auto_status` for nodes whose failure should never block the workflow — optional scans, best-effort cleanup steps, or informational commands where the output matters more than the exit code.
|
||||
Use `on_failure="succeed"` for nodes whose failure should never block the workflow — optional scans, best-effort cleanup steps, or informational commands where the output matters more than the exit code. An explicit `condition="outcome=failed"` edge still takes priority; the promotion applies only when no explicit route matches. The policy applies only to `failed` and leaves `partially_succeeded` unchanged. See [Failed-node routing policy](/workflows/transitions#failed-node-routing-policy) for the full set of policies.
|
||||
|
||||
<Note>
|
||||
`auto_status=true` is the deprecated spelling of `on_failure="succeed"`. Fabro still accepts it as an alias, and validation reports an `auto_status_deprecated` warning with the replacement. Unlike the old attribute, the alias no longer promotes `partially_succeeded` outcomes.
|
||||
</Note>
|
||||
|
||||
## Goal gate interaction
|
||||
|
||||
|
|
@ -111,7 +115,7 @@ make the workflow fail.
|
|||
|
||||
Nodes marked with `goal_gate=true` are checked when the workflow reaches the exit node. A goal gate is satisfied if its last outcome was `succeeded` **or** `partially_succeeded`. Any other outcome (`failed`, `skipped`) causes the workflow to fail, even though execution reached the exit.
|
||||
|
||||
This means `allow_partial=true` on a goal gate node lets the gate pass even if the node exhausted its retries — the promoted `partially_succeeded` outcome counts as passing.
|
||||
This means `allow_partial=true` on a goal gate node lets the gate pass even if the node exhausted its retries — the promoted `partially_succeeded` outcome counts as passing. Likewise, a `succeeded` outcome promoted by `on_failure="succeed"` satisfies the gate.
|
||||
|
||||
See [Goal gates](/execution/failures#goal-gates) for retry target resolution and failure behavior.
|
||||
|
||||
|
|
|
|||
|
|
@ -227,18 +227,29 @@ When a workflow runs in a remote sandbox (Daytona or Docker), Fabro clones the c
|
|||
|
||||
For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints.
|
||||
|
||||
#### Exact commits for run intents
|
||||
#### Git targets for run intents
|
||||
|
||||
The `RunIntent` create body names a required Git branch and may also pin a full
|
||||
40-character commit SHA. Creating the run validates and lowercase-normalizes
|
||||
the SHA, but it does not contact GitHub, resolve the commit, or prove that the
|
||||
commit belongs to the submitted branch.
|
||||
The `RunIntent` create body always names a GitHub repository and a working
|
||||
branch. It may also select a bare tag, pin a full 40-character commit SHA, or
|
||||
include both:
|
||||
|
||||
At sandbox setup, Docker fetches the submitted commit directly and Daytona
|
||||
receives it as `commit_id`; the submitted branch remains the working branch.
|
||||
If the exact commit is unavailable, setup fails. Fabro never substitutes the
|
||||
branch's newer HEAD. When the request omits `sha`, the sandbox resolves the
|
||||
branch at materialization time instead.
|
||||
| Target fields | Revision selected when the worker starts |
|
||||
|---|---|
|
||||
| `branch` | The branch HEAD |
|
||||
| `branch` + `sha` | The exact commit |
|
||||
| `branch` + `tag` | The tag's peeled commit |
|
||||
| `branch` + `tag` + `sha` | The exact commit; the tag remains part of the run's identity |
|
||||
|
||||
`branch` is always the attached branch inside the sandbox. `tag` is a bare tag
|
||||
name such as `v1.2.3`; `refs/tags/v1.2.3` and `tags/v1.2.3` are rejected. An
|
||||
unpinned tag is resolved when the worker starts, so moving a tag before that
|
||||
point changes the selected commit.
|
||||
|
||||
Creating the run validates the selectors and lowercase-normalizes `sha`, but
|
||||
does not contact GitHub or prove ancestry. An exact SHA is authoritative:
|
||||
Fabro does not prove it belongs to the branch or matches the accompanying tag.
|
||||
If a requested tag or exact commit is unavailable, sandbox setup fails without
|
||||
falling back to a same-named branch or the branch's newer HEAD.
|
||||
|
||||
### GITHUB_TOKEN injection
|
||||
|
||||
|
|
|
|||
|
|
@ -611,6 +611,7 @@ fabro mcp config [OPTIONS]
|
|||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--name <name>` | Name of the mcpServers entry; use distinct names to register multiple Fabro servers<br />Default: `fabro` |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
|
||||
|
||||
|
|
@ -632,6 +633,7 @@ fabro mcp init [OPTIONS] <AGENT>
|
|||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--name <name>` | Name of the mcpServers entry; use distinct names to register multiple Fabro servers<br />Default: `fabro` |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ rankdir=LR
|
|||
| `rankdir` | Identifier | Layout direction: `LR` (left-to-right) or `TB` (top-to-bottom) |
|
||||
| `model_stylesheet` | String | CSS-like rules for model assignment. The root value supports a MiniJinja template with `inputs` and `vars` (see [Model Stylesheets](/workflows/stylesheets)) |
|
||||
| `default_max_retries` | Integer | Default retry count for all nodes (default: 0) |
|
||||
| `on_failure` | String | Failed-node routing policy: `route` (default) or `exit` |
|
||||
| `on_failure` | String | Failed-node policy when no explicit recovery route matches: `route` (default), `exit`, or `succeed` |
|
||||
| `retry_target` | String | Default node ID to jump to on retry |
|
||||
| `fallback_retry_target` | String | Fallback retry target if primary target fails |
|
||||
| `default_fidelity` | String | Default [fidelity level](/execution/context) for all nodes |
|
||||
|
|
@ -199,13 +199,13 @@ Other node types still need their shape, because their attributes don't identify
|
|||
| `class` | String | Classes for [stylesheet](/workflows/stylesheets) targeting. Separate multiple classes with spaces. Commas are also accepted for compatibility. |
|
||||
| `timeout` | Duration | Execution timeout (e.g. `900s`). An agent's wait for human input does not consume this budget. On a human node, this is the response deadline. |
|
||||
| `max_visits` | Integer | Max times this node can execute in a run. Overrides the graph-level `max_node_visits` for this node. |
|
||||
| `on_failure` | String | Failed-node routing policy for this node: `route` or `exit`. Overrides the graph-level `on_failure`. |
|
||||
| `on_failure` | String | Failed-node policy for this node: `route`, `exit`, or `succeed`. Overrides the graph-level `on_failure`. See [Node Outcomes](/execution/outcomes#succeed-on-failure). |
|
||||
| `max_retries` | Integer | Override default retry count |
|
||||
| `retry_policy` | String | Named preset: `none`, `standard`, `aggressive`, `linear`, `patient` |
|
||||
| `retry_target` | String | Node ID to jump to on retry |
|
||||
| `fallback_retry_target` | String | Fallback node ID if primary `retry_target` is unreachable |
|
||||
| `goal_gate` | Boolean | When `true`, workflow fails if this node didn't finish with `succeeded` or `partially_succeeded`. See [Node Outcomes](/execution/outcomes#goal-gate-interaction). |
|
||||
| `auto_status` | Boolean | When `true`, overrides any non-`succeeded`/non-`skipped` outcome to `succeeded` after the handler completes. See [Node Outcomes](/execution/outcomes#auto_status). |
|
||||
| `auto_status` | Boolean | Deprecated alias for `on_failure="succeed"`. Validation warns when it is present. |
|
||||
| `allow_partial` | Boolean | When `true` and retries are exhausted on a retry-requesting failure, promotes the outcome to `partially_succeeded` instead of `failed`. Default `false`. See [Node Outcomes](/execution/outcomes#allow_partial). |
|
||||
| `selection` | String | Edge tiebreaking strategy: `deterministic` (default) or `random` (weighted-random). Cannot be combined with conditional edges. |
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ When a node completes, it produces an **outcome** with a [stage outcome](/execut
|
|||
2. **Condition match** — Edges with a `condition` attribute are evaluated first. If one or more conditions match, the edge with the highest `weight` wins (lexical tiebreak on target node ID).
|
||||
3. **Preferred label** — If the node's outcome includes a preferred label (for example, from a human gate selection), the edge whose `label` matches is chosen.
|
||||
4. **Suggested next** — If the node suggests a specific next node ID, the edge pointing to that node is chosen.
|
||||
5. **Failure policy** — For a failed outcome with an effective `on_failure="exit"` policy (node-level `on_failure` first, then graph-level), Fabro skips the unconditional fallback.
|
||||
5. **Failure policy** — For a failed outcome with no explicit route, the effective `on_failure` policy (node-level `on_failure` first, then graph-level) decides what happens next. `exit` skips the unconditional fallback. `succeed` promotes the outcome to `succeeded` and routes it as a success. `route` continues to the unconditional fallback.
|
||||
6. **Unconditional fallback** — Edges without conditions are considered last, again using `weight` then lexical tiebreak.
|
||||
7. **Retry target** — For a failed outcome with no selected edge, Fabro checks node-level and graph-level `retry_target` and `fallback_retry_target` values.
|
||||
|
||||
|
|
@ -21,10 +21,13 @@ If no edge or retry target supplies a next node, the workflow ends. A failed nod
|
|||
|
||||
## Failed-node routing policy
|
||||
|
||||
The `on_failure` attribute controls whether a failed node can take an unconditional edge:
|
||||
The `on_failure` attribute controls what happens to a failed node when no explicit recovery route matches:
|
||||
|
||||
- `route` keeps the existing routing behavior. It is the default.
|
||||
- `exit` stops normal fallback routing after explicit routes fail to match.
|
||||
| Policy | Effective outcome | Fallback routing |
|
||||
|---|---|---|
|
||||
| `route` (default) | stays `failed` | takes the unconditional edge |
|
||||
| `exit` | stays `failed` | skips the unconditional edge; the run ends unless a retry target applies |
|
||||
| `succeed` | becomes `succeeded` | uses normal success routing |
|
||||
|
||||
Set it at the graph level to apply the policy to every node, or on a node to control that node alone. A node-level `on_failure` overrides the graph level. A node without the attribute inherits the graph policy.
|
||||
|
||||
|
|
@ -60,17 +63,38 @@ digraph Build {
|
|||
}
|
||||
```
|
||||
|
||||
The `exit` policy only applies to the `failed` outcome. It does not change routing for `succeeded`, `partially_succeeded`, or `skipped` outcomes.
|
||||
Use `succeed` for a best-effort node whose failure must not block the workflow. Its failure becomes a `succeeded` outcome, so the node's normal success routing applies:
|
||||
|
||||
Conditioned edges, matching preferred labels, and matching suggested node IDs are explicit recovery routes. They still take priority under `exit`. An unmatched preferred label or suggested node ID does not make an unconditional edge explicit.
|
||||
```dot title="best-effort-node.fabro"
|
||||
digraph Review {
|
||||
graph [on_failure="exit"]
|
||||
|
||||
Retry targets also remain available. Fabro checks them after it skips the unconditional fallback. Use graph-level `max_node_visits` or node-level `max_visits` to bound workflows whose retry targets return to a failing path.
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
required_check [script="./required-check"]
|
||||
optional_scan [script="./optional-scan" on_failure="succeed"]
|
||||
|
||||
A failed human gate never falls through to an unconditional edge, regardless of policy. Node-level `on_failure="route"` does not change that; route an interrupted gate explicitly with `condition="outcome=failed"`.
|
||||
start -> required_check -> optional_scan -> exit
|
||||
}
|
||||
```
|
||||
|
||||
Under `succeed`, Fabro first checks explicit routes against the original `failed` outcome. If a `condition="outcome=failed"` edge, a matching preferred label, a matching suggested next node, or a handler jump applies, the outcome stays `failed` and that route is taken. Otherwise Fabro rewrites the outcome to `succeeded` before it records the node, so goal gates, the run context, events, and routing all see the promoted outcome. Edge selection then runs again: `condition="outcome=succeeded"` edges and unconditional edges apply. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record which scope promoted it. A promoted outcome is not `failed`, so retry targets do not apply to it.
|
||||
|
||||
Both `exit` and `succeed` apply only to the `failed` outcome. They do not change routing for `succeeded`, `partially_succeeded`, or `skipped` outcomes.
|
||||
|
||||
Conditioned edges, matching preferred labels, and matching suggested node IDs are explicit recovery routes. They take priority under every policy. An unmatched preferred label or suggested node ID does not make an unconditional edge explicit.
|
||||
|
||||
Retry targets also remain available under `exit`. Fabro checks them after it skips the unconditional fallback. Use graph-level `max_node_visits` or node-level `max_visits` to bound workflows whose retry targets return to a failing path.
|
||||
|
||||
A failed human gate never falls through to an unconditional edge as a failure, regardless of policy. Node-level `on_failure="route"` does not change that; route an interrupted gate explicitly with `condition="outcome=failed"`. Under `succeed`, an interrupted gate with no explicit route is promoted like any other node and then follows its success routing.
|
||||
|
||||
When `exit` stops routing, Fabro checkpoints the failed node without a next node and ends the run as failed. It does not execute the graph's exit node or emit an edge selection for an edge it did not take. An explicit recovery route can still reach the exit node normally.
|
||||
|
||||
For a parallel node, `exit` sees the final outcome returned by the parallel handler. It can stop routing for a failed parallel outcome. It does not add branch-level fail-fast behavior, and a `partially_succeeded` parallel outcome continues normally.
|
||||
For a parallel node, `exit` and `succeed` see the final outcome returned by the parallel handler. `exit` can stop routing for a failed parallel outcome; `succeed` promotes it. Neither adds branch-level fail-fast behavior, and a `partially_succeeded` parallel outcome continues normally. Inside the fan-out, a branch node whose effective policy is `succeed` counts as `succeeded` in the parent's aggregate when it fails. Branches have no edge routing, so there is no explicit route to prefer.
|
||||
|
||||
<Note>
|
||||
`auto_status=true` is the deprecated spelling of node-level `on_failure="succeed"`. Fabro still accepts it as an alias and validation warns with the replacement. See [Node Outcomes](/execution/outcomes#succeed-on-failure).
|
||||
</Note>
|
||||
|
||||
## Edge attributes
|
||||
|
||||
|
|
@ -201,7 +225,7 @@ gate -> fast_path [condition="outcome=succeeded"]
|
|||
gate -> slow_path
|
||||
```
|
||||
|
||||
For a failed outcome, `on_failure="exit"` skips this fallback after explicit routes are checked. The default `on_failure="route"` keeps the behavior shown above.
|
||||
For a failed outcome, `on_failure="exit"` skips this fallback after explicit routes are checked, and `on_failure="succeed"` promotes the outcome to `succeeded` before taking it. The default `on_failure="route"` keeps the behavior shown above.
|
||||
|
||||
## Weight tiebreaking
|
||||
|
||||
|
|
|
|||
|
|
@ -191,8 +191,13 @@ pub(crate) struct McpStartArgs {
|
|||
pub(crate) connection: ServerConnectionArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub(crate) struct McpConfigArgs {
|
||||
/// Name of the mcpServers entry; use distinct names to register multiple
|
||||
/// Fabro servers
|
||||
#[arg(long, value_name = "NAME", default_value = fabro_mcp_server::SERVER_NAME, value_parser = clap::builder::NonEmptyStringValueParser::new())]
|
||||
pub(crate) name: String,
|
||||
|
||||
#[command(flatten)]
|
||||
pub(crate) connection: ServerConnectionArgs,
|
||||
}
|
||||
|
|
@ -202,7 +207,7 @@ pub(crate) struct McpInitArgs {
|
|||
pub(crate) agent: McpAgent,
|
||||
|
||||
#[command(flatten)]
|
||||
pub(crate) connection: ServerConnectionArgs,
|
||||
pub(crate) config: McpConfigArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ use std::fmt::Write as _;
|
|||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use crate::args::{McpAgent, McpCommand, McpNamespace, ServerConnectionArgs};
|
||||
use crate::args::{
|
||||
McpAgent, McpCommand, McpConfigArgs, McpInitArgs, McpNamespace, ServerConnectionArgs,
|
||||
};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::server_client;
|
||||
|
||||
|
|
@ -12,12 +14,12 @@ pub(crate) async fn dispatch(ns: McpNamespace, base_ctx: &CommandContext) -> Res
|
|||
fabro_mcp_server::start(server_settings(base_ctx, &args.connection)?).await
|
||||
}
|
||||
McpCommand::Config(args) => {
|
||||
let json = fabro_mcp_server::config_json(&config_settings(&args.connection))?;
|
||||
let json = fabro_mcp_server::config_json(&config_settings(&args))?;
|
||||
let _ = write!(base_ctx.printer().stdout_important(), "{json}");
|
||||
Ok(())
|
||||
}
|
||||
McpCommand::Init(args) => {
|
||||
fabro_mcp_server::init_agent(&init_settings(args.agent, &args.connection)?)?;
|
||||
fabro_mcp_server::init_agent(&init_settings(&args)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -56,21 +58,19 @@ fn server_settings(
|
|||
})
|
||||
}
|
||||
|
||||
fn init_settings(
|
||||
agent: McpAgent,
|
||||
connection: &ServerConnectionArgs,
|
||||
) -> Result<fabro_mcp_server::McpInitSettings> {
|
||||
fn init_settings(args: &McpInitArgs) -> Result<fabro_mcp_server::McpInitSettings> {
|
||||
Ok(fabro_mcp_server::McpInitSettings {
|
||||
agent: McpAgentForServer(agent).into(),
|
||||
config: config_settings(connection),
|
||||
agent: McpAgentForServer(args.agent).into(),
|
||||
config: config_settings(&args.config),
|
||||
home_dir: home_dir()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn config_settings(connection: &ServerConnectionArgs) -> fabro_mcp_server::McpConfigSettings {
|
||||
fn config_settings(args: &McpConfigArgs) -> fabro_mcp_server::McpConfigSettings {
|
||||
fabro_mcp_server::McpConfigSettings {
|
||||
server: connection.target.server.clone(),
|
||||
storage_dir: connection.storage_dir.clone_path(),
|
||||
name: args.name.clone(),
|
||||
server: args.connection.target.server.clone(),
|
||||
storage_dir: args.connection.storage_dir.clone_path(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use fabro_sandbox::daytona::detect_repo_info;
|
||||
use git2::Repository;
|
||||
|
||||
pub(crate) fn ensure_matching_repo_origin(
|
||||
expected_origin_url: Option<&str>,
|
||||
|
|
@ -15,9 +18,9 @@ pub(crate) fn ensure_matching_repo_origin(
|
|||
"Current directory is not a git repository with an origin remote; refusing to {action} run from repository '{expected_origin_url}'"
|
||||
)
|
||||
})?;
|
||||
let current_origin_url = fabro_github::normalize_repo_origin_url(&origin_url);
|
||||
|
||||
if current_origin_url != expected_origin_url {
|
||||
if !origin_matches_expected(&origin_url, expected_origin_url, &cwd) {
|
||||
let current_origin_url = fabro_github::normalize_repo_origin_url(&origin_url);
|
||||
bail!(
|
||||
"Current repository origin '{current_origin_url}' does not match run repository '{expected_origin_url}'; refusing to {action} this run from the wrong checkout"
|
||||
);
|
||||
|
|
@ -26,12 +29,154 @@ pub(crate) fn ensure_matching_repo_origin(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the raw local `origin` URL denotes the same repository as
|
||||
/// `expected`, honoring `url.<replacement>.insteadOf` config rewrites.
|
||||
///
|
||||
/// A checkout can store its origin in rewritten form (for example an SSH host
|
||||
/// alias used for account separation) while the run spec stores the canonical
|
||||
/// URL, or the other way around. Both rewrite directions are therefore
|
||||
/// compared, after normalization, before the guard rejects the operation.
|
||||
fn origin_matches_expected(raw_origin: &str, expected: &str, repo_path: &Path) -> bool {
|
||||
let expected_normalized = fabro_github::normalize_repo_origin_url(expected);
|
||||
rewrite_candidates(
|
||||
raw_origin,
|
||||
&insteadof_rewrites(repo_path).unwrap_or_default(),
|
||||
)
|
||||
.iter()
|
||||
.any(|candidate| fabro_github::normalize_repo_origin_url(candidate) == expected_normalized)
|
||||
}
|
||||
|
||||
/// Every URL the raw origin can denote: itself, git's forward `insteadOf`
|
||||
/// rewrite, and the inverse rewrite that recovers the pre-rewrite form for
|
||||
/// origins stored in rewritten form.
|
||||
fn rewrite_candidates(raw_origin: &str, rewrites: &[(String, String)]) -> Vec<String> {
|
||||
let mut candidates = vec![raw_origin.to_string()];
|
||||
for (replacement, matcher) in rewrites {
|
||||
if let Some(rest) = raw_origin.strip_prefix(matcher.as_str()) {
|
||||
candidates.push(format!("{replacement}{rest}"));
|
||||
}
|
||||
if let Some(rest) = raw_origin.strip_prefix(replacement.as_str()) {
|
||||
candidates.push(format!("{matcher}{rest}"));
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
/// `url.<replacement>.insteadOf` pairs visible from `repo_path`, following
|
||||
/// git's config cascade (repo-local, global, system). Git allows multiple
|
||||
/// `insteadOf` values per replacement key; each becomes its own pair.
|
||||
fn insteadof_rewrites(repo_path: &Path) -> Option<Vec<(String, String)>> {
|
||||
let repo = Repository::discover(repo_path).ok()?;
|
||||
let config = repo.config().ok()?;
|
||||
let mut entries = config.entries(Some("url.*.insteadof")).ok()?;
|
||||
let mut pairs = Vec::new();
|
||||
while let Some(entry) = entries.next() {
|
||||
let entry = entry.ok()?;
|
||||
let Some(name) = entry.name() else {
|
||||
continue;
|
||||
};
|
||||
let name = name.to_ascii_lowercase();
|
||||
let Some(replacement) = name
|
||||
.strip_prefix("url.")
|
||||
.and_then(|rest| rest.strip_suffix(".insteadof"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(value) = entry.value() {
|
||||
pairs.push((replacement.to_string(), value.to_string()));
|
||||
}
|
||||
}
|
||||
Some(pairs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ensure_matching_repo_origin;
|
||||
use super::{ensure_matching_repo_origin, rewrite_candidates};
|
||||
|
||||
#[test]
|
||||
fn missing_expected_origin_skips_guard() {
|
||||
ensure_matching_repo_origin(None, "fork").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_keep_the_raw_url() {
|
||||
let candidates = rewrite_candidates("https://example.com/owner/repo.git", &[]);
|
||||
assert_eq!(candidates, vec!["https://example.com/owner/repo.git"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_recover_canonical_form_from_alias_origin() {
|
||||
// Origin stored in rewritten form: the inverse rewrite recovers the
|
||||
// canonical URL the run spec stores.
|
||||
let rewrites = vec![(
|
||||
"git@denkhaus.github.com:denkhaus/".to_string(),
|
||||
"https://github.com/denkhaus/".to_string(),
|
||||
)];
|
||||
let candidates =
|
||||
rewrite_candidates("git@denkhaus.github.com:denkhaus/fabro.git", &rewrites);
|
||||
assert!(candidates.contains(&"https://github.com/denkhaus/fabro.git".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_apply_forward_rewrite_to_canonical_origin() {
|
||||
// Origin stored canonically: git's forward rewrite yields the alias
|
||||
// form actually used for fetches and pushes.
|
||||
let rewrites = vec![(
|
||||
"git@denkhaus.github.com:denkhaus/".to_string(),
|
||||
"git@github.com:denkhaus/".to_string(),
|
||||
)];
|
||||
let candidates = rewrite_candidates("git@github.com:denkhaus/fabro.git", &rewrites);
|
||||
assert!(candidates.contains(&"git@denkhaus.github.com:denkhaus/fabro.git".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insteadof_rewrites_reads_git_config_cascade() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let repo = git2::Repository::init(dir.path()).expect("init repo");
|
||||
let mut config = repo.config().expect("repo config");
|
||||
config
|
||||
.set_str(
|
||||
"url.git@denkhaus.github.com:denkhaus/.insteadOf",
|
||||
"https://github.com/denkhaus/",
|
||||
)
|
||||
.expect("set insteadOf");
|
||||
config
|
||||
.set_multivar(
|
||||
"url.git@denkhaus.github.com:denkhaus/.insteadOf",
|
||||
"^$",
|
||||
"git@github.com:denkhaus/",
|
||||
)
|
||||
.expect("set second insteadOf value");
|
||||
|
||||
let pairs = super::insteadof_rewrites(dir.path())
|
||||
.expect("rewrites should be readable from the repo config");
|
||||
assert!(pairs.contains(&(
|
||||
"git@denkhaus.github.com:denkhaus/".to_string(),
|
||||
"https://github.com/denkhaus/".to_string(),
|
||||
)));
|
||||
assert!(pairs.contains(&(
|
||||
"git@denkhaus.github.com:denkhaus/".to_string(),
|
||||
"git@github.com:denkhaus/".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_use_every_insteadof_value_of_one_replacement() {
|
||||
// Git allows several insteadOf values per replacement; each match
|
||||
// pattern must produce its own inverse candidate.
|
||||
let rewrites = vec![
|
||||
(
|
||||
"git@denkhaus.github.com:denkhaus/".to_string(),
|
||||
"https://github.com/denkhaus/".to_string(),
|
||||
),
|
||||
(
|
||||
"git@denkhaus.github.com:denkhaus/".to_string(),
|
||||
"git@github.com:denkhaus/".to_string(),
|
||||
),
|
||||
];
|
||||
let candidates =
|
||||
rewrite_candidates("git@denkhaus.github.com:denkhaus/fabro.git", &rewrites);
|
||||
assert!(candidates.contains(&"https://github.com/denkhaus/fabro.git".to_string()));
|
||||
assert!(candidates.contains(&"git@github.com:denkhaus/fabro.git".to_string()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,10 +122,11 @@ fn config_help() {
|
|||
|
||||
Options:
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
|
||||
--name <NAME> Name of the mcpServers entry; use distinct names to register multiple Fabro servers [default: fabro]
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||||
-h, --help Print help
|
||||
|
|
@ -151,10 +152,11 @@ fn init_help() {
|
|||
|
||||
Options:
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
|
||||
--name <NAME> Name of the mcpServers entry; use distinct names to register multiple Fabro servers [default: fabro]
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||||
-h, --help Print help
|
||||
|
|
@ -221,6 +223,55 @@ fn config_preserves_connection_flags() {
|
|||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_uses_custom_entry_name() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args([
|
||||
"mcp",
|
||||
"config",
|
||||
"--name",
|
||||
"fabro-production",
|
||||
"--server",
|
||||
"https://fabro.example.test",
|
||||
]);
|
||||
fabro_snapshot!(context.filters(), cmd, @r#"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
{
|
||||
"mcpServers": {
|
||||
"fabro-production": {
|
||||
"command": "fabro",
|
||||
"args": [
|
||||
"mcp",
|
||||
"start",
|
||||
"--server",
|
||||
"https://fabro.example.test"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
----- stderr -----
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_rejects_empty_entry_name() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["mcp", "config", "--name", ""]);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
error: a value is required for '--name <NAME>' but none was supplied
|
||||
|
||||
For more information, try '--help'.
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_cursor_writes_idempotent_config() {
|
||||
let context = test_context!();
|
||||
|
|
@ -417,6 +468,91 @@ fn init_preserves_existing_servers() {
|
|||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_merges_multiple_named_fabro_entries() {
|
||||
let context = test_context!();
|
||||
context
|
||||
.command()
|
||||
.args(["mcp", "init", "cursor"])
|
||||
.assert()
|
||||
.success();
|
||||
context
|
||||
.command()
|
||||
.args([
|
||||
"mcp",
|
||||
"init",
|
||||
"cursor",
|
||||
"--name",
|
||||
"fabro-production",
|
||||
"--server",
|
||||
"https://production.example.test",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
context
|
||||
.command()
|
||||
.args([
|
||||
"mcp",
|
||||
"init",
|
||||
"cursor",
|
||||
"--name",
|
||||
"fabro-testing",
|
||||
"--server",
|
||||
"https://testing.example.test",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
// Reusing a name updates only that entry.
|
||||
context
|
||||
.command()
|
||||
.args([
|
||||
"mcp",
|
||||
"init",
|
||||
"cursor",
|
||||
"--name",
|
||||
"fabro-production",
|
||||
"--server",
|
||||
"https://production.example.test:8443",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let config_path = context.home_dir.join(".cursor").join("mcp.json");
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(config_path).unwrap()).unwrap();
|
||||
fabro_json_snapshot!(context, config, @r#"
|
||||
{
|
||||
"mcpServers": {
|
||||
"fabro": {
|
||||
"command": "fabro",
|
||||
"args": [
|
||||
"mcp",
|
||||
"start"
|
||||
]
|
||||
},
|
||||
"fabro-production": {
|
||||
"command": "fabro",
|
||||
"args": [
|
||||
"mcp",
|
||||
"start",
|
||||
"--server",
|
||||
"https://production.example.test:8443"
|
||||
]
|
||||
},
|
||||
"fabro-testing": {
|
||||
"command": "fabro",
|
||||
"args": [
|
||||
"mcp",
|
||||
"start",
|
||||
"--server",
|
||||
"https://testing.example.test"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_invalid_json_fails_without_overwrite() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -390,11 +390,29 @@ fn invalid_node_on_failure_is_a_validation_failure() {
|
|||
Workflow: InvalidNodeOnFailure (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/on_failure_node_invalid.fabro
|
||||
error [node: work]: Node 'work' has invalid on_failure value 'stop' (on_failure_valid)
|
||||
fix: Use one of: route, exit
|
||||
fix: Use one of: route, exit, succeed
|
||||
× Validation failed
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deprecated_auto_status_warns_with_succeed_policy_replacement() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.validate();
|
||||
cmd.arg(fixture("auto_status_deprecated.fabro"));
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
Workflow: DeprecatedAutoStatus (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/auto_status_deprecated.fabro
|
||||
warning [node: scan]: Node 'scan' sets deprecated 'auto_status=true' (auto_status_deprecated)
|
||||
fix: Use on_failure=\"succeed\" instead
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_on_failure_is_a_validation_failure() {
|
||||
let context = test_context!();
|
||||
|
|
@ -408,7 +426,7 @@ fn invalid_on_failure_is_a_validation_failure() {
|
|||
Workflow: InvalidOnFailure (2 nodes, 1 edges)
|
||||
Graph: [FIXTURES]/on_failure_invalid.fabro
|
||||
error: Graph has invalid on_failure value 'stop' (on_failure_valid)
|
||||
fix: Use one of: route, exit
|
||||
fix: Use one of: route, exit, succeed
|
||||
× Validation failed
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use anyhow::{Context as _, Result, anyhow};
|
|||
use serde_json::map::Entry;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{McpAgent, McpConfigSettings, McpInitSettings, SERVER_NAME};
|
||||
use crate::{McpAgent, McpConfigSettings, McpInitSettings};
|
||||
|
||||
pub fn config_json(settings: &McpConfigSettings) -> Result<String> {
|
||||
serde_json::to_string_pretty(&generic_config(settings))
|
||||
|
|
@ -18,19 +18,16 @@ pub fn config_json(settings: &McpConfigSettings) -> Result<String> {
|
|||
}
|
||||
|
||||
pub fn init_agent(settings: &McpInitSettings) -> Result<()> {
|
||||
let entry = server_entry(&settings.config);
|
||||
for path in agent_config_paths(settings.agent, &settings.home_dir) {
|
||||
merge_server_entry(&path, entry.clone())?;
|
||||
merge_server_entry(&path, &settings.config)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generic_config(settings: &McpConfigSettings) -> Value {
|
||||
json!({
|
||||
"mcpServers": {
|
||||
SERVER_NAME: server_entry(settings)
|
||||
}
|
||||
})
|
||||
let mut servers = Map::new();
|
||||
servers.insert(settings.name.clone(), server_entry(settings));
|
||||
json!({ "mcpServers": servers })
|
||||
}
|
||||
|
||||
fn server_entry(settings: &McpConfigSettings) -> Value {
|
||||
|
|
@ -53,7 +50,7 @@ fn start_args(settings: &McpConfigSettings) -> Vec<String> {
|
|||
args
|
||||
}
|
||||
|
||||
fn merge_server_entry(path: &Path, entry: Value) -> Result<()> {
|
||||
fn merge_server_entry(path: &Path, settings: &McpConfigSettings) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
|
@ -80,7 +77,7 @@ fn merge_server_entry(path: &Path, entry: Value) -> Result<()> {
|
|||
path.display()
|
||||
)
|
||||
})?;
|
||||
servers_object.insert(SERVER_NAME.to_string(), entry);
|
||||
servers_object.insert(settings.name.clone(), server_entry(settings));
|
||||
|
||||
let rendered = serde_json::to_string_pretty(&root)
|
||||
.map(|json| format!("{json}\n"))
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ pub use config::{config_json, init_agent};
|
|||
use fabro_client::Client;
|
||||
pub use server::start;
|
||||
|
||||
/// The name this MCP server reports over the wire and registers under in agent
|
||||
/// config files.
|
||||
pub(crate) const SERVER_NAME: &str = "fabro";
|
||||
/// The name this MCP server reports over the wire. It is also the default
|
||||
/// `mcpServers` key that `fabro mcp config` and `fabro mcp init` register.
|
||||
pub const SERVER_NAME: &str = "fabro";
|
||||
|
||||
pub type FabroClientFuture = Pin<Box<dyn Future<Output = Result<Client>> + Send>>;
|
||||
|
||||
|
|
@ -39,8 +39,10 @@ impl std::fmt::Debug for FabroMcpServerSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpConfigSettings {
|
||||
/// The `mcpServers` key the generated client entry is registered under.
|
||||
pub name: String,
|
||||
pub server: Option<String>,
|
||||
pub storage_dir: Option<PathBuf>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ chrono = { workspace = true }
|
|||
|
||||
[dev-dependencies]
|
||||
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
|
||||
git2.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tower = "0.5"
|
||||
http-body-util = "0.1"
|
||||
|
|
|
|||
|
|
@ -171,6 +171,16 @@ impl PreparedRun {
|
|||
&self.layered.settings
|
||||
}
|
||||
|
||||
pub(crate) fn with_target_and_git(
|
||||
mut self,
|
||||
target: RunTarget,
|
||||
git: Option<GitContext>,
|
||||
) -> Self {
|
||||
self.layered.metadata.target = Some(target);
|
||||
self.layered.metadata.git = git;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_identity(
|
||||
mut self,
|
||||
run_id: Option<RunId>,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ use fabro_config::{EnvironmentLayer, RunEnvironmentLayer, RunGoalLayer, Settings
|
|||
use fabro_environment::{EnvironmentId, EnvironmentValidationError};
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::{
|
||||
ManifestPath, SandboxProviderKind, TargetValidationError, WorkflowPath, WorkflowVersionId,
|
||||
GitContext, ManifestPath, RunTarget, SandboxProviderKind, TargetValidationError, WorkflowPath,
|
||||
WorkflowVersionId,
|
||||
};
|
||||
use fabro_workflow::git;
|
||||
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
|
||||
use fabro_workflow_version::{LoadedWorkflowVersionClosure, ValidatedWorkflowVersion};
|
||||
use thiserror::Error;
|
||||
use tokio::{fs, task};
|
||||
|
||||
use crate::run_compiler::{RunCompilerError, settings_layer_with_resolved_dockerfiles};
|
||||
|
||||
|
|
@ -26,6 +29,8 @@ pub(crate) enum RunIntentAdmissionError {
|
|||
#[error(transparent)]
|
||||
Target(#[from] TargetValidationError),
|
||||
#[error(transparent)]
|
||||
FolderTarget(#[from] FolderTargetValidationError),
|
||||
#[error(transparent)]
|
||||
Environment(#[from] EnvironmentSelectionError),
|
||||
#[error(transparent)]
|
||||
Compiler(#[from] RunCompilerError),
|
||||
|
|
@ -36,6 +41,85 @@ pub(crate) enum RunIntentAdmissionError {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum FolderTargetValidationError {
|
||||
#[error("folder target path must be absolute")]
|
||||
Relative,
|
||||
#[error("folder target path does not name an accessible filesystem entry")]
|
||||
Canonicalize {
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("folder target path must name a directory")]
|
||||
NotDirectory,
|
||||
#[error("folder target canonical path must be valid UTF-8")]
|
||||
NonUtf8,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PreparedIntentTarget {
|
||||
pub(crate) target: RunTarget,
|
||||
pub(crate) git: Option<GitContext>,
|
||||
}
|
||||
|
||||
/// Materialize filesystem-backed target facts after the effective environment
|
||||
/// has been admitted as Local and before run allocation. Folder targets are
|
||||
/// canonicalized once for durable identity and their optional Git metadata is
|
||||
/// observed under the same provider gate, so rejected requests never scan host
|
||||
/// repositories. Other targets pass through with their validated projection.
|
||||
pub(crate) async fn prepare_intent_target(
|
||||
target: RunTarget,
|
||||
git: Option<GitContext>,
|
||||
) -> Result<PreparedIntentTarget, FolderTargetValidationError> {
|
||||
let RunTarget::Folder { path } = target else {
|
||||
return Ok(PreparedIntentTarget { target, git });
|
||||
};
|
||||
let submitted = PathBuf::from(path);
|
||||
if !submitted.is_absolute() {
|
||||
return Err(FolderTargetValidationError::Relative);
|
||||
}
|
||||
let canonical = fs::canonicalize(&submitted)
|
||||
.await
|
||||
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
|
||||
let metadata = fs::metadata(&canonical)
|
||||
.await
|
||||
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(FolderTargetValidationError::NotDirectory);
|
||||
}
|
||||
let path = canonical_folder_text(&canonical)?;
|
||||
let git = task::spawn_blocking(move || {
|
||||
git::observe_git_context(&canonical).unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
error = ?error,
|
||||
path = %canonical.display(),
|
||||
"failed to observe optional git metadata for folder target"
|
||||
);
|
||||
None
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
error = ?error,
|
||||
path,
|
||||
"folder target git observation task failed"
|
||||
);
|
||||
None
|
||||
});
|
||||
|
||||
Ok(PreparedIntentTarget {
|
||||
target: RunTarget::Folder { path },
|
||||
git,
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_folder_text(path: &Path) -> Result<String, FolderTargetValidationError> {
|
||||
path.to_str()
|
||||
.map(str::to_string)
|
||||
.ok_or(FolderTargetValidationError::NonUtf8)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum EnvironmentSelectionError {
|
||||
#[error("invalid environment ID `{value}`")]
|
||||
|
|
@ -377,6 +461,81 @@ mod tests {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepares_a_canonical_folder_target_without_git_projection() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target_dir = dir.path().join("target");
|
||||
std::fs::create_dir(&target_dir).unwrap();
|
||||
std::fs::create_dir(dir.path().join("nested")).unwrap();
|
||||
let submitted = dir.path().join("nested").join("..").join("target");
|
||||
|
||||
let prepared = prepare_intent_target(
|
||||
RunTarget::Folder {
|
||||
path: submitted.to_string_lossy().to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let canonical = target_dir.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(prepared.git, None);
|
||||
assert_eq!(prepared.target, RunTarget::Folder {
|
||||
path: canonical.to_string_lossy().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_relative_missing_and_file_folder_targets() {
|
||||
let relative = prepare_intent_target(
|
||||
RunTarget::Folder {
|
||||
path: "relative/path".to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(relative, FolderTargetValidationError::Relative));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = prepare_intent_target(
|
||||
RunTarget::Folder {
|
||||
path: dir.path().join("missing").to_string_lossy().to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
missing,
|
||||
FolderTargetValidationError::Canonicalize { .. }
|
||||
));
|
||||
|
||||
let file = dir.path().join("file");
|
||||
fs::write(&file, "not a directory").await.unwrap();
|
||||
let file = prepare_intent_target(
|
||||
RunTarget::Folder {
|
||||
path: file.to_string_lossy().to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(file, FolderTargetValidationError::NotDirectory));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_a_non_utf8_canonical_folder_target() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
|
||||
let path = PathBuf::from(OsString::from_vec(vec![b'f', b'o', 0x80]));
|
||||
let error = canonical_folder_text(&path).unwrap_err();
|
||||
|
||||
assert!(matches!(error, FolderTargetValidationError::NonUtf8));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lowers_nested_entrypoints_and_inlines_goal_files() {
|
||||
let (database, _) = crate::test_support::test_store_bundle();
|
||||
|
|
|
|||
|
|
@ -935,6 +935,7 @@ fn preflight_sandbox_spec(
|
|||
run_id: None,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -947,6 +948,7 @@ fn preflight_sandbox_spec(
|
|||
run_id: None,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
api_key: daytona_api_key,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ pub(crate) async fn generate_title_or_current(input: GenerateTitleInput<'_>) ->
|
|||
let result = match generate::generate_object(params, title_response_schema()).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
tracing::debug!(error = %err, "Run title generation failed");
|
||||
tracing::warn!(run_id = %input.prompt.run_id, error = %err, "Run title generation failed");
|
||||
return current_title;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -809,12 +809,12 @@ async fn batch_archive_runs(
|
|||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<BatchRunLifecycleRequest>,
|
||||
) -> Response {
|
||||
batch_run_archive_action(
|
||||
Box::pin(batch_run_archive_action(
|
||||
state,
|
||||
Principal::User(user),
|
||||
request,
|
||||
ArchiveAction::Archive,
|
||||
)
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
@ -823,12 +823,12 @@ async fn batch_unarchive_runs(
|
|||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<BatchRunLifecycleRequest>,
|
||||
) -> Response {
|
||||
batch_run_archive_action(
|
||||
Box::pin(batch_run_archive_action(
|
||||
state,
|
||||
Principal::User(user),
|
||||
request,
|
||||
ArchiveAction::Unarchive,
|
||||
)
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ use crate::run_compiler::{
|
|||
};
|
||||
use crate::run_files::{list_run_commits, list_run_files};
|
||||
use crate::run_intent::{
|
||||
EnvironmentSelectionError, RunIntentAdmissionError, lower_workflow_closure,
|
||||
pin_workflow_environment_authority,
|
||||
EnvironmentSelectionError, PreparedIntentTarget, RunIntentAdmissionError,
|
||||
lower_workflow_closure, pin_workflow_environment_authority, prepare_intent_target,
|
||||
};
|
||||
use crate::run_manifest;
|
||||
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
|
||||
|
|
@ -697,6 +697,9 @@ async fn create_run_from_intent(
|
|||
let raw_compiler_input = RawRunCompilerInput {
|
||||
workflow_bundle: lowered.workflow_bundle,
|
||||
entrypoint: lowered.entrypoint,
|
||||
// Intent compilation is isolated from target-project content. Folder
|
||||
// identity is projected to `source_directory` during persistence and
|
||||
// must never become a compiler lookup root.
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
server_run_defaults: state.manifest_run_defaults().as_ref().clone(),
|
||||
server_environment_defaults: state.environment_store().catalog_layer().as_ref().clone(),
|
||||
|
|
@ -712,11 +715,13 @@ async fn create_run_from_intent(
|
|||
run_id: None,
|
||||
title,
|
||||
parent_id: intent.parent_id,
|
||||
git,
|
||||
// Target identity and its Git projection are attached after provider
|
||||
// admission via `with_target_and_git`; the compiler never reads them.
|
||||
git: None,
|
||||
storage_root: state.server_storage_dir(),
|
||||
workflow_slug: None,
|
||||
workflow_version_id: Some(intent.workflow_version_id),
|
||||
target: Some(target.clone()),
|
||||
target: None,
|
||||
provenance: run_provenance(&headers, &actor),
|
||||
web_url: None,
|
||||
submitted_manifest_bytes: None,
|
||||
|
|
@ -738,13 +743,18 @@ async fn create_run_from_intent(
|
|||
});
|
||||
}
|
||||
};
|
||||
let prepared = match run_compiler::apply_run_variables(layered, vars) {
|
||||
let mut prepared = match run_compiler::apply_run_variables(layered, vars) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return run_intent_admission_error(error.into()),
|
||||
};
|
||||
if let Err(error) = validate_intent_environment(&state, prepared.settings(), &target).await {
|
||||
return run_intent_admission_error(error.into());
|
||||
}
|
||||
let PreparedIntentTarget { target, git } = match prepare_intent_target(target, git).await {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return run_intent_admission_error(error.into()),
|
||||
};
|
||||
prepared = prepared.with_target_and_git(target, git);
|
||||
let (prepared, run_id) = prepared.resolve_run_id();
|
||||
if let Err(response) = validate_optional_parent(&state, run_id, prepared.parent_id()).await {
|
||||
return response;
|
||||
|
|
@ -978,7 +988,9 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
|
|||
"Run intent admission rejected"
|
||||
);
|
||||
}
|
||||
RunIntentAdmissionError::Target(_) | RunIntentAdmissionError::Environment(_) => {}
|
||||
RunIntentAdmissionError::Target(_)
|
||||
| RunIntentAdmissionError::FolderTarget(_)
|
||||
| RunIntentAdmissionError::Environment(_) => {}
|
||||
}
|
||||
|
||||
match error {
|
||||
|
|
@ -997,6 +1009,11 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
|
|||
error.to_string(),
|
||||
"target_invalid",
|
||||
),
|
||||
RunIntentAdmissionError::FolderTarget(error) => intent_error(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
error.to_string(),
|
||||
"target_invalid",
|
||||
),
|
||||
RunIntentAdmissionError::Environment(error) => match error {
|
||||
EnvironmentSelectionError::InvalidId { source, .. } => intent_error(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
|
@ -1074,7 +1091,7 @@ async fn validate_intent_environment(
|
|||
SandboxProviderKind::Daytona => image.docker.is_some(),
|
||||
};
|
||||
let (target_incompatible, detail) = match target {
|
||||
RunTarget::Git { .. } => (
|
||||
RunTarget::Git(_) => (
|
||||
provider == SandboxProviderKind::Local || !settings.run.clone.enabled,
|
||||
"Git targets require a compatible clone-enabled Docker or Daytona environment",
|
||||
),
|
||||
|
|
@ -1082,6 +1099,10 @@ async fn validate_intent_environment(
|
|||
provider == SandboxProviderKind::Local,
|
||||
"none targets require a compatible Docker or Daytona environment",
|
||||
),
|
||||
RunTarget::Folder { .. } => (
|
||||
provider != SandboxProviderKind::Local,
|
||||
"folder targets require a Local environment",
|
||||
),
|
||||
};
|
||||
if image_incompatible || target_incompatible {
|
||||
return Err(EnvironmentSelectionError::TargetUnsupported { detail });
|
||||
|
|
@ -1384,7 +1405,7 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) {
|
|||
let run_store = match task.state.stores.runs.open_run(&task.run_id).await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
tracing::debug!(run_id = %task.run_id, error = %err, "Failed to open run store for title update");
|
||||
tracing::warn!(run_id = %task.run_id, error = %err, "Failed to open run store for title update");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
|
@ -1402,7 +1423,7 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) {
|
|||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(run_id = %task.run_id, error = %err, "Failed to append generated run title event");
|
||||
tracing::warn!(run_id = %task.run_id, error = %err, "Failed to append generated run title event");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1169,7 +1169,10 @@ async fn drive_agent_session(
|
|||
result = &mut process => {
|
||||
while let Ok(event) = receiver.try_recv() {
|
||||
record_turn_output(output, &event);
|
||||
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
|
||||
Box::pin(persist_agent_event(
|
||||
run_store, run_id, session_id, turn_id, event, sender,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
|
@ -1177,7 +1180,10 @@ async fn drive_agent_session(
|
|||
match event {
|
||||
Ok(event) => {
|
||||
record_turn_output(output, &event);
|
||||
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
|
||||
Box::pin(persist_agent_event(
|
||||
run_store, run_id, session_id, turn_id, event, sender,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
Err(RecvError::Lagged(_) | RecvError::Closed) => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3529,21 +3529,37 @@ async fn generated_title_does_not_overwrite_user_title_edit() {
|
|||
}
|
||||
|
||||
async fn post_run_manifest(app: &Router, manifest: serde_json::Value) -> serde_json::Value {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(manifest.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = post_run_intent_response(app, manifest).await;
|
||||
response_json!(response, StatusCode::CREATED).await
|
||||
}
|
||||
|
||||
async fn post_run_intent_response(app: &Router, intent: serde_json::Value) -> Response {
|
||||
app.clone()
|
||||
.oneshot(json_request(Method::POST, "/runs", &intent))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// App state whose default environment runs in place on the server, which is
|
||||
/// the only placement folder targets admit.
|
||||
fn local_test_app_state() -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Local))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build()
|
||||
}
|
||||
|
||||
fn folder_intent(
|
||||
workflow_version_id: fabro_types::WorkflowVersionId,
|
||||
path: impl serde::Serialize,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"workflow_version_id": workflow_version_id,
|
||||
"target": { "kind": "folder", "path": path },
|
||||
"args": {}
|
||||
})
|
||||
}
|
||||
|
||||
async fn store_workflow_version(
|
||||
state: &AppState,
|
||||
graph: &str,
|
||||
|
|
@ -3582,7 +3598,7 @@ async fn store_workflow_version(
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_creates_submitted_version_backed_git_target_without_starting() {
|
||||
async fn post_runs_run_intent_persists_tagged_exact_git_target_without_starting() {
|
||||
let state = test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(
|
||||
|
|
@ -3622,6 +3638,7 @@ docker = "workflow-owned:latest"
|
|||
"kind": "git",
|
||||
"repo": "fabro-sh/fabro",
|
||||
"branch": "feature/run-intent",
|
||||
"tag": "v1.2.3",
|
||||
"sha": submitted_sha
|
||||
},
|
||||
"args": {
|
||||
|
|
@ -3655,11 +3672,12 @@ docker = "workflow-owned:latest"
|
|||
);
|
||||
assert_eq!(
|
||||
projection.spec.target,
|
||||
Some(fabro_types::RunTarget::Git {
|
||||
Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||
})
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
projection
|
||||
|
|
@ -3759,6 +3777,219 @@ async fn post_runs_run_intent_creates_submitted_none_target_without_git_projecti
|
|||
assert!(projection.spec.definition_blob.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_canonicalizes_and_persists_a_local_folder_target() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("workspace");
|
||||
let hop = dir.path().join("hop");
|
||||
std::fs::create_dir(&workspace).unwrap();
|
||||
std::fs::create_dir(&hop).unwrap();
|
||||
// Target-project files are not compiler inputs for a version-backed run.
|
||||
std::fs::write(workspace.join("workflow.toml"), "not valid TOML").unwrap();
|
||||
std::fs::write(workspace.join("goal.md"), "Goal from target folder").unwrap();
|
||||
let submitted = hop.join("..").join("workspace");
|
||||
let canonical = workspace
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let state = local_test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(
|
||||
&state,
|
||||
MINIMAL_DOT,
|
||||
Some("_version = 1\n[run.goal]\nfile = \"goal.md\"\n"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let body = post_run_manifest(
|
||||
&app,
|
||||
folder_intent(workflow_version_id, submitted.to_string_lossy()),
|
||||
)
|
||||
.await;
|
||||
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
||||
|
||||
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
|
||||
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
|
||||
let events = run_store.list_events().await.unwrap();
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.map(|event| event.event.event_name())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["run.created", "run.submitted"]
|
||||
);
|
||||
let projection = run_store.state().await.unwrap();
|
||||
assert_eq!(
|
||||
projection.spec.target,
|
||||
Some(fabro_types::RunTarget::Folder {
|
||||
path: canonical.clone(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
projection.spec.source_directory.as_deref(),
|
||||
Some(canonical.as_str())
|
||||
);
|
||||
assert_eq!(projection.spec.git, None);
|
||||
assert_eq!(
|
||||
projection.spec.graph.goal(),
|
||||
"Goal loaded from immutable version bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
projection.spec.settings.run.environment.provider,
|
||||
EnvironmentProvider::Local
|
||||
);
|
||||
assert_eq!(projection.spec.manifest_blob, None);
|
||||
assert!(projection.spec.definition_blob.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_observes_folder_git_metadata_without_a_remote_call() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||
let mut index = repo.index().unwrap();
|
||||
let tree_id = index.write_tree().unwrap();
|
||||
drop(index);
|
||||
let tree = repo.find_tree(tree_id).unwrap();
|
||||
let signature = git2::Signature::now("Fabro Test", "fabro@example.com").unwrap();
|
||||
let commit = repo
|
||||
.commit(Some("HEAD"), &signature, &signature, "initial", &tree, &[])
|
||||
.unwrap();
|
||||
let commit = commit.to_string();
|
||||
drop(tree);
|
||||
repo.remote("origin", "https://github.com/acme/widgets.git")
|
||||
.unwrap();
|
||||
drop(repo);
|
||||
let canonical = dir
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let state = local_test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
|
||||
let body = post_run_manifest(&app, folder_intent(workflow_version_id, canonical)).await;
|
||||
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
||||
let projection = state
|
||||
.stores
|
||||
.runs
|
||||
.open_run_reader(&run_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.state()
|
||||
.await
|
||||
.unwrap();
|
||||
let git = projection.spec.git.unwrap();
|
||||
|
||||
assert_eq!(git.origin_url, "https://github.com/acme/widgets");
|
||||
assert!(!git.branch.is_empty());
|
||||
assert_eq!(git.sha.as_deref(), Some(commit.as_str()));
|
||||
assert_eq!(git.dirty, fabro_types::DirtyStatus::Clean);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_rejects_invalid_folder_paths_before_persistence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("file");
|
||||
std::fs::write(&file, "not a directory").unwrap();
|
||||
let state = local_test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let invalid_paths = [
|
||||
String::new(),
|
||||
"relative/path".to_string(),
|
||||
dir.path().join("missing").to_string_lossy().to_string(),
|
||||
file.to_string_lossy().to_string(),
|
||||
];
|
||||
|
||||
for path in invalid_paths {
|
||||
let response =
|
||||
post_run_intent_response(&app, folder_intent(workflow_version_id, path)).await;
|
||||
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
assert_eq!(body["errors"][0]["code"], "target_invalid");
|
||||
}
|
||||
|
||||
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
|
||||
assert!(
|
||||
state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_applies_the_folder_target_environment_matrix() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// A missing path proves provider admission wins over filesystem
|
||||
// materialization. Touching the path first would return `target_invalid`
|
||||
// instead of the provider-specific errors asserted below.
|
||||
let target = dir.path().join("missing").to_string_lossy().to_string();
|
||||
|
||||
for state in [
|
||||
test_app_state(),
|
||||
TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Daytona))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build(),
|
||||
] {
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let response =
|
||||
post_run_intent_response(&app, folder_intent(workflow_version_id, &target)).await;
|
||||
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
assert_eq!(body["errors"][0]["code"], "target_environment_unsupported");
|
||||
assert!(
|
||||
state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
let disabled_state = TestAppStateBuilder::new()
|
||||
.runtime_settings(
|
||||
server_settings_from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth]
|
||||
methods = ["dev-token"]
|
||||
|
||||
[server.sandbox.providers.local]
|
||||
enabled = false
|
||||
"#,
|
||||
),
|
||||
RunLayer::default(),
|
||||
)
|
||||
.default_environment_provider(Some(EnvironmentProvider::Local))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&disabled_state));
|
||||
let workflow_version_id = store_workflow_version(&disabled_state, MINIMAL_DOT, None).await;
|
||||
let response =
|
||||
post_run_intent_response(&app, folder_intent(workflow_version_id, &target)).await;
|
||||
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
|
||||
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
|
||||
assert!(
|
||||
disabled_state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
|
|
@ -3965,10 +4196,7 @@ async fn post_runs_run_intent_maps_missing_version_environment_and_target_errors
|
|||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_rejects_none_target_with_local_environment_before_persistence() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Local))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let state = local_test_app_state();
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let response = app
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ async fn shell_reports_real_docker_process_outcome() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub(crate) enum CloneDecision {
|
|||
GitHub {
|
||||
origin_url: String,
|
||||
branch: Option<String>,
|
||||
tag: Option<String>,
|
||||
commit_sha: Option<String>,
|
||||
},
|
||||
}
|
||||
|
|
@ -82,17 +83,87 @@ pub(crate) fn exact_repository_init_command(clone_url: &str, checkout_path: &str
|
|||
)
|
||||
}
|
||||
|
||||
/// Fetch a single admitted commit with the same history depth a branch clone
|
||||
/// A revision the checkout is pinned to instead of the branch's current HEAD.
|
||||
///
|
||||
/// The working branch names the checkout the run works on; it never constrains
|
||||
/// which revision is fetched. No layer proves branch/revision ancestry, and an
|
||||
/// unavailable revision fails without falling back to branch HEAD.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum PinnedRevision {
|
||||
/// An exact commit SHA, already normalized by
|
||||
/// [`normalize_exact_commit_sha`].
|
||||
Commit(String),
|
||||
/// A bare tag name, fetched as `refs/tags/<tag>` so a same-named branch is
|
||||
/// never consulted.
|
||||
Tag(String),
|
||||
}
|
||||
|
||||
impl PinnedRevision {
|
||||
/// An exact commit is authoritative over a tag; the tag stays on the run
|
||||
/// target as durable identity but does not drive the checkout.
|
||||
pub(crate) fn from_selectors(tag: Option<&str>, commit_sha: Option<&str>) -> Option<Self> {
|
||||
match (commit_sha, tag) {
|
||||
(Some(sha), _) => Some(Self::Commit(sha.to_string())),
|
||||
(None, Some(tag)) => Some(Self::Tag(tag.to_string())),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable prefix for error messages.
|
||||
pub(crate) fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Commit(_) => "Exact commit checkout",
|
||||
Self::Tag(_) => "Tag checkout",
|
||||
}
|
||||
}
|
||||
|
||||
/// The refspec handed to `git fetch`.
|
||||
pub(crate) fn fetch_refspec(&self) -> String {
|
||||
match self {
|
||||
Self::Commit(sha) => sha.clone(),
|
||||
Self::Tag(tag) => tag_ref(tag),
|
||||
}
|
||||
}
|
||||
|
||||
/// The commit HEAD must resolve to after checkout, when one is known.
|
||||
pub(crate) fn expected_sha(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Commit(sha) => Some(sha),
|
||||
Self::Tag(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the `rev-parse HEAD` output of a pinned checkout and return the
|
||||
/// resolved commit ID.
|
||||
pub(crate) fn verify_head(&self, output: &str) -> crate::Result<String> {
|
||||
let actual_sha = verify_resolved_head(output)?;
|
||||
if self
|
||||
.expected_sha()
|
||||
.is_some_and(|expected| expected != actual_sha)
|
||||
{
|
||||
return Err(crate::Error::message(
|
||||
"Exact checkout HEAD did not match the requested commit",
|
||||
));
|
||||
}
|
||||
Ok(actual_sha)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fully-qualified ref for a bare tag name.
|
||||
pub(crate) fn tag_ref(tag: &str) -> String {
|
||||
format!("refs/tags/{tag}")
|
||||
}
|
||||
|
||||
/// Fetch a single pinned refspec with the same history depth a branch clone
|
||||
/// gets, so both paths can reach the same number of parent commits.
|
||||
///
|
||||
/// The fetch names the commit directly rather than the branch. No layer proves
|
||||
/// that the submitted commit belongs to the submitted branch: the branch names
|
||||
/// the working branch, while a fetchable exact commit is checked out as-is.
|
||||
/// The fetch names the revision directly rather than the branch, and
|
||||
/// `--no-tags` keeps unrelated tags from being pulled alongside it.
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) fn exact_fetch_command(
|
||||
pub(crate) fn pinned_fetch_command(
|
||||
checkout_path: &str,
|
||||
fetch_source: &str,
|
||||
commit_sha: &str,
|
||||
refspec: &str,
|
||||
depth: Option<usize>,
|
||||
) -> String {
|
||||
let depth_arg = depth_argument(depth);
|
||||
|
|
@ -100,7 +171,7 @@ pub(crate) fn exact_fetch_command(
|
|||
"{git} -C {} fetch{depth_arg} --no-tags {} -- {}",
|
||||
sandbox::shell_quote(checkout_path),
|
||||
sandbox::shell_quote(fetch_source),
|
||||
sandbox::shell_quote(commit_sha),
|
||||
sandbox::shell_quote(refspec),
|
||||
git = sandbox::GIT,
|
||||
)
|
||||
}
|
||||
|
|
@ -131,7 +202,8 @@ pub(crate) fn exact_branch_checkout_command(
|
|||
)
|
||||
}
|
||||
|
||||
/// Print the current HEAD commit and nothing else, for [`verify_exact_head`].
|
||||
/// Print the current HEAD commit and nothing else, for
|
||||
/// [`PinnedRevision::verify_head`].
|
||||
pub(crate) fn exact_head_revision_command(checkout_path: &str) -> String {
|
||||
format!(
|
||||
"{git} -C {path} rev-parse HEAD",
|
||||
|
|
@ -141,7 +213,8 @@ pub(crate) fn exact_head_revision_command(checkout_path: &str) -> String {
|
|||
}
|
||||
|
||||
/// Check out the admitted branch and print the resulting HEAD in one shell
|
||||
/// command; stdout is the `rev-parse HEAD` output for [`verify_exact_head`].
|
||||
/// command; stdout is the `rev-parse HEAD` output for
|
||||
/// [`PinnedRevision::verify_head`].
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) fn exact_checkout_verify_command(
|
||||
checkout_path: &str,
|
||||
|
|
@ -155,17 +228,17 @@ pub(crate) fn exact_checkout_verify_command(
|
|||
)
|
||||
}
|
||||
|
||||
pub(crate) fn verify_exact_head(output: &str, expected_sha: &str) -> crate::Result<()> {
|
||||
let actual_sha = output.trim();
|
||||
let actual_sha = normalize_exact_commit_sha(actual_sha).map_err(|err| {
|
||||
crate::Error::context("Exact checkout produced an invalid HEAD commit ID", err)
|
||||
})?;
|
||||
if actual_sha != expected_sha {
|
||||
return Err(crate::Error::message(
|
||||
"Exact checkout HEAD did not match the requested commit",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
/// The peeled commit behind whatever `git fetch` just wrote to `FETCH_HEAD`;
|
||||
/// a commit peels to itself, an annotated tag to the commit it points at.
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) const FETCH_HEAD_COMMIT: &str = "FETCH_HEAD^{commit}";
|
||||
|
||||
/// Validate that a `rev-parse HEAD` output is a single commit ID and return it
|
||||
/// normalized.
|
||||
pub(crate) fn verify_resolved_head(output: &str) -> crate::Result<String> {
|
||||
normalize_exact_commit_sha(output.trim()).map_err(|err| {
|
||||
crate::Error::context("Pinned checkout produced an invalid HEAD commit ID", err)
|
||||
})
|
||||
}
|
||||
|
||||
fn trim_root(root: &str) -> &str {
|
||||
|
|
@ -194,31 +267,39 @@ pub(crate) fn decide_clone(
|
|||
skip_clone: bool,
|
||||
clone_origin_url: Option<&str>,
|
||||
clone_branch: Option<&str>,
|
||||
clone_tag: Option<&str>,
|
||||
clone_commit_sha: Option<&str>,
|
||||
) -> crate::Result<CloneDecision> {
|
||||
if clone_tag.is_some_and(|tag| tag.trim().is_empty()) {
|
||||
return Err(crate::Error::message(
|
||||
"Tag checkout requires a non-empty tag",
|
||||
));
|
||||
}
|
||||
let tag = clone_tag.map(str::to_string);
|
||||
let commit_sha = clone_commit_sha
|
||||
.map(normalize_exact_commit_sha)
|
||||
.transpose()?;
|
||||
|
||||
if commit_sha.is_some() {
|
||||
if let Some(pin) = PinnedRevision::from_selectors(tag.as_deref(), commit_sha.as_deref()) {
|
||||
let selector = pin.label();
|
||||
if skip_clone {
|
||||
return Err(crate::Error::message(
|
||||
"Exact commit checkout requires cloning to be enabled",
|
||||
));
|
||||
return Err(crate::Error::message(format!(
|
||||
"{selector} requires cloning to be enabled"
|
||||
)));
|
||||
}
|
||||
if clone_origin_url.is_none_or(|url| url.trim().is_empty()) {
|
||||
return Err(crate::Error::message(
|
||||
"Exact commit checkout requires a repository origin",
|
||||
));
|
||||
return Err(crate::Error::message(format!(
|
||||
"{selector} requires a repository origin"
|
||||
)));
|
||||
}
|
||||
// The branch names the checkout the run works on; it is not used to
|
||||
// constrain which commits may be fetched. No layer proves branch/SHA
|
||||
// ancestry, and an unavailable exact commit fails without falling back
|
||||
// to branch HEAD.
|
||||
if clone_branch.is_none_or(|branch| branch.trim().is_empty()) {
|
||||
return Err(crate::Error::message(
|
||||
"Exact commit checkout requires a repository branch",
|
||||
));
|
||||
return Err(crate::Error::message(format!(
|
||||
"{selector} requires a repository branch"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,6 +327,7 @@ pub(crate) fn decide_clone(
|
|||
branch: clone_branch
|
||||
.filter(|branch| !branch.trim().is_empty())
|
||||
.map(str::to_string),
|
||||
tag,
|
||||
commit_sha,
|
||||
})
|
||||
}
|
||||
|
|
@ -267,7 +349,7 @@ pub(crate) fn repo_cloned_for_record(
|
|||
clone_origin_url: Option<&str>,
|
||||
) -> Option<bool> {
|
||||
Some(matches!(
|
||||
decide_clone(skip_clone, clone_origin_url, None, None).ok()?,
|
||||
decide_clone(skip_clone, clone_origin_url, None, None, None).ok()?,
|
||||
CloneDecision::GitHub { .. }
|
||||
))
|
||||
}
|
||||
|
|
@ -307,17 +389,8 @@ mod tests {
|
|||
String::from_utf8(output.stdout).expect("git output should be UTF-8")
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "hermetic command-builder proof intentionally runs local Bash synchronously"
|
||||
)]
|
||||
fn run_shell(cwd: &Path, command: &str) -> String {
|
||||
let output = isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
|
||||
"--noprofile",
|
||||
"--norc",
|
||||
"-c",
|
||||
command,
|
||||
]));
|
||||
let output = run_shell_output(cwd, command);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"command failed: {}",
|
||||
|
|
@ -326,6 +399,19 @@ mod tests {
|
|||
String::from_utf8(output.stdout).expect("command output should be UTF-8")
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "hermetic command-builder proof intentionally runs local Bash synchronously"
|
||||
)]
|
||||
fn run_shell_output(cwd: &Path, command: &str) -> Output {
|
||||
isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
|
||||
"--noprofile",
|
||||
"--norc",
|
||||
"-c",
|
||||
command,
|
||||
]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_clone_overrides_present_origin() {
|
||||
assert_eq!(
|
||||
|
|
@ -334,6 +420,7 @@ mod tests {
|
|||
Some("https://gitlab.com/acme/widgets.git"),
|
||||
Some("main"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::EmptyWorkspace {
|
||||
|
|
@ -345,7 +432,7 @@ mod tests {
|
|||
#[test]
|
||||
fn missing_origin_creates_empty_workspace() {
|
||||
assert_eq!(
|
||||
decide_clone(false, None, None, None).unwrap(),
|
||||
decide_clone(false, None, None, None, None).unwrap(),
|
||||
CloneDecision::EmptyWorkspace {
|
||||
reason: EmptyWorkspaceReason::MissingOrigin,
|
||||
}
|
||||
|
|
@ -360,16 +447,63 @@ mod tests {
|
|||
Some("git@github.com:acme/widgets.git"),
|
||||
Some("feature/work"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::GitHub {
|
||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||
branch: Some("feature/work".to_string()),
|
||||
tag: None,
|
||||
commit_sha: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_clone_keeps_working_branch_and_bare_tag_distinct() {
|
||||
assert_eq!(
|
||||
decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("release"),
|
||||
Some("v1.2.3"),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::GitHub {
|
||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||
branch: Some("release".to_string()),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
commit_sha: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_revision_prefers_exact_commit_and_qualifies_tags() {
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
assert_eq!(PinnedRevision::from_selectors(None, None), None);
|
||||
let tag = PinnedRevision::from_selectors(Some("release/v1"), None).unwrap();
|
||||
assert_eq!(tag.fetch_refspec(), "refs/tags/release/v1");
|
||||
assert_eq!(tag.expected_sha(), None);
|
||||
let commit = PinnedRevision::from_selectors(Some("release/v1"), Some(sha)).unwrap();
|
||||
assert_eq!(commit.fetch_refspec(), sha);
|
||||
assert_eq!(commit.expected_sha(), Some(sha));
|
||||
assert_eq!(
|
||||
pinned_fetch_command(
|
||||
"/repos/acme/widgets",
|
||||
"origin",
|
||||
&tag.fetch_refspec(),
|
||||
Some(10)
|
||||
),
|
||||
"git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets fetch --depth 10 --no-tags origin -- refs/tags/release/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
exact_checkout_verify_command("/repos/acme/widgets", "release", FETCH_HEAD_COMMIT),
|
||||
"git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets checkout -B release FETCH_HEAD'^{commit}' && git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets rev-parse HEAD"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_github_origin_fails_without_skip_clone() {
|
||||
let error = decide_clone(
|
||||
|
|
@ -377,6 +511,7 @@ mod tests {
|
|||
Some("https://gitlab.com/acme/widgets.git"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("non-GitHub origins should fail");
|
||||
assert!(error.to_string().contains("GitHub repository origins only"));
|
||||
|
|
@ -392,12 +527,14 @@ mod tests {
|
|||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("moving-branch"),
|
||||
Some("release"),
|
||||
Some(lowercase),
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::GitHub {
|
||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||
branch: Some("moving-branch".to_string()),
|
||||
tag: Some("release".to_string()),
|
||||
commit_sha: Some(lowercase.to_string()),
|
||||
}
|
||||
);
|
||||
|
|
@ -406,12 +543,14 @@ mod tests {
|
|||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("main"),
|
||||
None,
|
||||
Some(uppercase),
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::GitHub {
|
||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||
branch: Some("main".to_string()),
|
||||
tag: None,
|
||||
commit_sha: Some(uppercase.to_ascii_lowercase()),
|
||||
}
|
||||
);
|
||||
|
|
@ -432,6 +571,7 @@ mod tests {
|
|||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
None,
|
||||
None,
|
||||
Some(sha),
|
||||
)
|
||||
.expect_err("invalid exact commit SHA should fail");
|
||||
|
|
@ -443,33 +583,50 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn exact_checkout_requires_clone_origin_and_branch() {
|
||||
fn pinned_checkout_requires_clone_origin_and_branch() {
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
let skip_error = decide_clone(
|
||||
true,
|
||||
for (tag, commit_sha) in [(None, Some(sha)), (Some("v1"), None)] {
|
||||
let skip_error = decide_clone(
|
||||
true,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("main"),
|
||||
tag,
|
||||
commit_sha,
|
||||
)
|
||||
.expect_err("pinned checkout with skip-clone should fail");
|
||||
assert!(skip_error.to_string().contains("requires cloning"));
|
||||
|
||||
for origin in [None, Some(""), Some(" ")] {
|
||||
let error = decide_clone(false, origin, Some("main"), tag, commit_sha)
|
||||
.expect_err("pinned checkout without an origin should fail");
|
||||
assert!(error.to_string().contains("requires a repository origin"));
|
||||
}
|
||||
|
||||
for branch in [None, Some(""), Some(" ")] {
|
||||
let error = decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
branch,
|
||||
tag,
|
||||
commit_sha,
|
||||
)
|
||||
.expect_err("pinned checkout without a branch should fail");
|
||||
assert!(error.to_string().contains("requires a repository branch"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_checkout_rejects_empty_tag() {
|
||||
let empty_tag = decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("main"),
|
||||
Some(sha),
|
||||
Some(""),
|
||||
None,
|
||||
)
|
||||
.expect_err("exact checkout with skip-clone should fail");
|
||||
assert!(skip_error.to_string().contains("requires cloning"));
|
||||
|
||||
for origin in [None, Some(""), Some(" ")] {
|
||||
let error = decide_clone(false, origin, Some("main"), Some(sha))
|
||||
.expect_err("exact checkout without an origin should fail");
|
||||
assert!(error.to_string().contains("requires a repository origin"));
|
||||
}
|
||||
|
||||
for branch in [None, Some(""), Some(" ")] {
|
||||
let error = decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
branch,
|
||||
Some(sha),
|
||||
)
|
||||
.expect_err("exact checkout without a branch should fail");
|
||||
assert!(error.to_string().contains("requires a repository branch"));
|
||||
}
|
||||
.expect_err("empty tags should fail");
|
||||
assert!(empty_tag.to_string().contains("non-empty tag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -479,7 +636,7 @@ mod tests {
|
|||
"https://token@example.com/acme/widgets.git?x=a b",
|
||||
"/repos/acme's widgets",
|
||||
);
|
||||
let fetch = exact_fetch_command(
|
||||
let fetch = pinned_fetch_command(
|
||||
"/repos/acme's widgets",
|
||||
"https://token@example.com/acme/widgets.git?x=a b",
|
||||
sha,
|
||||
|
|
@ -503,9 +660,9 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn exact_fetch_omits_depth_for_full_history() {
|
||||
fn pinned_fetch_omits_depth_for_full_history() {
|
||||
assert_eq!(
|
||||
exact_fetch_command(
|
||||
pinned_fetch_command(
|
||||
"/repos/acme/widgets",
|
||||
"origin",
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
|
|
@ -518,15 +675,18 @@ mod tests {
|
|||
#[test]
|
||||
fn exact_checkout_verification_rejects_invalid_or_mismatched_head() {
|
||||
let expected = "0123456789abcdef0123456789abcdef01234567";
|
||||
verify_exact_head("0123456789ABCDEF0123456789ABCDEF01234567\n", expected)
|
||||
let pin = PinnedRevision::Commit(expected.to_string());
|
||||
pin.verify_head("0123456789ABCDEF0123456789ABCDEF01234567\n")
|
||||
.expect("uppercase command output should normalize");
|
||||
|
||||
let invalid = verify_exact_head("fatal: not a revision", expected)
|
||||
let invalid = pin
|
||||
.verify_head("fatal: not a revision")
|
||||
.expect_err("non-SHA output should fail verification");
|
||||
assert!(invalid.to_string().contains("invalid HEAD commit ID"));
|
||||
assert!(!invalid.to_string().contains("fatal: not a revision"));
|
||||
|
||||
let mismatched = verify_exact_head("1123456789abcdef0123456789abcdef01234567", expected)
|
||||
let mismatched = pin
|
||||
.verify_head("1123456789abcdef0123456789abcdef01234567")
|
||||
.expect_err("mismatched SHA should fail verification");
|
||||
assert!(mismatched.to_string().contains("did not match"));
|
||||
}
|
||||
|
|
@ -536,7 +696,7 @@ mod tests {
|
|||
clippy::disallowed_methods,
|
||||
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
|
||||
)]
|
||||
fn exact_checkout_fetches_admitted_commit_after_branch_advances() {
|
||||
fn exact_checkout_fetches_admitted_commit_after_branch_and_tag_advance() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let remote = temp.path().join("remote.git");
|
||||
let source = temp.path().join("source");
|
||||
|
|
@ -561,10 +721,14 @@ mod tests {
|
|||
]);
|
||||
run_git(&source, &["push", "-u", "origin", "main"]);
|
||||
let admitted_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
run_git(&source, &["tag", "release"]);
|
||||
run_git(&source, &["push", "origin", "refs/tags/release"]);
|
||||
|
||||
fs::write(source.join("revision.txt"), "B\n").expect("write commit B");
|
||||
run_git(&source, &["commit", "-am", "commit B"]);
|
||||
run_git(&source, &["push", "origin", "main"]);
|
||||
run_git(&source, &["tag", "-f", "release"]);
|
||||
run_git(&source, &["push", "--force", "origin", "refs/tags/release"]);
|
||||
let advanced_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
assert_ne!(admitted_sha, advanced_sha);
|
||||
|
||||
|
|
@ -576,11 +740,11 @@ mod tests {
|
|||
);
|
||||
run_shell(
|
||||
temp.path(),
|
||||
&exact_fetch_command(checkout_path, remote_path, &admitted_sha, Some(10)),
|
||||
&pinned_fetch_command(checkout_path, remote_path, &admitted_sha, Some(10)),
|
||||
);
|
||||
let checked_out_sha = run_shell(
|
||||
temp.path(),
|
||||
&exact_checkout_verify_command(checkout_path, "main", "FETCH_HEAD"),
|
||||
&exact_checkout_verify_command(checkout_path, "main", FETCH_HEAD_COMMIT),
|
||||
);
|
||||
|
||||
assert_eq!(checked_out_sha.trim(), admitted_sha);
|
||||
|
|
@ -609,6 +773,79 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
|
||||
)]
|
||||
fn tag_checkout_peels_lightweight_and_annotated_tags_without_branch_fallback() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let remote = temp.path().join("remote.git");
|
||||
let source = temp.path().join("source");
|
||||
fs::create_dir(&source).expect("source directory");
|
||||
|
||||
run_git(temp.path(), &[
|
||||
"init",
|
||||
"--bare",
|
||||
remote.to_str().expect("UTF-8 remote path"),
|
||||
]);
|
||||
run_git(&source, &["init"]);
|
||||
fs::write(source.join("revision.txt"), "release\n").expect("write release commit");
|
||||
run_git(&source, &["add", "revision.txt"]);
|
||||
run_git(&source, &["commit", "-m", "release"]);
|
||||
run_git(&source, &["branch", "-M", "main"]);
|
||||
let release_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
run_git(&source, &["tag", "lightweight"]);
|
||||
run_git(&source, &["tag", "-a", "annotated", "-m", "annotated"]);
|
||||
run_git(&source, &[
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
remote.to_str().expect("UTF-8 remote path"),
|
||||
]);
|
||||
run_git(&source, &["push", "origin", "main", "--tags"]);
|
||||
|
||||
let remote_path = remote.to_str().expect("UTF-8 remote path");
|
||||
for tag in ["lightweight", "annotated"] {
|
||||
let checkout = temp.path().join(format!("checkout-{tag}"));
|
||||
let checkout_path = checkout.to_str().expect("UTF-8 checkout path");
|
||||
run_shell(
|
||||
temp.path(),
|
||||
&exact_repository_init_command(remote_path, checkout_path),
|
||||
);
|
||||
run_shell(
|
||||
temp.path(),
|
||||
&pinned_fetch_command(checkout_path, "origin", &tag_ref(tag), Some(10)),
|
||||
);
|
||||
let head = run_shell(
|
||||
temp.path(),
|
||||
&exact_checkout_verify_command(checkout_path, "release-work", FETCH_HEAD_COMMIT),
|
||||
);
|
||||
|
||||
assert_eq!(verify_resolved_head(&head).unwrap(), release_sha);
|
||||
assert_eq!(
|
||||
run_git(&checkout, &["symbolic-ref", "HEAD"]).trim(),
|
||||
"refs/heads/release-work"
|
||||
);
|
||||
}
|
||||
|
||||
let missing = temp.path().join("missing");
|
||||
let missing_path = missing.to_str().expect("UTF-8 checkout path");
|
||||
run_shell(
|
||||
temp.path(),
|
||||
&exact_repository_init_command(remote_path, missing_path),
|
||||
);
|
||||
let output = run_shell_output(
|
||||
temp.path(),
|
||||
&pinned_fetch_command(missing_path, "origin", &tag_ref("main"), Some(10)),
|
||||
);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"a branch must not satisfy a tag fetch"
|
||||
);
|
||||
assert!(!missing.join("revision.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_layout_maps_ssh_origin_to_repos_checkout_and_workspace_link() {
|
||||
let layout = github_repo_layout(
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ use tokio::task::JoinHandle;
|
|||
use tokio::{fs, time};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason};
|
||||
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason, PinnedRevision};
|
||||
use crate::git_retry::{self, CredentialContext, GitRetryReason};
|
||||
use crate::push_credentials::{self, PushCredentialState};
|
||||
use crate::redact::redact_auth_url;
|
||||
|
|
@ -109,6 +109,16 @@ const DAYTONA_STATE_CHANGE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
|||
/// leaked by a dead worker. An explicit `0` disables auto-stop entirely.
|
||||
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120;
|
||||
|
||||
/// The ref Daytona's native clone checks out. A pinned tag is fetched by its
|
||||
/// fully-qualified ref so a same-named branch is never consulted; with an
|
||||
/// exact commit, `commit_id` drives the checkout and the branch is only a name.
|
||||
fn git_clone_selector(branch: Option<&str>, pin: Option<&PinnedRevision>) -> Option<String> {
|
||||
match pin {
|
||||
Some(PinnedRevision::Tag(tag)) => Some(clone_source::tag_ref(tag)),
|
||||
Some(PinnedRevision::Commit(_)) | None => branch.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn daytona_not_found(err: &DaytonaError) -> bool {
|
||||
matches!(err, DaytonaError::NotFound { .. }) || err.status_code() == Some(404)
|
||||
}
|
||||
|
|
@ -464,6 +474,7 @@ pub struct DaytonaSandbox {
|
|||
/// Explicit branch to clone. When set, overrides the branch detected by
|
||||
/// the submitted run spec.
|
||||
clone_branch: Option<String>,
|
||||
clone_tag: Option<String>,
|
||||
clone_commit_sha: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -478,14 +489,16 @@ impl DaytonaSandbox {
|
|||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
clone_tag: Option<String>,
|
||||
clone_commit_sha: Option<String>,
|
||||
api_key: Option<String>,
|
||||
) -> crate::Result<Self> {
|
||||
if clone_commit_sha.is_some() {
|
||||
if clone_tag.is_some() || clone_commit_sha.is_some() {
|
||||
clone_source::decide_clone(
|
||||
config.skip_clone,
|
||||
clone_origin_url.as_deref(),
|
||||
clone_branch.as_deref(),
|
||||
clone_tag.as_deref(),
|
||||
clone_commit_sha.as_deref(),
|
||||
)?;
|
||||
}
|
||||
|
|
@ -512,6 +525,7 @@ impl DaytonaSandbox {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
})
|
||||
}
|
||||
|
|
@ -565,6 +579,7 @@ impl DaytonaSandbox {
|
|||
run_id: None,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
})
|
||||
}
|
||||
|
|
@ -650,25 +665,29 @@ impl DaytonaSandbox {
|
|||
self.fail_init(init_start, err)
|
||||
}
|
||||
|
||||
/// Point the admitted branch at the exact commit and verify the resulting
|
||||
/// HEAD.
|
||||
/// Point the admitted branch at the pinned revision and verify the
|
||||
/// resulting HEAD.
|
||||
///
|
||||
/// Daytona's native clone honors `commit_id`, but leaves the workspace on
|
||||
/// whatever ref its own checkout produced. Re-pointing the branch keeps the
|
||||
/// admitted branch name readable back out of the workspace, matching what
|
||||
/// the Docker provider produces for the same inputs.
|
||||
async fn attach_exact_commit_branch(
|
||||
/// whatever ref its own checkout produced (a detached tag, or the exact
|
||||
/// commit). Re-pointing the branch keeps the admitted branch name readable
|
||||
/// back out of the workspace, matching what the Docker provider produces
|
||||
/// for the same inputs.
|
||||
async fn attach_pinned_branch(
|
||||
process_svc: &daytona_sdk::ProcessService,
|
||||
checkout_path: &str,
|
||||
branch: &str,
|
||||
expected_sha: &str,
|
||||
pin: &PinnedRevision,
|
||||
deadline: time::Instant,
|
||||
) -> crate::Result<()> {
|
||||
// An exact commit is named directly; a tag clone is already sitting on
|
||||
// the tag, so peel whatever HEAD points at to its commit.
|
||||
let revision = pin.expected_sha().unwrap_or("HEAD^{commit}");
|
||||
Self::run_required_post_clone_command(
|
||||
process_svc,
|
||||
&clone_source::exact_branch_checkout_command(checkout_path, branch, expected_sha),
|
||||
&clone_source::exact_branch_checkout_command(checkout_path, branch, revision),
|
||||
"/",
|
||||
"git checkout exact commit",
|
||||
"git checkout pinned revision",
|
||||
deadline,
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -676,11 +695,12 @@ impl DaytonaSandbox {
|
|||
process_svc,
|
||||
&clone_source::exact_head_revision_command(checkout_path),
|
||||
"/",
|
||||
"git rev-parse HEAD after exact checkout",
|
||||
"git rev-parse HEAD after pinned checkout",
|
||||
deadline,
|
||||
)
|
||||
.await?;
|
||||
clone_source::verify_exact_head(&head, expected_sha)
|
||||
pin.verify_head(&head)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute one post-clone command under the shared setup deadline.
|
||||
|
|
@ -1521,6 +1541,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
self.config.skip_clone,
|
||||
self.clone_origin_url.as_deref(),
|
||||
self.clone_branch.as_deref(),
|
||||
self.clone_tag.as_deref(),
|
||||
self.clone_commit_sha.as_deref(),
|
||||
)
|
||||
.map_err(|e| self.fail_init(init_start, e))?;
|
||||
|
|
@ -1549,6 +1570,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
CloneDecision::GitHub {
|
||||
origin_url,
|
||||
branch,
|
||||
tag,
|
||||
commit_sha,
|
||||
} => {
|
||||
let layout =
|
||||
|
|
@ -1647,6 +1669,8 @@ impl Sandbox for DaytonaSandbox {
|
|||
self.fail_init(init_start, err)
|
||||
})?;
|
||||
|
||||
let pin = PinnedRevision::from_selectors(tag.as_deref(), commit_sha.as_deref());
|
||||
let clone_selector = git_clone_selector(branch.as_deref(), pin.as_ref());
|
||||
let clone_plan = git_retry::RetryPlan::clone_default(None);
|
||||
let clone_result = git_retry::retry_git_operation(
|
||||
SandboxProviderKind::Daytona,
|
||||
|
|
@ -1657,7 +1681,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
let origin = origin_url.as_str();
|
||||
let target = layout.primary_repo_path.as_str();
|
||||
let options = GitCloneOptions {
|
||||
branch: branch.clone(),
|
||||
branch: clone_selector.clone(),
|
||||
commit_id: commit_sha.clone(),
|
||||
username: username.clone(),
|
||||
password: password.clone(),
|
||||
|
|
@ -1702,21 +1726,22 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
};
|
||||
|
||||
if let Some(expected_sha) = commit_sha.as_deref() {
|
||||
if let Some(pin) = &pin {
|
||||
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty())
|
||||
else {
|
||||
let err = crate::Error::message(
|
||||
"Exact commit checkout requires a repository branch",
|
||||
);
|
||||
let err = crate::Error::message(format!(
|
||||
"{} requires a repository branch",
|
||||
pin.label()
|
||||
));
|
||||
return Err(self
|
||||
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
|
||||
.await);
|
||||
};
|
||||
if let Err(err) = Self::attach_exact_commit_branch(
|
||||
if let Err(err) = Self::attach_pinned_branch(
|
||||
&process_svc,
|
||||
&layout.primary_repo_path,
|
||||
branch,
|
||||
expected_sha,
|
||||
pin,
|
||||
post_clone_deadline,
|
||||
)
|
||||
.await
|
||||
|
|
@ -3193,6 +3218,25 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::sandbox::BASH_PROBE_MARKER;
|
||||
|
||||
#[test]
|
||||
fn daytona_clone_selector_uses_fully_qualified_tag_unless_sha_is_exact() {
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
let tag = PinnedRevision::from_selectors(Some("v1.2.3"), None);
|
||||
assert_eq!(
|
||||
git_clone_selector(Some("release-work"), tag.as_ref()).as_deref(),
|
||||
Some("refs/tags/v1.2.3")
|
||||
);
|
||||
let commit = PinnedRevision::from_selectors(Some("v1.2.3"), Some(sha));
|
||||
assert_eq!(
|
||||
git_clone_selector(Some("release-work"), commit.as_ref()).as_deref(),
|
||||
Some("release-work")
|
||||
);
|
||||
assert_eq!(
|
||||
git_clone_selector(Some("release-work"), None).as_deref(),
|
||||
Some("release-work")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_exact_sha_fails_before_daytona_client_construction() {
|
||||
let error = DaytonaSandbox::new(
|
||||
|
|
@ -3201,6 +3245,7 @@ mod tests {
|
|||
None,
|
||||
Some("https://github.com/acme/widgets".to_string()),
|
||||
Some("main".to_string()),
|
||||
None,
|
||||
Some("not-a-sha".to_string()),
|
||||
Some("dtn_not_used".to_string()),
|
||||
)
|
||||
|
|
@ -3220,6 +3265,7 @@ mod tests {
|
|||
None,
|
||||
Some("https://github.com/acme/widgets".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
Some("dtn_not_used".to_string()),
|
||||
)
|
||||
|
|
@ -3547,6 +3593,7 @@ mod tests {
|
|||
run_id: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -3726,6 +3773,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dtn_test".to_string()),
|
||||
)
|
||||
.await
|
||||
|
|
@ -3765,6 +3813,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dtn_test".to_string()),
|
||||
)
|
||||
.await
|
||||
|
|
@ -4093,6 +4142,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dtn_test".to_string()),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ pub struct DockerSandbox {
|
|||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
clone_tag: Option<String>,
|
||||
clone_commit_sha: Option<String>,
|
||||
container_id: OnceCell<String>,
|
||||
repo_cloned: OnceCell<bool>,
|
||||
|
|
@ -187,13 +188,15 @@ impl DockerSandbox {
|
|||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
clone_tag: Option<String>,
|
||||
clone_commit_sha: Option<String>,
|
||||
) -> crate::Result<Self> {
|
||||
if clone_commit_sha.is_some() {
|
||||
if clone_tag.is_some() || clone_commit_sha.is_some() {
|
||||
clone_source::decide_clone(
|
||||
config.skip_clone,
|
||||
clone_origin_url.as_deref(),
|
||||
clone_branch.as_deref(),
|
||||
clone_tag.as_deref(),
|
||||
clone_commit_sha.as_deref(),
|
||||
)?;
|
||||
}
|
||||
|
|
@ -205,6 +208,7 @@ impl DockerSandbox {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
)
|
||||
}
|
||||
|
|
@ -216,6 +220,7 @@ impl DockerSandbox {
|
|||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
clone_tag: Option<String>,
|
||||
clone_commit_sha: Option<String>,
|
||||
) -> crate::Result<Self> {
|
||||
let push_credentials = PushCredentialState::new(push_credentials::build_token_source(
|
||||
|
|
@ -229,6 +234,7 @@ impl DockerSandbox {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
container_id: OnceCell::new(),
|
||||
repo_cloned: OnceCell::new(),
|
||||
|
|
@ -256,6 +262,7 @@ impl DockerSandbox {
|
|||
clone_origin_url.clone(),
|
||||
clone_branch,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
sandbox.validate_managed_container(container_id).await?;
|
||||
sandbox
|
||||
|
|
@ -903,6 +910,7 @@ impl DockerSandbox {
|
|||
&self,
|
||||
origin_url: String,
|
||||
branch: Option<String>,
|
||||
tag: Option<String>,
|
||||
commit_sha: Option<String>,
|
||||
) -> crate::Result<()> {
|
||||
self.verify_git_available().await?;
|
||||
|
|
@ -965,13 +973,15 @@ impl DockerSandbox {
|
|||
}
|
||||
|
||||
let clone_deadline = time::Instant::now() + GIT_CLONE_TIMEOUT;
|
||||
if let Some(expected_sha) = commit_sha.as_deref() {
|
||||
// `decide_clone` already rejects an exact commit without a branch;
|
||||
// re-check here so the checkout can never silently drop the branch
|
||||
// name callers read back out of the workspace.
|
||||
if let Some(pin) =
|
||||
clone_source::PinnedRevision::from_selectors(tag.as_deref(), commit_sha.as_deref())
|
||||
{
|
||||
// `decide_clone` already rejects a pinned revision without a
|
||||
// branch; re-check here so the checkout can never silently drop the
|
||||
// branch name callers read back out of the workspace.
|
||||
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty()) else {
|
||||
let error =
|
||||
crate::Error::message("Exact commit checkout requires a repository branch");
|
||||
crate::Error::message(format!("{} requires a repository branch", pin.label()));
|
||||
return Err(self.report_clone_failure(&origin_url, error));
|
||||
};
|
||||
|
||||
|
|
@ -980,7 +990,7 @@ impl DockerSandbox {
|
|||
if let Err(error) = self
|
||||
.run_exact_local_git_command(
|
||||
&init_command,
|
||||
"initialize Docker exact repository checkout",
|
||||
"initialize Docker pinned repository checkout",
|
||||
clone_deadline,
|
||||
auth_url.as_ref(),
|
||||
)
|
||||
|
|
@ -989,18 +999,18 @@ impl DockerSandbox {
|
|||
return Err(self.report_clone_failure(&origin_url, error));
|
||||
}
|
||||
|
||||
let fetch_command = clone_source::exact_fetch_command(
|
||||
let fetch_command = clone_source::pinned_fetch_command(
|
||||
&layout.primary_repo_path,
|
||||
"origin",
|
||||
expected_sha,
|
||||
&pin.fetch_refspec(),
|
||||
self.config.clone_depth,
|
||||
);
|
||||
if let Err(failure) = self
|
||||
.retry_git_transfer(
|
||||
&fetch_command,
|
||||
"fetch",
|
||||
"Docker exact fetch",
|
||||
"git fetch exact commit",
|
||||
"Docker pinned fetch",
|
||||
"git fetch pinned revision",
|
||||
clone_deadline,
|
||||
clone_credential_context,
|
||||
auth_url.as_ref(),
|
||||
|
|
@ -1013,12 +1023,12 @@ impl DockerSandbox {
|
|||
let checkout_command = clone_source::exact_checkout_verify_command(
|
||||
&layout.primary_repo_path,
|
||||
branch,
|
||||
"FETCH_HEAD",
|
||||
clone_source::FETCH_HEAD_COMMIT,
|
||||
);
|
||||
let head = match self
|
||||
.run_exact_local_git_command(
|
||||
&checkout_command,
|
||||
"git checkout exact commit",
|
||||
"git checkout pinned revision",
|
||||
clone_deadline,
|
||||
auth_url.as_ref(),
|
||||
)
|
||||
|
|
@ -1027,7 +1037,7 @@ impl DockerSandbox {
|
|||
Ok(result) => result,
|
||||
Err(error) => return Err(self.report_clone_failure(&origin_url, error)),
|
||||
};
|
||||
if let Err(error) = clone_source::verify_exact_head(&head.stdout, expected_sha) {
|
||||
if let Err(error) = pin.verify_head(&head.stdout) {
|
||||
return Err(self.report_clone_failure(&origin_url, error));
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1826,6 +1836,7 @@ impl Sandbox for DockerSandbox {
|
|||
self.config.skip_clone,
|
||||
self.clone_origin_url.as_deref(),
|
||||
self.clone_branch.as_deref(),
|
||||
self.clone_tag.as_deref(),
|
||||
self.clone_commit_sha.as_deref(),
|
||||
)
|
||||
.map_err(|e| self.fail_init(init_start, e))?;
|
||||
|
|
@ -1847,9 +1858,13 @@ impl Sandbox for DockerSandbox {
|
|||
CloneDecision::GitHub {
|
||||
origin_url,
|
||||
branch,
|
||||
tag,
|
||||
commit_sha,
|
||||
} => {
|
||||
if let Err(e) = self.clone_github_repo(origin_url, branch, commit_sha).await {
|
||||
if let Err(e) = self
|
||||
.clone_github_repo(origin_url, branch, tag, commit_sha)
|
||||
.await
|
||||
{
|
||||
return Err(self.fail_init(init_start, e));
|
||||
}
|
||||
}
|
||||
|
|
@ -2668,6 +2683,7 @@ mod tests {
|
|||
None,
|
||||
Some("https://github.com/acme/widgets".to_string()),
|
||||
Some("main".to_string()),
|
||||
None,
|
||||
Some("not-a-sha".to_string()),
|
||||
)
|
||||
.err()
|
||||
|
|
@ -2685,6 +2701,7 @@ mod tests {
|
|||
None,
|
||||
Some("https://github.com/acme/widgets".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
)
|
||||
.err()
|
||||
|
|
@ -3072,6 +3089,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("test sandbox should build");
|
||||
sandbox
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ impl SandboxProvider for DaytonaSandboxProvider {
|
|||
clone_origin_url,
|
||||
clone_branch,
|
||||
None,
|
||||
None,
|
||||
Some(api_key),
|
||||
)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ impl SandboxProvider for DockerSandboxProvider {
|
|||
clone_origin_url,
|
||||
clone_branch,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
sandbox.initialize().await?;
|
||||
let container_id = sandbox.container_identifier()?.to_string();
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ pub enum SandboxSpec {
|
|||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
clone_tag: Option<String>,
|
||||
clone_commit_sha: Option<String>,
|
||||
},
|
||||
#[cfg(feature = "daytona")]
|
||||
|
|
@ -41,6 +42,7 @@ pub enum SandboxSpec {
|
|||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
clone_tag: Option<String>,
|
||||
clone_commit_sha: Option<String>,
|
||||
api_key: Option<String>,
|
||||
},
|
||||
|
|
@ -204,6 +206,7 @@ impl SandboxSpec {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
} => {
|
||||
let mut sandbox = DockerSandbox::new(
|
||||
|
|
@ -212,6 +215,7 @@ impl SandboxSpec {
|
|||
*run_id,
|
||||
clone_origin_url.clone(),
|
||||
clone_branch.clone(),
|
||||
clone_tag.clone(),
|
||||
clone_commit_sha.clone(),
|
||||
)
|
||||
.context("Failed to create Docker sandbox")?;
|
||||
|
|
@ -227,6 +231,7 @@ impl SandboxSpec {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
api_key,
|
||||
} => {
|
||||
|
|
@ -236,6 +241,7 @@ impl SandboxSpec {
|
|||
*run_id,
|
||||
clone_origin_url.clone(),
|
||||
clone_branch.clone(),
|
||||
clone_tag.clone(),
|
||||
clone_commit_sha.clone(),
|
||||
api_key.clone(),
|
||||
)
|
||||
|
|
@ -282,6 +288,7 @@ mod tests {
|
|||
run_id: None,
|
||||
clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()),
|
||||
clone_branch: Some("main".to_string()),
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
};
|
||||
let mut sandbox = MockSandbox::linux();
|
||||
|
|
@ -320,6 +327,7 @@ mod tests {
|
|||
run_id: None,
|
||||
clone_origin_url: Some("https://github.com/acme/widgets".to_string()),
|
||||
clone_branch: Some("main".to_string()),
|
||||
clone_tag: None,
|
||||
clone_commit_sha: Some("not-a-sha".to_string()),
|
||||
};
|
||||
|
||||
|
|
@ -349,6 +357,7 @@ mod tests {
|
|||
run_id: None,
|
||||
clone_origin_url: Some("https://gitlab.com/acme/widgets".to_string()),
|
||||
clone_branch: None,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
};
|
||||
let mut sandbox = MockSandbox::linux();
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
|
@ -76,6 +77,7 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
sandbox.initialize().await?;
|
||||
|
|
@ -182,6 +184,7 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
@ -232,6 +235,7 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
@ -300,6 +304,7 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
@ -114,6 +115,7 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
@ -176,6 +178,7 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
|
|||
Some("https://github.com/brynary/rack-test".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
@ -243,6 +246,7 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
@ -337,6 +341,7 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
@ -422,6 +427,7 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
use fabro_graphviz::graph::Graph;
|
||||
|
||||
use crate::{Diagnostic, LintRule, Severity};
|
||||
|
||||
pub(super) fn rule() -> Box<dyn LintRule> {
|
||||
Box::new(Rule)
|
||||
}
|
||||
|
||||
/// `auto_status=true` is the deprecated spelling of `on_failure="succeed"`.
|
||||
/// The runtime still honors it as an alias; this rule points workflows at the
|
||||
/// explicit policy.
|
||||
struct Rule;
|
||||
|
||||
impl LintRule for Rule {
|
||||
fn name(&self) -> &'static str {
|
||||
"auto_status_deprecated"
|
||||
}
|
||||
|
||||
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
|
||||
let mut nodes: Vec<_> = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|node| node.attrs.contains_key("auto_status"))
|
||||
.collect();
|
||||
nodes.sort_unstable_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| {
|
||||
let (message, fix) = if node.attrs.contains_key("on_failure") {
|
||||
(
|
||||
format!(
|
||||
"Node '{}' sets deprecated 'auto_status', which is ignored because \
|
||||
'on_failure' is set",
|
||||
node.id
|
||||
),
|
||||
"Remove 'auto_status'",
|
||||
)
|
||||
} else if node.auto_status() {
|
||||
(
|
||||
format!("Node '{}' sets deprecated 'auto_status=true'", node.id),
|
||||
"Use on_failure=\"succeed\" instead",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"Node '{}' sets deprecated 'auto_status', which has no effect \
|
||||
unless it is true",
|
||||
node.id
|
||||
),
|
||||
"Remove 'auto_status'",
|
||||
)
|
||||
};
|
||||
Diagnostic {
|
||||
rule: self.name().to_string(),
|
||||
severity: Severity::Warning,
|
||||
message,
|
||||
node_id: Some(node.id.clone()),
|
||||
fix: Some(fix.to_string()),
|
||||
..Diagnostic::default()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_graphviz::graph::{AttrValue, Graph};
|
||||
|
||||
use super::Rule;
|
||||
use crate::rules::test_support::{minimal_graph, node_with_attrs};
|
||||
use crate::{LintRule, Severity};
|
||||
|
||||
fn graph_with_auto_status(value: AttrValue, on_failure: Option<&str>) -> Graph {
|
||||
let mut graph = minimal_graph();
|
||||
let mut node = match on_failure {
|
||||
Some(policy) => node_with_attrs("work", &[("on_failure", policy)]),
|
||||
None => node_with_attrs("work", &[]),
|
||||
};
|
||||
node.attrs.insert("auto_status".to_string(), value);
|
||||
graph.nodes.insert("work".to_string(), node);
|
||||
graph
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_graphs_without_auto_status() {
|
||||
let mut graph = minimal_graph();
|
||||
graph.nodes.insert(
|
||||
"work".to_string(),
|
||||
node_with_attrs("work", &[("on_failure", "succeed")]),
|
||||
);
|
||||
assert!(Rule.apply(&graph).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_for_auto_status_true_and_suggests_succeed_policy() {
|
||||
let graph = graph_with_auto_status(AttrValue::Boolean(true), None);
|
||||
|
||||
let diagnostics = Rule.apply(&graph);
|
||||
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].severity, Severity::Warning);
|
||||
assert_eq!(
|
||||
diagnostics[0].message,
|
||||
"Node 'work' sets deprecated 'auto_status=true'"
|
||||
);
|
||||
assert_eq!(diagnostics[0].node_id.as_deref(), Some("work"));
|
||||
assert_eq!(
|
||||
diagnostics[0].fix.as_deref(),
|
||||
Some("Use on_failure=\"succeed\" instead")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_that_auto_status_is_ignored_when_on_failure_is_set() {
|
||||
let graph = graph_with_auto_status(AttrValue::Boolean(true), Some("exit"));
|
||||
|
||||
let diagnostics = Rule.apply(&graph);
|
||||
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].severity, Severity::Warning);
|
||||
assert_eq!(
|
||||
diagnostics[0].message,
|
||||
"Node 'work' sets deprecated 'auto_status', which is ignored because 'on_failure' is set"
|
||||
);
|
||||
assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_for_auto_status_false() {
|
||||
let graph = graph_with_auto_status(AttrValue::Boolean(false), None);
|
||||
|
||||
let diagnostics = Rule.apply(&graph);
|
||||
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].severity, Severity::Warning);
|
||||
assert_eq!(
|
||||
diagnostics[0].message,
|
||||
"Node 'work' sets deprecated 'auto_status', which has no effect unless it is true"
|
||||
);
|
||||
assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_nodes_in_id_order() {
|
||||
let mut graph = minimal_graph();
|
||||
for id in ["zeta", "alpha"] {
|
||||
let mut node = node_with_attrs(id, &[]);
|
||||
node.attrs
|
||||
.insert("auto_status".to_string(), AttrValue::Boolean(true));
|
||||
graph.nodes.insert(id.to_string(), node);
|
||||
}
|
||||
|
||||
let ids: Vec<_> = Rule
|
||||
.apply(&graph)
|
||||
.into_iter()
|
||||
.map(|diagnostic| diagnostic.node_id.unwrap())
|
||||
.collect();
|
||||
|
||||
assert_eq!(ids, ["alpha", "zeta"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
mod all_conditional_edges;
|
||||
mod auto_status_deprecated;
|
||||
mod backend_valid;
|
||||
mod command_requires_script;
|
||||
mod condition_syntax;
|
||||
|
|
@ -65,6 +66,7 @@ pub fn built_in_rules() -> Vec<Box<dyn LintRule>> {
|
|||
all_conditional_edges::rule(),
|
||||
orphan_custom_outcome::rule(),
|
||||
on_failure_valid::rule(),
|
||||
auto_status_deprecated::rule(),
|
||||
script_absolute_cd::rule(),
|
||||
command_requires_script::rule(),
|
||||
import_error::rule(),
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ mod tests {
|
|||
let mut graph = minimal_graph();
|
||||
assert!(Rule.apply(&graph).is_empty());
|
||||
|
||||
for value in ["route", "exit"] {
|
||||
for value in ["route", "exit", "succeed"] {
|
||||
graph.attrs.insert(
|
||||
"on_failure".to_string(),
|
||||
AttrValue::String(value.to_string()),
|
||||
|
|
@ -109,7 +109,7 @@ mod tests {
|
|||
#[test]
|
||||
fn accepts_supported_node_values() {
|
||||
let mut graph = minimal_graph();
|
||||
for value in ["route", "exit"] {
|
||||
for value in ["route", "exit", "succeed"] {
|
||||
graph.nodes.insert(
|
||||
"work".to_string(),
|
||||
node_with_attrs("work", &[("on_failure", value)]),
|
||||
|
|
@ -136,7 +136,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
diagnostics[0].fix.as_deref(),
|
||||
Some("Use one of: route, exit")
|
||||
Some("Use one of: route, exit, succeed")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ mod tests {
|
|||
assert_eq!(diagnostics[0].node_id.as_deref(), Some("work"));
|
||||
assert_eq!(
|
||||
diagnostics[0].fix.as_deref(),
|
||||
Some("Use one of: route, exit")
|
||||
Some("Use one of: route, exit, succeed")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -169,8 +169,49 @@ use fabro_graphviz::Fidelity;
|
|||
use fabro_types::{ParallelBranchId, RunId, StageId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::error::{Error, FailureSignature, FailureSignatureExt};
|
||||
use crate::event::StageScope;
|
||||
use crate::outcome::{Outcome, OutcomeExt};
|
||||
|
||||
/// Applies the context values derived from a completed node result.
|
||||
///
|
||||
/// Edge-policy projection and the durable `after_record` lifecycle use this
|
||||
/// same function so conditional routes observe identical values.
|
||||
pub(crate) fn apply_recorded_outcome_context(
|
||||
context: &Context,
|
||||
node_id: &str,
|
||||
outcome: &Outcome,
|
||||
retry_count: u32,
|
||||
) {
|
||||
let failure_class = outcome.classified_failure_category();
|
||||
let failure_signature = failure_class
|
||||
.map(|category| {
|
||||
let signature_hint = outcome
|
||||
.failure
|
||||
.as_ref()
|
||||
.and_then(|failure| failure.signature.as_deref());
|
||||
FailureSignature::new(node_id, category, signature_hint, outcome.failure_reason())
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
context.set(
|
||||
keys::retry_count_key(node_id),
|
||||
serde_json::json!(retry_count),
|
||||
);
|
||||
context.set(keys::OUTCOME, serde_json::json!(outcome.status.to_string()));
|
||||
context.set(
|
||||
keys::FAILURE_CLASS,
|
||||
serde_json::json!(failure_class.map_or(String::new(), |class| class.to_string())),
|
||||
);
|
||||
context.set(
|
||||
keys::FAILURE_SIGNATURE,
|
||||
serde_json::json!(failure_signature),
|
||||
);
|
||||
if let Some(preferred_label) = &outcome.preferred_label {
|
||||
context.set(keys::PREFERRED_LABEL, serde_json::json!(preferred_label));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys whose values changed or were added in `after` relative to `before`.
|
||||
/// Takes `after` by value so changed entries move instead of clone.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use anyhow::Context as _;
|
|||
pub use fabro_checkpoint::META_BRANCH_PREFIX;
|
||||
pub use fabro_checkpoint::author::GitAuthor;
|
||||
use fabro_checkpoint::git::Store;
|
||||
use fabro_types::WorkflowSettings;
|
||||
use fabro_redact::DisplaySafeUrl;
|
||||
use fabro_types::{DirtyStatus, GitContext, WorkflowSettings};
|
||||
use tokio::task::{JoinError, spawn_blocking};
|
||||
use tokio::time::timeout;
|
||||
|
||||
|
|
@ -14,6 +15,106 @@ use crate::error::{Error, Result};
|
|||
/// Branch prefix for workflow run branches (e.g. `fabro/run/{run_id}`).
|
||||
pub const RUN_BRANCH_PREFIX: &str = "fabro/run/";
|
||||
|
||||
/// A local checkout could not be inspected without changing it.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GitObservationError {
|
||||
#[error("failed to discover the local Git repository")]
|
||||
Discover {
|
||||
#[source]
|
||||
source: git2::Error,
|
||||
},
|
||||
#[error("failed to read the local Git repository HEAD")]
|
||||
Head {
|
||||
#[source]
|
||||
source: git2::Error,
|
||||
},
|
||||
#[error("failed to read the local Git repository origin")]
|
||||
Origin {
|
||||
#[source]
|
||||
source: git2::Error,
|
||||
},
|
||||
#[error("failed to read the local Git repository status")]
|
||||
Status {
|
||||
#[source]
|
||||
source: git2::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Observe the current branch, commit, origin, and dirty state of a local
|
||||
/// checkout without invoking Git commands, contacting a remote, or mutating
|
||||
/// the repository. Non-repositories, unborn repositories, and detached HEADs
|
||||
/// have no usable [`GitContext`] and return `Ok(None)`.
|
||||
pub fn observe_git_context(
|
||||
path: &Path,
|
||||
) -> std::result::Result<Option<GitContext>, GitObservationError> {
|
||||
let repo = match git2::Repository::discover(path) {
|
||||
Ok(repo) => repo,
|
||||
Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(None),
|
||||
Err(source) => return Err(GitObservationError::Discover { source }),
|
||||
};
|
||||
let head = match repo.head() {
|
||||
Ok(head) => head,
|
||||
Err(source)
|
||||
if matches!(
|
||||
source.code(),
|
||||
git2::ErrorCode::NotFound | git2::ErrorCode::UnbornBranch
|
||||
) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(source) => return Err(GitObservationError::Head { source }),
|
||||
};
|
||||
if !head.is_branch() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(branch) = head.shorthand().filter(|branch| !branch.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let branch = branch.to_string();
|
||||
let sha = head.target().map(|oid| oid.to_string());
|
||||
drop(head);
|
||||
|
||||
let origin_url = match repo.find_remote("origin") {
|
||||
Ok(remote) => remote.url().map(sanitized_origin_url).unwrap_or_default(),
|
||||
Err(source) if source.code() == git2::ErrorCode::NotFound => String::new(),
|
||||
Err(source) => return Err(GitObservationError::Origin { source }),
|
||||
};
|
||||
let mut status_options = git2::StatusOptions::new();
|
||||
status_options
|
||||
.include_untracked(true)
|
||||
.no_refresh(true)
|
||||
.update_index(false);
|
||||
let statuses = repo
|
||||
.statuses(Some(&mut status_options))
|
||||
.map_err(|source| GitObservationError::Status { source })?;
|
||||
let dirty = if statuses
|
||||
.iter()
|
||||
.any(|entry| entry.status() != git2::Status::CURRENT)
|
||||
{
|
||||
DirtyStatus::Dirty
|
||||
} else {
|
||||
DirtyStatus::Clean
|
||||
};
|
||||
|
||||
Ok(Some(GitContext {
|
||||
origin_url,
|
||||
branch,
|
||||
sha,
|
||||
dirty,
|
||||
}))
|
||||
}
|
||||
|
||||
fn sanitized_origin_url(value: &str) -> String {
|
||||
let normalized = fabro_github::normalize_repo_origin_url(value);
|
||||
let Ok(url) = DisplaySafeUrl::parse(&normalized) else {
|
||||
return String::new();
|
||||
};
|
||||
let mut url = url.without_credentials().into_owned();
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
fabro_github::normalize_repo_origin_url(url.as_str())
|
||||
}
|
||||
|
||||
pub fn git_author_from_settings(settings: &WorkflowSettings) -> GitAuthor {
|
||||
settings
|
||||
.run
|
||||
|
|
@ -304,6 +405,84 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_git_context_is_read_only_and_reports_local_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
let repo = git2::Repository::open(dir.path()).unwrap();
|
||||
repo.remote("origin", "git@github.com:fabro-sh/fabro.git")
|
||||
.unwrap();
|
||||
drop(repo);
|
||||
let git_dir = dir.path().join(".git");
|
||||
let head_before = fs::read(git_dir.join("HEAD")).unwrap();
|
||||
let index_before = fs::read(git_dir.join("index")).unwrap();
|
||||
let config_before = fs::read(git_dir.join("config")).unwrap();
|
||||
|
||||
let observed = observe_git_context(dir.path()).unwrap().unwrap();
|
||||
assert!(!observed.branch.is_empty());
|
||||
assert_eq!(observed.origin_url, "https://github.com/fabro-sh/fabro");
|
||||
assert_eq!(observed.sha.as_deref().map(str::len), Some(40));
|
||||
assert_eq!(observed.dirty, DirtyStatus::Clean);
|
||||
assert_eq!(fs::read(git_dir.join("HEAD")).unwrap(), head_before);
|
||||
assert_eq!(fs::read(git_dir.join("index")).unwrap(), index_before);
|
||||
assert_eq!(fs::read(git_dir.join("config")).unwrap(), config_before);
|
||||
assert!(!git_dir.join("HEAD.lock").exists());
|
||||
assert!(!git_dir.join("index.lock").exists());
|
||||
assert!(!git_dir.join("config.lock").exists());
|
||||
|
||||
fs::write(dir.path().join("untracked.txt"), "changed").unwrap();
|
||||
let observed = observe_git_context(dir.path()).unwrap().unwrap();
|
||||
assert_eq!(observed.dirty, DirtyStatus::Dirty);
|
||||
assert_eq!(fs::read(git_dir.join("HEAD")).unwrap(), head_before);
|
||||
assert_eq!(fs::read(git_dir.join("index")).unwrap(), index_before);
|
||||
assert_eq!(fs::read(git_dir.join("config")).unwrap(), config_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_git_context_never_persists_remote_credentials() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
let repo = git2::Repository::open(dir.path()).unwrap();
|
||||
repo.remote(
|
||||
"origin",
|
||||
"http://run-user:secret@example.com/acme/widgets.git?token=secret#fragment",
|
||||
)
|
||||
.unwrap();
|
||||
drop(repo);
|
||||
|
||||
let observed = observe_git_context(dir.path()).unwrap().unwrap();
|
||||
|
||||
assert_eq!(observed.origin_url, "http://example.com/acme/widgets");
|
||||
assert!(!observed.origin_url.contains("secret"));
|
||||
assert!(!observed.origin_url.contains("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_git_context_accepts_a_non_repository() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(observe_git_context(dir.path()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_git_context_handles_unborn_detached_and_nested_checkouts() {
|
||||
let unborn = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(unborn.path()).unwrap();
|
||||
assert_eq!(observe_git_context(unborn.path()).unwrap(), None);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
let nested = dir.path().join("nested");
|
||||
fs::create_dir(&nested).unwrap();
|
||||
let observed = observe_git_context(&nested).unwrap().unwrap();
|
||||
assert!(observed.origin_url.is_empty());
|
||||
assert!(!observed.branch.is_empty());
|
||||
|
||||
let repo = git2::Repository::open(dir.path()).unwrap();
|
||||
let head = repo.head().unwrap().target().unwrap();
|
||||
repo.set_head_detached(head).unwrap();
|
||||
assert_eq!(observe_git_context(dir.path()).unwrap(), None);
|
||||
}
|
||||
|
||||
fn test_store() -> Arc<Database> {
|
||||
Arc::new(fabro_store::test_support::test_database(
|
||||
Arc::new(InMemory::new()),
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ use std::sync::Arc;
|
|||
|
||||
use fabro_core::error::{Error as CoreError, Result as CoreResult};
|
||||
use fabro_core::graph::{EdgeSelection as CoreEdgeSelection, EdgeSpec, Graph, NodeSpec};
|
||||
use fabro_core::outcome::NodeResult;
|
||||
use fabro_graphviz::graph::types::{Edge as GvEdge, Graph as GvGraph, Node as GvNode};
|
||||
use fabro_types::ResolvedOnFailure;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::context::{self, Context};
|
||||
use crate::outcome::{BilledModelUsage, Outcome};
|
||||
|
||||
// ---- WorkflowNode ----
|
||||
|
|
@ -120,6 +121,20 @@ impl Graph for WorkflowGraph {
|
|||
})
|
||||
}
|
||||
|
||||
fn project_result_context(
|
||||
&self,
|
||||
node: &Self::Node,
|
||||
result: &NodeResult<Self::Meta>,
|
||||
context: &Context,
|
||||
) {
|
||||
context::apply_recorded_outcome_context(
|
||||
context,
|
||||
node.id(),
|
||||
&result.outcome,
|
||||
result.attempts.saturating_sub(1),
|
||||
);
|
||||
}
|
||||
|
||||
fn check_goal_gates(
|
||||
&self,
|
||||
outcomes: &HashMap<String, Outcome>,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use fabro_core::graph::EdgeSelectionReason;
|
||||
use fabro_graphviz::graph::types::{Edge as GvEdge, Graph as GvGraph, Node as GvNode};
|
||||
use fabro_types::OnFailure;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::condition::evaluate_condition;
|
||||
|
|
@ -11,7 +11,7 @@ use crate::outcome::Outcome;
|
|||
/// Result of edge selection: the chosen edge and the reason it was selected.
|
||||
pub(crate) struct SelectedGraphEdge<'a> {
|
||||
pub(crate) edge: &'a GvEdge,
|
||||
pub(crate) reason: &'static str,
|
||||
pub(crate) reason: EdgeSelectionReason,
|
||||
}
|
||||
|
||||
/// Check whether a node is a terminal (exit) node.
|
||||
|
|
@ -44,7 +44,7 @@ pub(crate) fn select_edge<'a>(
|
|||
if !condition_matched.is_empty() {
|
||||
return pick_edge(&condition_matched, selection).map(|edge| SelectedGraphEdge {
|
||||
edge,
|
||||
reason: "condition",
|
||||
reason: EdgeSelectionReason::Condition,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ pub(crate) fn select_edge<'a>(
|
|||
if normalize_label(label) == normalized_pref {
|
||||
return Some(SelectedGraphEdge {
|
||||
edge,
|
||||
reason: "preferred_label",
|
||||
reason: EdgeSelectionReason::PreferredLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -69,16 +69,12 @@ pub(crate) fn select_edge<'a>(
|
|||
if edge.condition().is_none_or(str::is_empty) && edge.to == *suggested_id {
|
||||
return Some(SelectedGraphEdge {
|
||||
edge,
|
||||
reason: "suggested_next",
|
||||
reason: EdgeSelectionReason::SuggestedNext,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.status.is_failure() && graph.resolve_on_failure(node).policy() == OnFailure::Exit {
|
||||
return None;
|
||||
}
|
||||
|
||||
if blocks_unconditional_failure_fallthrough(node, outcome) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -91,7 +87,7 @@ pub(crate) fn select_edge<'a>(
|
|||
if !unconditional.is_empty() {
|
||||
return pick_edge(&unconditional, selection).map(|edge| SelectedGraphEdge {
|
||||
edge,
|
||||
reason: "unconditional",
|
||||
reason: EdgeSelectionReason::Unconditional,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -238,6 +234,7 @@ mod tests {
|
|||
use std::collections::HashMap;
|
||||
|
||||
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
||||
use fabro_types::{OnFailure, ResolvedOnFailure};
|
||||
|
||||
use super::*;
|
||||
use crate::context::Context;
|
||||
|
|
@ -257,20 +254,6 @@ mod tests {
|
|||
g
|
||||
}
|
||||
|
||||
fn set_on_failure(graph: &mut Graph, on_failure: OnFailure) {
|
||||
graph.attrs.insert(
|
||||
"on_failure".to_string(),
|
||||
AttrValue::String(on_failure.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
fn set_node_on_failure(graph: &mut Graph, node_id: &str, value: &str) {
|
||||
graph.nodes.get_mut(node_id).unwrap().attrs.insert(
|
||||
"on_failure".to_string(),
|
||||
AttrValue::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_label_lowercase_and_trim() {
|
||||
assert_eq!(normalize_label(" Yes "), "yes");
|
||||
|
|
@ -394,41 +377,25 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "b");
|
||||
assert_eq!(sel.reason, "unconditional");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::Unconditional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_outcome_takes_unconditional_edge_in_default_and_route_modes() {
|
||||
for policy in [None, Some(OnFailure::Route)] {
|
||||
let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
if let Some(policy) = policy {
|
||||
set_on_failure(&mut graph, policy);
|
||||
}
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
let outcome = Outcome::fail_classify("boom");
|
||||
|
||||
let selected =
|
||||
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
|
||||
|
||||
assert_eq!(selected.edge.to, "b");
|
||||
assert_eq!(selected.reason, "unconditional");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_policy_blocks_unconditional_edge_for_failed_outcome() {
|
||||
let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
set_on_failure(&mut graph, OnFailure::Exit);
|
||||
fn failed_outcome_selects_unconditional_edge() {
|
||||
let graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
let outcome = Outcome::fail_classify("boom");
|
||||
|
||||
assert!(select_edge(node, &outcome, &Context::new(), &graph, "deterministic").is_none());
|
||||
let selected =
|
||||
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
|
||||
|
||||
assert_eq!(selected.edge.to, "b");
|
||||
assert_eq!(selected.reason, EdgeSelectionReason::Unconditional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_policy_allows_unconditional_edge_for_non_failed_outcomes() {
|
||||
let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
set_on_failure(&mut graph, OnFailure::Exit);
|
||||
fn non_failed_outcomes_select_unconditional_edge() {
|
||||
let graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
let mut partial = Outcome::success();
|
||||
partial.status = StageOutcome::PartiallySucceeded;
|
||||
|
|
@ -437,19 +404,18 @@ mod tests {
|
|||
let selected =
|
||||
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
|
||||
assert_eq!(selected.edge.to, "b");
|
||||
assert_eq!(selected.reason, "unconditional");
|
||||
assert_eq!(selected.reason, EdgeSelectionReason::Unconditional);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_policy_allows_matching_failure_condition() {
|
||||
fn failure_condition_is_an_explicit_selection() {
|
||||
let mut recovery = Edge::new("a", "recover");
|
||||
recovery.attrs.insert(
|
||||
"condition".to_string(),
|
||||
AttrValue::String("outcome=failed".to_string()),
|
||||
);
|
||||
let mut graph = make_graph_with_edges(vec![recovery, Edge::new("a", "fallback")]);
|
||||
set_on_failure(&mut graph, OnFailure::Exit);
|
||||
let graph = make_graph_with_edges(vec![recovery, Edge::new("a", "fallback")]);
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
let outcome = Outcome::fail_classify("boom");
|
||||
|
||||
|
|
@ -457,18 +423,17 @@ mod tests {
|
|||
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
|
||||
|
||||
assert_eq!(selected.edge.to, "recover");
|
||||
assert_eq!(selected.reason, "condition");
|
||||
assert_eq!(selected.reason, EdgeSelectionReason::Condition);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_policy_allows_matching_preferred_and_suggested_routes() {
|
||||
fn preferred_and_suggested_routes_are_explicit_selections() {
|
||||
let mut preferred = Edge::new("a", "preferred");
|
||||
preferred.attrs.insert(
|
||||
"label".to_string(),
|
||||
AttrValue::String("Recover".to_string()),
|
||||
);
|
||||
let mut graph = make_graph_with_edges(vec![preferred, Edge::new("a", "suggested")]);
|
||||
set_on_failure(&mut graph, OnFailure::Exit);
|
||||
let graph = make_graph_with_edges(vec![preferred, Edge::new("a", "suggested")]);
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
|
||||
let mut preferred_outcome = Outcome::fail_classify("boom");
|
||||
|
|
@ -482,7 +447,7 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected.edge.to, "preferred");
|
||||
assert_eq!(selected.reason, "preferred_label");
|
||||
assert_eq!(selected.reason, EdgeSelectionReason::PreferredLabel);
|
||||
|
||||
let mut suggested_outcome = Outcome::fail_classify("boom");
|
||||
suggested_outcome.suggested_next_ids = vec!["suggested".to_string()];
|
||||
|
|
@ -495,118 +460,26 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected.edge.to, "suggested");
|
||||
assert_eq!(selected.reason, "suggested_next");
|
||||
assert_eq!(selected.reason, EdgeSelectionReason::SuggestedNext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_policy_blocks_fallback_for_unmatched_routing_hints() {
|
||||
let mut graph = make_graph_with_edges(vec![Edge::new("a", "fallback")]);
|
||||
set_on_failure(&mut graph, OnFailure::Exit);
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
|
||||
let mut preferred_outcome = Outcome::fail_classify("boom");
|
||||
preferred_outcome.preferred_label = Some("missing".to_string());
|
||||
assert!(
|
||||
select_edge(
|
||||
node,
|
||||
&preferred_outcome,
|
||||
&Context::new(),
|
||||
&graph,
|
||||
"deterministic"
|
||||
)
|
||||
.is_none()
|
||||
fn promoted_outcome_selects_succeeded_condition() {
|
||||
let mut on_success = Edge::new("a", "next");
|
||||
on_success.attrs.insert(
|
||||
"condition".to_string(),
|
||||
AttrValue::String("outcome=succeeded".to_string()),
|
||||
);
|
||||
|
||||
let mut suggested_outcome = Outcome::fail_classify("boom");
|
||||
suggested_outcome.suggested_next_ids = vec!["missing".to_string()];
|
||||
assert!(
|
||||
select_edge(
|
||||
node,
|
||||
&suggested_outcome,
|
||||
&Context::new(),
|
||||
&graph,
|
||||
"deterministic"
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_exit_overrides_graph_route_and_blocks_unconditional_edge() {
|
||||
for graph_policy in [None, Some(OnFailure::Route)] {
|
||||
let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
if let Some(policy) = graph_policy {
|
||||
set_on_failure(&mut graph, policy);
|
||||
}
|
||||
set_node_on_failure(&mut graph, "a", "exit");
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
let outcome = Outcome::fail_classify("boom");
|
||||
|
||||
assert!(
|
||||
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_route_overrides_graph_exit_and_takes_unconditional_edge() {
|
||||
let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
set_on_failure(&mut graph, OnFailure::Exit);
|
||||
set_node_on_failure(&mut graph, "a", "route");
|
||||
let graph = make_graph_with_edges(vec![on_success, Edge::new("a", "fallback")]);
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
let outcome = Outcome::fail_classify("boom");
|
||||
let mut outcome = Outcome::fail_classify("boom");
|
||||
outcome.apply_on_failure(ResolvedOnFailure::node(OnFailure::Succeed));
|
||||
|
||||
let selected =
|
||||
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
|
||||
|
||||
assert_eq!(selected.edge.to, "b");
|
||||
assert_eq!(selected.reason, "unconditional");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_policy_does_not_affect_routing_from_other_nodes() {
|
||||
let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
set_node_on_failure(&mut graph, "b", "exit");
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
let outcome = Outcome::fail_classify("boom");
|
||||
|
||||
let selected =
|
||||
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
|
||||
|
||||
assert_eq!(selected.edge.to, "b");
|
||||
assert_eq!(selected.reason, "unconditional");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_node_on_failure_value_inherits_graph_policy() {
|
||||
let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
set_on_failure(&mut graph, OnFailure::Exit);
|
||||
set_node_on_failure(&mut graph, "a", "bogus");
|
||||
let node = graph.nodes.get("a").unwrap();
|
||||
let outcome = Outcome::fail_classify("boom");
|
||||
|
||||
assert!(select_edge(node, &outcome, &Context::new(), &graph, "deterministic").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_human_gate_ignores_node_route_override() {
|
||||
let mut graph = make_graph_with_edges(vec![
|
||||
Edge::new("gate", "approve"),
|
||||
Edge::new("gate", "skip"),
|
||||
]);
|
||||
set_on_failure(&mut graph, OnFailure::Exit);
|
||||
set_node_on_failure(&mut graph, "gate", "route");
|
||||
let gate = graph.nodes.get_mut("gate").unwrap();
|
||||
gate.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("hexagon".to_string()),
|
||||
);
|
||||
let node = graph.nodes.get("gate").unwrap().clone();
|
||||
let outcome = Outcome::fail_deterministic(
|
||||
"human interaction interrupted before an answer was provided",
|
||||
);
|
||||
|
||||
assert!(select_edge(&node, &outcome, &Context::new(), &graph, "deterministic").is_none());
|
||||
assert_eq!(selected.edge.to, "next");
|
||||
assert_eq!(selected.reason, EdgeSelectionReason::Condition);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -627,7 +500,7 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "success_path");
|
||||
assert_eq!(sel.reason, "condition");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::Condition);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -649,7 +522,7 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "fix");
|
||||
assert_eq!(sel.reason, "preferred_label");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::PreferredLabel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -663,7 +536,7 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "path2");
|
||||
assert_eq!(sel.reason, "suggested_next");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::SuggestedNext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -679,7 +552,7 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "high");
|
||||
assert_eq!(sel.reason, "unconditional");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::Unconditional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -692,7 +565,7 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "alpha");
|
||||
assert_eq!(sel.reason, "unconditional");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::Unconditional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -709,7 +582,7 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "cond_path");
|
||||
assert_eq!(sel.reason, "condition");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::Condition);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -722,7 +595,7 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let sel = select_edge(node, &outcome, &context, &g, "random").unwrap();
|
||||
assert!(sel.edge.to == "b" || sel.edge.to == "c");
|
||||
assert_eq!(sel.reason, "unconditional");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::Unconditional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -740,29 +613,26 @@ mod tests {
|
|||
let context = Context::new();
|
||||
let sel = select_edge(node, &outcome, &context, &g, "random").unwrap();
|
||||
assert_eq!(sel.edge.to, "approve");
|
||||
assert_eq!(sel.reason, "preferred_label");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::PreferredLabel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_edge_failed_human_gate_does_not_fall_through_to_unconditional() {
|
||||
for policy in [OnFailure::Route, OnFailure::Exit] {
|
||||
let mut graph = make_graph_with_edges(vec![
|
||||
Edge::new("gate", "approve"),
|
||||
Edge::new("gate", "skip"),
|
||||
]);
|
||||
set_on_failure(&mut graph, policy);
|
||||
let mut node = graph.nodes.get("gate").unwrap().clone();
|
||||
node.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("hexagon".to_string()),
|
||||
);
|
||||
let outcome = Outcome::fail_deterministic(
|
||||
"human interaction interrupted before an answer was provided",
|
||||
);
|
||||
let context = Context::new();
|
||||
let graph = make_graph_with_edges(vec![
|
||||
Edge::new("gate", "approve"),
|
||||
Edge::new("gate", "skip"),
|
||||
]);
|
||||
let mut node = graph.nodes.get("gate").unwrap().clone();
|
||||
node.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("hexagon".to_string()),
|
||||
);
|
||||
let outcome = Outcome::fail_deterministic(
|
||||
"human interaction interrupted before an answer was provided",
|
||||
);
|
||||
let context = Context::new();
|
||||
|
||||
assert!(select_edge(&node, &outcome, &context, &graph, "deterministic").is_none());
|
||||
}
|
||||
assert!(select_edge(&node, &outcome, &context, &graph, "deterministic").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -773,23 +643,20 @@ mod tests {
|
|||
AttrValue::String("outcome=failed".to_string()),
|
||||
);
|
||||
let approve = Edge::new("gate", "approve");
|
||||
for policy in [OnFailure::Route, OnFailure::Exit] {
|
||||
let mut graph = make_graph_with_edges(vec![fail.clone(), approve.clone()]);
|
||||
set_on_failure(&mut graph, policy);
|
||||
let mut node = graph.nodes.get("gate").unwrap().clone();
|
||||
node.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("hexagon".to_string()),
|
||||
);
|
||||
let outcome = Outcome::fail_deterministic(
|
||||
"human interaction interrupted before an answer was provided",
|
||||
);
|
||||
let context = Context::new();
|
||||
let graph = make_graph_with_edges(vec![fail, approve]);
|
||||
let mut node = graph.nodes.get("gate").unwrap().clone();
|
||||
node.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("hexagon".to_string()),
|
||||
);
|
||||
let outcome = Outcome::fail_deterministic(
|
||||
"human interaction interrupted before an answer was provided",
|
||||
);
|
||||
let context = Context::new();
|
||||
|
||||
let sel = select_edge(&node, &outcome, &context, &graph, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "retry");
|
||||
assert_eq!(sel.reason, "condition");
|
||||
}
|
||||
let sel = select_edge(&node, &outcome, &context, &graph, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "retry");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::Condition);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ async fn run_branches(
|
|||
);
|
||||
|
||||
let mut attempt = 0_u32;
|
||||
let outcome = loop {
|
||||
let mut outcome = loop {
|
||||
attempt = attempt.saturating_add(1);
|
||||
let attempt_result = node_handler::execute_single_attempt(
|
||||
&target,
|
||||
|
|
@ -569,6 +569,12 @@ async fn run_branches(
|
|||
backoff_or_cancel(delay, &branch_services).await?;
|
||||
permit = acquire_branch_permit(&semaphore, &branch_services).await?;
|
||||
};
|
||||
// Branches have no edge routing, so `succeed` has no
|
||||
// explicit recovery route to defer to: a failed branch
|
||||
// under that policy always counts as succeeded in the
|
||||
// parent's aggregate, with its failure kept on the
|
||||
// outcome.
|
||||
outcome.apply_on_failure(graph.resolve_on_failure(&target));
|
||||
|
||||
let context_updates = branch_context_updates(
|
||||
&parent_snapshot,
|
||||
|
|
@ -1401,6 +1407,122 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Fails the named branch node and succeeds everywhere else.
|
||||
struct FailNamedBranchHandler(&'static str);
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for FailNamedBranchHandler {
|
||||
async fn execute(
|
||||
&self,
|
||||
node: &Node,
|
||||
_context: &Context,
|
||||
_graph: &Graph,
|
||||
_run_dir: &Path,
|
||||
_services: &EngineServices,
|
||||
) -> Result<Outcome, Error> {
|
||||
if node.id == self.0 {
|
||||
Ok(Outcome::fail_classify("branch boom"))
|
||||
} else {
|
||||
Ok(Outcome::success())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutates a test graph to opt a branch into the `succeed` policy.
|
||||
type PolicyEdit = fn(&mut Graph);
|
||||
|
||||
async fn run_parallel_with_failing_branch_a(graph: &Graph, node: &Node) -> Outcome {
|
||||
let mut services = make_services();
|
||||
services.registry = Arc::new(super::super::HandlerRegistry::new(Box::new(
|
||||
FailNamedBranchHandler("branch_a"),
|
||||
)));
|
||||
ParallelHandler
|
||||
.execute(
|
||||
node,
|
||||
&test_context(),
|
||||
graph,
|
||||
Path::new("/tmp/test"),
|
||||
&services,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_handler_failed_branch_without_policy_is_partial() {
|
||||
let (node, graph) = parallel_graph();
|
||||
|
||||
let outcome = run_parallel_with_failing_branch_a(&graph, &node).await;
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::PartiallySucceeded);
|
||||
let results: Vec<ParallelBranchResult> =
|
||||
serde_json::from_value(outcome.context_updates[keys::PARALLEL_RESULTS].clone())
|
||||
.unwrap();
|
||||
assert!(results[0].status.is_failure());
|
||||
assert_eq!(results[1].status, StageOutcome::Succeeded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_handler_succeed_policy_counts_failed_branch_as_succeeded() {
|
||||
let cases: [(&str, PolicyEdit); 3] = [
|
||||
("node", |graph| {
|
||||
graph.nodes.get_mut("branch_a").unwrap().attrs.insert(
|
||||
"on_failure".to_string(),
|
||||
AttrValue::String("succeed".to_string()),
|
||||
);
|
||||
}),
|
||||
("graph", |graph| {
|
||||
graph.attrs.insert(
|
||||
"on_failure".to_string(),
|
||||
AttrValue::String("succeed".to_string()),
|
||||
);
|
||||
}),
|
||||
("alias", |graph| {
|
||||
graph
|
||||
.nodes
|
||||
.get_mut("branch_a")
|
||||
.unwrap()
|
||||
.attrs
|
||||
.insert("auto_status".to_string(), AttrValue::Boolean(true));
|
||||
}),
|
||||
];
|
||||
for (scope, apply) in cases {
|
||||
let (node, mut graph) = parallel_graph();
|
||||
apply(&mut graph);
|
||||
|
||||
let outcome = run_parallel_with_failing_branch_a(&graph, &node).await;
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded, "scope {scope}");
|
||||
assert_eq!(
|
||||
outcome.notes.as_deref(),
|
||||
Some("Parallel node dispatched 2 branches (2 succeeded, 0 failed)"),
|
||||
"scope {scope}"
|
||||
);
|
||||
let results: Vec<ParallelBranchResult> =
|
||||
serde_json::from_value(outcome.context_updates[keys::PARALLEL_RESULTS].clone())
|
||||
.unwrap();
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.all(|result| result.status == StageOutcome::Succeeded),
|
||||
"scope {scope}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_handler_exit_policy_does_not_change_branch_outcomes() {
|
||||
let (node, mut graph) = parallel_graph();
|
||||
graph.attrs.insert(
|
||||
"on_failure".to_string(),
|
||||
AttrValue::String("exit".to_string()),
|
||||
);
|
||||
|
||||
let outcome = run_parallel_with_failing_branch_a(&graph, &node).await;
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::PartiallySucceeded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_handler_no_branches() {
|
||||
let outcome = ParallelHandler
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use fabro_core::error::Result as CoreResult;
|
||||
use fabro_core::lifecycle::RunLifecycle;
|
||||
use fabro_core::outcome::NodeResult;
|
||||
use fabro_core::state::ExecutionState;
|
||||
|
||||
use crate::graph::{WorkflowGraph, WorkflowNode};
|
||||
use crate::outcome::{BilledModelUsage, StageOutcome};
|
||||
|
||||
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||
|
||||
/// Sub-lifecycle responsible for auto-status override on nodes with
|
||||
/// `auto_status=true`.
|
||||
pub(crate) struct AutoStatusLifecycle;
|
||||
|
||||
#[async_trait]
|
||||
impl RunLifecycle<WorkflowGraph> for AutoStatusLifecycle {
|
||||
async fn after_node(
|
||||
&self,
|
||||
node: &WorkflowNode,
|
||||
result: &mut WfNodeResult,
|
||||
_state: &WfRunState,
|
||||
) -> CoreResult<()> {
|
||||
let gv = node.inner();
|
||||
let outcome = &mut result.outcome;
|
||||
if gv.auto_status()
|
||||
&& outcome.status != StageOutcome::Succeeded
|
||||
&& outcome.status != StageOutcome::Skipped
|
||||
{
|
||||
outcome.status = StageOutcome::Succeeded;
|
||||
outcome.notes =
|
||||
Some("auto-status: handler completed without writing status".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
pub(crate) mod artifact;
|
||||
pub(crate) mod auto_status;
|
||||
pub(crate) mod circuit_breaker;
|
||||
pub(crate) mod event;
|
||||
pub(crate) mod fidelity;
|
||||
|
|
@ -26,7 +25,6 @@ use fabro_sandbox::Sandbox;
|
|||
use fabro_types::RunId;
|
||||
|
||||
use self::artifact::ArtifactLifecycle;
|
||||
use self::auto_status::AutoStatusLifecycle;
|
||||
use self::circuit_breaker::CircuitBreakerLifecycle;
|
||||
use self::event::EventLifecycle;
|
||||
use self::fidelity::FidelityLifecycle;
|
||||
|
|
@ -34,10 +32,10 @@ use self::git::{GitCheckpointResult, GitLifecycle};
|
|||
use self::hook::HookLifecycle;
|
||||
use crate::artifact_upload::ArtifactSink;
|
||||
use crate::context;
|
||||
use crate::error::{FailureSignature, FailureSignatureExt};
|
||||
use crate::error::FailureSignature;
|
||||
use crate::event::Emitter;
|
||||
use crate::graph::{WorkflowGraph, WorkflowNode};
|
||||
use crate::outcome::{BilledModelUsage, Outcome, OutcomeExt};
|
||||
use crate::outcome::{BilledModelUsage, Outcome};
|
||||
use crate::run_control::RunControlState;
|
||||
use crate::run_metadata::{RunMetadataRuntime, RunMetadataWriterHandle};
|
||||
use crate::run_options::RunOptions;
|
||||
|
|
@ -56,7 +54,6 @@ pub(crate) struct WorkflowLifecycle {
|
|||
event: EventLifecycle,
|
||||
hook: HookLifecycle,
|
||||
fidelity: FidelityLifecycle,
|
||||
auto_status: AutoStatusLifecycle,
|
||||
circuit_breaker: Arc<CircuitBreakerLifecycle>,
|
||||
git: GitLifecycle,
|
||||
artifact: ArtifactLifecycle,
|
||||
|
|
@ -187,7 +184,6 @@ impl WorkflowLifecycle {
|
|||
event,
|
||||
hook,
|
||||
fidelity,
|
||||
auto_status: AutoStatusLifecycle,
|
||||
circuit_breaker,
|
||||
git,
|
||||
artifact,
|
||||
|
|
@ -361,7 +357,6 @@ impl RunLifecycle<WorkflowGraph> for WorkflowLifecycle {
|
|||
result: &mut WfNodeResult,
|
||||
state: &WfRunState,
|
||||
) -> CoreResult<()> {
|
||||
self.auto_status.after_node(node, result, state).await?;
|
||||
self.circuit_breaker.after_node(node, result, state).await?;
|
||||
self.artifact.after_node(node, result, state).await?;
|
||||
self.event.after_node(node, result, state).await?;
|
||||
|
|
@ -375,47 +370,13 @@ impl RunLifecycle<WorkflowGraph> for WorkflowLifecycle {
|
|||
result: &WfNodeResult,
|
||||
state: &WfRunState,
|
||||
) -> CoreResult<()> {
|
||||
let outcome = &result.outcome;
|
||||
let retry_count = state.node_retries.get(node.id()).copied().unwrap_or(0);
|
||||
let failure_class = outcome.classified_failure_category();
|
||||
let failure_signature = failure_class
|
||||
.map(|category| {
|
||||
let signature_hint = outcome
|
||||
.failure
|
||||
.as_ref()
|
||||
.and_then(|f| f.signature.as_deref());
|
||||
FailureSignature::new(
|
||||
node.id(),
|
||||
category,
|
||||
signature_hint,
|
||||
outcome.failure_reason(),
|
||||
)
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
state.context.set(
|
||||
context::keys::retry_count_key(node.id()),
|
||||
serde_json::json!(retry_count),
|
||||
context::apply_recorded_outcome_context(
|
||||
&state.context,
|
||||
node.id(),
|
||||
&result.outcome,
|
||||
retry_count,
|
||||
);
|
||||
state.context.set(
|
||||
context::keys::OUTCOME,
|
||||
serde_json::json!(outcome.status.to_string()),
|
||||
);
|
||||
state.context.set(
|
||||
context::keys::FAILURE_CLASS,
|
||||
serde_json::json!(failure_class.map_or(String::new(), |fc| fc.to_string())),
|
||||
);
|
||||
state.context.set(
|
||||
context::keys::FAILURE_SIGNATURE,
|
||||
serde_json::json!(failure_signature),
|
||||
);
|
||||
if let Some(ref preferred_label) = outcome.preferred_label {
|
||||
state.context.set(
|
||||
context::keys::PREFERRED_LABEL,
|
||||
serde_json::json!(preferred_label),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -475,10 +475,11 @@ pub async fn persist_create_run(
|
|||
source_directory,
|
||||
labels,
|
||||
} = materialized;
|
||||
// An empty-workspace target has no submitter cwd to project; `git` is
|
||||
// already `None` for it because admission derives it from the target.
|
||||
let source_directory =
|
||||
(!matches!(target.as_ref(), Some(RunTarget::None {}))).then_some(source_directory);
|
||||
let (source_directory, git) = match target.as_ref() {
|
||||
Some(RunTarget::None {}) => (None, None),
|
||||
Some(RunTarget::Folder { path }) => (Some(path.clone()), git),
|
||||
Some(RunTarget::Git(_)) | None => (Some(source_directory), git),
|
||||
};
|
||||
let persisted_run_dir = run_dir.clone();
|
||||
let persisted = spawn_blocking(move || {
|
||||
let run_spec = RunSpec {
|
||||
|
|
@ -2363,6 +2364,70 @@ reasoning = false
|
|||
assert_eq!(created.persisted.run_spec().git, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_folder_target_projects_its_path_over_the_compiler_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("workspace");
|
||||
std::fs::create_dir(&workspace).unwrap();
|
||||
let canonical = workspace
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let storage_root = dir.path().join("storage");
|
||||
let store = memory_store();
|
||||
let git = fabro_types::GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
sha: None,
|
||||
dirty: fabro_types::DirtyStatus::Clean,
|
||||
};
|
||||
let created = create(
|
||||
&store,
|
||||
CreateRunInput {
|
||||
workflow: WorkflowInput::DotSource {
|
||||
source: MINIMAL_DOT.to_string(),
|
||||
base_dir: None,
|
||||
},
|
||||
settings: test_default_settings(),
|
||||
vars: HashMap::new(),
|
||||
cwd: dir.path().to_path_buf(),
|
||||
workflow_slug: None,
|
||||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
target: Some(RunTarget::Folder {
|
||||
path: canonical.clone(),
|
||||
}),
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: Some(fixtures::RUN_2),
|
||||
title: None,
|
||||
automation: None,
|
||||
git: Some(git.clone()),
|
||||
fork_source_ref: None,
|
||||
parent_id: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
configured_providers: test_provider_ids(),
|
||||
web_url: None,
|
||||
},
|
||||
storage_root,
|
||||
test_catalog(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
created.persisted.run_spec().target,
|
||||
Some(RunTarget::Folder {
|
||||
path: canonical.clone(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
created.persisted.run_spec().source_directory.as_deref(),
|
||||
Some(canonical.as_str())
|
||||
);
|
||||
assert_eq!(created.persisted.run_spec().git, Some(git));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_persists_repo_origin_url_from_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use anyhow::Result as AnyResult;
|
||||
use fabro_store::{Database, RunProjection, RunProjectionReducer};
|
||||
use fabro_types::{EventBody, EventEnvelope, ForkSourceRef, RunId};
|
||||
use fabro_types::{EventBody, EventEnvelope, ForkSourceRef, RunId, RunTarget};
|
||||
|
||||
use super::timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline};
|
||||
use crate::error::Error;
|
||||
|
|
@ -47,6 +47,7 @@ pub async fn fork_run(
|
|||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
validate_target_support(state.spec.target.as_ref())?;
|
||||
let timeline = build_timeline(&state).map_err(|err| Error::engine(err.to_string()))?;
|
||||
let entry = resolve_fork_entry(&timeline, &source_run_id, input.target.as_ref())
|
||||
.map_err(|err| Error::Validation(err.to_string()))?;
|
||||
|
|
@ -100,6 +101,16 @@ pub async fn fork_run(
|
|||
})
|
||||
}
|
||||
|
||||
fn validate_target_support(target: Option<&RunTarget>) -> std::result::Result<(), Error> {
|
||||
if matches!(target, Some(RunTarget::Folder { .. })) {
|
||||
return Err(Error::Validation(
|
||||
"Local folder runs execute in place without Git checkpoints; cannot fork or rewind"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_source_spec(spec: &RunSpec, checkpoint_sha: &str) -> std::result::Result<(), Error> {
|
||||
if checkpoint_sha.trim().is_empty() {
|
||||
return Err(Error::Validation(
|
||||
|
|
@ -302,6 +313,17 @@ mod tests {
|
|||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_targets_report_that_fork_and_rewind_are_unsupported() {
|
||||
let target = RunTarget::Folder {
|
||||
path: "/canonical/project".to_string(),
|
||||
};
|
||||
|
||||
let error = validate_target_support(Some(&target)).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("cannot fork or rewind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_replay_keeps_stage_scoped_session_activation_only() {
|
||||
assert!(replay_event_for_fork_projection(
|
||||
|
|
@ -385,11 +407,12 @@ mod tests {
|
|||
source_directory: Some("/client/source".to_string()),
|
||||
workflow_slug: Some("fork-source".to_string()),
|
||||
workflow_version_id: Some(workflow_version_id),
|
||||
target: Some(fabro_types::RunTarget::Git {
|
||||
target: Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
|
||||
repo: "example/repo".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
}),
|
||||
})),
|
||||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
|
|
@ -478,11 +501,12 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
forked_state.spec.target,
|
||||
Some(fabro_types::RunTarget::Git {
|
||||
Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
|
||||
repo: "example/repo".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
})
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
forked_state.spec.fork_source_ref.unwrap().source_run_id,
|
||||
|
|
|
|||
|
|
@ -170,11 +170,12 @@ mod tests {
|
|||
}
|
||||
|
||||
fn run_target() -> RunTarget {
|
||||
RunTarget::Git {
|
||||
RunTarget::Git(fabro_types::GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn append_created(
|
||||
|
|
@ -515,6 +516,63 @@ mod tests {
|
|||
assert_eq!(retry_state.spec.source_directory, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_preserves_folder_target_and_source_directory_without_git() {
|
||||
let store = memory_store();
|
||||
let source_run_id = fixtures::RUN_1;
|
||||
let source_store = store.create_run(&source_run_id).await.unwrap();
|
||||
let path = "/canonical/local/folder".to_string();
|
||||
let target = RunTarget::Folder { path: path.clone() };
|
||||
event::append_event(&source_store, &source_run_id, &Event::RunCreated {
|
||||
run_id: source_run_id,
|
||||
title: Some("Folder target".to_string()),
|
||||
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
|
||||
graph: serde_json::to_value(Graph::new("folder_target_retry")).unwrap(),
|
||||
workflow_source: Some("digraph folder_target_retry { start -> exit }".to_string()),
|
||||
labels: BTreeMap::new(),
|
||||
source_directory: Some(path.clone()),
|
||||
workflow_slug: Some("folder-target-retry".to_string()),
|
||||
workflow_version_id: Some(test_support::test_workflow_version_id()),
|
||||
target: Some(target.clone()),
|
||||
automation: None,
|
||||
provenance: provenance("source-user"),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
parent_id: None,
|
||||
web_url: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
event::append_event(&source_store, &source_run_id, &Event::RunSubmitted {
|
||||
definition_blob: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_failed(&source_store, source_run_id, FailureReason::WorkflowError).await;
|
||||
|
||||
let outcome = retry_run(&store, &RetryRunInput {
|
||||
source_run_id,
|
||||
new_run_id: RunId::new(),
|
||||
provenance: provenance("retry-user"),
|
||||
web_url: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let retry_store = store.open_run(&outcome.new_run_id).await.unwrap();
|
||||
let retry_state = retry_store.state().await.unwrap();
|
||||
assert_eq!(retry_state.status, RunStatus::Submitted);
|
||||
assert_eq!(retry_state.spec.target, Some(target));
|
||||
assert_eq!(
|
||||
retry_state.spec.source_directory.as_deref(),
|
||||
Some(path.as_str())
|
||||
);
|
||||
assert_eq!(retry_state.spec.git, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_creates_fresh_run_from_succeeded_source() {
|
||||
let store = memory_store();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -16,19 +16,22 @@ use fabro_sandbox::from_environment::{
|
|||
};
|
||||
use fabro_sandbox::{DockerSandboxOptions, SandboxSpec};
|
||||
use fabro_static::EnvVars;
|
||||
#[cfg(test)]
|
||||
use fabro_types::GitRunTarget;
|
||||
use fabro_types::settings::run::{
|
||||
ApprovalMode, McpServerSettings as ResolvedMcpServerSettings, PullRequestSettings,
|
||||
ResolvedGithubIntegration, ResolvedMcpEntry, RunMode, RunNamespace as ResolvedRunSettings,
|
||||
RunPrepareSettings as ResolvedRunPrepareSettings,
|
||||
};
|
||||
use fabro_types::{
|
||||
ManifestPath, RunId, RunRunnableSource, RunSpec, SandboxProviderKind, TargetValidationError,
|
||||
ManifestPath, RunId, RunRunnableSource, RunSpec, RunTarget, SandboxProviderKind,
|
||||
TargetValidationError,
|
||||
};
|
||||
use fabro_util::error::collect_chain;
|
||||
use fabro_vault::Vault;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
use tokio::time;
|
||||
use tokio::{fs, time};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::artifact_upload::ArtifactSink;
|
||||
|
|
@ -460,26 +463,38 @@ impl RunSession {
|
|||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
if sandbox_provider != SandboxProviderKind::Local
|
||||
&& matches!(record.target, Some(RunTarget::Folder { .. }))
|
||||
{
|
||||
return Err(Error::engine(
|
||||
"persisted folder run targets require the Local sandbox provider",
|
||||
));
|
||||
}
|
||||
let sandbox = match sandbox_provider {
|
||||
SandboxProviderKind::Local => {
|
||||
if let Some(target) = &record.target {
|
||||
SandboxProviderKind::Local => match record.target.as_ref() {
|
||||
Some(target @ (RunTarget::Git(_) | RunTarget::None {})) => {
|
||||
return Err(Error::engine(format!(
|
||||
"persisted {} run targets require a clone-based sandbox provider",
|
||||
target.kind_name()
|
||||
)));
|
||||
}
|
||||
let working_directory = local_working_directory_from_environment(
|
||||
&resolved.environment,
|
||||
record.source_directory.as_deref().map(Path::new),
|
||||
)
|
||||
.map_err(|err| {
|
||||
Error::engine_with_source(
|
||||
"Failed to resolve local environment working directory",
|
||||
err,
|
||||
Some(RunTarget::Folder { path }) => SandboxSpec::Local {
|
||||
working_directory: folder_working_directory_from_record(record, path).await?,
|
||||
},
|
||||
None => {
|
||||
let working_directory = local_working_directory_from_environment(
|
||||
&resolved.environment,
|
||||
record.source_directory.as_deref().map(Path::new),
|
||||
)
|
||||
})?;
|
||||
SandboxSpec::Local { working_directory }
|
||||
}
|
||||
.map_err(|err| {
|
||||
Error::engine_with_source(
|
||||
"Failed to resolve local environment working directory",
|
||||
err,
|
||||
)
|
||||
})?;
|
||||
SandboxSpec::Local { working_directory }
|
||||
}
|
||||
},
|
||||
SandboxProviderKind::Docker => {
|
||||
let mut config = resolve_docker_config(resolved, secret_lookup)?;
|
||||
config.skip_clone |= clone_source.skip_clone;
|
||||
|
|
@ -489,6 +504,7 @@ impl RunSession {
|
|||
run_id: Some(record.run_id),
|
||||
clone_origin_url: clone_source.origin_url,
|
||||
clone_branch: clone_source.branch,
|
||||
clone_tag: clone_source.tag,
|
||||
clone_commit_sha: clone_source.commit_sha,
|
||||
}
|
||||
}
|
||||
|
|
@ -504,6 +520,7 @@ impl RunSession {
|
|||
run_id: Some(record.run_id),
|
||||
clone_origin_url: clone_source.origin_url,
|
||||
clone_branch: clone_source.branch,
|
||||
clone_tag: clone_source.tag,
|
||||
clone_commit_sha: clone_source.commit_sha,
|
||||
api_key,
|
||||
}
|
||||
|
|
@ -586,17 +603,61 @@ impl RunSession {
|
|||
struct CloneSourceForRun {
|
||||
origin_url: Option<String>,
|
||||
branch: Option<String>,
|
||||
tag: Option<String>,
|
||||
commit_sha: Option<String>,
|
||||
/// The target asked for an empty workspace, so the provider must not
|
||||
/// clone even when it would otherwise inherit an origin.
|
||||
skip_clone: bool,
|
||||
}
|
||||
|
||||
async fn folder_working_directory_from_record(
|
||||
record: &RunSpec,
|
||||
target_path: &str,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let source_directory = record.source_directory.as_deref().ok_or_else(|| {
|
||||
Error::engine("persisted folder run target is missing its source-directory projection")
|
||||
})?;
|
||||
if source_directory != target_path {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run target disagrees with its source-directory projection",
|
||||
));
|
||||
}
|
||||
|
||||
// The persisted path was canonical at admission, so it is absolute and
|
||||
// symlink-free. Re-canonicalizing detects any redirection since then.
|
||||
let canonical = fs::canonicalize(target_path).await.map_err(|source| {
|
||||
Error::engine_with_source(
|
||||
"persisted folder run target path could not be canonicalized",
|
||||
source,
|
||||
)
|
||||
})?;
|
||||
if canonical.to_str() != Some(target_path) {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run target path is no longer canonical",
|
||||
));
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&canonical).await.map_err(|source| {
|
||||
Error::engine_with_source(
|
||||
"persisted folder run target path could not be inspected",
|
||||
source,
|
||||
)
|
||||
})?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run target path is not a directory",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
|
||||
let Some(target) = &record.target else {
|
||||
return Ok(CloneSourceForRun {
|
||||
origin_url: record.repo_origin_url().map(str::to_string),
|
||||
branch: record.base_branch().map(str::to_string),
|
||||
tag: None,
|
||||
commit_sha: None,
|
||||
skip_clone: false,
|
||||
});
|
||||
|
|
@ -613,20 +674,25 @@ fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
|
|||
"persisted Git run target has an invalid repository slug"
|
||||
}
|
||||
TargetValidationError::Branch => "persisted Git run target has an invalid branch",
|
||||
TargetValidationError::Tag => "persisted Git run target has an invalid tag",
|
||||
TargetValidationError::Sha => "persisted Git run target has an invalid SHA",
|
||||
})
|
||||
})?;
|
||||
// A target with no Git projection (`none`) asks for an empty workspace.
|
||||
Ok(match validated.git {
|
||||
Some(git) => CloneSourceForRun {
|
||||
// A target with no Git projection (`none` or `folder`) supplies no clone
|
||||
// source. Folder targets only reach the Local provider, where `skip_clone`
|
||||
// is unused.
|
||||
Ok(match (validated.target, validated.git) {
|
||||
(RunTarget::Git(target), Some(git)) => CloneSourceForRun {
|
||||
origin_url: Some(git.origin_url),
|
||||
branch: Some(git.branch),
|
||||
branch: Some(target.branch),
|
||||
tag: target.tag,
|
||||
commit_sha: git.sha,
|
||||
skip_clone: false,
|
||||
},
|
||||
None => CloneSourceForRun {
|
||||
_ => CloneSourceForRun {
|
||||
origin_url: None,
|
||||
branch: None,
|
||||
tag: None,
|
||||
commit_sha: None,
|
||||
skip_clone: true,
|
||||
},
|
||||
|
|
@ -1950,6 +2016,200 @@ reasoning = false
|
|||
assert!(error.to_string().contains("none run targets require"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_session_new_folder_target_uses_canonical_path_over_environment_cwd() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
|
||||
let (canonical_folder, canonical_text) = canonical_folder(&temp);
|
||||
let environment_cwd = temp.path().join("environment-cwd");
|
||||
std::fs::create_dir_all(&environment_cwd).unwrap();
|
||||
let mut settings = settings_from_run_layer(RunLayer::default());
|
||||
settings.run.environment.provider = EnvironmentProvider::Local;
|
||||
settings.run.environment.cwd = Some(environment_cwd.to_string_lossy().into_owned());
|
||||
let (persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let persisted = persisted_with_target_projection(
|
||||
persisted,
|
||||
RunTarget::Folder {
|
||||
path: canonical_text.clone(),
|
||||
},
|
||||
Some(canonical_text),
|
||||
);
|
||||
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
|
||||
let registry = Arc::new(test_registry());
|
||||
|
||||
let session = RunSession::new(
|
||||
&persisted,
|
||||
test_start_services(&store, &storage_root, emitter, registry).await,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let SandboxSpec::Local { working_directory } = session.sandbox else {
|
||||
panic!("folder target should retain the selected Local provider");
|
||||
};
|
||||
assert_eq!(working_directory, canonical_folder);
|
||||
assert_ne!(working_directory, environment_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_session_new_folder_target_rejects_clone_based_providers() {
|
||||
for provider in [EnvironmentProvider::Docker, EnvironmentProvider::Daytona] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
|
||||
let (_, canonical_text) = canonical_folder(&temp);
|
||||
let mut settings = settings_from_run_layer(RunLayer::default());
|
||||
settings.run.environment.provider = provider;
|
||||
settings.run.environment.image.docker = match provider {
|
||||
EnvironmentProvider::Docker => Some("buildpack-deps:noble".to_string()),
|
||||
EnvironmentProvider::Daytona | EnvironmentProvider::Local => None,
|
||||
};
|
||||
let (persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let persisted = persisted_with_target_projection(
|
||||
persisted,
|
||||
RunTarget::Folder {
|
||||
path: canonical_text.clone(),
|
||||
},
|
||||
Some(canonical_text),
|
||||
);
|
||||
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
|
||||
let registry = Arc::new(test_registry());
|
||||
|
||||
let Err(error) = RunSession::new(
|
||||
&persisted,
|
||||
test_start_services(&store, &storage_root, emitter, registry).await,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("folder target with a clone-based provider should fail closed");
|
||||
};
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("folder run targets require the Local sandbox provider")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_session_new_legacy_local_run_still_prefers_environment_cwd() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
|
||||
let environment_cwd = temp.path().join("environment-cwd");
|
||||
std::fs::create_dir_all(&environment_cwd).unwrap();
|
||||
let mut settings = settings_from_run_layer(RunLayer::default());
|
||||
settings.run.environment.provider = EnvironmentProvider::Local;
|
||||
settings.run.environment.cwd = Some(environment_cwd.to_string_lossy().into_owned());
|
||||
let (persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
|
||||
let registry = Arc::new(test_registry());
|
||||
|
||||
let session = RunSession::new(
|
||||
&persisted,
|
||||
test_start_services(&store, &storage_root, emitter, registry).await,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let SandboxSpec::Local { working_directory } = session.sandbox else {
|
||||
panic!("legacy Local run should retain the selected Local provider");
|
||||
};
|
||||
assert_eq!(working_directory, environment_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_target_start_rejects_projection_drift() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_, canonical_text) = canonical_folder(&temp);
|
||||
let mut record = test_folder_run_spec(&canonical_text);
|
||||
|
||||
record.source_directory = None;
|
||||
let missing_error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("missing source-directory projection should fail");
|
||||
assert!(missing_error.to_string().contains("missing"));
|
||||
|
||||
record.source_directory = Some(temp.path().to_string_lossy().into_owned());
|
||||
let drift_error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("mismatched source-directory projection should fail");
|
||||
assert!(drift_error.to_string().contains("disagrees"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_target_start_rejects_relative_and_noncanonical_paths() {
|
||||
let relative = "relative/folder";
|
||||
let relative_record = test_folder_run_spec(relative);
|
||||
let relative_error = folder_working_directory_from_record(&relative_record, relative)
|
||||
.await
|
||||
.expect_err("relative persisted target should fail");
|
||||
assert!(
|
||||
relative_error
|
||||
.to_string()
|
||||
.contains("persisted folder run target path")
|
||||
);
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (canonical_folder, _) = canonical_folder(&temp);
|
||||
let noncanonical = canonical_folder
|
||||
.join("..")
|
||||
.join(canonical_folder.file_name().unwrap());
|
||||
let noncanonical_text = noncanonical.to_str().unwrap();
|
||||
let noncanonical_record = test_folder_run_spec(noncanonical_text);
|
||||
|
||||
let error = folder_working_directory_from_record(&noncanonical_record, noncanonical_text)
|
||||
.await
|
||||
.expect_err("noncanonical persisted target should fail");
|
||||
assert!(error.to_string().contains("no longer canonical"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_target_start_rejects_disappeared_or_retyped_path() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (canonical_folder, canonical_text) = canonical_folder(&temp);
|
||||
let record = test_folder_run_spec(&canonical_text);
|
||||
|
||||
std::fs::remove_dir(&canonical_folder).unwrap();
|
||||
let missing_error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("disappeared folder target should fail");
|
||||
assert!(
|
||||
missing_error
|
||||
.to_string()
|
||||
.contains("could not be canonicalized")
|
||||
);
|
||||
assert!(!missing_error.causes().is_empty());
|
||||
|
||||
fs::write(&canonical_folder, "not a directory")
|
||||
.await
|
||||
.unwrap();
|
||||
let file_error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("folder target replaced by a file should fail");
|
||||
assert!(file_error.to_string().contains("is not a directory"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn folder_target_start_rejects_redirected_path() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (canonical_folder, canonical_text) = canonical_folder(&temp);
|
||||
let redirected = temp.path().join("redirected-target");
|
||||
let record = test_folder_run_spec(&canonical_text);
|
||||
std::fs::rename(&canonical_folder, &redirected).unwrap();
|
||||
symlink(&redirected, &canonical_folder).unwrap();
|
||||
|
||||
let error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("redirected folder target should fail");
|
||||
assert!(error.to_string().contains("no longer canonical"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_docker_config_maps_environment_hints() {
|
||||
let settings = settings_from_run_layer(RunLayer {
|
||||
|
|
@ -2069,6 +2329,36 @@ reasoning = false
|
|||
(created.persisted, store)
|
||||
}
|
||||
|
||||
fn persisted_with_target_projection(
|
||||
persisted: Persisted,
|
||||
target: RunTarget,
|
||||
source_directory: Option<String>,
|
||||
) -> Persisted {
|
||||
let (graph, source, diagnostics, run_dir, mut run_spec) = persisted.into_parts();
|
||||
run_spec.target = Some(target);
|
||||
run_spec.source_directory = source_directory;
|
||||
Persisted::new(graph, source, diagnostics, run_dir, run_spec)
|
||||
}
|
||||
|
||||
/// Create `folder-target` under `temp` and return its canonical path and
|
||||
/// the UTF-8 text a persisted folder target would carry.
|
||||
fn canonical_folder(temp: &tempfile::TempDir) -> (PathBuf, String) {
|
||||
let folder = temp.path().join("folder-target");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let canonical_folder = folder.canonicalize().unwrap();
|
||||
let canonical_text = canonical_folder.to_str().unwrap().to_string();
|
||||
(canonical_folder, canonical_text)
|
||||
}
|
||||
|
||||
fn test_folder_run_spec(path: &str) -> RunSpec {
|
||||
let mut record = test_support::test_run_spec();
|
||||
record.target = Some(RunTarget::Folder {
|
||||
path: path.to_string(),
|
||||
});
|
||||
record.source_directory = Some(path.to_string());
|
||||
record
|
||||
}
|
||||
|
||||
async fn persisted_workflow(dot: &str, storage_root: &Path) -> (Persisted, Arc<Database>) {
|
||||
persisted_workflow_with_settings(
|
||||
dot,
|
||||
|
|
@ -2894,11 +3184,12 @@ reasoning = false
|
|||
let mut spec = test_support::test_run_spec();
|
||||
let submitted_sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
let normalized_sha = "abcdef0123456789abcdef0123456789abcdef01";
|
||||
spec.target = Some(RunTarget::Git {
|
||||
spec.target = Some(RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
sha: Some(submitted_sha.to_string()),
|
||||
});
|
||||
}));
|
||||
spec.git = Some(fabro_types::GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
|
|
@ -2913,17 +3204,19 @@ reasoning = false
|
|||
Some("https://github.com/fabro-sh/fabro")
|
||||
);
|
||||
assert_eq!(source.branch.as_deref(), Some("feature/run-intent"));
|
||||
assert_eq!(source.tag.as_deref(), Some("v1.2.3"));
|
||||
assert_eq!(source.commit_sha.as_deref(), Some(normalized_sha));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_commit_persisted_git_target_without_sha_keeps_branch_unpinned() {
|
||||
let mut spec = test_support::test_run_spec();
|
||||
spec.target = Some(RunTarget::Git {
|
||||
spec.target = Some(RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
});
|
||||
}));
|
||||
spec.git = Some(fabro_types::GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
|
|
@ -2937,14 +3230,32 @@ reasoning = false
|
|||
assert_eq!(source.commit_sha, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_source_preserves_unpinned_tag_separately_from_working_branch() {
|
||||
let mut spec = test_support::test_run_spec();
|
||||
spec.target = Some(RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "release-work".to_string(),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
sha: None,
|
||||
}));
|
||||
|
||||
let source = clone_source_for_run(&spec).unwrap();
|
||||
|
||||
assert_eq!(source.branch.as_deref(), Some("release-work"));
|
||||
assert_eq!(source.tag.as_deref(), Some("v1.2.3"));
|
||||
assert_eq!(source.commit_sha, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_commit_persisted_git_target_is_authoritative_over_projection() {
|
||||
let mut spec = test_support::test_run_spec();
|
||||
spec.target = Some(RunTarget::Git {
|
||||
spec.target = Some(RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
});
|
||||
}));
|
||||
// A drifted (or absent) projection never feeds the clone source: the
|
||||
// validated target alone does.
|
||||
spec.git = Some(fabro_types::GitContext {
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ async fn create_env_with_github_app(
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?")
|
||||
|
|
@ -413,7 +414,7 @@ async fn daytona_snapshot_sandbox() {
|
|||
};
|
||||
|
||||
let creds = load_github_app_credentials();
|
||||
let env = DaytonaSandbox::new(config, Some(creds), None, None, None, None, None)
|
||||
let env = DaytonaSandbox::new(config, Some(creds), None, None, None, None, None, None)
|
||||
.await
|
||||
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?");
|
||||
env.initialize().await.unwrap();
|
||||
|
|
@ -1618,7 +1619,7 @@ async fn daytona_computer_use_browser_screenshot() {
|
|||
skip_clone: true,
|
||||
..DaytonaConfig::default()
|
||||
};
|
||||
let env = DaytonaSandbox::new(config, None, None, None, None, None, None)
|
||||
let env = DaytonaSandbox::new(config, None, None, None, None, None, None, None)
|
||||
.await
|
||||
.expect("DAYTONA_API_KEY must be set");
|
||||
env.initialize().await.unwrap();
|
||||
|
|
@ -1766,7 +1767,7 @@ async fn daytona_playwright_mcp_sandbox_transport() {
|
|||
skip_clone: true,
|
||||
..DaytonaConfig::default()
|
||||
};
|
||||
let sandbox = DaytonaSandbox::new(config, None, None, None, None, None, None)
|
||||
let sandbox = DaytonaSandbox::new(config, None, None, None, None, None, None, None)
|
||||
.await
|
||||
.expect("DAYTONA_API_KEY must be set");
|
||||
sandbox.initialize().await.unwrap();
|
||||
|
|
|
|||
|
|
@ -1143,7 +1143,11 @@ impl Handler for OnFailureRecordingHandler {
|
|||
) -> Result<Outcome, fabro_workflow::error::Error> {
|
||||
self.visits.lock().unwrap().push(node.id.clone());
|
||||
if node.id == "work" {
|
||||
Ok(Outcome::fail_classify("forced work failure"))
|
||||
let mut outcome = Outcome::fail_classify("forced work failure");
|
||||
outcome
|
||||
.context_updates
|
||||
.insert("recovery_ready".to_string(), serde_json::json!(true));
|
||||
Ok(outcome)
|
||||
} else {
|
||||
Ok(Outcome::success())
|
||||
}
|
||||
|
|
@ -1319,6 +1323,127 @@ async fn node_on_failure_route_overrides_graph_exit_policy() {
|
|||
assert_eq!(*run.visits.lock().unwrap(), vec!["work", "downstream"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_on_failure_succeed_promotes_failed_node_and_keeps_failure_in_events() {
|
||||
let graph = on_failure_graph(
|
||||
r#"graph [on_failure="exit"]
|
||||
work [on_failure="succeed"]"#,
|
||||
);
|
||||
let emitter = Emitter::default();
|
||||
let events = collect_events(&emitter);
|
||||
let run = run_on_failure(&graph, emitter).await;
|
||||
|
||||
assert_eq!(run.outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(*run.visits.lock().unwrap(), vec!["work", "downstream"]);
|
||||
|
||||
let checkpoint = run
|
||||
.state
|
||||
.current_checkpoint()
|
||||
.expect("downstream should be checkpointed");
|
||||
let work = &checkpoint.node_outcomes["work"];
|
||||
assert_eq!(work.status, StageOutcome::Succeeded);
|
||||
assert_eq!(work.failure_reason(), Some("forced work failure"));
|
||||
assert_eq!(
|
||||
work.notes.as_deref(),
|
||||
Some("node on_failure=succeed promoted a failed outcome to succeeded")
|
||||
);
|
||||
|
||||
let events = events.lock().unwrap();
|
||||
let completed = events
|
||||
.iter()
|
||||
.find_map(|event| match &event.body {
|
||||
EventBody::StageCompleted(props) if event.node_id.as_deref() == Some("work") => {
|
||||
Some(props.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("promoted work stage should emit stage.completed");
|
||||
assert_eq!(completed.status, StageOutcome::Succeeded);
|
||||
assert_eq!(
|
||||
completed
|
||||
.failure
|
||||
.as_ref()
|
||||
.map(|failure| failure.message.as_str()),
|
||||
Some("forced work failure")
|
||||
);
|
||||
assert!(!events.iter().any(|event| {
|
||||
matches!(&event.body, EventBody::StageFailed(_)) && event.node_id.as_deref() == Some("work")
|
||||
}));
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(&event.body, EventBody::RunCompleted(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_status_true_is_an_alias_for_on_failure_succeed() {
|
||||
let graph = on_failure_graph(
|
||||
r#"graph [on_failure="exit"]
|
||||
work [auto_status=true]"#,
|
||||
);
|
||||
let run = run_on_failure(&graph, Emitter::default()).await;
|
||||
|
||||
assert_eq!(run.outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(*run.visits.lock().unwrap(), vec!["work", "downstream"]);
|
||||
let checkpoint = run
|
||||
.state
|
||||
.current_checkpoint()
|
||||
.expect("downstream should be checkpointed");
|
||||
assert_eq!(
|
||||
checkpoint.node_outcomes["work"].status,
|
||||
StageOutcome::Succeeded
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_on_failure_succeed_prefers_explicit_failure_edge() {
|
||||
let graph = on_failure_graph(
|
||||
r#"work [on_failure="succeed"]
|
||||
recovery
|
||||
work -> recovery [condition="outcome=failed"]
|
||||
recovery -> exit"#,
|
||||
);
|
||||
let run = run_on_failure(&graph, Emitter::default()).await;
|
||||
|
||||
assert_eq!(run.outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(*run.visits.lock().unwrap(), vec!["work", "recovery"]);
|
||||
let checkpoint = run
|
||||
.state
|
||||
.current_checkpoint()
|
||||
.expect("recovery should be checkpointed");
|
||||
assert!(checkpoint.node_outcomes["work"].status.is_failure());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_on_failure_succeed_tests_recovery_edge_with_pending_result_context() {
|
||||
let graph = on_failure_graph(
|
||||
r#"work [on_failure="succeed"]
|
||||
recovery
|
||||
work -> recovery [condition="outcome=failed && context.recovery_ready=true && context.failure_class=deterministic"]
|
||||
recovery -> exit"#,
|
||||
);
|
||||
let run = run_on_failure(&graph, Emitter::default()).await;
|
||||
|
||||
assert_eq!(run.outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(*run.visits.lock().unwrap(), vec!["work", "recovery"]);
|
||||
let checkpoint = run
|
||||
.state
|
||||
.current_checkpoint()
|
||||
.expect("recovery should be checkpointed");
|
||||
assert!(checkpoint.node_outcomes["work"].status.is_failure());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_on_failure_succeed_satisfies_goal_gate_without_retry_target() {
|
||||
let graph =
|
||||
on_failure_graph(r#"work [on_failure="succeed" goal_gate=true retry_target="start"]"#);
|
||||
let run = run_on_failure(&graph, Emitter::default()).await;
|
||||
|
||||
assert_eq!(run.outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(*run.visits.lock().unwrap(), vec!["work", "downstream"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_gate_routes_to_retry_target_on_failure() {
|
||||
// Pipeline:
|
||||
|
|
@ -13612,7 +13737,7 @@ async fn asset_collection_docker_sandbox() {
|
|||
..Default::default()
|
||||
};
|
||||
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
|
||||
fabro_agent::DockerSandbox::new(config, None, None, None, None, None)
|
||||
fabro_agent::DockerSandbox::new(config, None, None, None, None, None, None)
|
||||
.expect("Docker not available"),
|
||||
);
|
||||
sandbox.initialize().await.expect("Docker init failed");
|
||||
|
|
|
|||
|
|
@ -739,6 +739,7 @@ fn main() {
|
|||
("RunIntent", "fabro_types::RunIntent", &[]),
|
||||
("RunIntentArgs", "fabro_types::RunIntentArgs", &[]),
|
||||
("RunTarget", "fabro_types::RunTarget", &[]),
|
||||
("GitRunTarget", "fabro_types::GitRunTarget", &[]),
|
||||
("WorkflowPath", "fabro_types::WorkflowPath", &[]),
|
||||
("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]),
|
||||
("BlobHash", "fabro_types::BlobHash", &[]),
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ pub mod types {
|
|||
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
|
||||
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
|
||||
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
|
||||
FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,
|
||||
FailureSignature, GitContext, GitRunTarget, IdpIdentity, IntegrationConnectionKind,
|
||||
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
|
||||
IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind,
|
||||
McpServerDraft as CreateMcpServerRequest, McpServerProjection,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::any::{TypeId, type_name};
|
|||
use std::collections::HashMap;
|
||||
|
||||
use fabro_api::types::{RunIntent as ApiRunIntent, RunIntentArgs as ApiRunIntentArgs};
|
||||
use fabro_types::{RunIntent, RunIntentArgs, RunTarget, test_support};
|
||||
use fabro_types::{GitRunTarget, RunIntent, RunIntentArgs, RunTarget, test_support};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
|
|
@ -10,17 +10,19 @@ fn run_intent_schemas_reuse_canonical_types() {
|
|||
assert_same_type::<ApiRunIntent, RunIntent>();
|
||||
assert_same_type::<ApiRunIntentArgs, RunIntentArgs>();
|
||||
assert_same_type::<fabro_api::types::RunTarget, RunTarget>();
|
||||
assert_same_type::<fabro_api::types::GitRunTarget, GitRunTarget>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_round_trips_the_openapi_shape() {
|
||||
let intent = RunIntent {
|
||||
workflow_version_id: test_support::test_workflow_version_id(),
|
||||
target: RunTarget::Git {
|
||||
target: RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||
},
|
||||
}),
|
||||
args: RunIntentArgs {
|
||||
model: Some("gpt-5.6-sol".to_string()),
|
||||
provider: Some("openai".to_string()),
|
||||
|
|
@ -61,6 +63,33 @@ fn run_intent_none_target_round_trips_the_openapi_shape() {
|
|||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_folder_target_round_trips_the_openapi_shape() {
|
||||
let intent = RunIntent {
|
||||
workflow_version_id: test_support::test_workflow_version_id(),
|
||||
target: RunTarget::Folder {
|
||||
path: "/srv/fabro/workspaces/example".to_string(),
|
||||
},
|
||||
args: RunIntentArgs::default(),
|
||||
environment_id: Some("local".to_string()),
|
||||
parent_id: None,
|
||||
title: None,
|
||||
goal: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&intent).unwrap();
|
||||
let api: ApiRunIntent = serde_json::from_value(value.clone()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
value["target"],
|
||||
json!({
|
||||
"kind": "folder",
|
||||
"path": "/srv/fabro/workspaces/example",
|
||||
})
|
||||
);
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ fabro-types = { path = "../fabro-types" }
|
|||
fabro-util = { path = "../fabro-util" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
|
|
|
|||
|
|
@ -9,13 +9,15 @@ use tokio_util::sync::CancellationToken;
|
|||
|
||||
use crate::context::Context;
|
||||
use crate::error::{Error, Result, VisitLimitSource};
|
||||
use crate::graph::{EdgeSpec, Graph, NodeSpec};
|
||||
use crate::graph::{EdgeSelection, EdgeSpec, Graph, NodeSpec};
|
||||
use crate::handler::NodeHandler;
|
||||
use crate::lifecycle::{
|
||||
AttemptContext, AttemptResultContext, EdgeContext, EdgeDecision, NodeDecision, NoopLifecycle,
|
||||
RunLifecycle,
|
||||
};
|
||||
use crate::outcome::{NodeResult, NodeResultExt, Outcome, OutcomeMeta};
|
||||
use crate::outcome::{
|
||||
FailureDetail, NodeResult, NodeResultExt, Outcome, OutcomeMeta, StageOutcome,
|
||||
};
|
||||
use crate::state::ExecutionState;
|
||||
|
||||
/// Build a [`NodeResult`] from an attempt outcome, pulling the inference and
|
||||
|
|
@ -66,6 +68,34 @@ enum NextStep {
|
|||
End,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct RoutingFingerprint {
|
||||
status: StageOutcome,
|
||||
preferred_label: Option<String>,
|
||||
suggested_next_ids: Vec<String>,
|
||||
context_updates: std::collections::HashMap<String, serde_json::Value>,
|
||||
jump_to_node: Option<String>,
|
||||
failure: Option<FailureDetail>,
|
||||
}
|
||||
|
||||
impl<M: OutcomeMeta> From<&Outcome<M>> for RoutingFingerprint {
|
||||
fn from(outcome: &Outcome<M>) -> Self {
|
||||
Self {
|
||||
status: outcome.status,
|
||||
preferred_label: outcome.preferred_label.clone(),
|
||||
suggested_next_ids: outcome.suggested_next_ids.clone(),
|
||||
context_updates: outcome.context_updates.clone(),
|
||||
jump_to_node: outcome.jump_to_node.clone(),
|
||||
failure: outcome.failure.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PreparedEdgeSelection<G: Graph> {
|
||||
fingerprint: RoutingFingerprint,
|
||||
selection: Option<EdgeSelection<G>>,
|
||||
}
|
||||
|
||||
pub struct ExecutorBuilder<G: Graph> {
|
||||
handler: Arc<dyn NodeHandler<G>>,
|
||||
lifecycle: Option<Box<dyn RunLifecycle<G>>>,
|
||||
|
|
@ -208,47 +238,56 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
state.increment_visits(node.id());
|
||||
|
||||
// before_node lifecycle
|
||||
let node_result = match self.lifecycle.before_node(&node, &state).await? {
|
||||
NodeDecision::Skip(outcome) => {
|
||||
let mut result = NodeResult::from_skip(*outcome);
|
||||
self.lifecycle
|
||||
.after_node(&node, &mut result, &state)
|
||||
.await?;
|
||||
result
|
||||
}
|
||||
NodeDecision::Block(msg) => {
|
||||
return Err(Error::blocked(msg));
|
||||
}
|
||||
NodeDecision::Continue => {
|
||||
// Execute with retry, racing against stall token
|
||||
let execution_result = if let Some(ref stall) = self.options.stall_token {
|
||||
tokio::select! {
|
||||
r = self.execute_with_retry(&node, &state, graph) => r,
|
||||
() = stall.cancelled() => {
|
||||
return Err(Error::StallTimeout {
|
||||
node_id: node.id().to_string(),
|
||||
});
|
||||
let (node_result, prepared_selection) =
|
||||
match self.lifecycle.before_node(&node, &state).await? {
|
||||
NodeDecision::Skip(outcome) => {
|
||||
let mut result = NodeResult::from_skip(*outcome);
|
||||
self.lifecycle
|
||||
.after_node(&node, &mut result, &state)
|
||||
.await?;
|
||||
(result, None)
|
||||
}
|
||||
NodeDecision::Block(msg) => {
|
||||
return Err(Error::blocked(msg));
|
||||
}
|
||||
NodeDecision::Continue => {
|
||||
// Execute with retry, racing against stall token
|
||||
let execution_result = if let Some(ref stall) = self.options.stall_token {
|
||||
tokio::select! {
|
||||
r = self.execute_with_retry(&node, &state, graph) => r,
|
||||
() = stall.cancelled() => {
|
||||
return Err(Error::StallTimeout {
|
||||
node_id: node.id().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.execute_with_retry(&node, &state, graph).await
|
||||
};
|
||||
let mut result = match execution_result {
|
||||
Ok(result) => result,
|
||||
Err(Error::Cancelled) => {
|
||||
state.cancelled = true;
|
||||
let outcome = Outcome::fail("run cancelled");
|
||||
self.lifecycle.on_run_end(&outcome, &state).await;
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let mut prepared_selection = self
|
||||
.apply_succeed_policy(&node, &mut result, &state, graph)
|
||||
.await?;
|
||||
self.lifecycle
|
||||
.after_node(&node, &mut result, &state)
|
||||
.await?;
|
||||
if prepared_selection.as_ref().is_some_and(|prepared| {
|
||||
prepared.fingerprint != RoutingFingerprint::from(&result.outcome)
|
||||
}) {
|
||||
prepared_selection = None;
|
||||
}
|
||||
} else {
|
||||
self.execute_with_retry(&node, &state, graph).await
|
||||
};
|
||||
let mut result = match execution_result {
|
||||
Ok(result) => result,
|
||||
Err(Error::Cancelled) => {
|
||||
state.cancelled = true;
|
||||
let outcome = Outcome::fail("run cancelled");
|
||||
self.lifecycle.on_run_end(&outcome, &state).await;
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
self.lifecycle
|
||||
.after_node(&node, &mut result, &state)
|
||||
.await?;
|
||||
result
|
||||
}
|
||||
};
|
||||
(result, prepared_selection)
|
||||
}
|
||||
};
|
||||
|
||||
state.record(node.id(), &node_result);
|
||||
self.lifecycle
|
||||
|
|
@ -258,7 +297,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
// Determine next step
|
||||
let last_outcome = &state.node_outcomes[node.id()];
|
||||
let next = self
|
||||
.resolve_next_step(&node, last_outcome, &state, graph)
|
||||
.resolve_next_step(&node, last_outcome, &state, graph, prepared_selection)
|
||||
.await?;
|
||||
|
||||
// Checkpoint AFTER edge selection so next_node_id is known
|
||||
|
|
@ -285,7 +324,10 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
if outcome.status.is_failure() {
|
||||
let resolved = graph.resolve_on_failure(&node);
|
||||
let message = match resolved.policy() {
|
||||
OnFailure::Route => {
|
||||
// A failed outcome under `succeed` only reaches
|
||||
// the end when an explicit route matched but
|
||||
// produced no next node, which mirrors `route`.
|
||||
OnFailure::Route | OnFailure::Succeed => {
|
||||
format!("stage {} failed with no outgoing fail edge", node.id())
|
||||
}
|
||||
OnFailure::Exit => format!(
|
||||
|
|
@ -423,12 +465,65 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
unreachable!("loop always returns or continues")
|
||||
}
|
||||
|
||||
/// Applies the `on_failure="succeed"` policy to a failed node result.
|
||||
///
|
||||
/// This runs before the lifecycle observes the result, so the recorded
|
||||
/// outcome, context keys, goal gates, events, and routing all see the
|
||||
/// effective outcome. Explicit recovery routes take priority: a failed
|
||||
/// outcome that carries a jump, or that an explicit edge would route,
|
||||
/// stays `failed`.
|
||||
async fn apply_succeed_policy(
|
||||
&self,
|
||||
node: &G::Node,
|
||||
result: &mut NodeResult<G::Meta>,
|
||||
state: &ExecutionState<G::Meta>,
|
||||
graph: &G,
|
||||
) -> Result<Option<PreparedEdgeSelection<G>>> {
|
||||
let outcome = &result.outcome;
|
||||
if !outcome.status.is_failure() || outcome.jump_to_node.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let resolved = graph.resolve_on_failure(node);
|
||||
if resolved.policy() != OnFailure::Succeed {
|
||||
return Ok(None);
|
||||
}
|
||||
let projected_context = state.context.fork();
|
||||
projected_context.apply_updates(&result.outcome.context_updates);
|
||||
graph.project_result_context(node, result, &projected_context);
|
||||
let routing_context = self
|
||||
.handler
|
||||
.context_for_edge_selection(&projected_context, graph)
|
||||
.await?;
|
||||
if let Some(selection) = graph
|
||||
.select_edge(node, outcome, &routing_context)
|
||||
.filter(|selection| selection.reason.is_explicit())
|
||||
{
|
||||
return Ok(Some(PreparedEdgeSelection {
|
||||
fingerprint: RoutingFingerprint::from(&result.outcome),
|
||||
selection: Some(selection),
|
||||
}));
|
||||
}
|
||||
if result.outcome.apply_on_failure(resolved) {
|
||||
tracing::debug!(
|
||||
node = %node.id(),
|
||||
scope = %resolved.scope(),
|
||||
"on_failure=succeed promoted failed outcome"
|
||||
);
|
||||
}
|
||||
graph.project_result_context(node, result, &routing_context);
|
||||
Ok(Some(PreparedEdgeSelection {
|
||||
fingerprint: RoutingFingerprint::from(&result.outcome),
|
||||
selection: graph.select_edge(node, &result.outcome, &routing_context),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_next_step(
|
||||
&self,
|
||||
node: &G::Node,
|
||||
outcome: &Outcome<G::Meta>,
|
||||
state: &ExecutionState<G::Meta>,
|
||||
graph: &G,
|
||||
prepared_selection: Option<PreparedEdgeSelection<G>>,
|
||||
) -> Result<NextStep> {
|
||||
// Jump takes priority
|
||||
if let Some(ref target) = outcome.jump_to_node {
|
||||
|
|
@ -447,14 +542,26 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
}
|
||||
}
|
||||
|
||||
// Normal edge selection
|
||||
let routing_context = self
|
||||
.handler
|
||||
.context_for_edge_selection(&state.context, graph)
|
||||
.await?;
|
||||
if let Some(selection) = graph.select_edge(node, outcome, &routing_context) {
|
||||
// Normal edge selection. A failed `succeed` result prepared this
|
||||
// decision while its original failure context was still available.
|
||||
let selection = if let Some(prepared) = prepared_selection {
|
||||
prepared.selection
|
||||
} else {
|
||||
let routing_context = self
|
||||
.handler
|
||||
.context_for_edge_selection(&state.context, graph)
|
||||
.await?;
|
||||
graph.select_edge(node, outcome, &routing_context)
|
||||
}
|
||||
.filter(|selection| {
|
||||
!outcome.status.is_failure()
|
||||
|| selection.reason.is_explicit()
|
||||
|| graph.resolve_on_failure(node).policy() == OnFailure::Route
|
||||
});
|
||||
if let Some(selection) = selection {
|
||||
let target = selection.edge.target().to_string();
|
||||
let is_restart = selection.edge.is_loop_restart();
|
||||
let reason: &'static str = selection.reason.into();
|
||||
|
||||
let ctx = EdgeContext {
|
||||
from: node.id(),
|
||||
|
|
@ -462,7 +569,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
edge: Some(selection.edge.clone()),
|
||||
is_jump: false,
|
||||
outcome,
|
||||
reason: selection.reason,
|
||||
reason,
|
||||
};
|
||||
match self.lifecycle.on_edge_selected(&ctx, state).await? {
|
||||
EdgeDecision::Continue => {
|
||||
|
|
@ -2324,6 +2431,317 @@ mod tests {
|
|||
assert!(state.node_outcomes.contains_key("downstream"));
|
||||
}
|
||||
|
||||
/// Records the outcome status each lifecycle callback observed, so tests
|
||||
/// can prove the `succeed` policy is applied before `after_node`.
|
||||
struct StatusCaptureLifecycle(Arc<Mutex<Vec<(String, StageOutcome)>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl RunLifecycle<TestGraph> for StatusCaptureLifecycle {
|
||||
async fn after_node(
|
||||
&self,
|
||||
node: &TestNode,
|
||||
result: &mut NodeResult,
|
||||
_state: &ExecutionState,
|
||||
) -> Result<()> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((node.id().to_string(), result.outcome.status));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn succeed_policy_graph() -> TestGraph {
|
||||
TestGraph::new(
|
||||
vec![
|
||||
TestNode::new("work").with_on_failure(OnFailure::Succeed),
|
||||
TestNode::new("downstream"),
|
||||
TestNode::terminal("end"),
|
||||
],
|
||||
vec![
|
||||
TestEdge::new("work", "downstream"),
|
||||
TestEdge::new("downstream", "end"),
|
||||
],
|
||||
"work",
|
||||
)
|
||||
}
|
||||
|
||||
fn fail_work_handler() -> Arc<dyn NodeHandler<TestGraph>> {
|
||||
Arc::new(
|
||||
DispatchHandler::new(Arc::new(AlwaysSucceedHandler))
|
||||
.with_handler("work", Arc::new(AlwaysFailHandler::new("boom"))),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_succeed_policy_promotes_failed_node_before_lifecycle_and_continues() {
|
||||
let graph = succeed_policy_graph();
|
||||
let state = ExecutionState::new(&graph).unwrap();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let executor = ExecutorBuilder::new(fail_work_handler())
|
||||
.lifecycle(Box::new(StatusCaptureLifecycle(Arc::clone(&seen))))
|
||||
.build();
|
||||
|
||||
let (outcome, state) = executor.run(&graph, state).await.unwrap();
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
let work = &state.node_outcomes["work"];
|
||||
assert_eq!(work.status, StageOutcome::Succeeded);
|
||||
assert_eq!(
|
||||
work.failure
|
||||
.as_ref()
|
||||
.map(|failure| failure.message.as_str()),
|
||||
Some("boom"),
|
||||
"the original failure stays on the recorded outcome"
|
||||
);
|
||||
assert_eq!(
|
||||
work.notes.as_deref(),
|
||||
Some("node on_failure=succeed promoted a failed outcome to succeeded")
|
||||
);
|
||||
assert!(state.node_outcomes.contains_key("downstream"));
|
||||
assert_eq!(
|
||||
seen.lock().unwrap().clone(),
|
||||
vec![
|
||||
("work".to_string(), StageOutcome::Succeeded),
|
||||
("downstream".to_string(), StageOutcome::Succeeded),
|
||||
],
|
||||
"after_node observes the effective outcome"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_succeed_policy_resolves_routing_context_once_per_node() {
|
||||
struct CountingHandler(Arc<AtomicU32>);
|
||||
|
||||
#[async_trait]
|
||||
impl NodeHandler<TestGraph> for CountingHandler {
|
||||
async fn execute(
|
||||
&self,
|
||||
node: &TestNode,
|
||||
_context: &Context,
|
||||
_graph: &TestGraph,
|
||||
) -> Result<Outcome> {
|
||||
if node.id() == "work" {
|
||||
Ok(Outcome::fail("boom"))
|
||||
} else {
|
||||
Ok(Outcome::success())
|
||||
}
|
||||
}
|
||||
|
||||
async fn context_for_edge_selection(
|
||||
&self,
|
||||
context: &Context,
|
||||
_graph: &TestGraph,
|
||||
) -> Result<Context> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(context.clone())
|
||||
}
|
||||
}
|
||||
|
||||
let graph = succeed_policy_graph();
|
||||
let state = ExecutionState::new(&graph).unwrap();
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let executor = ExecutorBuilder::new(Arc::new(CountingHandler(Arc::clone(&calls)))).build();
|
||||
|
||||
let (outcome, _) = executor.run(&graph, state).await.unwrap();
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_succeed_policy_keeps_failed_outcome_when_explicit_route_matches() {
|
||||
let graph = TestGraph::new(
|
||||
vec![
|
||||
TestNode::new("work").with_on_failure(OnFailure::Succeed),
|
||||
TestNode::new("recovery"),
|
||||
TestNode::new("downstream"),
|
||||
TestNode::terminal("end"),
|
||||
],
|
||||
vec![
|
||||
TestEdge::new("work", "recovery").with_label("failed"),
|
||||
TestEdge::new("work", "downstream"),
|
||||
TestEdge::new("recovery", "end"),
|
||||
TestEdge::new("downstream", "end"),
|
||||
],
|
||||
"work",
|
||||
);
|
||||
let state = ExecutionState::new(&graph).unwrap();
|
||||
let executor = ExecutorBuilder::new(fail_work_handler()).build();
|
||||
|
||||
let (outcome, state) = executor.run(&graph, state).await.unwrap();
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(state.node_outcomes["work"].status, StageOutcome::Failed {
|
||||
retry_requested: false,
|
||||
});
|
||||
assert!(state.node_outcomes.contains_key("recovery"));
|
||||
assert!(!state.node_outcomes.contains_key("downstream"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_succeed_policy_keeps_failed_outcome_with_jump() {
|
||||
struct FailWithJump;
|
||||
|
||||
#[async_trait]
|
||||
impl NodeHandler<TestGraph> for FailWithJump {
|
||||
async fn execute(
|
||||
&self,
|
||||
_node: &TestNode,
|
||||
_context: &Context,
|
||||
_graph: &TestGraph,
|
||||
) -> Result<Outcome> {
|
||||
let mut outcome = Outcome::fail("boom");
|
||||
outcome.jump_to_node = Some("recovery".to_string());
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
let graph = TestGraph::new(
|
||||
vec![
|
||||
TestNode::new("work").with_on_failure(OnFailure::Succeed),
|
||||
TestNode::new("recovery"),
|
||||
TestNode::new("downstream"),
|
||||
TestNode::terminal("end"),
|
||||
],
|
||||
vec![
|
||||
TestEdge::new("work", "downstream"),
|
||||
TestEdge::new("recovery", "end"),
|
||||
TestEdge::new("downstream", "end"),
|
||||
],
|
||||
"work",
|
||||
);
|
||||
let state = ExecutionState::new(&graph).unwrap();
|
||||
let handler = DispatchHandler::new(Arc::new(AlwaysSucceedHandler))
|
||||
.with_handler("work", Arc::new(FailWithJump));
|
||||
let executor =
|
||||
ExecutorBuilder::new(Arc::new(handler) as Arc<dyn NodeHandler<TestGraph>>).build();
|
||||
|
||||
let (outcome, state) = executor.run(&graph, state).await.unwrap();
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
assert!(state.node_outcomes["work"].status.is_failure());
|
||||
assert!(state.node_outcomes.contains_key("recovery"));
|
||||
assert!(!state.node_outcomes.contains_key("downstream"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_succeed_policy_satisfies_goal_gate() {
|
||||
let graph = TestGraph::new(
|
||||
vec![
|
||||
TestNode::new("work").with_on_failure(OnFailure::Succeed),
|
||||
TestNode::terminal("end").with_goal_gate("work", StageOutcome::Succeeded),
|
||||
],
|
||||
vec![TestEdge::new("work", "end")],
|
||||
"work",
|
||||
);
|
||||
let state = ExecutionState::new(&graph).unwrap();
|
||||
let executor = ExecutorBuilder::new(fail_work_handler()).build();
|
||||
|
||||
let (outcome, _state) = executor.run(&graph, state).await.unwrap();
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_succeed_policy_skips_retry_target() {
|
||||
let graph = TestGraph::new(
|
||||
vec![
|
||||
TestNode::new("work").with_on_failure(OnFailure::Succeed),
|
||||
TestNode::new("downstream"),
|
||||
TestNode::new("retry_only"),
|
||||
TestNode::terminal("end"),
|
||||
],
|
||||
vec![
|
||||
TestEdge::new("work", "downstream"),
|
||||
TestEdge::new("downstream", "end"),
|
||||
TestEdge::new("retry_only", "end"),
|
||||
],
|
||||
"work",
|
||||
)
|
||||
.with_retry_target("work", "retry_only");
|
||||
let state = ExecutionState::new(&graph).unwrap();
|
||||
let executor = ExecutorBuilder::new(fail_work_handler()).build();
|
||||
|
||||
let (outcome, state) = executor.run(&graph, state).await.unwrap();
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
assert!(state.node_outcomes.contains_key("downstream"));
|
||||
assert!(
|
||||
!state.node_outcomes.contains_key("retry_only"),
|
||||
"a promoted outcome is not failed, so retry targets do not apply"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_graph_succeed_policy_promotes_every_failed_node() {
|
||||
let graph = TestGraph::new(
|
||||
vec![
|
||||
TestNode::new("work"),
|
||||
TestNode::new("downstream"),
|
||||
TestNode::terminal("end"),
|
||||
],
|
||||
vec![
|
||||
TestEdge::new("work", "downstream"),
|
||||
TestEdge::new("downstream", "end"),
|
||||
],
|
||||
"work",
|
||||
)
|
||||
.with_on_failure(OnFailure::Succeed);
|
||||
let state = ExecutionState::new(&graph).unwrap();
|
||||
let executor = ExecutorBuilder::new(
|
||||
Arc::new(AlwaysFailHandler::new("boom")) as Arc<dyn NodeHandler<TestGraph>>
|
||||
)
|
||||
.build();
|
||||
|
||||
let (outcome, state) = executor.run(&graph, state).await.unwrap();
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
for node_id in ["work", "downstream"] {
|
||||
let recorded = &state.node_outcomes[node_id];
|
||||
assert_eq!(recorded.status, StageOutcome::Succeeded);
|
||||
assert_eq!(
|
||||
recorded.notes.as_deref(),
|
||||
Some("graph on_failure=succeed promoted a failed outcome to succeeded")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_succeed_policy_leaves_partial_outcome_unchanged() {
|
||||
struct PartialHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl NodeHandler<TestGraph> for PartialHandler {
|
||||
async fn execute(
|
||||
&self,
|
||||
_node: &TestNode,
|
||||
_context: &Context,
|
||||
_graph: &TestGraph,
|
||||
) -> Result<Outcome> {
|
||||
let mut outcome = Outcome::success();
|
||||
outcome.status = StageOutcome::PartiallySucceeded;
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
let graph = succeed_policy_graph();
|
||||
let state = ExecutionState::new(&graph).unwrap();
|
||||
let handler = DispatchHandler::new(Arc::new(AlwaysSucceedHandler))
|
||||
.with_handler("work", Arc::new(PartialHandler));
|
||||
let executor =
|
||||
ExecutorBuilder::new(Arc::new(handler) as Arc<dyn NodeHandler<TestGraph>>).build();
|
||||
|
||||
let (outcome, state) = executor.run(&graph, state).await.unwrap();
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(
|
||||
state.node_outcomes["work"].status,
|
||||
StageOutcome::PartiallySucceeded
|
||||
);
|
||||
assert_eq!(state.node_outcomes["work"].notes, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_goal_gate_retry_target_to_terminal_fails_without_looping() {
|
||||
let terminal_visits = Arc::new(AtomicU32::new(0));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,26 @@ use fabro_types::ResolvedOnFailure;
|
|||
|
||||
use crate::context::Context;
|
||||
use crate::error::Result;
|
||||
use crate::outcome::{Outcome, OutcomeMeta};
|
||||
use crate::outcome::{NodeResult, Outcome, OutcomeMeta};
|
||||
|
||||
/// How edge selection chose an edge.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, strum::IntoStaticStr)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum EdgeSelectionReason {
|
||||
Condition,
|
||||
PreferredLabel,
|
||||
SuggestedNext,
|
||||
Unconditional,
|
||||
}
|
||||
|
||||
impl EdgeSelectionReason {
|
||||
/// Returns whether this selection is an explicit route supplied by the
|
||||
/// node result or an edge condition.
|
||||
#[must_use]
|
||||
pub const fn is_explicit(self) -> bool {
|
||||
!matches!(self, Self::Unconditional)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NodeSpec: Send + Sync + Clone {
|
||||
fn id(&self) -> &str;
|
||||
|
|
@ -20,7 +39,7 @@ pub trait EdgeSpec: Send + Sync + Clone {
|
|||
|
||||
pub struct EdgeSelection<G: Graph + ?Sized> {
|
||||
pub edge: G::Edge,
|
||||
pub reason: &'static str,
|
||||
pub reason: EdgeSelectionReason,
|
||||
}
|
||||
|
||||
pub trait Graph: Send + Sync {
|
||||
|
|
@ -37,6 +56,19 @@ pub trait Graph: Send + Sync {
|
|||
outcome: &Outcome<Self::Meta>,
|
||||
context: &Context,
|
||||
) -> Option<EdgeSelection<Self>>;
|
||||
/// Projects derived values from a pending node result into a context used
|
||||
/// to test edge conditions before the result is durably recorded.
|
||||
///
|
||||
/// Implementations can add the same derived values that their lifecycle
|
||||
/// writes after recording. The executor applies `context_updates` before
|
||||
/// this method runs.
|
||||
fn project_result_context(
|
||||
&self,
|
||||
_node: &Self::Node,
|
||||
_result: &NodeResult<Self::Meta>,
|
||||
_context: &Context,
|
||||
) {
|
||||
}
|
||||
fn check_goal_gates(
|
||||
&self,
|
||||
outcomes: &HashMap<String, Outcome<Self::Meta>>,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub mod test_fixtures;
|
|||
pub use context::Context;
|
||||
pub use error::{Error, HandlerErrorDetail, Result, VisitLimitSource};
|
||||
pub use executor::{Executor, ExecutorBuilder, ExecutorOptions};
|
||||
pub use graph::{EdgeSelection, EdgeSpec, Graph, NodeSpec};
|
||||
pub use graph::{EdgeSelection, EdgeSelectionReason, EdgeSpec, Graph, NodeSpec};
|
||||
pub use handler::NodeHandler;
|
||||
pub use lifecycle::{
|
||||
AttemptContext, AttemptResultContext, CompositeLifecycle, EdgeContext, EdgeDecision,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use fabro_types::{OnFailure, ResolvedOnFailure};
|
|||
|
||||
use crate::context::Context;
|
||||
use crate::error::{Error, HandlerErrorDetail, Result};
|
||||
use crate::graph::{EdgeSelection, EdgeSpec, Graph, NodeSpec};
|
||||
use crate::graph::{EdgeSelection, EdgeSelectionReason, EdgeSpec, Graph, NodeSpec};
|
||||
use crate::handler::NodeHandler;
|
||||
use crate::outcome::{FailureCategory, FailureDetail, Outcome, StageOutcome};
|
||||
use crate::retry::RetryPolicy;
|
||||
|
|
@ -199,7 +199,7 @@ impl Graph for TestGraph {
|
|||
{
|
||||
return Some(EdgeSelection {
|
||||
edge: e.clone(),
|
||||
reason: "preferred_label",
|
||||
reason: EdgeSelectionReason::PreferredLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -212,7 +212,7 @@ impl Graph for TestGraph {
|
|||
{
|
||||
return Some(EdgeSelection {
|
||||
edge: e.clone(),
|
||||
reason: "condition",
|
||||
reason: EdgeSelectionReason::Condition,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -221,21 +221,16 @@ impl Graph for TestGraph {
|
|||
if let Some(e) = edges.iter().find(|e| e.to == *suggested) {
|
||||
return Some(EdgeSelection {
|
||||
edge: e.clone(),
|
||||
reason: "suggested_next",
|
||||
reason: EdgeSelectionReason::SuggestedNext,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.status.is_failure() && self.resolve_on_failure(node).policy() == OnFailure::Exit
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Fourth: unconditional (no label)
|
||||
if let Some(e) = edges.iter().find(|e| e.label.is_none()) {
|
||||
return Some(EdgeSelection {
|
||||
edge: e.clone(),
|
||||
reason: "unconditional",
|
||||
reason: EdgeSelectionReason::Unconditional,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -532,7 +527,7 @@ mod tests {
|
|||
let ctx = Context::new();
|
||||
let sel = g.select_edge(&node, &outcome, &ctx).unwrap();
|
||||
assert_eq!(sel.edge.target(), "b");
|
||||
assert_eq!(sel.reason, "condition");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::Condition);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -543,7 +538,7 @@ mod tests {
|
|||
let ctx = Context::new();
|
||||
let sel = g.select_edge(&node, &outcome, &ctx).unwrap();
|
||||
assert_eq!(sel.edge.target(), "end");
|
||||
assert_eq!(sel.reason, "unconditional");
|
||||
assert_eq!(sel.reason, EdgeSelectionReason::Unconditional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ use strum::VariantNames;
|
|||
|
||||
use crate::AgentBackend;
|
||||
|
||||
/// Policy for routing a failed node when no explicit recovery edge matches.
|
||||
/// Policy for a failed node when no explicit recovery route matches.
|
||||
///
|
||||
/// Explicit routes (a jump, a matching edge condition, a matching preferred
|
||||
/// label, or a matching suggested next node) take priority under every
|
||||
/// policy. The policy decides what happens when none of them match.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
|
|
@ -24,9 +28,15 @@ use crate::AgentBackend;
|
|||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum OnFailure {
|
||||
/// The outcome stays `failed` and may take an unconditional edge.
|
||||
#[default]
|
||||
Route,
|
||||
/// The outcome stays `failed` and skips the unconditional edge, so the
|
||||
/// run ends unless a retry target applies.
|
||||
Exit,
|
||||
/// The outcome becomes `succeeded` and follows normal success routing.
|
||||
/// The original failure details stay on the outcome for observability.
|
||||
Succeed,
|
||||
}
|
||||
|
||||
impl OnFailure {
|
||||
|
|
@ -349,13 +359,18 @@ impl Node {
|
|||
self.str_attr("fallback_retry_target")
|
||||
}
|
||||
|
||||
/// Node-level failure routing policy override. `None` means the node
|
||||
/// inherits the graph-level policy. Invalid values are rejected during
|
||||
/// workflow validation, so runtime resolution treats them as absent.
|
||||
/// Node-level failure policy override. `None` means the node inherits
|
||||
/// the graph-level policy. Invalid values are rejected during workflow
|
||||
/// validation, so runtime resolution treats them as absent.
|
||||
///
|
||||
/// The deprecated `auto_status=true` attribute is a compatibility alias
|
||||
/// for `on_failure="succeed"`. An explicit `on_failure` attribute wins.
|
||||
#[must_use]
|
||||
pub fn on_failure(&self) -> Option<OnFailure> {
|
||||
self.str_attr("on_failure")
|
||||
.and_then(|value| value.parse().ok())
|
||||
match self.attrs.get("on_failure") {
|
||||
Some(value) => value.as_str().and_then(|value| value.parse().ok()),
|
||||
None => self.auto_status().then_some(OnFailure::Succeed),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -392,6 +407,8 @@ impl Node {
|
|||
self.str_attr("speed")
|
||||
}
|
||||
|
||||
/// Deprecated spelling of `on_failure="succeed"`. Validation warns when
|
||||
/// it is present; [`Node::on_failure`] resolves the alias at runtime.
|
||||
#[must_use]
|
||||
pub fn auto_status(&self) -> bool {
|
||||
self.bool_attr("auto_status").unwrap_or(false)
|
||||
|
|
@ -614,7 +631,7 @@ impl Graph {
|
|||
.and_then(AttrValue::as_str)
|
||||
}
|
||||
|
||||
/// Graph-level failure routing policy. Invalid values are rejected during
|
||||
/// Graph-level failure policy. Invalid values are rejected during
|
||||
/// workflow validation, so runtime resolution can use the compatibility
|
||||
/// default.
|
||||
#[must_use]
|
||||
|
|
@ -626,7 +643,7 @@ impl Graph {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Effective failure routing policy for a node. A node-level `on_failure`
|
||||
/// Effective failure policy for a node. A node-level `on_failure`
|
||||
/// attribute overrides the graph level; an absent (or invalid, hence
|
||||
/// validation-rejected) node attribute inherits the graph policy.
|
||||
#[must_use]
|
||||
|
|
@ -770,9 +787,11 @@ mod tests {
|
|||
fn on_failure_parses_and_displays_supported_values() {
|
||||
assert_eq!("route".parse::<OnFailure>().unwrap(), OnFailure::Route);
|
||||
assert_eq!("exit".parse::<OnFailure>().unwrap(), OnFailure::Exit);
|
||||
assert_eq!("succeed".parse::<OnFailure>().unwrap(), OnFailure::Succeed);
|
||||
assert_eq!(OnFailure::Route.to_string(), "route");
|
||||
assert_eq!(OnFailure::Exit.to_string(), "exit");
|
||||
assert_eq!(OnFailure::expected_values(), "route, exit");
|
||||
assert_eq!(OnFailure::Succeed.to_string(), "succeed");
|
||||
assert_eq!(OnFailure::expected_values(), "route, exit, succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -815,6 +834,43 @@ mod tests {
|
|||
assert_eq!(node.on_failure(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_auto_status_is_an_alias_for_on_failure_succeed() {
|
||||
let mut node = Node::new("work");
|
||||
node.attrs
|
||||
.insert("auto_status".to_string(), AttrValue::Boolean(true));
|
||||
assert!(node.auto_status());
|
||||
assert_eq!(node.on_failure(), Some(OnFailure::Succeed));
|
||||
|
||||
// An explicit on_failure attribute wins over the alias.
|
||||
node.attrs.insert(
|
||||
"on_failure".to_string(),
|
||||
AttrValue::String("exit".to_string()),
|
||||
);
|
||||
assert_eq!(node.on_failure(), Some(OnFailure::Exit));
|
||||
|
||||
// auto_status=false does not set a policy.
|
||||
let mut node = Node::new("work");
|
||||
node.attrs
|
||||
.insert("auto_status".to_string(), AttrValue::Boolean(false));
|
||||
assert_eq!(node.on_failure(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_invalid_on_failure_does_not_fall_back_to_auto_status() {
|
||||
for value in [
|
||||
AttrValue::String("invalid".to_string()),
|
||||
AttrValue::Boolean(true),
|
||||
] {
|
||||
let mut node = Node::new("work");
|
||||
node.attrs
|
||||
.insert("auto_status".to_string(), AttrValue::Boolean(true));
|
||||
node.attrs.insert("on_failure".to_string(), value);
|
||||
|
||||
assert_eq!(node.on_failure(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_on_failure_prefers_node_policy_over_graph_policy() {
|
||||
let mut graph = Graph::new("test");
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ pub use run_event::{
|
|||
pub use run_failure::RunFailure;
|
||||
pub use run_id::{RunId, fixtures};
|
||||
pub use run_intent::{
|
||||
RunIntent, RunIntentArgs, RunTarget, TargetValidationError, ValidatedRunTarget,
|
||||
GitRunTarget, RunIntent, RunIntentArgs, RunTarget, TargetValidationError, ValidatedRunTarget,
|
||||
};
|
||||
pub use run_projection::{
|
||||
ActivatedSkill, AgentControlState, CheckpointRecord, McpServerProjection, McpServerStatus,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||
use serde_json::Value;
|
||||
use strum::{Display, EnumString, IntoStaticStr};
|
||||
|
||||
use crate::{ExecOutputTail, FailureSignature, StageTiming, SystemActorKind};
|
||||
use crate::{
|
||||
ExecOutputTail, FailureSignature, OnFailure, ResolvedOnFailure, StageTiming, SystemActorKind,
|
||||
};
|
||||
|
||||
pub trait OutcomeMeta:
|
||||
Default + Clone + Send + Sync + fmt::Debug + Serialize + DeserializeOwned + 'static
|
||||
|
|
@ -329,13 +331,90 @@ impl<M: OutcomeMeta> Outcome<M> {
|
|||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a resolved failure policy to this outcome.
|
||||
///
|
||||
/// `succeed` promotes a `failed` outcome and records the policy scope.
|
||||
/// The original `failure` stays available for durable diagnostics. Other
|
||||
/// policies and statuses do not change the outcome.
|
||||
pub fn apply_on_failure(&mut self, policy: ResolvedOnFailure) -> bool {
|
||||
if policy.policy() != OnFailure::Succeed || !self.status.is_failure() {
|
||||
return false;
|
||||
}
|
||||
self.status = StageOutcome::Succeeded;
|
||||
let note = format!(
|
||||
"{} on_failure=succeed promoted a failed outcome to succeeded",
|
||||
policy.scope()
|
||||
);
|
||||
self.notes = Some(match self.notes.take() {
|
||||
Some(existing) => format!("{existing}\n{note}"),
|
||||
None => note,
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{FailureCategory, FailureDetail, StageOutcome, StageState};
|
||||
use super::{FailureCategory, FailureDetail, Outcome, StageOutcome, StageState};
|
||||
use crate::{OnFailure, ResolvedOnFailure};
|
||||
|
||||
#[test]
|
||||
fn apply_on_failure_keeps_failure_and_records_scope() {
|
||||
let mut outcome: Outcome = Outcome::fail("boom");
|
||||
assert!(outcome.apply_on_failure(ResolvedOnFailure::node(OnFailure::Succeed)));
|
||||
|
||||
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.failure
|
||||
.as_ref()
|
||||
.map(|failure| failure.message.as_str()),
|
||||
Some("boom")
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.notes.as_deref(),
|
||||
Some("node on_failure=succeed promoted a failed outcome to succeeded")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_on_failure_appends_to_existing_notes() {
|
||||
let mut outcome: Outcome = Outcome::fail("boom");
|
||||
outcome.notes = Some("handler note".to_string());
|
||||
assert!(outcome.apply_on_failure(ResolvedOnFailure::graph(OnFailure::Succeed)));
|
||||
|
||||
assert_eq!(
|
||||
outcome.notes.as_deref(),
|
||||
Some("handler note\ngraph on_failure=succeed promoted a failed outcome to succeeded")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_on_failure_ignores_non_failed_outcomes() {
|
||||
let mut partial: Outcome = Outcome::success();
|
||||
partial.status = StageOutcome::PartiallySucceeded;
|
||||
let mut skipped: Outcome = Outcome::skipped("not needed");
|
||||
|
||||
for outcome in [&mut partial, &mut skipped] {
|
||||
let before = outcome.clone();
|
||||
assert!(!outcome.apply_on_failure(ResolvedOnFailure::node(OnFailure::Succeed)));
|
||||
assert_eq!(*outcome, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_on_failure_ignores_non_succeed_policies() {
|
||||
for policy in [OnFailure::Route, OnFailure::Exit] {
|
||||
let mut outcome: Outcome = Outcome::fail("boom");
|
||||
let before = outcome.clone();
|
||||
|
||||
assert!(!outcome.apply_on_failure(ResolvedOnFailure::node(policy)));
|
||||
assert_eq!(outcome, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_outcome_failed_serde_is_lossy_for_retry_intent() {
|
||||
|
|
|
|||
|
|
@ -45,17 +45,44 @@ pub struct RunIntentArgs {
|
|||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum RunTarget {
|
||||
Git {
|
||||
repo: String,
|
||||
branch: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
sha: Option<String>,
|
||||
},
|
||||
Git(GitRunTarget),
|
||||
None {},
|
||||
Folder { path: String },
|
||||
}
|
||||
|
||||
/// A Git-backed run target.
|
||||
///
|
||||
/// `branch` is always the attached working branch. When present, `tag` names
|
||||
/// the requested release identity. An exact `sha` is authoritative over both
|
||||
/// selectors while preserving the tag in durable state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GitRunTarget {
|
||||
pub repo: String,
|
||||
pub branch: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha: Option<String>,
|
||||
}
|
||||
|
||||
/// A bare branch or tag name: not `HEAD`, not a `refs/` or `tags/` selector,
|
||||
/// not a commit SHA, and otherwise a valid GitHub ref selector.
|
||||
///
|
||||
/// The selector grammar is checked on the bare name so its leading-character
|
||||
/// rules apply to the name itself, not to a prefixed selector that would mask
|
||||
/// them.
|
||||
fn is_bare_ref_name(name: &str) -> bool {
|
||||
name != "HEAD"
|
||||
&& !name.starts_with("tags/")
|
||||
&& !name.starts_with("refs/")
|
||||
&& repository::normalize_git_commit_sha(name).is_none()
|
||||
&& repository::is_valid_github_ref_selector(name)
|
||||
}
|
||||
|
||||
impl RunTarget {
|
||||
/// The wire `kind` discriminator (`git`, `none`), for diagnostics.
|
||||
/// The wire `kind` discriminator (`git`, `none`, or `folder`), for
|
||||
/// diagnostics.
|
||||
pub fn kind_name(&self) -> &'static str {
|
||||
self.into()
|
||||
}
|
||||
|
|
@ -63,24 +90,24 @@ impl RunTarget {
|
|||
/// Validates and canonicalizes the target without any network resolution.
|
||||
///
|
||||
/// Git targets include their derived operational Git projection. Targets
|
||||
/// without a repository return no projection.
|
||||
/// without a repository return no projection. Folder paths require
|
||||
/// filesystem validation and canonicalization during provider admission.
|
||||
pub fn validate(self) -> Result<ValidatedRunTarget, TargetValidationError> {
|
||||
match self {
|
||||
Self::Git { repo, branch, sha } => {
|
||||
Self::Git(GitRunTarget {
|
||||
repo,
|
||||
branch,
|
||||
tag,
|
||||
sha,
|
||||
}) => {
|
||||
let slug = GitHubRepositorySlug::try_new(&repo)
|
||||
.ok_or(TargetValidationError::Repository)?;
|
||||
// The selector grammar is checked on the bare branch name so
|
||||
// its leading-character rules apply to the branch itself, not
|
||||
// to a `heads/`-prefixed selector that would mask them.
|
||||
if branch == "HEAD"
|
||||
|| branch.starts_with("heads/")
|
||||
|| branch.starts_with("tags/")
|
||||
|| branch.starts_with("refs/")
|
||||
|| repository::normalize_git_commit_sha(&branch).is_some()
|
||||
|| !repository::is_valid_github_ref_selector(&branch)
|
||||
{
|
||||
if !is_bare_ref_name(&branch) || branch.starts_with("heads/") {
|
||||
return Err(TargetValidationError::Branch);
|
||||
}
|
||||
if tag.as_deref().is_some_and(|tag| !is_bare_ref_name(tag)) {
|
||||
return Err(TargetValidationError::Tag);
|
||||
}
|
||||
let sha = sha
|
||||
.map(|sha| {
|
||||
repository::normalize_git_commit_sha(&sha).ok_or(TargetValidationError::Sha)
|
||||
|
|
@ -93,7 +120,12 @@ impl RunTarget {
|
|||
dirty: DirtyStatus::Clean,
|
||||
};
|
||||
Ok(ValidatedRunTarget {
|
||||
target: Self::Git { repo, branch, sha },
|
||||
target: Self::Git(GitRunTarget {
|
||||
repo,
|
||||
branch,
|
||||
tag,
|
||||
sha,
|
||||
}),
|
||||
git: Some(git),
|
||||
})
|
||||
}
|
||||
|
|
@ -101,6 +133,10 @@ impl RunTarget {
|
|||
target: Self::None {},
|
||||
git: None,
|
||||
}),
|
||||
Self::Folder { path } => Ok(ValidatedRunTarget {
|
||||
target: Self::Folder { path },
|
||||
git: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -120,6 +156,8 @@ pub enum TargetValidationError {
|
|||
Repository,
|
||||
#[error("target branch must be a non-empty branch name, not a ref or commit selector")]
|
||||
Branch,
|
||||
#[error("target tag must be a non-empty bare tag name, not a ref or commit selector")]
|
||||
Tag,
|
||||
#[error("target SHA must be exactly 40 ASCII hexadecimal characters")]
|
||||
Sha,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ use fabro_types::run_event::{RunSessionTurnFailedCode, RunSessionTurnFailedProps
|
|||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::RunGoal;
|
||||
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
|
||||
use fabro_types::{AutomationRef, EventBody, RunTarget, TurnId, WorkflowSettings, fixtures};
|
||||
use fabro_types::{
|
||||
AutomationRef, EventBody, GitRunTarget, RunTarget, TurnId, WorkflowSettings, fixtures,
|
||||
};
|
||||
|
||||
fn templated_settings() -> WorkflowSettings {
|
||||
let mut settings = WorkflowSettings::default();
|
||||
|
|
@ -26,11 +28,12 @@ fn run_created_props_round_trip_templated_settings() {
|
|||
source_directory: Some("/Users/client/project".to_string()),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
workflow_version_id: Some(test_workflow_version_id()),
|
||||
target: Some(RunTarget::Git {
|
||||
target: Some(RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
}),
|
||||
})),
|
||||
automation: Some(AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use fabro_types::{
|
||||
RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId, normalize_git_commit_sha,
|
||||
GitRunTarget, RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId, normalize_git_commit_sha,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -14,11 +14,12 @@ fn version_id() -> WorkflowVersionId {
|
|||
fn intent() -> RunIntent {
|
||||
RunIntent {
|
||||
workflow_version_id: version_id(),
|
||||
target: RunTarget::Git {
|
||||
target: RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
||||
},
|
||||
}),
|
||||
args: RunIntentArgs {
|
||||
model: Some("gpt-5.6".to_string()),
|
||||
provider: Some("openai".to_string()),
|
||||
|
|
@ -43,6 +44,7 @@ fn run_intent_round_trips_the_strict_git_shape() {
|
|||
assert_eq!(value["target"]["kind"], "git");
|
||||
assert_eq!(value["target"]["repo"], "fabro-sh/fabro");
|
||||
assert_eq!(value["target"]["branch"], "feature/run-intent");
|
||||
assert_eq!(value["target"]["tag"], "v1.2.3");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<RunIntent>(value).expect("intent should deserialize"),
|
||||
intent
|
||||
|
|
@ -71,6 +73,42 @@ fn run_intent_none_target_rejects_unknown_fields() {
|
|||
assert!(serde_json::from_value::<RunIntent>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_round_trips_the_strict_folder_shape() {
|
||||
let mut intent = intent();
|
||||
intent.target = RunTarget::Folder {
|
||||
path: "/srv/fabro/workspaces/example".to_string(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&intent).expect("intent should serialize");
|
||||
|
||||
assert_eq!(
|
||||
value["target"],
|
||||
json!({
|
||||
"kind": "folder",
|
||||
"path": "/srv/fabro/workspaces/example",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<RunIntent>(value).expect("intent should deserialize"),
|
||||
intent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_folder_target_rejects_unknown_and_missing_fields() {
|
||||
let mut value = serde_json::to_value(intent()).expect("intent should serialize");
|
||||
value["target"] = json!({
|
||||
"kind": "folder",
|
||||
"path": "/srv/fabro/workspaces/example",
|
||||
"unexpected": true,
|
||||
});
|
||||
assert!(serde_json::from_value::<RunIntent>(value.clone()).is_err());
|
||||
|
||||
value["target"] = json!({ "kind": "folder" });
|
||||
assert!(serde_json::from_value::<RunIntent>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_rejects_unknown_fields_at_every_object_boundary() {
|
||||
let value = serde_json::to_value(intent()).expect("intent should serialize");
|
||||
|
|
@ -122,11 +160,12 @@ fn git_commit_sha_normalization_is_exact_and_pure() {
|
|||
|
||||
#[test]
|
||||
fn target_validation_normalizes_sha_without_network_resolution() {
|
||||
let validated = RunTarget::Git {
|
||||
let validated = RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
tag: Some("release/v1".to_string()),
|
||||
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
||||
}
|
||||
})
|
||||
.validate()
|
||||
.unwrap();
|
||||
let git = validated
|
||||
|
|
@ -138,11 +177,15 @@ fn target_validation_normalizes_sha_without_network_resolution() {
|
|||
Some("abcdef0123456789abcdef0123456789abcdef01")
|
||||
);
|
||||
assert_eq!(git.origin_url, "https://github.com/fabro-sh/fabro");
|
||||
assert_eq!(validated.target, RunTarget::Git {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||
});
|
||||
assert_eq!(
|
||||
validated.target,
|
||||
RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "feature/run-intent".to_string(),
|
||||
tag: Some("release/v1".to_string()),
|
||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -152,21 +195,34 @@ fn run_intent_none_target_validates_without_a_git_projection() {
|
|||
assert_eq!(validated.git, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_folder_target_is_preserved_for_provider_admission() {
|
||||
let target = RunTarget::Folder {
|
||||
path: "/srv/fabro/workspaces/example".to_string(),
|
||||
};
|
||||
|
||||
let validated = target.clone().validate().unwrap();
|
||||
|
||||
assert_eq!(validated.target, target);
|
||||
assert_eq!(validated.git, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_validation_rejects_invalid_grammar() {
|
||||
use fabro_types::TargetValidationError;
|
||||
|
||||
let validate = |repo: &str, branch: &str, sha: Option<&str>| {
|
||||
RunTarget::Git {
|
||||
let validate = |repo: &str, branch: &str, tag: Option<&str>, sha: Option<&str>| {
|
||||
RunTarget::Git(GitRunTarget {
|
||||
repo: repo.to_string(),
|
||||
branch: branch.to_string(),
|
||||
tag: tag.map(str::to_string),
|
||||
sha: sha.map(str::to_string),
|
||||
}
|
||||
})
|
||||
.validate()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
validate("not-a-slug", "main", None).unwrap_err(),
|
||||
validate("not-a-slug", "main", None, None).unwrap_err(),
|
||||
TargetValidationError::Repository
|
||||
);
|
||||
for branch in [
|
||||
|
|
@ -181,13 +237,66 @@ fn target_validation_rejects_invalid_grammar() {
|
|||
"bad..branch",
|
||||
] {
|
||||
assert_eq!(
|
||||
validate("fabro-sh/fabro", branch, None).unwrap_err(),
|
||||
validate("fabro-sh/fabro", branch, None, None).unwrap_err(),
|
||||
TargetValidationError::Branch,
|
||||
"{branch:?}"
|
||||
);
|
||||
}
|
||||
for tag in [
|
||||
"",
|
||||
"HEAD",
|
||||
"-v1",
|
||||
".v1",
|
||||
"tags/v1",
|
||||
"refs/tags/v1",
|
||||
"abcdef0123456789abcdef0123456789abcdef01",
|
||||
"bad..tag",
|
||||
] {
|
||||
assert_eq!(
|
||||
validate("fabro-sh/fabro", "main", Some(tag), None).unwrap_err(),
|
||||
TargetValidationError::Tag,
|
||||
"{tag:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
validate("fabro-sh/fabro", "main", Some("short")).unwrap_err(),
|
||||
validate("fabro-sh/fabro", "main", None, Some("short")).unwrap_err(),
|
||||
TargetValidationError::Sha
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_target_round_trips_all_branch_tag_sha_states() {
|
||||
for target in [
|
||||
GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
},
|
||||
GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||
},
|
||||
GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "release".to_string(),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
sha: None,
|
||||
},
|
||||
GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "release".to_string(),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||
},
|
||||
] {
|
||||
let run_target = RunTarget::Git(target);
|
||||
let value = serde_json::to_value(&run_target).expect("target should serialize");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<RunTarget>(value).expect("target should deserialize"),
|
||||
run_target
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, RunSpec};
|
|||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::RunGoal;
|
||||
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
|
||||
use fabro_types::{AutomationRef, RunTarget, WorkflowSettings, fixtures};
|
||||
use fabro_types::{AutomationRef, GitRunTarget, RunTarget, WorkflowSettings, fixtures};
|
||||
|
||||
fn templated_settings() -> WorkflowSettings {
|
||||
let mut settings = WorkflowSettings::default();
|
||||
|
|
@ -22,11 +22,12 @@ fn run_spec_round_trips_templated_settings() {
|
|||
graph_source: None,
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
workflow_version_id: Some(test_workflow_version_id()),
|
||||
target: Some(RunTarget::Git {
|
||||
target: Some(RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: Some("abc123".to_string()),
|
||||
}),
|
||||
})),
|
||||
automation: Some(AutomationRef {
|
||||
id: "nightly".to_string(),
|
||||
name: Some("Nightly".to_string()),
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ models/failure-detail.ts
|
|||
models/failure-reason.ts
|
||||
models/file-checkpoint.ts
|
||||
models/file-diff.ts
|
||||
models/folder-run-target.ts
|
||||
models/fork-request.ts
|
||||
models/fork-response.ts
|
||||
models/fork-source-ref.ts
|
||||
|
|
|
|||
32
lib/packages/fabro-api-client/src/models/folder-run-target.ts
generated
Normal file
32
lib/packages/fabro-api-client/src/models/folder-run-target.ts
generated
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Existing directory on the Fabro server, executed in place by a Local environment. The submitted path must be absolute and name an existing directory; Fabro resolves symlinks and persists its canonical UTF-8 path. This target is intended for trusted single-tenant deployments. Docker and Daytona environments always reject it. This target does not add Local Git cloning or Local scratch workspaces. Folder runs execute in place without Fabro Git checkpoints, so fork and rewind are unavailable.
|
||||
*/
|
||||
export interface FolderRunTarget {
|
||||
'kind': FolderRunTargetKindEnum;
|
||||
/**
|
||||
* Absolute path on the Fabro server, not on the API caller\'s machine.
|
||||
*/
|
||||
'path': string;
|
||||
}
|
||||
|
||||
export const FolderRunTargetKindEnum = {
|
||||
FOLDER: 'folder'
|
||||
} as const;
|
||||
|
||||
export type FolderRunTargetKindEnum = typeof FolderRunTargetKindEnum[keyof typeof FolderRunTargetKindEnum];
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
|
||||
/**
|
||||
* Public github.com repository target.
|
||||
* Public github.com repository target. The branch names the attached working branch. An optional tag selects a release at worker start, and an optional exact SHA is authoritative when both are present.
|
||||
*/
|
||||
export interface GitRunTarget {
|
||||
'kind': GitRunTargetKindEnum;
|
||||
|
|
@ -24,11 +24,15 @@ export interface GitRunTarget {
|
|||
*/
|
||||
'repo': string;
|
||||
/**
|
||||
* Required branch name, preserved exactly.
|
||||
* Required attached working branch name, preserved exactly.
|
||||
*/
|
||||
'branch': string;
|
||||
/**
|
||||
* Optional exact commit. The server lowercase-normalizes its syntax but does not resolve it or prove branch ancestry.
|
||||
* Optional bare tag name. Prefixes such as `refs/tags/` and `tags/` are rejected. Without `sha`, the worker resolves this tag when the sandbox starts and fails if it is unavailable.
|
||||
*/
|
||||
'tag'?: string;
|
||||
/**
|
||||
* Optional exact commit. The server lowercase-normalizes its syntax but does not resolve it, prove branch ancestry, or prove that it matches an accompanying tag. When present, this exact commit wins.
|
||||
*/
|
||||
'sha'?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ export * from './failure-detail';
|
|||
export * from './failure-reason';
|
||||
export * from './file-checkpoint';
|
||||
export * from './file-diff';
|
||||
export * from './folder-run-target';
|
||||
export * from './fork-request';
|
||||
export * from './fork-response';
|
||||
export * from './fork-source-ref';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { FolderRunTarget } from './folder-run-target';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { GitRunTarget } from './git-run-target';
|
||||
|
|
@ -24,4 +27,4 @@ import type { NoneRunTarget } from './none-run-target';
|
|||
* @type RunTarget
|
||||
* Workspace content and location requested for a run.
|
||||
*/
|
||||
export type RunTarget = { kind: 'git' } & GitRunTarget | { kind: 'none' } & NoneRunTarget;
|
||||
export type RunTarget = { kind: 'folder' } & FolderRunTarget | { kind: 'git' } & GitRunTarget | { kind: 'none' } & NoneRunTarget;
|
||||
|
|
|
|||
7
test/auto_status_deprecated.fabro
Normal file
7
test/auto_status_deprecated.fabro
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
digraph DeprecatedAutoStatus {
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
scan [prompt="Scan the tree" auto_status=true]
|
||||
|
||||
start -> scan -> exit
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue