mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Compare commits
No commits in common. "main" and "v0.336.0-nightly.0" have entirely different histories.
main
...
v0.336.0-n
173 changed files with 1814 additions and 10896 deletions
108
Cargo.lock
generated
108
Cargo.lock
generated
|
|
@ -2257,7 +2257,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-acp"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-tokio",
|
||||
|
|
@ -2276,7 +2276,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2323,7 +2323,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-automation",
|
||||
|
|
@ -2346,7 +2346,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2371,7 +2371,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-automation"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2391,11 +2391,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -2411,7 +2411,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2513,7 +2513,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2542,7 +2542,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2572,14 +2572,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum 0.28.0",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
|
@ -2588,7 +2587,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-db"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2601,7 +2600,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2620,7 +2619,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2634,7 +2633,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-environment"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2656,7 +2655,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2681,7 +2680,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2696,7 +2695,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -2719,7 +2718,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2729,7 +2728,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2748,7 +2747,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2763,7 +2762,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2805,7 +2804,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2816,7 +2815,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2837,7 +2836,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2857,7 +2856,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2885,7 +2884,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-store"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-db",
|
||||
|
|
@ -2903,9 +2902,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
"insta",
|
||||
|
|
@ -2920,7 +2918,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2942,7 +2940,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2950,7 +2948,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2959,7 +2957,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2975,7 +2973,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3019,7 +3017,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3071,7 +3069,6 @@ dependencies = [
|
|||
"fabro-workflow",
|
||||
"fabro-workflow-version",
|
||||
"futures-util",
|
||||
"git2",
|
||||
"globset",
|
||||
"hex",
|
||||
"hkdf 0.12.4",
|
||||
|
|
@ -3115,7 +3112,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -3137,18 +3134,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -3164,7 +3161,6 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"slatedb",
|
||||
"sqlx",
|
||||
"strum 0.28.0",
|
||||
|
|
@ -3179,7 +3175,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -3205,7 +3201,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -3219,7 +3215,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3244,7 +3240,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3265,7 +3261,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3279,7 +3275,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -3302,7 +3298,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -3325,7 +3321,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-acp",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -3338,7 +3334,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-variable"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3355,7 +3351,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3374,7 +3370,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3444,7 +3440,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow-version"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-config",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -8601,7 +8597,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -8620,7 +8616,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.338.0-nightly.0"
|
||||
version = "0.336.0-nightly.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
|
|
@ -4,16 +4,10 @@ import type {
|
|||
Automation,
|
||||
AutomationTrigger,
|
||||
Run,
|
||||
RunProjection,
|
||||
WorkflowSettings,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import {
|
||||
findApiTrigger,
|
||||
findScheduleTrigger,
|
||||
gitTarget,
|
||||
type GitRunTarget,
|
||||
} from "../lib/automation";
|
||||
import { findApiTrigger, findScheduleTrigger } from "../lib/automation";
|
||||
import { Panel, Row } from "./settings-panel";
|
||||
import { INPUT_CLASS } from "./ui";
|
||||
import { sandboxRuntime } from "../lib/run-sandbox-lifecycle";
|
||||
|
|
@ -23,9 +17,7 @@ export interface AutomationFormValues {
|
|||
name: string;
|
||||
description: string;
|
||||
repository: string;
|
||||
branch: string;
|
||||
tag: string;
|
||||
sha: string;
|
||||
ref: string;
|
||||
workflow: string;
|
||||
manualEnabled: boolean;
|
||||
scheduleEnabled: boolean;
|
||||
|
|
@ -37,9 +29,7 @@ export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
|
|||
name: "",
|
||||
description: "",
|
||||
repository: "",
|
||||
branch: "main",
|
||||
tag: "",
|
||||
sha: "",
|
||||
ref: "main",
|
||||
workflow: "",
|
||||
manualEnabled: true,
|
||||
scheduleEnabled: false,
|
||||
|
|
@ -56,16 +46,13 @@ const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
|
|||
export function automationToFormValues(automation: Automation): AutomationFormValues {
|
||||
const apiTrigger = findApiTrigger(automation);
|
||||
const scheduleTrigger = findScheduleTrigger(automation);
|
||||
const target = gitTarget(automation.target);
|
||||
return {
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description ?? "",
|
||||
repository: target?.repo ?? "",
|
||||
branch: target?.branch ?? EMPTY_AUTOMATION_FORM.branch,
|
||||
tag: target?.tag ?? "",
|
||||
sha: target?.sha ?? "",
|
||||
workflow: automation.workflow,
|
||||
repository: automation.target.repository,
|
||||
ref: automation.target.ref,
|
||||
workflow: automation.target.workflow,
|
||||
manualEnabled: apiTrigger?.enabled ?? false,
|
||||
scheduleEnabled: scheduleTrigger?.enabled ?? false,
|
||||
cron: scheduleTrigger?.expression ?? "0 9 * * 1-5",
|
||||
|
|
@ -74,7 +61,6 @@ export function automationToFormValues(automation: Automation): AutomationFormVa
|
|||
|
||||
export function automationFormValuesFromRun(
|
||||
run: Run,
|
||||
runState?: RunProjection | null,
|
||||
settings?: WorkflowSettings | null,
|
||||
): AutomationFormValues {
|
||||
const name = firstPresentString(
|
||||
|
|
@ -89,9 +75,7 @@ export function automationFormValuesFromRun(
|
|||
run.workflow.graph_name,
|
||||
name,
|
||||
);
|
||||
const canonicalTarget = gitTarget(runState?.spec.target);
|
||||
const repository = canonicalTarget?.repo
|
||||
?? githubRepositoryFromSettings(settings)
|
||||
const repository = githubRepositoryFromSettings(settings)
|
||||
?? githubRepositoryName(run.repository?.name)
|
||||
?? githubRepositoryFromOriginUrl(run.repository?.origin_url)
|
||||
?? "";
|
||||
|
|
@ -101,11 +85,7 @@ export function automationFormValuesFromRun(
|
|||
id: kebabify(name),
|
||||
name,
|
||||
repository,
|
||||
branch: canonicalTarget?.branch
|
||||
?? cloneBranch
|
||||
?? EMPTY_AUTOMATION_FORM.branch,
|
||||
tag: canonicalTarget?.tag ?? "",
|
||||
sha: canonicalTarget?.sha ?? "",
|
||||
ref: cloneBranch ?? EMPTY_AUTOMATION_FORM.ref,
|
||||
workflow: run.workflow.slug?.trim() || kebabify(workflowName),
|
||||
};
|
||||
}
|
||||
|
|
@ -131,31 +111,11 @@ export function isFormValid(values: AutomationFormValues): boolean {
|
|||
values.id.trim() !== "" &&
|
||||
values.name.trim() !== "" &&
|
||||
values.repository.trim() !== "" &&
|
||||
values.branch.trim() !== "" &&
|
||||
isOptionalShaValid(values.sha) &&
|
||||
values.ref.trim() !== "" &&
|
||||
values.workflow.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
const GIT_SHA_RE = /^[0-9a-fA-F]{40}$/;
|
||||
|
||||
/** An empty SHA means "no pin"; anything else must be a full 40-hex commit id. */
|
||||
function isOptionalShaValid(sha: string): boolean {
|
||||
const trimmed = sha.trim();
|
||||
return trimmed === "" || GIT_SHA_RE.test(trimmed);
|
||||
}
|
||||
|
||||
/** Canonical Git target sent in create/replace requests. */
|
||||
export function targetFromFormValues(values: AutomationFormValues): GitRunTarget {
|
||||
return {
|
||||
kind: "git",
|
||||
repo: values.repository.trim(),
|
||||
branch: values.branch.trim(),
|
||||
tag: values.tag.trim() || undefined,
|
||||
sha: values.sha.trim().toLowerCase() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function kebabify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
|
|
@ -234,7 +194,6 @@ export function AutomationFormFields({
|
|||
lockIdAndTarget = false,
|
||||
}: AutomationFormFieldsProps) {
|
||||
const slugTouchedRef = useRef(values.id.length > 0);
|
||||
const shaValid = isOptionalShaValid(values.sha);
|
||||
|
||||
function patch(partial: Partial<AutomationFormValues>) {
|
||||
onChange({ ...values, ...partial });
|
||||
|
|
@ -318,59 +277,19 @@ export function AutomationFormFields({
|
|||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Working branch</Label>}
|
||||
help="Attached branch retained with the run, including when a tag or exact commit is selected."
|
||||
>
|
||||
<Row title={<Label required>Branch</Label>} help="Default branch to run against.">
|
||||
<input
|
||||
type="text"
|
||||
name="branch"
|
||||
aria-label="Working branch"
|
||||
value={values.branch}
|
||||
onChange={(e) => patch({ branch: e.target.value })}
|
||||
aria-label="Default branch"
|
||||
value={values.ref}
|
||||
onChange={(e) => patch({ ref: e.target.value })}
|
||||
placeholder="main"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label optional>Tag</Label>}
|
||||
help="Bare tag name resolved when the automation fires. Used only when exact SHA is empty."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="tag"
|
||||
aria-label="Tag"
|
||||
value={values.tag}
|
||||
onChange={(e) => patch({ tag: e.target.value })}
|
||||
placeholder="v1.2.3"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label optional>Exact SHA</Label>}
|
||||
help={
|
||||
shaValid
|
||||
? "A 40-character commit SHA pins exact content and takes precedence over branch and tag."
|
||||
: <span className="text-coral">Enter exactly 40 hexadecimal characters.</span>
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="sha"
|
||||
aria-label="Exact commit SHA"
|
||||
aria-invalid={!shaValid}
|
||||
value={values.sha}
|
||||
onChange={(e) => patch({ sha: e.target.value })}
|
||||
placeholder="0123456789abcdef0123456789abcdef01234567"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label required>Workflow slug</Label>}
|
||||
help="Dash-separated identifier matching the workflow directory name (e.g. patch-cves)."
|
||||
|
|
|
|||
|
|
@ -1,13 +1,4 @@
|
|||
import type { Automation, AutomationTrigger, RunTarget } from "@qltysh/fabro-api-client";
|
||||
|
||||
export type GitRunTarget = Extract<RunTarget, { kind: "git" }>;
|
||||
|
||||
/** Label shown in place of a repository when an automation's target is not Git-backed. */
|
||||
export const UNSUPPORTED_TARGET_LABEL = "Unsupported target";
|
||||
|
||||
export function gitTarget(target: RunTarget | null | undefined): GitRunTarget | null {
|
||||
return target?.kind === "git" ? target : null;
|
||||
}
|
||||
import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client";
|
||||
|
||||
type TriggerOfType<K extends AutomationTrigger["type"]> = Extract<
|
||||
AutomationTrigger,
|
||||
|
|
|
|||
|
|
@ -18,12 +18,7 @@ import type {
|
|||
|
||||
import { toRunWithStatus } from "../data/runs";
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import {
|
||||
UNSUPPORTED_TARGET_LABEL,
|
||||
findApiTrigger,
|
||||
findScheduleTrigger,
|
||||
gitTarget,
|
||||
} from "../lib/automation";
|
||||
import { findApiTrigger, findScheduleTrigger } from "../lib/automation";
|
||||
import { useAutomation, useAutomationRuns } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useDataUpdatedAt } from "../hooks/use-data-updated-at";
|
||||
|
|
@ -98,7 +93,6 @@ function AutomationHeader({ automation }: { automation: Automation }) {
|
|||
|
||||
const scheduleTrigger = findScheduleTrigger(automation);
|
||||
const apiTrigger = findApiTrigger(automation);
|
||||
const target = gitTarget(automation.target);
|
||||
const canRun = apiTrigger?.enabled === true;
|
||||
|
||||
async function onRun() {
|
||||
|
|
@ -145,16 +139,10 @@ function AutomationHeader({ automation }: { automation: Automation }) {
|
|||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<Chip icon={FolderIcon}>
|
||||
{target?.repo ?? UNSUPPORTED_TARGET_LABEL}
|
||||
{target ? (
|
||||
<span className="text-fg-muted/70">
|
||||
{" · "}{target.branch}
|
||||
{target.tag ? ` · ${target.tag}` : ""}
|
||||
{target.sha ? ` · ${target.sha.slice(0, 8)}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{automation.target.repository}
|
||||
<span className="text-fg-muted/70"> · {automation.target.ref}</span>
|
||||
</Chip>
|
||||
<Chip icon={RectangleStackIcon}>{automation.workflow}</Chip>
|
||||
<Chip icon={RectangleStackIcon}>{automation.target.workflow}</Chip>
|
||||
{scheduleTrigger ? (
|
||||
<Chip icon={ClockIcon}>{scheduleTrigger.expression}</Chip>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
AutomationFormFields,
|
||||
automationToFormValues,
|
||||
isFormValid,
|
||||
targetFromFormValues,
|
||||
triggersFromFormValues,
|
||||
type AutomationFormValues,
|
||||
} from "../components/automation-form";
|
||||
|
|
@ -86,8 +85,11 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
|
|||
automationsApi.replaceAutomation(automation.id, automation.revision, {
|
||||
name: trimmedName,
|
||||
description: values.description.trim() || null,
|
||||
target: targetFromFormValues(values),
|
||||
workflow: values.workflow.trim(),
|
||||
target: {
|
||||
repository: values.repository.trim(),
|
||||
ref: values.ref.trim(),
|
||||
workflow: values.workflow.trim(),
|
||||
},
|
||||
triggers: triggersFromFormValues(values),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import { setupReactTestEnv } from "../lib/test-utils";
|
|||
let currentRun: any = null;
|
||||
let currentRunError: unknown = null;
|
||||
let currentRunLoading = false;
|
||||
let currentRunState: any = null;
|
||||
let currentRunStateLoading = false;
|
||||
let currentRunSettings: any = null;
|
||||
const queryCalls: Array<{ hook: string; id: string | undefined }> = [];
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
|
|
@ -60,14 +58,6 @@ mock.module("../lib/queries", () => ({
|
|||
isLoading: false,
|
||||
};
|
||||
},
|
||||
useRunState: (id: string | undefined) => {
|
||||
queryCalls.push({ hook: "useRunState", id });
|
||||
return {
|
||||
data: currentRunState,
|
||||
error: null,
|
||||
isLoading: currentRunStateLoading,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("../lib/api-client", () => ({
|
||||
|
|
@ -263,8 +253,6 @@ beforeEach(() => {
|
|||
currentRun = null;
|
||||
currentRunError = null;
|
||||
currentRunLoading = false;
|
||||
currentRunState = null;
|
||||
currentRunStateLoading = false;
|
||||
currentRunSettings = null;
|
||||
queryCalls.length = 0;
|
||||
createAutomationMock.mockClear();
|
||||
|
|
@ -286,9 +274,7 @@ describe("AutomationsNew", () => {
|
|||
expect(fieldValue(renderer, "Automation name")).toBe("");
|
||||
expect(fieldValue(renderer, "Automation slug")).toBe("");
|
||||
expect(fieldValue(renderer, "Repository")).toBe("");
|
||||
expect(fieldValue(renderer, "Working branch")).toBe("main");
|
||||
expect(fieldValue(renderer, "Tag")).toBe("");
|
||||
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
|
||||
expect(fieldValue(renderer, "Default branch")).toBe("main");
|
||||
expect(fieldValue(renderer, "Workflow slug")).toBe("");
|
||||
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
|
||||
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
|
||||
|
|
@ -313,9 +299,7 @@ describe("AutomationsNew", () => {
|
|||
expect(fieldValue(renderer, "Automation name")).toBe("Fix failing tests");
|
||||
expect(fieldValue(renderer, "Automation slug")).toBe("fix-failing-tests");
|
||||
expect(fieldValue(renderer, "Repository")).toBe("qltysh/fabro");
|
||||
expect(fieldValue(renderer, "Working branch")).toBe("feature/from-run");
|
||||
expect(fieldValue(renderer, "Tag")).toBe("");
|
||||
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
|
||||
expect(fieldValue(renderer, "Default branch")).toBe("feature/from-run");
|
||||
expect(fieldValue(renderer, "Workflow slug")).toBe("fix-ci");
|
||||
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
|
||||
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
|
||||
|
|
@ -323,35 +307,9 @@ describe("AutomationsNew", () => {
|
|||
renderer.root.findAllByProps({ "aria-label": "Cron expression" }),
|
||||
).toHaveLength(0);
|
||||
expect(queryCalls).toContainEqual({ hook: "useRun", id: "run_1" });
|
||||
expect(queryCalls).toContainEqual({ hook: "useRunState", id: "run_1" });
|
||||
expect(queryCalls).toContainEqual({ hook: "useRunSettings", id: "run_1" });
|
||||
});
|
||||
|
||||
test("canonical run target wins over legacy run, settings, and sandbox projections", async () => {
|
||||
currentRun = makeRun();
|
||||
currentRunSettings = makeRunSettings();
|
||||
currentRunState = {
|
||||
spec: {
|
||||
target: {
|
||||
kind: "git",
|
||||
repo: "canonical/repo",
|
||||
branch: "release",
|
||||
tag: "v2.0.0",
|
||||
sha: "0123456789abcdef0123456789abcdef01234567",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
|
||||
|
||||
expect(fieldValue(renderer, "Repository")).toBe("canonical/repo");
|
||||
expect(fieldValue(renderer, "Working branch")).toBe("release");
|
||||
expect(fieldValue(renderer, "Tag")).toBe("v2.0.0");
|
||||
expect(fieldValue(renderer, "Exact commit SHA")).toBe(
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
);
|
||||
});
|
||||
|
||||
test("automationFormValuesFromRun kebab-cases the workflow name fallback", () => {
|
||||
const run = makeRun({
|
||||
workflow: {
|
||||
|
|
@ -376,7 +334,7 @@ describe("AutomationsNew", () => {
|
|||
expect(textFromNode(renderer.toJSON())).toContain("fill it out manually");
|
||||
expect(fieldValue(renderer, "Automation name")).toBe("");
|
||||
expect(fieldValue(renderer, "Repository")).toBe("");
|
||||
expect(fieldValue(renderer, "Working branch")).toBe("main");
|
||||
expect(fieldValue(renderer, "Default branch")).toBe("main");
|
||||
expect(fieldValue(renderer, "Workflow slug")).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
|||
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useRun, useRunSettings, useRunState } from "../lib/queries";
|
||||
import { useRun, useRunSettings } from "../lib/queries";
|
||||
import {
|
||||
AutomationFormFields,
|
||||
EMPTY_AUTOMATION_FORM,
|
||||
automationFormValuesFromRun,
|
||||
isFormValid,
|
||||
targetFromFormValues,
|
||||
triggersFromFormValues,
|
||||
type AutomationFormValues,
|
||||
} from "../components/automation-form";
|
||||
|
|
@ -32,7 +31,6 @@ export default function AutomationsNew() {
|
|||
const [searchParams] = useSearchParams();
|
||||
const fromRunId = searchParams.get("from_run")?.trim() || undefined;
|
||||
const runQuery = useRun(fromRunId);
|
||||
const runStateQuery = useRunState(fromRunId);
|
||||
const settingsQuery = useRunSettings(fromRunId);
|
||||
|
||||
if (!fromRunId) {
|
||||
|
|
@ -47,9 +45,8 @@ export default function AutomationsNew() {
|
|||
// Wait for both queries to settle before mounting the form, so the user's
|
||||
// edits aren't blown away when settings arrive after the run.
|
||||
const runPending = runQuery.isLoading && !runQuery.data;
|
||||
const runStatePending = runStateQuery.isLoading && !runStateQuery.data;
|
||||
const settingsPending = settingsQuery.isLoading && !settingsQuery.data;
|
||||
if (runPending || runStatePending || settingsPending) {
|
||||
if (runPending || settingsPending) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader />
|
||||
|
|
@ -72,7 +69,6 @@ export default function AutomationsNew() {
|
|||
|
||||
const initialValues = automationFormValuesFromRun(
|
||||
runQuery.data,
|
||||
runStateQuery.data ?? null,
|
||||
settingsQuery.data ?? null,
|
||||
);
|
||||
|
||||
|
|
@ -112,8 +108,11 @@ function AutomationCreateForm({
|
|||
id: values.id.trim(),
|
||||
name: trimmedName,
|
||||
description: values.description.trim() || null,
|
||||
target: targetFromFormValues(values),
|
||||
workflow: values.workflow.trim(),
|
||||
target: {
|
||||
repository: values.repository.trim(),
|
||||
ref: values.ref.trim(),
|
||||
workflow: values.workflow.trim(),
|
||||
},
|
||||
triggers: triggersFromFormValues(values),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,12 +18,7 @@ import { FilterButton } from "../components/runs-list/filter-button";
|
|||
import type { Automation, AutomationListResponse } from "@qltysh/fabro-api-client";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import {
|
||||
UNSUPPORTED_TARGET_LABEL,
|
||||
findScheduleTrigger,
|
||||
gitTarget,
|
||||
hasEnabledApiTrigger,
|
||||
} from "../lib/automation";
|
||||
import { findScheduleTrigger, hasEnabledApiTrigger } from "../lib/automation";
|
||||
import { useAutomations } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { ConfirmDialog, PRIMARY_BUTTON_CLASS } from "../components/ui";
|
||||
|
|
@ -86,20 +81,17 @@ const MENU_ITEM_DANGER_CLASS =
|
|||
|
||||
function mapAutomations(result: AutomationListResponse | undefined): AutomationRow[] {
|
||||
const automations = result?.data ?? [];
|
||||
return automations.map((a) => {
|
||||
const target = gitTarget(a.target);
|
||||
return {
|
||||
id: a.id,
|
||||
revision: a.revision,
|
||||
name: a.name,
|
||||
workflow: a.workflow,
|
||||
repository: target?.repo ?? UNSUPPORTED_TARGET_LABEL,
|
||||
schedule: findScheduleTrigger(a)?.expression,
|
||||
apiEnabled: hasEnabledApiTrigger(a),
|
||||
icon: slugIconMap[a.workflow] ?? CodeBracketIcon,
|
||||
color: slugColorMap[a.workflow] ?? "var(--color-teal-500)",
|
||||
};
|
||||
});
|
||||
return automations.map((a) => ({
|
||||
id: a.id,
|
||||
revision: a.revision,
|
||||
name: a.name,
|
||||
workflow: a.target.workflow,
|
||||
repository: a.target.repository,
|
||||
schedule: findScheduleTrigger(a)?.expression,
|
||||
apiEnabled: hasEnabledApiTrigger(a),
|
||||
icon: slugIconMap[a.target.workflow] ?? CodeBracketIcon,
|
||||
color: slugColorMap[a.target.workflow] ?? "var(--color-teal-500)",
|
||||
}));
|
||||
}
|
||||
|
||||
function PlayIcon({ className }: { className?: string }) {
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ honors those hand-edited values even though the browser wizard does not manage t
|
|||
|
||||
Shared relational state, including vault entries, server-managed definitions, and CLI auth sessions, lives at `<storage_root>/db/fabro.sqlite3`. Run events continue to use the `[server.slatedb]` object store.
|
||||
|
||||
CLI auth sessions are stored as an `auth_sessions` row per signed-in CLI, with the rotating refresh tokens for that session in `refresh_tokens`. Pending browser-to-CLI handoffs live briefly in `oauth_authorization_codes`; the table contains a SHA-256 hash of each one-time code, never the raw bearer value. Revoking a session from **Settings → Sessions**, or with `DELETE /api/v1/auth/sessions/{id}`, deletes the session row and its tokens together.
|
||||
CLI auth sessions are stored as an `auth_sessions` row per signed-in CLI, with the rotating refresh tokens for that session in `refresh_tokens`. Revoking a session from **Settings → Sessions**, or with `DELETE /api/v1/auth/sessions/{id}`, deletes the session row and its tokens together.
|
||||
|
||||
Before applying pending SQLite migrations, Fabro creates `<storage_root>/db/fabro.sqlite3.pre-migration.bak` with SQLite's `VACUUM INTO`. Each migration run replaces the previous snapshot, so only the most recent pre-migration backup is retained.
|
||||
|
||||
|
|
|
|||
|
|
@ -28,15 +28,6 @@ fabro mcp start
|
|||
|
||||
Pass `--server` when the MCP client should connect to a specific Fabro server, or `--storage-dir` when it should use a non-default CLI storage directory.
|
||||
|
||||
Both commands register the entry under the `mcpServers` key `fabro` by default. Pass `--name` to choose a different key. Each named entry launches its own single-target `fabro mcp start` process, so you can register more than one Fabro server in the same MCP client:
|
||||
|
||||
```bash
|
||||
fabro mcp init claude --name fabro-production --server https://fabro.example.com
|
||||
fabro mcp init claude --name fabro-testing --server https://fabro-testing.example.com
|
||||
```
|
||||
|
||||
`fabro mcp init` keeps entries with other names and replaces only the entry that matches `--name`.
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `fabro_run_create` | Create one or more workflow runs, optionally under a parent run, starting them by default. |
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ Captured stage artifacts such as screenshots, videos, reports, and traces still
|
|||
|
||||
For remote sandboxes (Docker, Daytona), execution-time file access happens inside the sandbox filesystem.
|
||||
|
||||
- Blob refs are materialized into the sandbox runtime directory, `{runtime_directory}/blobs/{blob_hash}.json`. This directory lives outside the repository checkout, so materialized blobs never show up in `git status` or in checkpoint commits.
|
||||
- Blob refs are materialized into `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
- Explicit non-blob `file://` refs keep the existing copy-on-demand behavior and are copied into `{working_directory}/.fabro/artifacts/{filename}` when needed
|
||||
|
||||
In both cases, downstream handlers and agents continue to consume ordinary `file://` pointers during execution.
|
||||
|
|
|
|||
|
|
@ -5692,7 +5692,6 @@ paths:
|
|||
description: The canonical model ID or an alias.
|
||||
- $ref: "#/components/parameters/ModelTestProviderParam"
|
||||
- $ref: "#/components/parameters/ModelTestModeParam"
|
||||
- $ref: "#/components/parameters/ModelTestReasoningEffortParam"
|
||||
responses:
|
||||
"200":
|
||||
description: Test result
|
||||
|
|
@ -5701,7 +5700,7 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ModelTestResult"
|
||||
"400":
|
||||
description: Invalid test mode or reasoning effort
|
||||
description: Invalid test mode
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
|
|
@ -6235,15 +6234,6 @@ components:
|
|||
$ref: "#/components/schemas/ProviderId"
|
||||
example: openrouter
|
||||
|
||||
ModelTestReasoningEffortParam:
|
||||
name: reasoning_effort
|
||||
in: query
|
||||
required: false
|
||||
description: Optional native reasoning-effort level for the model test.
|
||||
schema:
|
||||
$ref: "#/components/schemas/ReasoningEffort"
|
||||
example: high
|
||||
|
||||
headers:
|
||||
XRequestId:
|
||||
description: >
|
||||
|
|
@ -6738,7 +6728,6 @@ components:
|
|||
- name
|
||||
- description
|
||||
- target
|
||||
- workflow
|
||||
- triggers
|
||||
properties:
|
||||
id:
|
||||
|
|
@ -6757,16 +6746,34 @@ components:
|
|||
type: ["string", "null"]
|
||||
example: Keeps dependencies fresh.
|
||||
target:
|
||||
$ref: "#/components/schemas/RunTarget"
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the selected repository checkout.
|
||||
example: dependency-update
|
||||
$ref: "#/components/schemas/AutomationTarget"
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AutomationTrigger"
|
||||
|
||||
AutomationTarget:
|
||||
description: Repository and workflow selected by an automation.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- repository
|
||||
- ref
|
||||
- workflow
|
||||
properties:
|
||||
repository:
|
||||
type: string
|
||||
description: GitHub repository slug in `owner/repo` form.
|
||||
example: fabro-sh/fabro
|
||||
ref:
|
||||
type: string
|
||||
description: Branch, tag, or SHA selector resolved when materializing a run.
|
||||
example: main
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the target repository.
|
||||
example: dependency-update
|
||||
|
||||
AutomationTrigger:
|
||||
description: |
|
||||
Automation trigger configuration. Unknown `type` discriminator values
|
||||
|
|
@ -6833,7 +6840,6 @@ components:
|
|||
- id
|
||||
- name
|
||||
- target
|
||||
- workflow
|
||||
- triggers
|
||||
properties:
|
||||
id:
|
||||
|
|
@ -6847,11 +6853,7 @@ components:
|
|||
type: ["string", "null"]
|
||||
example: Keeps dependencies fresh.
|
||||
target:
|
||||
$ref: "#/components/schemas/RunTarget"
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the selected repository checkout.
|
||||
example: dependency-update
|
||||
$ref: "#/components/schemas/AutomationTarget"
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
|
|
@ -6864,7 +6866,6 @@ components:
|
|||
required:
|
||||
- name
|
||||
- target
|
||||
- workflow
|
||||
- triggers
|
||||
properties:
|
||||
name:
|
||||
|
|
@ -6874,11 +6875,7 @@ components:
|
|||
type: ["string", "null"]
|
||||
example: Keeps dependencies fresh.
|
||||
target:
|
||||
$ref: "#/components/schemas/RunTarget"
|
||||
workflow:
|
||||
type: string
|
||||
description: Workflow slug or path resolved in the selected repository checkout.
|
||||
example: dependency-update
|
||||
$ref: "#/components/schemas/AutomationTarget"
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
|
|
@ -9301,20 +9298,13 @@ components:
|
|||
description: Workspace content and location requested for a run.
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/GitRunTarget"
|
||||
- $ref: "#/components/schemas/NoneRunTarget"
|
||||
- $ref: "#/components/schemas/FolderRunTarget"
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
git: "#/components/schemas/GitRunTarget"
|
||||
none: "#/components/schemas/NoneRunTarget"
|
||||
folder: "#/components/schemas/FolderRunTarget"
|
||||
|
||||
GitRunTarget:
|
||||
description: >-
|
||||
Public github.com repository target. 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.
|
||||
description: Public github.com repository target.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
|
|
@ -9331,62 +9321,14 @@ components:
|
|||
example: acme/my-app
|
||||
branch:
|
||||
type: string
|
||||
description: Required attached working branch name, preserved exactly.
|
||||
description: Required branch name, preserved exactly.
|
||||
example: feature/foo
|
||||
tag:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: >-
|
||||
Optional bare tag name. Prefixes such as `refs/tags/` and `tags/`
|
||||
are rejected. Without `sha`, the worker resolves this tag when the
|
||||
sandbox starts and fails if it is unavailable.
|
||||
example: v1.2.3
|
||||
sha:
|
||||
type: string
|
||||
pattern: "^[0-9A-Fa-f]{40}$"
|
||||
description: >-
|
||||
Optional exact commit. The server lowercase-normalizes its syntax
|
||||
but does not resolve it, prove branch ancestry, or prove that it
|
||||
matches an accompanying tag. When present, this exact commit wins.
|
||||
|
||||
NoneRunTarget:
|
||||
description: >-
|
||||
Empty workspace with no repository. Docker and Daytona accept this
|
||||
target and suppress cloning even when workflow settings enable it.
|
||||
Local environments reject it; Local scratch allocation is a separate
|
||||
future capability.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [none]
|
||||
|
||||
FolderRunTarget:
|
||||
description: >-
|
||||
Existing directory on the Fabro server, executed in place by a Local
|
||||
environment. The submitted path must be absolute and name an existing
|
||||
directory; Fabro resolves symlinks and persists its canonical UTF-8
|
||||
path. This target is intended for trusted single-tenant deployments.
|
||||
Docker and Daytona environments always reject it. This target does not
|
||||
add Local Git cloning or Local scratch workspaces. Folder runs execute
|
||||
in place without Fabro Git checkpoints, so fork and rewind are
|
||||
unavailable.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- kind
|
||||
- path
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [folder]
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Absolute path on the Fabro server, not on the API caller's machine.
|
||||
but does not resolve it or prove branch ancestry.
|
||||
|
||||
RunManifest:
|
||||
description: Self-contained workflow run manifest.
|
||||
|
|
|
|||
|
|
@ -15,10 +15,6 @@ Two dates on **Settings → Sessions** were wrong as a result and are now correc
|
|||
|
||||
Listing and revoking sessions no longer reads every refresh token the server has ever issued, so both stay fast as a workspace accumulates logins. Revoking a session removes its tokens in the same operation.
|
||||
|
||||
## Pending CLI logins
|
||||
|
||||
Pending CLI authorization codes now live in SQLite as SHA-256 hashes and are consumed atomically on the first exchange attempt. A login that is already between browser approval and token exchange when the server upgrades cannot carry across the storage cutover; run `fabro auth login` again. These codes expire after 60 seconds, and completed logins are unaffected.
|
||||
|
||||
## Refresh token replay
|
||||
|
||||
Replaying a refresh token still revokes its whole chain immediately. One detail changed: when several requests present the same already-rotated token at once, later ones now report `refresh_token_expired` where they previously reported `refresh_token_revoked`. The CLI treats both the same way — it discards the stored credentials and prompts you to sign in again.
|
||||
|
|
|
|||
|
|
@ -1,25 +1,8 @@
|
|||
---
|
||||
title: "Empty run workspaces and more reliable Daytona activation"
|
||||
title: "More reliable Daytona snapshot activation"
|
||||
date: "2026-08-23"
|
||||
---
|
||||
|
||||
Version-backed run intents can now use `{ "kind": "none" }` when a workflow
|
||||
should start without a repository. The target creates an empty Docker or
|
||||
Daytona workspace and suppresses cloning even when the resolved workflow
|
||||
settings enable it.
|
||||
|
||||
Local environments reject the `none` target. Server-managed Local scratch
|
||||
workspaces remain a separate future capability.
|
||||
|
||||
Run intents can also use
|
||||
`{ "kind": "folder", "path": "/absolute/server/path" }` with a Local
|
||||
environment to execute in an existing server directory. Fabro resolves the
|
||||
submitted path to an existing canonical directory, persists that path, and
|
||||
uses it instead of the environment's `cwd`. Folder targets are intended for
|
||||
trusted single-tenant deployments and are rejected by Docker and Daytona.
|
||||
They execute in place without Fabro Git checkpoints, so retries retain the
|
||||
folder target while fork and rewind remain unavailable.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
---
|
||||
title: "Model stylesheet templates and failure routing"
|
||||
date: "2026-08-25"
|
||||
---
|
||||
|
||||
Workflows can now set graph-level `on_failure="exit"` to stop after a failed
|
||||
node when no explicit recovery route matches. Fabro skips the unconditional
|
||||
edge, checks configured retry targets, and ends the run as failed if no retry
|
||||
target exists.
|
||||
|
||||
The default `on_failure="route"` preserves existing workflow behavior.
|
||||
|
||||
A node can also set its own `on_failure` to override the graph policy in
|
||||
either direction: a best-effort node can use `on_failure="route"` inside an
|
||||
`exit` graph, or a single critical node can use `on_failure="exit"` while the
|
||||
rest of the graph keeps the default.
|
||||
|
||||
```dot
|
||||
digraph Build {
|
||||
graph [on_failure="exit"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
plan [prompt="Plan the work"]
|
||||
implement [prompt="Implement the plan"]
|
||||
verify [prompt="Verify the implementation"]
|
||||
|
||||
start -> plan -> implement -> verify -> exit
|
||||
}
|
||||
```
|
||||
|
||||
## Model stylesheet templates
|
||||
|
||||
The root graph's `model_stylesheet` now supports MiniJinja templates. A stylesheet can use typed run inputs and server-managed variables through `inputs` and `vars`. Conditions, loops, filters, macros, local values, and static includes use the same template engine as workflow goals and prompts.
|
||||
|
||||
Fabro renders the stylesheet before parsing and applying its rules. Undefined values produce the existing `template_undefined_variable` diagnostic. Offline validation skips stylesheet syntax checks until those values are available, which avoids a second error from incomplete generated stylesheet text.
|
||||
|
||||
Stylesheet templates do not expose `goal`, `env`, or `secrets`. Stylesheets on imported graphs remain ignored and now produce an `imported_model_stylesheet_ignored` warning.
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
---
|
||||
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,8 +309,6 @@
|
|||
"group": "August 2026",
|
||||
"icon": "clock-rotate-left",
|
||||
"pages": [
|
||||
"changelog/2026-08-26",
|
||||
"changelog/2026-08-25",
|
||||
"changelog/2026-08-23",
|
||||
"changelog/2026-08-21",
|
||||
"changelog/2026-08-20",
|
||||
|
|
|
|||
|
|
@ -3,37 +3,13 @@ title: "Automations"
|
|||
description: "Named, repeatable run configurations with API and schedule triggers"
|
||||
---
|
||||
|
||||
An **automation** is a saved run configuration — a Git repository, working branch, optional tag or exact commit, and workflow — plus the triggers that may start it. Every trigger fire creates and starts a normal Fabro run through the same pipeline as `POST /api/v1/runs`, so automation runs get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
|
||||
An **automation** is a saved run configuration — a repository, ref, and workflow — plus the triggers that may start it. Every trigger fire creates and starts a normal Fabro run through the same pipeline as `POST /api/v1/runs`, so automation runs get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
|
||||
|
||||
## Defining automations
|
||||
|
||||
The server stores automations in its SQLite database. Manage them in the web UI at `/automations` or through the `/api/v1/automations` REST API.
|
||||
|
||||
New definitions use Fabro's canonical Git run target. The working branch is always required. An optional tag selects that tag when no exact commit is present, and an optional 40-character commit SHA pins the run exactly. The exact commit wins when both a tag and SHA are present; the branch is retained as the run's working branch in every case.
|
||||
|
||||
```json title="Create automation request"
|
||||
{
|
||||
"name": "Nightly release",
|
||||
"description": "Cut a nightly build from main",
|
||||
"target": {
|
||||
"kind": "git",
|
||||
"repo": "fabro-sh/fabro",
|
||||
"branch": "main",
|
||||
"tag": "v1.2.3",
|
||||
"sha": "0123456789abcdef0123456789abcdef01234567"
|
||||
},
|
||||
"workflow": "release",
|
||||
"triggers": [
|
||||
{ "type": "api", "id": "manual", "enabled": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Automations currently support Git targets only. Folder and empty run targets are rejected during validation.
|
||||
|
||||
### Upgrading legacy targets
|
||||
|
||||
When upgrading from file-backed automation storage, startup imports every valid `automations/*.toml` file next to the active `settings.toml`. Existing SQLite definitions win on ID conflicts. After a successful import, Fabro renames the directory to a timestamped backup such as `automations.imported-20260711T180000000000Z.bak`. Invalid TOML or an invalid target leaves the original directory untouched for operator repair.
|
||||
When upgrading from file-backed automation storage, startup imports every valid `automations/*.toml` file next to the active `settings.toml`. Existing SQLite definitions win on ID conflicts. After a successful import, Fabro renames the directory to a timestamped backup such as `automations.imported-20260711T180000000000Z.bak`. Invalid TOML leaves the original directory untouched for operator repair.
|
||||
|
||||
The legacy files use this shape:
|
||||
|
||||
|
|
@ -58,19 +34,7 @@ enabled = true
|
|||
expression = "0 0 * * *"
|
||||
```
|
||||
|
||||
Fabro converts legacy refs deterministically:
|
||||
|
||||
- A 40-character hexadecimal SHA becomes an exact commit on working branch `main`.
|
||||
- `refs/tags/<name>` and `tags/<name>` become a tag on working branch `main`.
|
||||
- `refs/heads/<name>` and `heads/<name>` become a working branch.
|
||||
- `HEAD` becomes working branch `main`.
|
||||
- Any other bare value becomes a working branch.
|
||||
|
||||
The `main` default is only a migration assumption. If the repository uses another working branch, edit the imported automation before running it.
|
||||
|
||||
The same conversion runs transactionally for automations already in SQLite. An unsupported `refs/*` selector or an invalid branch or tag name aborts startup with an actionable error instead of guessing. The database remains on its previous schema and data, and the migration snapshot remains available. Edit the unsupported legacy `target_ref` to a branch, head selector, tag selector, `HEAD`, or exact SHA, then restart Fabro.
|
||||
|
||||
When a trigger fires, Fabro clones the repository at the selected branch, tag, or exact commit, resolves the workflow, and creates and starts the run. The created run records the exact checked-out commit in its canonical target, so later inspection and automation creation preserve the revision that actually ran. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
|
||||
The target names a GitHub repository as an `owner/repo` slug, the ref to run against, and a project workflow defined in that repository. When a trigger fires, Fabro clones the repository at the ref, resolves the workflow, and creates and starts the run. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
|
||||
|
||||
## Triggers
|
||||
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ Checkpoints and checkpoint-completed events persist these `blob://` refs, not ho
|
|||
Before Fabro builds a preamble or starts the next stage, it resolves any blob refs into execution-local files so handlers and agents still see normal `file://` references:
|
||||
|
||||
- Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_hash}.json`
|
||||
- Remote sandboxes materialize blobs under the sandbox runtime directory, `{runtime_directory}/blobs/{blob_hash}.json`. This directory lives outside the repository checkout, so materialized blobs never appear in `git status` and are never committed by a checkpoint.
|
||||
- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
|
||||
These materialized `file://` paths are runtime-only. They are not written back into durable context snapshots.
|
||||
|
||||
|
|
|
|||
|
|
@ -231,18 +231,6 @@ 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
|
||||
|
|
@ -270,7 +258,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. 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.
|
||||
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start with an empty workspace. Set `[run.clone] depth = 0` to clone full history. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
|
||||
|
||||
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.
|
||||
|
||||
|
|
|
|||
|
|
@ -40,53 +40,6 @@ approve -> manual_review [condition="outcome=failed"]
|
|||
|
||||
If no `outcome=failed` edge or `retry_target` exists, the run stops rather than advancing past the approval gate.
|
||||
|
||||
## Stop linear workflows on failure
|
||||
|
||||
By default, Fabro uses `on_failure="route"`. A failed node can take an unconditional edge when no explicit route matches. This compatibility default lets existing workflows decide how later nodes handle the failure.
|
||||
|
||||
Set graph-level `on_failure="exit"` to stop a linear workflow at a failed node:
|
||||
|
||||
```dot title="stop-on-failure.fabro"
|
||||
digraph Build {
|
||||
graph [on_failure="exit"]
|
||||
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
plan [prompt="Plan the work"]
|
||||
implement [prompt="Implement the plan"]
|
||||
verify [prompt="Verify the implementation"]
|
||||
|
||||
start -> plan -> implement -> verify -> exit
|
||||
}
|
||||
```
|
||||
|
||||
Fabro still uses an explicit recovery edge, such as `condition="outcome=failed"`, before it applies this policy. Matching preferred labels and suggested next node IDs also remain explicit routes. If no explicit edge matches, `exit` skips the unconditional edge and checks retry targets. The run ends as failed only when no retry target exists.
|
||||
|
||||
Set `on_failure` on a node to control that node alone. The node-level attribute overrides the graph level, in both directions: a node can opt out of a graph-level `exit` with `on_failure="route"`, or stop the run on its own failure with `on_failure="exit"` while the rest of the graph keeps the default. A node without the attribute inherits the graph policy. See [Failed-node routing policy](/workflows/transitions#failed-node-routing-policy).
|
||||
|
||||
The policy applies only to `failed`. Other outcomes keep their normal routing behavior. For parallel nodes, the policy uses the completed parallel node's final outcome. It does not stop or cancel individual branches early.
|
||||
|
||||
## Treat a failed node as succeeded
|
||||
|
||||
Set `on_failure="succeed"` on a best-effort node so its failure never blocks the workflow. This pairs well with a strict graph default:
|
||||
|
||||
```dot title="best-effort-node.fabro"
|
||||
digraph Review {
|
||||
graph [on_failure="exit"]
|
||||
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
required_check [script="./required-check"]
|
||||
optional_scan [script="./optional-scan" on_failure="succeed"]
|
||||
|
||||
start -> required_check -> optional_scan -> exit
|
||||
}
|
||||
```
|
||||
|
||||
When `optional_scan` fails, Fabro first checks explicit recovery routes with the `failed` outcome. If none match, it rewrites the outcome to `succeeded` and routes the node as a success. Retries still run first; only the final outcome changes. The original failure stays on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion. A promoted outcome satisfies a goal gate. Setting `on_failure="succeed"` on the graph applies it to every node.
|
||||
|
||||
`succeed` applies only to `failed`. It does not change a `partially_succeeded` outcome. `auto_status=true` is the deprecated spelling of this policy; validation warns and suggests `on_failure="succeed"`.
|
||||
|
||||
## Retry layers
|
||||
|
||||
Fabro retries failures at three levels: **LLM retries** handle transient API errors inside a single model call, **turn-level retries** recover from dropped streams mid-response, and **node retries** re-execute the entire node handler when the first two levels aren't enough. These layers are independent — a node retry re-runs the full handler, which gets its own fresh set of LLM and turn-level retries.
|
||||
|
|
@ -351,14 +304,9 @@ A node failure does **not** automatically terminate the run. Fabro follows this
|
|||
2. **Turn-level retries** — dropped streams retry the same agent turn (up to 3 retries), preserving conversation history
|
||||
3. **Provider failover** — if configured, switch to a fallback provider
|
||||
4. **Node retries** — re-execute the entire handler (per the retry policy)
|
||||
5. **Direct jump** — use `jump_to_node` when the outcome supplies one
|
||||
6. **Explicit edge routing** — look for a matching condition, preferred label, or suggested next node
|
||||
7. **Failure policy** — with no explicit route, apply the effective `on_failure` (node-level `on_failure` first, then graph-level): `exit` skips the unconditional edge, `succeed` promotes the outcome to `succeeded` and routes it as a success, and `route` (or no attribute) keeps normal fallback routing
|
||||
8. **Unconditional edge** — in `route` mode, or after a `succeed` promotion, use an edge without a condition as the fallback
|
||||
9. **Retry target** — if no edge was selected, check `retry_target` and `fallback_retry_target` on the node, then on the graph
|
||||
10. **Run failure** — if none of the above produces a path forward, the run terminates
|
||||
|
||||
When a retry target sends the run back to a failing path, use graph-level `max_node_visits` or node-level `max_visits` to stop an unbounded cycle.
|
||||
4. **Edge routing** — if the node ultimately fails, look for an outgoing edge that matches (e.g., `condition="outcome=failed"`)
|
||||
5. **Retry target** — if no matching edge exists, check `retry_target` / `fallback_retry_target` on the node and graph
|
||||
6. **Run failure** — if none of the above produces a path forward, the run terminates
|
||||
|
||||
The run also terminates immediately for:
|
||||
|
||||
|
|
|
|||
|
|
@ -83,28 +83,24 @@ 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.
|
||||
|
||||
## Succeed on failure
|
||||
## `auto_status`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
| Attribute | Type | Default |
|
||||
|---|---|---|
|
||||
| `on_failure` | String | inherits the graph-level `on_failure` (default `route`) |
|
||||
| `auto_status` | Boolean | `false` |
|
||||
|
||||
```dot
|
||||
scan [
|
||||
label="Scan",
|
||||
shape=parallelogram,
|
||||
on_failure="succeed",
|
||||
auto_status=true,
|
||||
script="find . -name '*.log' | head -20"
|
||||
]
|
||||
```
|
||||
|
||||
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>
|
||||
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.
|
||||
|
||||
## Goal gate interaction
|
||||
|
||||
|
|
@ -115,7 +111,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. Likewise, a `succeeded` outcome promoted by `on_failure="succeed"` satisfies the gate.
|
||||
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.
|
||||
|
||||
See [Goal gates](/execution/failures#goal-gates) for retry target resolution and failure behavior.
|
||||
|
||||
|
|
|
|||
|
|
@ -444,19 +444,16 @@ repo_url = "https://github.com/fabro-sh/fabro"
|
|||
language = "rust"
|
||||
```
|
||||
|
||||
Inputs can be used in graph `goal`, root `model_stylesheet`, and node `prompt` attributes with `{{ inputs.name }}` syntax:
|
||||
Inputs can be used in graph `goal` and node `prompt` attributes with `{{ inputs.name }}` syntax:
|
||||
|
||||
```dot title="c-i.fabro"
|
||||
digraph CI {
|
||||
graph [
|
||||
goal="Run tests for {{ inputs.repo_name }}",
|
||||
model_stylesheet="{% if inputs.language == 'rust' %}* { reasoning_effort: high; }{% endif %}"
|
||||
]
|
||||
graph [goal="Run tests for {{ inputs.repo_name }}"]
|
||||
test [label="Test", prompt="Clone {{ inputs.repo_url }} and run the {{ inputs.language }} test suite."]
|
||||
}
|
||||
```
|
||||
|
||||
Inputs cannot parameterize workflow structure, file references such as node IDs, edges, `import` paths, `@file` paths, or child workflow paths, or any full-template attribute besides `prompt`, `goal`, and the root `model_stylesheet`. Command `script` supports only simple value substitution. Other attributes such as `label` are literal text.
|
||||
Inputs cannot parameterize workflow structure, file references such as node IDs, edges, `import` paths, `@file` paths, or child workflow paths, or any attribute besides `prompt` and `goal` — other attributes such as `script` and `label` are literal text.
|
||||
|
||||
If a workflow template references an undefined input like `{{ inputs.langauge }}`, `fabro validate` reports a warning. Run-style commands promote that diagnostic to an error before creating or starting a run.
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ Fabro does not use the `gpt56` profile for DeepSeek. That profile has a smaller
|
|||
|
||||
```bash
|
||||
fabro model list --provider deepseek
|
||||
fabro model test --provider deepseek --model deepseek-v4-flash --tools
|
||||
fabro model test --provider deepseek --model deepseek-v4-flash --deep
|
||||
fabro run workflow.fabro --provider deepseek --model deepseek
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -227,29 +227,18 @@ When a workflow runs in a remote sandbox (Daytona or Docker), Fabro clones the c
|
|||
|
||||
For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints.
|
||||
|
||||
#### Git targets for run intents
|
||||
#### Exact commits for run intents
|
||||
|
||||
The `RunIntent` create body always names a GitHub repository and a working
|
||||
branch. It may also select a bare tag, pin a full 40-character commit SHA, or
|
||||
include both:
|
||||
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.
|
||||
|
||||
| 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.
|
||||
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.
|
||||
|
||||
### GITHUB_TOKEN injection
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Both models support text input, tool calling, native reasoning, streaming, and a
|
|||
|
||||
```bash
|
||||
fabro model list --provider poolside
|
||||
fabro model test --model laguna-xs-2.1 --tools
|
||||
fabro model test --model laguna-xs-2.1 --deep
|
||||
fabro run workflow.fabro --model laguna-s-2.1
|
||||
```
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ enabled = true
|
|||
The OpenRouter routes use vendor-namespaced model IDs so they can coexist with direct Poolside routes:
|
||||
|
||||
```bash
|
||||
fabro model test --model poolside/laguna-xs-2.1 --tools
|
||||
fabro model test --model poolside/laguna-xs-2.1 --deep
|
||||
fabro run workflow.fabro --model poolside/laguna-s-2.1
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ Pin Venice when the run must use Venice:
|
|||
|
||||
```bash
|
||||
fabro model list --provider venice
|
||||
fabro model test --provider venice --model deepseek-v4-flash --tools
|
||||
fabro model test --provider venice --model deepseek-v4-flash --deep
|
||||
fabro run workflow.fabro --provider venice --model deepseek-v4-flash
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -611,7 +611,6 @@ 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) |
|
||||
|
||||
|
|
@ -633,7 +632,6 @@ 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) |
|
||||
|
||||
|
|
@ -695,12 +693,11 @@ fabro model test [OPTIONS]
|
|||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--deep` | Run a multi-turn tool-use test (catches reasoning round-trip bugs) |
|
||||
| `-j, --jobs <jobs>` | Number of model tests to run concurrently in bulk mode<br />Default: `4` |
|
||||
| `-m, --model <model>` | Test a specific model |
|
||||
| `-p, --provider <provider>` | Filter by provider |
|
||||
| `--reasoning-effort <reasoning_effort>` | Request a reasoning-effort level<br />Values: `low`, `medium`, `high`, `xhigh`, `max` |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
| `--tools` | Run a multi-turn tool-use test |
|
||||
|
||||
### `fabro parent`
|
||||
|
||||
|
|
|
|||
|
|
@ -75,9 +75,8 @@ rankdir=LR
|
|||
|---|---|---|
|
||||
| `goal` | String | Workflow objective — guides agent behavior |
|
||||
| `rankdir` | Identifier | Layout direction: `LR` (left-to-right) or `TB` (top-to-bottom) |
|
||||
| `model_stylesheet` | String | CSS-like rules for model assignment. The root value supports a MiniJinja template with `inputs` and `vars` (see [Model Stylesheets](/workflows/stylesheets)) |
|
||||
| `model_stylesheet` | String | CSS-like rules for model assignment (see [Model Stylesheets](/workflows/stylesheets)) |
|
||||
| `default_max_retries` | Integer | Default retry count for all nodes (default: 0) |
|
||||
| `on_failure` | String | Failed-node policy when no explicit recovery route matches: `route` (default), `exit`, or `succeed` |
|
||||
| `retry_target` | String | Default node ID to jump to on retry |
|
||||
| `fallback_retry_target` | String | Fallback retry target if primary target fails |
|
||||
| `default_fidelity` | String | Default [fidelity level](/execution/context) for all nodes |
|
||||
|
|
@ -199,13 +198,12 @@ Other node types still need their shape, because their attributes don't identify
|
|||
| `class` | String | Classes for [stylesheet](/workflows/stylesheets) targeting. Separate multiple classes with spaces. Commas are also accepted for compatibility. |
|
||||
| `timeout` | Duration | Execution timeout (e.g. `900s`). An agent's wait for human input does not consume this budget. On a human node, this is the response deadline. |
|
||||
| `max_visits` | Integer | Max times this node can execute in a run. Overrides the graph-level `max_node_visits` for this node. |
|
||||
| `on_failure` | String | Failed-node policy for this node: `route`, `exit`, or `succeed`. Overrides the graph-level `on_failure`. See [Node Outcomes](/execution/outcomes#succeed-on-failure). |
|
||||
| `max_retries` | Integer | Override default retry count |
|
||||
| `retry_policy` | String | Named preset: `none`, `standard`, `aggressive`, `linear`, `patient` |
|
||||
| `retry_target` | String | Node ID to jump to on retry |
|
||||
| `fallback_retry_target` | String | Fallback node ID if primary `retry_target` is unreachable |
|
||||
| `goal_gate` | Boolean | When `true`, workflow fails if this node didn't finish with `succeeded` or `partially_succeeded`. See [Node Outcomes](/execution/outcomes#goal-gate-interaction). |
|
||||
| `auto_status` | Boolean | Deprecated alias for `on_failure="succeed"`. Validation warns when it is present. |
|
||||
| `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). |
|
||||
| `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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -33,70 +33,10 @@ digraph Example {
|
|||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- **spec** gets Haiku (matches `*`)
|
||||
- **implement** and **test** get Sonnet with high reasoning (match `.coding`)
|
||||
- **review** gets Gemini Pro (matches `#review`)
|
||||
|
||||
## Template stylesheets
|
||||
|
||||
The root graph's `model_stylesheet` is a [MiniJinja template](/workflows/variables). It can read typed run inputs and server-managed variables through `inputs` and `vars`:
|
||||
|
||||
```dot title="variable-effort.fabro"
|
||||
digraph Review {
|
||||
graph [
|
||||
model_stylesheet="
|
||||
* { reasoning_effort: low; }
|
||||
|
||||
{% if inputs.effort == 'deep' %}
|
||||
.variable-effort { reasoning_effort: high; }
|
||||
{% elif inputs.effort == 'balanced' %}
|
||||
.variable-effort { reasoning_effort: medium; }
|
||||
{% endif %}
|
||||
"
|
||||
]
|
||||
|
||||
triage [prompt="Triage the change"]
|
||||
review [prompt="Review the change", class="variable-effort"]
|
||||
}
|
||||
```
|
||||
|
||||
Stylesheet templates support expressions, conditionals, loops, filters, macros, `{% set %}`, and normal local values such as `loop`. They do not expose `goal`, `env`, or `secrets`.
|
||||
|
||||
Fabro renders a stylesheet once. If an input or variable contains `{{ ... }}` or `{% ... %}`, that text stays literal. Fabro does not render it again.
|
||||
|
||||
Template output is not escaped as stylesheet syntax. Map user-facing choices to fixed declarations instead of inserting unrestricted text directly:
|
||||
|
||||
```dot
|
||||
model_stylesheet="
|
||||
{% set efforts = {'quick': 'low', 'thorough': 'high'} %}
|
||||
.review { reasoning_effort: {{ efforts[inputs.review_mode] }}; }
|
||||
"
|
||||
```
|
||||
|
||||
Use single quotes inside MiniJinja expressions when possible. A double quote must follow normal DOT string escaping because the surrounding graph attribute uses double quotes. MiniJinja braces need no extra escaping inside the quoted DOT attribute.
|
||||
|
||||
Static template includes are supported and resolve relative to the workflow template root:
|
||||
|
||||
```dot
|
||||
graph [model_stylesheet="{% include 'styles/models.partial' %}"]
|
||||
```
|
||||
|
||||
Include paths must be literal. Dynamic or root-escaping include paths fail validation. `model_stylesheet` does not support the `@file` shorthand.
|
||||
|
||||
Fabro uses this order:
|
||||
|
||||
1. Parse the DOT source.
|
||||
2. Expand workflow imports and supported file references.
|
||||
3. Render the root `model_stylesheet` with `{ inputs, vars }`.
|
||||
4. Parse and apply the rendered stylesheet.
|
||||
5. Resolve model and provider selectors.
|
||||
6. Validate the transformed graph.
|
||||
|
||||
A `model_stylesheet` on an imported graph is ignored and produces an `imported_model_stylesheet_ignored` warning. Put the stylesheet on the root graph. A root stylesheet can target imported nodes by their generated IDs, classes, or shapes.
|
||||
|
||||
If an input or variable is unavailable, `fabro validate` reports `template_undefined_variable`. It skips stylesheet syntax and model checks for that validation pass. Run-style commands treat the same diagnostic as an error before they create or start a run.
|
||||
|
||||
## Selectors
|
||||
|
||||
Each rule starts with a selector that determines which nodes it applies to:
|
||||
|
|
@ -120,7 +60,7 @@ This node matches both `.coding` and `.critical` rules.
|
|||
|
||||
## Properties
|
||||
|
||||
Stylesheets support five properties:
|
||||
Stylesheets support four properties:
|
||||
|
||||
| Property | Description | Example |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -7,94 +7,14 @@ After each node finishes, Fabro must decide which edge to follow to the next nod
|
|||
|
||||
## How transitions work
|
||||
|
||||
When a node completes, it produces an **outcome** with a [stage outcome](/execution/outcomes) (`succeeded`, `failed`, `partially_succeeded`, or `skipped`) and optional signals like a preferred label or suggested next node. A node's retry policy runs before routing starts. Fabro then selects the next step in this order:
|
||||
When a node completes, it produces an **outcome** with a [stage outcome](/execution/outcomes) (`succeeded`, `failed`, `partially_succeeded`, or `skipped`) and optional signals like a preferred label or suggested next node. Fabro evaluates the outgoing edges in a fixed priority order:
|
||||
|
||||
1. **Direct jump** — An outcome's `jump_to_node` value bypasses edge selection.
|
||||
2. **Condition match** — Edges with a `condition` attribute are evaluated first. If one or more conditions match, the edge with the highest `weight` wins (lexical tiebreak on target node ID).
|
||||
3. **Preferred label** — If the node's outcome includes a preferred label (for example, from a human gate selection), the edge whose `label` matches is chosen.
|
||||
4. **Suggested next** — If the node suggests a specific next node ID, the edge pointing to that node is chosen.
|
||||
5. **Failure policy** — For a failed outcome with no explicit route, the effective `on_failure` policy (node-level `on_failure` first, then graph-level) decides what happens next. `exit` skips the unconditional fallback. `succeed` promotes the outcome to `succeeded` and routes it as a success. `route` continues to the unconditional fallback.
|
||||
6. **Unconditional fallback** — Edges without conditions are considered last, again using `weight` then lexical tiebreak.
|
||||
7. **Retry target** — For a failed outcome with no selected edge, Fabro checks node-level and graph-level `retry_target` and `fallback_retry_target` values.
|
||||
1. **Condition match** — Edges with a `condition` attribute are evaluated first. If one or more conditions match, the edge with the highest `weight` wins (lexical tiebreak on target node ID).
|
||||
2. **Preferred label** — If the node's outcome includes a preferred label (e.g. from a human gate selection), the edge whose `label` matches is chosen.
|
||||
3. **Suggested next** — If the node suggests a specific next node ID, the edge pointing to that node is chosen.
|
||||
4. **Unconditional fallback** — Edges without conditions are considered last, again using `weight` then lexical tiebreak.
|
||||
|
||||
If no edge or retry target supplies a next node, the workflow ends. A failed node produces a failed run outcome.
|
||||
|
||||
## Failed-node routing policy
|
||||
|
||||
The `on_failure` attribute controls what happens to a failed node when no explicit recovery route matches:
|
||||
|
||||
| Policy | Effective outcome | Fallback routing |
|
||||
|---|---|---|
|
||||
| `route` (default) | stays `failed` | takes the unconditional edge |
|
||||
| `exit` | stays `failed` | skips the unconditional edge; the run ends unless a retry target applies |
|
||||
| `succeed` | becomes `succeeded` | uses normal success routing |
|
||||
|
||||
Set it at the graph level to apply the policy to every node, or on a node to control that node alone. A node-level `on_failure` overrides the graph level. A node without the attribute inherits the graph policy.
|
||||
|
||||
This lets a linear workflow stop at the first failed work node:
|
||||
|
||||
```dot title="stop-on-failure.fabro"
|
||||
digraph Build {
|
||||
graph [on_failure="exit"]
|
||||
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
plan [prompt="Plan the work"]
|
||||
implement [prompt="Implement the plan"]
|
||||
verify [prompt="Verify the implementation"]
|
||||
|
||||
start -> plan -> implement -> verify -> exit
|
||||
}
|
||||
```
|
||||
|
||||
Node-level overrides work in both directions. A strict graph can mark one best-effort node as `route` so its failure continues down the unconditional edge, and a default graph can mark one critical node as `exit`:
|
||||
|
||||
```dot title="mixed-policies.fabro"
|
||||
digraph Build {
|
||||
graph [on_failure="exit"]
|
||||
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
implement [prompt="Implement the change"]
|
||||
lint [prompt="Run optional lint cleanup" on_failure="route"]
|
||||
verify [prompt="Verify the implementation"]
|
||||
|
||||
start -> implement -> lint -> verify -> exit
|
||||
}
|
||||
```
|
||||
|
||||
Use `succeed` for a best-effort node whose failure must not block the workflow. Its failure becomes a `succeeded` outcome, so the node's normal success routing applies:
|
||||
|
||||
```dot title="best-effort-node.fabro"
|
||||
digraph Review {
|
||||
graph [on_failure="exit"]
|
||||
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
required_check [script="./required-check"]
|
||||
optional_scan [script="./optional-scan" on_failure="succeed"]
|
||||
|
||||
start -> required_check -> optional_scan -> exit
|
||||
}
|
||||
```
|
||||
|
||||
Under `succeed`, Fabro first checks explicit routes against the original `failed` outcome. If a `condition="outcome=failed"` edge, a matching preferred label, a matching suggested next node, or a handler jump applies, the outcome stays `failed` and that route is taken. Otherwise Fabro rewrites the outcome to `succeeded` before it records the node, so goal gates, the run context, events, and routing all see the promoted outcome. Edge selection then runs again: `condition="outcome=succeeded"` edges and unconditional edges apply. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record which scope promoted it. A promoted outcome is not `failed`, so retry targets do not apply to it.
|
||||
|
||||
Both `exit` and `succeed` apply only to the `failed` outcome. They do not change routing for `succeeded`, `partially_succeeded`, or `skipped` outcomes.
|
||||
|
||||
Conditioned edges, matching preferred labels, and matching suggested node IDs are explicit recovery routes. They take priority under every policy. An unmatched preferred label or suggested node ID does not make an unconditional edge explicit.
|
||||
|
||||
Retry targets also remain available under `exit`. Fabro checks them after it skips the unconditional fallback. Use graph-level `max_node_visits` or node-level `max_visits` to bound workflows whose retry targets return to a failing path.
|
||||
|
||||
A failed human gate never falls through to an unconditional edge as a failure, regardless of policy. Node-level `on_failure="route"` does not change that; route an interrupted gate explicitly with `condition="outcome=failed"`. Under `succeed`, an interrupted gate with no explicit route is promoted like any other node and then follows its success routing.
|
||||
|
||||
When `exit` stops routing, Fabro checkpoints the failed node without a next node and ends the run as failed. It does not execute the graph's exit node or emit an edge selection for an edge it did not take. An explicit recovery route can still reach the exit node normally.
|
||||
|
||||
For a parallel node, `exit` and `succeed` see the final outcome returned by the parallel handler. `exit` can stop routing for a failed parallel outcome; `succeed` promotes it. Neither adds branch-level fail-fast behavior, and a `partially_succeeded` parallel outcome continues normally. Inside the fan-out, a branch node whose effective policy is `succeed` counts as `succeeded` in the parent's aggregate when it fails. Branches have no edge routing, so there is no explicit route to prefer.
|
||||
|
||||
<Note>
|
||||
`auto_status=true` is the deprecated spelling of node-level `on_failure="succeed"`. Fabro still accepts it as an alias and validation warns with the replacement. See [Node Outcomes](/execution/outcomes#succeed-on-failure).
|
||||
</Note>
|
||||
If no edge matches at all, the workflow halts with an error.
|
||||
|
||||
## Edge attributes
|
||||
|
||||
|
|
@ -212,7 +132,7 @@ The `[A]`, `[R]`, `[S]` prefixes are keyboard accelerators — Fabro strips them
|
|||
|
||||
## Unconditional edges
|
||||
|
||||
An edge without a `condition` attribute is the normal fallback. When a node has a single outgoing edge, it doesn't need a condition:
|
||||
An edge without a `condition` attribute always matches. When a node has a single outgoing edge, it doesn't need a condition:
|
||||
|
||||
```dot
|
||||
start -> plan -> implement -> exit
|
||||
|
|
@ -225,8 +145,6 @@ gate -> fast_path [condition="outcome=succeeded"]
|
|||
gate -> slow_path
|
||||
```
|
||||
|
||||
For a failed outcome, `on_failure="exit"` skips this fallback after explicit routes are checked, and `on_failure="succeed"` promotes the outcome to `succeeded` before taking it. The default `on_failure="route"` keeps the behavior shown above.
|
||||
|
||||
## Weight tiebreaking
|
||||
|
||||
When multiple edges match (e.g. two unconditional edges), `weight` determines the winner. Higher weight wins:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: "Variables"
|
|||
description: "Using templates in workflows"
|
||||
---
|
||||
|
||||
Fabro renders full MiniJinja templates in three workflow attributes: the graph `goal`, the root graph's `model_stylesheet`, and node `prompt`s. A command node's `script` gets narrower treatment — [simple value substitution](#command-node-scripts), not templating. Every other attribute is literal text.
|
||||
Fabro renders `{{ ... }}` templates in exactly two workflow attributes: the graph `goal` and node `prompt`s. A command node's `script` gets narrower treatment — [simple value substitution](#command-node-scripts), not templating. Every other attribute is literal text.
|
||||
|
||||
## Template context
|
||||
|
||||
|
|
@ -15,9 +15,7 @@ Goal templates can reference inputs and server-managed variables. Prompt templat
|
|||
| `{{ inputs.name }}` | A value from `[run.inputs]`, optionally overridden by CLI input flags |
|
||||
| `{{ vars.NAME }}` | A server-managed variable snapshotted when the run is created |
|
||||
|
||||
The root `model_stylesheet` receives only `inputs` and `vars`. It does not receive `goal`. See [Model Stylesheets](/workflows/stylesheets#template-stylesheets) for examples and output safety guidance.
|
||||
|
||||
Secrets are **not** available in goal, prompt, or model stylesheet templates. Use `{{ secrets.NAME }}` only in the configuration fields that support run-boundary interpolation.
|
||||
Secrets are **not** available in goal or prompt templates. Use `{{ secrets.NAME }}` only in the configuration fields that support run-boundary interpolation.
|
||||
|
||||
## Run config inputs
|
||||
|
||||
|
|
@ -38,7 +36,7 @@ repo_url = "https://github.com/fabro-sh/fabro"
|
|||
language = "rust"
|
||||
```
|
||||
|
||||
These values are available in the graph `goal`, root `model_stylesheet`, and node `prompt` attributes:
|
||||
These values are available in the graph `goal` and node `prompt` attributes:
|
||||
|
||||
```dot title="check.fabro"
|
||||
digraph Check {
|
||||
|
|
@ -53,7 +51,7 @@ digraph Check {
|
|||
}
|
||||
```
|
||||
|
||||
Other attributes — `label`, `model`, `provider`, `condition`, and all edge attributes — do not render templates. If one of them contains `{{ … }}` or `{% … %}`, the syntax is treated as literal text and Fabro records a `detemplated_attribute` warning suggesting you move the dynamic value into a `prompt`, `goal`, or `model_stylesheet`.
|
||||
Other attributes — `label`, `model`, `provider`, `condition`, and all edge attributes — do not render templates. If one of them contains `{{ … }}` or `{% … %}`, the syntax is treated as literal text and Fabro records a `detemplated_attribute` warning suggesting you move the dynamic value into a `prompt` or `goal`.
|
||||
|
||||
Override individual inputs at run time with repeatable `-I` / `--input` flags:
|
||||
|
||||
|
|
@ -127,7 +125,7 @@ Use server-managed variables for non-sensitive values that should be shared acro
|
|||
fabro variable set DEPLOY_ENV staging --description "Deployment target"
|
||||
```
|
||||
|
||||
Run configuration strings, graph goals, root model stylesheets, and node prompts can reference these values with `{{ vars.NAME }}`:
|
||||
Run configuration strings, graph goals, and node prompts can reference these values with `{{ vars.NAME }}`:
|
||||
|
||||
```toml title="workflow.toml"
|
||||
_version = 1
|
||||
|
|
@ -173,10 +171,9 @@ Fabro keeps workflow structure static and renders workflow templates once:
|
|||
2. Literal `import`, `@file`, graph-goal file, and child-workflow references are resolved.
|
||||
3. The graph `goal` is rendered with the `{ inputs, vars }` context.
|
||||
4. Node `prompt` attributes are rendered with the `{ goal, inputs, vars }` context.
|
||||
5. The root `model_stylesheet` is rendered with the `{ inputs, vars }` context, then parsed and applied.
|
||||
6. Node `script` attributes have their `{{ goal }}`, `{{ inputs.* }}`, and `{{ vars.* }}` values substituted.
|
||||
5. Node `script` attributes have their `{{ goal }}`, `{{ inputs.* }}`, and `{{ vars.* }}` values substituted.
|
||||
|
||||
Templates are not supported in graph syntax, node IDs, edge structure, `import` paths, `@file` paths, child workflow paths, other file references, or any attribute besides `prompt`, `goal`, and the root `model_stylesheet` — and `script`, which takes value substitution rather than templates.
|
||||
Templates are not supported in graph syntax, node IDs, edge structure, `import` paths, `@file` paths, child workflow paths, other file references, or any attribute besides `prompt` and `goal` — and `script`, which takes value substitution rather than templates.
|
||||
|
||||
Command `stdin_source` values are literal context keys. Fabro resolves them at
|
||||
stage execution time, after upstream nodes have updated the workflow context.
|
||||
|
|
@ -191,9 +188,9 @@ In a `script`, an undefined value records the same diagnostic but leaves the tok
|
|||
|
||||
## Template includes
|
||||
|
||||
Prompt, goal, and root model stylesheet templates support static MiniJinja loader dependencies such as `{% include "partial.md" %}`. Includes are resolved relative to the template file being rendered and can be nested.
|
||||
Prompt and goal templates support static MiniJinja loader dependencies such as `{% include "partial.md" %}`. Includes are resolved relative to the template file being rendered and can be nested.
|
||||
|
||||
Fabro discovers those static dependencies while building the run manifest so sandbox providers receive every required template file. Dynamic loader expressions such as `{% include inputs.partial %}` are rejected; use a literal include path and choose content with normal template conditionals instead.
|
||||
Fabro discovers those static dependencies while building the run manifest so sandbox providers receive every required prompt file. Dynamic loader expressions such as `{% include inputs.partial %}` are rejected; use a literal include path and choose content with normal template conditionals instead.
|
||||
|
||||
## Escaping
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ fabro-auth = { path = "../../foundation/fabro-auth" }
|
|||
fabro-config = { path = "../../foundation/fabro-config" }
|
||||
fabro-environment = { path = "../../components/fabro-environment" }
|
||||
fabro-llm = { path = "../../components/fabro-llm" }
|
||||
fabro-model = { path = "../../foundation/fabro-model", features = ["clap"] }
|
||||
fabro-model = { path = "../../foundation/fabro-model" }
|
||||
fabro-oauth = { path = "../../foundation/fabro-oauth" }
|
||||
fabro-github = { path = "../../components/fabro-github" }
|
||||
fabro-agent = { path = "../../components/fabro-agent" }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ use anyhow::{Context, Result, bail};
|
|||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use fabro_agent::cli::AgentArgs;
|
||||
use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer};
|
||||
use fabro_model::ReasoningEffort;
|
||||
use fabro_server::serve::DEFAULT_TCP_PORT;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
|
||||
|
|
@ -191,13 +190,8 @@ pub(crate) struct McpStartArgs {
|
|||
pub(crate) connection: ServerConnectionArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
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,
|
||||
}
|
||||
|
|
@ -207,7 +201,7 @@ pub(crate) struct McpInitArgs {
|
|||
pub(crate) agent: McpAgent,
|
||||
|
||||
#[command(flatten)]
|
||||
pub(crate) config: McpConfigArgs,
|
||||
pub(crate) connection: ServerConnectionArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
|
|
@ -1097,13 +1091,9 @@ pub(crate) struct ModelTestArgs {
|
|||
)]
|
||||
pub(crate) jobs: usize,
|
||||
|
||||
/// Run a multi-turn tool-use test
|
||||
#[arg(long, alias = "deep")]
|
||||
pub(crate) tools: bool,
|
||||
|
||||
/// Request a reasoning-effort level
|
||||
#[arg(long, value_enum)]
|
||||
pub(crate) reasoning_effort: Option<ReasoningEffort>,
|
||||
/// Run a multi-turn tool-use test (catches reasoning round-trip bugs)
|
||||
#[arg(long)]
|
||||
pub(crate) deep: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ use std::fmt::Write as _;
|
|||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use crate::args::{
|
||||
McpAgent, McpCommand, McpConfigArgs, McpInitArgs, McpNamespace, ServerConnectionArgs,
|
||||
};
|
||||
use crate::args::{McpAgent, McpCommand, McpNamespace, ServerConnectionArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::server_client;
|
||||
|
||||
|
|
@ -14,12 +12,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))?;
|
||||
let json = fabro_mcp_server::config_json(&config_settings(&args.connection))?;
|
||||
let _ = write!(base_ctx.printer().stdout_important(), "{json}");
|
||||
Ok(())
|
||||
}
|
||||
McpCommand::Init(args) => {
|
||||
fabro_mcp_server::init_agent(&init_settings(&args)?)?;
|
||||
fabro_mcp_server::init_agent(&init_settings(args.agent, &args.connection)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -58,19 +56,21 @@ fn server_settings(
|
|||
})
|
||||
}
|
||||
|
||||
fn init_settings(args: &McpInitArgs) -> Result<fabro_mcp_server::McpInitSettings> {
|
||||
fn init_settings(
|
||||
agent: McpAgent,
|
||||
connection: &ServerConnectionArgs,
|
||||
) -> Result<fabro_mcp_server::McpInitSettings> {
|
||||
Ok(fabro_mcp_server::McpInitSettings {
|
||||
agent: McpAgentForServer(args.agent).into(),
|
||||
config: config_settings(&args.config),
|
||||
agent: McpAgentForServer(agent).into(),
|
||||
config: config_settings(connection),
|
||||
home_dir: home_dir()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn config_settings(args: &McpConfigArgs) -> fabro_mcp_server::McpConfigSettings {
|
||||
fn config_settings(connection: &ServerConnectionArgs) -> fabro_mcp_server::McpConfigSettings {
|
||||
fabro_mcp_server::McpConfigSettings {
|
||||
name: args.name.clone(),
|
||||
server: args.connection.target.server.clone(),
|
||||
storage_dir: args.connection.storage_dir.clone_path(),
|
||||
server: connection.target.server.clone(),
|
||||
storage_dir: connection.storage_dir.clone_path(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -254,15 +254,14 @@ fn model_test_row_from_status(model: &Model, status: &str, result_color: Color)
|
|||
)]
|
||||
async fn test_models_via_server(
|
||||
client: &server_client::Client,
|
||||
args: &ModelTestArgs,
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
deep: bool,
|
||||
jobs: usize,
|
||||
styles: &Styles,
|
||||
json_output: bool,
|
||||
) -> Result<()> {
|
||||
let provider = args.provider.as_deref();
|
||||
let model = args.model.as_deref();
|
||||
let jobs = args.jobs;
|
||||
let reasoning_effort = args.reasoning_effort;
|
||||
let request_mode = args.tools.then_some(ModelTestMode::Deep);
|
||||
let request_mode = deep.then_some(ModelTestMode::Deep);
|
||||
|
||||
let use_color = styles.use_color;
|
||||
let mut title = models_title(use_color);
|
||||
|
|
@ -289,12 +288,7 @@ async fn test_models_via_server(
|
|||
} else {
|
||||
Some(
|
||||
client
|
||||
.test_model(
|
||||
model_id,
|
||||
requested_provider.as_ref(),
|
||||
request_mode,
|
||||
reasoning_effort,
|
||||
)
|
||||
.test_model(model_id, requested_provider.as_ref(), request_mode)
|
||||
.await,
|
||||
)
|
||||
};
|
||||
|
|
@ -381,12 +375,7 @@ async fn test_models_via_server(
|
|||
let client = client.clone();
|
||||
async move {
|
||||
let result = client
|
||||
.test_model(
|
||||
info.id.as_str(),
|
||||
Some(&info.provider),
|
||||
request_mode,
|
||||
reasoning_effort,
|
||||
)
|
||||
.test_model(info.id.as_str(), Some(&info.provider), request_mode)
|
||||
.await;
|
||||
if !json_output {
|
||||
eprintln!("Testing {}... done", info.id);
|
||||
|
|
@ -497,8 +486,23 @@ async fn run_models(
|
|||
print_models_table(&models, &styles);
|
||||
}
|
||||
}
|
||||
ModelsCommand::Test(args) => {
|
||||
test_models_via_server(client, &args, &styles, json_output).await?;
|
||||
ModelsCommand::Test(ModelTestArgs {
|
||||
provider,
|
||||
model,
|
||||
deep,
|
||||
jobs,
|
||||
..
|
||||
}) => {
|
||||
test_models_via_server(
|
||||
client,
|
||||
provider.as_deref(),
|
||||
model.as_deref(),
|
||||
deep,
|
||||
jobs,
|
||||
&styles,
|
||||
json_output,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -514,8 +518,7 @@ impl Default for ModelsCommand {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_model::{
|
||||
ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffort,
|
||||
ReasoningEffortFeature,
|
||||
ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
|
@ -671,24 +674,20 @@ mod tests {
|
|||
.await;
|
||||
|
||||
let client = test_client(&server.url(""));
|
||||
let response = client
|
||||
.test_model("test-model", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = client.test_model("test-model", None, None).await.unwrap();
|
||||
|
||||
assert_eq!(response.status, api_types::ModelTestResultStatus::Ok);
|
||||
assert!(response.error_message.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_via_server_passes_mode_and_reasoning_effort() {
|
||||
async fn test_model_via_server_passes_mode_and_parses_error() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST")
|
||||
.path("/api/v1/models/test-model/test")
|
||||
.query_param("mode", "deep")
|
||||
.query_param("reasoning_effort", "high");
|
||||
.query_param("mode", "deep");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(
|
||||
|
|
@ -705,12 +704,7 @@ mod tests {
|
|||
|
||||
let client = test_client(&server.url(""));
|
||||
let response = client
|
||||
.test_model(
|
||||
"test-model",
|
||||
None,
|
||||
Some(ModelTestMode::Deep),
|
||||
Some(ReasoningEffort::High),
|
||||
)
|
||||
.test_model("test-model", None, Some(ModelTestMode::Deep))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -738,10 +732,7 @@ mod tests {
|
|||
.await;
|
||||
|
||||
let client = test_client(&server.url(""));
|
||||
let response = client
|
||||
.test_model("kimi-k2.5", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = client.test_model("kimi-k2.5", None, None).await.unwrap();
|
||||
|
||||
assert_eq!(response.status, api_types::ModelTestResultStatus::Skip);
|
||||
assert!(response.error_message.is_none());
|
||||
|
|
@ -765,7 +756,7 @@ mod tests {
|
|||
.await;
|
||||
|
||||
let client = test_client(&server.url(""));
|
||||
let result = client.test_model("bad-model", None, None, None).await;
|
||||
let result = client.test_model("bad-model", None, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Model not found"));
|
||||
}
|
||||
|
|
@ -809,14 +800,17 @@ mod tests {
|
|||
|
||||
let client = test_client(&server.url(""));
|
||||
|
||||
let args = ModelTestArgs {
|
||||
model: Some("venice-large".to_string()),
|
||||
jobs: 1,
|
||||
..ModelTestArgs::default()
|
||||
};
|
||||
test_models_via_server(&client, &args, &Styles::new(false), true)
|
||||
.await
|
||||
.unwrap();
|
||||
test_models_via_server(
|
||||
&client,
|
||||
None,
|
||||
Some("venice-large"),
|
||||
false,
|
||||
1,
|
||||
&Styles::new(false),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -877,13 +871,12 @@ mod tests {
|
|||
})
|
||||
.await;
|
||||
|
||||
let args = ModelTestArgs {
|
||||
jobs: 2,
|
||||
..ModelTestArgs::default()
|
||||
};
|
||||
test_models_via_server(
|
||||
&test_client(&server.url("")),
|
||||
&args,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
2,
|
||||
&Styles::new(false),
|
||||
true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -122,11 +122,10 @@ fn config_help() {
|
|||
|
||||
Options:
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--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=]
|
||||
--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]
|
||||
--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=]
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||||
-h, --help Print help
|
||||
|
|
@ -152,11 +151,10 @@ fn init_help() {
|
|||
|
||||
Options:
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--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=]
|
||||
--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]
|
||||
--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=]
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||||
-h, --help Print help
|
||||
|
|
@ -223,55 +221,6 @@ 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!();
|
||||
|
|
@ -468,91 +417,6 @@ fn init_preserves_existing_servers() {
|
|||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_merges_multiple_named_fabro_entries() {
|
||||
let context = test_context!();
|
||||
context
|
||||
.command()
|
||||
.args(["mcp", "init", "cursor"])
|
||||
.assert()
|
||||
.success();
|
||||
context
|
||||
.command()
|
||||
.args([
|
||||
"mcp",
|
||||
"init",
|
||||
"cursor",
|
||||
"--name",
|
||||
"fabro-production",
|
||||
"--server",
|
||||
"https://production.example.test",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
context
|
||||
.command()
|
||||
.args([
|
||||
"mcp",
|
||||
"init",
|
||||
"cursor",
|
||||
"--name",
|
||||
"fabro-testing",
|
||||
"--server",
|
||||
"https://testing.example.test",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
// Reusing a name updates only that entry.
|
||||
context
|
||||
.command()
|
||||
.args([
|
||||
"mcp",
|
||||
"init",
|
||||
"cursor",
|
||||
"--name",
|
||||
"fabro-production",
|
||||
"--server",
|
||||
"https://production.example.test:8443",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let config_path = context.home_dir.join(".cursor").join("mcp.json");
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(config_path).unwrap()).unwrap();
|
||||
fabro_json_snapshot!(context, config, @r#"
|
||||
{
|
||||
"mcpServers": {
|
||||
"fabro": {
|
||||
"command": "fabro",
|
||||
"args": [
|
||||
"mcp",
|
||||
"start"
|
||||
]
|
||||
},
|
||||
"fabro-production": {
|
||||
"command": "fabro",
|
||||
"args": [
|
||||
"mcp",
|
||||
"start",
|
||||
"--server",
|
||||
"https://production.example.test:8443"
|
||||
]
|
||||
},
|
||||
"fabro-testing": {
|
||||
"command": "fabro",
|
||||
"args": [
|
||||
"mcp",
|
||||
"start",
|
||||
"--server",
|
||||
"https://testing.example.test"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_invalid_json_fails_without_overwrite() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -86,81 +86,21 @@ fn help() {
|
|||
Usage: fabro model test [OPTIONS]
|
||||
|
||||
Options:
|
||||
--json
|
||||
Output as JSON [env: FABRO_JSON=]
|
||||
--server <SERVER>
|
||||
Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--debug
|
||||
Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
-p, --provider <PROVIDER>
|
||||
Filter by provider
|
||||
-m, --model <MODEL>
|
||||
Test a specific model
|
||||
--no-upgrade-check
|
||||
Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
-j, --jobs <JOBS>
|
||||
Number of model tests to run concurrently in bulk mode [default: 4]
|
||||
--quiet
|
||||
Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--tools
|
||||
Run a multi-turn tool-use test
|
||||
--verbose
|
||||
Enable verbose output [env: FABRO_VERBOSE=]
|
||||
--reasoning-effort <REASONING_EFFORT>
|
||||
Request a reasoning-effort level [possible values: low, medium, high, xhigh, max]
|
||||
-h, --help
|
||||
Print help
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
-p, --provider <PROVIDER> Filter by provider
|
||||
-m, --model <MODEL> Test a specific model
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
-j, --jobs <JOBS> Number of model tests to run concurrently in bulk mode [default: 4]
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--deep Run a multi-turn tool-use test (catches reasoning round-trip bugs)
|
||||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||||
-h, --help Print help
|
||||
----- stderr -----
|
||||
");
|
||||
}
|
||||
|
||||
fn assert_model_test_forwards(cli_args: &[&str], expected_query: &[(&str, &str)]) {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
context.set_http_target(&server.base_url());
|
||||
let list = mock_model_list(&server, [model_json("test-model", "anthropic", true)]);
|
||||
let test = server.mock(|when, then| {
|
||||
let mut when = when.method("POST").path("/api/v1/models/test-model/test");
|
||||
for (name, value) in expected_query {
|
||||
when = when.query_param(*name, *value);
|
||||
}
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"model_id": "test-model",
|
||||
"provider": "anthropic",
|
||||
"status": "ok"
|
||||
}));
|
||||
});
|
||||
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["model", "test", "--model", "test-model"]);
|
||||
cmd.args(cli_args);
|
||||
let output = cmd.output().expect("command should execute");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"model test should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
list.assert();
|
||||
test.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_test_tools_and_reasoning_effort_are_forwarded() {
|
||||
assert_model_test_forwards(&["--tools", "--reasoning-effort", "low"], &[
|
||||
("mode", "deep"),
|
||||
("reasoning_effort", "low"),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_test_deep_remains_an_alias_for_tools() {
|
||||
assert_model_test_forwards(&["--deep"], &[("mode", "deep")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_test_unknown_model_errors() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -76,9 +76,9 @@ fn preflight_rejects_unbound_template_inputs() {
|
|||
Goal: Demo
|
||||
|
||||
error: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
error: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
× Validation failed
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,27 +199,9 @@ fn bare_fabro_with_unbound_inputs_validates_structurally_with_warning() {
|
|||
Workflow: TemplatedUnbound (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound.fabro
|
||||
warning: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
warning: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbound_model_stylesheet_input_warns_without_css_error() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.validate();
|
||||
cmd.arg(fixture("model_stylesheet_unbound.fabro"));
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
Workflow: ModelStylesheetUnbound (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/model_stylesheet_unbound.fabro
|
||||
warning: [FIXTURES]/model_stylesheet_unbound.fabro:4:38: undefined template variable `inputs.effort` in graph attribute `model_stylesheet` (template_undefined_variable)
|
||||
fix: bind `effort` via `[run.inputs]` in workflow.toml, or pass `--input effort=<value>`
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
|
@ -242,7 +224,7 @@ fn bare_fabro_with_unbound_inputs_in_imported_prompt_validates_structurally_with
|
|||
Workflow: TemplatedUnboundImported (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound_imported/workflow.fabro
|
||||
warning: [FIXTURES]/templated_unbound_imported/work.md:1:12: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
|
@ -264,7 +246,7 @@ fn bare_fabro_with_unbound_inputs_in_template_partial_validates_structurally_wit
|
|||
Workflow: TemplatedUnboundPartial (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound_partial/workflow.fabro
|
||||
warning: [FIXTURES]/templated_unbound_partial/test-include.partial.md:1:4: undefined template variable `inputs.hello` in node `test_imported_include` attribute `prompt` [node: test_imported_include] (template_undefined_variable)
|
||||
fix: bind `hello` via `[run.inputs]` in workflow.toml, or pass `--input hello=<value>`
|
||||
fix: bind `inputs.hello` via `[run.inputs]` in workflow.toml, or pass `--input inputs.hello=<value>`
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
|
@ -376,57 +358,3 @@ fn invalid() {
|
|||
× Validation failed
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_node_on_failure_is_a_validation_failure() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.validate();
|
||||
cmd.arg(fixture("on_failure_node_invalid.fabro"));
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
Workflow: InvalidNodeOnFailure (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/on_failure_node_invalid.fabro
|
||||
error [node: work]: Node 'work' has invalid on_failure value 'stop' (on_failure_valid)
|
||||
fix: Use one of: route, exit, succeed
|
||||
× Validation failed
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deprecated_auto_status_warns_with_succeed_policy_replacement() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.validate();
|
||||
cmd.arg(fixture("auto_status_deprecated.fabro"));
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
Workflow: DeprecatedAutoStatus (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/auto_status_deprecated.fabro
|
||||
warning [node: scan]: Node 'scan' sets deprecated 'auto_status=true' (auto_status_deprecated)
|
||||
fix: Use on_failure=\"succeed\" instead
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_on_failure_is_a_validation_failure() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.validate();
|
||||
cmd.arg(fixture("on_failure_invalid.fabro"));
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
Workflow: InvalidOnFailure (2 nodes, 1 edges)
|
||||
Graph: [FIXTURES]/on_failure_invalid.fabro
|
||||
error: Graph has invalid on_failure value 'stop' (on_failure_valid)
|
||||
fix: Use one of: route, exit, succeed
|
||||
× Validation failed
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
use crate::{McpAgent, McpConfigSettings, McpInitSettings, SERVER_NAME};
|
||||
|
||||
pub fn config_json(settings: &McpConfigSettings) -> Result<String> {
|
||||
serde_json::to_string_pretty(&generic_config(settings))
|
||||
|
|
@ -18,16 +18,19 @@ 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, &settings.config)?;
|
||||
merge_server_entry(&path, entry.clone())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generic_config(settings: &McpConfigSettings) -> Value {
|
||||
let mut servers = Map::new();
|
||||
servers.insert(settings.name.clone(), server_entry(settings));
|
||||
json!({ "mcpServers": servers })
|
||||
json!({
|
||||
"mcpServers": {
|
||||
SERVER_NAME: server_entry(settings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn server_entry(settings: &McpConfigSettings) -> Value {
|
||||
|
|
@ -50,7 +53,7 @@ fn start_args(settings: &McpConfigSettings) -> Vec<String> {
|
|||
args
|
||||
}
|
||||
|
||||
fn merge_server_entry(path: &Path, settings: &McpConfigSettings) -> Result<()> {
|
||||
fn merge_server_entry(path: &Path, entry: Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
|
@ -77,7 +80,7 @@ fn merge_server_entry(path: &Path, settings: &McpConfigSettings) -> Result<()> {
|
|||
path.display()
|
||||
)
|
||||
})?;
|
||||
servers_object.insert(settings.name.clone(), server_entry(settings));
|
||||
servers_object.insert(SERVER_NAME.to_string(), entry);
|
||||
|
||||
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. It is also the default
|
||||
/// `mcpServers` key that `fabro mcp config` and `fabro mcp init` register.
|
||||
pub const SERVER_NAME: &str = "fabro";
|
||||
/// The name this MCP server reports over the wire and registers under in agent
|
||||
/// config files.
|
||||
pub(crate) const SERVER_NAME: &str = "fabro";
|
||||
|
||||
pub type FabroClientFuture = Pin<Box<dyn Future<Output = Result<Client>> + Send>>;
|
||||
|
||||
|
|
@ -39,10 +39,8 @@ impl std::fmt::Debug for FabroMcpServerSettings {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
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,7 +113,6 @@ 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"
|
||||
|
|
|
|||
|
|
@ -125,14 +125,12 @@ pub(crate) async fn activate_blob_storage(
|
|||
);
|
||||
|
||||
let blob_store = Arc::new(fabro_store::BlobStore::new(database.clone_pool()));
|
||||
let run_summary_store = Arc::new(fabro_store::RunSummaryStore::new(database.clone_pool()));
|
||||
let store = Arc::new(fabro_store::Database::new(
|
||||
object_store,
|
||||
slatedb_prefix,
|
||||
flush_interval,
|
||||
cache_path,
|
||||
Arc::clone(&blob_store),
|
||||
run_summary_store,
|
||||
));
|
||||
|
||||
let inventory = store
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ use url::{Host, Url};
|
|||
|
||||
use crate::auth::browser_shell::browser_shell;
|
||||
use crate::auth::{
|
||||
self, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, JwtSubject,
|
||||
PendingCliAuthorization, REFRESH_TOKEN_PREFIX, RotateOutcome,
|
||||
self, AuthCode, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, JwtSubject,
|
||||
REFRESH_TOKEN_PREFIX, RotateOutcome,
|
||||
};
|
||||
use crate::jwt_auth::{AuthMode, ConfiguredAuth, bearer_token_from_headers};
|
||||
use crate::principal_middleware::{
|
||||
|
|
@ -390,12 +390,18 @@ async fn token(
|
|||
);
|
||||
}
|
||||
|
||||
let Some(entry) = (match state
|
||||
.stores
|
||||
.auth_codes
|
||||
.consume(code, chrono::Utc::now())
|
||||
.await
|
||||
{
|
||||
let auth_codes = match state.store_ref().auth_codes().await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to open auth code store");
|
||||
return oauth_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Could not complete authentication",
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(entry) = (match auth_codes.consume(code).await {
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to consume auth code");
|
||||
|
|
@ -1111,7 +1117,8 @@ async fn issue_auth_code_response(
|
|||
let Some(redirect_uri) = canonical_loopback_redirect_uri(redirect_uri) else {
|
||||
return static_error_page(INVALID_REDIRECT_URI);
|
||||
};
|
||||
let entry = PendingCliAuthorization {
|
||||
let entry = AuthCode {
|
||||
code: code.clone(),
|
||||
identity,
|
||||
login: session.login.clone(),
|
||||
name: session.name.clone(),
|
||||
|
|
@ -1122,7 +1129,20 @@ async fn issue_auth_code_response(
|
|||
expires_at: chrono::Utc::now() + chrono::Duration::seconds(60),
|
||||
};
|
||||
|
||||
if let Err(err) = state.stores.auth_codes.issue(&code, &entry).await {
|
||||
let store = match state.store_ref().auth_codes().await {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to open auth code store");
|
||||
return redirect_with_error(
|
||||
&redirect_uri,
|
||||
state_token,
|
||||
"server_error",
|
||||
"Could not complete GitHub sign-in",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = store.insert(entry).await {
|
||||
warn!(error = %err, "Failed to persist auth code");
|
||||
return redirect_with_error(
|
||||
&redirect_uri,
|
||||
|
|
@ -1165,9 +1185,7 @@ mod tests {
|
|||
CliFlowCookie, DEV_TOKEN_LOGIN_INSTRUCTIONS, add_cli_flow_cookie, read_private_cli_flow,
|
||||
user_agent_fingerprint, web_routes,
|
||||
};
|
||||
use crate::auth::{
|
||||
self, AuthErrorCode, AuthSessionRecord, InitialRefreshToken, PendingCliAuthorization,
|
||||
};
|
||||
use crate::auth::{self, AuthCode, AuthErrorCode, AuthSessionRecord, InitialRefreshToken};
|
||||
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
|
||||
use crate::principal_middleware::{AuthStatus, RequestAuthContext};
|
||||
use crate::server::AppState;
|
||||
|
|
@ -1311,10 +1329,10 @@ client_id = "github-client-id"
|
|||
}
|
||||
|
||||
async fn insert_auth_code(state: &crate::server::AppState, code: &str, verifier: &str) {
|
||||
state
|
||||
.stores
|
||||
.auth_codes
|
||||
.issue(code, &PendingCliAuthorization {
|
||||
let auth_codes = state.store_ref().auth_codes().await.unwrap();
|
||||
auth_codes
|
||||
.insert(AuthCode {
|
||||
code: code.to_string(),
|
||||
identity: fabro_types::IdpIdentity::new("https://github.com", "12345")
|
||||
.expect("identity should be valid"),
|
||||
login: "octocat".to_string(),
|
||||
|
|
@ -1703,10 +1721,9 @@ client_id = "github-client-id"
|
|||
.nth(1)
|
||||
.and_then(|segment| segment.split('&').next())
|
||||
.expect("auth code should be present");
|
||||
let entry = state
|
||||
.stores
|
||||
.auth_codes
|
||||
.consume(code, chrono::Utc::now())
|
||||
let auth_codes = state.store_ref().auth_codes().await.unwrap();
|
||||
let entry = auth_codes
|
||||
.consume(code)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("code should exist");
|
||||
|
|
@ -2061,49 +2078,6 @@ client_id = "github-client-id"
|
|||
assert_eq!(body["error"], "invalid_code");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_storage_failure_returns_safe_oauth_error() {
|
||||
let (app, state) = test_router(github_settings("https://fabro.example"));
|
||||
state.stores.auth_codes.test_close().await;
|
||||
let raw_code = "raw-code-that-must-not-escape";
|
||||
let raw_verifier = "raw-verifier-that-must-not-escape";
|
||||
let redirect_uri = "http://127.0.0.1:4444/callback";
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/auth/cli/token")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"grant_type": "authorization_code",
|
||||
"code": raw_code,
|
||||
"code_verifier": raw_verifier,
|
||||
"redirect_uri": redirect_uri
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let rendered = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&rendered).unwrap(),
|
||||
json!({
|
||||
"error": "server_error",
|
||||
"error_description": "Could not complete authentication"
|
||||
})
|
||||
);
|
||||
for sensitive in [raw_code, raw_verifier, redirect_uri] {
|
||||
assert!(!rendered.contains(sensitive));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_rejects_userinfo_injected_redirect_uri() {
|
||||
let (app, state) = test_router(github_settings("https://fabro.example"));
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ pub(crate) const REFRESH_TOKEN_PREFIX: &str = "fabro_refresh_";
|
|||
|
||||
pub(crate) use browser_shell::browser_shell;
|
||||
pub(crate) use cli_flow::web_routes;
|
||||
pub(crate) use fabro_store::PendingCliAuthorization;
|
||||
pub(crate) use fabro_store::AuthCode;
|
||||
pub(crate) use fabro_store::auth_session_store::{
|
||||
AuthSessionRecord, InitialRefreshToken, RotateOutcome,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use async_trait::async_trait;
|
||||
use fabro_api::types::RunManifest;
|
||||
use fabro_automation::AutomationId;
|
||||
use fabro_automation::{AutomationId, AutomationTarget};
|
||||
use fabro_config::{EnvironmentLayer, MergeMap};
|
||||
use fabro_manifest::ManifestBuildInput;
|
||||
use fabro_types::{GitHubRepositorySlug, GitRunTarget, RunId, RunTarget, TargetValidationError};
|
||||
use fabro_types::{DirtyStatus, GitContext, GitHubRepositorySlug, RunId};
|
||||
use fabro_util::error::collect_chain;
|
||||
use tokio::{fs, task};
|
||||
|
||||
use crate::git_checkout::{
|
||||
|
|
@ -16,8 +18,7 @@ use crate::git_checkout::{
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct AutomationRunMaterializeInput {
|
||||
pub automation_id: AutomationId,
|
||||
pub target: GitRunTarget,
|
||||
pub workflow: String,
|
||||
pub target: AutomationTarget,
|
||||
pub run_id: RunId,
|
||||
pub user_settings_path: PathBuf,
|
||||
pub temp_root: PathBuf,
|
||||
|
|
@ -27,52 +28,28 @@ pub(crate) struct AutomationRunMaterializeInput {
|
|||
pub(crate) struct AutomationRunMaterialized {
|
||||
pub manifest: RunManifest,
|
||||
pub submitted_manifest_bytes: Vec<u8>,
|
||||
pub target: GitRunTarget,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum RunMaterializeError {
|
||||
#[error("invalid automation Git target")]
|
||||
InvalidTarget {
|
||||
#[source]
|
||||
source: TargetValidationError,
|
||||
},
|
||||
#[error("failed to prepare automation checkout")]
|
||||
Checkout {
|
||||
#[from]
|
||||
source: GitCheckoutError,
|
||||
},
|
||||
#[error("failed to prepare automation temporary directory {path}")]
|
||||
TempDirectory {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to resolve automation workflow")]
|
||||
WorkflowNotFound {
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
#[error("failed to build run manifest")]
|
||||
Manifest {
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
#[error("manifest build task failed")]
|
||||
ManifestTask {
|
||||
#[source]
|
||||
source: task::JoinError,
|
||||
},
|
||||
#[error("failed to serialize materialized run manifest")]
|
||||
SerializeManifest {
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("failed to load GitHub credentials")]
|
||||
Credentials {
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
#[error("invalid repository target: {0}")]
|
||||
InvalidTarget(String),
|
||||
#[error("failed to clone repository: {0}")]
|
||||
CloneFailed(String),
|
||||
#[error("failed to resolve workflow: {0}")]
|
||||
WorkflowNotFound(String),
|
||||
#[error("failed to build run manifest: {0}")]
|
||||
Manifest(String),
|
||||
#[error("failed to load GitHub credentials: {0}")]
|
||||
Credentials(String),
|
||||
}
|
||||
|
||||
impl From<GitCheckoutError> for RunMaterializeError {
|
||||
fn from(value: GitCheckoutError) -> Self {
|
||||
match value {
|
||||
GitCheckoutError::CloneFailed(message) => Self::CloneFailed(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -116,17 +93,13 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
|
|||
&self,
|
||||
input: AutomationRunMaterializeInput,
|
||||
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
|
||||
let repo = GitHubRepositorySlug::try_new(&input.target.repo).ok_or(
|
||||
RunMaterializeError::InvalidTarget {
|
||||
source: TargetValidationError::Repository,
|
||||
},
|
||||
)?;
|
||||
fs::create_dir_all(&input.temp_root)
|
||||
.await
|
||||
.map_err(|source| RunMaterializeError::TempDirectory {
|
||||
path: input.temp_root.clone(),
|
||||
source,
|
||||
})?;
|
||||
let repo = parse_target_repository(&input.target.repository)?;
|
||||
fs::create_dir_all(&input.temp_root).await.map_err(|err| {
|
||||
RunMaterializeError::CloneFailed(format!(
|
||||
"failed to create temp root {}: {err}",
|
||||
input.temp_root.display()
|
||||
))
|
||||
})?;
|
||||
let temp_dir = tempfile::Builder::new()
|
||||
.prefix(&format!(
|
||||
"automation-{}-{}-",
|
||||
|
|
@ -134,9 +107,11 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
|
|||
input.run_id
|
||||
))
|
||||
.tempdir_in(&input.temp_root)
|
||||
.map_err(|source| RunMaterializeError::TempDirectory {
|
||||
path: input.temp_root.clone(),
|
||||
source,
|
||||
.map_err(|err| {
|
||||
RunMaterializeError::CloneFailed(format!(
|
||||
"failed to create per-run temp directory under {}: {err}",
|
||||
input.temp_root.display()
|
||||
))
|
||||
})?;
|
||||
let checkout_dir = temp_dir.path().join("repo");
|
||||
let auth = resolve_git_auth_config(
|
||||
|
|
@ -146,43 +121,62 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
|
|||
self.http_client.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|source| RunMaterializeError::Credentials { source })?;
|
||||
.map_err(|err| RunMaterializeError::CloneFailed(render_error_chain(err.as_ref())))?;
|
||||
|
||||
let checked_out_sha = self
|
||||
.repo_cache
|
||||
.prepare_worktree(WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &input.target,
|
||||
ref_selector: &input.target.ref_selector,
|
||||
auth: auth.as_ref(),
|
||||
worktree_dir: &checkout_dir,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut exact_target = input.target;
|
||||
exact_target.sha = Some(checked_out_sha);
|
||||
|
||||
let manifest_input = ManifestFromCheckoutInput {
|
||||
workflow: input.workflow,
|
||||
workflow: input.target.workflow,
|
||||
user_settings_path: input.user_settings_path,
|
||||
checkout_dir,
|
||||
target: exact_target,
|
||||
git_context: ManifestGitContextInput {
|
||||
repo,
|
||||
ref_selector: input.target.ref_selector,
|
||||
checked_out_sha,
|
||||
},
|
||||
environment_defaults: self.environment_defaults.clone(),
|
||||
};
|
||||
task::spawn_blocking(move || build_manifest_from_checkout(manifest_input))
|
||||
.await
|
||||
.map_err(|source| RunMaterializeError::ManifestTask { source })?
|
||||
.map_err(|err| {
|
||||
RunMaterializeError::Manifest(format!("manifest build task failed: {err}"))
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
fn render_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
collect_chain(error).join(": ")
|
||||
}
|
||||
|
||||
fn parse_target_repository(value: &str) -> Result<GitHubRepositorySlug, RunMaterializeError> {
|
||||
fabro_automation::parse_github_repository_slug(value)
|
||||
.map_err(|err| RunMaterializeError::InvalidTarget(err.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ManifestFromCheckoutInput {
|
||||
workflow: String,
|
||||
user_settings_path: PathBuf,
|
||||
checkout_dir: PathBuf,
|
||||
target: GitRunTarget,
|
||||
git_context: ManifestGitContextInput,
|
||||
environment_defaults: MergeMap<EnvironmentLayer>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ManifestGitContextInput {
|
||||
repo: GitHubRepositorySlug,
|
||||
ref_selector: String,
|
||||
checked_out_sha: String,
|
||||
}
|
||||
|
||||
fn build_manifest_from_checkout(
|
||||
args: ManifestFromCheckoutInput,
|
||||
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
|
||||
|
|
@ -190,17 +184,9 @@ fn build_manifest_from_checkout(
|
|||
workflow,
|
||||
user_settings_path,
|
||||
checkout_dir,
|
||||
target,
|
||||
git_context,
|
||||
environment_defaults,
|
||||
} = args;
|
||||
// Re-validating the exact target (now carrying the checked-out SHA) yields
|
||||
// the same `GitContext` projection the run-intent path uses.
|
||||
let validated = RunTarget::Git(target)
|
||||
.validate()
|
||||
.map_err(|source| RunMaterializeError::InvalidTarget { source })?;
|
||||
let RunTarget::Git(target) = validated.target else {
|
||||
unreachable!("validating a Git target yields a Git target");
|
||||
};
|
||||
let built = fabro_manifest::build_run_manifest(ManifestBuildInput {
|
||||
workflow: workflow.into(),
|
||||
cwd: checkout_dir,
|
||||
|
|
@ -208,28 +194,33 @@ fn build_manifest_from_checkout(
|
|||
environment_defaults,
|
||||
..ManifestBuildInput::default()
|
||||
})
|
||||
.map_err(manifest_build_error)?;
|
||||
.map_err(|err| manifest_build_error(&err))?;
|
||||
|
||||
let mut manifest = built.manifest;
|
||||
manifest.git = validated.git;
|
||||
manifest.git = Some(GitContext {
|
||||
origin_url: git_context.repo.https_url(),
|
||||
branch: git_context.ref_selector,
|
||||
sha: Some(git_context.checked_out_sha),
|
||||
dirty: DirtyStatus::Clean,
|
||||
});
|
||||
let submitted_manifest_bytes = serde_json::to_vec(&manifest)
|
||||
.map_err(|source| RunMaterializeError::SerializeManifest { source })?;
|
||||
.context("failed to serialize materialized run manifest")
|
||||
.map_err(|err| RunMaterializeError::Manifest(err.to_string()))?;
|
||||
Ok(AutomationRunMaterialized {
|
||||
manifest,
|
||||
submitted_manifest_bytes,
|
||||
target,
|
||||
})
|
||||
}
|
||||
|
||||
fn manifest_build_error(error: anyhow::Error) -> RunMaterializeError {
|
||||
fn manifest_build_error(error: &anyhow::Error) -> RunMaterializeError {
|
||||
if error.chain().any(|source| {
|
||||
source
|
||||
.downcast_ref::<fabro_config::Error>()
|
||||
.is_some_and(|err| matches!(err, fabro_config::Error::WorkflowNotFound(_)))
|
||||
}) {
|
||||
RunMaterializeError::WorkflowNotFound { source: error }
|
||||
RunMaterializeError::WorkflowNotFound(render_error_chain(error.as_ref()))
|
||||
} else {
|
||||
RunMaterializeError::Manifest { source: error }
|
||||
RunMaterializeError::Manifest(render_error_chain(error.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -242,28 +233,23 @@ pub struct TestAutomationRunMaterializer {
|
|||
#[cfg(any(test, feature = "test-support"))]
|
||||
struct TestAutomationRunMaterializerState {
|
||||
captured_inputs: Vec<AutomationRunMaterializeInput>,
|
||||
response: Result<Box<AutomationRunMaterialized>, TargetValidationError>,
|
||||
response: Result<AutomationRunMaterialized, RunMaterializeError>,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl TestAutomationRunMaterializer {
|
||||
pub fn succeed(
|
||||
manifest: RunManifest,
|
||||
submitted_manifest_bytes: Vec<u8>,
|
||||
target: GitRunTarget,
|
||||
) -> Self {
|
||||
Self::new(Ok(Box::new(AutomationRunMaterialized {
|
||||
pub fn succeed(manifest: RunManifest, submitted_manifest_bytes: Vec<u8>) -> Self {
|
||||
Self::new(Ok(AutomationRunMaterialized {
|
||||
manifest,
|
||||
submitted_manifest_bytes,
|
||||
target,
|
||||
})))
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn fail_invalid_target() -> Self {
|
||||
Self::new(Err(TargetValidationError::Repository))
|
||||
pub fn fail_invalid_target(message: impl Into<String>) -> Self {
|
||||
Self::new(Err(RunMaterializeError::InvalidTarget(message.into())))
|
||||
}
|
||||
|
||||
fn new(response: Result<Box<AutomationRunMaterialized>, TargetValidationError>) -> Self {
|
||||
fn new(response: Result<AutomationRunMaterialized, RunMaterializeError>) -> Self {
|
||||
Self {
|
||||
inner: std::sync::Arc::new(std::sync::Mutex::new(TestAutomationRunMaterializerState {
|
||||
captured_inputs: Vec::new(),
|
||||
|
|
@ -297,11 +283,7 @@ impl AutomationRunMaterializer for TestAutomationRunMaterializer {
|
|||
.lock()
|
||||
.expect("test automation materializer lock poisoned");
|
||||
guard.captured_inputs.push(input);
|
||||
guard
|
||||
.response
|
||||
.clone()
|
||||
.map(|materialized| *materialized)
|
||||
.map_err(|source| RunMaterializeError::InvalidTarget { source })
|
||||
guard.response.clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,17 +328,17 @@ mod tests {
|
|||
.unwrap();
|
||||
let user_settings_path = temp.path().join("settings.toml");
|
||||
fs::write(&user_settings_path, "_version = 1\n").unwrap();
|
||||
let repo = parse_target_repository("workspace-org/app").unwrap();
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567".to_string();
|
||||
|
||||
let materialized = build_manifest_from_checkout(ManifestFromCheckoutInput {
|
||||
workflow: "demo".to_string(),
|
||||
user_settings_path: user_settings_path.clone(),
|
||||
checkout_dir: checkout.clone(),
|
||||
target: GitRunTarget {
|
||||
repo: "workspace-org/app".to_string(),
|
||||
branch: "release".to_string(),
|
||||
tag: Some("v1".to_string()),
|
||||
sha: Some(sha.clone()),
|
||||
git_context: ManifestGitContextInput {
|
||||
repo,
|
||||
ref_selector: "release".to_string(),
|
||||
checked_out_sha: sha.clone(),
|
||||
},
|
||||
environment_defaults: test_environment_defaults(),
|
||||
})
|
||||
|
|
@ -383,8 +365,6 @@ mod tests {
|
|||
assert_eq!(git.branch, "release");
|
||||
assert_eq!(git.sha.as_deref(), Some(sha.as_str()));
|
||||
assert_eq!(git.dirty, DirtyStatus::Clean);
|
||||
assert_eq!(materialized.target.tag.as_deref(), Some("v1"));
|
||||
assert_eq!(materialized.target.sha.as_deref(), Some(sha.as_str()));
|
||||
let submitted_manifest: serde_json::Value =
|
||||
serde_json::from_slice(&materialized.submitted_manifest_bytes)
|
||||
.expect("submitted bytes should be a manifest");
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
use std::borrow::Cow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_store::KeyedMutex;
|
||||
use fabro_types::{GitHubRepositorySlug, GitRunTarget};
|
||||
use fabro_types::GitHubRepositorySlug;
|
||||
use tokio::process::Command;
|
||||
use tokio::{fs, time};
|
||||
|
||||
|
|
@ -16,64 +15,10 @@ const GIT_WORKTREE_PRUNE_TIMEOUT: Duration = Duration::from_secs(10);
|
|||
const GIT_REV_PARSE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Error returned while preparing a checkout from a git source.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum GitCheckoutError {
|
||||
#[error("failed to create Git cache directory {path}")]
|
||||
CacheDirectory {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to clone repository")]
|
||||
Clone {
|
||||
#[source]
|
||||
source: GitCommandError,
|
||||
},
|
||||
#[error("failed to fetch branch {branch:?}")]
|
||||
FetchBranch {
|
||||
branch: String,
|
||||
#[source]
|
||||
source: GitCommandError,
|
||||
},
|
||||
#[error("failed to fetch tag {tag:?}")]
|
||||
FetchTag {
|
||||
tag: String,
|
||||
#[source]
|
||||
source: GitCommandError,
|
||||
},
|
||||
#[error("failed to fetch exact commit {sha}")]
|
||||
FetchCommit {
|
||||
sha: String,
|
||||
#[source]
|
||||
source: GitCommandError,
|
||||
},
|
||||
#[error("failed to resolve fetched Git target to a commit")]
|
||||
ResolveCommit {
|
||||
#[source]
|
||||
source: GitCommandError,
|
||||
},
|
||||
#[error("failed to add Git worktree")]
|
||||
AddWorktree {
|
||||
#[source]
|
||||
source: GitCommandError,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum GitCommandError {
|
||||
#[error("{command} timed out after {timeout_secs}s")]
|
||||
Timeout {
|
||||
command: String,
|
||||
timeout_secs: u64,
|
||||
},
|
||||
#[error("failed to run {command}")]
|
||||
Spawn {
|
||||
command: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Exit { message: String },
|
||||
#[error("failed to clone repository: {0}")]
|
||||
CloneFailed(String),
|
||||
}
|
||||
|
||||
/// Persistent on-disk cache of bare GitHub clones, one per `(owner, repo)`.
|
||||
|
|
@ -168,30 +113,25 @@ impl GitRepoCache {
|
|||
if !bare_exists {
|
||||
if let Some(parent) = bare_dir.parent() {
|
||||
fs::create_dir_all(parent).await.map_err(|err| {
|
||||
GitCheckoutError::CacheDirectory {
|
||||
path: parent.to_path_buf(),
|
||||
source: err,
|
||||
}
|
||||
GitCheckoutError::CloneFailed(format!(
|
||||
"failed to create cache dir {}: {err}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
run_git_plan(build_bare_clone_plan(clone_url, bare_dir, args.auth))
|
||||
.await
|
||||
.map_err(|source| GitCheckoutError::Clone { source })?;
|
||||
run_git_plan(build_bare_clone_plan(clone_url, bare_dir, args.auth)).await?;
|
||||
}
|
||||
|
||||
let fetch_target = GitFetchTarget::from(args.target);
|
||||
run_git_plan(build_bare_fetch_plan(
|
||||
bare_dir,
|
||||
clone_url,
|
||||
&fetch_target.selector(),
|
||||
args.ref_selector,
|
||||
args.auth,
|
||||
))
|
||||
.await
|
||||
.map_err(|source| fetch_target.checkout_error(source))?;
|
||||
.await?;
|
||||
|
||||
let checked_out_sha = run_git_plan(build_rev_parse_fetch_head_plan(bare_dir))
|
||||
.await
|
||||
.map_err(|source| GitCheckoutError::ResolveCommit { source })
|
||||
.map(|stdout| String::from_utf8_lossy(&stdout).trim().to_string())?;
|
||||
|
||||
add_worktree_with_stale_retry(bare_dir, args.worktree_dir, &checked_out_sha).await?;
|
||||
|
|
@ -202,55 +142,11 @@ impl GitRepoCache {
|
|||
|
||||
pub(crate) struct WorktreePrepareInput<'a> {
|
||||
pub repo: &'a GitHubRepositorySlug,
|
||||
pub target: &'a GitRunTarget,
|
||||
pub ref_selector: &'a str,
|
||||
pub auth: Option<&'a GitAuthConfig>,
|
||||
pub worktree_dir: &'a Path,
|
||||
}
|
||||
|
||||
enum GitFetchTarget<'a> {
|
||||
Branch(&'a str),
|
||||
Tag(&'a str),
|
||||
Commit(&'a str),
|
||||
}
|
||||
|
||||
impl<'a> From<&'a GitRunTarget> for GitFetchTarget<'a> {
|
||||
fn from(target: &'a GitRunTarget) -> Self {
|
||||
if let Some(sha) = target.sha.as_deref() {
|
||||
Self::Commit(sha)
|
||||
} else if let Some(tag) = target.tag.as_deref() {
|
||||
Self::Tag(tag)
|
||||
} else {
|
||||
Self::Branch(&target.branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GitFetchTarget<'_> {
|
||||
fn selector(&self) -> Cow<'_, str> {
|
||||
match self {
|
||||
Self::Branch(selector) | Self::Commit(selector) => Cow::Borrowed(selector),
|
||||
Self::Tag(tag) => Cow::Owned(format!("refs/tags/{tag}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn checkout_error(&self, source: GitCommandError) -> GitCheckoutError {
|
||||
match self {
|
||||
Self::Branch(branch) => GitCheckoutError::FetchBranch {
|
||||
branch: (*branch).to_string(),
|
||||
source,
|
||||
},
|
||||
Self::Tag(tag) => GitCheckoutError::FetchTag {
|
||||
tag: (*tag).to_string(),
|
||||
source,
|
||||
},
|
||||
Self::Commit(sha) => GitCheckoutError::FetchCommit {
|
||||
sha: (*sha).to_string(),
|
||||
source,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn bare_clone_may_be_corrupt(bare_dir: &Path) -> bool {
|
||||
match fs::metadata(&bare_dir.join("HEAD")).await {
|
||||
Ok(meta) => meta.len() == 0,
|
||||
|
|
@ -452,8 +348,7 @@ fn build_worktree_prune_plan(bare_dir: &Path) -> GitCommandPlan {
|
|||
}
|
||||
|
||||
fn build_rev_parse_fetch_head_plan(bare_dir: &Path) -> GitCommandPlan {
|
||||
GitCommandPlan::new(["rev-parse", "FETCH_HEAD^{commit}"], GIT_REV_PARSE_TIMEOUT)
|
||||
.current_dir(bare_dir)
|
||||
GitCommandPlan::new(["rev-parse", "FETCH_HEAD"], GIT_REV_PARSE_TIMEOUT).current_dir(bare_dir)
|
||||
}
|
||||
|
||||
async fn add_worktree_with_stale_retry(
|
||||
|
|
@ -465,14 +360,14 @@ async fn add_worktree_with_stale_retry(
|
|||
Ok(_) => Ok(()),
|
||||
Err(first_err) => {
|
||||
tracing::warn!(
|
||||
error = ?first_err,
|
||||
%first_err,
|
||||
bare_dir = %bare_dir.display(),
|
||||
worktree_dir = %worktree_dir.display(),
|
||||
"git worktree add failed; pruning stale worktree entries and retrying"
|
||||
);
|
||||
if let Err(prune_err) = run_git_plan(build_worktree_prune_plan(bare_dir)).await {
|
||||
tracing::warn!(
|
||||
error = ?prune_err,
|
||||
%prune_err,
|
||||
bare_dir = %bare_dir.display(),
|
||||
"failed to prune stale git worktree entries"
|
||||
);
|
||||
|
|
@ -480,12 +375,11 @@ async fn add_worktree_with_stale_retry(
|
|||
run_git_plan(build_worktree_add_plan(bare_dir, worktree_dir, target))
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|source| GitCheckoutError::AddWorktree { source })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCommandError> {
|
||||
async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCheckoutError> {
|
||||
let mut command = Command::new(&plan.program);
|
||||
command.args(&plan.args);
|
||||
command.envs(plan.env.iter().map(|(key, value)| (key, value)));
|
||||
|
|
@ -496,13 +390,18 @@ async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCommandError>
|
|||
|
||||
let output = time::timeout(plan.timeout, command.output())
|
||||
.await
|
||||
.map_err(|_| GitCommandError::Timeout {
|
||||
command: safe_command_label(&plan),
|
||||
timeout_secs: plan.timeout.as_secs(),
|
||||
.map_err(|_| {
|
||||
GitCheckoutError::CloneFailed(format!(
|
||||
"{} timed out after {}s",
|
||||
safe_command_label(&plan),
|
||||
plan.timeout.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|err| GitCommandError::Spawn {
|
||||
command: safe_command_label(&plan),
|
||||
source: err,
|
||||
.map_err(|err| {
|
||||
GitCheckoutError::CloneFailed(format!(
|
||||
"failed to run {}: {err}",
|
||||
safe_command_label(&plan)
|
||||
))
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
|
|
@ -523,9 +422,10 @@ async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCommandError>
|
|||
message.push_str(": ");
|
||||
message.push_str(stdout.trim());
|
||||
}
|
||||
Err(GitCommandError::Exit {
|
||||
message: redact_git_output(&message, &plan.sensitive_values),
|
||||
})
|
||||
Err(GitCheckoutError::CloneFailed(redact_git_output(
|
||||
&message,
|
||||
&plan.sensitive_values,
|
||||
)))
|
||||
}
|
||||
|
||||
fn safe_command_label(plan: &GitCommandPlan) -> String {
|
||||
|
|
@ -566,15 +466,6 @@ mod tests {
|
|||
GitHubRepositorySlug::try_new(value).expect("slug should parse")
|
||||
}
|
||||
|
||||
fn git_target(branch: &str, tag: Option<&str>, sha: Option<&str>) -> GitRunTarget {
|
||||
GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: branch.to_string(),
|
||||
tag: tag.map(str::to_string),
|
||||
sha: sha.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_repository_urls_are_github_metadata_urls_without_credentials() {
|
||||
let repo = repository_slug("fabro-sh/fabro");
|
||||
|
|
@ -655,7 +546,7 @@ mod tests {
|
|||
assert_eq!(prune.timeout, Duration::from_secs(10));
|
||||
|
||||
let rev_parse = build_rev_parse_fetch_head_plan(&bare_dir);
|
||||
assert_eq!(rev_parse.args, vec!["rev-parse", "FETCH_HEAD^{commit}"]);
|
||||
assert_eq!(rev_parse.args, vec!["rev-parse", "FETCH_HEAD"]);
|
||||
assert_eq!(rev_parse.current_dir.as_deref(), Some(bare_dir.as_path()));
|
||||
assert_eq!(rev_parse.timeout, Duration::from_secs(10));
|
||||
}
|
||||
|
|
@ -747,16 +638,11 @@ mod tests {
|
|||
.args(["-C", work.to_str().unwrap(), "commit", "-m", "seed"])
|
||||
.status()
|
||||
.expect("git commit seed");
|
||||
std::process::Command::new("git")
|
||||
.args(["-C", work.to_str().unwrap(), "tag", "-a", "v1", "-m", "v1"])
|
||||
.status()
|
||||
.expect("git tag seed");
|
||||
std::process::Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
work.to_str().unwrap(),
|
||||
"push",
|
||||
"--follow-tags",
|
||||
upstream.to_str().unwrap(),
|
||||
"main",
|
||||
])
|
||||
|
|
@ -803,14 +689,13 @@ mod tests {
|
|||
let cache = GitRepoCache::new(temp.path().join("cache"));
|
||||
let repo = repository_slug("fabro-sh/fabro");
|
||||
let upstream_url = upstream.to_str().unwrap().to_string();
|
||||
let target = git_target("main", None, None);
|
||||
|
||||
let worktree_a = temp.path().join("wt-a");
|
||||
let sha_a = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
ref_selector: "main",
|
||||
auth: None,
|
||||
worktree_dir: &worktree_a,
|
||||
},
|
||||
|
|
@ -832,7 +717,7 @@ mod tests {
|
|||
.prepare_worktree_with_clone_url(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
ref_selector: "main",
|
||||
auth: None,
|
||||
worktree_dir: &worktree_b,
|
||||
},
|
||||
|
|
@ -856,14 +741,13 @@ mod tests {
|
|||
let cache = GitRepoCache::new(temp.path().join("cache"));
|
||||
let repo = repository_slug("fabro-sh/fabro");
|
||||
let upstream_url = upstream.to_str().unwrap().to_string();
|
||||
let target = git_target("main", None, None);
|
||||
|
||||
let worktree_a = temp.path().join("wt-a");
|
||||
cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
ref_selector: "main",
|
||||
auth: None,
|
||||
worktree_dir: &worktree_a,
|
||||
},
|
||||
|
|
@ -881,7 +765,7 @@ mod tests {
|
|||
.prepare_worktree_with_clone_url(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
ref_selector: "main",
|
||||
auth: None,
|
||||
worktree_dir: &worktree_b,
|
||||
},
|
||||
|
|
@ -897,84 +781,4 @@ mod tests {
|
|||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tag_and_exact_commit_modes_return_the_peeled_sha() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let upstream = temp.path().join("upstream.git");
|
||||
let expected_sha = seed_upstream(&upstream);
|
||||
let cache = GitRepoCache::new(temp.path().join("cache"));
|
||||
let repo = repository_slug("fabro-sh/fabro");
|
||||
let upstream_url = upstream.to_str().unwrap().to_string();
|
||||
|
||||
for (name, target) in [
|
||||
("tag", git_target("main", Some("v1"), None)),
|
||||
(
|
||||
"pinned-tag",
|
||||
git_target("main", Some("v1"), Some(&expected_sha)),
|
||||
),
|
||||
("commit", git_target("main", None, Some(&expected_sha))),
|
||||
] {
|
||||
let sha = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &target,
|
||||
auth: None,
|
||||
worktree_dir: &temp.path().join(name),
|
||||
},
|
||||
&upstream_url,
|
||||
)
|
||||
.await
|
||||
.expect("target should materialize");
|
||||
assert_eq!(sha, expected_sha, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_tag_and_unavailable_commit_are_distinct_errors() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let upstream = temp.path().join("upstream.git");
|
||||
seed_upstream(&upstream);
|
||||
let cache = GitRepoCache::new(temp.path().join("cache"));
|
||||
let repo = repository_slug("fabro-sh/fabro");
|
||||
let upstream_url = upstream.to_str().unwrap().to_string();
|
||||
let missing_tag = git_target("main", Some("missing"), None);
|
||||
let unavailable_sha = "ffffffffffffffffffffffffffffffffffffffff";
|
||||
let unavailable_commit = git_target("main", None, Some(unavailable_sha));
|
||||
|
||||
let tag_error = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &missing_tag,
|
||||
auth: None,
|
||||
worktree_dir: &temp.path().join("missing-tag"),
|
||||
},
|
||||
&upstream_url,
|
||||
)
|
||||
.await
|
||||
.expect_err("missing tag should fail");
|
||||
assert!(matches!(
|
||||
tag_error,
|
||||
GitCheckoutError::FetchTag { tag, .. } if tag == "missing"
|
||||
));
|
||||
|
||||
let commit_error = cache
|
||||
.prepare_worktree_with_clone_url(
|
||||
WorktreePrepareInput {
|
||||
repo: &repo,
|
||||
target: &unavailable_commit,
|
||||
auth: None,
|
||||
worktree_dir: &temp.path().join("missing-commit"),
|
||||
},
|
||||
&upstream_url,
|
||||
)
|
||||
.await
|
||||
.expect_err("unavailable commit should fail");
|
||||
assert!(matches!(
|
||||
commit_error,
|
||||
GitCheckoutError::FetchCommit { sha, .. } if sha == unavailable_sha
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,16 +171,6 @@ 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,14 +6,11 @@ use fabro_config::{EnvironmentLayer, RunEnvironmentLayer, RunGoalLayer, Settings
|
|||
use fabro_environment::{EnvironmentId, EnvironmentValidationError};
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::{
|
||||
GitContext, ManifestPath, RunTarget, SandboxProviderKind, TargetValidationError, WorkflowPath,
|
||||
WorkflowVersionId,
|
||||
ManifestPath, 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};
|
||||
|
||||
|
|
@ -29,8 +26,6 @@ pub(crate) enum RunIntentAdmissionError {
|
|||
#[error(transparent)]
|
||||
Target(#[from] TargetValidationError),
|
||||
#[error(transparent)]
|
||||
FolderTarget(#[from] FolderTargetValidationError),
|
||||
#[error(transparent)]
|
||||
Environment(#[from] EnvironmentSelectionError),
|
||||
#[error(transparent)]
|
||||
Compiler(#[from] RunCompilerError),
|
||||
|
|
@ -41,85 +36,6 @@ 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}`")]
|
||||
|
|
@ -130,8 +46,8 @@ pub(crate) enum EnvironmentSelectionError {
|
|||
},
|
||||
#[error("environment `{id}` not found")]
|
||||
NotFound { id: EnvironmentId },
|
||||
#[error("{detail}")]
|
||||
TargetUnsupported { detail: &'static str },
|
||||
#[error("Git targets require a compatible clone-enabled Docker or Daytona environment")]
|
||||
TargetUnsupported,
|
||||
#[error("{detail}")]
|
||||
ProviderDisabled {
|
||||
provider: SandboxProviderKind,
|
||||
|
|
@ -461,81 +377,6 @@ 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,7 +935,6 @@ fn preflight_sandbox_spec(
|
|||
run_id: None,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -948,7 +947,6 @@ 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::warn!(run_id = %input.prompt.run_id, error = %err, "Run title generation failed");
|
||||
tracing::debug!(error = %err, "Run title generation failed");
|
||||
return current_title;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -783,12 +783,10 @@ where
|
|||
)
|
||||
.await
|
||||
.context("activating SQLite blob storage")?;
|
||||
let auth_code_store = store.auth_codes().await?;
|
||||
// Refresh tokens now live in SQLite. Nothing reads the old records and no
|
||||
// reaper collects them any more, so clear them out once rather than
|
||||
// leaving them in the object store forever. Pending authorization codes
|
||||
// also moved to SQLite, but their old records are left in place: at most a
|
||||
// handful exist at cutover, every binary rejects them within 60 seconds of
|
||||
// issue, and nothing reads their keyspace again.
|
||||
// leaving them in the object store forever.
|
||||
match store.retire_refresh_token_keyspace().await {
|
||||
Ok(0) => {}
|
||||
Ok(removed) => info!(removed, "Removed retired SlateDB refresh token records"),
|
||||
|
|
@ -859,7 +857,7 @@ where
|
|||
.await?;
|
||||
|
||||
spawn_auth_store_reapers(
|
||||
Arc::clone(&state.stores.auth_codes),
|
||||
Arc::clone(&auth_code_store),
|
||||
Arc::clone(&state.stores.auth_sessions),
|
||||
shutdown.clone(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -85,8 +85,8 @@ use fabro_slack::threads::ThreadRegistry;
|
|||
use fabro_slack::{blocks as slack_blocks, connection as slack_connection};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::{
|
||||
ArtifactKey, ArtifactStore, AuthCodeStore, AuthSessionStore, CachedRunProjection, Database,
|
||||
EventEnvelope, EventPayload, KeyedMutex, NodeArtifact, PendingInterviewRecord, RunSummaryStore,
|
||||
ArtifactKey, ArtifactStore, AuthSessionStore, CachedRunProjection, Database, EventEnvelope,
|
||||
EventPayload, KeyedMutex, NodeArtifact, PendingInterviewRecord, RunSummaryStore,
|
||||
StageArtifactEntry, StageId,
|
||||
};
|
||||
#[cfg(test)]
|
||||
|
|
@ -1154,7 +1154,6 @@ pub struct AppState {
|
|||
pub(crate) struct AppStores {
|
||||
pub(crate) runs: Arc<Database>,
|
||||
pub(crate) run_summaries: Arc<RunSummaryStore>,
|
||||
pub(crate) auth_codes: Arc<AuthCodeStore>,
|
||||
pub(crate) auth_sessions: Arc<AuthSessionStore>,
|
||||
pub(crate) automations: Arc<AutomationStore>,
|
||||
pub(crate) environments: Arc<EnvironmentStore>,
|
||||
|
|
@ -1171,12 +1170,6 @@ impl AppState {
|
|||
pub fn test_auth_session_store(&self) -> &Arc<AuthSessionStore> {
|
||||
&self.stores.auth_sessions
|
||||
}
|
||||
|
||||
/// Access the auth-code store used by this router.
|
||||
#[must_use]
|
||||
pub fn test_auth_code_store(&self) -> &Arc<AuthCodeStore> {
|
||||
&self.stores.auth_codes
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -1205,7 +1198,7 @@ impl AppState {
|
|||
let credentials = self
|
||||
.github_credentials(&settings.server.integrations.github)
|
||||
.await
|
||||
.map_err(|source| RunMaterializeError::Credentials { source })?;
|
||||
.map_err(|err| RunMaterializeError::Credentials(err.to_string()))?;
|
||||
ProductionAutomationRunMaterializer::new(
|
||||
credentials,
|
||||
self.github_api_base_url.clone(),
|
||||
|
|
@ -2447,8 +2440,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
})
|
||||
.context("load environments")?,
|
||||
);
|
||||
let run_summaries = store.run_summary_store();
|
||||
let auth_codes = Arc::new(AuthCodeStore::new(db_pool.clone()));
|
||||
let run_summaries =
|
||||
store.attach_run_summary_store(Arc::new(RunSummaryStore::new(db_pool.clone())));
|
||||
let auth_sessions = Arc::new(AuthSessionStore::new(db_pool.clone()));
|
||||
let mcp_server_dir = mcp_server_dir_for_active_config(&active_config_path);
|
||||
let mcp_server_pool = db_pool.clone();
|
||||
|
|
@ -2556,7 +2549,6 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
stores: AppStores {
|
||||
runs: store,
|
||||
run_summaries,
|
||||
auth_codes,
|
||||
auth_sessions,
|
||||
automations: automation_store,
|
||||
environments: environment_store,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use croner::errors::CronError;
|
|||
use fabro_automation::{
|
||||
Automation, AutomationId, AutomationRevision, AutomationTriggerId, parse_schedule_expression,
|
||||
};
|
||||
use fabro_types::{AutomationRef, Principal, RunId, RunTarget, SystemActorKind};
|
||||
use fabro_types::{AutomationRef, Principal, RunId, SystemActorKind};
|
||||
use tokio::time::sleep;
|
||||
use tracing::{Instrument, error, info, info_span, warn};
|
||||
|
||||
|
|
@ -229,18 +229,10 @@ async fn fire_scheduled_automation_run(
|
|||
) {
|
||||
let automation_id = automation.id.clone();
|
||||
let run_id = RunId::new();
|
||||
let Some(target) = automation.git_target().cloned() else {
|
||||
error!(
|
||||
automation_id = %automation_id,
|
||||
"Stored automation target is not Git-backed",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let materialized = match state
|
||||
.materialize_automation_run(AutomationRunMaterializeInput {
|
||||
automation_id: automation_id.clone(),
|
||||
target,
|
||||
workflow: automation.workflow.clone(),
|
||||
target: automation.target.clone(),
|
||||
run_id,
|
||||
user_settings_path: state.active_config_path().to_path_buf(),
|
||||
temp_root: state.automation_temp_root(),
|
||||
|
|
@ -251,7 +243,7 @@ async fn fire_scheduled_automation_run(
|
|||
Err(err) => {
|
||||
error!(
|
||||
due_at = %due_at,
|
||||
error = ?err,
|
||||
error = %err,
|
||||
"Failed to materialize scheduled automation run",
|
||||
);
|
||||
return;
|
||||
|
|
@ -279,7 +271,6 @@ async fn fire_scheduled_automation_run(
|
|||
actor: actor.clone(),
|
||||
headers: HeaderMap::new(),
|
||||
automation: Some(automation_ref),
|
||||
target: Some(RunTarget::Git(materialized.target)),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
|
@ -344,10 +335,10 @@ fn run_due_schedules_once<'a>(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_api::types::RunManifest;
|
||||
use fabro_automation::{AutomationDraft, AutomationTrigger, ScheduleTrigger};
|
||||
use fabro_automation::{AutomationDraft, AutomationTarget, AutomationTrigger, ScheduleTrigger};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::ListRunsQuery;
|
||||
use fabro_types::{GitRunTarget, RunStatus};
|
||||
use fabro_types::RunStatus;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -359,19 +350,14 @@ mod tests {
|
|||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn git_target() -> GitRunTarget {
|
||||
GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
fn target() -> AutomationTarget {
|
||||
AutomationTarget {
|
||||
repository: "fabro-sh/fabro".to_string(),
|
||||
ref_selector: "main".to_string(),
|
||||
workflow: "workflow.fabro".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn target() -> RunTarget {
|
||||
RunTarget::Git(git_target())
|
||||
}
|
||||
|
||||
fn schedule_trigger(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
|
||||
AutomationTrigger::Schedule(ScheduleTrigger {
|
||||
id: AutomationTriggerId::new(id).expect("test trigger id should be valid"),
|
||||
|
|
@ -387,7 +373,6 @@ mod tests {
|
|||
name: name.to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "workflow.fabro".to_string(),
|
||||
triggers,
|
||||
}
|
||||
}
|
||||
|
|
@ -405,7 +390,6 @@ mod tests {
|
|||
name: name.to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "workflow.fabro".to_string(),
|
||||
triggers,
|
||||
})
|
||||
.await
|
||||
|
|
@ -438,9 +422,7 @@ mod tests {
|
|||
let manifest = minimal_manifest();
|
||||
let submitted_manifest_bytes =
|
||||
serde_json::to_vec(&manifest).expect("manifest should serialize");
|
||||
let mut exact_target = git_target();
|
||||
exact_target.sha = Some("0123456789abcdef0123456789abcdef01234567".to_string());
|
||||
TestAutomationRunMaterializer::succeed(manifest, submitted_manifest_bytes, exact_target)
|
||||
TestAutomationRunMaterializer::succeed(manifest, submitted_manifest_bytes)
|
||||
}
|
||||
|
||||
fn test_state_with_materializer(materializer: TestAutomationRunMaterializer) -> Arc<AppState> {
|
||||
|
|
@ -714,7 +696,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn failing_materializer_waits_until_next_cron_occurrence() {
|
||||
let materializer = TestAutomationRunMaterializer::fail_invalid_target();
|
||||
let materializer = TestAutomationRunMaterializer::fail_invalid_target("boom");
|
||||
let state = test_state_with_materializer(materializer.clone());
|
||||
create_automation(state.as_ref(), "nightly", "Nightly", vec![
|
||||
schedule_trigger("schedule", "* * * * *", true),
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ use fabro_automation::{
|
|||
Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationStoreError,
|
||||
};
|
||||
use fabro_store::{RunSummaryListQuery, RunSummaryVisibility};
|
||||
use fabro_types::{AutomationRef, RunId, RunTarget};
|
||||
use fabro_util::error as error_util;
|
||||
use fabro_types::{AutomationRef, RunId};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::super::{
|
||||
|
|
@ -117,20 +116,12 @@ async fn create_automation_run(
|
|||
.into_response();
|
||||
};
|
||||
let api_trigger_id = api_trigger.id.to_string();
|
||||
let Some(target) = automation.git_target().cloned() else {
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Stored automation target is not Git-backed",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let run_id = RunId::new();
|
||||
let materialized = match state
|
||||
.materialize_automation_run(AutomationRunMaterializeInput {
|
||||
automation_id: automation.id.clone(),
|
||||
target,
|
||||
workflow: automation.workflow.clone(),
|
||||
target: automation.target.clone(),
|
||||
run_id,
|
||||
user_settings_path: state.active_config_path().to_path_buf(),
|
||||
temp_root: state.automation_temp_root(),
|
||||
|
|
@ -139,8 +130,8 @@ async fn create_automation_run(
|
|||
{
|
||||
Ok(materialized) => materialized,
|
||||
Err(err) => {
|
||||
let message = error_util::collect_chain(&err).join(": ");
|
||||
return ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, message).into_response();
|
||||
return ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let explicit_title_supplied = materialized.manifest.title.is_some();
|
||||
|
|
@ -160,7 +151,6 @@ async fn create_automation_run(
|
|||
actor: actor.clone(),
|
||||
headers,
|
||||
automation: Some(automation_ref),
|
||||
target: Some(RunTarget::Git(materialized.target)),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -809,12 +809,12 @@ async fn batch_archive_runs(
|
|||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<BatchRunLifecycleRequest>,
|
||||
) -> Response {
|
||||
Box::pin(batch_run_archive_action(
|
||||
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 {
|
||||
Box::pin(batch_run_archive_action(
|
||||
batch_run_archive_action(
|
||||
state,
|
||||
Principal::User(user),
|
||||
request,
|
||||
ArchiveAction::Unarchive,
|
||||
))
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||
use fabro_auth::ApiCredential;
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
|
||||
use fabro_model::{ModelSelectionError, ReasoningEffort};
|
||||
use fabro_model::ModelSelectionError;
|
||||
use fabro_redact::redact_string;
|
||||
|
||||
use super::super::{
|
||||
|
|
@ -41,11 +41,9 @@ struct ModelListParams {
|
|||
#[derive(serde::Deserialize)]
|
||||
struct ModelTestParams {
|
||||
#[serde(default)]
|
||||
mode: Option<String>,
|
||||
mode: Option<String>,
|
||||
#[serde(default)]
|
||||
provider: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning_effort: Option<String>,
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_models(
|
||||
|
|
@ -190,32 +188,24 @@ async fn test_providers(_auth: RequiredUser, State(state): State<Arc<AppState>>)
|
|||
}
|
||||
}
|
||||
|
||||
fn parse_query_enum<T: FromStr>(value: Option<&str>, label: &str) -> Result<Option<T>, ApiError> {
|
||||
value
|
||||
.map(|value| {
|
||||
T::from_str(value).map_err(|_| {
|
||||
ApiError::new(StatusCode::BAD_REQUEST, format!("invalid {label}: {value}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn test_model(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<ModelTestParams>,
|
||||
) -> Response {
|
||||
let mode = match parse_query_enum(params.mode.as_deref(), "model test mode") {
|
||||
Ok(mode) => mode.unwrap_or(ModelTestMode::Basic),
|
||||
Err(error) => return error.into_response(),
|
||||
};
|
||||
let reasoning_effort = match parse_query_enum::<ReasoningEffort>(
|
||||
params.reasoning_effort.as_deref(),
|
||||
"reasoning effort",
|
||||
) {
|
||||
Ok(reasoning_effort) => reasoning_effort,
|
||||
Err(error) => return error.into_response(),
|
||||
let mode = match params.mode.as_deref() {
|
||||
Some(value) => match ModelTestMode::from_str(value) {
|
||||
Ok(mode) => mode,
|
||||
Err(_) => {
|
||||
return ApiError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid model test mode: {value}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
},
|
||||
None => ModelTestMode::Basic,
|
||||
};
|
||||
let llm_result = match state.resolve_llm_client().await {
|
||||
Ok(result) => result,
|
||||
|
|
@ -263,7 +253,7 @@ async fn test_model(
|
|||
}
|
||||
let client = Arc::new(llm_result.client);
|
||||
|
||||
let outcome = run_model_test(info, mode, reasoning_effort, client).await;
|
||||
let outcome = run_model_test(info, mode, client).await;
|
||||
Json(serde_json::json!({
|
||||
"model_id": info.id,
|
||||
"provider": info.provider,
|
||||
|
|
|
|||
|
|
@ -28,10 +28,9 @@ use fabro_store::{
|
|||
};
|
||||
use fabro_types::{
|
||||
AutomationRef, ManifestPath, Principal, Run, RunClientProvenance, RunId, RunProvenance,
|
||||
RunServerProvenance, RunStatusKind, RunTarget, SandboxProviderKind, StageContextWindow,
|
||||
RunServerProvenance, RunStatusKind, SandboxProviderKind, StageContextWindow,
|
||||
StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler,
|
||||
StageModelUsage, StageProjection, SystemActorKind, ValidatedRunTarget,
|
||||
json_scalar_to_toml_value, parse_blob_ref,
|
||||
StageModelUsage, StageProjection, SystemActorKind, json_scalar_to_toml_value, parse_blob_ref,
|
||||
};
|
||||
use fabro_util::error as error_util;
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
|
|
@ -60,8 +59,8 @@ use crate::run_compiler::{
|
|||
};
|
||||
use crate::run_files::{list_run_commits, list_run_files};
|
||||
use crate::run_intent::{
|
||||
EnvironmentSelectionError, PreparedIntentTarget, RunIntentAdmissionError,
|
||||
lower_workflow_closure, pin_workflow_environment_authority, prepare_intent_target,
|
||||
EnvironmentSelectionError, RunIntentAdmissionError, lower_workflow_closure,
|
||||
pin_workflow_environment_authority,
|
||||
};
|
||||
use crate::run_manifest;
|
||||
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
|
||||
|
|
@ -562,7 +561,6 @@ async fn create_run(
|
|||
actor,
|
||||
headers,
|
||||
automation: None,
|
||||
target: None,
|
||||
},
|
||||
))
|
||||
.await
|
||||
|
|
@ -612,8 +610,8 @@ async fn create_run_from_intent(
|
|||
) -> Response {
|
||||
// Validate the pure, in-memory request facts before paying for
|
||||
// blob-store reads and closure lowering.
|
||||
let ValidatedRunTarget { target, git } = match intent.target.validate() {
|
||||
Ok(validated) => validated,
|
||||
let validated_target = match intent.target.validate() {
|
||||
Ok(target) => target,
|
||||
Err(error) => return run_intent_admission_error(error.into()),
|
||||
};
|
||||
let environment_id = match select_intent_environment_id(
|
||||
|
|
@ -698,9 +696,6 @@ 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(),
|
||||
|
|
@ -716,13 +711,11 @@ async fn create_run_from_intent(
|
|||
run_id: None,
|
||||
title,
|
||||
parent_id: intent.parent_id,
|
||||
// Target identity and its Git projection are attached after provider
|
||||
// admission via `with_target_and_git`; the compiler never reads them.
|
||||
git: None,
|
||||
git: Some(validated_target.git),
|
||||
storage_root: state.server_storage_dir(),
|
||||
workflow_slug: None,
|
||||
workflow_version_id: Some(intent.workflow_version_id),
|
||||
target: None,
|
||||
target: Some(validated_target.target),
|
||||
provenance: run_provenance(&headers, &actor),
|
||||
web_url: None,
|
||||
submitted_manifest_bytes: None,
|
||||
|
|
@ -744,18 +737,13 @@ async fn create_run_from_intent(
|
|||
});
|
||||
}
|
||||
};
|
||||
let mut prepared = match run_compiler::apply_run_variables(layered, vars) {
|
||||
let 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 {
|
||||
if let Err(error) = validate_intent_environment(&state, prepared.settings()).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;
|
||||
|
|
@ -989,9 +977,7 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
|
|||
"Run intent admission rejected"
|
||||
);
|
||||
}
|
||||
RunIntentAdmissionError::Target(_)
|
||||
| RunIntentAdmissionError::FolderTarget(_)
|
||||
| RunIntentAdmissionError::Environment(_) => {}
|
||||
RunIntentAdmissionError::Target(_) | RunIntentAdmissionError::Environment(_) => {}
|
||||
}
|
||||
|
||||
match error {
|
||||
|
|
@ -1010,11 +996,6 @@ 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,
|
||||
|
|
@ -1026,7 +1007,7 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
|
|||
error.to_string(),
|
||||
"environment_not_found",
|
||||
),
|
||||
EnvironmentSelectionError::TargetUnsupported { .. } => intent_error(
|
||||
EnvironmentSelectionError::TargetUnsupported => intent_error(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
error.to_string(),
|
||||
"target_environment_unsupported",
|
||||
|
|
@ -1082,31 +1063,16 @@ fn select_intent_environment_id(
|
|||
async fn validate_intent_environment(
|
||||
state: &AppState,
|
||||
settings: &fabro_types::WorkflowSettings,
|
||||
target: &RunTarget,
|
||||
) -> Result<(), EnvironmentSelectionError> {
|
||||
let provider = run_manifest::effective_sandbox_provider(&settings.run);
|
||||
let image = &settings.run.environment.image;
|
||||
let image_incompatible = match provider {
|
||||
SandboxProviderKind::Local => false,
|
||||
let incompatible = match provider {
|
||||
SandboxProviderKind::Local => true,
|
||||
SandboxProviderKind::Docker => image.docker.is_none() && image.dockerfile.is_some(),
|
||||
SandboxProviderKind::Daytona => image.docker.is_some(),
|
||||
};
|
||||
let (target_incompatible, detail) = match target {
|
||||
RunTarget::Git(_) => (
|
||||
provider == SandboxProviderKind::Local || !settings.run.clone.enabled,
|
||||
"Git targets require a compatible clone-enabled Docker or Daytona environment",
|
||||
),
|
||||
RunTarget::None {} => (
|
||||
provider == SandboxProviderKind::Local,
|
||||
"none targets require a compatible Docker or Daytona environment",
|
||||
),
|
||||
RunTarget::Folder { .. } => (
|
||||
provider != SandboxProviderKind::Local,
|
||||
"folder targets require a Local environment",
|
||||
),
|
||||
};
|
||||
if image_incompatible || target_incompatible {
|
||||
return Err(EnvironmentSelectionError::TargetUnsupported { detail });
|
||||
if incompatible || !settings.run.clone.enabled {
|
||||
return Err(EnvironmentSelectionError::TargetUnsupported);
|
||||
}
|
||||
if let Some(detail) =
|
||||
run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
|
||||
|
|
@ -1143,9 +1109,6 @@ pub(crate) struct CreateRunFromManifestRequest {
|
|||
pub(crate) actor: Principal,
|
||||
pub(crate) headers: HeaderMap,
|
||||
pub(crate) automation: Option<AutomationRef>,
|
||||
/// Trusted canonical target supplied by an internal manifest producer.
|
||||
/// Public legacy manifest requests always leave this absent.
|
||||
pub(crate) target: Option<RunTarget>,
|
||||
}
|
||||
|
||||
struct ManifestRunCompilerAdapter {
|
||||
|
|
@ -1291,7 +1254,6 @@ pub(crate) async fn create_run_from_manifest(
|
|||
actor,
|
||||
headers,
|
||||
automation,
|
||||
target,
|
||||
} = request;
|
||||
let manifest_run_defaults = state.manifest_run_defaults();
|
||||
let manifest_environment_defaults = state.environment_store().catalog_layer();
|
||||
|
|
@ -1323,7 +1285,7 @@ pub(crate) async fn create_run_from_manifest(
|
|||
storage_root: state.server_storage_dir(),
|
||||
workflow_slug: None,
|
||||
workflow_version_id: None,
|
||||
target,
|
||||
target: None,
|
||||
provenance: run_provenance(&headers, &actor),
|
||||
web_url: None,
|
||||
submitted_manifest_bytes: Some(submitted_manifest_bytes),
|
||||
|
|
@ -1410,7 +1372,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::warn!(run_id = %task.run_id, error = %err, "Failed to open run store for title update");
|
||||
tracing::debug!(run_id = %task.run_id, error = %err, "Failed to open run store for title update");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
|
@ -1428,7 +1390,7 @@ fn spawn_generated_title_task(task: GeneratedTitleTask) {
|
|||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(run_id = %task.run_id, error = %err, "Failed to append generated run title event");
|
||||
tracing::debug!(run_id = %task.run_id, error = %err, "Failed to append generated run title event");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1169,10 +1169,7 @@ async fn drive_agent_session(
|
|||
result = &mut process => {
|
||||
while let Ok(event) = receiver.try_recv() {
|
||||
record_turn_output(output, &event);
|
||||
Box::pin(persist_agent_event(
|
||||
run_store, run_id, session_id, turn_id, event, sender,
|
||||
))
|
||||
.await?;
|
||||
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
|
@ -1180,10 +1177,7 @@ async fn drive_agent_session(
|
|||
match event {
|
||||
Ok(event) => {
|
||||
record_turn_output(output, &event);
|
||||
Box::pin(persist_agent_event(
|
||||
run_store, run_id, session_id, turn_id, event, sender,
|
||||
))
|
||||
.await?;
|
||||
persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?;
|
||||
}
|
||||
Err(RecvError::Lagged(_) | RecvError::Closed) => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use async_zip::base::read::mem::ZipFileReader;
|
|||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, header};
|
||||
use chrono::{Duration as ChronoDuration, SubsecRound as _, Utc};
|
||||
use fabro_automation::AutomationId;
|
||||
use fabro_automation::{AutomationId, AutomationTarget};
|
||||
use fabro_config::bind::Bind;
|
||||
use fabro_config::{
|
||||
EnvironmentLayer, MergeMap, RunLayer, ServerSettingsBuilder, WorkflowSettingsBuilder,
|
||||
|
|
@ -27,8 +27,8 @@ use fabro_types::settings::ServerAuthMethod;
|
|||
use fabro_types::settings::run::EnvironmentProvider;
|
||||
use fabro_types::{
|
||||
AgentBackend, AttrValue, AuthMethod, BlobHash, CommandTermination, FailureCategory,
|
||||
FailureDetail, GitRunTarget, Graph, InterviewQuestionRecord, Node, Outcome, ParallelBranchId,
|
||||
QuestionType, RunId, RunSpec, RunTarget, SandboxProviderKind, StageContextWindowBreakdownItem,
|
||||
FailureDetail, Graph, InterviewQuestionRecord, Node, Outcome, ParallelBranchId, QuestionType,
|
||||
RunId, RunSpec, SandboxProviderKind, StageContextWindowBreakdownItem,
|
||||
StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
|
||||
StageContextWindowStaleness, StageContextWindowWarning, StageModelUsage, StageTiming,
|
||||
SuccessReason, SystemActorKind, WorkflowSettings, fixtures, test_support,
|
||||
|
|
@ -3529,35 +3529,19 @@ 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 = 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))
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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": {}
|
||||
})
|
||||
.unwrap();
|
||||
response_json!(response, StatusCode::CREATED).await
|
||||
}
|
||||
|
||||
async fn store_workflow_version(
|
||||
|
|
@ -3598,7 +3582,7 @@ async fn store_workflow_version(
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_persists_tagged_exact_git_target_without_starting() {
|
||||
async fn post_runs_run_intent_creates_submitted_version_backed_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(
|
||||
|
|
@ -3638,7 +3622,6 @@ docker = "workflow-owned:latest"
|
|||
"kind": "git",
|
||||
"repo": "fabro-sh/fabro",
|
||||
"branch": "feature/run-intent",
|
||||
"tag": "v1.2.3",
|
||||
"sha": submitted_sha
|
||||
},
|
||||
"args": {
|
||||
|
|
@ -3672,12 +3655,11 @@ docker = "workflow-owned:latest"
|
|||
);
|
||||
assert_eq!(
|
||||
projection.spec.target,
|
||||
Some(fabro_types::RunTarget::Git(fabro_types::GitRunTarget {
|
||||
Some(fabro_types::RunTarget::Git {
|
||||
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
|
||||
|
|
@ -3735,307 +3717,6 @@ docker = "workflow-owned:latest"
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_creates_submitted_none_target_without_git_projection() {
|
||||
let state = test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let body = post_run_manifest(
|
||||
&app,
|
||||
json!({
|
||||
"workflow_version_id": workflow_version_id,
|
||||
"target": { "kind": "none" },
|
||||
"args": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
||||
|
||||
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
|
||||
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
|
||||
let events = run_store.list_events().await.unwrap();
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.map(|event| event.event.event_name())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["run.created", "run.submitted"]
|
||||
);
|
||||
let projection = run_store.state().await.unwrap();
|
||||
assert_eq!(
|
||||
projection.spec.target,
|
||||
Some(fabro_types::RunTarget::None {})
|
||||
);
|
||||
assert_eq!(
|
||||
projection.spec.workflow_version_id,
|
||||
Some(workflow_version_id)
|
||||
);
|
||||
assert_eq!(projection.spec.source_directory, None);
|
||||
assert_eq!(projection.spec.git, None);
|
||||
assert!(projection.spec.settings.run.clone.enabled);
|
||||
assert_eq!(projection.spec.manifest_blob, None);
|
||||
assert!(projection.spec.definition_blob.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_canonicalizes_and_persists_a_local_folder_target() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("workspace");
|
||||
let hop = dir.path().join("hop");
|
||||
std::fs::create_dir(&workspace).unwrap();
|
||||
std::fs::create_dir(&hop).unwrap();
|
||||
// Target-project files are not compiler inputs for a version-backed run.
|
||||
std::fs::write(workspace.join("workflow.toml"), "not valid TOML").unwrap();
|
||||
std::fs::write(workspace.join("goal.md"), "Goal from target folder").unwrap();
|
||||
let submitted = hop.join("..").join("workspace");
|
||||
let canonical = workspace
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let state = local_test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(
|
||||
&state,
|
||||
MINIMAL_DOT,
|
||||
Some("_version = 1\n[run.goal]\nfile = \"goal.md\"\n"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let body = post_run_manifest(
|
||||
&app,
|
||||
folder_intent(workflow_version_id, submitted.to_string_lossy()),
|
||||
)
|
||||
.await;
|
||||
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
||||
|
||||
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
|
||||
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
|
||||
let events = run_store.list_events().await.unwrap();
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.map(|event| event.event.event_name())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["run.created", "run.submitted"]
|
||||
);
|
||||
let projection = run_store.state().await.unwrap();
|
||||
assert_eq!(
|
||||
projection.spec.target,
|
||||
Some(fabro_types::RunTarget::Folder {
|
||||
path: canonical.clone(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
projection.spec.source_directory.as_deref(),
|
||||
Some(canonical.as_str())
|
||||
);
|
||||
assert_eq!(projection.spec.git, None);
|
||||
assert_eq!(
|
||||
projection.spec.graph.goal(),
|
||||
"Goal loaded from immutable version bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
projection.spec.settings.run.environment.provider,
|
||||
EnvironmentProvider::Local
|
||||
);
|
||||
assert_eq!(projection.spec.manifest_blob, None);
|
||||
assert!(projection.spec.definition_blob.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_observes_folder_git_metadata_without_a_remote_call() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||
let mut index = repo.index().unwrap();
|
||||
let tree_id = index.write_tree().unwrap();
|
||||
drop(index);
|
||||
let tree = repo.find_tree(tree_id).unwrap();
|
||||
let signature = git2::Signature::now("Fabro Test", "fabro@example.com").unwrap();
|
||||
let commit = repo
|
||||
.commit(Some("HEAD"), &signature, &signature, "initial", &tree, &[])
|
||||
.unwrap();
|
||||
let commit = commit.to_string();
|
||||
drop(tree);
|
||||
repo.remote("origin", "https://github.com/acme/widgets.git")
|
||||
.unwrap();
|
||||
drop(repo);
|
||||
let canonical = dir
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let state = local_test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
|
||||
let body = post_run_manifest(&app, folder_intent(workflow_version_id, canonical)).await;
|
||||
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
||||
let projection = state
|
||||
.stores
|
||||
.runs
|
||||
.open_run_reader(&run_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.state()
|
||||
.await
|
||||
.unwrap();
|
||||
let git = projection.spec.git.unwrap();
|
||||
|
||||
assert_eq!(git.origin_url, "https://github.com/acme/widgets");
|
||||
assert!(!git.branch.is_empty());
|
||||
assert_eq!(git.sha.as_deref(), Some(commit.as_str()));
|
||||
assert_eq!(git.dirty, fabro_types::DirtyStatus::Clean);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_rejects_invalid_folder_paths_before_persistence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("file");
|
||||
std::fs::write(&file, "not a directory").unwrap();
|
||||
let state = local_test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let invalid_paths = [
|
||||
String::new(),
|
||||
"relative/path".to_string(),
|
||||
dir.path().join("missing").to_string_lossy().to_string(),
|
||||
file.to_string_lossy().to_string(),
|
||||
];
|
||||
|
||||
for path in invalid_paths {
|
||||
let response =
|
||||
post_run_intent_response(&app, folder_intent(workflow_version_id, path)).await;
|
||||
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
assert_eq!(body["errors"][0]["code"], "target_invalid");
|
||||
}
|
||||
|
||||
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
|
||||
assert!(
|
||||
state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_applies_the_folder_target_environment_matrix() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// A missing path proves provider admission wins over filesystem
|
||||
// materialization. Touching the path first would return `target_invalid`
|
||||
// instead of the provider-specific errors asserted below.
|
||||
let target = dir.path().join("missing").to_string_lossy().to_string();
|
||||
|
||||
for state in [
|
||||
test_app_state(),
|
||||
TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Daytona))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build(),
|
||||
] {
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let response =
|
||||
post_run_intent_response(&app, folder_intent(workflow_version_id, &target)).await;
|
||||
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
assert_eq!(body["errors"][0]["code"], "target_environment_unsupported");
|
||||
assert!(
|
||||
state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
let disabled_state = TestAppStateBuilder::new()
|
||||
.runtime_settings(
|
||||
server_settings_from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth]
|
||||
methods = ["dev-token"]
|
||||
|
||||
[server.sandbox.providers.local]
|
||||
enabled = false
|
||||
"#,
|
||||
),
|
||||
RunLayer::default(),
|
||||
)
|
||||
.default_environment_provider(Some(EnvironmentProvider::Local))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&disabled_state));
|
||||
let workflow_version_id = store_workflow_version(&disabled_state, MINIMAL_DOT, None).await;
|
||||
let response =
|
||||
post_run_intent_response(&app, folder_intent(workflow_version_id, &target)).await;
|
||||
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
|
||||
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
|
||||
assert!(
|
||||
disabled_state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Daytona))
|
||||
.vault_entries([
|
||||
(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key"),
|
||||
(
|
||||
fabro_static::EnvVars::DAYTONA_API_KEY,
|
||||
"test-daytona-api-key",
|
||||
),
|
||||
])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let body = post_run_manifest(
|
||||
&app,
|
||||
json!({
|
||||
"workflow_version_id": workflow_version_id,
|
||||
"target": { "kind": "none" },
|
||||
"args": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
|
||||
|
||||
let projection = state
|
||||
.stores
|
||||
.runs
|
||||
.open_run_reader(&run_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.state()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
projection.spec.target,
|
||||
Some(fabro_types::RunTarget::None {})
|
||||
);
|
||||
assert_eq!(
|
||||
projection.spec.settings.run.environment.provider,
|
||||
EnvironmentProvider::Daytona
|
||||
);
|
||||
assert_eq!(projection.spec.source_directory, None);
|
||||
assert_eq!(projection.spec.git, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_dispatches_errors_without_changing_legacy_lane() {
|
||||
let state = test_app_state();
|
||||
|
|
@ -4194,89 +3875,6 @@ async fn post_runs_run_intent_maps_missing_version_environment_and_target_errors
|
|||
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_rejects_none_target_with_local_environment_before_persistence() {
|
||||
let state = local_test_app_state();
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workflow_version_id": workflow_version_id,
|
||||
"target": { "kind": "none" },
|
||||
"args": {}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
assert_eq!(body["errors"][0]["code"], "target_environment_unsupported");
|
||||
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
|
||||
assert!(
|
||||
state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// Posts a Git and a `none` run intent against `state` and asserts both are
|
||||
/// rejected as `integration_unavailable` without persisting anything.
|
||||
async fn assert_run_intent_targets_unavailable(state: &Arc<AppState>) {
|
||||
let version_id = store_workflow_version(state, MINIMAL_DOT, None).await;
|
||||
let app = crate::test_support::build_test_router(Arc::clone(state));
|
||||
for target in [
|
||||
json!({
|
||||
"kind": "git",
|
||||
"repo": "fabro-sh/fabro",
|
||||
"branch": "feature/run-intent"
|
||||
}),
|
||||
json!({ "kind": "none" }),
|
||||
] {
|
||||
let intent = json!({
|
||||
"workflow_version_id": version_id,
|
||||
"target": target,
|
||||
"args": {}
|
||||
});
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(intent.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
|
||||
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
|
||||
}
|
||||
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
|
||||
assert!(
|
||||
state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_rejects_disabled_or_unready_sandbox_integrations() {
|
||||
let disabled_state = test_app_state_with_options(
|
||||
|
|
@ -4294,13 +3892,73 @@ enabled = false
|
|||
RunLayer::default(),
|
||||
5,
|
||||
);
|
||||
assert_run_intent_targets_unavailable(&disabled_state).await;
|
||||
let disabled_version_id = store_workflow_version(&disabled_state, MINIMAL_DOT, None).await;
|
||||
let disabled_app = crate::test_support::build_test_router(Arc::clone(&disabled_state));
|
||||
let intent = json!({
|
||||
"workflow_version_id": disabled_version_id,
|
||||
"target": {
|
||||
"kind": "git",
|
||||
"repo": "fabro-sh/fabro",
|
||||
"branch": "feature/run-intent"
|
||||
},
|
||||
"args": {}
|
||||
});
|
||||
let response = disabled_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(intent.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
|
||||
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
|
||||
assert!(
|
||||
disabled_state
|
||||
.runs
|
||||
.lock()
|
||||
.expect("runs lock poisoned")
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let daytona_state = TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Daytona))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
assert_run_intent_targets_unavailable(&daytona_state).await;
|
||||
let daytona_version_id = store_workflow_version(&daytona_state, MINIMAL_DOT, None).await;
|
||||
let daytona_app = crate::test_support::build_test_router(Arc::clone(&daytona_state));
|
||||
let intent = json!({
|
||||
"workflow_version_id": daytona_version_id,
|
||||
"target": {
|
||||
"kind": "git",
|
||||
"repo": "fabro-sh/fabro",
|
||||
"branch": "feature/run-intent"
|
||||
},
|
||||
"args": {}
|
||||
});
|
||||
let response = daytona_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(intent.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
|
||||
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
|
||||
assert!(
|
||||
daytona_state
|
||||
.runs
|
||||
.lock()
|
||||
.expect("runs lock poisoned")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -4422,7 +4080,6 @@ async fn create_run_from_manifest_helper_persists_without_automation_metadata()
|
|||
},
|
||||
headers: HeaderMap::new(),
|
||||
automation: None,
|
||||
target: None,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
|
@ -4438,12 +4095,10 @@ async fn create_run_from_manifest_helper_persists_without_automation_metadata()
|
|||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(summary.automation.is_none());
|
||||
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
|
||||
assert!(run_store.state().await.unwrap().spec.target.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_run_from_manifest_helper_persists_automation_metadata_and_exact_target() {
|
||||
async fn create_run_from_manifest_helper_persists_automation_metadata() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
.env_lookup(|_| None)
|
||||
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
|
|
@ -4456,12 +4111,6 @@ async fn create_run_from_manifest_helper_persists_automation_metadata_and_exact_
|
|||
name: Some("Nightly".to_string()),
|
||||
trigger_id: Some("schedule".to_string()),
|
||||
};
|
||||
let target = RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
});
|
||||
|
||||
let response = Box::pin(handler::runs::create_run_from_manifest(
|
||||
Arc::clone(&state),
|
||||
|
|
@ -4475,7 +4124,6 @@ async fn create_run_from_manifest_helper_persists_automation_metadata_and_exact_
|
|||
},
|
||||
headers: HeaderMap::new(),
|
||||
automation: Some(automation.clone()),
|
||||
target: Some(target.clone()),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
|
@ -4498,8 +4146,6 @@ async fn create_run_from_manifest_helper_persists_automation_metadata_and_exact_
|
|||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(summary.automation, Some(automation));
|
||||
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
|
||||
assert_eq!(run_store.state().await.unwrap().spec.target, Some(target));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -4577,7 +4223,6 @@ layer = "project"
|
|||
},
|
||||
headers,
|
||||
automation: None,
|
||||
target: None,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
|
@ -4598,10 +4243,6 @@ layer = "project"
|
|||
);
|
||||
let run_state = run_store.state().await.unwrap();
|
||||
let spec = &run_state.spec;
|
||||
assert!(
|
||||
spec.target.is_none(),
|
||||
"legacy manifest GitContext must not become canonical target authority"
|
||||
);
|
||||
assert_eq!(spec.run_id, run_id);
|
||||
assert_eq!(spec.graph.goal(), "Inline release goal");
|
||||
assert_eq!(
|
||||
|
|
@ -4740,7 +4381,6 @@ async fn create_run_from_manifest_pins_compiler_http_error_mappings() {
|
|||
},
|
||||
headers: HeaderMap::new(),
|
||||
automation: None,
|
||||
target: None,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
|
@ -4795,7 +4435,6 @@ async fn create_run_from_manifest_preserves_competing_preparation_error_preceden
|
|||
},
|
||||
headers: HeaderMap::new(),
|
||||
automation: None,
|
||||
target: None,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
|
@ -4841,7 +4480,6 @@ async fn create_run_from_manifest_resolves_generated_id_after_variable_snapshot(
|
|||
},
|
||||
headers: HeaderMap::new(),
|
||||
automation: None,
|
||||
target: None,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
|
@ -4860,12 +4498,6 @@ async fn fake_automation_materializer_injection_captures_input_and_returns_manif
|
|||
let fake = TestAutomationRunMaterializer::succeed(
|
||||
materialized_manifest.clone(),
|
||||
b"{\"fake\":true}".to_vec(),
|
||||
GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
},
|
||||
);
|
||||
let state = TestAppStateBuilder::new()
|
||||
.automation_materializer(fake.clone())
|
||||
|
|
@ -4873,18 +4505,16 @@ async fn fake_automation_materializer_injection_captures_input_and_returns_manif
|
|||
let run_id = RunId::new();
|
||||
let user_settings_path = PathBuf::from("/tmp/fabro/settings.toml");
|
||||
let temp_root = PathBuf::from("/tmp/fabro/automation");
|
||||
let target = GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
let target = AutomationTarget {
|
||||
repository: "fabro-sh/fabro".to_string(),
|
||||
ref_selector: "main".to_string(),
|
||||
workflow: "demo".to_string(),
|
||||
};
|
||||
|
||||
let output = state
|
||||
.materialize_automation_run(AutomationRunMaterializeInput {
|
||||
automation_id: AutomationId::new("nightly").unwrap(),
|
||||
target: target.clone(),
|
||||
workflow: "demo".to_string(),
|
||||
run_id,
|
||||
user_settings_path: user_settings_path.clone(),
|
||||
temp_root: temp_root.clone(),
|
||||
|
|
@ -4901,7 +4531,6 @@ async fn fake_automation_materializer_injection_captures_input_and_returns_manif
|
|||
assert_eq!(captured.len(), 1);
|
||||
assert_eq!(captured[0].automation_id.as_str(), "nightly");
|
||||
assert_eq!(captured[0].target, target);
|
||||
assert_eq!(captured[0].workflow, "demo");
|
||||
assert_eq!(captured[0].run_id, run_id);
|
||||
assert_eq!(captured[0].user_settings_path, user_settings_path);
|
||||
assert_eq!(captured[0].temp_root, temp_root);
|
||||
|
|
@ -8230,117 +7859,6 @@ async fn test_model_invalid_mode_returns_400() {
|
|||
assert_status!(response, StatusCode::BAD_REQUEST).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_invalid_reasoning_effort_returns_400() {
|
||||
let state = test_app_state_with_env_lookup(
|
||||
default_test_server_settings(),
|
||||
RunLayer::default(),
|
||||
5,
|
||||
|_| None,
|
||||
);
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/models/claude-opus-4-6/test?reasoning_effort=bogus"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_status!(response, StatusCode::BAD_REQUEST).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_forwards_and_validates_reasoning_effort() {
|
||||
let upstream = MockServer::start();
|
||||
let completion = upstream.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/chat/completions")
|
||||
.json_body_includes(r#"{"model":"acme-reasoner","reasoning_effort":"low"}"#);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(json!({
|
||||
"id": "chatcmpl-test",
|
||||
"model": "acme-reasoner",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "OK"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2
|
||||
}
|
||||
}));
|
||||
});
|
||||
let settings: LlmCatalogSettings = toml::from_str(&format!(
|
||||
r#"
|
||||
[providers.acme]
|
||||
display_name = "Acme"
|
||||
adapter = "openai_compatible"
|
||||
agent_profile = "openai"
|
||||
base_url = "{}"
|
||||
priority = 120
|
||||
|
||||
[providers.acme.auth]
|
||||
credentials = ["vault:ACME_API_KEY"]
|
||||
|
||||
[providers.acme.models.acme-reasoner]
|
||||
display_name = "Acme Reasoner"
|
||||
family = "acme"
|
||||
default = true
|
||||
|
||||
[providers.acme.models.acme-reasoner.limits]
|
||||
context_window = 128000
|
||||
|
||||
[providers.acme.models.acme-reasoner.features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = true
|
||||
reasoning_effort = "levels"
|
||||
|
||||
[providers.acme.models.acme-reasoner.controls]
|
||||
reasoning_effort = ["low", "high"]
|
||||
"#,
|
||||
upstream.base_url()
|
||||
))
|
||||
.expect("catalog fixture should parse");
|
||||
let state = TestAppStateBuilder::new()
|
||||
.llm_catalog_settings(settings)
|
||||
.vault_entries([("ACME_API_KEY", "acme-test-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(state);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(
|
||||
"/models/acme-reasoner/test?provider=acme&reasoning_effort=low",
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
assert_eq!(body["status"], "ok");
|
||||
|
||||
let unsupported = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(
|
||||
"/models/acme-reasoner/test?provider=acme&reasoning_effort=medium",
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(unsupported).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
assert_eq!(body["status"], "error");
|
||||
assert_eq!(
|
||||
body["error_message"],
|
||||
"Invalid request: model 'acme-reasoner' does not support reasoning_effort 'medium'; allowed values: low, high"
|
||||
);
|
||||
completion.assert_calls(1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_provider_credentials_uses_app_state_catalog() {
|
||||
let upstream = MockServer::start();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use fabro_server::test_support::{
|
|||
TestAppStateBuilder, TestAutomationRunMaterializer, build_test_router, test_auth_mode,
|
||||
};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::GitRunTarget;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Row as _;
|
||||
use tower::ServiceExt;
|
||||
|
|
@ -24,11 +23,10 @@ fn automation_body(id: &str, name: &str) -> Value {
|
|||
"name": name,
|
||||
"description": "Runs on a schedule.",
|
||||
"target": {
|
||||
"kind": "git",
|
||||
"repo": "fabro-sh/fabro",
|
||||
"branch": "main"
|
||||
"repository": "fabro-sh/fabro",
|
||||
"ref": "main",
|
||||
"workflow": "release"
|
||||
},
|
||||
"workflow": "release",
|
||||
"triggers": [
|
||||
{
|
||||
"type": "api",
|
||||
|
|
@ -50,11 +48,10 @@ fn replacement_body(name: &str) -> Value {
|
|||
"name": name,
|
||||
"description": null,
|
||||
"target": {
|
||||
"kind": "git",
|
||||
"repo": "fabro-sh/fabro",
|
||||
"branch": "main"
|
||||
"repository": "fabro-sh/fabro",
|
||||
"ref": "main",
|
||||
"workflow": "release"
|
||||
},
|
||||
"workflow": "release",
|
||||
"triggers": [
|
||||
{
|
||||
"type": "api",
|
||||
|
|
@ -94,12 +91,6 @@ fn automation_app_with_fake_materializer() -> (axum::Router, tempfile::TempDir,
|
|||
.automation_materializer(TestAutomationRunMaterializer::succeed(
|
||||
materialized_manifest,
|
||||
submitted_manifest_bytes,
|
||||
GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
},
|
||||
))
|
||||
.build();
|
||||
(build_test_router(state), temp_dir, sqlite_path)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
|
|||
use fabro_server::server::{AppState, RouterOptions, build_router_with_options};
|
||||
use fabro_server::test_support::test_app_state_with_store_and_runtime_settings;
|
||||
use fabro_store::auth_session_store::{AuthSessionRecord, InitialRefreshToken};
|
||||
use fabro_store::{ArtifactStore, PendingCliAuthorization};
|
||||
use fabro_store::{ArtifactStore, AuthCode, Database};
|
||||
use object_store::memory::InMemory;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tower::ServiceExt;
|
||||
|
|
@ -16,7 +16,7 @@ use uuid::Uuid;
|
|||
|
||||
use crate::helpers::{body_json, settings_from_toml};
|
||||
|
||||
fn test_app(source: &str) -> (axum::Router, Arc<AppState>) {
|
||||
fn test_app(source: &str) -> (axum::Router, Arc<Database>, Arc<AppState>) {
|
||||
let settings = settings_from_toml(source);
|
||||
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
|
||||
let store = Arc::new(fabro_store::test_support::test_database(
|
||||
|
|
@ -41,7 +41,7 @@ fn test_app(source: &str) -> (axum::Router, Arc<AppState>) {
|
|||
artifact_store,
|
||||
);
|
||||
let app = build_router_with_options(Arc::clone(&state), &auth_mode, RouterOptions::default());
|
||||
(app, state)
|
||||
(app, store, state)
|
||||
}
|
||||
|
||||
fn pkce_challenge(verifier: &str) -> String {
|
||||
|
|
@ -54,7 +54,7 @@ fn hash_refresh_secret(secret: &str) -> [u8; 32] {
|
|||
|
||||
#[tokio::test]
|
||||
async fn cli_auth_token_exchanges_code_over_public_router() {
|
||||
let (app, state) = test_app(
|
||||
let (app, store, _state) = test_app(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
|
|
@ -71,9 +71,10 @@ url = "https://fabro.example"
|
|||
client_id = "Iv1.test"
|
||||
"#,
|
||||
);
|
||||
state
|
||||
.test_auth_code_store()
|
||||
.issue("integration-code", &PendingCliAuthorization {
|
||||
let auth_codes = store.auth_codes().await.unwrap();
|
||||
auth_codes
|
||||
.insert(AuthCode {
|
||||
code: "integration-code".to_string(),
|
||||
identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
|
||||
login: "octocat".to_string(),
|
||||
name: "The Octocat".to_string(),
|
||||
|
|
@ -121,7 +122,7 @@ client_id = "Iv1.test"
|
|||
|
||||
#[tokio::test]
|
||||
async fn cli_auth_refresh_replay_revokes_chain_over_public_router() {
|
||||
let (app, state) = test_app(
|
||||
let (app, _store, state) = test_app(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ async fn shell_reports_real_docker_process_outcome() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,15 +6,10 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use chrono::Utc;
|
||||
use fabro_db::{DbPool, ImportReport};
|
||||
use fabro_types::{GitRunTarget, RunTarget, repository};
|
||||
use serde::Deserialize;
|
||||
use tokio::fs;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
Automation, AutomationId, AutomationReplace, AutomationRevision, AutomationStoreError,
|
||||
AutomationTrigger, store,
|
||||
};
|
||||
use crate::{Automation, AutomationId, AutomationStoreError, store};
|
||||
|
||||
pub(crate) const REMOVAL_DEADLINE: &str = "2026-10-11";
|
||||
|
||||
|
|
@ -31,7 +26,7 @@ pub async fn import_legacy_directory_once(
|
|||
let bytes = fs::read(&path)
|
||||
.await
|
||||
.map_err(|source| AutomationStoreError::io(&path, source))?;
|
||||
automations.push(parse_legacy_automation(id, &bytes, &path)?);
|
||||
automations.push(Automation::from_persisted_path(id, &bytes, path)?);
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
|
@ -66,88 +61,6 @@ pub async fn import_legacy_directory_once(
|
|||
Ok(Some(report))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LegacyPersistedAutomation {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
target: LegacyAutomationTarget,
|
||||
#[serde(default)]
|
||||
triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LegacyAutomationTarget {
|
||||
repository: String,
|
||||
#[serde(rename = "ref")]
|
||||
selector: String,
|
||||
workflow: String,
|
||||
}
|
||||
|
||||
fn parse_legacy_automation(
|
||||
id: AutomationId,
|
||||
bytes: &[u8],
|
||||
path: &Path,
|
||||
) -> Result<Automation, AutomationStoreError> {
|
||||
let revision = AutomationRevision::from_bytes(bytes);
|
||||
let content = std::str::from_utf8(bytes)
|
||||
.map_err(|source| AutomationStoreError::invalid_utf8(path, source))?;
|
||||
let legacy: LegacyPersistedAutomation =
|
||||
toml::from_str(content).map_err(|source| AutomationStoreError::parse(path, source))?;
|
||||
let LegacyAutomationTarget {
|
||||
repository,
|
||||
selector,
|
||||
workflow,
|
||||
} = legacy.target;
|
||||
let target = legacy_target(repository, &selector, path)?;
|
||||
Automation::from_stored(id.clone(), revision, AutomationReplace {
|
||||
name: legacy.name,
|
||||
description: legacy.description,
|
||||
target,
|
||||
workflow,
|
||||
triggers: legacy.triggers,
|
||||
})
|
||||
.map_err(|source| AutomationStoreError::StoredValidation { id, source })
|
||||
}
|
||||
|
||||
fn legacy_target(
|
||||
repository: String,
|
||||
selector: &str,
|
||||
path: &Path,
|
||||
) -> Result<RunTarget, AutomationStoreError> {
|
||||
let (branch, tag, sha) = if let Some(sha) = repository::normalize_git_commit_sha(selector) {
|
||||
("main".to_string(), None, Some(sha))
|
||||
} else if let Some(tag) = selector
|
||||
.strip_prefix("refs/tags/")
|
||||
.or_else(|| selector.strip_prefix("tags/"))
|
||||
{
|
||||
("main".to_string(), Some(tag.to_string()), None)
|
||||
} else if let Some(branch) = selector
|
||||
.strip_prefix("refs/heads/")
|
||||
.or_else(|| selector.strip_prefix("heads/"))
|
||||
{
|
||||
(branch.to_string(), None, None)
|
||||
} else if selector == "HEAD" {
|
||||
("main".to_string(), None, None)
|
||||
} else {
|
||||
(selector.to_string(), None, None)
|
||||
};
|
||||
RunTarget::Git(GitRunTarget {
|
||||
repo: repository,
|
||||
branch,
|
||||
tag,
|
||||
sha,
|
||||
})
|
||||
.validate()
|
||||
.map(|validated| validated.target)
|
||||
.map_err(|source| AutomationStoreError::LegacyTarget {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
async fn legacy_automation_paths(
|
||||
source_dir: &Path,
|
||||
) -> Result<Option<Vec<(AutomationId, PathBuf)>>, AutomationStoreError> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use croner::errors::CronError;
|
||||
use fabro_types::TargetValidationError;
|
||||
use toml::de::Error as TomlDeError;
|
||||
use toml::ser::Error as TomlSerError;
|
||||
|
||||
|
|
@ -15,13 +14,10 @@ pub enum AutomationValidationError {
|
|||
InvalidAutomationTriggerId { value: String },
|
||||
#[error("automation name must not be empty")]
|
||||
EmptyName,
|
||||
#[error("automation target kind {kind:?} is not supported; only Git targets are accepted")]
|
||||
UnsupportedTarget { kind: String },
|
||||
#[error("automation Git target is invalid")]
|
||||
InvalidTarget {
|
||||
#[source]
|
||||
source: TargetValidationError,
|
||||
},
|
||||
#[error("repository slug {value:?} must be a GitHub owner/repo slug")]
|
||||
InvalidRepositorySlug { value: String },
|
||||
#[error("git ref selector {value:?} is not safe")]
|
||||
InvalidGitRefSelector { value: String },
|
||||
#[error("workflow selector {value:?} is not safe")]
|
||||
InvalidWorkflowSelector { value: String },
|
||||
#[error("duplicate automation trigger id {id:?}")]
|
||||
|
|
@ -118,14 +114,6 @@ pub enum AutomationStoreError {
|
|||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error(
|
||||
"legacy automation target at {path:?} cannot be migrated; edit target.ref to a branch, supported heads/tags selector, HEAD, or 40-hex SHA and restart"
|
||||
)]
|
||||
LegacyTarget {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: TargetValidationError,
|
||||
},
|
||||
}
|
||||
|
||||
impl AutomationStoreError {
|
||||
|
|
@ -168,7 +156,6 @@ impl AutomationStoreError {
|
|||
Self::Serialize { .. } => "serialize",
|
||||
Self::Io { .. } => "io",
|
||||
Self::LegacyBackup { .. } => "legacy_backup",
|
||||
Self::LegacyTarget { .. } => "legacy_target",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ pub use fabro_types::GitHubRepositorySlug;
|
|||
pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, AutomationTriggerId};
|
||||
pub use migrations::{ImportReport, import_legacy_directory_once};
|
||||
pub use model::{
|
||||
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTrigger, ScheduleTrigger,
|
||||
parse_schedule_expression,
|
||||
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTarget,
|
||||
AutomationTrigger, ScheduleTrigger, parse_github_repository_slug, parse_schedule_expression,
|
||||
};
|
||||
pub use store::AutomationStore;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::LazyLock;
|
|||
use croner::Cron;
|
||||
use croner::errors::CronError;
|
||||
use croner::parser::{CronParser, Seconds, Year};
|
||||
use fabro_types::{GitRunTarget, RunTarget};
|
||||
use fabro_types::{GitHubRepositorySlug, repository};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
|
|
@ -38,8 +38,7 @@ pub struct Automation {
|
|||
pub revision: AutomationRevision,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub target: RunTarget,
|
||||
pub workflow: String,
|
||||
pub target: AutomationTarget,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +49,17 @@ impl Automation {
|
|||
Self::from_persisted(id, revision, persisted).map_err(AutomationStoreError::from)
|
||||
}
|
||||
|
||||
pub(crate) fn from_persisted_path(
|
||||
id: AutomationId,
|
||||
bytes: &[u8],
|
||||
path: impl Into<std::path::PathBuf>,
|
||||
) -> Result<Self, AutomationStoreError> {
|
||||
let path = path.into();
|
||||
let revision = AutomationRevision::from_bytes(bytes);
|
||||
let persisted = parse_persisted(bytes, Some(path))?;
|
||||
Self::from_persisted(id, revision, persisted).map_err(AutomationStoreError::from)
|
||||
}
|
||||
|
||||
pub(crate) fn from_replace(
|
||||
id: AutomationId,
|
||||
draft: AutomationReplace,
|
||||
|
|
@ -102,15 +112,6 @@ impl Automation {
|
|||
self.enabled_api_trigger().is_some()
|
||||
}
|
||||
|
||||
/// Returns the validated Git target owned by this automation.
|
||||
#[must_use]
|
||||
pub fn git_target(&self) -> Option<&GitRunTarget> {
|
||||
match &self.target {
|
||||
RunTarget::Git(target) => Some(target),
|
||||
RunTarget::None {} | RunTarget::Folder { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_persisted(
|
||||
id: AutomationId,
|
||||
revision: AutomationRevision,
|
||||
|
|
@ -131,12 +132,20 @@ impl Automation {
|
|||
name: replace.name,
|
||||
description: replace.description,
|
||||
target: replace.target,
|
||||
workflow: replace.workflow,
|
||||
triggers: replace.triggers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AutomationTarget {
|
||||
pub repository: String,
|
||||
#[serde(rename = "ref")]
|
||||
pub ref_selector: String,
|
||||
pub workflow: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum AutomationTrigger {
|
||||
|
|
@ -196,8 +205,7 @@ pub struct AutomationDraft {
|
|||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub target: RunTarget,
|
||||
pub workflow: String,
|
||||
pub target: AutomationTarget,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +215,6 @@ impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
|
|||
name: value.name,
|
||||
description: value.description,
|
||||
target: value.target,
|
||||
workflow: value.workflow,
|
||||
triggers: value.triggers,
|
||||
})
|
||||
}
|
||||
|
|
@ -219,8 +226,7 @@ pub struct AutomationReplace {
|
|||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub target: RunTarget,
|
||||
pub workflow: String,
|
||||
pub target: AutomationTarget,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
||||
|
|
@ -230,8 +236,7 @@ pub(crate) struct PersistedAutomation {
|
|||
name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
target: RunTarget,
|
||||
workflow: String,
|
||||
target: AutomationTarget,
|
||||
#[serde(default)]
|
||||
triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
|
@ -242,7 +247,6 @@ impl From<AutomationReplace> for PersistedAutomation {
|
|||
name: value.name,
|
||||
description: value.description,
|
||||
target: value.target,
|
||||
workflow: value.workflow,
|
||||
triggers: value.triggers,
|
||||
}
|
||||
}
|
||||
|
|
@ -254,7 +258,6 @@ impl From<PersistedAutomation> for AutomationReplace {
|
|||
name: value.name,
|
||||
description: value.description,
|
||||
target: value.target,
|
||||
workflow: value.workflow,
|
||||
triggers: value.triggers,
|
||||
}
|
||||
}
|
||||
|
|
@ -285,14 +288,15 @@ fn validate_fields(value: &AutomationReplace) -> Result<(), AutomationValidation
|
|||
if value.name.trim().is_empty() {
|
||||
return Err(AutomationValidationError::EmptyName);
|
||||
}
|
||||
validate_workflow_selector(&value.workflow)?;
|
||||
validate_repository_slug(&value.target.repository)?;
|
||||
validate_git_ref_selector(&value.target.ref_selector)?;
|
||||
validate_workflow_selector(&value.target.workflow)?;
|
||||
validate_triggers(&value.triggers)
|
||||
}
|
||||
|
||||
fn normalize_replace(
|
||||
mut value: AutomationReplace,
|
||||
) -> Result<AutomationReplace, AutomationValidationError> {
|
||||
value.target = validate_target(value.target)?;
|
||||
validate_fields(&value)?;
|
||||
|
||||
let api_enabled = value
|
||||
|
|
@ -330,16 +334,28 @@ fn normalize_replace(
|
|||
Ok(value)
|
||||
}
|
||||
|
||||
fn validate_target(target: RunTarget) -> Result<RunTarget, AutomationValidationError> {
|
||||
if !matches!(&target, RunTarget::Git(_)) {
|
||||
return Err(AutomationValidationError::UnsupportedTarget {
|
||||
kind: target.kind_name().to_string(),
|
||||
});
|
||||
pub fn parse_github_repository_slug(
|
||||
value: &str,
|
||||
) -> Result<GitHubRepositorySlug, AutomationValidationError> {
|
||||
GitHubRepositorySlug::try_new(value).ok_or_else(|| {
|
||||
AutomationValidationError::InvalidRepositorySlug {
|
||||
value: value.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_repository_slug(value: &str) -> Result<(), AutomationValidationError> {
|
||||
parse_github_repository_slug(value).map(|_| ())
|
||||
}
|
||||
|
||||
fn validate_git_ref_selector(value: &str) -> Result<(), AutomationValidationError> {
|
||||
if repository::is_valid_github_ref_selector(value) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AutomationValidationError::InvalidGitRefSelector {
|
||||
value: value.to_string(),
|
||||
})
|
||||
}
|
||||
target
|
||||
.validate()
|
||||
.map(|validated| validated.target)
|
||||
.map_err(|source| AutomationValidationError::InvalidTarget { source })
|
||||
}
|
||||
|
||||
fn validate_workflow_selector(value: &str) -> Result<(), AutomationValidationError> {
|
||||
|
|
@ -403,20 +419,17 @@ fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationVal
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::{GitRunTarget, RunTarget, TargetValidationError};
|
||||
|
||||
use crate::{
|
||||
ApiTrigger, Automation, AutomationId, AutomationReplace, AutomationTrigger,
|
||||
AutomationTriggerId, AutomationValidationError, ScheduleTrigger,
|
||||
ApiTrigger, Automation, AutomationId, AutomationReplace, AutomationTarget,
|
||||
AutomationTrigger, AutomationTriggerId, AutomationValidationError, ScheduleTrigger,
|
||||
};
|
||||
|
||||
fn target() -> RunTarget {
|
||||
RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
})
|
||||
fn target() -> AutomationTarget {
|
||||
AutomationTarget {
|
||||
repository: "fabro-sh/fabro".to_string(),
|
||||
ref_selector: "main".to_string(),
|
||||
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_trigger(id: &str) -> AutomationTrigger {
|
||||
|
|
@ -442,12 +455,11 @@ mod tests {
|
|||
fn persisted_toml_applies_defaults_and_canonicalizes_without_id_or_revision() {
|
||||
let bytes = br#"
|
||||
name = "Nightly"
|
||||
workflow = "release"
|
||||
|
||||
[target]
|
||||
kind = "git"
|
||||
repo = "fabro-sh/fabro"
|
||||
branch = "main"
|
||||
repository = "fabro-sh/fabro"
|
||||
ref = "main"
|
||||
workflow = "release"
|
||||
|
||||
[[triggers]]
|
||||
type = "api"
|
||||
|
|
@ -480,7 +492,6 @@ expression = "0 0 * * *"
|
|||
let bytes = br#"
|
||||
name = "Legacy"
|
||||
enabled = false
|
||||
workflow = "release"
|
||||
|
||||
[target]
|
||||
repository = "fabro-sh/fabro"
|
||||
|
|
@ -505,7 +516,6 @@ enabled = true
|
|||
name: "Nightly".to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
|
||||
triggers: vec![
|
||||
api_trigger("manual"),
|
||||
schedule_trigger_with_enabled("nightly", "0 0 * * *", true),
|
||||
|
|
@ -523,29 +533,41 @@ enabled = true
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_git_target_preserves_the_shared_validation_error() {
|
||||
let error = super::validate_target(RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main;rm".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
}))
|
||||
.unwrap_err();
|
||||
fn repository_slug_parser_returns_the_shared_type() {
|
||||
let slug: fabro_types::GitHubRepositorySlug =
|
||||
crate::parse_github_repository_slug("owner/.github").unwrap();
|
||||
|
||||
assert!(matches!(&error, AutomationValidationError::InvalidTarget {
|
||||
source: TargetValidationError::Branch,
|
||||
}));
|
||||
assert_eq!(error.to_string(), "automation Git target is invalid");
|
||||
assert_eq!(slug.owner(), "owner");
|
||||
assert_eq!(slug.repo(), ".github");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_git_targets_are_rejected_with_their_kind() {
|
||||
let error = super::validate_target(RunTarget::None {}).unwrap_err();
|
||||
fn invalid_repository_slug_preserves_the_automation_error() {
|
||||
let error = crate::parse_github_repository_slug("not/github/slug").unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
AutomationValidationError::UnsupportedTarget { kind } if kind == "none"
|
||||
&error,
|
||||
AutomationValidationError::InvalidRepositorySlug { value }
|
||||
if value == "not/github/slug"
|
||||
));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"repository slug \"not/github/slug\" must be a GitHub owner/repo slug"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_git_ref_selector_preserves_the_automation_error() {
|
||||
let error = super::validate_git_ref_selector("main;rm").unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
&error,
|
||||
AutomationValidationError::InvalidGitRefSelector { value } if value == "main;rm"
|
||||
));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"git ref selector \"main;rm\" is not safe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -555,45 +577,42 @@ enabled = true
|
|||
name: " ".to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![api_trigger("manual")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad repo".to_string(),
|
||||
description: None,
|
||||
target: RunTarget::Git(GitRunTarget {
|
||||
repo: "not/github/slug".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
}),
|
||||
workflow: "release".to_string(),
|
||||
target: AutomationTarget {
|
||||
repository: "not/github/slug".to_string(),
|
||||
ref_selector: "main".to_string(),
|
||||
workflow: "release".to_string(),
|
||||
},
|
||||
triggers: vec![api_trigger("manual")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad ref".to_string(),
|
||||
description: None,
|
||||
target: RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main;rm".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
}),
|
||||
workflow: "release".to_string(),
|
||||
target: AutomationTarget {
|
||||
repository: "fabro-sh/fabro".to_string(),
|
||||
ref_selector: "main;rm".to_string(),
|
||||
workflow: "release".to_string(),
|
||||
},
|
||||
triggers: vec![api_trigger("manual")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad workflow".to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "../release".to_string(),
|
||||
target: AutomationTarget {
|
||||
repository: "fabro-sh/fabro".to_string(),
|
||||
ref_selector: "main".to_string(),
|
||||
workflow: "../release".to_string(),
|
||||
},
|
||||
triggers: vec![api_trigger("manual")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Duplicate trigger".to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![
|
||||
api_trigger("manual"),
|
||||
schedule_trigger("manual", "0 0 * * *"),
|
||||
|
|
@ -603,21 +622,18 @@ enabled = true
|
|||
name: "Two API triggers".to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![api_trigger("one"), api_trigger("two")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Six field cron".to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad cron".to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use std::str::FromStr as _;
|
||||
|
||||
use fabro_db::DbPool;
|
||||
use fabro_types::{GitRunTarget, RunTarget};
|
||||
use sqlx::sqlite::SqliteRow;
|
||||
use sqlx::{Row as _, Sqlite, Transaction};
|
||||
|
||||
use crate::{
|
||||
ApiTrigger, Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
|
||||
AutomationStoreError, AutomationTrigger, AutomationTriggerId, ScheduleTrigger,
|
||||
AutomationStoreError, AutomationTarget, AutomationTrigger, AutomationTriggerId,
|
||||
ScheduleTrigger,
|
||||
};
|
||||
|
||||
/// Shared projection for loading automations with their schedule triggers.
|
||||
|
|
@ -22,9 +22,7 @@ macro_rules! select_automations_sql {
|
|||
a.description,
|
||||
a.api_enabled,
|
||||
a.target_repository,
|
||||
a.target_branch,
|
||||
a.target_tag,
|
||||
a.target_sha,
|
||||
a.target_ref,
|
||||
a.target_workflow,
|
||||
t.id AS trigger_id,
|
||||
t.enabled AS trigger_enabled,
|
||||
|
|
@ -95,7 +93,6 @@ impl AutomationStore {
|
|||
draft: AutomationReplace,
|
||||
) -> Result<Automation, AutomationStoreError> {
|
||||
let (automation, _) = Automation::from_replace(id.clone(), draft)?;
|
||||
let target = stored_git_target(&automation);
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
r"
|
||||
|
|
@ -105,9 +102,7 @@ impl AutomationStore {
|
|||
description = ?,
|
||||
api_enabled = ?,
|
||||
target_repository = ?,
|
||||
target_branch = ?,
|
||||
target_tag = ?,
|
||||
target_sha = ?,
|
||||
target_ref = ?,
|
||||
target_workflow = ?
|
||||
WHERE id = ? AND revision = ?
|
||||
",
|
||||
|
|
@ -116,11 +111,9 @@ impl AutomationStore {
|
|||
.bind(&automation.name)
|
||||
.bind(automation.description.as_deref())
|
||||
.bind(automation.api_enabled())
|
||||
.bind(&target.repo)
|
||||
.bind(&target.branch)
|
||||
.bind(target.tag.as_deref())
|
||||
.bind(target.sha.as_deref())
|
||||
.bind(&automation.workflow)
|
||||
.bind(&automation.target.repository)
|
||||
.bind(&automation.target.ref_selector)
|
||||
.bind(&automation.target.workflow)
|
||||
.bind(id.as_str())
|
||||
.bind(expected.as_str())
|
||||
.execute(&mut *transaction)
|
||||
|
|
@ -163,8 +156,7 @@ struct StoredAutomation {
|
|||
name: String,
|
||||
description: Option<String>,
|
||||
api_enabled: bool,
|
||||
target: RunTarget,
|
||||
workflow: String,
|
||||
target: AutomationTarget,
|
||||
schedule_triggers: Vec<ScheduleTrigger>,
|
||||
}
|
||||
|
||||
|
|
@ -188,13 +180,11 @@ impl StoredAutomation {
|
|||
name: row.try_get("name")?,
|
||||
description: row.try_get("description")?,
|
||||
api_enabled: row.try_get("api_enabled")?,
|
||||
target: RunTarget::Git(GitRunTarget {
|
||||
repo: row.try_get("target_repository")?,
|
||||
branch: row.try_get("target_branch")?,
|
||||
tag: row.try_get("target_tag")?,
|
||||
sha: row.try_get("target_sha")?,
|
||||
}),
|
||||
workflow: row.try_get("target_workflow")?,
|
||||
target: AutomationTarget {
|
||||
repository: row.try_get("target_repository")?,
|
||||
ref_selector: row.try_get("target_ref")?,
|
||||
workflow: row.try_get("target_workflow")?,
|
||||
},
|
||||
schedule_triggers: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
|
@ -240,7 +230,6 @@ impl StoredAutomation {
|
|||
name: self.name,
|
||||
description: self.description,
|
||||
target: self.target,
|
||||
workflow: self.workflow,
|
||||
triggers,
|
||||
})
|
||||
.map_err(|source| AutomationStoreError::StoredValidation { id, source })
|
||||
|
|
@ -283,7 +272,6 @@ pub(crate) async fn insert_automation_ignoring_conflict(
|
|||
transaction: &mut Transaction<'_, Sqlite>,
|
||||
automation: &Automation,
|
||||
) -> Result<bool, AutomationStoreError> {
|
||||
let target = stored_git_target(automation);
|
||||
let result = sqlx::query(
|
||||
r"
|
||||
INSERT INTO automations (
|
||||
|
|
@ -293,11 +281,9 @@ pub(crate) async fn insert_automation_ignoring_conflict(
|
|||
description,
|
||||
api_enabled,
|
||||
target_repository,
|
||||
target_branch,
|
||||
target_tag,
|
||||
target_sha,
|
||||
target_ref,
|
||||
target_workflow
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
",
|
||||
)
|
||||
|
|
@ -306,11 +292,9 @@ pub(crate) async fn insert_automation_ignoring_conflict(
|
|||
.bind(&automation.name)
|
||||
.bind(automation.description.as_deref())
|
||||
.bind(automation.api_enabled())
|
||||
.bind(&target.repo)
|
||||
.bind(&target.branch)
|
||||
.bind(target.tag.as_deref())
|
||||
.bind(target.sha.as_deref())
|
||||
.bind(&automation.workflow)
|
||||
.bind(&automation.target.repository)
|
||||
.bind(&automation.target.ref_selector)
|
||||
.bind(&automation.target.workflow)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
|
|
@ -320,12 +304,6 @@ pub(crate) async fn insert_automation_ignoring_conflict(
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
fn stored_git_target(automation: &Automation) -> &GitRunTarget {
|
||||
automation
|
||||
.git_target()
|
||||
.expect("stored automations have already passed Git-only validation")
|
||||
}
|
||||
|
||||
async fn insert_schedule_triggers(
|
||||
transaction: &mut Transaction<'_, Sqlite>,
|
||||
automation: &Automation,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
use std::path::Path;
|
||||
|
||||
use fabro_automation::{
|
||||
ApiTrigger, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
|
||||
AutomationStore, AutomationStoreError, AutomationTrigger, AutomationTriggerId, ScheduleTrigger,
|
||||
ApiTrigger, AutomationDraft, AutomationId, AutomationReplace, AutomationStore,
|
||||
AutomationStoreError, AutomationTarget, AutomationTrigger, AutomationTriggerId,
|
||||
ScheduleTrigger,
|
||||
};
|
||||
use fabro_db::Database;
|
||||
use fabro_types::{GitRunTarget, RunTarget};
|
||||
use tokio::fs;
|
||||
|
||||
async fn test_database() -> (tempfile::TempDir, Database) {
|
||||
|
|
@ -22,13 +22,12 @@ async fn test_database() -> (tempfile::TempDir, Database) {
|
|||
(dir, database)
|
||||
}
|
||||
|
||||
fn target() -> RunTarget {
|
||||
RunTarget::Git(GitRunTarget {
|
||||
repo: "fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
tag: None,
|
||||
sha: None,
|
||||
})
|
||||
fn target() -> AutomationTarget {
|
||||
AutomationTarget {
|
||||
repository: "fabro-sh/fabro".to_string(),
|
||||
ref_selector: "main".to_string(),
|
||||
workflow: "release".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
|
||||
|
|
@ -45,7 +44,6 @@ fn draft(id: &str, api_enabled: bool) -> AutomationDraft {
|
|||
name: "Nightly".to_string(),
|
||||
description: Some("Runs every night".to_string()),
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![
|
||||
schedule("z-last", "0 2 * * *", false),
|
||||
AutomationTrigger::Api(ApiTrigger {
|
||||
|
|
@ -62,7 +60,6 @@ fn replacement(name: &str, expression: &str) -> AutomationReplace {
|
|||
name: name.to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![
|
||||
schedule("nightly", expression, true),
|
||||
AutomationTrigger::Api(ApiTrigger {
|
||||
|
|
@ -221,7 +218,6 @@ async fn failed_schedule_insert_rolls_back_parent_replace() {
|
|||
name: "Should roll back".to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
workflow: "release".to_string(),
|
||||
triggers: vec![schedule("blocked", "0 7 * * *", true)],
|
||||
};
|
||||
|
||||
|
|
@ -258,8 +254,7 @@ async fn legacy_import_is_transactional_and_sql_wins() {
|
|||
let source_dir = dir.path().join("automations");
|
||||
fs::create_dir_all(&source_dir).await.unwrap();
|
||||
write_legacy_automation(&source_dir, "existing", "Legacy existing").await;
|
||||
let imported_bytes = write_legacy_automation(&source_dir, "imported", "Imported").await;
|
||||
let expected_revision = AutomationRevision::from_bytes(&imported_bytes);
|
||||
write_legacy_automation(&source_dir, "imported", "Imported").await;
|
||||
fs::write(source_dir.join("notes.txt"), "ignored")
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -283,23 +278,15 @@ async fn legacy_import_is_transactional_and_sql_wins() {
|
|||
.name,
|
||||
"Nightly"
|
||||
);
|
||||
let imported = store
|
||||
.get(&AutomationId::new("imported").unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(imported.name, "Imported");
|
||||
assert_eq!(imported.revision, expected_revision);
|
||||
assert_eq!(imported.workflow, "release");
|
||||
assert!(matches!(
|
||||
imported.target,
|
||||
RunTarget::Git(GitRunTarget {
|
||||
branch,
|
||||
tag: None,
|
||||
sha: None,
|
||||
..
|
||||
}) if branch == "main"
|
||||
));
|
||||
assert_eq!(
|
||||
store
|
||||
.get(&AutomationId::new("imported").unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.name,
|
||||
"Imported"
|
||||
);
|
||||
|
||||
fs::create_dir_all(&source_dir).await.unwrap();
|
||||
write_legacy_automation(&source_dir, "existing", "Legacy existing").await;
|
||||
|
|
@ -353,47 +340,15 @@ async fn invalid_legacy_file_leaves_directory_and_database_unchanged() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_legacy_target_leaves_directory_and_database_unchanged() {
|
||||
let (dir, database) = test_database().await;
|
||||
let source_dir = dir.path().join("automations");
|
||||
fs::create_dir_all(&source_dir).await.unwrap();
|
||||
let bytes = legacy_automation_bytes("Unsupported", "refs/pull/123/head");
|
||||
fs::write(source_dir.join("unsupported.toml"), bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = fabro_automation::import_legacy_directory_once(database.pool(), &source_dir)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, AutomationStoreError::LegacyTarget { .. }));
|
||||
assert!(err.to_string().contains("edit target.ref"));
|
||||
assert!(source_dir.exists());
|
||||
assert!(
|
||||
AutomationStore::new(database.clone_pool())
|
||||
.list()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
async fn write_legacy_automation(dir: &Path, id: &str, name: &str) -> Vec<u8> {
|
||||
let bytes = legacy_automation_bytes(name, "main");
|
||||
fs::write(dir.join(format!("{id}.toml")), &bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
fn legacy_automation_bytes(name: &str, ref_selector: &str) -> Vec<u8> {
|
||||
format!(
|
||||
r#"name = "{name}"
|
||||
async fn write_legacy_automation(dir: &Path, id: &str, name: &str) {
|
||||
fs::write(
|
||||
dir.join(format!("{id}.toml")),
|
||||
format!(
|
||||
r#"name = "{name}"
|
||||
|
||||
[target]
|
||||
repository = "fabro-sh/fabro"
|
||||
ref = "{ref_selector}"
|
||||
ref = "main"
|
||||
workflow = "release"
|
||||
|
||||
[[triggers]]
|
||||
|
|
@ -407,6 +362,8 @@ type = "schedule"
|
|||
enabled = true
|
||||
expression = "0 3 * * *"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.into_bytes()
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -617,8 +617,8 @@ async fn mint_installation_token_with_jwt(
|
|||
str::to_string,
|
||||
);
|
||||
bail!(
|
||||
"GitHub App is not installed for {owner}, or its installation does not \
|
||||
include {owner}/{primary_repo}. Check repository access at {install_url}"
|
||||
"GitHub App is not installed for {owner}. \
|
||||
Install it at {install_url}"
|
||||
);
|
||||
}
|
||||
InstallationLookup::Failed(403) => {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ impl StreamState {
|
|||
}
|
||||
|
||||
/// Process a parsed SSE chunk and return events to emit, if any.
|
||||
fn process_chunk(&mut self, mut chunk: StreamChunk) -> Result<Option<Vec<StreamEvent>>, Error> {
|
||||
fn process_chunk(&mut self, mut chunk: StreamChunk) -> Option<Vec<StreamEvent>> {
|
||||
// Capture response metadata from the first chunk.
|
||||
if let Some(id) = &chunk.id {
|
||||
if self.response_id.is_empty() {
|
||||
|
|
@ -80,12 +80,8 @@ impl StreamState {
|
|||
.or_else(|| chunk.cost.as_ref().and_then(|cost| cost.usd));
|
||||
self.cost_usd = cost_usd.or(self.cost_usd);
|
||||
|
||||
let Some(choices) = chunk.choices.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(choice) = choices.first_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let choices = chunk.choices.as_mut()?;
|
||||
let choice = choices.first_mut()?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
|
|
@ -94,9 +90,7 @@ impl StreamState {
|
|||
self.finish_reason = map_finish_reason(Some(reason.as_str()));
|
||||
}
|
||||
|
||||
let Some(delta) = choice.delta.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let delta = choice.delta.as_mut()?;
|
||||
|
||||
// Accumulate reasoning/thinking content (Kimi, etc.).
|
||||
if let Some(reasoning) = delta.reasoning() {
|
||||
|
|
@ -127,22 +121,8 @@ impl StreamState {
|
|||
for tc in tool_calls {
|
||||
let index = tc.index;
|
||||
|
||||
// A delta may only continue an already-started tool call or
|
||||
// open the next slot. Padding a skipped slot would materialize
|
||||
// a phantom tool call with an empty id and name, which poisons
|
||||
// the conversation once echoed back to the provider.
|
||||
if index > self.tool_calls.len() {
|
||||
return Err(Error::Stream {
|
||||
message: format!(
|
||||
"malformed tool call stream from {}: delta for tool_calls[{index}] \
|
||||
arrived before tool_calls[{}] was started",
|
||||
self.provider_name,
|
||||
self.tool_calls.len()
|
||||
),
|
||||
source: None,
|
||||
});
|
||||
}
|
||||
if index == self.tool_calls.len() {
|
||||
// Grow the accumulated tool calls vector if needed.
|
||||
while self.tool_calls.len() <= index {
|
||||
self.tool_calls.push(AccumulatedToolCall {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
|
|
@ -183,9 +163,9 @@ impl StreamState {
|
|||
}
|
||||
|
||||
if events.is_empty() {
|
||||
Ok(None)
|
||||
None
|
||||
} else {
|
||||
Ok(Some(events))
|
||||
Some(events)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +262,7 @@ impl StreamDecoder for StreamState {
|
|||
let chunk: StreamChunk = serde_json::from_str(ev.data)
|
||||
.map_err(|e| Error::stream_error(format!("failed to parse SSE chunk: {e}"), e))?;
|
||||
|
||||
Ok(self.process_chunk(chunk)?.unwrap_or_default())
|
||||
Ok(self.process_chunk(chunk).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Vec<StreamEvent> {
|
||||
|
|
@ -393,7 +373,7 @@ mod tests {
|
|||
let chunk1: StreamChunk = serde_json::from_str(
|
||||
r#"{"id":"c1","model":"m1","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}"#,
|
||||
).unwrap();
|
||||
let events1 = state.process_chunk(chunk1).unwrap().unwrap();
|
||||
let events1 = state.process_chunk(chunk1).unwrap();
|
||||
assert_eq!(events1.len(), 2);
|
||||
assert!(matches!(events1[0], StreamEvent::TextStart { .. }));
|
||||
assert!(matches!(events1[1], StreamEvent::TextDelta { .. }));
|
||||
|
|
@ -401,7 +381,7 @@ mod tests {
|
|||
let chunk2: StreamChunk = serde_json::from_str(
|
||||
r#"{"id":"c1","model":"m1","choices":[{"delta":{"content":" world"},"finish_reason":null}]}"#,
|
||||
).unwrap();
|
||||
let events2 = state.process_chunk(chunk2).unwrap().unwrap();
|
||||
let events2 = state.process_chunk(chunk2).unwrap();
|
||||
assert_eq!(events2.len(), 1);
|
||||
assert!(matches!(events2[0], StreamEvent::TextDelta { .. }));
|
||||
|
||||
|
|
@ -415,14 +395,14 @@ mod tests {
|
|||
let chunk1: StreamChunk = serde_json::from_str(
|
||||
r#"{"id":"c1","model":"m1","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"fn1","arguments":"{\"k"}}]},"finish_reason":null}]}"#,
|
||||
).unwrap();
|
||||
let events1 = state.process_chunk(chunk1).unwrap().unwrap();
|
||||
let events1 = state.process_chunk(chunk1).unwrap();
|
||||
assert_eq!(events1.len(), 1);
|
||||
assert!(matches!(events1[0], StreamEvent::ToolCallStart { .. }));
|
||||
|
||||
let chunk2: StreamChunk = serde_json::from_str(
|
||||
r#"{"id":"c1","model":"m1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ey\"}"}}]},"finish_reason":null}]}"#,
|
||||
).unwrap();
|
||||
let events2 = state.process_chunk(chunk2).unwrap().unwrap();
|
||||
let events2 = state.process_chunk(chunk2).unwrap();
|
||||
assert_eq!(events2.len(), 1);
|
||||
assert!(matches!(events2[0], StreamEvent::ToolCallDelta { .. }));
|
||||
|
||||
|
|
@ -499,37 +479,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// Reproduces run 01M11JZVT7V507R56BCJJHZB1B: venice (proxying Anthropic)
|
||||
// numbered tool_calls[].index by content block, so the first tool call
|
||||
// arrived with index 1 when text preceded it. Padding the skipped slot
|
||||
// used to materialize a phantom tool call with an empty id and name that
|
||||
// the provider rejected once echoed back (tool_use.id must match
|
||||
// '^[a-zA-Z0-9_-]+$'). A gap in the index sequence is indistinguishable
|
||||
// from lost chunks, so the stream must fail instead.
|
||||
#[test]
|
||||
fn sparse_tool_call_index_is_a_stream_error() {
|
||||
let mut state = test_state("venice", "claude-opus-5");
|
||||
|
||||
let text_chunk: StreamChunk = serde_json::from_str(
|
||||
r#"{"id":"c1","model":"claude-opus-5","choices":[{"delta":{"content":"I'll start by reading the state file."},"finish_reason":null}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
state.process_chunk(text_chunk).unwrap();
|
||||
|
||||
let tool_chunk: StreamChunk = serde_json::from_str(
|
||||
r#"{"id":"c1","model":"claude-opus-5","choices":[{"delta":{"tool_calls":[{"index":1,"id":"toolu_01EgMidFVtGhitWE22jXQ9Eo","function":{"name":"Read","arguments":"{\"file_path\":\"state.json\"}"}}]},"finish_reason":null}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let err = state.process_chunk(tool_chunk).unwrap_err();
|
||||
|
||||
assert!(err.retryable(), "malformed stream should be retryable");
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("tool_calls[1]") && message.contains("venice"),
|
||||
"unexpected error message: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_request_model_as_fallback() {
|
||||
let mut state = test_state("test", "fallback-model");
|
||||
|
|
|
|||
|
|
@ -46,32 +46,16 @@ impl ModelTestOutcome {
|
|||
pub async fn run_model_test(
|
||||
info: &Model,
|
||||
mode: ModelTestMode,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
client: Arc<Client>,
|
||||
) -> ModelTestOutcome {
|
||||
match mode {
|
||||
ModelTestMode::Basic => run_basic_test(info, reasoning_effort, client).await,
|
||||
ModelTestMode::Deep => run_tools_test(info, reasoning_effort, client).await,
|
||||
ModelTestMode::Basic => run_basic_test(info, client).await,
|
||||
ModelTestMode::Deep => run_deep_test(info, client).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Output budget for tests where reasoning or tool rounds consume completion
|
||||
/// tokens before the final answer.
|
||||
const EXPANDED_MAX_TOKENS: i64 = 1024;
|
||||
|
||||
async fn run_basic_test(
|
||||
info: &Model,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
client: Arc<Client>,
|
||||
) -> ModelTestOutcome {
|
||||
basic_probe(
|
||||
info.id.as_str(),
|
||||
info.provider.to_string(),
|
||||
reasoning_effort,
|
||||
client,
|
||||
Duration::from_secs(ModelTestMode::Basic.timeout_secs()),
|
||||
)
|
||||
.await
|
||||
async fn run_basic_test(info: &Model, client: Arc<Client>) -> ModelTestOutcome {
|
||||
run_basic_model_probe(info.id.as_str(), &info.provider, client).await
|
||||
}
|
||||
|
||||
/// Run the cheap single-prompt model availability probe without requiring a
|
||||
|
|
@ -96,41 +80,12 @@ pub async fn run_basic_model_probe_with_timeout(
|
|||
client: Arc<Client>,
|
||||
probe_timeout: Duration,
|
||||
) -> ModelTestOutcome {
|
||||
basic_probe(model_id, provider.to_string(), None, client, probe_timeout).await
|
||||
}
|
||||
|
||||
async fn basic_probe(
|
||||
model_id: &str,
|
||||
provider: String,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
client: Arc<Client>,
|
||||
probe_timeout: Duration,
|
||||
) -> ModelTestOutcome {
|
||||
let params = build_basic_test_params(model_id, provider, reasoning_effort, client);
|
||||
basic_model_probe_outcome(generate::generate(params), probe_timeout).await
|
||||
}
|
||||
|
||||
fn build_basic_test_params(
|
||||
model_id: &str,
|
||||
provider: String,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
client: Arc<Client>,
|
||||
) -> GenerateParams {
|
||||
let max_tokens = if reasoning_effort.is_some() {
|
||||
EXPANDED_MAX_TOKENS
|
||||
} else {
|
||||
16
|
||||
};
|
||||
let mut params = GenerateParams::new(model_id, client)
|
||||
.provider(provider)
|
||||
let params = GenerateParams::new(model_id, client)
|
||||
.provider(provider.to_string())
|
||||
.prompt("Say OK")
|
||||
.max_tokens(max_tokens);
|
||||
.max_tokens(16);
|
||||
|
||||
if let Some(reasoning_effort) = reasoning_effort {
|
||||
params = params.reasoning_effort(reasoning_effort);
|
||||
}
|
||||
|
||||
params
|
||||
basic_model_probe_outcome(generate::generate(params), probe_timeout).await
|
||||
}
|
||||
|
||||
async fn basic_model_probe_outcome<F>(probe: F, probe_timeout: Duration) -> ModelTestOutcome
|
||||
|
|
@ -144,12 +99,8 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
async fn run_tools_test(
|
||||
info: &Model,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
client: Arc<Client>,
|
||||
) -> ModelTestOutcome {
|
||||
let Some(params) = build_tools_test_params(info, reasoning_effort, client) else {
|
||||
async fn run_deep_test(info: &Model, client: Arc<Client>) -> ModelTestOutcome {
|
||||
let Some(params) = build_deep_test_params(info, client) else {
|
||||
return ModelTestOutcome::error("model does not support tools");
|
||||
};
|
||||
|
||||
|
|
@ -160,7 +111,7 @@ async fn run_tools_test(
|
|||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(gen_result)) => match validate_tools_result(&gen_result) {
|
||||
Ok(Ok(gen_result)) => match validate_deep_result(&gen_result) {
|
||||
Ok(()) => ModelTestOutcome::ok(),
|
||||
Err(message) => ModelTestOutcome::error(message),
|
||||
},
|
||||
|
|
@ -169,11 +120,7 @@ async fn run_tools_test(
|
|||
}
|
||||
}
|
||||
|
||||
fn build_tools_test_params(
|
||||
info: &Model,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
client: Arc<Client>,
|
||||
) -> Option<GenerateParams> {
|
||||
fn build_deep_test_params(info: &Model, client: Arc<Client>) -> Option<GenerateParams> {
|
||||
if !info.features.tools {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -210,16 +157,16 @@ fn build_tools_test_params(
|
|||
)
|
||||
.tools(vec![add_tool])
|
||||
.max_tool_rounds(5)
|
||||
.max_tokens(EXPANDED_MAX_TOKENS);
|
||||
.max_tokens(1024);
|
||||
|
||||
if let Some(reasoning_effort) = reasoning_effort {
|
||||
params = params.reasoning_effort(reasoning_effort);
|
||||
if info.supports_reasoning_effort() {
|
||||
params = params.reasoning_effort(ReasoningEffort::High);
|
||||
}
|
||||
|
||||
Some(params)
|
||||
}
|
||||
|
||||
fn validate_tools_result(result: &GenerateResult) -> Result<(), String> {
|
||||
fn validate_deep_result(result: &GenerateResult) -> Result<(), String> {
|
||||
if result.steps.len() < 2 {
|
||||
return Err("model did not call tool".to_string());
|
||||
}
|
||||
|
|
@ -294,7 +241,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_model_test_tools_errors_when_model_lacks_tools() {
|
||||
async fn run_model_test_deep_errors_when_model_lacks_tools() {
|
||||
let info = test_model_with(ModelFeatures {
|
||||
tools: false,
|
||||
vision: false,
|
||||
|
|
@ -305,7 +252,7 @@ mod tests {
|
|||
sampling_params: true,
|
||||
});
|
||||
|
||||
let outcome = run_model_test(&info, ModelTestMode::Deep, None, empty_test_client()).await;
|
||||
let outcome = run_model_test(&info, ModelTestMode::Deep, empty_test_client()).await;
|
||||
|
||||
assert_eq!(outcome.status, ModelTestStatus::Error);
|
||||
assert_eq!(
|
||||
|
|
@ -327,38 +274,25 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn basic_test_expands_output_budget_for_reasoning() {
|
||||
let params = build_basic_test_params(
|
||||
"test-model",
|
||||
"anthropic".to_string(),
|
||||
Some(ReasoningEffort::Max),
|
||||
empty_test_client(),
|
||||
);
|
||||
|
||||
assert_eq!(params.reasoning_effort, Some(ReasoningEffort::Max));
|
||||
assert_eq!(params.max_tokens, Some(1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_test_omits_effort_when_not_requested() {
|
||||
fn deep_test_omits_effort_for_reasoning_without_effort_controls() {
|
||||
let info = test_model_with(ModelFeatures {
|
||||
tools: true,
|
||||
vision: false,
|
||||
reasoning: true,
|
||||
reasoning_effort: ReasoningEffortFeature::Levels,
|
||||
reasoning_effort: ReasoningEffortFeature::None,
|
||||
prompt_cache: true,
|
||||
cache_control_breakpoints: false,
|
||||
sampling_params: true,
|
||||
});
|
||||
|
||||
let params = build_tools_test_params(&info, None, empty_test_client())
|
||||
.expect("tool-capable model should produce tools-test params");
|
||||
let params = build_deep_test_params(&info, empty_test_client())
|
||||
.expect("tool-capable model should produce deep-test params");
|
||||
|
||||
assert_eq!(params.reasoning_effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_test_uses_requested_effort() {
|
||||
fn deep_test_uses_high_effort_when_supported() {
|
||||
let info = test_model_with(ModelFeatures {
|
||||
tools: true,
|
||||
vision: false,
|
||||
|
|
@ -369,15 +303,14 @@ mod tests {
|
|||
sampling_params: true,
|
||||
});
|
||||
|
||||
let params =
|
||||
build_tools_test_params(&info, Some(ReasoningEffort::Low), empty_test_client())
|
||||
.expect("tool-capable model should produce tools-test params");
|
||||
let params = build_deep_test_params(&info, empty_test_client())
|
||||
.expect("tool-capable model should produce deep-test params");
|
||||
|
||||
assert_eq!(params.reasoning_effort, Some(ReasoningEffort::Low));
|
||||
assert_eq!(params.reasoning_effort, Some(ReasoningEffort::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_tools_result_does_not_fail_only_for_missing_reasoning() {
|
||||
fn validate_deep_result_does_not_fail_only_for_missing_reasoning() {
|
||||
let tool_results = vec![ToolResult::success("call_1", serde_json::json!(42))];
|
||||
let first_step = StepResult {
|
||||
response: response_with_text("tool step"),
|
||||
|
|
@ -395,6 +328,6 @@ mod tests {
|
|||
output: None,
|
||||
};
|
||||
|
||||
assert_eq!(validate_tools_result(&result), Ok(()));
|
||||
assert_eq!(validate_deep_result(&result), Ok(()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ async fn assert_deep_tool_round_trip(
|
|||
.get_on_provider(provider, model_id)
|
||||
.unwrap_or_else(|| panic!("{provider} {model_id} should be present"));
|
||||
|
||||
let outcome = run_model_test(model, ModelTestMode::Deep, None, client).await;
|
||||
let outcome = run_model_test(model, ModelTestMode::Deep, client).await;
|
||||
assert_eq!(
|
||||
outcome.status,
|
||||
ModelTestStatus::Ok,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ use fabro_config::project::WorkflowLocation;
|
|||
use fabro_config::{EnvironmentDockerfileLayer, EnvironmentImageLayer, SettingsLayer};
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::{
|
||||
BundleTemplateStore, FilesystemTemplateStore, GraphPosition, GraphReference,
|
||||
GraphReferenceError, RecordingTemplateStore, TemplateContext, TemplateDependencyClosure,
|
||||
TemplateRenderMode, TemplateSource, validate_static_reference, visit_graph_references,
|
||||
BundleTemplateStore, FilesystemTemplateStore, GraphReference, GraphReferenceError,
|
||||
RecordingTemplateStore, TemplateContext, TemplateDependencyClosure, TemplateRenderMode,
|
||||
TemplateSource, validate_static_reference, visit_graph_references,
|
||||
};
|
||||
use fabro_types::ManifestPath;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
|
|
@ -87,12 +87,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
.ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?;
|
||||
self.collect_config_dockerfile(&config_path, &config.source, &mut files)?;
|
||||
}
|
||||
self.collect_workflow_files(
|
||||
&scan,
|
||||
&mut files,
|
||||
&mut visited_imports,
|
||||
GraphPosition::Entrypoint,
|
||||
)?;
|
||||
self.collect_workflow_files(&scan, &mut files, &mut visited_imports)?;
|
||||
|
||||
self.workflows
|
||||
.insert(dot_key.clone(), types::ManifestWorkflow {
|
||||
|
|
@ -128,7 +123,6 @@ impl<'a> WorkflowBundler<'a> {
|
|||
workflow: &WorkflowScanInput,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
visited_imports: &mut HashSet<String>,
|
||||
position: GraphPosition,
|
||||
) -> Result<()> {
|
||||
let graph = parser::parse(&workflow.source)
|
||||
.with_context(|| format!("Failed to parse {}", workflow.absolute_dot_path.display()))?;
|
||||
|
|
@ -143,7 +137,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
let mut imports = Vec::new();
|
||||
let mut children = Vec::new();
|
||||
|
||||
visit_graph_references(&graph, position, |reference| -> Result<()> {
|
||||
visit_graph_references(&graph, |reference| -> Result<()> {
|
||||
match reference {
|
||||
GraphReference::GoalFile { reference } => {
|
||||
let bundled = self.collect_bundled_file(
|
||||
|
|
@ -157,17 +151,15 @@ impl<'a> WorkflowBundler<'a> {
|
|||
self.collect_bundled_template_includes(files, &bundled, &workflow_template_root)
|
||||
}
|
||||
GraphReference::GoalInline { content }
|
||||
| GraphReference::InlinePrompt { content }
|
||||
| GraphReference::ModelStylesheetInline { content } => self
|
||||
.collect_template_include_files(
|
||||
files,
|
||||
TemplateSource::new(
|
||||
workflow.dot_path.clone(),
|
||||
workflow_template_root.clone(),
|
||||
content.to_owned(),
|
||||
),
|
||||
Some(&workflow.dot_path),
|
||||
| GraphReference::InlinePrompt { content } => self.collect_template_include_files(
|
||||
files,
|
||||
TemplateSource::new(
|
||||
workflow.dot_path.clone(),
|
||||
workflow_template_root.clone(),
|
||||
content.to_owned(),
|
||||
),
|
||||
Some(&workflow.dot_path),
|
||||
),
|
||||
GraphReference::FileInline { key, reference } => {
|
||||
let bundled = self.collect_bundled_file(
|
||||
files,
|
||||
|
|
@ -220,12 +212,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
dot_path: imported.path,
|
||||
source: imported_source,
|
||||
};
|
||||
self.collect_workflow_files(
|
||||
&imported_scan,
|
||||
files,
|
||||
visited_imports,
|
||||
GraphPosition::Imported,
|
||||
)?;
|
||||
self.collect_workflow_files(&imported_scan, files, visited_imports)?;
|
||||
}
|
||||
}
|
||||
for child in children {
|
||||
|
|
@ -487,101 +474,6 @@ mod tests {
|
|||
assert_eq!(goal.ref_.original, "@goal.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_model_stylesheet_bundles_nested_static_includes() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
graph [model_stylesheet="{% include 'styles/base.css' %}"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&temp.path().join("styles/base.css"),
|
||||
"{% include 'nested.css' %}",
|
||||
);
|
||||
write_file(
|
||||
&temp.path().join("styles/nested.css"),
|
||||
"* { reasoning_effort: low; }",
|
||||
);
|
||||
|
||||
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
|
||||
let files = &workflows["workflow.fabro"].files;
|
||||
|
||||
assert_eq!(
|
||||
files["styles/base.css"].content,
|
||||
"{% include 'nested.css' %}"
|
||||
);
|
||||
assert_eq!(
|
||||
files["styles/nested.css"].content,
|
||||
"* { reasoning_effort: low; }"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_model_stylesheet_rejects_invalid_includes() {
|
||||
for template in [
|
||||
"{% include 'missing.css' %}",
|
||||
"{% include inputs.stylesheet %}",
|
||||
"{% include '../outside.css' %}",
|
||||
] {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
&format!(
|
||||
r#"digraph Root {{
|
||||
graph [model_stylesheet="{template}"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}}"#,
|
||||
),
|
||||
);
|
||||
|
||||
let error = bundle_graph(temp.path(), &graph)
|
||||
.expect_err("invalid stylesheet include should fail bundling");
|
||||
assert!(
|
||||
error.to_string().contains("template dependencies"),
|
||||
"template: {template}; error: {error:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_model_stylesheet_includes_are_not_bundled() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
child [import="child.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> child -> exit
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&temp.path().join("child.fabro"),
|
||||
r#"digraph Child {
|
||||
graph [model_stylesheet="{% include 'missing.css' %}"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#,
|
||||
);
|
||||
|
||||
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
|
||||
let files = &workflows["workflow.fabro"].files;
|
||||
|
||||
assert!(files.contains_key("child.fabro"));
|
||||
assert!(!files.contains_key("missing.css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_errors_keep_the_graphviz_error_in_the_source_chain() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ pub(crate) enum CloneDecision {
|
|||
GitHub {
|
||||
origin_url: String,
|
||||
branch: Option<String>,
|
||||
tag: Option<String>,
|
||||
commit_sha: Option<String>,
|
||||
},
|
||||
}
|
||||
|
|
@ -83,87 +82,17 @@ pub(crate) fn exact_repository_init_command(clone_url: &str, checkout_path: &str
|
|||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Fetch a single admitted commit with the same history depth a branch clone
|
||||
/// gets, so both paths can reach the same number of parent commits.
|
||||
///
|
||||
/// The fetch names the revision directly rather than the branch, and
|
||||
/// `--no-tags` keeps unrelated tags from being pulled alongside it.
|
||||
/// 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.
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) fn pinned_fetch_command(
|
||||
pub(crate) fn exact_fetch_command(
|
||||
checkout_path: &str,
|
||||
fetch_source: &str,
|
||||
refspec: &str,
|
||||
commit_sha: &str,
|
||||
depth: Option<usize>,
|
||||
) -> String {
|
||||
let depth_arg = depth_argument(depth);
|
||||
|
|
@ -171,7 +100,7 @@ pub(crate) fn pinned_fetch_command(
|
|||
"{git} -C {} fetch{depth_arg} --no-tags {} -- {}",
|
||||
sandbox::shell_quote(checkout_path),
|
||||
sandbox::shell_quote(fetch_source),
|
||||
sandbox::shell_quote(refspec),
|
||||
sandbox::shell_quote(commit_sha),
|
||||
git = sandbox::GIT,
|
||||
)
|
||||
}
|
||||
|
|
@ -202,8 +131,7 @@ pub(crate) fn exact_branch_checkout_command(
|
|||
)
|
||||
}
|
||||
|
||||
/// Print the current HEAD commit and nothing else, for
|
||||
/// [`PinnedRevision::verify_head`].
|
||||
/// Print the current HEAD commit and nothing else, for [`verify_exact_head`].
|
||||
pub(crate) fn exact_head_revision_command(checkout_path: &str) -> String {
|
||||
format!(
|
||||
"{git} -C {path} rev-parse HEAD",
|
||||
|
|
@ -213,8 +141,7 @@ 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
|
||||
/// [`PinnedRevision::verify_head`].
|
||||
/// command; stdout is the `rev-parse HEAD` output for [`verify_exact_head`].
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) fn exact_checkout_verify_command(
|
||||
checkout_path: &str,
|
||||
|
|
@ -228,17 +155,17 @@ pub(crate) fn exact_checkout_verify_command(
|
|||
)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
})
|
||||
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(())
|
||||
}
|
||||
|
||||
fn trim_root(root: &str) -> &str {
|
||||
|
|
@ -267,39 +194,31 @@ 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 let Some(pin) = PinnedRevision::from_selectors(tag.as_deref(), commit_sha.as_deref()) {
|
||||
let selector = pin.label();
|
||||
if commit_sha.is_some() {
|
||||
if skip_clone {
|
||||
return Err(crate::Error::message(format!(
|
||||
"{selector} requires cloning to be enabled"
|
||||
)));
|
||||
return Err(crate::Error::message(
|
||||
"Exact commit checkout requires cloning to be enabled",
|
||||
));
|
||||
}
|
||||
if clone_origin_url.is_none_or(|url| url.trim().is_empty()) {
|
||||
return Err(crate::Error::message(format!(
|
||||
"{selector} requires a repository origin"
|
||||
)));
|
||||
return Err(crate::Error::message(
|
||||
"Exact commit checkout 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(format!(
|
||||
"{selector} requires a repository branch"
|
||||
)));
|
||||
return Err(crate::Error::message(
|
||||
"Exact commit checkout requires a repository branch",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +246,6 @@ pub(crate) fn decide_clone(
|
|||
branch: clone_branch
|
||||
.filter(|branch| !branch.trim().is_empty())
|
||||
.map(str::to_string),
|
||||
tag,
|
||||
commit_sha,
|
||||
})
|
||||
}
|
||||
|
|
@ -349,7 +267,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, None).ok()?,
|
||||
decide_clone(skip_clone, clone_origin_url, None, None).ok()?,
|
||||
CloneDecision::GitHub { .. }
|
||||
))
|
||||
}
|
||||
|
|
@ -389,8 +307,17 @@ 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 = run_shell_output(cwd, command);
|
||||
let output = isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
|
||||
"--noprofile",
|
||||
"--norc",
|
||||
"-c",
|
||||
command,
|
||||
]));
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"command failed: {}",
|
||||
|
|
@ -399,19 +326,6 @@ 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!(
|
||||
|
|
@ -420,7 +334,6 @@ mod tests {
|
|||
Some("https://gitlab.com/acme/widgets.git"),
|
||||
Some("main"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::EmptyWorkspace {
|
||||
|
|
@ -432,7 +345,7 @@ mod tests {
|
|||
#[test]
|
||||
fn missing_origin_creates_empty_workspace() {
|
||||
assert_eq!(
|
||||
decide_clone(false, None, None, None, None).unwrap(),
|
||||
decide_clone(false, None, None, None).unwrap(),
|
||||
CloneDecision::EmptyWorkspace {
|
||||
reason: EmptyWorkspaceReason::MissingOrigin,
|
||||
}
|
||||
|
|
@ -447,63 +360,16 @@ 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(
|
||||
|
|
@ -511,7 +377,6 @@ 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"));
|
||||
|
|
@ -527,14 +392,12 @@ 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()),
|
||||
}
|
||||
);
|
||||
|
|
@ -543,14 +406,12 @@ 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()),
|
||||
}
|
||||
);
|
||||
|
|
@ -571,7 +432,6 @@ mod tests {
|
|||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
None,
|
||||
None,
|
||||
Some(sha),
|
||||
)
|
||||
.expect_err("invalid exact commit SHA should fail");
|
||||
|
|
@ -583,50 +443,33 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_checkout_requires_clone_origin_and_branch() {
|
||||
fn exact_checkout_requires_clone_origin_and_branch() {
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
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,
|
||||
let skip_error = decide_clone(
|
||||
true,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("main"),
|
||||
Some(""),
|
||||
None,
|
||||
Some(sha),
|
||||
)
|
||||
.expect_err("empty tags should fail");
|
||||
assert!(empty_tag.to_string().contains("non-empty tag"));
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -636,7 +479,7 @@ mod tests {
|
|||
"https://token@example.com/acme/widgets.git?x=a b",
|
||||
"/repos/acme's widgets",
|
||||
);
|
||||
let fetch = pinned_fetch_command(
|
||||
let fetch = exact_fetch_command(
|
||||
"/repos/acme's widgets",
|
||||
"https://token@example.com/acme/widgets.git?x=a b",
|
||||
sha,
|
||||
|
|
@ -660,9 +503,9 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_fetch_omits_depth_for_full_history() {
|
||||
fn exact_fetch_omits_depth_for_full_history() {
|
||||
assert_eq!(
|
||||
pinned_fetch_command(
|
||||
exact_fetch_command(
|
||||
"/repos/acme/widgets",
|
||||
"origin",
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
|
|
@ -675,18 +518,15 @@ mod tests {
|
|||
#[test]
|
||||
fn exact_checkout_verification_rejects_invalid_or_mismatched_head() {
|
||||
let expected = "0123456789abcdef0123456789abcdef01234567";
|
||||
let pin = PinnedRevision::Commit(expected.to_string());
|
||||
pin.verify_head("0123456789ABCDEF0123456789ABCDEF01234567\n")
|
||||
verify_exact_head("0123456789ABCDEF0123456789ABCDEF01234567\n", expected)
|
||||
.expect("uppercase command output should normalize");
|
||||
|
||||
let invalid = pin
|
||||
.verify_head("fatal: not a revision")
|
||||
let invalid = verify_exact_head("fatal: not a revision", expected)
|
||||
.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 = pin
|
||||
.verify_head("1123456789abcdef0123456789abcdef01234567")
|
||||
let mismatched = verify_exact_head("1123456789abcdef0123456789abcdef01234567", expected)
|
||||
.expect_err("mismatched SHA should fail verification");
|
||||
assert!(mismatched.to_string().contains("did not match"));
|
||||
}
|
||||
|
|
@ -696,7 +536,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_and_tag_advance() {
|
||||
fn exact_checkout_fetches_admitted_commit_after_branch_advances() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let remote = temp.path().join("remote.git");
|
||||
let source = temp.path().join("source");
|
||||
|
|
@ -721,14 +561,10 @@ 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);
|
||||
|
||||
|
|
@ -740,11 +576,11 @@ mod tests {
|
|||
);
|
||||
run_shell(
|
||||
temp.path(),
|
||||
&pinned_fetch_command(checkout_path, remote_path, &admitted_sha, Some(10)),
|
||||
&exact_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_COMMIT),
|
||||
&exact_checkout_verify_command(checkout_path, "main", "FETCH_HEAD"),
|
||||
);
|
||||
|
||||
assert_eq!(checked_out_sha.trim(), admitted_sha);
|
||||
|
|
@ -773,79 +609,6 @@ 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, PinnedRevision};
|
||||
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason};
|
||||
use crate::git_retry::{self, CredentialContext, GitRetryReason};
|
||||
use crate::push_credentials::{self, PushCredentialState};
|
||||
use crate::redact::redact_auth_url;
|
||||
|
|
@ -61,11 +61,6 @@ const DAYTONA_BASH_SESSION_REMEDIATION: &str = "Daytona ran the direct command t
|
|||
|
||||
pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace";
|
||||
pub(crate) const REPOS_ROOT: &str = "/home/daytona/repos";
|
||||
// Beneath the system tmp dir so any sandbox user can create it; the
|
||||
// trailing `runtime` component is load-bearing — materialized blobs at
|
||||
// `runtime/blobs/{hash}.json` are recognized as managed blob references and
|
||||
// normalized back to `blob://` in durable context.
|
||||
pub(crate) const RUNTIME_DIRECTORY: &str = "/tmp/fabro/runtime";
|
||||
const DEFAULT_SNAPSHOT: &str = "daytona-medium";
|
||||
pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api";
|
||||
pub(crate) const DAYTONA_DASHBOARD_SANDBOXES_URL: &str =
|
||||
|
|
@ -109,16 +104,6 @@ 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)
|
||||
}
|
||||
|
|
@ -474,7 +459,6 @@ 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>,
|
||||
}
|
||||
|
||||
|
|
@ -489,16 +473,14 @@ 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_tag.is_some() || clone_commit_sha.is_some() {
|
||||
if 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(),
|
||||
)?;
|
||||
}
|
||||
|
|
@ -525,7 +507,6 @@ impl DaytonaSandbox {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
})
|
||||
}
|
||||
|
|
@ -579,7 +560,6 @@ impl DaytonaSandbox {
|
|||
run_id: None,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
})
|
||||
}
|
||||
|
|
@ -665,29 +645,25 @@ impl DaytonaSandbox {
|
|||
self.fail_init(init_start, err)
|
||||
}
|
||||
|
||||
/// Point the admitted branch at the pinned revision and verify the
|
||||
/// resulting HEAD.
|
||||
/// Point the admitted branch at the exact commit and verify the resulting
|
||||
/// HEAD.
|
||||
///
|
||||
/// Daytona's native clone honors `commit_id`, but leaves the workspace on
|
||||
/// 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(
|
||||
/// 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(
|
||||
process_svc: &daytona_sdk::ProcessService,
|
||||
checkout_path: &str,
|
||||
branch: &str,
|
||||
pin: &PinnedRevision,
|
||||
expected_sha: &str,
|
||||
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, revision),
|
||||
&clone_source::exact_branch_checkout_command(checkout_path, branch, expected_sha),
|
||||
"/",
|
||||
"git checkout pinned revision",
|
||||
"git checkout exact commit",
|
||||
deadline,
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -695,12 +671,11 @@ impl DaytonaSandbox {
|
|||
process_svc,
|
||||
&clone_source::exact_head_revision_command(checkout_path),
|
||||
"/",
|
||||
"git rev-parse HEAD after pinned checkout",
|
||||
"git rev-parse HEAD after exact checkout",
|
||||
deadline,
|
||||
)
|
||||
.await?;
|
||||
pin.verify_head(&head)?;
|
||||
Ok(())
|
||||
clone_source::verify_exact_head(&head, expected_sha)
|
||||
}
|
||||
|
||||
/// Execute one post-clone command under the shared setup deadline.
|
||||
|
|
@ -811,41 +786,6 @@ impl DaytonaSandbox {
|
|||
Self::probe_bash_session(sandbox).await
|
||||
}
|
||||
|
||||
/// Create the run-scoped Fabro runtime directory outside the repository
|
||||
/// checkout, with owner-private permissions on each created level.
|
||||
async fn create_runtime_directory(sandbox: &daytona_sdk::Sandbox) -> crate::Result<()> {
|
||||
let fs_svc = sandbox
|
||||
.fs()
|
||||
.await
|
||||
.map_err(|e| crate::Error::context("Failed to get Daytona fs service", e))?;
|
||||
let runtime_parent = Path::new(RUNTIME_DIRECTORY)
|
||||
.parent()
|
||||
.map(|parent| parent.to_string_lossy().to_string());
|
||||
if let Some(runtime_parent) = runtime_parent {
|
||||
fs_svc
|
||||
.create_folder(&runtime_parent, Some("0700"))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
wrap_fs_error(
|
||||
"Failed to create Daytona runtime parent directory",
|
||||
&runtime_parent,
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs_svc
|
||||
.create_folder(RUNTIME_DIRECTORY, Some("0700"))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
wrap_fs_error(
|
||||
"Failed to create Daytona runtime directory",
|
||||
RUNTIME_DIRECTORY,
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Probe Bash over the direct process-exec transport.
|
||||
async fn probe_bash_exec(sandbox: &daytona_sdk::Sandbox) -> crate::Result<()> {
|
||||
let start = Instant::now();
|
||||
|
|
@ -1530,18 +1470,10 @@ impl Sandbox for DaytonaSandbox {
|
|||
return Err(self.fail_init(init_start, err));
|
||||
}
|
||||
|
||||
if let Err(runtime_error) = Self::create_runtime_directory(&sandbox).await {
|
||||
let err = self
|
||||
.finish_failed_initialization(sandbox, runtime_error)
|
||||
.await;
|
||||
return Err(self.fail_init(init_start, err));
|
||||
}
|
||||
|
||||
let clone_decision = clone_source::decide_clone(
|
||||
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))?;
|
||||
|
|
@ -1570,7 +1502,6 @@ impl Sandbox for DaytonaSandbox {
|
|||
CloneDecision::GitHub {
|
||||
origin_url,
|
||||
branch,
|
||||
tag,
|
||||
commit_sha,
|
||||
} => {
|
||||
let layout =
|
||||
|
|
@ -1669,8 +1600,6 @@ 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,
|
||||
|
|
@ -1681,7 +1610,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
let origin = origin_url.as_str();
|
||||
let target = layout.primary_repo_path.as_str();
|
||||
let options = GitCloneOptions {
|
||||
branch: clone_selector.clone(),
|
||||
branch: branch.clone(),
|
||||
commit_id: commit_sha.clone(),
|
||||
username: username.clone(),
|
||||
password: password.clone(),
|
||||
|
|
@ -1726,22 +1655,21 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
};
|
||||
|
||||
if let Some(pin) = &pin {
|
||||
if let Some(expected_sha) = commit_sha.as_deref() {
|
||||
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty())
|
||||
else {
|
||||
let err = crate::Error::message(format!(
|
||||
"{} requires a repository branch",
|
||||
pin.label()
|
||||
));
|
||||
let err = crate::Error::message(
|
||||
"Exact commit checkout requires a repository branch",
|
||||
);
|
||||
return Err(self
|
||||
.fail_clone_initialization(sandbox, &origin_url, init_start, err)
|
||||
.await);
|
||||
};
|
||||
if let Err(err) = Self::attach_pinned_branch(
|
||||
if let Err(err) = Self::attach_exact_commit_branch(
|
||||
&process_svc,
|
||||
&layout.primary_repo_path,
|
||||
branch,
|
||||
pin,
|
||||
expected_sha,
|
||||
post_clone_deadline,
|
||||
)
|
||||
.await
|
||||
|
|
@ -1944,10 +1872,6 @@ impl Sandbox for DaytonaSandbox {
|
|||
.map_or(WORKING_DIRECTORY, String::as_str)
|
||||
}
|
||||
|
||||
fn runtime_directory(&self) -> Option<&str> {
|
||||
Some(RUNTIME_DIRECTORY)
|
||||
}
|
||||
|
||||
fn platform(&self) -> &'static str {
|
||||
"linux"
|
||||
}
|
||||
|
|
@ -3218,25 +3142,6 @@ 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(
|
||||
|
|
@ -3245,7 +3150,6 @@ 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()),
|
||||
)
|
||||
|
|
@ -3265,7 +3169,6 @@ mod tests {
|
|||
None,
|
||||
Some("https://github.com/acme/widgets".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
Some("dtn_not_used".to_string()),
|
||||
)
|
||||
|
|
@ -3593,7 +3496,6 @@ mod tests {
|
|||
run_id: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -3773,7 +3675,6 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dtn_test".to_string()),
|
||||
)
|
||||
.await
|
||||
|
|
@ -3813,7 +3714,6 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dtn_test".to_string()),
|
||||
)
|
||||
.await
|
||||
|
|
@ -4142,7 +4042,6 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dtn_test".to_string()),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -49,11 +49,6 @@ const DOCKER_BASH_REQUIREMENT: &str = "Docker sandboxes require /bin/bash for ev
|
|||
|
||||
pub(crate) const WORKING_DIRECTORY: &str = "/workspace";
|
||||
pub(crate) const REPOS_ROOT: &str = "/repos";
|
||||
// Beneath the system tmp dir so any container user can create it; the
|
||||
// trailing `runtime` component is load-bearing — materialized blobs at
|
||||
// `runtime/blobs/{hash}.json` are recognized as managed blob references and
|
||||
// normalized back to `blob://` in durable context.
|
||||
pub(crate) const RUNTIME_DIRECTORY: &str = "/tmp/fabro/runtime";
|
||||
const DEFAULT_GIT_CLONE_DEPTH: usize = RunCloneSettings::DEFAULT_DEPTH.unsigned_abs() as usize;
|
||||
const GIT_CLONE_TIMEOUT: Duration = Duration::from_mins(5);
|
||||
#[cfg(test)]
|
||||
|
|
@ -156,7 +151,6 @@ 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>,
|
||||
|
|
@ -188,15 +182,13 @@ 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_tag.is_some() || clone_commit_sha.is_some() {
|
||||
if 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(),
|
||||
)?;
|
||||
}
|
||||
|
|
@ -208,7 +200,6 @@ impl DockerSandbox {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
)
|
||||
}
|
||||
|
|
@ -220,7 +211,6 @@ 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(
|
||||
|
|
@ -234,7 +224,6 @@ impl DockerSandbox {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
container_id: OnceCell::new(),
|
||||
repo_cloned: OnceCell::new(),
|
||||
|
|
@ -262,7 +251,6 @@ impl DockerSandbox {
|
|||
clone_origin_url.clone(),
|
||||
clone_branch,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
sandbox.validate_managed_container(container_id).await?;
|
||||
sandbox
|
||||
|
|
@ -729,28 +717,6 @@ impl DockerSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the run-scoped Fabro runtime directory outside the repository
|
||||
/// checkout. The umask keeps every created level owner-private.
|
||||
async fn create_runtime_directory(&self) -> crate::Result<()> {
|
||||
let result = self
|
||||
.docker_exec_shell(
|
||||
&format!("umask 077 && mkdir -p {}", shell_quote(RUNTIME_DIRECTORY)),
|
||||
10_000,
|
||||
Some("/"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if !result.is_success() {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Failed to create Docker runtime directory (exit {}): {}",
|
||||
result.display_exit_code(),
|
||||
result.stderr
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify the container evaluates commands as non-login Bash.
|
||||
///
|
||||
/// Shared by fresh initialization and by `start` after a reconnect, so a
|
||||
|
|
@ -910,7 +876,6 @@ impl DockerSandbox {
|
|||
&self,
|
||||
origin_url: String,
|
||||
branch: Option<String>,
|
||||
tag: Option<String>,
|
||||
commit_sha: Option<String>,
|
||||
) -> crate::Result<()> {
|
||||
self.verify_git_available().await?;
|
||||
|
|
@ -973,15 +938,13 @@ impl DockerSandbox {
|
|||
}
|
||||
|
||||
let clone_deadline = time::Instant::now() + GIT_CLONE_TIMEOUT;
|
||||
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.
|
||||
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.
|
||||
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty()) else {
|
||||
let error =
|
||||
crate::Error::message(format!("{} requires a repository branch", pin.label()));
|
||||
crate::Error::message("Exact commit checkout requires a repository branch");
|
||||
return Err(self.report_clone_failure(&origin_url, error));
|
||||
};
|
||||
|
||||
|
|
@ -990,7 +953,7 @@ impl DockerSandbox {
|
|||
if let Err(error) = self
|
||||
.run_exact_local_git_command(
|
||||
&init_command,
|
||||
"initialize Docker pinned repository checkout",
|
||||
"initialize Docker exact repository checkout",
|
||||
clone_deadline,
|
||||
auth_url.as_ref(),
|
||||
)
|
||||
|
|
@ -999,18 +962,18 @@ impl DockerSandbox {
|
|||
return Err(self.report_clone_failure(&origin_url, error));
|
||||
}
|
||||
|
||||
let fetch_command = clone_source::pinned_fetch_command(
|
||||
let fetch_command = clone_source::exact_fetch_command(
|
||||
&layout.primary_repo_path,
|
||||
"origin",
|
||||
&pin.fetch_refspec(),
|
||||
expected_sha,
|
||||
self.config.clone_depth,
|
||||
);
|
||||
if let Err(failure) = self
|
||||
.retry_git_transfer(
|
||||
&fetch_command,
|
||||
"fetch",
|
||||
"Docker pinned fetch",
|
||||
"git fetch pinned revision",
|
||||
"Docker exact fetch",
|
||||
"git fetch exact commit",
|
||||
clone_deadline,
|
||||
clone_credential_context,
|
||||
auth_url.as_ref(),
|
||||
|
|
@ -1023,12 +986,12 @@ impl DockerSandbox {
|
|||
let checkout_command = clone_source::exact_checkout_verify_command(
|
||||
&layout.primary_repo_path,
|
||||
branch,
|
||||
clone_source::FETCH_HEAD_COMMIT,
|
||||
"FETCH_HEAD",
|
||||
);
|
||||
let head = match self
|
||||
.run_exact_local_git_command(
|
||||
&checkout_command,
|
||||
"git checkout pinned revision",
|
||||
"git checkout exact commit",
|
||||
clone_deadline,
|
||||
auth_url.as_ref(),
|
||||
)
|
||||
|
|
@ -1037,7 +1000,7 @@ impl DockerSandbox {
|
|||
Ok(result) => result,
|
||||
Err(error) => return Err(self.report_clone_failure(&origin_url, error)),
|
||||
};
|
||||
if let Err(error) = pin.verify_head(&head.stdout) {
|
||||
if let Err(error) = clone_source::verify_exact_head(&head.stdout, expected_sha) {
|
||||
return Err(self.report_clone_failure(&origin_url, error));
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1181,16 +1144,14 @@ impl DockerSandbox {
|
|||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Fabro runtime files stay owner-private; repository files keep the
|
||||
// conventional world-readable mode.
|
||||
let is_runtime_path = container_path.starts_with(&format!("{RUNTIME_DIRECTORY}/"));
|
||||
let mkdir_cmd = if is_runtime_path {
|
||||
format!("umask 077 && mkdir -p {}", shell_quote(&parent_dir))
|
||||
} else {
|
||||
format!("mkdir -p {}", shell_quote(&parent_dir))
|
||||
};
|
||||
let result = self
|
||||
.docker_exec_shell(&mkdir_cmd, 10_000, Some("/"), None, None)
|
||||
.docker_exec_shell(
|
||||
&format!("mkdir -p {}", shell_quote(&parent_dir)),
|
||||
10_000,
|
||||
Some("/"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if !result.is_success() {
|
||||
return Err(crate::Error::message(format!(
|
||||
|
|
@ -1199,8 +1160,7 @@ impl DockerSandbox {
|
|||
)));
|
||||
}
|
||||
|
||||
let file_mode = if is_runtime_path { 0o600 } else { 0o644 };
|
||||
let tar_bytes = build_single_file_tar(&file_name, bytes, file_mode)?;
|
||||
let tar_bytes = build_single_file_tar(&file_name, bytes)?;
|
||||
let upload_opts = UploadToContainerOptions {
|
||||
path: parent_dir,
|
||||
no_overwrite_dir_non_dir: "false".to_string(),
|
||||
|
|
@ -1700,7 +1660,7 @@ fn bash_remediation(image: &str) -> String {
|
|||
format!("Failed to start Docker container from image '{image}'. {DOCKER_BASH_REQUIREMENT}")
|
||||
}
|
||||
|
||||
fn build_single_file_tar(file_name: &str, bytes: &[u8], mode: u32) -> crate::Result<Vec<u8>> {
|
||||
fn build_single_file_tar(file_name: &str, bytes: &[u8]) -> crate::Result<Vec<u8>> {
|
||||
let mut tar_builder = tar::Builder::new(Vec::new());
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header
|
||||
|
|
@ -1710,7 +1670,7 @@ fn build_single_file_tar(file_name: &str, bytes: &[u8], mode: u32) -> crate::Res
|
|||
u64::try_from(bytes.len())
|
||||
.map_err(|_| crate::Error::message("file is too large for tar header"))?,
|
||||
);
|
||||
header.set_mode(mode);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar_builder
|
||||
.append(&header, bytes)
|
||||
|
|
@ -1828,15 +1788,10 @@ impl Sandbox for DockerSandbox {
|
|||
.cached_os_version
|
||||
.set(format!("linux {}", uname_output.trim()));
|
||||
|
||||
if let Err(e) = self.create_runtime_directory().await {
|
||||
return Err(self.fail_init(init_start, e));
|
||||
}
|
||||
|
||||
let clone_decision = clone_source::decide_clone(
|
||||
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))?;
|
||||
|
|
@ -1858,13 +1813,9 @@ impl Sandbox for DockerSandbox {
|
|||
CloneDecision::GitHub {
|
||||
origin_url,
|
||||
branch,
|
||||
tag,
|
||||
commit_sha,
|
||||
} => {
|
||||
if let Err(e) = self
|
||||
.clone_github_repo(origin_url, branch, tag, commit_sha)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = self.clone_github_repo(origin_url, branch, commit_sha).await {
|
||||
return Err(self.fail_init(init_start, e));
|
||||
}
|
||||
}
|
||||
|
|
@ -2362,10 +2313,6 @@ impl Sandbox for DockerSandbox {
|
|||
.map_or(WORKING_DIRECTORY, String::as_str)
|
||||
}
|
||||
|
||||
fn runtime_directory(&self) -> Option<&str> {
|
||||
Some(RUNTIME_DIRECTORY)
|
||||
}
|
||||
|
||||
async fn ssh_access_command(&self) -> crate::Result<Option<String>> {
|
||||
Ok(Some(docker_access_command(
|
||||
self.container_id()?,
|
||||
|
|
@ -2683,7 +2630,6 @@ mod tests {
|
|||
None,
|
||||
Some("https://github.com/acme/widgets".to_string()),
|
||||
Some("main".to_string()),
|
||||
None,
|
||||
Some("not-a-sha".to_string()),
|
||||
)
|
||||
.err()
|
||||
|
|
@ -2701,7 +2647,6 @@ mod tests {
|
|||
None,
|
||||
Some("https://github.com/acme/widgets".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
||||
)
|
||||
.err()
|
||||
|
|
@ -3060,26 +3005,16 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn single_file_tar_contains_named_file() {
|
||||
let bytes = build_single_file_tar("nested.txt", b"hello", 0o644).unwrap();
|
||||
let bytes = build_single_file_tar("nested.txt", b"hello").unwrap();
|
||||
let mut archive = tar::Archive::new(Cursor::new(bytes));
|
||||
let mut entries = archive.entries().unwrap();
|
||||
let mut entry = entries.next().unwrap().unwrap();
|
||||
assert_eq!(entry.path().unwrap().to_string_lossy(), "nested.txt");
|
||||
assert_eq!(entry.header().mode().unwrap(), 0o644);
|
||||
let mut content = String::new();
|
||||
entry.read_to_string(&mut content).unwrap();
|
||||
assert_eq!(content, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_file_tar_applies_private_mode() {
|
||||
let bytes = build_single_file_tar("blob.json", b"{}", 0o600).unwrap();
|
||||
let mut archive = tar::Archive::new(Cursor::new(bytes));
|
||||
let mut entries = archive.entries().unwrap();
|
||||
let entry = entries.next().unwrap().unwrap();
|
||||
assert_eq!(entry.header().mode().unwrap(), 0o600);
|
||||
}
|
||||
|
||||
fn test_docker_sandbox(docker: Docker, container_id: &str) -> DockerSandbox {
|
||||
let sandbox = DockerSandbox::with_docker_client(
|
||||
docker,
|
||||
|
|
@ -3089,7 +3024,6 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("test sandbox should build");
|
||||
sandbox
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ impl SandboxProvider for DaytonaSandboxProvider {
|
|||
clone_origin_url,
|
||||
clone_branch,
|
||||
None,
|
||||
None,
|
||||
Some(api_key),
|
||||
)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ impl SandboxProvider for DockerSandboxProvider {
|
|||
clone_origin_url,
|
||||
clone_branch,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
sandbox.initialize().await?;
|
||||
let container_id = sandbox.container_identifier()?.to_string();
|
||||
|
|
|
|||
|
|
@ -1441,24 +1441,6 @@ pub trait Sandbox: Send + Sync {
|
|||
}
|
||||
async fn cleanup(&self) -> crate::Result<()>;
|
||||
fn working_directory(&self) -> &str;
|
||||
/// Run-scoped directory for Fabro-owned runtime files inside the sandbox,
|
||||
/// or `None` when the sandbox has no such directory.
|
||||
///
|
||||
/// The directory sits outside every repository checkout, so runtime files
|
||||
/// Fabro materializes beneath it — for example oversized prompt values
|
||||
/// projected out of the durable blob store — never appear in `git status`
|
||||
/// and can never be committed by a checkpoint. Its contents are
|
||||
/// disposable: everything beneath it can be recreated from durable
|
||||
/// storage on demand.
|
||||
///
|
||||
/// Providers that provision an isolated per-run environment (Docker,
|
||||
/// Daytona) create the directory during initialization with private
|
||||
/// permissions and return its path. Sandboxes that execute directly on
|
||||
/// the worker host return `None`; the workflow engine owns a host-side
|
||||
/// runtime directory for those runs.
|
||||
fn runtime_directory(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn platform(&self) -> &str;
|
||||
fn os_version(&self) -> String;
|
||||
/// Return a human-readable identifier for the sandbox (e.g. container ID,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ 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")]
|
||||
|
|
@ -42,7 +41,6 @@ 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>,
|
||||
},
|
||||
|
|
@ -206,7 +204,6 @@ impl SandboxSpec {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
} => {
|
||||
let mut sandbox = DockerSandbox::new(
|
||||
|
|
@ -215,7 +212,6 @@ impl SandboxSpec {
|
|||
*run_id,
|
||||
clone_origin_url.clone(),
|
||||
clone_branch.clone(),
|
||||
clone_tag.clone(),
|
||||
clone_commit_sha.clone(),
|
||||
)
|
||||
.context("Failed to create Docker sandbox")?;
|
||||
|
|
@ -231,7 +227,6 @@ impl SandboxSpec {
|
|||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
api_key,
|
||||
} => {
|
||||
|
|
@ -241,7 +236,6 @@ impl SandboxSpec {
|
|||
*run_id,
|
||||
clone_origin_url.clone(),
|
||||
clone_branch.clone(),
|
||||
clone_tag.clone(),
|
||||
clone_commit_sha.clone(),
|
||||
api_key.clone(),
|
||||
)
|
||||
|
|
@ -288,7 +282,6 @@ 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();
|
||||
|
|
@ -327,7 +320,6 @@ 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()),
|
||||
};
|
||||
|
||||
|
|
@ -357,7 +349,6 @@ 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,7 +38,6 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
|
@ -77,7 +76,6 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
sandbox.initialize().await?;
|
||||
|
|
@ -184,7 +182,6 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
@ -235,7 +232,6 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
@ -304,7 +300,6 @@ mod daytona_streaming_live {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
@ -115,7 +114,6 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
@ -178,7 +176,6 @@ 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
|
||||
|
|
@ -246,7 +243,6 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
@ -341,7 +337,6 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
|
|
@ -400,79 +395,3 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
|
|||
"`**/SKILL.md` should match files nested several levels deep, got: {recursive:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The Fabro runtime directory is where prompt blobs materialize, so it must
|
||||
// exist after initialization, sit outside the repository checkout, and stay
|
||||
// owner-private along with the files written beneath it (issue #798).
|
||||
#[tokio::test]
|
||||
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker runtime directory setup"]
|
||||
async fn docker_runtime_directory_is_private_and_outside_workspace() {
|
||||
let image = "buildpack-deps:noble";
|
||||
let Ok(docker) = Docker::connect_with_local_defaults() else {
|
||||
return;
|
||||
};
|
||||
if docker.inspect_image(image).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = DockerSandbox::new(
|
||||
DockerSandboxOptions {
|
||||
image: image.to_string(),
|
||||
auto_pull: false,
|
||||
skip_clone: true,
|
||||
..DockerSandboxOptions::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
.await
|
||||
.expect("docker sandbox should initialize");
|
||||
|
||||
let runtime_directory = sandbox
|
||||
.runtime_directory()
|
||||
.expect("docker sandbox should expose a runtime directory")
|
||||
.to_string();
|
||||
assert!(
|
||||
!runtime_directory.starts_with(sandbox.working_directory()),
|
||||
"runtime directory {runtime_directory} must sit outside the workspace"
|
||||
);
|
||||
|
||||
let blob_path = format!("{runtime_directory}/blobs/test-blob.json");
|
||||
sandbox
|
||||
.write_file(&blob_path, "{}")
|
||||
.await
|
||||
.expect("runtime blob write should succeed");
|
||||
|
||||
let modes = sandbox
|
||||
.exec_command(
|
||||
&format!("stat -c '%a' {runtime_directory} {blob_path}"),
|
||||
10_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("stat should run");
|
||||
let readback = sandbox.read_file_text(&blob_path).await;
|
||||
|
||||
sandbox
|
||||
.cleanup()
|
||||
.await
|
||||
.expect("docker cleanup should succeed");
|
||||
|
||||
assert!(modes.is_success(), "stat failed: {}", modes.stderr);
|
||||
let modes: Vec<&str> = modes.stdout.split_whitespace().collect();
|
||||
assert_eq!(
|
||||
modes,
|
||||
["700", "600"],
|
||||
"runtime directory and blob file should be owner-private"
|
||||
);
|
||||
assert_eq!(readback.expect("runtime blob should be readable"), "{}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ tokio-stream.workspace = true
|
|||
dashmap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
strum.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
|
|
|
|||
|
|
@ -1,370 +0,0 @@
|
|||
//! SQLite-backed storage for pending CLI authorizations.
|
||||
//!
|
||||
//! The raw authorization code is a one-time bearer credential. It remains at
|
||||
//! the HTTP boundary and is hashed before every database operation; the
|
||||
//! stored domain type owns only the approved authorization the code unlocks.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::IdpIdentity;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use sqlx::sqlite::SqliteRow;
|
||||
use sqlx::{Row as _, SqlitePool};
|
||||
|
||||
use crate::{Result, sqlite_row};
|
||||
|
||||
const RECORD_NAME: &str = "pending CLI authorization";
|
||||
|
||||
/// Approved identity and OAuth context waiting for a CLI code exchange.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingCliAuthorization {
|
||||
pub identity: IdpIdentity,
|
||||
pub login: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub avatar_url: String,
|
||||
pub code_challenge: String,
|
||||
pub redirect_uri: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Issues, consumes, and expires pending CLI authorizations in SQLite.
|
||||
pub struct AuthCodeStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AuthCodeStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthCodeStore").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthCodeStore {
|
||||
#[must_use]
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Persist a pending authorization under the SHA-256 digest of `code`.
|
||||
pub async fn issue(&self, code: &str, pending: &PendingCliAuthorization) -> Result<()> {
|
||||
let code_hash = hash_code(code);
|
||||
sqlx::query(
|
||||
r"
|
||||
INSERT INTO oauth_authorization_codes (
|
||||
code_hash, identity_issuer, identity_subject, login, name, email,
|
||||
avatar_url, code_challenge, redirect_uri, expires_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
",
|
||||
)
|
||||
.bind(code_hash.as_slice())
|
||||
.bind(pending.identity.issuer())
|
||||
.bind(pending.identity.subject())
|
||||
.bind(&pending.login)
|
||||
.bind(&pending.name)
|
||||
.bind(&pending.email)
|
||||
.bind(&pending.avatar_url)
|
||||
.bind(&pending.code_challenge)
|
||||
.bind(&pending.redirect_uri)
|
||||
.bind(pending.expires_at.timestamp_millis())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically remove the authorization for `code` and return it if live.
|
||||
///
|
||||
/// Expiry is checked after deletion so every exchange attempt burns a
|
||||
/// matching code, including an expired one.
|
||||
pub async fn consume(
|
||||
&self,
|
||||
code: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Option<PendingCliAuthorization>> {
|
||||
let code_hash = hash_code(code);
|
||||
let row = sqlx::query(
|
||||
r"
|
||||
DELETE FROM oauth_authorization_codes
|
||||
WHERE code_hash = ?
|
||||
RETURNING identity_issuer, identity_subject, login, name, email, avatar_url,
|
||||
code_challenge, redirect_uri, expires_at_ms
|
||||
",
|
||||
)
|
||||
.bind(code_hash.as_slice())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let pending = pending_from_row(&row)?;
|
||||
if pending.expires_at <= now {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(pending))
|
||||
}
|
||||
|
||||
/// Delete authorizations expiring at or before `cutoff`.
|
||||
pub async fn gc_expired(&self, cutoff: DateTime<Utc>) -> Result<u64> {
|
||||
let result = sqlx::query("DELETE FROM oauth_authorization_codes WHERE expires_at_ms <= ?")
|
||||
.bind(cutoff.timestamp_millis())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Close the shared pool to exercise storage-failure paths in consumers.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub async fn test_close(&self) {
|
||||
self.pool.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_code(code: &str) -> [u8; 32] {
|
||||
Sha256::digest(code.as_bytes()).into()
|
||||
}
|
||||
|
||||
fn pending_from_row(row: &SqliteRow) -> Result<PendingCliAuthorization> {
|
||||
Ok(PendingCliAuthorization {
|
||||
identity: sqlite_row::identity_from_row(row, RECORD_NAME)?,
|
||||
login: row.try_get("login")?,
|
||||
name: row.try_get("name")?,
|
||||
email: row.try_get("email")?,
|
||||
avatar_url: row.try_get("avatar_url")?,
|
||||
code_challenge: row.try_get("code_challenge")?,
|
||||
redirect_uri: row.try_get("redirect_uri")?,
|
||||
expires_at: sqlite_row::timestamp_from_row(row, RECORD_NAME, "expires_at_ms")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use fabro_types::IdpIdentity;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tokio::fs;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use super::{AuthCodeStore, PendingCliAuthorization};
|
||||
use crate::{Error, test_support};
|
||||
|
||||
fn pending(expires_at: chrono::DateTime<Utc>) -> PendingCliAuthorization {
|
||||
PendingCliAuthorization {
|
||||
identity: IdpIdentity::new("https://github.com", "12345").unwrap(),
|
||||
login: "octocat".to_string(),
|
||||
name: "The Octocat".to_string(),
|
||||
email: "octocat@example.com".to_string(),
|
||||
avatar_url: "https://example.com/octocat.png".to_string(),
|
||||
code_challenge: "challenge".to_string(),
|
||||
redirect_uri: "http://127.0.0.1:4444/callback".to_string(),
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> chrono::DateTime<Utc> {
|
||||
chrono::DateTime::from_timestamp_millis(Utc::now().timestamp_millis()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_and_consume_round_trips_once() {
|
||||
let (_directory, store) = test_support::sqlite_auth_code_store().await;
|
||||
let now = now();
|
||||
let expected = pending(now + Duration::seconds(60));
|
||||
store.issue("one-time-code", &expected).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.consume("one-time-code", now).await.unwrap(),
|
||||
Some(expected)
|
||||
);
|
||||
assert!(store.consume("one-time-code", now).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_consume_has_one_winner_across_store_instances() {
|
||||
let (_directory, store) = test_support::sqlite_auth_code_store().await;
|
||||
let now = now();
|
||||
store
|
||||
.issue("contended-code", &pending(now + Duration::seconds(60)))
|
||||
.await
|
||||
.unwrap();
|
||||
let stores = [
|
||||
Arc::new(AuthCodeStore::new(store.pool.clone())),
|
||||
Arc::new(AuthCodeStore::new(store.pool.clone())),
|
||||
];
|
||||
|
||||
let mut tasks = JoinSet::new();
|
||||
for index in 0..16 {
|
||||
let store = Arc::clone(&stores[index % stores.len()]);
|
||||
tasks.spawn(async move {
|
||||
store
|
||||
.consume("contended-code", now)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
});
|
||||
}
|
||||
|
||||
let mut winners = 0;
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
if result.unwrap() {
|
||||
winners += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(winners, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_consume_deletes_the_row() {
|
||||
let (_directory, store) = test_support::sqlite_auth_code_store().await;
|
||||
let now = now();
|
||||
store
|
||||
.issue("expired-code", &pending(now - Duration::seconds(1)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(store.consume("expired-code", now).await.unwrap().is_none());
|
||||
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_authorization_codes")
|
||||
.fetch_one(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gc_removes_only_rows_at_or_before_cutoff() {
|
||||
let (_directory, store) = test_support::sqlite_auth_code_store().await;
|
||||
let now = now();
|
||||
for (code, expiry) in [
|
||||
("before", now - Duration::seconds(1)),
|
||||
("at", now),
|
||||
("after", now + Duration::seconds(1)),
|
||||
] {
|
||||
store.issue(code, &pending(expiry)).await.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(store.gc_expired(now).await.unwrap(), 2);
|
||||
assert!(store.consume("before", now).await.unwrap().is_none());
|
||||
assert!(store.consume("at", now).await.unwrap().is_none());
|
||||
assert!(store.consume("after", now).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn survives_reopening_the_sqlite_pool() {
|
||||
let (directory, store) = test_support::sqlite_auth_code_store().await;
|
||||
let now = now();
|
||||
let expected = pending(now + Duration::seconds(60));
|
||||
store.issue("durable-code", &expected).await.unwrap();
|
||||
store.pool.close().await;
|
||||
|
||||
let database = fabro_db::Database::connect(directory.path().join("fabro.sqlite3"))
|
||||
.await
|
||||
.unwrap();
|
||||
database.migrate().await.unwrap();
|
||||
let reopened = AuthCodeStore::new(database.clone_pool());
|
||||
assert_eq!(
|
||||
reopened.consume("durable-code", now).await.unwrap(),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_hash_fails_without_overwriting() {
|
||||
let (_directory, store) = test_support::sqlite_auth_code_store().await;
|
||||
let now = now();
|
||||
let first = pending(now + Duration::seconds(60));
|
||||
let mut second = pending(now + Duration::seconds(120));
|
||||
second.login = "different-login".to_string();
|
||||
store.issue("duplicate-code", &first).await.unwrap();
|
||||
|
||||
assert!(store.issue("duplicate-code", &second).await.is_err());
|
||||
assert_eq!(
|
||||
store.consume("duplicate-code", now).await.unwrap(),
|
||||
Some(first)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_do_not_expose_sensitive_fields() {
|
||||
let (_directory, store) = test_support::sqlite_auth_code_store().await;
|
||||
let raw_code = "raw-authorization-code";
|
||||
let entry = pending(now() + Duration::seconds(60));
|
||||
store.issue(raw_code, &entry).await.unwrap();
|
||||
|
||||
let err = store.issue(raw_code, &entry).await.unwrap_err();
|
||||
let rendered = err.to_string();
|
||||
let code_hash = hex::encode(super::hash_code(raw_code));
|
||||
for sensitive in [
|
||||
raw_code,
|
||||
code_hash.as_str(),
|
||||
entry.code_challenge.as_str(),
|
||||
entry.redirect_uri.as_str(),
|
||||
entry.login.as_str(),
|
||||
entry.name.as_str(),
|
||||
entry.email.as_str(),
|
||||
entry.avatar_url.as_str(),
|
||||
] {
|
||||
assert!(
|
||||
!rendered.contains(sensitive),
|
||||
"storage error exposed sensitive field {sensitive:?}: {rendered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistence_contains_hash_but_not_raw_code() {
|
||||
let (directory, store) = test_support::sqlite_auth_code_store().await;
|
||||
let raw_code = "raw-authorization-code-that-must-never-be-persisted";
|
||||
store
|
||||
.issue(raw_code, &pending(Utc::now() + Duration::seconds(60)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let persisted_hash: Vec<u8> =
|
||||
sqlx::query_scalar("SELECT code_hash FROM oauth_authorization_codes")
|
||||
.fetch_one(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_hash: [u8; 32] = Sha256::digest(raw_code.as_bytes()).into();
|
||||
assert_eq!(persisted_hash, expected_hash);
|
||||
sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
.execute(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
store.pool.close().await;
|
||||
let bytes = fs::read(directory.path().join("fabro.sqlite3"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!bytes
|
||||
.windows(raw_code.len())
|
||||
.any(|window| window == raw_code.as_bytes())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_stored_timestamp_is_typed() {
|
||||
let (_directory, store) = test_support::sqlite_auth_code_store().await;
|
||||
store
|
||||
.issue(
|
||||
"invalid-timestamp-code",
|
||||
&pending(Utc::now() + Duration::seconds(60)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE oauth_authorization_codes SET expires_at_ms = ?")
|
||||
.bind(i64::MAX)
|
||||
.execute(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = store
|
||||
.consume("invalid-timestamp-code", Utc::now())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::InvalidStoredTimestamp {
|
||||
record: "pending CLI authorization",
|
||||
field: "expires_at_ms",
|
||||
value: i64::MAX,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -11,9 +11,7 @@ use sqlx::sqlite::SqliteRow;
|
|||
use sqlx::{Row as _, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{Error, Result, sqlite_row};
|
||||
|
||||
const RECORD_NAME: &str = "auth session";
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A CLI auth session: one rotation chain, owned by one identity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -178,8 +176,7 @@ INSERT INTO auth_sessions (
|
|||
.await?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let expires_at =
|
||||
sqlite_row::timestamp_from_row(&row, RECORD_NAME, "expires_at_ms")?;
|
||||
let expires_at = timestamp_from_row(&row, "expires_at_ms")?;
|
||||
Ok(ActiveCliSession {
|
||||
session: session_from_row(&row)?,
|
||||
expires_at,
|
||||
|
|
@ -379,19 +376,34 @@ async fn load_session(
|
|||
}
|
||||
|
||||
fn session_from_row(row: &SqliteRow) -> Result<AuthSessionRecord> {
|
||||
let identity = IdpIdentity::new(
|
||||
row.try_get::<String, _>("identity_issuer")?,
|
||||
row.try_get::<String, _>("identity_subject")?,
|
||||
)
|
||||
.map_err(|err| {
|
||||
Error::Other(format!(
|
||||
"stored auth session has an invalid identity: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(AuthSessionRecord {
|
||||
id: parse_uuid(&row.try_get::<String, _>("id")?)?,
|
||||
identity: sqlite_row::identity_from_row(row, RECORD_NAME)?,
|
||||
login: row.try_get("login")?,
|
||||
name: row.try_get("name")?,
|
||||
email: row.try_get("email")?,
|
||||
avatar_url: row.try_get("avatar_url")?,
|
||||
user_agent: row.try_get("user_agent")?,
|
||||
created_at: sqlite_row::timestamp_from_row(row, RECORD_NAME, "created_at_ms")?,
|
||||
last_used_at: sqlite_row::timestamp_from_row(row, RECORD_NAME, "last_used_at_ms")?,
|
||||
id: parse_uuid(&row.try_get::<String, _>("id")?)?,
|
||||
identity,
|
||||
login: row.try_get("login")?,
|
||||
name: row.try_get("name")?,
|
||||
email: row.try_get("email")?,
|
||||
avatar_url: row.try_get("avatar_url")?,
|
||||
user_agent: row.try_get("user_agent")?,
|
||||
created_at: timestamp_from_row(row, "created_at_ms")?,
|
||||
last_used_at: timestamp_from_row(row, "last_used_at_ms")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn timestamp_from_row(row: &SqliteRow, column: &str) -> Result<DateTime<Utc>> {
|
||||
let millis: i64 = row.try_get(column)?;
|
||||
DateTime::from_timestamp_millis(millis)
|
||||
.ok_or_else(|| Error::Other(format!("stored auth session has an invalid {column}")))
|
||||
}
|
||||
|
||||
fn parse_uuid(value: &str) -> Result<Uuid> {
|
||||
Uuid::parse_str(value)
|
||||
.map_err(|err| Error::Other(format!("stored auth session has an invalid id: {err}")))
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ impl Record for Blob {
|
|||
|
||||
const PREFIX: &'static str = "blobs/sha256";
|
||||
|
||||
#[cfg(test)]
|
||||
fn id(&self) -> Self::Id {
|
||||
BlobHash::new(&self.0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use fabro_types::{BlobHash, IdpIdentityError};
|
||||
use fabro_types::BlobHash;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
|
|
@ -12,18 +12,6 @@ pub enum Error {
|
|||
Serde(#[from] serde_json::Error),
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(#[from] sqlx::Error),
|
||||
#[error("stored {record} has an invalid identity")]
|
||||
InvalidStoredIdentity {
|
||||
record: &'static str,
|
||||
#[source]
|
||||
source: IdpIdentityError,
|
||||
},
|
||||
#[error("stored {record} has an invalid {field} timestamp: {value}")]
|
||||
InvalidStoredTimestamp {
|
||||
record: &'static str,
|
||||
field: &'static str,
|
||||
value: i64,
|
||||
},
|
||||
#[error("stored blob {blob_hash} has bytes that conflict with its hash")]
|
||||
BlobHashConflict { blob_hash: BlobHash },
|
||||
#[error("stored blob data does not match requested hash {blob_hash}")]
|
||||
|
|
@ -56,18 +44,6 @@ pub enum Error {
|
|||
run_id: String,
|
||||
field: &'static str,
|
||||
},
|
||||
#[error("run {run_id} head mismatch: expected {expected_last_seq}, stored {actual_last_seq:?}")]
|
||||
RunHeadMismatch {
|
||||
run_id: String,
|
||||
expected_last_seq: u32,
|
||||
actual_last_seq: Option<u32>,
|
||||
},
|
||||
#[error("stored run event {run_id} sequence {seq} has inconsistent field {field}")]
|
||||
RunEventMismatch {
|
||||
run_id: String,
|
||||
seq: u32,
|
||||
field: &'static str,
|
||||
},
|
||||
#[error(transparent)]
|
||||
InvalidTransition(#[from] fabro_types::InvalidTransition),
|
||||
#[error("{0}")]
|
||||
|
|
|
|||
|
|
@ -1128,7 +1128,7 @@ mod tests {
|
|||
PASSIVE_CHECKPOINT_BYTES, set_automatic_checkpoint,
|
||||
};
|
||||
use crate::keys::SlateKey;
|
||||
use crate::{BlobStore, Database, test_support as store_test_support};
|
||||
use crate::{BlobStore, Database};
|
||||
|
||||
type TestResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
|
|
@ -1153,7 +1153,6 @@ mod tests {
|
|||
Duration::from_millis(1),
|
||||
None,
|
||||
Arc::clone(&target),
|
||||
store_test_support::test_run_summary_store(),
|
||||
);
|
||||
let source_db = source.open_db().await?;
|
||||
Ok(Self {
|
||||
|
|
@ -1854,7 +1853,6 @@ mod tests {
|
|||
Duration::from_millis(1),
|
||||
None,
|
||||
Arc::clone(&target),
|
||||
store_test_support::test_run_summary_store(),
|
||||
);
|
||||
|
||||
let mut connection = pool.acquire().await?;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
|
||||
mod artifact_store;
|
||||
mod auth_code_store;
|
||||
pub mod auth_session_store;
|
||||
mod blob_store;
|
||||
mod error;
|
||||
|
|
@ -14,7 +13,6 @@ mod run_state;
|
|||
mod run_summary_store;
|
||||
mod serializable_projection;
|
||||
mod slate;
|
||||
mod sqlite_row;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
mod types;
|
||||
|
|
@ -23,7 +21,6 @@ pub use artifact_store::{
|
|||
ArtifactKey, ArtifactStore, NodeArtifact, StageArtifactEntry, retry_storage_segment,
|
||||
stage_storage_segment,
|
||||
};
|
||||
pub use auth_code_store::{AuthCodeStore, PendingCliAuthorization};
|
||||
pub use auth_session_store::{
|
||||
ActiveCliSession, AuthSessionRecord, AuthSessionStore, InitialRefreshToken, RotateOutcome,
|
||||
};
|
||||
|
|
@ -47,7 +44,10 @@ pub use run_summary_store::{
|
|||
RunSummarySortDirection, RunSummaryStore, RunSummaryVisibility,
|
||||
};
|
||||
pub use serializable_projection::SerializableProjection;
|
||||
pub use slate::{CachedRunProjection, Database, RunCatalogIndex, RunDatabase, Runs, UnreadableRun};
|
||||
pub use slate::{
|
||||
AuthCode, AuthCodeStore, CachedRunProjection, Database, RunCatalogIndex, RunDatabase, Runs,
|
||||
UnreadableRun,
|
||||
};
|
||||
pub use types::EventPayload;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
use bytes::Bytes;
|
||||
#[cfg(test)]
|
||||
use serde::Serialize;
|
||||
#[cfg(test)]
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
|
@ -12,10 +10,8 @@ pub(crate) trait Codec<R>: Send + Sync + 'static {
|
|||
fn decode(bytes: &[u8]) -> Result<R>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct JsonCodec;
|
||||
|
||||
#[cfg(test)]
|
||||
impl<R> Codec<R> for JsonCodec
|
||||
where
|
||||
R: Serialize + DeserializeOwned,
|
||||
|
|
|
|||
|
|
@ -4,19 +4,18 @@
|
|||
//! - [`Record`]: declares the key prefix, id type, and codec for one persisted
|
||||
//! type.
|
||||
//! - [`RecordId`]: converts the typed id to and from key segments.
|
||||
//! - [`Repository`]: performs the generic get/put/delete/scan operations.
|
||||
//! - [`Repository`]: performs the generic get/put/delete/scan/gc operations.
|
||||
//!
|
||||
//! Production callers should add a named domain store on top of this layer
|
||||
//! rather than exposing `Repository<R>` directly. See `slate/blob_store.rs`
|
||||
//! and `slate/run_catalog_index.rs` for the intended pattern.
|
||||
//! rather than exposing `Repository<R>` directly. See `slate/auth_codes.rs`,
|
||||
//! `slate/blob_store.rs`, and `slate/run_catalog_index.rs` for the intended
|
||||
//! pattern.
|
||||
|
||||
mod codec;
|
||||
mod record_id;
|
||||
mod repository;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use codec::JsonCodec;
|
||||
pub(crate) use codec::{Codec, MarkerCodec, RawBytesCodec};
|
||||
pub(crate) use codec::{Codec, JsonCodec, MarkerCodec, RawBytesCodec};
|
||||
pub(crate) use repository::Repository;
|
||||
|
||||
use crate::Result;
|
||||
|
|
@ -27,7 +26,6 @@ pub(crate) trait Record: Sized + Send + Sync + 'static {
|
|||
|
||||
const PREFIX: &'static str;
|
||||
|
||||
#[cfg(test)]
|
||||
fn id(&self) -> Self::Id;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,12 +52,13 @@
|
|||
//! async fn get(&self, id: &str) -> Result<Option<Session>> {
|
||||
//! self.repo.get(&id.to_string()).await
|
||||
//! }
|
||||
//!
|
||||
//! async fn gc_expired(&self, now: DateTime<Utc>) -> Result<u64> {
|
||||
//! self.repo.gc(|session| session.expires_at <= now).await
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! `JsonCodec` is currently compiled only for tests; un-gate it when the
|
||||
//! first production JSON-encoded record type appears.
|
||||
//!
|
||||
//! Keep `Repository<R>` internal. Domain-specific invariants such as consume
|
||||
//! locks, token rotation, or marker-only behavior belong in the named store
|
||||
//! that wraps it, not in this generic layer.
|
||||
|
|
@ -68,7 +69,7 @@ use std::sync::Arc;
|
|||
|
||||
use futures::stream::{self};
|
||||
use futures::{Stream, StreamExt};
|
||||
use slatedb::{Db, KeyValue};
|
||||
use slatedb::{Db, KeyValue, WriteBatch};
|
||||
|
||||
use super::{Codec, Record, RecordId};
|
||||
use crate::{Error, Result, keys};
|
||||
|
|
@ -77,7 +78,7 @@ use crate::{Error, Result, keys};
|
|||
/// stores.
|
||||
///
|
||||
/// This type is intentionally `pub(crate)`: callers should interact through a
|
||||
/// named store such as `RunCatalogIndex` or `BlobStore`, which can add
|
||||
/// named store such as `AuthCodeStore` or `BlobStore`, which can add
|
||||
/// domain-specific behavior on top of the generic storage primitives here.
|
||||
pub(crate) struct Repository<R: Record> {
|
||||
db: Arc<Db>,
|
||||
|
|
@ -93,7 +94,6 @@ impl<R: Record> Repository<R> {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn get(&self, id: &R::Id) -> Result<Option<R>> {
|
||||
self.db
|
||||
.get(key_for_id::<R>(id)?)
|
||||
|
|
@ -102,7 +102,6 @@ impl<R: Record> Repository<R> {
|
|||
.transpose()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn put(&self, record: &R) -> Result<()> {
|
||||
let id = record.id();
|
||||
self.put_at(&id, record).await
|
||||
|
|
@ -161,6 +160,29 @@ impl<R: Record> Repository<R> {
|
|||
Err(err) => Box::pin(stream::once(async move { Err(err) })),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn gc<F>(&self, predicate: F) -> Result<u64>
|
||||
where
|
||||
F: Fn(&R) -> bool + Send + Sync,
|
||||
{
|
||||
let mut iter = self.db.scan_prefix(prefix_key::<R>(&[])?).await?;
|
||||
let mut batch = WriteBatch::new();
|
||||
let mut deletes = 0_u64;
|
||||
|
||||
while let Some(entry) = iter.next().await? {
|
||||
let value = R::Codec::decode(&entry.value)?;
|
||||
if predicate(&value) {
|
||||
batch.delete(entry.key);
|
||||
deletes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if deletes > 0 {
|
||||
self.db.write(batch).await?;
|
||||
}
|
||||
|
||||
Ok(deletes)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type RepositoryStream<'a, T> = Pin<Box<dyn Stream<Item = Result<T>> + Send + 'a>>;
|
||||
|
|
@ -445,6 +467,25 @@ mod tests {
|
|||
assert!(repo.get(&saved.id()).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gc_deletes_matching_records() {
|
||||
let repo = Repository::<TestRecord>::new(db().await);
|
||||
for record in [
|
||||
record("bucket-a", "keep", false),
|
||||
record("bucket-a", "delete", true),
|
||||
record("bucket-b", "keep", false),
|
||||
record("bucket-b", "delete", true),
|
||||
] {
|
||||
repo.put(&record).await.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(repo.gc(|record| record.delete_me).await.unwrap(), 2);
|
||||
|
||||
let remaining = repo.scan_stream().try_collect::<Vec<_>>().await.unwrap();
|
||||
assert_eq!(remaining.len(), 2);
|
||||
assert!(remaining.iter().all(|(_, record)| !record.delete_me));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marker_records_use_put_at_exists_and_scan_ids() {
|
||||
let repo = Repository::<TestMarker>::new(db().await);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
214
lib/components/fabro-store/src/slate/auth_codes.rs
Normal file
214
lib/components/fabro-store/src/slate/auth_codes.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::IdpIdentity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::record::{JsonCodec, Record, Repository};
|
||||
use crate::{KeyedMutex, Result};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthCode {
|
||||
pub code: String,
|
||||
pub identity: IdpIdentity,
|
||||
pub login: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub avatar_url: String,
|
||||
pub code_challenge: String,
|
||||
pub redirect_uri: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Record for AuthCode {
|
||||
type Id = String;
|
||||
type Codec = JsonCodec;
|
||||
|
||||
const PREFIX: &'static str = "auth/code";
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.code.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthCodeStore {
|
||||
repo: Repository<AuthCode>,
|
||||
consume_locks: KeyedMutex<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AuthCodeStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthCodeStore").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthCodeStore {
|
||||
pub(crate) fn new(db: Arc<slatedb::Db>) -> Self {
|
||||
Self {
|
||||
repo: Repository::new(db),
|
||||
consume_locks: KeyedMutex::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert(&self, entry: AuthCode) -> Result<()> {
|
||||
self.repo.put(&entry).await
|
||||
}
|
||||
|
||||
pub async fn consume(&self, code: &str) -> Result<Option<AuthCode>> {
|
||||
let code = code.to_string();
|
||||
let _guard = self.consume_locks.lock(code.clone()).await;
|
||||
let entry = self.repo.get(&code).await?;
|
||||
let result = match entry {
|
||||
Some(entry) if entry.expires_at > Utc::now() => {
|
||||
self.repo.delete(&code).await?;
|
||||
Some(entry)
|
||||
}
|
||||
Some(_) => {
|
||||
self.repo.delete(&code).await?;
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn gc_expired(&self, cutoff: DateTime<Utc>) -> Result<u64> {
|
||||
self.repo
|
||||
.gc(|auth_code| auth_code.expires_at <= cutoff)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use object_store::memory::InMemory;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use super::{AuthCode, AuthCodeStore};
|
||||
use crate::test_support;
|
||||
|
||||
async fn store() -> Arc<AuthCodeStore> {
|
||||
let db = test_support::test_database(
|
||||
Arc::new(InMemory::new()),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
db.auth_codes().await.unwrap()
|
||||
}
|
||||
|
||||
fn auth_code(code: &str, expires_at: chrono::DateTime<chrono::Utc>) -> AuthCode {
|
||||
AuthCode {
|
||||
code: code.to_string(),
|
||||
identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
|
||||
login: "octocat".to_string(),
|
||||
name: "The Octocat".to_string(),
|
||||
email: "octocat@example.com".to_string(),
|
||||
avatar_url: String::new(),
|
||||
code_challenge: "challenge".to_string(),
|
||||
redirect_uri: "http://127.0.0.1/callback".to_string(),
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_and_consume_is_single_use() {
|
||||
let store = store().await;
|
||||
store
|
||||
.insert(auth_code(
|
||||
"code-1",
|
||||
chrono::Utc::now() + ChronoDuration::seconds(60),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(store.consume("code-1").await.unwrap().is_some());
|
||||
assert!(store.consume("code-1").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_legacy_json_without_avatar_url() {
|
||||
let entry: AuthCode = serde_json::from_value(serde_json::json!({
|
||||
"code": "legacy-code",
|
||||
"identity": {
|
||||
"issuer": "https://github.com",
|
||||
"subject": "12345"
|
||||
},
|
||||
"login": "octocat",
|
||||
"name": "The Octocat",
|
||||
"email": "octocat@example.com",
|
||||
"code_challenge": "challenge",
|
||||
"redirect_uri": "http://127.0.0.1/callback",
|
||||
"expires_at": "2026-01-01T00:00:00Z"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(entry.avatar_url, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_avatar_url_when_present() {
|
||||
let mut entry = auth_code("avatar-code", chrono::Utc::now());
|
||||
entry.avatar_url = "https://example.com/octocat.png".to_string();
|
||||
|
||||
let json = serde_json::to_value(&entry).unwrap();
|
||||
|
||||
assert_eq!(json["avatar_url"], "https://example.com/octocat.png");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_consume_has_one_winner() {
|
||||
let store = store().await;
|
||||
store
|
||||
.insert(auth_code(
|
||||
"code-2",
|
||||
chrono::Utc::now() + ChronoDuration::seconds(60),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut tasks = JoinSet::new();
|
||||
for _ in 0..16 {
|
||||
let store = Arc::clone(&store);
|
||||
tasks.spawn(async move { store.consume("code-2").await.unwrap().is_some() });
|
||||
}
|
||||
|
||||
let mut successes = 0;
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
if result.unwrap() {
|
||||
successes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(successes, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gc_expired_removes_only_expired_codes() {
|
||||
let store = store().await;
|
||||
store
|
||||
.insert(auth_code(
|
||||
"expired",
|
||||
chrono::Utc::now() - ChronoDuration::seconds(1),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.insert(auth_code(
|
||||
"live",
|
||||
chrono::Utc::now() + ChronoDuration::seconds(60),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(store.gc_expired(chrono::Utc::now()).await.unwrap(), 1);
|
||||
assert!(store.consume("expired").await.unwrap().is_none());
|
||||
assert!(store.consume("live").await.unwrap().is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
mod auth_codes;
|
||||
mod projection_cache;
|
||||
mod run_catalog_index;
|
||||
mod run_store;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
pub use auth_codes::{AuthCode, AuthCodeStore};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::{Run, RunId, SessionId};
|
||||
use object_store::ObjectStore;
|
||||
|
|
@ -43,9 +45,10 @@ pub struct Database {
|
|||
active_runs: Arc<Mutex<HashMap<RunId, Arc<RunDatabaseInner>>>>,
|
||||
blobs: Arc<BlobStore>,
|
||||
catalog_index: Arc<OnceCell<Arc<RunCatalogIndex>>>,
|
||||
auth_codes: Arc<OnceCell<Arc<AuthCodeStore>>>,
|
||||
projection_cache: Arc<RunProjectionCache>,
|
||||
projection_cache_warmed: Arc<OnceCell<()>>,
|
||||
run_summary_store: Arc<RunSummaryStore>,
|
||||
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Database {
|
||||
|
|
@ -65,7 +68,6 @@ impl Database {
|
|||
flush_interval: Duration,
|
||||
cache_path: Option<PathBuf>,
|
||||
blobs: Arc<BlobStore>,
|
||||
run_summary_store: Arc<RunSummaryStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
object_store,
|
||||
|
|
@ -76,15 +78,19 @@ impl Database {
|
|||
active_runs: Arc::new(Mutex::new(HashMap::new())),
|
||||
blobs,
|
||||
catalog_index: Arc::new(OnceCell::new()),
|
||||
auth_codes: Arc::new(OnceCell::new()),
|
||||
projection_cache: Arc::new(RunProjectionCache::default()),
|
||||
projection_cache_warmed: Arc::new(OnceCell::new()),
|
||||
run_summary_store,
|
||||
run_summary_store: Arc::new(OnceLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn run_summary_store(&self) -> Arc<RunSummaryStore> {
|
||||
Arc::clone(&self.run_summary_store)
|
||||
pub fn attach_run_summary_store(&self, store: Arc<RunSummaryStore>) -> Arc<RunSummaryStore> {
|
||||
Arc::clone(self.run_summary_store.get_or_init(|| store))
|
||||
}
|
||||
|
||||
fn run_summary_store(&self) -> Option<Arc<RunSummaryStore>> {
|
||||
self.run_summary_store.get().cloned()
|
||||
}
|
||||
|
||||
fn shared_db_prefix(&self) -> String {
|
||||
|
|
@ -140,7 +146,7 @@ impl Database {
|
|||
read_only,
|
||||
self.blobs(),
|
||||
Arc::clone(&self.projection_cache),
|
||||
self.run_summary_store(),
|
||||
Arc::clone(&self.run_summary_store),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -238,7 +244,9 @@ impl Database {
|
|||
}
|
||||
}
|
||||
}
|
||||
self.run_summary_store.reconcile(&entries).await?;
|
||||
if let Some(store) = self.run_summary_store() {
|
||||
store.reconcile(&entries).await?;
|
||||
}
|
||||
self.projection_cache.replace_all(entries).await;
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
|
|
@ -385,7 +393,9 @@ impl Database {
|
|||
self.delete_session_indexes_for_run(run_id).await?;
|
||||
self.catalog_index().await?.remove(run_id).await?;
|
||||
self.remove_cached_run(run_id).await;
|
||||
self.run_summary_store.delete(run_id).await?;
|
||||
if let Some(store) = self.run_summary_store() {
|
||||
store.delete(run_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -407,6 +417,17 @@ impl Database {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth_codes(&self) -> Result<Arc<AuthCodeStore>> {
|
||||
let store = self
|
||||
.auth_codes
|
||||
.get_or_try_init(|| async {
|
||||
let db = Arc::new(self.open_db().await?);
|
||||
Ok::<_, Error>(Arc::new(AuthCodeStore::new(db)))
|
||||
})
|
||||
.await?;
|
||||
Ok(Arc::clone(store))
|
||||
}
|
||||
|
||||
pub async fn catalog_index(&self) -> Result<Arc<RunCatalogIndex>> {
|
||||
let store = self
|
||||
.catalog_index
|
||||
|
|
@ -552,21 +573,6 @@ mod tests {
|
|||
(object_store, store)
|
||||
}
|
||||
|
||||
fn make_store_with_run_summaries(
|
||||
run_summaries: Arc<RunSummaryStore>,
|
||||
) -> (Arc<dyn ObjectStore>, Database) {
|
||||
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
||||
let store = store_test_support::test_database_with_stores(
|
||||
object_store.clone(),
|
||||
"runs/",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
store_test_support::test_blob_store(),
|
||||
run_summaries,
|
||||
);
|
||||
(object_store, store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retire_refresh_token_keyspace_clears_the_prefix_and_is_idempotent() {
|
||||
let (_object_store, store) = make_store();
|
||||
|
|
@ -579,8 +585,8 @@ mod tests {
|
|||
.as_ref()
|
||||
.to_vec()
|
||||
});
|
||||
// "auth/code" sorts adjacent to "auth/refresh", so it is the
|
||||
// neighbour a too-wide prefix delete would take with it.
|
||||
// "auth/code" sorts adjacent to "auth/refresh" and is still live, so
|
||||
// it is the neighbour a too-wide prefix delete would take with it.
|
||||
let auth_code_key = keys::SlateKey::new("auth")
|
||||
.with("code")
|
||||
.with("keep")
|
||||
|
|
@ -605,8 +611,8 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
async fn make_run_summary_store() -> (tempfile::TempDir, Arc<RunSummaryStore>) {
|
||||
let (directory, store) = store_test_support::sqlite_run_summary_store().await;
|
||||
async fn make_summary_store() -> (tempfile::TempDir, Arc<RunSummaryStore>) {
|
||||
let (directory, store) = store_test_support::sqlite_summary_store().await;
|
||||
(directory, Arc::new(store))
|
||||
}
|
||||
|
||||
|
|
@ -981,8 +987,9 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn rejected_transition_leaves_reconciled_summary_present() {
|
||||
let (_directory, summaries) = make_run_summary_store().await;
|
||||
let (_object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries));
|
||||
let (_object_store, store) = make_store();
|
||||
let (_directory, summaries) = make_summary_store().await;
|
||||
store.attach_run_summary_store(Arc::clone(&summaries));
|
||||
let run_id = test_run_id("run-1");
|
||||
let run = store.create_run(&run_id).await.unwrap();
|
||||
append_runnable(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
|
||||
|
|
@ -1003,9 +1010,10 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn best_effort_run_summary_update_failure_keeps_slate_append_repairable() {
|
||||
let (directory, summaries) = make_run_summary_store().await;
|
||||
let (object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries));
|
||||
async fn committed_append_succeeds_when_summary_update_fails_and_is_repairable() {
|
||||
let (object_store, store) = make_store();
|
||||
let (directory, summaries) = make_summary_store().await;
|
||||
store.attach_run_summary_store(Arc::clone(&summaries));
|
||||
let run_id = test_run_id("run-1");
|
||||
let run = store.create_run(&run_id).await.unwrap();
|
||||
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
|
||||
|
|
@ -1029,7 +1037,7 @@ mod tests {
|
|||
assert_eq!(stored.event, result.unwrap().event);
|
||||
|
||||
let repaired_summaries =
|
||||
Arc::new(store_test_support::sqlite_run_summary_store_at(directory.path()).await);
|
||||
Arc::new(store_test_support::sqlite_summary_store_at(directory.path()).await);
|
||||
let stale = repaired_summaries
|
||||
.get(&run_id, Utc::now())
|
||||
.await
|
||||
|
|
@ -1037,14 +1045,13 @@ mod tests {
|
|||
.unwrap();
|
||||
assert_ne!(stale.title, "Committed title");
|
||||
|
||||
let reopened = store_test_support::test_database_with_stores(
|
||||
let reopened = store_test_support::test_database(
|
||||
object_store,
|
||||
"runs/",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
store_test_support::test_blob_store(),
|
||||
Arc::clone(&repaired_summaries),
|
||||
);
|
||||
reopened.attach_run_summary_store(Arc::clone(&repaired_summaries));
|
||||
reopened.warm_projection_cache().await.unwrap();
|
||||
let repaired = repaired_summaries
|
||||
.get(&run_id, Utc::now())
|
||||
|
|
@ -1665,9 +1672,10 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_run_summary_append_refreshes_cache_and_delete_removes_rows() {
|
||||
let (_directory, summaries) = make_run_summary_store().await;
|
||||
let (_object_store, store) = make_store_with_run_summaries(Arc::clone(&summaries));
|
||||
async fn append_event_refreshes_projection_cache_and_delete_removes_it() {
|
||||
let (_object_store, store) = make_store();
|
||||
let (_directory, summaries) = make_summary_store().await;
|
||||
store.attach_run_summary_store(Arc::clone(&summaries));
|
||||
let run = store.create_run(&test_run_id("run-1")).await.unwrap();
|
||||
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
|
||||
store.warm_projection_cache().await.unwrap();
|
||||
|
|
@ -1841,20 +1849,15 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_run_summary_warmup_backfills_sqlite_run_summaries() {
|
||||
async fn projection_cache_warmup_backfills_sqlite_run_summaries() {
|
||||
let (object_store, store) = make_store();
|
||||
let run = store.create_run(&test_run_id("run-1")).await.unwrap();
|
||||
append_completed(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
|
||||
|
||||
let (_directory, summaries) = make_run_summary_store().await;
|
||||
let reopened = store_test_support::test_database_with_stores(
|
||||
object_store,
|
||||
"runs",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
store_test_support::test_blob_store(),
|
||||
Arc::clone(&summaries),
|
||||
);
|
||||
let reopened =
|
||||
store_test_support::test_database(object_store, "runs", Duration::from_millis(1), None);
|
||||
let (_directory, summaries) = make_summary_store().await;
|
||||
reopened.attach_run_summary_store(Arc::clone(&summaries));
|
||||
reopened.warm_projection_cache().await.unwrap();
|
||||
|
||||
let summary = summaries
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ impl Record for RunCatalogEntry {
|
|||
|
||||
const PREFIX: &'static str = "runs/_index/by-start";
|
||||
|
||||
#[cfg(test)]
|
||||
fn id(&self) -> Self::Id {
|
||||
unreachable!("marker records must use put_at")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use bytes::Bytes;
|
||||
use chrono::Utc;
|
||||
|
|
@ -45,7 +45,9 @@ pub(crate) struct RunDatabaseInner {
|
|||
state_lock: Mutex<()>,
|
||||
projection_cache: Mutex<EventProjectionCache>,
|
||||
shared_projection_cache: Arc<RunProjectionCache>,
|
||||
run_summary_store: Arc<RunSummaryStore>,
|
||||
// Shared cell rather than a snapshot so a summary store attached after
|
||||
// this writer opened is still picked up by later appends.
|
||||
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
|
||||
recent_events: Mutex<VecDeque<EventEnvelope>>,
|
||||
recent_event_limit: usize,
|
||||
event_tx: broadcast::Sender<EventEnvelope>,
|
||||
|
|
@ -58,7 +60,7 @@ impl RunDatabase {
|
|||
read_only: bool,
|
||||
blob_store: Arc<BlobStore>,
|
||||
shared_projection_cache: Arc<RunProjectionCache>,
|
||||
run_summary_store: Arc<RunSummaryStore>,
|
||||
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
|
||||
) -> Result<Self> {
|
||||
let cached_projection = shared_projection_cache.projection_snapshot(&run_id).await;
|
||||
let projection_cache = cached_projection.as_ref().map_or_else(
|
||||
|
|
@ -229,13 +231,15 @@ impl RunDatabase {
|
|||
}
|
||||
|
||||
async fn update_summary_after_committed_append(&self, cached: &CachedRunProjection) {
|
||||
if let Err(err) = self.inner.run_summary_store.upsert_projection(cached).await {
|
||||
warn!(
|
||||
run_id = %self.inner.run_id,
|
||||
source_last_seq = cached.last_seq,
|
||||
error = ?err,
|
||||
"failed to update SQLite run summary after committed append"
|
||||
);
|
||||
if let Some(store) = self.inner.run_summary_store.get() {
|
||||
if let Err(err) = store.upsert_projection(cached).await {
|
||||
warn!(
|
||||
run_id = %self.inner.run_id,
|
||||
source_last_seq = cached.last_seq,
|
||||
error = ?err,
|
||||
"failed to update SQLite run summary after committed append"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue