mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Merge origin/main into fix/doctor-timeout-budget
This commit is contained in:
commit
0355a13db8
671 changed files with 8971 additions and 2905 deletions
123
Cargo.lock
generated
123
Cargo.lock
generated
|
|
@ -2255,7 +2255,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-acp"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-tokio",
|
||||
|
|
@ -2274,7 +2274,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2320,7 +2320,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-automation",
|
||||
|
|
@ -2343,7 +2343,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2368,7 +2368,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-automation"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2388,11 +2388,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -2408,7 +2408,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2510,7 +2510,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2539,7 +2539,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2569,7 +2569,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
|
|
@ -2584,7 +2584,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-db"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2596,7 +2596,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2615,7 +2615,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2629,7 +2629,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-environment"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2651,9 +2651,10 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"fabro-http",
|
||||
|
|
@ -2665,6 +2666,7 @@ dependencies = [
|
|||
"jsonwebtoken",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum 0.28.0",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
@ -2673,7 +2675,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2688,7 +2690,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -2711,7 +2713,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2721,7 +2723,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2740,7 +2742,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2755,7 +2757,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2797,7 +2799,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2808,7 +2810,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2816,9 +2818,11 @@ dependencies = [
|
|||
"fabro-github",
|
||||
"fabro-graphviz",
|
||||
"fabro-template",
|
||||
"fabro-test",
|
||||
"fabro-types",
|
||||
"fabro-workflow",
|
||||
"git2",
|
||||
"insta",
|
||||
"serde_json",
|
||||
"temp-env",
|
||||
"tempfile",
|
||||
|
|
@ -2827,7 +2831,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2847,7 +2851,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2875,7 +2879,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-store"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-db",
|
||||
|
|
@ -2893,7 +2897,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2909,7 +2913,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2931,7 +2935,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2939,7 +2943,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2948,7 +2952,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2964,7 +2968,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3008,7 +3012,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3058,6 +3062,7 @@ dependencies = [
|
|||
"fabro-variable",
|
||||
"fabro-vault",
|
||||
"fabro-workflow",
|
||||
"fabro-workflow-version",
|
||||
"futures-util",
|
||||
"globset",
|
||||
"hex",
|
||||
|
|
@ -3102,7 +3107,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -3124,18 +3129,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -3165,7 +3170,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -3191,7 +3196,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -3205,7 +3210,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3230,7 +3235,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3251,7 +3256,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -3265,7 +3270,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -3288,7 +3293,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -3311,7 +3316,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-acp",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -3324,7 +3329,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-variable"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3341,7 +3346,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -3360,7 +3365,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -3428,6 +3433,22 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-workflow-version"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-config",
|
||||
"fabro-graphviz",
|
||||
"fabro-store",
|
||||
"fabro-template",
|
||||
"fabro-types",
|
||||
"object_store",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fail-parallel"
|
||||
version = "0.5.1"
|
||||
|
|
@ -8525,7 +8546,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -8544,7 +8565,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.316.0-nightly.0"
|
||||
version = "0.331.0-nightly.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { BoardColumn, Run } from "@qltysh/fabro-api-client";
|
|||
|
||||
import {
|
||||
buildBoardColumns,
|
||||
buildFilterOptions,
|
||||
loadStoredRunsWorkspaceSearchParams,
|
||||
placeArchivedColumnLast,
|
||||
persistRunsWorkspacePreferences,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
shouldRefreshBoardForEvent,
|
||||
} from "./runs";
|
||||
import { summarizeBatchLifecycleAction } from "../components/runs-list/batch-lifecycle";
|
||||
import { mapRunListItem } from "../data/runs";
|
||||
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
|
||||
|
||||
function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
|
||||
|
|
@ -217,6 +219,38 @@ describe("runs route board mapping", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("runs route filter options", () => {
|
||||
function runWith(id: string, repoName: string, workflowName: string): Run {
|
||||
const run = boardRun(id, "running");
|
||||
return {
|
||||
...run,
|
||||
repository: { ...run.repository, name: repoName },
|
||||
workflow: { ...run.workflow, name: workflowName },
|
||||
};
|
||||
}
|
||||
|
||||
test("derives sorted unique options from run items", () => {
|
||||
const items = [
|
||||
runWith("a", "qlty/beta", "release"),
|
||||
runWith("b", "qlty/alpha", "hello"),
|
||||
runWith("c", "qlty/beta", "release"),
|
||||
].map(mapRunListItem);
|
||||
|
||||
expect(buildFilterOptions(items, (item) => item.repo, "all")).toEqual(["alpha", "beta"]);
|
||||
expect(buildFilterOptions(items, (item) => item.workflow, "all")).toEqual([
|
||||
"hello",
|
||||
"release",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps the active selection when no loaded run matches it", () => {
|
||||
const items = [runWith("a", "qlty/beta", "release")].map(mapRunListItem);
|
||||
|
||||
expect(buildFilterOptions(items, (item) => item.repo, "gamma")).toEqual(["beta", "gamma"]);
|
||||
expect(buildFilterOptions([], (item) => item.workflow, "release")).toEqual(["release"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs route workspace preferences", () => {
|
||||
class MemoryStorage {
|
||||
values = new Map<string, string>();
|
||||
|
|
|
|||
|
|
@ -140,6 +140,18 @@ export function buildBoardColumns(
|
|||
});
|
||||
}
|
||||
|
||||
export function buildFilterOptions(
|
||||
items: RunItem[],
|
||||
pick: (item: RunItem) => string,
|
||||
selected: string,
|
||||
): string[] {
|
||||
const values = new Set(items.map(pick));
|
||||
// Keep the active selection visible even when no loaded run matches it,
|
||||
// e.g. a stored repo filter while paginating the list view.
|
||||
if (selected !== "all") values.add(selected);
|
||||
return Array.from(values).sort();
|
||||
}
|
||||
|
||||
export function placeArchivedColumnLast(columns: Column[], includeArchived: boolean): Column[] {
|
||||
if (!includeArchived) return columns;
|
||||
const archived = columns.find((column) => column.id === "archived");
|
||||
|
|
@ -771,18 +783,18 @@ export default function Runs() {
|
|||
);
|
||||
const hasGitHubAuth = authConfig.data?.methods.includes("github") === true;
|
||||
const serverUrl = systemInfo.data?.server_url;
|
||||
const allRepos = Array.from(
|
||||
new Set(
|
||||
initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.repo))),
|
||||
),
|
||||
// Filter options come from the loaded runs: all runs in columns view, the
|
||||
// current page in list view (until a facets endpoint provides the full set).
|
||||
const filterSourceItems: RunItem[] =
|
||||
view === "list"
|
||||
? (listRunsPage.data?.data ?? []).map(mapRunListItem)
|
||||
: initialColumns.flatMap((col: Column) => col.items);
|
||||
const allRepos = buildFilterOptions(filterSourceItems, (item) => item.repo, repoFilter);
|
||||
const allWorkflows = buildFilterOptions(
|
||||
filterSourceItems,
|
||||
(item) => item.workflow,
|
||||
workflowFilter,
|
||||
);
|
||||
allRepos.sort();
|
||||
const allWorkflows = Array.from(
|
||||
new Set(
|
||||
initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.workflow))),
|
||||
),
|
||||
);
|
||||
allWorkflows.sort();
|
||||
const [columnsState, setColumnsState] = useState(() => ({
|
||||
base: initialColumns,
|
||||
columns: initialColumns,
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ Emitted when the run record is created.
|
|||
| `source_directory` | string? | Submitter-side source directory |
|
||||
| `workflow_slug` | string? | Workflow slug |
|
||||
| `provenance` | object | Actor and request provenance |
|
||||
| `manifest_blob` | string? | Blob id for the submitted manifest |
|
||||
| `manifest_blob` | string? | Blob hash for the submitted manifest |
|
||||
| `git` | object? | Git provenance observed before the run: normalized `origin_url`, `branch`, optional `sha`, and `dirty` status |
|
||||
| `fork_source_ref` | object? | Source run/checkpoint reference when this run was forked |
|
||||
| `in_place` | boolean | Whether the run was created with `--in-place` (no git checkpoints) |
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ when does it resolve** — see [Which process resolves what](#which-process-reso
|
|||
- Resolution is snapshot-based: env and file are read once at construction, then treated as immutable for the life of the process.
|
||||
- `process env` wins over `server.env` on conflicts.
|
||||
- Optional integration secrets are vault-only in the **server process**. Do not add optional server integrations to `ServerSecrets`, and do not add bespoke env fallback paths to it.
|
||||
- Not every credential is a `ServerSecrets` or vault lookup. A third mechanism exists: **settings-declared credentials** in `InterpString` fields, resolved at consumption time from `{{ env.NAME }}` or `{{ secrets.NAME }}`. See [Settings-declared credentials](#settings-declared-credentials).
|
||||
- Not every credential is a `ServerSecrets` or vault lookup. A third mechanism exists: **settings-declared credentials** in `InterpString` fields, resolved at consumption time from `{{ secrets.NAME }}`. See [Settings-declared credentials](#settings-declared-credentials).
|
||||
- `fabro server start` never generates secrets. Missing required secrets are a startup error.
|
||||
- `std::env::set_var` and `std::env::remove_var` are banned workspace-wide. Tests are not exempt. Enforced by clippy via `disallowed_methods` in `clippy.toml`; intentional exceptions must be annotated with a scoped `#[expect(clippy::disallowed_methods, reason = "...")]` at the call site.
|
||||
|
||||
|
|
@ -70,7 +70,6 @@ than saying "server runtime", which is ambiguous.
|
|||
| Bootstrap server secret | Server process, via `ServerSecrets` | Once at construction, then immutable |
|
||||
| Optional integration secret | Server process or worker, via the vault | At use |
|
||||
| `{{ vars.NAME }}` | Server process | When the run is created, from that run's variable snapshot |
|
||||
| `{{ env.NAME }}` | The process that owns the value (usually the worker) | At consumption time |
|
||||
| `{{ secrets.NAME }}` | The process that owns the value, against the server vault | At consumption time |
|
||||
|
||||
`docs/public/agents/mcp.mdx` documents the same split for MCP server configuration and is a good
|
||||
|
|
@ -80,18 +79,17 @@ worked example of the shape.
|
|||
|
||||
Some credentials are declared in settings rather than looked up by name. Those fields are
|
||||
`InterpString` (`lib/foundation/fabro-types/src/settings/interp.rs`), which supports narrow
|
||||
`{{ namespace.NAME }}` tokens with no template logic. Three namespaces resolve: `env` (process
|
||||
environment, consumption time), `secrets` (vault, consumption time), and `vars` (non-sensitive run
|
||||
variables, substituted early at run creation). A token whose namespace is unavailable in the
|
||||
resolution context fails loudly.
|
||||
`{{ namespace.NAME }}` tokens with no template logic. Two namespaces resolve: `secrets` (vault,
|
||||
consumption time) and `vars` (non-sensitive run variables, substituted early at run creation).
|
||||
`{{ env.NAME }}` tokens still parse but never resolve; they fail loudly with a migration message. A
|
||||
token whose namespace is unavailable in the resolution context also fails loudly.
|
||||
|
||||
The reference implementation is LLM provider `extra_headers`, resolved against env plus vault at
|
||||
The reference implementation is LLM provider `extra_headers`, resolved against the vault at
|
||||
`lib/foundation/fabro-auth/src/resolve.rs:376-378`:
|
||||
|
||||
```toml
|
||||
[llm.providers.example.extra_headers]
|
||||
authorization = "Bearer {{ secrets.EXAMPLE_TOKEN }}"
|
||||
x-tenant = "{{ env.EXAMPLE_TENANT }}"
|
||||
```
|
||||
|
||||
Use this mechanism when the credential belongs to an operator-configured integration declared in
|
||||
|
|
@ -149,7 +147,7 @@ First pick the mechanism. These are the only three:
|
|||
|---|---|---|
|
||||
| Bootstrap server secret | Platform env or install-written `server.env` | `state.server_secret(...)` |
|
||||
| Optional integration secret | Vault (`fabro secret set`, `fabro install`) | `state.vault_secret(...)` |
|
||||
| Settings-declared credential | `{{ secrets.* }}` or `{{ env.* }}` in an `InterpString` settings field | Resolved at consumption time by the owning process |
|
||||
| Settings-declared credential | `{{ secrets.* }}` in an `InterpString` settings field | Resolved at consumption time by the owning process |
|
||||
|
||||
Then:
|
||||
|
||||
|
|
|
|||
|
|
@ -219,10 +219,10 @@ When Fabro builds a [preamble](/execution/context#preamble-construction) for a d
|
|||
- **plan**: success
|
||||
- Model: claude-sonnet-4-5, 12.4k tokens in / 3.2k out
|
||||
- Files: src/main.rs, tests/api_test.rs
|
||||
- Response: See: /path/to/runtime/blobs/<blob_id>.json
|
||||
- Response: See: /path/to/runtime/blobs/<blob_hash>.json
|
||||
- **test**: success
|
||||
- Script: `cargo test 2>&1 || true`
|
||||
- Stdout: See: /path/to/runtime/blobs/<blob_id>.json
|
||||
- Stdout: See: /path/to/runtime/blobs/<blob_hash>.json
|
||||
```
|
||||
|
||||
This keeps preambles concise while still giving agents a path to read the full output if needed.
|
||||
|
|
@ -237,7 +237,7 @@ Captured stage artifacts such as screenshots, videos, reports, and traces still
|
|||
|
||||
For remote sandboxes (Docker, Daytona), execution-time file access happens inside the sandbox filesystem.
|
||||
|
||||
- Blob refs are materialized into `{working_directory}/.fabro/blobs/{blob_id}.json`
|
||||
- Blob refs are materialized into `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
- Explicit non-blob `file://` refs keep the existing copy-on-demand behavior and are copied into `{working_directory}/.fabro/artifacts/{filename}` when needed
|
||||
|
||||
In both cases, downstream handlers and agents continue to consume ordinary `file://` pointers during execution.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
openapi: "3.1.0"
|
||||
info:
|
||||
title: Fabro Run API
|
||||
version: "0.1.0"
|
||||
version: "0.2.0"
|
||||
description: HTTP API for managing Fabro workflow run executions.
|
||||
|
||||
tags:
|
||||
|
|
@ -33,6 +33,8 @@ tags:
|
|||
description: Internal run details (stages, turns, context, configuration)
|
||||
- name: Workflows
|
||||
description: Workflow definitions and execution
|
||||
- name: Workflow Versions
|
||||
description: Immutable, content-addressed workflow packages
|
||||
- name: Billing
|
||||
description: Token counts and billed totals
|
||||
- name: Insights
|
||||
|
|
@ -1074,6 +1076,69 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Workflow Versions ─────────────────────────────────────────────────
|
||||
|
||||
/api/v1/workflow-versions:
|
||||
post:
|
||||
operationId: createWorkflowVersion
|
||||
tags: [Workflow Versions]
|
||||
summary: Create Workflow Version
|
||||
description: >-
|
||||
Validates and stores an immutable workflow package in content-addressed
|
||||
storage. Repeating the same canonical content returns the same identifier.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WorkflowVersion"
|
||||
responses:
|
||||
"201":
|
||||
description: Workflow version stored or already present
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateWorkflowVersionResponse"
|
||||
"400":
|
||||
description: Malformed JSON (`invalid_json`)
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"413":
|
||||
description: Request body exceeds 2 MiB (`workflow_version_too_large`)
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: >-
|
||||
Invalid workflow content (`workflow_version_invalid`) or an absent,
|
||||
invalid, or non-canonical dependency
|
||||
(`workflow_version_dependency_not_found`)
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Workflow version storage failed
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Runs ──────────────────────────────────────────────────────────────
|
||||
|
||||
/api/v1/runs:
|
||||
|
|
@ -3036,7 +3101,7 @@ paths:
|
|||
operationId: writeRunBlob
|
||||
tags: [Run Internals]
|
||||
summary: Write Run Blob
|
||||
description: Writes an opaque binary blob and returns its content-addressed blob identifier.
|
||||
description: Writes an opaque binary blob and returns its content-addressed blob hash.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
requestBody:
|
||||
|
|
@ -3081,15 +3146,15 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/blobs/{blobId}:
|
||||
/api/v1/runs/{id}/blobs/{blobHash}:
|
||||
get:
|
||||
operationId: readRunBlob
|
||||
tags: [Run Internals]
|
||||
summary: Read Run Blob
|
||||
description: Reads a previously stored blob by identifier.
|
||||
description: Reads a previously stored blob by hash.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
- $ref: "#/components/parameters/BlobId"
|
||||
- $ref: "#/components/parameters/BlobHash"
|
||||
responses:
|
||||
"200":
|
||||
description: Blob contents
|
||||
|
|
@ -5918,14 +5983,13 @@ components:
|
|||
default: 65536
|
||||
example: 65536
|
||||
|
||||
BlobId:
|
||||
name: blobId
|
||||
BlobHash:
|
||||
name: blobHash
|
||||
in: path
|
||||
required: true
|
||||
description: Content-addressed blob identifier.
|
||||
description: Content-addressed blob hash.
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{64}$'
|
||||
$ref: "#/components/schemas/BlobHash"
|
||||
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
||||
|
||||
ArtifactFilename:
|
||||
|
|
@ -9083,6 +9147,73 @@ components:
|
|||
detail:
|
||||
$ref: "#/components/schemas/FailureDetail"
|
||||
|
||||
WorkflowPath:
|
||||
description: >-
|
||||
Canonical portable path inside one workflow version. Paths are UTF-8,
|
||||
relative, at most 240 bytes and 16 components, and cannot contain empty,
|
||||
dot, parent, backslash, control, tilde-root, or drive-letter segments.
|
||||
Map keys receive stricter byte and structural validation in the domain
|
||||
model than OpenAPI can express.
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 240
|
||||
example: graphs/main.fabro
|
||||
|
||||
WorkflowVersionId:
|
||||
description: >-
|
||||
SHA-256 identity of validated canonical workflow-version bytes. Hex input is
|
||||
case-insensitive; Fabro emits the canonical lowercase form.
|
||||
type: string
|
||||
pattern: "^[0-9A-Fa-f]{64}$"
|
||||
example: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
WorkflowVersion:
|
||||
description: >-
|
||||
Complete immutable package for one rooted workflow. It contains at most
|
||||
512 files and 512 workflow dependencies, each file is at most 512 KiB
|
||||
of UTF-8 content, and its compact canonical JSON representation is at
|
||||
most 2 MiB.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- entrypoint
|
||||
- files
|
||||
- workflow_dependencies
|
||||
properties:
|
||||
entrypoint:
|
||||
$ref: "#/components/schemas/WorkflowPath"
|
||||
files:
|
||||
type: object
|
||||
description: >-
|
||||
Workflow-local text files keyed by canonical path. Keys receive
|
||||
stricter domain validation than OpenAPI can express; each value is
|
||||
limited to 512 KiB of UTF-8 bytes.
|
||||
maxProperties: 512
|
||||
propertyNames:
|
||||
$ref: "#/components/schemas/WorkflowPath"
|
||||
additionalProperties:
|
||||
type: string
|
||||
workflow_dependencies:
|
||||
type: object
|
||||
description: >-
|
||||
Exact stored workflow-version IDs keyed by resolved child-workflow
|
||||
path. Keys receive stricter domain validation than OpenAPI can express.
|
||||
maxProperties: 512
|
||||
propertyNames:
|
||||
$ref: "#/components/schemas/WorkflowPath"
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/WorkflowVersionId"
|
||||
|
||||
CreateWorkflowVersionResponse:
|
||||
description: Identity of the stored immutable workflow version.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workflow_version_id
|
||||
properties:
|
||||
workflow_version_id:
|
||||
$ref: "#/components/schemas/WorkflowVersionId"
|
||||
|
||||
RunManifest:
|
||||
description: Self-contained workflow run manifest.
|
||||
type: object
|
||||
|
|
@ -10162,16 +10293,22 @@ components:
|
|||
description: Assigned event sequence number.
|
||||
example: 42
|
||||
|
||||
BlobHash:
|
||||
description: >-
|
||||
Content-addressed SHA-256 hash of a stored blob. Hex input is case-insensitive;
|
||||
Fabro emits the canonical lowercase form.
|
||||
type: string
|
||||
pattern: "^[0-9A-Fa-f]{64}$"
|
||||
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
||||
|
||||
WriteBlobResponse:
|
||||
description: Content-addressed identifier for a stored blob.
|
||||
description: Content-addressed hash of a stored blob.
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- hash
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Blob identifier.
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
hash:
|
||||
$ref: "#/components/schemas/BlobHash"
|
||||
|
||||
CommandTermination:
|
||||
description: Terminal state for a command execution.
|
||||
|
|
@ -10280,7 +10417,7 @@ components:
|
|||
example: src/lib.rs
|
||||
sha256:
|
||||
type: ["string", "null"]
|
||||
description: Optional lowercase hex SHA-256 checksum for the file contents.
|
||||
description: Optional SHA-256 checksum for the file contents; hex input is case-insensitive.
|
||||
example: 3f785df4c5b7d3f1f4c1f0ecb0f55f1d9f6f6a3d9f0a8a98f7a74f29d1f81a2c
|
||||
expected_bytes:
|
||||
type: ["integer", "null"]
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ Do not rewrite working code. Make targeted fixes to the specific failures.
|
|||
|
||||
### Max visits as a safety valve
|
||||
|
||||
`max_visits=5` on the `fix` node prevents infinite loops. If the agent can't pass in 5 iterations, the workflow moves on with the best result so far. Tune this based on spec complexity: a 30-line spec might need 2 iterations, a 2,000-line spec might need 10.
|
||||
`max_visits=5` on the `fix` node prevents infinite loops. The node can execute up to 5 times; a sixth visit fails the run rather than looping forever. Tune this based on spec complexity: a 30-line spec might need 2 iterations, a 2,000-line spec might need 10.
|
||||
|
||||
### Goal gate on full conformance
|
||||
|
||||
|
|
|
|||
|
|
@ -243,8 +243,8 @@ Checkpoints and checkpoint-completed events persist these `blob://` refs, not ho
|
|||
|
||||
Before Fabro builds a preamble or starts the next stage, it resolves any blob refs into execution-local files so handlers and agents still see normal `file://` references:
|
||||
|
||||
- Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_id}.json`
|
||||
- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_id}.json`
|
||||
- Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_hash}.json`
|
||||
- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
|
||||
These materialized `file://` paths are runtime-only. They are not written back into durable context snapshots.
|
||||
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ memory = "8GB"
|
|||
| `network.allow` | CIDRs for `cidr_allow_list`; entries are validated as CIDRs. |
|
||||
| `lifecycle.preserve` | Keep the created sandbox after the run finishes. |
|
||||
| `lifecycle.stop_on_terminal` | Stop the sandbox when the run reaches a terminal state. |
|
||||
| `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. |
|
||||
| `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. Defaults to `"120m"`; `"0s"` disables auto-stop. |
|
||||
| `labels` | Provider labels. Merge by key across layers. |
|
||||
| `env` | Environment variables passed to command and agent execution. Merge by key across layers. |
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,10 @@ The `lifecycle.auto_stop` setting tells Daytona to stop the sandbox after a peri
|
|||
auto_stop = "30m"
|
||||
```
|
||||
|
||||
When `auto_stop` is unset, Fabro applies a default of 120 minutes so a sandbox leaked by an interrupted run is still reclaimed. Set `auto_stop = "0s"` to disable auto-stop and let the sandbox run indefinitely.
|
||||
|
||||
Daytona counts inactivity from the last sandbox interaction (a command, file operation, or other API call). Time an agent spends on LLM inference does not touch the sandbox, so intervals shorter than your longest inference call risk stopping the sandbox mid-run.
|
||||
|
||||
## Server defaults
|
||||
|
||||
When running via `fabro server start`, the server config at `~/.fabro/settings.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely).
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ async fn write_run_dump(
|
|||
dump.add_file_bytes("run.log", log);
|
||||
}
|
||||
|
||||
dump.hydrate_referenced_blobs_with_reader(|blob_id| {
|
||||
Box::pin(async move { client.read_run_blob(run_id, &blob_id).await })
|
||||
dump.hydrate_referenced_blobs_with_reader(|blob_hash| {
|
||||
Box::pin(async move { client.read_run_blob(run_id, &blob_hash).await })
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use anyhow::{Context as _, Result};
|
|||
use cli_table::format::{Border, Justify, Separator};
|
||||
use cli_table::{Cell, CellStruct, Style, Table};
|
||||
use fabro_api::types;
|
||||
use fabro_types::{PullRequestLink, RunBlobId, RunId, StageId, parse_blob_ref};
|
||||
use fabro_types::{PullRequestLink, RunId, StageId, parse_blob_ref};
|
||||
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
|
||||
use fabro_util::error::render_with_causes;
|
||||
use fabro_util::printer::Printer;
|
||||
|
|
@ -325,11 +325,11 @@ async fn resolve_response_string(
|
|||
run_id: &RunId,
|
||||
response: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(blob_id) = blob_id_from_response(response) else {
|
||||
let Some(blob_hash) = parse_blob_ref(response) else {
|
||||
return Ok(Some(response.to_string()));
|
||||
};
|
||||
|
||||
let Some(bytes) = client.read_run_blob(run_id, &blob_id).await? else {
|
||||
let Some(bytes) = client.read_run_blob(run_id, &blob_hash).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value: serde_json::Value =
|
||||
|
|
@ -341,10 +341,6 @@ async fn resolve_response_string(
|
|||
}))
|
||||
}
|
||||
|
||||
fn blob_id_from_response(response: &str) -> Option<RunBlobId> {
|
||||
parse_blob_ref(response)
|
||||
}
|
||||
|
||||
async fn list_artifact_display_entries_with_client(
|
||||
client: &server_client::Client,
|
||||
run_id: &RunId,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
|||
use fabro_tool::fabro_client::ClientBackend;
|
||||
use fabro_types::settings::run::{RunMode, RunNamespace};
|
||||
use fabro_types::{
|
||||
ArtifactUpload, EventBody, FailureReason, Principal, RunBlobId, RunEvent, RunId,
|
||||
ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId,
|
||||
WorkflowSettings,
|
||||
};
|
||||
use fabro_vault::{SecretStore, Vault};
|
||||
|
|
@ -1008,7 +1008,7 @@ impl RunStoreBackend for HttpRunStore {
|
|||
self.apply_acknowledged_event(seq, event).await
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
self.with_retries("write run blob", || {
|
||||
let client = self.client.clone_for_reuse();
|
||||
let run_id = self.run_id;
|
||||
|
|
@ -1018,12 +1018,12 @@ impl RunStoreBackend for HttpRunStore {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn read_blob(&self, id: &RunBlobId) -> Result<Option<bytes::Bytes>> {
|
||||
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<bytes::Bytes>> {
|
||||
self.with_retries("read run blob", || {
|
||||
let client = self.client.clone_for_reuse();
|
||||
let run_id = self.run_id;
|
||||
let blob_id = *id;
|
||||
async move { client.read_run_blob(&run_id, &blob_id).await }
|
||||
let blob_hash = *blob_hash;
|
||||
async move { client.read_run_blob(&run_id, &blob_hash).await }
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,20 +66,8 @@ fn format_output_snapshot(output: &Output, filters: &[(String, String)]) -> Stri
|
|||
}
|
||||
|
||||
fn normalize_attach_json_progress_event(mut event: Value) -> Value {
|
||||
if let Some(properties) = event.get_mut("properties").and_then(Value::as_object_mut) {
|
||||
if properties.contains_key("manifest_blob") {
|
||||
properties.insert(
|
||||
"manifest_blob".to_string(),
|
||||
Value::String("[BLOB_ID]".to_string()),
|
||||
);
|
||||
}
|
||||
if properties.contains_key("definition_blob") {
|
||||
properties.insert(
|
||||
"definition_blob".to_string(),
|
||||
Value::String("[BLOB_ID]".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// manifest_blob/definition_blob hashes are already rewritten to
|
||||
// [BLOB_HASH] by the shared json_snapshot_filters regexes.
|
||||
// Strip v2-shape server/version fields that the bridge emits,
|
||||
// since the test fixture's socket path is randomised per run.
|
||||
if let Some(settings) = event
|
||||
|
|
@ -896,7 +884,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
}
|
||||
}
|
||||
},
|
||||
"manifest_blob": "[BLOB_ID]",
|
||||
"manifest_blob": "[BLOB_HASH]",
|
||||
"provenance": {
|
||||
"client": {
|
||||
"name": "fabro-cli",
|
||||
|
|
@ -1036,7 +1024,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"event": "run.submitted",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
"definition_blob": "[BLOB_ID]"
|
||||
"definition_blob": "[BLOB_HASH]"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"ts": "[TIMESTAMP]"
|
||||
|
|
|
|||
|
|
@ -314,7 +314,8 @@ fn validate_reports_missing_template_dependency() {
|
|||
exit_code: 1
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
× failed to discover template dependencies: missing template dependency `missing.tpl.md` from `[FIXTURES]/templates/missing_dependency/workflow.fabro`
|
||||
× failed to discover template dependencies
|
||||
╰─▶ missing template dependency `missing.tpl.md` from `[FIXTURES]/templates/missing_dependency/workflow.fabro`
|
||||
");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ fabro-hooks = { path = "../../components/fabro-hooks" }
|
|||
fabro-interview = { path = "../../components/fabro-interview" }
|
||||
fabro-slack = { path = "../../components/fabro-slack" }
|
||||
fabro-workflow = { path = "../../components/fabro-workflow" }
|
||||
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
|
||||
fabro-validate = { path = "../../components/fabro-validate" }
|
||||
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona", "docker"] }
|
||||
fabro-github = { path = "../../components/fabro-github" }
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ use axum::http::StatusCode;
|
|||
use axum::http::request::Parts;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use fabro_types::{AuthMethod, IdpIdentity, Principal, RunBlobId, RunId, StageId, UserPrincipal};
|
||||
use fabro_types::{AuthMethod, BlobHash, IdpIdentity, Principal, RunId, StageId, UserPrincipal};
|
||||
use jsonwebtoken::decode_header;
|
||||
use strum::IntoStaticStr;
|
||||
|
||||
use crate::auth::{AuthErrorCode, JwtError, REFRESH_TOKEN_PREFIX};
|
||||
use crate::error::ApiError;
|
||||
use crate::jwt_auth::{self, AuthMode, ConfiguredAuth};
|
||||
use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path};
|
||||
use crate::server::{AppState, parse_blob_hash_path, parse_run_id_path, parse_stage_id_path};
|
||||
use crate::worker_token::{self, WORKER_TOKEN_KID, WorkerScopeSet};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -61,7 +61,7 @@ pub(crate) struct RequiredRunToolActor(pub(crate) Principal);
|
|||
pub(crate) struct RequireRunScoped(pub(crate) RunId);
|
||||
pub(crate) struct RequireWorkerRunScoped(pub(crate) RunId);
|
||||
pub(crate) struct RequireRunManagementTarget(pub(crate) RunId, pub(crate) Principal);
|
||||
pub(crate) struct RequireRunBlob(pub(crate) RunId, pub(crate) RunBlobId);
|
||||
pub(crate) struct RequireRunBlob(pub(crate) RunId, pub(crate) BlobHash);
|
||||
pub(crate) struct RequireRunStageScoped(pub(crate) RunId, pub(crate) String);
|
||||
pub(crate) struct RequireStageArtifact(pub(crate) RunId, pub(crate) StageId);
|
||||
pub(crate) struct RequireCommandLog(pub(crate) RunId, pub(crate) StageId);
|
||||
|
|
@ -295,14 +295,14 @@ impl FromRequestParts<Arc<AppState>> for RequireRunBlob {
|
|||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Path((id, blob_id)): Path<(String, String)> = Path::from_request_parts(parts, state)
|
||||
let Path((id, blob_hash)): Path<(String, String)> = Path::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let run_id = parse_run_id_path(&id)?;
|
||||
let blob_id = parse_blob_id_path(&blob_id)?;
|
||||
let blob_hash = parse_blob_hash_path(&blob_hash)?;
|
||||
require_worker_or_user_for_run(&auth_slot_from_parts(parts), &run_id)
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
Ok(Self(run_id, blob_id))
|
||||
Ok(Self(run_id, blob_hash))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -586,17 +586,7 @@ fn resolve_dockerfiles(
|
|||
config_path: &ManifestPath,
|
||||
files: &HashMap<ManifestPath, String>,
|
||||
) -> Result<()> {
|
||||
for environment in layer.environments.values_mut() {
|
||||
if let Some(image) = environment.image.as_mut() {
|
||||
resolve_dockerfile(image, config_path, files)?;
|
||||
}
|
||||
}
|
||||
if let Some(image) = layer
|
||||
.run
|
||||
.as_mut()
|
||||
.and_then(|run| run.environment.as_mut())
|
||||
.and_then(|environment| environment.image.as_mut())
|
||||
{
|
||||
for image in layer.environment_images_mut() {
|
||||
resolve_dockerfile(image, config_path, files)?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -96,10 +96,10 @@ use fabro_types::settings::server::{
|
|||
GithubIntegrationSettings, GithubIntegrationStrategy, LogDestination,
|
||||
};
|
||||
use fabro_types::{
|
||||
AgentBackend, AskFabro, AskFabroUnavailableReason, EventBody, InterviewQuestionRecord, PairId,
|
||||
PairMessageId, PairTarget, PendingReason, Principal, PullRequestLink, QuestionType, RunBlobId,
|
||||
RunControlAction, RunEvent, RunId, RunRunnableSource, SandboxProviderKind, ServerSettings,
|
||||
SessionCapability,
|
||||
AgentBackend, AskFabro, AskFabroUnavailableReason, BlobHash, EventBody,
|
||||
InterviewQuestionRecord, PairId, PairMessageId, PairTarget, PendingReason, Principal,
|
||||
PullRequestLink, QuestionType, RunControlAction, RunEvent, RunId, RunRunnableSource,
|
||||
SandboxProviderKind, ServerSettings, SessionCapability,
|
||||
};
|
||||
use fabro_util::error::{
|
||||
SharedError, collect_causes, render_compact_with_causes, render_with_causes,
|
||||
|
|
@ -2904,11 +2904,11 @@ pub(crate) fn parse_stage_id_path(stage_id: &str) -> Result<StageId, Response> {
|
|||
|
||||
#[allow(
|
||||
clippy::result_large_err,
|
||||
reason = "Blob ID parsing returns HTTP 400 responses directly."
|
||||
reason = "Blob hash parsing returns HTTP 400 responses directly."
|
||||
)]
|
||||
pub(crate) fn parse_blob_id_path(blob_id: &str) -> Result<RunBlobId, Response> {
|
||||
RunBlobId::from_str(blob_id)
|
||||
.map_err(|_| ApiError::bad_request("Invalid blob ID.").into_response())
|
||||
pub(crate) fn parse_blob_hash_path(blob_hash: &str) -> Result<BlobHash, Response> {
|
||||
BlobHash::from_str(blob_hash)
|
||||
.map_err(|_| ApiError::bad_request("Invalid blob hash.").into_response())
|
||||
}
|
||||
|
||||
#[allow(
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
Router::new()
|
||||
.route("/runs/{id}/checkpoint", get(get_checkpoint))
|
||||
.route("/runs/{id}/blobs", post(write_run_blob))
|
||||
.route("/runs/{id}/blobs/{blobId}", get(read_run_blob))
|
||||
.route("/runs/{id}/blobs/{blobHash}", get(read_run_blob))
|
||||
.route("/runs/{id}/artifacts", get(list_run_artifacts))
|
||||
.route("/runs/{id}/artifacts/download", get(download_run_artifacts))
|
||||
.route(
|
||||
|
|
@ -105,10 +105,7 @@ async fn write_run_blob(
|
|||
}
|
||||
match state.stores.runs.open_run(&id).await {
|
||||
Ok(run_store) => match run_store.write_blob(&body).await {
|
||||
Ok(blob_id) => Json(WriteBlobResponse {
|
||||
id: blob_id.to_string(),
|
||||
})
|
||||
.into_response(),
|
||||
Ok(blob_hash) => Json(WriteBlobResponse { hash: blob_hash }).into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
}
|
||||
|
|
@ -118,11 +115,11 @@ async fn write_run_blob(
|
|||
}
|
||||
|
||||
async fn read_run_blob(
|
||||
RequireRunBlob(id, blob_id): RequireRunBlob,
|
||||
RequireRunBlob(id, blob_hash): RequireRunBlob,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Response {
|
||||
match state.stores.runs.open_run_reader(&id).await {
|
||||
Ok(run_store) => match run_store.read_blob(&blob_id).await {
|
||||
Ok(run_store) => match run_store.read_blob(&blob_hash).await {
|
||||
Ok(Some(bytes)) => octet_stream_response(bytes),
|
||||
Ok(None) => ApiError::not_found("Blob not found.").into_response(),
|
||||
Err(err) => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ mod steer;
|
|||
pub(in crate::server) mod system;
|
||||
mod variables;
|
||||
mod worker_control;
|
||||
mod workflow_versions;
|
||||
|
||||
pub(super) use system::{health, openapi_spec};
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
|
|||
)
|
||||
.route("/runs/{id}/attach", get(demo::run_events_stub))
|
||||
.route("/runs/{id}/blobs", post(not_implemented))
|
||||
.route("/runs/{id}/blobs/{blobId}", get(not_implemented))
|
||||
.route("/runs/{id}/blobs/{blobHash}", get(not_implemented))
|
||||
.route(
|
||||
"/runs/{id}/stages/{stageId}/logs/output",
|
||||
get(not_implemented),
|
||||
|
|
@ -226,6 +227,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
|
|||
.merge(secrets::routes())
|
||||
.merge(variables::routes())
|
||||
.merge(worker_control::routes())
|
||||
.merge(workflow_versions::routes())
|
||||
.merge(sessions::routes())
|
||||
.merge(system::routes())
|
||||
.merge(completions::routes())
|
||||
|
|
|
|||
392
lib/apps/fabro-server/src/server/handler/workflow_versions.rs
Normal file
392
lib/apps/fabro-server/src/server/handler/workflow_versions.rs
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use fabro_api::types::{CreateWorkflowVersionResponse, WorkflowVersion};
|
||||
use fabro_types::MAX_WORKFLOW_VERSION_BYTES;
|
||||
use fabro_util::error;
|
||||
use fabro_workflow_version::{
|
||||
ValidatedWorkflowVersion, WorkflowVersionStore, WorkflowVersionStoreError,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, IntoResponse, Json, RequiredUser, Response, Router, State, StatusCode, post,
|
||||
};
|
||||
|
||||
const INVALID_JSON_CODE: &str = "invalid_json";
|
||||
const INVALID_VERSION_CODE: &str = "workflow_version_invalid";
|
||||
const DEPENDENCY_NOT_FOUND_CODE: &str = "workflow_version_dependency_not_found";
|
||||
const VERSION_TOO_LARGE_CODE: &str = "workflow_version_too_large";
|
||||
|
||||
pub(super) fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route(
|
||||
"/workflow-versions",
|
||||
post(create_workflow_version).layer(DefaultBodyLimit::max(MAX_WORKFLOW_VERSION_BYTES)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn create_workflow_version(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
payload: Result<Json<WorkflowVersion>, JsonRejection>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let Json(version) = payload.map_err(json_rejection)?;
|
||||
let version = ValidatedWorkflowVersion::new(version).map_err(|err| {
|
||||
ApiError::with_code(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
err.to_string(),
|
||||
INVALID_VERSION_CODE,
|
||||
)
|
||||
})?;
|
||||
let blobs = state.store_ref().blobs().await.map_err(|err| {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
error_chain = ?error::collect_chain(&err),
|
||||
"Failed to open workflow version storage"
|
||||
);
|
||||
internal_store_error()
|
||||
})?;
|
||||
let store = WorkflowVersionStore::new(blobs);
|
||||
let workflow_version_id = store.put(&version).await.map_err(store_error)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreateWorkflowVersionResponse {
|
||||
workflow_version_id,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn json_rejection(rejection: JsonRejection) -> ApiError {
|
||||
if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
|
||||
return ApiError::with_code(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"workflow version request exceeds 2 MiB",
|
||||
VERSION_TOO_LARGE_CODE,
|
||||
);
|
||||
}
|
||||
|
||||
match rejection {
|
||||
JsonRejection::JsonDataError(err) => ApiError::with_code(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
err.body_text(),
|
||||
INVALID_VERSION_CODE,
|
||||
),
|
||||
other => ApiError::with_code(
|
||||
StatusCode::BAD_REQUEST,
|
||||
other.body_text(),
|
||||
INVALID_JSON_CODE,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_error(err: WorkflowVersionStoreError) -> ApiError {
|
||||
match err {
|
||||
// The top-level message names the offending dependency without its
|
||||
// internal source chain, so it is safe to surface to the caller.
|
||||
err @ (WorkflowVersionStoreError::DependencyNotFound { .. }
|
||||
| WorkflowVersionStoreError::DependencyInvalid { .. }) => ApiError::with_code(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
err.to_string(),
|
||||
DEPENDENCY_NOT_FOUND_CODE,
|
||||
),
|
||||
err => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
error_chain = ?error::collect_chain(&err),
|
||||
"Workflow version store operation failed"
|
||||
);
|
||||
internal_store_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn internal_store_error() -> ApiError {
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"workflow version store operation failed",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Method, Request, StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
use fabro_types::{BlobHash, WorkflowVersion, WorkflowVersionId};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{
|
||||
DEPENDENCY_NOT_FOUND_CODE, INVALID_JSON_CODE, INVALID_VERSION_CODE,
|
||||
MAX_WORKFLOW_VERSION_BYTES, VERSION_TOO_LARGE_CODE, store_error,
|
||||
};
|
||||
use crate::server;
|
||||
use crate::test_support::{self, TestAppStateBuilder};
|
||||
|
||||
const GRAPH: &str = "digraph W { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
|
||||
|
||||
fn request(body: impl Into<Body>) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/v1/workflow-versions")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body.into())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn version(graph: &str) -> Value {
|
||||
json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": { "workflow.fabro": graph },
|
||||
"workflow_dependencies": {}
|
||||
})
|
||||
}
|
||||
|
||||
async fn response_json(response: axum::response::Response) -> Value {
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
fn error_code(body: &Value) -> &str {
|
||||
body["errors"][0]["code"].as_str().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_requires_authenticated_user() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = server::build_router(state, test_support::test_auth_mode());
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&version(GRAPH)).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_and_equivalent_requests_return_the_same_id() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = test_support::build_test_router(Arc::clone(&state));
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(request(serde_json::to_vec(&version(GRAPH)).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
let first = response_json(first).await;
|
||||
assert_eq!(first.as_object().unwrap().len(), 1);
|
||||
|
||||
let reordered = format!(
|
||||
r#"{{"workflow_dependencies":{{}},"files":{{"workflow.fabro":{}}},"entrypoint":"workflow.fabro"}}"#,
|
||||
serde_json::to_string(GRAPH).unwrap()
|
||||
);
|
||||
let second = app.oneshot(request(reordered)).await.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::CREATED);
|
||||
assert_eq!(response_json(second).await, first);
|
||||
|
||||
let id = first["workflow_version_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<WorkflowVersionId>()
|
||||
.unwrap();
|
||||
assert!(
|
||||
state
|
||||
.store_ref()
|
||||
.blobs()
|
||||
.await
|
||||
.unwrap()
|
||||
.read(&id.into())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_json_and_domain_content_have_distinct_codes() {
|
||||
let app = test_support::build_test_router(TestAppStateBuilder::new().build());
|
||||
let malformed = app.clone().oneshot(request("{")).await.unwrap();
|
||||
assert_eq!(malformed.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
error_code(&response_json(malformed).await),
|
||||
INVALID_JSON_CODE
|
||||
);
|
||||
|
||||
let unknown = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": { "workflow.fabro": GRAPH },
|
||||
"workflow_dependencies": {},
|
||||
"metadata": {}
|
||||
});
|
||||
let invalid = app
|
||||
.oneshot(request(serde_json::to_vec(&unknown).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invalid.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
error_code(&response_json(invalid).await),
|
||||
INVALID_VERSION_CODE
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_workflow_config_with_missing_goal_file_before_storage() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = test_support::build_test_router(Arc::clone(&state));
|
||||
let payload = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": GRAPH,
|
||||
"workflow.toml": "_version = 1\n[run.goal]\nfile = \"prompts/goal.md\"\n"
|
||||
},
|
||||
"workflow_dependencies": {}
|
||||
});
|
||||
let version = serde_json::from_value::<WorkflowVersion>(payload.clone()).unwrap();
|
||||
let id = WorkflowVersionId::from(BlobHash::new(&version.canonical_bytes().unwrap()));
|
||||
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&payload).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = fabro_test::expect_axum_json(
|
||||
response,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"POST /api/v1/workflow-versions with missing run goal file",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(error_code(&body), INVALID_VERSION_CODE);
|
||||
assert!(
|
||||
!state
|
||||
.store_ref()
|
||||
.blobs()
|
||||
.await
|
||||
.unwrap()
|
||||
.exists(&id.into())
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_dependency_has_specific_code() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = test_support::build_test_router(Arc::clone(&state));
|
||||
let missing_id = WorkflowVersionId::from(fabro_types::BlobHash::new(b"missing"));
|
||||
let root = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": "digraph W { child [stack.child_workflow=\"child.fabro\"] }"
|
||||
},
|
||||
"workflow_dependencies": { "child.fabro": missing_id }
|
||||
});
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&root).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
error_code(&response_json(response).await),
|
||||
DEPENDENCY_NOT_FOUND_CODE
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_stored_dependency_is_a_client_error_without_internals() {
|
||||
let state = TestAppStateBuilder::new().build();
|
||||
let app = test_support::build_test_router(Arc::clone(&state));
|
||||
let dependency_id = WorkflowVersionId::from(
|
||||
state
|
||||
.store_ref()
|
||||
.blobs()
|
||||
.await
|
||||
.unwrap()
|
||||
.write(b"not a workflow version")
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let root = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": "digraph W { child [stack.child_workflow=\"child.fabro\"] }"
|
||||
},
|
||||
"workflow_dependencies": { "child.fabro": dependency_id }
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&root).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(error_code(&body), DEPENDENCY_NOT_FOUND_CODE);
|
||||
assert!(
|
||||
body["errors"][0]["detail"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("child.fabro")
|
||||
);
|
||||
assert!(!body.to_string().contains("cannot be decoded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stored_child_can_be_pinned_as_a_dependency() {
|
||||
let app = test_support::build_test_router(TestAppStateBuilder::new().build());
|
||||
let child = app
|
||||
.clone()
|
||||
.oneshot(request(serde_json::to_vec(&version(GRAPH)).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(child.status(), StatusCode::CREATED);
|
||||
let child_id = response_json(child).await["workflow_version_id"].clone();
|
||||
let root = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": "digraph W { child [stack.child_workflow=\"child.fabro\"] }"
|
||||
},
|
||||
"workflow_dependencies": { "child.fabro": child_id }
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(request(serde_json::to_vec(&root).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
assert_eq!(response_json(response).await.as_object().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn body_limit_has_specific_code() {
|
||||
let app = test_support::build_test_router(TestAppStateBuilder::new().build());
|
||||
let response = app
|
||||
.oneshot(request(vec![b' '; MAX_WORKFLOW_VERSION_BYTES + 1]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert_eq!(
|
||||
error_code(&response_json(response).await),
|
||||
VERSION_TOO_LARGE_CODE
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn storage_fault_response_is_curated() {
|
||||
let response = store_error(fabro_workflow_version::WorkflowVersionStoreError::Storage {
|
||||
source: fabro_store::Error::Other("private persistence detail".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(
|
||||
body["errors"][0]["detail"],
|
||||
"workflow version store operation failed"
|
||||
);
|
||||
assert!(!body.to_string().contains("private persistence detail"));
|
||||
}
|
||||
}
|
||||
|
|
@ -26,12 +26,12 @@ use fabro_model::{Catalog, ModelRef, ProviderId, ReasoningEffort, Speed};
|
|||
use fabro_types::settings::ServerAuthMethod;
|
||||
use fabro_types::settings::run::EnvironmentProvider;
|
||||
use fabro_types::{
|
||||
AgentBackend, AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph,
|
||||
InterviewQuestionRecord, Node, Outcome, ParallelBranchId, QuestionType, RunBlobId, RunId,
|
||||
RunSpec, SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory,
|
||||
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
|
||||
StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind,
|
||||
WorkflowSettings, fixtures, test_support,
|
||||
AgentBackend, AttrValue, AuthMethod, BlobHash, CommandTermination, FailureCategory,
|
||||
FailureDetail, Graph, InterviewQuestionRecord, Node, Outcome, ParallelBranchId, QuestionType,
|
||||
RunId, RunSpec, SandboxProviderKind, StageContextWindowBreakdownItem,
|
||||
StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
|
||||
StageContextWindowStaleness, StageContextWindowWarning, StageModelUsage, StageTiming,
|
||||
SuccessReason, SystemActorKind, WorkflowSettings, fixtures, test_support,
|
||||
};
|
||||
use fabro_util::check_report::CheckStatus;
|
||||
use fabro_workflow::records::CheckpointExt;
|
||||
|
|
@ -3890,7 +3890,7 @@ layer = "project"
|
|||
let manifest_blob = created["properties"]["manifest_blob"]
|
||||
.as_str()
|
||||
.expect("run.created should carry the submitted source blob")
|
||||
.parse::<RunBlobId>()
|
||||
.parse::<BlobHash>()
|
||||
.unwrap();
|
||||
let persisted_manifest = run_store
|
||||
.read_blob(&manifest_blob)
|
||||
|
|
@ -10786,12 +10786,12 @@ async fn create_run_persists_manifest_and_definition_blobs_without_bundle_file()
|
|||
let manifest_blob = created["properties"]["manifest_blob"]
|
||||
.as_str()
|
||||
.expect("run.created should carry manifest_blob")
|
||||
.parse::<RunBlobId>()
|
||||
.parse::<BlobHash>()
|
||||
.unwrap();
|
||||
let definition_blob = submitted["properties"]["definition_blob"]
|
||||
.as_str()
|
||||
.expect("run.submitted should carry definition_blob")
|
||||
.parse::<RunBlobId>()
|
||||
.parse::<BlobHash>()
|
||||
.unwrap();
|
||||
|
||||
let submitted_manifest_bytes = run_store
|
||||
|
|
@ -11034,7 +11034,7 @@ async fn get_checkpoint_returns_null_initially() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_and_read_run_blob_round_trip() {
|
||||
async fn write_and_read_run_blob_accepts_uppercase_hash() {
|
||||
let state = test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
|
||||
|
|
@ -11057,11 +11057,14 @@ async fn write_and_read_run_blob_round_trip() {
|
|||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
let blob_id = body["id"].as_str().unwrap();
|
||||
let blob_hash = body["hash"].as_str().unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/runs/{run_id}/blobs/{blob_id}")))
|
||||
.uri(api(&format!(
|
||||
"/runs/{run_id}/blobs/{}",
|
||||
blob_hash.to_uppercase()
|
||||
)))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
|
|
@ -11459,7 +11462,7 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
|
|||
let worker_token = issue_test_worker_token(&run_id);
|
||||
let other_run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let other_worker_token = issue_test_worker_token(&other_run_id);
|
||||
let blob_id = state
|
||||
let blob_hash = state
|
||||
.stores
|
||||
.runs
|
||||
.open_run(&run_id)
|
||||
|
|
@ -11553,7 +11556,7 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
|
|||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/blobs/{blob_id}"),
|
||||
&format!("/runs/{run_id}/blobs/{blob_hash}"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
|
|
@ -12058,7 +12061,7 @@ async fn worker_token_is_rejected_on_user_only_routes() {
|
|||
let user_jwt = issue_test_user_jwt();
|
||||
let run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let worker_token = issue_test_worker_token(&run_id);
|
||||
let blob_id = RunBlobId::new(b"blob");
|
||||
let blob_hash = BlobHash::new(b"blob");
|
||||
let user_only_routes = vec![
|
||||
(Method::GET, "/runs".to_string()),
|
||||
(Method::POST, "/runs".to_string()),
|
||||
|
|
@ -12121,7 +12124,7 @@ async fn worker_token_is_rejected_on_user_only_routes() {
|
|||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/blobs/{blob_id}"),
|
||||
&format!("/runs/{run_id}/blobs/{blob_hash}"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
|
|
|
|||
|
|
@ -59,8 +59,9 @@ pub use question_tools::{
|
|||
};
|
||||
pub use sandbox::{
|
||||
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingRequest, ExecStreamingResult,
|
||||
GrepOptions, RefreshOutcome, Sandbox, SandboxEvent, SandboxEventCallback, StderrCollector,
|
||||
StdioProcess, StdioProcessHandle, format_lines_numbered, shell_quote,
|
||||
GrepOptions, RefreshOutcome, RemoteCredentialAction, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle, TokenProvenance,
|
||||
TokenSnapshot, format_lines_numbered, shell_quote,
|
||||
};
|
||||
pub use session::{
|
||||
CompletionCoordinator, Session, SessionControlHandle, SessionInputTiming,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
// `crate::delegate_sandbox!` invocations continue to work.
|
||||
pub use fabro_sandbox::{
|
||||
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingRequest, ExecStreamingResult,
|
||||
GrepOptions, RefreshOutcome, Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile,
|
||||
StderrCollector, StdioProcess, StdioProcessHandle, StdioProcessTermination, WalkOptions,
|
||||
delegate_sandbox, format_lines_numbered, shell_quote,
|
||||
GrepOptions, RefreshOutcome, RemoteCredentialAction, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback, SandboxFile, StderrCollector, StdioProcess, StdioProcessHandle,
|
||||
StdioProcessTermination, TokenProvenance, TokenSnapshot, WalkOptions, delegate_sandbox,
|
||||
format_lines_numbered, shell_quote,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ pub fn make_edit_file_tool() -> RegisteredTool {
|
|||
};
|
||||
|
||||
ctx.env
|
||||
.write_file(file_path, &new_content)
|
||||
.write_existing_file(file_path, &new_content)
|
||||
.await
|
||||
.map_err(|e| e.display_with_causes())?;
|
||||
Ok(format!("Successfully edited {file_path}"))
|
||||
|
|
@ -1002,6 +1002,7 @@ mod tests {
|
|||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), "Successfully wrote to /out.txt");
|
||||
assert_eq!(env.existing_file_write_count(), 0);
|
||||
let written = env.written_files.lock().unwrap();
|
||||
assert_eq!(written.len(), 1);
|
||||
assert_eq!(written[0].0, "/out.txt");
|
||||
|
|
@ -1036,6 +1037,7 @@ mod tests {
|
|||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), "Successfully edited /f.txt");
|
||||
assert_eq!(env.existing_file_write_count(), 1);
|
||||
let written = env.written_files.lock().unwrap();
|
||||
assert_eq!(written.len(), 1);
|
||||
assert_eq!(written[0].1, "goodbye world");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "in-memory Vec<u8>::write_all for jsonl serialization; no filesystem or network I/O"
|
||||
|
|
@ -16,10 +17,10 @@ use bytes::Bytes;
|
|||
use fabro_store::{
|
||||
EventEnvelope, RunProjection, SerializableProjection, StageId, retry_storage_segment,
|
||||
};
|
||||
use fabro_types::{RunBlobId, parse_blob_ref};
|
||||
use fabro_types::{BlobHash, parse_blob_ref};
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
pub type BlobReader = Box<dyn FnMut(RunBlobId) -> BoxFuture<'static, Result<Option<Bytes>>> + Send>;
|
||||
pub type BlobReader = Box<dyn FnMut(BlobHash) -> BoxFuture<'static, Result<Option<Bytes>>> + Send>;
|
||||
|
||||
const STAGE_RANK_WIDTH: usize = 3;
|
||||
const MAX_STAGES_IN_DUMP: usize = {
|
||||
|
|
@ -208,37 +209,48 @@ impl RunDump {
|
|||
mut read_blob: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnMut(RunBlobId) -> BoxFuture<'a, Result<Option<Bytes>>>,
|
||||
F: FnMut(BlobHash) -> BoxFuture<'a, Result<Option<Bytes>>>,
|
||||
{
|
||||
let mut cache = HashMap::new();
|
||||
for entry in &mut self.entries {
|
||||
match &mut entry.contents {
|
||||
RunDumpContents::Json(value) => {
|
||||
let mut blob_ids = Vec::new();
|
||||
collect_blob_refs_in_value(value, &mut blob_ids);
|
||||
for blob_id in blob_ids {
|
||||
if cache.contains_key(&blob_id) {
|
||||
let mut blob_hashes = Vec::new();
|
||||
collect_blob_refs_in_value(value, &mut blob_hashes);
|
||||
for blob_hash in blob_hashes {
|
||||
if cache.contains_key(&blob_hash) {
|
||||
continue;
|
||||
}
|
||||
let blob = read_blob(blob_id).await?.with_context(|| {
|
||||
format!("blob {blob_id:?} is missing from the store")
|
||||
let blob = read_blob(blob_hash).await?.with_context(|| {
|
||||
format!("blob {blob_hash:?} is missing from the store")
|
||||
})?;
|
||||
let hydrated: serde_json::Value = serde_json::from_slice(&blob)
|
||||
.with_context(|| format!("blob {blob_id:?} is not valid JSON"))?;
|
||||
cache.insert(blob_id, hydrated);
|
||||
.with_context(|| format!("blob {blob_hash:?} is not valid JSON"))?;
|
||||
cache.insert(blob_hash, hydrated);
|
||||
}
|
||||
replace_blob_refs_in_value(value, &cache)?;
|
||||
}
|
||||
RunDumpContents::Text(text) => {
|
||||
let Some(blob_id) = parse_blob_ref(text) else {
|
||||
let Some(blob_hash) = parse_blob_ref(text) else {
|
||||
continue;
|
||||
};
|
||||
let blob = read_blob(blob_id)
|
||||
.await?
|
||||
.with_context(|| format!("blob {blob_id:?} is missing from the store"))?;
|
||||
*text = serde_json::from_slice::<String>(&blob).with_context(|| {
|
||||
format!("blob {blob_id:?} is not a JSON string text log")
|
||||
})?;
|
||||
let hydrated = match cache.entry(blob_hash) {
|
||||
Entry::Occupied(entry) => entry.into_mut(),
|
||||
Entry::Vacant(entry) => {
|
||||
let blob = read_blob(blob_hash).await?.with_context(|| {
|
||||
format!("blob {blob_hash:?} is missing from the store")
|
||||
})?;
|
||||
let hydrated: serde_json::Value = serde_json::from_slice(&blob)
|
||||
.with_context(|| format!("blob {blob_hash:?} is not valid JSON"))?;
|
||||
entry.insert(hydrated)
|
||||
}
|
||||
};
|
||||
*text = hydrated
|
||||
.as_str()
|
||||
.with_context(|| {
|
||||
format!("blob {blob_hash:?} is not a JSON string text log")
|
||||
})?
|
||||
.to_string();
|
||||
}
|
||||
RunDumpContents::Bytes(_) => {}
|
||||
}
|
||||
|
|
@ -386,21 +398,21 @@ fn validate_relative_path(kind: &str, value: &str) -> Result<PathBuf> {
|
|||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn collect_blob_refs_in_value(value: &serde_json::Value, blob_ids: &mut Vec<RunBlobId>) {
|
||||
fn collect_blob_refs_in_value(value: &serde_json::Value, blob_hashes: &mut Vec<BlobHash>) {
|
||||
match value {
|
||||
serde_json::Value::String(current) => {
|
||||
if let Some(blob_id) = parse_blob_ref(current) {
|
||||
blob_ids.push(blob_id);
|
||||
if let Some(blob_hash) = parse_blob_ref(current) {
|
||||
blob_hashes.push(blob_hash);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_blob_refs_in_value(item, blob_ids);
|
||||
collect_blob_refs_in_value(item, blob_hashes);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for item in map.values() {
|
||||
collect_blob_refs_in_value(item, blob_ids);
|
||||
collect_blob_refs_in_value(item, blob_hashes);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
|
||||
|
|
@ -409,17 +421,16 @@ fn collect_blob_refs_in_value(value: &serde_json::Value, blob_ids: &mut Vec<RunB
|
|||
|
||||
fn replace_blob_refs_in_value(
|
||||
value: &mut serde_json::Value,
|
||||
cache: &HashMap<RunBlobId, serde_json::Value>,
|
||||
cache: &HashMap<BlobHash, serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
match value {
|
||||
serde_json::Value::String(current) => {
|
||||
let Some(blob_id) = parse_blob_ref(current) else {
|
||||
let Some(blob_hash) = parse_blob_ref(current) else {
|
||||
return Ok(());
|
||||
};
|
||||
let hydrated = cache
|
||||
.get(&blob_id)
|
||||
.cloned()
|
||||
.with_context(|| format!("blob {blob_id:?} is missing from the hydration cache"))?;
|
||||
let hydrated = cache.get(&blob_hash).cloned().with_context(|| {
|
||||
format!("blob {blob_hash:?} is missing from the hydration cache")
|
||||
})?;
|
||||
*value = hydrated;
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
|
|
@ -475,8 +486,7 @@ mod tests {
|
|||
use fabro_types::{
|
||||
Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunSandboxInstance,
|
||||
RunSandboxPlan, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,
|
||||
StageOutcome, StartRecord, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
|
||||
test_support,
|
||||
StageOutcome, StartRecord, SuccessReason, first_event_seq, fixtures, test_support,
|
||||
};
|
||||
use futures::executor;
|
||||
|
||||
|
|
@ -484,24 +494,18 @@ mod tests {
|
|||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("ship"),
|
||||
graph_source: Some("digraph Ship {}".to_string()),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
automation: None,
|
||||
graph: Graph::new("ship"),
|
||||
graph_source: Some("digraph Ship {}".to_string()),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
git: Some(fabro_types::GitContext {
|
||||
git: Some(fabro_types::GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
sha: None,
|
||||
dirty: fabro_types::DirtyStatus::Clean,
|
||||
}),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -724,8 +728,8 @@ mod tests {
|
|||
#[test]
|
||||
fn hydrate_referenced_blobs_ignores_legacy_artifact_file_refs() {
|
||||
let blob = serde_json::to_vec("hydrated legacy text").unwrap();
|
||||
let blob_id = fabro_types::RunBlobId::new(&blob);
|
||||
let legacy_ref = format!("file:///sandbox/.fabro/artifacts/{blob_id}.json");
|
||||
let blob_hash = fabro_types::BlobHash::new(&blob);
|
||||
let legacy_ref = format!("file:///sandbox/.fabro/artifacts/{blob_hash}.json");
|
||||
let mut dump = RunDump {
|
||||
entries: vec![RunDumpEntry::json(
|
||||
"run.json",
|
||||
|
|
@ -736,10 +740,10 @@ mod tests {
|
|||
};
|
||||
|
||||
executor::block_on(async {
|
||||
dump.hydrate_referenced_blobs_with_reader(|read_blob_id| {
|
||||
dump.hydrate_referenced_blobs_with_reader(|read_blob_hash| {
|
||||
let blob = blob.clone();
|
||||
Box::pin(async move {
|
||||
assert_eq!(read_blob_id, blob_id);
|
||||
assert_eq!(read_blob_hash, blob_hash);
|
||||
Ok(Some(bytes::Bytes::from(blob)))
|
||||
})
|
||||
})
|
||||
|
|
@ -752,4 +756,43 @@ mod tests {
|
|||
};
|
||||
assert_eq!(value["stdout"], legacy_ref);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydrate_referenced_blobs_fetches_shared_blobs_once() {
|
||||
let blob = serde_json::to_vec("offloaded response text").unwrap();
|
||||
let blob_hash = fabro_types::BlobHash::new(&blob);
|
||||
let blob_ref = fabro_types::format_blob_ref(&blob_hash);
|
||||
let mut dump = RunDump {
|
||||
entries: vec![
|
||||
RunDumpEntry::json("run.json", serde_json::json!({ "response": blob_ref })),
|
||||
RunDumpEntry::text("stages/001-demo@1/response.md", blob_ref.clone()),
|
||||
],
|
||||
stage_ranks: HashMap::new(),
|
||||
dump_log_index: None,
|
||||
};
|
||||
|
||||
let reads = std::cell::Cell::new(0);
|
||||
executor::block_on(async {
|
||||
dump.hydrate_referenced_blobs_with_reader(|read_blob_hash| {
|
||||
reads.set(reads.get() + 1);
|
||||
let blob = blob.clone();
|
||||
Box::pin(async move {
|
||||
assert_eq!(read_blob_hash, blob_hash);
|
||||
Ok(Some(bytes::Bytes::from(blob)))
|
||||
})
|
||||
})
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reads.get(), 1, "shared blob should be fetched once");
|
||||
let RunDumpContents::Json(value) = &dump.entries[0].contents else {
|
||||
panic!("entry should be JSON");
|
||||
};
|
||||
assert_eq!(value["response"], "offloaded response text");
|
||||
let RunDumpContents::Text(text) = &dump.entries[1].contents else {
|
||||
panic!("entry should be text");
|
||||
};
|
||||
assert_eq!(text, "offloaded response text");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,17 @@ description = "GitHub App authentication and API helpers for Fabro"
|
|||
[lib]
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
strum.workspace = true
|
||||
serde_json.workspace = true
|
||||
fabro-http.workspace = true
|
||||
fabro-redact.workspace = true
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ use fabro_types::settings::run::MergeStrategy;
|
|||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub mod token_source;
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
|
||||
pub const GITHUB_API_BASE_URL: &str = "https://api.github.com";
|
||||
|
||||
/// Returns the GitHub API base URL, allowing override via `GITHUB_BASE_URL` env
|
||||
|
|
|
|||
26
lib/components/fabro-github/src/test_support.rs
Normal file
26
lib/components/fabro-github/src/test_support.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::InstallationToken;
|
||||
use crate::token_source::{InstallationTokenMinter as InnerMinter, InstallationTokenSource};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait InstallationTokenMinter: Send + Sync {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken>;
|
||||
}
|
||||
|
||||
struct TestMinterAdapter(Arc<dyn InstallationTokenMinter>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InnerMinter for TestMinterAdapter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.0.mint().await
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn installation_token_source(
|
||||
repo: impl Into<String>,
|
||||
minter: Arc<dyn InstallationTokenMinter>,
|
||||
) -> Arc<InstallationTokenSource> {
|
||||
InstallationTokenSource::with_minter(repo.into(), Box::new(TestMinterAdapter(minter)))
|
||||
}
|
||||
674
lib/components/fabro-github/src/token_source.rs
Normal file
674
lib/components/fabro-github/src/token_source.rs
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
//! Cached GitHub installation-token source.
|
||||
//!
|
||||
//! One [`InstallationTokenSource`] can serve GitHub-token consumers that share
|
||||
//! a repository and permission scope. Reusing mature tokens keeps consumers
|
||||
//! out of GitHub's token-replication lag window, where a token minted
|
||||
//! milliseconds earlier is rejected with 404 "Repository not found" or an
|
||||
//! authentication failure.
|
||||
//!
|
||||
//! The source also reports *provenance*: when it minted the token it returned,
|
||||
//! and which mint generation it belongs to. Retry classification, logging, and
|
||||
//! failure reports all read that one fact instead of threading booleans
|
||||
//! through call stacks.
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{GitHubAppCredentials, GitHubCredentials, InstallationToken};
|
||||
|
||||
/// How long before expiry a cached installation token stops being reused.
|
||||
///
|
||||
/// Must comfortably exceed the longest git operation that pins a resolved
|
||||
/// token, so a token handed out just above the margin still outlives the
|
||||
/// operation. GitHub App installation tokens live 60 minutes.
|
||||
pub const REFRESH_MARGIN: Duration = Duration::from_mins(10);
|
||||
|
||||
/// Where the token a resolve returned came from.
|
||||
///
|
||||
/// Time metadata exists only for tokens this source minted. Static
|
||||
/// credentials (a PAT, or a pre-minted installation token) carry no
|
||||
/// `minted_at`, so token age is undefined for them and they are never
|
||||
/// treated as freshly minted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum TokenProvenance {
|
||||
/// This resolve minted the token.
|
||||
Minted {
|
||||
minted_at: DateTime<Utc>,
|
||||
expires_at: DateTime<Utc>,
|
||||
},
|
||||
/// This resolve returned a token minted by an earlier resolve.
|
||||
Reused {
|
||||
minted_at: DateTime<Utc>,
|
||||
expires_at: DateTime<Utc>,
|
||||
},
|
||||
/// A fixed credential the source cannot re-mint.
|
||||
Static,
|
||||
}
|
||||
|
||||
/// Non-secret description of the token a resolve returned. Shared by the
|
||||
/// source, refresh outcomes, logs, and events.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TokenSnapshot {
|
||||
/// Increments per mint; 0 for `Static`.
|
||||
pub generation: u64,
|
||||
pub provenance: TokenProvenance,
|
||||
}
|
||||
|
||||
impl TokenSnapshot {
|
||||
#[must_use]
|
||||
pub fn minted_at(&self) -> Option<DateTime<Utc>> {
|
||||
match self.provenance {
|
||||
TokenProvenance::Minted { minted_at, .. }
|
||||
| TokenProvenance::Reused { minted_at, .. } => Some(minted_at),
|
||||
TokenProvenance::Static => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expires_at(&self) -> Option<DateTime<Utc>> {
|
||||
match self.provenance {
|
||||
TokenProvenance::Minted { expires_at, .. }
|
||||
| TokenProvenance::Reused { expires_at, .. } => Some(expires_at),
|
||||
TokenProvenance::Static => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Age of the token at `now`. `None` for static credentials, whose age is
|
||||
/// undefined.
|
||||
#[must_use]
|
||||
pub fn age_at(&self, now: DateTime<Utc>) -> Option<Duration> {
|
||||
let minted_at = self.minted_at()?;
|
||||
Some((now - minted_at).to_std().unwrap_or(Duration::ZERO))
|
||||
}
|
||||
|
||||
/// Age of the token in milliseconds, measured now.
|
||||
#[must_use]
|
||||
pub fn age_ms(&self) -> Option<u64> {
|
||||
self.age_at(Utc::now())
|
||||
.map(|age| u64::try_from(age.as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_static(&self) -> bool {
|
||||
matches!(self.provenance, TokenProvenance::Static)
|
||||
}
|
||||
}
|
||||
|
||||
/// A token secret that never appears in `Debug` output. Call
|
||||
/// [`SecretString::expose`] at the point of use (URL embedding, git
|
||||
/// credentials) — never in a log line.
|
||||
#[derive(Clone)]
|
||||
pub struct SecretString(String);
|
||||
|
||||
impl SecretString {
|
||||
#[must_use]
|
||||
pub fn new(secret: String) -> Self {
|
||||
Self(secret)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretString {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("SecretString(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// A token handed out by [`InstallationTokenSource::resolve`]: the secret plus
|
||||
/// its non-secret snapshot. Only the snapshot may cross logging or event
|
||||
/// boundaries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedToken {
|
||||
pub token: SecretString,
|
||||
pub snapshot: TokenSnapshot,
|
||||
}
|
||||
|
||||
/// Mints installation tokens for [`InstallationTokenSource`]. Abstracted so
|
||||
/// tests can script mint results without HTTP.
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait InstallationTokenMinter: Send + Sync {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken>;
|
||||
}
|
||||
|
||||
/// Real minter backed by GitHub App credentials.
|
||||
struct AppTokenMinter {
|
||||
creds: GitHubAppCredentials,
|
||||
http: fabro_http::HttpClient,
|
||||
owner: String,
|
||||
repo: String,
|
||||
base_url: String,
|
||||
permissions: serde_json::Value,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InstallationTokenMinter for AppTokenMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.creds
|
||||
.mint_installation_token(
|
||||
&self.http,
|
||||
&self.owner,
|
||||
&self.repo,
|
||||
&self.base_url,
|
||||
self.permissions.clone(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// A minted token plus the metadata the cache tracks for it.
|
||||
struct CachedToken {
|
||||
token: InstallationToken,
|
||||
minted_at: DateTime<Utc>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl CachedToken {
|
||||
fn resolved(&self, provenance: TokenProvenance) -> ResolvedToken {
|
||||
ResolvedToken {
|
||||
token: SecretString::new(self.token.token.clone()),
|
||||
snapshot: TokenSnapshot {
|
||||
generation: self.generation,
|
||||
provenance,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SourceState {
|
||||
/// A fixed personal access token — no expiry metadata.
|
||||
Pat(SecretString),
|
||||
/// A pre-minted installation token — fixed, rejected client-side once
|
||||
/// expired.
|
||||
Installation(InstallationToken),
|
||||
/// GitHub App credentials that mint installation tokens on demand.
|
||||
///
|
||||
/// The async lock is held across the mint, making `resolve()`
|
||||
/// single-flight: concurrent near-expiry callers wait and receive the
|
||||
/// same generation instead of racing to mint.
|
||||
App {
|
||||
minter: Box<dyn InstallationTokenMinter>,
|
||||
cache: Mutex<Option<CachedToken>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Cached installation-token source for one origin repository.
|
||||
///
|
||||
/// Static credentials pass through unchanged. App credentials mint through
|
||||
/// the shared cache: a resolve reuses the cached token until it is within
|
||||
/// [`REFRESH_MARGIN`] of expiry, then mints a new generation.
|
||||
pub struct InstallationTokenSource {
|
||||
/// `owner/repo`, for logs only.
|
||||
repo: String,
|
||||
state: SourceState,
|
||||
}
|
||||
|
||||
impl InstallationTokenSource {
|
||||
/// Build a source for `creds` against the repository in `origin_url`.
|
||||
///
|
||||
/// `permissions` scopes minted installation tokens; static credentials
|
||||
/// pass through and ignore it.
|
||||
pub fn for_origin(
|
||||
creds: &GitHubCredentials,
|
||||
origin_url: &str,
|
||||
permissions: serde_json::Value,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let normalized = crate::normalize_repo_origin_url(origin_url);
|
||||
let (owner, repo) = crate::parse_github_owner_repo(&normalized)
|
||||
.context("parsing GitHub origin for token source")?;
|
||||
Self::for_repository(creds, owner, repo, permissions)
|
||||
}
|
||||
|
||||
/// Build a source for an already parsed GitHub repository.
|
||||
pub fn for_repository(
|
||||
creds: &GitHubCredentials,
|
||||
owner: String,
|
||||
repo: String,
|
||||
permissions: serde_json::Value,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let repo_display = format!("{owner}/{repo}");
|
||||
let state = match creds {
|
||||
GitHubCredentials::Pat(token) => SourceState::Pat(SecretString::new(token.clone())),
|
||||
GitHubCredentials::Installation(token) => SourceState::Installation(token.clone()),
|
||||
GitHubCredentials::App(app) => {
|
||||
let http = fabro_http::http_client()
|
||||
.map_err(anyhow::Error::new)
|
||||
.context("building HTTP client for token source")?;
|
||||
SourceState::App {
|
||||
minter: Box::new(AppTokenMinter {
|
||||
creds: app.clone(),
|
||||
http,
|
||||
owner,
|
||||
repo,
|
||||
base_url: crate::github_api_base_url(),
|
||||
permissions,
|
||||
}),
|
||||
cache: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Arc::new(Self {
|
||||
repo: repo_display,
|
||||
state,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build a source for a personal access token.
|
||||
#[must_use]
|
||||
pub fn pat(token: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
repo: String::new(),
|
||||
state: SourceState::Pat(SecretString::new(token)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a source for a pre-minted installation token.
|
||||
#[must_use]
|
||||
pub fn installation(token: InstallationToken) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
repo: String::new(),
|
||||
state: SourceState::Installation(token),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a minting source over a custom minter.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[must_use]
|
||||
pub(crate) fn with_minter(repo: String, minter: Box<dyn InstallationTokenMinter>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
repo,
|
||||
state: SourceState::App {
|
||||
minter,
|
||||
cache: Mutex::new(None),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this source can mint new tokens (GitHub App credentials).
|
||||
#[must_use]
|
||||
pub fn mints_installation_tokens(&self) -> bool {
|
||||
matches!(self.state, SourceState::App { .. })
|
||||
}
|
||||
|
||||
/// Resolve a token, reusing the cached one until it nears expiry.
|
||||
pub async fn resolve(&self) -> anyhow::Result<ResolvedToken> {
|
||||
match &self.state {
|
||||
SourceState::Pat(_) | SourceState::Installation(_) => self.resolve_static(),
|
||||
SourceState::App { minter, cache } => {
|
||||
let mut cache = cache.lock().await;
|
||||
// Re-check under the lock: a waiter queued behind a minter
|
||||
// finds the fresh token here instead of minting again.
|
||||
if let Some(cached) = cache.as_ref() {
|
||||
if !cached.token.near_expiry(REFRESH_MARGIN) {
|
||||
let resolved = cached.resolved(TokenProvenance::Reused {
|
||||
minted_at: cached.minted_at,
|
||||
expires_at: cached.token.expires_at,
|
||||
});
|
||||
tracing::debug!(
|
||||
repo = %self.repo,
|
||||
generation = cached.generation,
|
||||
expires_at = %cached.token.expires_at,
|
||||
"Reusing cached GitHub installation token"
|
||||
);
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
match self.mint_locked(minter.as_ref(), &mut cache).await {
|
||||
Ok(resolved) => Ok(resolved),
|
||||
Err(err) => {
|
||||
if let Some(cached) = cache.as_ref() {
|
||||
if cached.token.valid_token().is_ok() {
|
||||
tracing::warn!(
|
||||
error = %format!("{err:#}"),
|
||||
repo = %self.repo,
|
||||
generation = cached.generation,
|
||||
expires_at = %cached.token.expires_at,
|
||||
"GitHub installation token refresh failed; using cached token"
|
||||
);
|
||||
return Ok(cached.resolved(TokenProvenance::Reused {
|
||||
minted_at: cached.minted_at,
|
||||
expires_at: cached.token.expires_at,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a fresh token for the first repository clone and seed the cache
|
||||
/// with it.
|
||||
///
|
||||
/// The clone deliberately never reuses a warm cache: retrying a clone with
|
||||
/// the token minted for it is the established replication-lag recovery,
|
||||
/// and reuse of older tokens for clones is a separate follow-up. Seeding
|
||||
/// makes the clone token generation 1, so later refreshes reuse it until
|
||||
/// it nears expiry.
|
||||
pub async fn mint_for_clone(&self) -> anyhow::Result<ResolvedToken> {
|
||||
match &self.state {
|
||||
SourceState::Pat(_) | SourceState::Installation(_) => self.resolve_static(),
|
||||
SourceState::App { minter, cache } => {
|
||||
let mut cache = cache.lock().await;
|
||||
self.mint_locked(minter.as_ref(), &mut cache).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_static(&self) -> anyhow::Result<ResolvedToken> {
|
||||
let secret = match &self.state {
|
||||
SourceState::Pat(token) => token.clone(),
|
||||
SourceState::Installation(token) => SecretString::new(token.valid_token()?.to_owned()),
|
||||
SourceState::App { .. } => unreachable!("resolve_static called for App credentials"),
|
||||
};
|
||||
Ok(ResolvedToken {
|
||||
token: secret,
|
||||
snapshot: TokenSnapshot {
|
||||
generation: 0,
|
||||
provenance: TokenProvenance::Static,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn mint_locked(
|
||||
&self,
|
||||
minter: &dyn InstallationTokenMinter,
|
||||
cache: &mut Option<CachedToken>,
|
||||
) -> anyhow::Result<ResolvedToken> {
|
||||
let token = minter
|
||||
.mint()
|
||||
.await
|
||||
.context("minting GitHub installation access token")?;
|
||||
let generation = cache.as_ref().map_or(0, |cached| cached.generation) + 1;
|
||||
let minted_at = Utc::now();
|
||||
tracing::info!(
|
||||
repo = %self.repo,
|
||||
generation,
|
||||
expires_at = %token.expires_at,
|
||||
"Minted GitHub installation token"
|
||||
);
|
||||
let cached = CachedToken {
|
||||
token,
|
||||
minted_at,
|
||||
generation,
|
||||
};
|
||||
let resolved = cached.resolved(TokenProvenance::Minted {
|
||||
minted_at,
|
||||
expires_at: cached.token.expires_at,
|
||||
});
|
||||
*cache = Some(cached);
|
||||
Ok(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::*;
|
||||
|
||||
enum MintAction {
|
||||
Token(&'static str, DateTime<Utc>),
|
||||
Error(&'static str),
|
||||
}
|
||||
|
||||
struct MockMinter {
|
||||
calls: AtomicUsize,
|
||||
script: Mutex<VecDeque<MintAction>>,
|
||||
}
|
||||
|
||||
impl MockMinter {
|
||||
fn new(script: Vec<MintAction>) -> Self {
|
||||
Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
script: Mutex::new(script.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InstallationTokenMinter for MockMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
match self.script.lock().await.pop_front().expect("mint script") {
|
||||
MintAction::Token(token, expires_at) => Ok(InstallationToken {
|
||||
token: token.to_string(),
|
||||
expires_at,
|
||||
}),
|
||||
MintAction::Error(message) => Err(anyhow!(message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedMinter(Arc<MockMinter>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InstallationTokenMinter for SharedMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.0.mint().await
|
||||
}
|
||||
}
|
||||
|
||||
fn mintable(script: Vec<MintAction>) -> (Arc<InstallationTokenSource>, Arc<MockMinter>) {
|
||||
let minter = Arc::new(MockMinter::new(script));
|
||||
let source = InstallationTokenSource::with_minter(
|
||||
"owner/repo".to_string(),
|
||||
Box::new(SharedMinter(Arc::clone(&minter))),
|
||||
);
|
||||
(source, minter)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pat_resolves_as_static_generation_zero() {
|
||||
let source = InstallationTokenSource::for_origin(
|
||||
&GitHubCredentials::Pat("ghp_pat".to_string()),
|
||||
"https://github.com/owner/repo.git",
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = source.resolve().await.unwrap();
|
||||
assert_eq!(resolved.token.expose(), "ghp_pat");
|
||||
assert_eq!(resolved.snapshot.generation, 0);
|
||||
assert!(resolved.snapshot.is_static());
|
||||
assert!(!source.mints_installation_tokens());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_installation_token_resolves_until_expiry() {
|
||||
let valid = InstallationTokenSource::for_origin(
|
||||
&GitHubCredentials::Installation(InstallationToken {
|
||||
token: "ghs_static".to_string(),
|
||||
expires_at: Utc::now() + chrono::Duration::minutes(30),
|
||||
}),
|
||||
"https://github.com/owner/repo.git",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = valid.resolve().await.unwrap();
|
||||
assert_eq!(resolved.token.expose(), "ghs_static");
|
||||
assert!(resolved.snapshot.is_static());
|
||||
|
||||
let expired = InstallationTokenSource::for_origin(
|
||||
&GitHubCredentials::Installation(InstallationToken {
|
||||
token: "ghs_expired".to_string(),
|
||||
expires_at: Utc::now() - chrono::Duration::seconds(1),
|
||||
}),
|
||||
"https://github.com/owner/repo.git",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(expired.resolve().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_reuses_cached_token_before_the_margin() {
|
||||
let (source, minter) = mintable(vec![MintAction::Token(
|
||||
"ghs_gen1",
|
||||
Utc::now() + chrono::Duration::minutes(30),
|
||||
)]);
|
||||
|
||||
let first = source.resolve().await.unwrap();
|
||||
let second = source.resolve().await.unwrap();
|
||||
|
||||
assert_eq!(minter.calls(), 1);
|
||||
assert_eq!(first.snapshot.generation, 1);
|
||||
assert_eq!(second.snapshot.generation, 1);
|
||||
assert!(matches!(
|
||||
first.snapshot.provenance,
|
||||
TokenProvenance::Minted { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
second.snapshot.provenance,
|
||||
TokenProvenance::Reused { .. }
|
||||
));
|
||||
assert_eq!(second.token.expose(), "ghs_gen1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_mints_a_new_generation_inside_the_margin() {
|
||||
let (source, minter) = mintable(vec![
|
||||
// Expires inside REFRESH_MARGIN, so the second resolve re-mints.
|
||||
MintAction::Token("ghs_gen1", Utc::now() + chrono::Duration::minutes(5)),
|
||||
MintAction::Token("ghs_gen2", Utc::now() + chrono::Duration::minutes(60)),
|
||||
]);
|
||||
|
||||
let first = source.resolve().await.unwrap();
|
||||
let second = source.resolve().await.unwrap();
|
||||
|
||||
assert_eq!(minter.calls(), 2);
|
||||
assert_eq!(first.snapshot.generation, 1);
|
||||
assert_eq!(second.snapshot.generation, 2);
|
||||
assert!(matches!(
|
||||
second.snapshot.provenance,
|
||||
TokenProvenance::Minted { .. }
|
||||
));
|
||||
assert_eq!(second.token.expose(), "ghs_gen2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_uses_a_valid_cached_token_when_refresh_fails() {
|
||||
let (source, minter) = mintable(vec![
|
||||
MintAction::Token("ghs_gen1", Utc::now() + chrono::Duration::minutes(5)),
|
||||
MintAction::Error("mint failed"),
|
||||
]);
|
||||
|
||||
let first = source.resolve().await.unwrap();
|
||||
let second = source.resolve().await.unwrap();
|
||||
|
||||
assert_eq!(minter.calls(), 2);
|
||||
assert_eq!(first.snapshot.generation, 1);
|
||||
assert_eq!(second.snapshot.generation, 1);
|
||||
assert!(matches!(
|
||||
second.snapshot.provenance,
|
||||
TokenProvenance::Reused { .. }
|
||||
));
|
||||
assert_eq!(second.token.expose(), "ghs_gen1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_resolves_share_one_generation() {
|
||||
// Single mint in the script: a second mint would panic on an empty
|
||||
// script, so success proves single-flight.
|
||||
let (source, minter) = mintable(vec![MintAction::Token(
|
||||
"ghs_gen1",
|
||||
Utc::now() + chrono::Duration::minutes(60),
|
||||
)]);
|
||||
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
let source = Arc::clone(&source);
|
||||
tokio::spawn(async move { source.resolve().await })
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in handles {
|
||||
let resolved = handle.await.unwrap().unwrap();
|
||||
assert_eq!(resolved.snapshot.generation, 1);
|
||||
assert_eq!(resolved.token.expose(), "ghs_gen1");
|
||||
}
|
||||
assert_eq!(minter.calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_for_clone_always_mints_and_seeds_the_cache() {
|
||||
let (source, minter) = mintable(vec![MintAction::Token(
|
||||
"ghs_clone",
|
||||
Utc::now() + chrono::Duration::minutes(60),
|
||||
)]);
|
||||
|
||||
let clone_token = source.mint_for_clone().await.unwrap();
|
||||
assert_eq!(clone_token.snapshot.generation, 1);
|
||||
assert!(matches!(
|
||||
clone_token.snapshot.provenance,
|
||||
TokenProvenance::Minted { .. }
|
||||
));
|
||||
|
||||
// A later resolve reuses the clone token instead of minting again.
|
||||
let refreshed = source.resolve().await.unwrap();
|
||||
assert_eq!(refreshed.snapshot.generation, 1);
|
||||
assert_eq!(refreshed.token.expose(), "ghs_clone");
|
||||
assert!(matches!(
|
||||
refreshed.snapshot.provenance,
|
||||
TokenProvenance::Reused { .. }
|
||||
));
|
||||
assert_eq!(minter.calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_failure_surfaces_with_context() {
|
||||
let (source, _minter) = mintable(vec![MintAction::Error("mint failed")]);
|
||||
|
||||
let err = format!("{:#}", source.resolve().await.unwrap_err());
|
||||
assert!(err.contains("mint failed"), "got: {err}");
|
||||
assert!(
|
||||
err.contains("minting GitHub installation access token"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_string_debug_never_prints_the_secret() {
|
||||
let secret = SecretString::new("ghs_super_secret".to_string());
|
||||
let rendered = format!("{secret:?}");
|
||||
assert!(!rendered.contains("ghs_super_secret"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_age_is_defined_only_for_minted_tokens() {
|
||||
let now = Utc::now();
|
||||
let minted = TokenSnapshot {
|
||||
generation: 3,
|
||||
provenance: TokenProvenance::Minted {
|
||||
minted_at: now - chrono::Duration::seconds(42),
|
||||
expires_at: now + chrono::Duration::minutes(60),
|
||||
},
|
||||
};
|
||||
assert_eq!(minted.age_at(now), Some(Duration::from_secs(42)));
|
||||
|
||||
let fixed = TokenSnapshot {
|
||||
generation: 0,
|
||||
provenance: TokenProvenance::Static,
|
||||
};
|
||||
assert_eq!(fixed.age_at(now), None);
|
||||
assert_eq!(fixed.expires_at(), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -355,7 +355,14 @@ pub fn error_from_status_code(
|
|||
// error types
|
||||
let kind = match status_code {
|
||||
401 => ProviderErrorKind::Authentication,
|
||||
403 => ProviderErrorKind::AccessDenied,
|
||||
// A 412 is never about the request: no LLM request carries
|
||||
// conditional-request preconditions. Fireworks documents it as
|
||||
// "Account is suspended or there's an issue with account status",
|
||||
// also emitted for a LoRA model that failed to load
|
||||
// (https://docs.fireworks.ai/guides/inference-error-codes). The same
|
||||
// family as `account_deactivated`: deterministic here, but another
|
||||
// provider has independent billing and model inventory.
|
||||
403 | 412 => ProviderErrorKind::AccessDenied,
|
||||
404 => ProviderErrorKind::NotFound,
|
||||
408 => {
|
||||
return Error::RequestTimeout {
|
||||
|
|
@ -728,6 +735,53 @@ mod tests {
|
|||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded));
|
||||
}
|
||||
|
||||
/// Fireworks reports an account suspension (spending cap reached or
|
||||
/// unpaid invoices) as HTTP 412 with `code: "PRECONDITION_FAILED"` in
|
||||
/// the body. A chat completion carries no conditional-request
|
||||
/// preconditions, so a 412 is always an account-level lockout, never a
|
||||
/// defect in the request: it must not classify as `InvalidRequest`, and
|
||||
/// a fallback provider with independent billing must stay eligible.
|
||||
#[test]
|
||||
fn account_suspension_412_is_failover_eligible() {
|
||||
let err = error_from_status_code(
|
||||
412,
|
||||
"Account lithoscomputer is suspended, possibly due to reaching \
|
||||
the monthly spending limit or failure to pay past invoices."
|
||||
.into(),
|
||||
"fireworks".into(),
|
||||
// The openai_compatible dialect reads `error.type` as the code,
|
||||
// so the discriminating `PRECONDITION_FAILED` only reaches this
|
||||
// mapping through the status code.
|
||||
Some("error".into()),
|
||||
Some(serde_json::json!({
|
||||
"error": {
|
||||
"message": "Account lithoscomputer is suspended, possibly due to reaching the monthly spending limit or failure to pay past invoices. Please go to https://fireworks.ai/account/billing for more information.",
|
||||
"param": null,
|
||||
"code": "PRECONDITION_FAILED",
|
||||
"type": "error"
|
||||
},
|
||||
"request_id": "chatcmpl-d9652b89a6604931ac27dddd5ef5bdc0"
|
||||
})),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied));
|
||||
assert!(!err.retryable());
|
||||
assert!(err.failover_eligible());
|
||||
|
||||
// A bare 412 with no parseable body classifies the same way.
|
||||
let err = error_from_status_code(
|
||||
412,
|
||||
"Precondition Failed".into(),
|
||||
"fireworks".into(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied));
|
||||
assert!(err.failover_eligible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_from_error_code_covers_every_dialect() {
|
||||
for (code, expected) in [
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ git2.workspace = true
|
|||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
fabro-test.workspace = true
|
||||
insta.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
temp-env = "0.3"
|
||||
|
|
|
|||
|
|
@ -3,34 +3,32 @@
|
|||
reason = "CLI manifest builder: sync file I/O building install manifests"
|
||||
)]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
mod workflow_bundler;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use fabro_api::types;
|
||||
use fabro_config::project::{self, WorkflowLocation, discover_project_config};
|
||||
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
|
||||
use fabro_config::{
|
||||
CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
|
||||
EnvironmentLifecycleLayer, MergeMap, ReplaceMap, RunEnvironmentLayer, RunExecutionLayer,
|
||||
RunGoalLayer, RunLayer, RunModelLayer, SettingsLayer, WorkflowSettingsBuilder,
|
||||
CliLayer, EnvironmentLayer, EnvironmentLifecycleLayer, MergeMap, ReplaceMap,
|
||||
RunEnvironmentLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer,
|
||||
WorkflowSettingsBuilder,
|
||||
};
|
||||
use fabro_graphviz::graph::AttrValue;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::{
|
||||
BundleTemplateStore, FilesystemTemplateStore, RecordingTemplateStore, TemplateContext,
|
||||
TemplateRenderMode, TemplateSource, discover_static_dependency_closure, render_source,
|
||||
};
|
||||
use fabro_template::validate_static_reference;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
|
||||
use fabro_types::{DirtyStatus, GitContext, ManifestPath, WorkflowSettings};
|
||||
use fabro_workflow::git::{
|
||||
GitSyncStatus, branch_needs_push, head_sha, push_branch_noninteractive, sync_status,
|
||||
};
|
||||
use fabro_workflow::static_reference::{
|
||||
AttributeScope, ReferenceKind, reference_kind_for_attribute,
|
||||
};
|
||||
|
||||
use crate::workflow_bundler::WorkflowBundler;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ManifestBuildInput {
|
||||
|
|
@ -127,20 +125,6 @@ pub fn build_sparse_run_overrides(input: RunOverrideInput<'_>) -> Option<RunLaye
|
|||
.then_some(run)
|
||||
}
|
||||
|
||||
struct CollectContext<'a> {
|
||||
cwd: &'a Path,
|
||||
inputs: HashMap<String, toml::Value>,
|
||||
workflows: HashMap<String, types::ManifestWorkflow>,
|
||||
visited_workflows: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WorkflowScanInput {
|
||||
absolute_dot_path: PathBuf,
|
||||
dot_path: ManifestPath,
|
||||
source: String,
|
||||
}
|
||||
|
||||
pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
||||
let root_location = WorkflowLocation::resolve(&input.workflow, &input.cwd)?;
|
||||
if root_location.toml.is_none() && !root_location.graph.is_file() {
|
||||
|
|
@ -188,24 +172,12 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
|||
let target_path = root_location.graph.clone();
|
||||
let target_manifest_path = manifest_path_from_absolute(&target_path, &input.cwd)?;
|
||||
let target_key = target_manifest_path.to_string();
|
||||
|
||||
let mut context = CollectContext {
|
||||
cwd: &input.cwd,
|
||||
inputs: workflow_settings.run.inputs.clone(),
|
||||
workflows: HashMap::new(),
|
||||
visited_workflows: HashSet::new(),
|
||||
};
|
||||
collect_workflow_entry(&mut context, &input.workflow, &input.cwd)?;
|
||||
if let Some((_, config_path, source)) = project_config_source.as_ref() {
|
||||
let workflow = context
|
||||
.workflows
|
||||
.get_mut(&target_key)
|
||||
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
|
||||
collect_config_dockerfile(context.cwd, config_path, source, &mut workflow.files)?;
|
||||
}
|
||||
|
||||
let root_source = context
|
||||
.workflows
|
||||
let project_config_input = project_config_source
|
||||
.as_ref()
|
||||
.map(|(_, path, source)| (path, source.as_str()));
|
||||
let workflows = WorkflowBundler::new(&input.cwd, &workflow_settings.run.inputs)
|
||||
.bundle(&input.workflow, project_config_input)?;
|
||||
let root_source = workflows
|
||||
.get(&target_key)
|
||||
.map(|workflow| workflow.source.clone())
|
||||
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
|
||||
|
|
@ -218,7 +190,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
|||
type_: types::ManifestConfigType::Project,
|
||||
});
|
||||
}
|
||||
if let Some(path) = input.user_settings_path.filter(|p| p.is_file()) {
|
||||
if let Some(path) = input.user_settings_path.filter(|path| path.is_file()) {
|
||||
let source = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
configs.push(types::ManifestConfig {
|
||||
|
|
@ -254,415 +226,12 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
|||
title: None,
|
||||
target: types::ManifestTarget { path: target_key },
|
||||
version: 1,
|
||||
workflows: context.workflows,
|
||||
workflows,
|
||||
},
|
||||
target_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_workflow_entry(
|
||||
context: &mut CollectContext<'_>,
|
||||
workflow: &Path,
|
||||
resolve_from: &Path,
|
||||
) -> Result<()> {
|
||||
let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() {
|
||||
normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"unsupported manifest workflow reference: {}",
|
||||
workflow.display()
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
workflow.to_path_buf()
|
||||
};
|
||||
let location = WorkflowLocation::resolve(&normalized_workflow, resolve_from)?;
|
||||
let dot_path = manifest_path_from_absolute(&location.graph, context.cwd)?;
|
||||
let dot_key = dot_path.to_string();
|
||||
if !context.visited_workflows.insert(dot_key.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let source = std::fs::read_to_string(&location.graph)
|
||||
.with_context(|| format!("Failed to read {}", location.graph.display()))?;
|
||||
let config = if let Some(workflow_toml_path) = location.toml.as_ref() {
|
||||
Some(types::ManifestWorkflowConfig {
|
||||
path: manifest_path_from_absolute(workflow_toml_path, context.cwd)?.to_string(),
|
||||
source: std::fs::read_to_string(workflow_toml_path)
|
||||
.with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let scan = WorkflowScanInput {
|
||||
absolute_dot_path: location.graph,
|
||||
dot_path,
|
||||
source: source.clone(),
|
||||
};
|
||||
let mut files = HashMap::new();
|
||||
let mut visited_imports = HashSet::new();
|
||||
if let Some(config) = config.as_ref() {
|
||||
let config_path = ManifestPath::from_wire(&config.path)
|
||||
.ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?;
|
||||
collect_config_dockerfile(context.cwd, &config_path, &config.source, &mut files)?;
|
||||
}
|
||||
collect_workflow_files(context, &scan, &mut files, &mut visited_imports)?;
|
||||
|
||||
context.workflows.insert(dot_key, types::ManifestWorkflow {
|
||||
config,
|
||||
files,
|
||||
source,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_workflow_files(
|
||||
context: &mut CollectContext<'_>,
|
||||
workflow: &WorkflowScanInput,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
visited_imports: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let graph = parser::parse(&workflow.source).map_err(|err| {
|
||||
anyhow!(
|
||||
"Failed to parse {}: {err}",
|
||||
workflow.absolute_dot_path.display()
|
||||
)
|
||||
})?;
|
||||
let workflow_base_dir = workflow
|
||||
.absolute_dot_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let workflow_template_root = manifest_parent_or_dot(&workflow.dot_path)?;
|
||||
|
||||
if let Some(goal_ref) = graph.attrs.get("goal").and_then(AttrValue::as_str) {
|
||||
if goal_ref.starts_with('@') {
|
||||
let bundled = collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
context.cwd,
|
||||
goal_ref.trim_start_matches('@'),
|
||||
types::ManifestFileRefType::FileInline,
|
||||
manifest_attr_reference_kind(AttributeScope::Graph, "goal", goal_ref)?,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
let source = std::fs::read_to_string(&bundled.absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", bundled.absolute_path.display()))?;
|
||||
let template_root =
|
||||
template_root_for_bundled_file(&bundled.path, &workflow_template_root)?;
|
||||
collect_template_include_files(
|
||||
files,
|
||||
context.cwd,
|
||||
TemplateSource::new(bundled.path.clone(), template_root, source),
|
||||
Some(&bundled.path),
|
||||
&context.inputs,
|
||||
)?;
|
||||
} else {
|
||||
collect_template_include_files(
|
||||
files,
|
||||
context.cwd,
|
||||
TemplateSource::new(
|
||||
workflow.dot_path.clone(),
|
||||
workflow_template_root.clone(),
|
||||
goal_ref.to_owned(),
|
||||
),
|
||||
Some(&workflow.dot_path),
|
||||
&context.inputs,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
for node in graph.nodes.values() {
|
||||
if let Some(prompt_ref) = node.attrs.get("prompt").and_then(AttrValue::as_str) {
|
||||
if !prompt_ref.starts_with('@') {
|
||||
collect_template_include_files(
|
||||
files,
|
||||
context.cwd,
|
||||
TemplateSource::new(
|
||||
workflow.dot_path.clone(),
|
||||
workflow_template_root.clone(),
|
||||
prompt_ref.to_owned(),
|
||||
),
|
||||
Some(&workflow.dot_path),
|
||||
&context.inputs,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
for (name, value) in &node.attrs {
|
||||
let Some(value) = value.as_str() else {
|
||||
continue;
|
||||
};
|
||||
let Some(ReferenceKind::FileInline) =
|
||||
reference_kind_for_attribute(AttributeScope::Node, name, value)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let reference = value.strip_prefix('@').ok_or_else(|| {
|
||||
anyhow!("file inline reference must start with '@': {name}={value}")
|
||||
})?;
|
||||
let bundled = collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
context.cwd,
|
||||
reference,
|
||||
types::ManifestFileRefType::FileInline,
|
||||
ReferenceKind::FileInline,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
|
||||
if name == "prompt" {
|
||||
let source =
|
||||
std::fs::read_to_string(&bundled.absolute_path).with_context(|| {
|
||||
format!("Failed to read {}", bundled.absolute_path.display())
|
||||
})?;
|
||||
let template_root =
|
||||
template_root_for_bundled_file(&bundled.path, &workflow_template_root)?;
|
||||
collect_template_include_files(
|
||||
files,
|
||||
context.cwd,
|
||||
TemplateSource::new(bundled.path.clone(), template_root, source),
|
||||
Some(&bundled.path),
|
||||
&context.inputs,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(import_ref) = node.attrs.get("import").and_then(AttrValue::as_str) {
|
||||
let imported = collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
context.cwd,
|
||||
import_ref,
|
||||
types::ManifestFileRefType::Import,
|
||||
manifest_attr_reference_kind(AttributeScope::Node, "import", import_ref)?,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
let import_key = imported.path.to_string();
|
||||
if visited_imports.insert(import_key) {
|
||||
let imported_source = std::fs::read_to_string(&imported.absolute_path)
|
||||
.with_context(|| {
|
||||
format!("Failed to read {}", imported.absolute_path.display())
|
||||
})?;
|
||||
let imported_scan = WorkflowScanInput {
|
||||
absolute_dot_path: imported.absolute_path,
|
||||
dot_path: imported.path,
|
||||
source: imported_source,
|
||||
};
|
||||
collect_workflow_files(context, &imported_scan, files, visited_imports)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(child_ref) = node
|
||||
.attrs
|
||||
.get("stack.child_workflow")
|
||||
.and_then(AttrValue::as_str)
|
||||
{
|
||||
manifest_attr_reference_kind(AttributeScope::Node, "stack.child_workflow", child_ref)?
|
||||
.validate(child_ref)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
collect_workflow_entry(context, Path::new(child_ref), workflow_base_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_template_include_files(
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
cwd: &Path,
|
||||
source: TemplateSource,
|
||||
from: Option<&ManifestPath>,
|
||||
inputs: &HashMap<String, toml::Value>,
|
||||
) -> Result<()> {
|
||||
let source_path = source.path.clone();
|
||||
let store = FilesystemTemplateStore::new(cwd.to_path_buf());
|
||||
let closure = discover_static_dependency_closure([source], &store)
|
||||
.map_err(|err| anyhow!("failed to discover template dependencies: {err}"))?;
|
||||
verify_recorded_template_dependencies(&source_path, &closure, files, from, inputs)?;
|
||||
|
||||
for (path, source) in closure.sources {
|
||||
if path == source_path {
|
||||
continue;
|
||||
}
|
||||
let key = path.to_string();
|
||||
files
|
||||
.entry(key)
|
||||
.or_insert_with(|| types::ManifestFileEntry {
|
||||
content: source.content,
|
||||
ref_: types::ManifestFileRef {
|
||||
from: from.map(std::string::ToString::to_string),
|
||||
original: path.to_string(),
|
||||
type_: types::ManifestFileRefType::FileInline,
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn template_root_for_bundled_file(
|
||||
path: &ManifestPath,
|
||||
workflow_template_root: &ManifestPath,
|
||||
) -> Result<ManifestPath> {
|
||||
if manifest_path_is_within_root(path, workflow_template_root) {
|
||||
Ok(workflow_template_root.clone())
|
||||
} else {
|
||||
manifest_parent_or_dot(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_path_is_within_root(path: &ManifestPath, root: &ManifestPath) -> bool {
|
||||
if root.as_path().as_os_str().is_empty() {
|
||||
return !matches!(
|
||||
path.as_path().components().next(),
|
||||
Some(Component::ParentDir)
|
||||
);
|
||||
}
|
||||
path.starts_with(root)
|
||||
}
|
||||
|
||||
fn verify_recorded_template_dependencies(
|
||||
source_path: &ManifestPath,
|
||||
closure: &fabro_template::TemplateDependencyClosure,
|
||||
files: &HashMap<String, types::ManifestFileEntry>,
|
||||
from: Option<&ManifestPath>,
|
||||
inputs: &HashMap<String, toml::Value>,
|
||||
) -> Result<()> {
|
||||
let Some(source) = closure.sources.get(source_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut bundled_files = closure
|
||||
.sources
|
||||
.iter()
|
||||
.map(|(path, source)| (path.clone(), source.content.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
for (path, entry) in files {
|
||||
if let Some(path) = ManifestPath::from_wire(path) {
|
||||
bundled_files.insert(path, entry.content.clone());
|
||||
}
|
||||
}
|
||||
let allowed = bundled_files.keys().cloned().collect();
|
||||
let store =
|
||||
RecordingTemplateStore::with_allowed(BundleTemplateStore::new(bundled_files), allowed);
|
||||
let ctx = TemplateContext::for_input_scan(inputs.clone());
|
||||
render_source(source, &ctx, Arc::new(store), TemplateRenderMode::Lenient).with_context(
|
||||
|| {
|
||||
let from =
|
||||
from.map_or_else(|| source_path.to_string(), std::string::ToString::to_string);
|
||||
format!("failed to verify template dependencies for {from}")
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn manifest_attr_reference_kind(
|
||||
scope: AttributeScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<ReferenceKind> {
|
||||
reference_kind_for_attribute(scope, key, value)
|
||||
.ok_or_else(|| anyhow!("unsupported manifest reference attribute: {key}={value}"))
|
||||
}
|
||||
|
||||
fn collect_config_dockerfile(
|
||||
cwd: &Path,
|
||||
config_path: &ManifestPath,
|
||||
source: &str,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
) -> Result<()> {
|
||||
let layer = source
|
||||
.parse::<SettingsLayer>()
|
||||
.context("Failed to parse run config TOML")?;
|
||||
let absolute_config_path = cwd.join(config_path.as_path());
|
||||
let base_dir = absolute_config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
|
||||
for environment in layer.environments.values() {
|
||||
collect_environment_dockerfile(
|
||||
files,
|
||||
base_dir,
|
||||
cwd,
|
||||
config_path,
|
||||
environment.image.as_ref(),
|
||||
)?;
|
||||
}
|
||||
if let Some(run_environment) = layer.run.as_ref().and_then(|run| run.environment.as_ref()) {
|
||||
collect_environment_dockerfile(
|
||||
files,
|
||||
base_dir,
|
||||
cwd,
|
||||
config_path,
|
||||
run_environment.image.as_ref(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_environment_dockerfile(
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
base_dir: &Path,
|
||||
cwd: &Path,
|
||||
config_path: &ManifestPath,
|
||||
image: Option<&EnvironmentImageLayer>,
|
||||
) -> Result<()> {
|
||||
let dockerfile = image.and_then(|image| image.dockerfile.as_ref());
|
||||
let Some(EnvironmentDockerfileLayer::Path { path }) = dockerfile else {
|
||||
return Ok(());
|
||||
};
|
||||
collect_bundled_file(
|
||||
files,
|
||||
base_dir,
|
||||
cwd,
|
||||
path,
|
||||
types::ManifestFileRefType::Dockerfile,
|
||||
ReferenceKind::Dockerfile,
|
||||
Some(config_path.clone()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct BundledFile {
|
||||
absolute_path: PathBuf,
|
||||
path: ManifestPath,
|
||||
}
|
||||
|
||||
fn collect_bundled_file(
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
base_dir: &Path,
|
||||
cwd: &Path,
|
||||
reference: &str,
|
||||
ref_type: types::ManifestFileRefType,
|
||||
reference_kind: ReferenceKind,
|
||||
from: Option<ManifestPath>,
|
||||
) -> Result<BundledFile> {
|
||||
reference_kind
|
||||
.validate(reference)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
|
||||
let absolute_path = normalize_absolute_path(base_dir, reference)
|
||||
.ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?;
|
||||
let path = manifest_path_from_absolute(&absolute_path, cwd)?;
|
||||
let key = path.to_string();
|
||||
if !files.contains_key(&key) {
|
||||
let content = std::fs::read_to_string(&absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", absolute_path.display()))?;
|
||||
files.insert(key.clone(), types::ManifestFileEntry {
|
||||
content,
|
||||
ref_: types::ManifestFileRef {
|
||||
from: from.map(|value| value.to_string()),
|
||||
original: reference.to_string(),
|
||||
type_: ref_type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Ok(BundledFile {
|
||||
absolute_path,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_manifest_goal(
|
||||
run_overrides: Option<&RunLayer>,
|
||||
settings: &WorkflowSettings,
|
||||
|
|
@ -697,8 +266,7 @@ fn resolve_manifest_goal(
|
|||
return Ok(None);
|
||||
};
|
||||
if let Some(reference) = goal.strip_prefix('@') {
|
||||
ReferenceKind::GraphGoalFile
|
||||
.validate(reference)
|
||||
validate_static_reference(reference, ReferenceKind::GraphGoalFile)
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let goal_path = normalize_absolute_path(
|
||||
root_dot_path.parent().unwrap_or_else(|| Path::new(".")),
|
||||
|
|
@ -854,12 +422,6 @@ fn manifest_path_from_absolute(path: &Path, cwd: &Path) -> Result<ManifestPath>
|
|||
.ok_or_else(|| anyhow!("Failed to compute manifest path for {}", path.display()))
|
||||
}
|
||||
|
||||
fn manifest_parent_or_dot(path: &ManifestPath) -> Result<ManifestPath> {
|
||||
let parent = path.parent_or_dot().to_string_lossy();
|
||||
ManifestPath::from_wire(&parent)
|
||||
.ok_or_else(|| anyhow!("invalid manifest parent path for {path}: {parent}"))
|
||||
}
|
||||
|
||||
pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
|
||||
args.auto_approve.is_none()
|
||||
&& args.dry_run.is_none()
|
||||
|
|
@ -872,6 +434,18 @@ pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
|
|||
&& args.verbose.is_none()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_fixtures {
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn write_file(path: &Path, source: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("fixture directory should be created");
|
||||
}
|
||||
std::fs::write(path, source).expect("fixture file should be written");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -932,6 +506,178 @@ mod tests {
|
|||
assert_eq!(schema.content, schema_source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_manifest_characterizes_the_complete_legacy_projection() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let root = project.join(".fabro/workflows/root");
|
||||
let child = project.join(".fabro/workflows/child");
|
||||
let user_config_path = temp.path().join("home/.fabro/config.toml");
|
||||
let project_config = r#"_version = 1
|
||||
|
||||
[environments.project]
|
||||
provider = "docker"
|
||||
|
||||
[environments.project.image]
|
||||
dockerfile = { path = "Project.Dockerfile" }
|
||||
"#;
|
||||
let root_config = r#"_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
"#;
|
||||
let child_config = root_config;
|
||||
let user_config = "_version = 1\n";
|
||||
let root_graph = r#"digraph Root {
|
||||
graph [goal="@goals/goal.md"]
|
||||
start [shape=Mdiamond]
|
||||
prompt [prompt="@prompts/plan.md"]
|
||||
schema [type="agent", prompt="schema", output_schema="@schemas/output.json"]
|
||||
imported [import="imports/shared.fabro"]
|
||||
child [shape=house, stack.child_workflow="../child/workflow.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> prompt -> schema -> imported -> child -> exit
|
||||
}"#;
|
||||
let child_graph =
|
||||
"digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
|
||||
let imported_graph = r#"digraph Shared {
|
||||
start [shape=Mdiamond]
|
||||
shared [prompt="@../prompts/shared.md"]
|
||||
exit [shape=Msquare]
|
||||
start -> shared -> exit
|
||||
}"#;
|
||||
let plan_prompt = "{% include \"partial.md\" %}\n{% from \"helpers.md\" import render %}";
|
||||
let helpers = "{% macro render() %}{% include \"deep.md\" %}{% endmacro %}";
|
||||
let output_schema = r#"{"type":"object"}"#;
|
||||
let write = test_fixtures::write_file;
|
||||
write(&project.join(".fabro/project.toml"), project_config);
|
||||
write(&project.join(".fabro/Project.Dockerfile"), "FROM project\n");
|
||||
write(&user_config_path, user_config);
|
||||
write(&root.join("workflow.toml"), root_config);
|
||||
write(&root.join("workflow.fabro"), root_graph);
|
||||
write(&root.join("goals/goal.md"), "ship it\n");
|
||||
write(&root.join("prompts/plan.md"), plan_prompt);
|
||||
write(&root.join("prompts/partial.md"), "partial\n");
|
||||
write(&root.join("prompts/helpers.md"), helpers);
|
||||
write(&root.join("prompts/deep.md"), "deep\n");
|
||||
write(&root.join("prompts/shared.md"), "shared\n");
|
||||
write(&root.join("schemas/output.json"), output_schema);
|
||||
write(&root.join("imports/shared.fabro"), imported_graph);
|
||||
write(&child.join("workflow.toml"), child_config);
|
||||
write(&child.join("workflow.fabro"), child_graph);
|
||||
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: PathBuf::from(".fabro/workflows/root/workflow.toml"),
|
||||
cwd: project.clone(),
|
||||
input_overrides: HashMap::from([("feature".to_owned(), toml::Value::Boolean(true))]),
|
||||
args: Some(types::ManifestArgs {
|
||||
dry_run: Some(true),
|
||||
input: vec!["feature=true".to_owned()],
|
||||
label: vec!["suite=characterization".to_owned()],
|
||||
..types::ManifestArgs::default()
|
||||
}),
|
||||
environment_defaults: test_environment_defaults(),
|
||||
user_settings_path: Some(user_config_path),
|
||||
..ManifestBuildInput::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut actual = serde_json::to_value(&built.manifest).unwrap();
|
||||
actual["cwd"] = serde_json::json!("<cwd>");
|
||||
actual["configs"][0]["path"] = serde_json::json!("<project-config>");
|
||||
actual["configs"][1]["path"] = serde_json::json!("<user-config>");
|
||||
fabro_test::fabro_json_snapshot!(sorted_json(actual));
|
||||
}
|
||||
|
||||
/// `serde_json` is built with `preserve_order`, so `HashMap`-backed
|
||||
/// manifest maps serialize in nondeterministic order; sort recursively
|
||||
/// for a stable snapshot.
|
||||
fn sorted_json(value: serde_json::Value) -> serde_json::Value {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut entries: Vec<_> = map.into_iter().collect();
|
||||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
serde_json::Value::Object(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, sorted_json(value)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
serde_json::Value::Array(items.into_iter().map(sorted_json).collect())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_manifest_keeps_legacy_parent_paths_for_external_siblings() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cwd = temp.path().join("checkout");
|
||||
let root = temp.path().join("user/workflows/root");
|
||||
let child = temp.path().join("user/workflows/child");
|
||||
std::fs::create_dir_all(&cwd).unwrap();
|
||||
for directory in [&root, &child] {
|
||||
std::fs::create_dir_all(directory.join("prompts")).unwrap();
|
||||
std::fs::write(
|
||||
directory.join("workflow.toml"),
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
root.join("workflow.fabro"),
|
||||
r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
prompt [prompt="@prompts/root.md"]
|
||||
child [shape=house, stack.child_workflow="../child/workflow.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> prompt -> child -> exit
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("prompts/root.md"), "root prompt\n").unwrap();
|
||||
std::fs::write(
|
||||
child.join("workflow.fabro"),
|
||||
r#"digraph Child {
|
||||
start [shape=Mdiamond]
|
||||
prompt [prompt="@prompts/child.md"]
|
||||
exit [shape=Msquare]
|
||||
start -> prompt -> exit
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(child.join("prompts/child.md"), "child prompt\n").unwrap();
|
||||
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: root.join("workflow.fabro"),
|
||||
cwd,
|
||||
environment_defaults: test_environment_defaults(),
|
||||
..ManifestBuildInput::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let root_key = "../user/workflows/root/workflow.fabro";
|
||||
let child_key = "../user/workflows/child/workflow.fabro";
|
||||
assert_eq!(built.manifest.target.path, root_key);
|
||||
let root_workflow = &built.manifest.workflows[root_key];
|
||||
assert_eq!(
|
||||
root_workflow.config.as_ref().unwrap().path,
|
||||
"../user/workflows/root/workflow.toml"
|
||||
);
|
||||
let root_prompt = &root_workflow.files["../user/workflows/root/prompts/root.md"];
|
||||
assert_eq!(root_prompt.ref_.from.as_deref(), Some(root_key));
|
||||
assert_eq!(root_prompt.ref_.original, "prompts/root.md");
|
||||
let child_workflow = &built.manifest.workflows[child_key];
|
||||
assert_eq!(
|
||||
child_workflow.config.as_ref().unwrap().path,
|
||||
"../user/workflows/child/workflow.toml"
|
||||
);
|
||||
let child_prompt = &child_workflow.files["../user/workflows/child/prompts/child.md"];
|
||||
assert_eq!(child_prompt.ref_.from.as_deref(), Some(child_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_overrides_sets_common_cli_and_mcp_layers() {
|
||||
let overrides = build_run_overrides(RunOverrideInput {
|
||||
|
|
@ -1181,17 +927,26 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
let root = &built.manifest.workflows[".fabro/workflows/demo/workflow.fabro"];
|
||||
assert!(
|
||||
root.files
|
||||
.contains_key(".fabro/workflows/demo/prompts/goal.tpl.md")
|
||||
assert_eq!(
|
||||
root.files[".fabro/workflows/demo/prompts/goal.tpl.md"]
|
||||
.ref_
|
||||
.from
|
||||
.as_deref(),
|
||||
Some(".fabro/workflows/demo/prompts/goal.md")
|
||||
);
|
||||
assert!(
|
||||
root.files
|
||||
.contains_key(".fabro/workflows/demo/prompts/plan.tpl.md")
|
||||
assert_eq!(
|
||||
root.files[".fabro/workflows/demo/prompts/plan.tpl.md"]
|
||||
.ref_
|
||||
.from
|
||||
.as_deref(),
|
||||
Some(".fabro/workflows/demo/prompts/plan.md")
|
||||
);
|
||||
assert!(
|
||||
root.files
|
||||
.contains_key(".fabro/workflows/demo/inline.tpl.md")
|
||||
assert_eq!(
|
||||
root.files[".fabro/workflows/demo/inline.tpl.md"]
|
||||
.ref_
|
||||
.from
|
||||
.as_deref(),
|
||||
Some(".fabro/workflows/demo/workflow.fabro")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1290,7 +1045,9 @@ mod tests {
|
|||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("dynamic template dependency"),
|
||||
err.chain().any(|cause| cause
|
||||
.downcast_ref::<fabro_template::TemplateDiscoveryError>()
|
||||
.is_some()),
|
||||
"unexpected error: {err:#}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
---
|
||||
source: lib/components/fabro-manifest/src/lib.rs
|
||||
expression: rendered
|
||||
---
|
||||
{
|
||||
"args": {
|
||||
"dry_run": true,
|
||||
"input": [
|
||||
"feature=true"
|
||||
],
|
||||
"label": [
|
||||
"suite=characterization"
|
||||
]
|
||||
},
|
||||
"configs": [
|
||||
{
|
||||
"path": "<project-config>",
|
||||
"source": "_version = 1/n/n[environments.project]/nprovider = \"docker\"/n/n[environments.project.image]/ndockerfile = { path = \"Project.Dockerfile\" }/n",
|
||||
"type": "project"
|
||||
},
|
||||
{
|
||||
"path": "<user-config>",
|
||||
"source": "_version = 1/n",
|
||||
"type": "user"
|
||||
}
|
||||
],
|
||||
"cwd": "<cwd>",
|
||||
"goal": {
|
||||
"text": "ship it/n",
|
||||
"type": "graph"
|
||||
},
|
||||
"target": {
|
||||
"path": ".fabro/workflows/root/workflow.fabro"
|
||||
},
|
||||
"version": 1,
|
||||
"workflows": {
|
||||
".fabro/workflows/child/workflow.fabro": {
|
||||
"config": {
|
||||
"path": ".fabro/workflows/child/workflow.toml",
|
||||
"source": "_version = 1/n/n[workflow]/ngraph = \"workflow.fabro\"/n"
|
||||
},
|
||||
"source": "digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }"
|
||||
},
|
||||
".fabro/workflows/root/workflow.fabro": {
|
||||
"config": {
|
||||
"path": ".fabro/workflows/root/workflow.toml",
|
||||
"source": "_version = 1/n/n[workflow]/ngraph = \"workflow.fabro\"/n"
|
||||
},
|
||||
"files": {
|
||||
".fabro/Project.Dockerfile": {
|
||||
"content": "FROM project/n",
|
||||
"ref": {
|
||||
"from": ".fabro/project.toml",
|
||||
"original": "Project.Dockerfile",
|
||||
"type": "dockerfile"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/goals/goal.md": {
|
||||
"content": "ship it/n",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/workflow.fabro",
|
||||
"original": "goals/goal.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/imports/shared.fabro": {
|
||||
"content": "digraph Shared {/n start [shape=Mdiamond]/n shared [prompt=\"@../prompts/shared.md\"]/n exit [shape=Msquare]/n start -> shared -> exit/n }",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/workflow.fabro",
|
||||
"original": "imports/shared.fabro",
|
||||
"type": "import"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/deep.md": {
|
||||
"content": "deep/n",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/prompts/plan.md",
|
||||
"original": ".fabro/workflows/root/prompts/deep.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/helpers.md": {
|
||||
"content": "{% macro render() %}{% include \"deep.md\" %}{% endmacro %}",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/prompts/plan.md",
|
||||
"original": ".fabro/workflows/root/prompts/helpers.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/partial.md": {
|
||||
"content": "partial/n",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/prompts/plan.md",
|
||||
"original": ".fabro/workflows/root/prompts/partial.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/plan.md": {
|
||||
"content": "{% include \"partial.md\" %}/n{% from \"helpers.md\" import render %}",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/workflow.fabro",
|
||||
"original": "prompts/plan.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/prompts/shared.md": {
|
||||
"content": "shared/n",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/imports/shared.fabro",
|
||||
"original": "../prompts/shared.md",
|
||||
"type": "file_inline"
|
||||
}
|
||||
},
|
||||
".fabro/workflows/root/schemas/output.json": {
|
||||
"content": "{\"type\":\"object\"}",
|
||||
"ref": {
|
||||
"from": ".fabro/workflows/root/workflow.fabro",
|
||||
"original": "schemas/output.json",
|
||||
"type": "file_inline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"source": "digraph Root {/n graph [goal=\"@goals/goal.md\"]/n start [shape=Mdiamond]/n prompt [prompt=\"@prompts/plan.md\"]/n schema [type=\"agent\", prompt=\"schema\", output_schema=\"@schemas/output.json\"]/n imported [import=\"imports/shared.fabro\"]/n child [shape=house, stack.child_workflow=\"../child/workflow.fabro\"]/n exit [shape=Msquare]/n start -> prompt -> schema -> imported -> child -> exit/n }"
|
||||
}
|
||||
}
|
||||
}
|
||||
558
lib/components/fabro-manifest/src/workflow_bundler.rs
Normal file
558
lib/components/fabro-manifest/src/workflow_bundler.rs
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use fabro_api::types;
|
||||
use fabro_config::project::WorkflowLocation;
|
||||
use fabro_config::{EnvironmentDockerfileLayer, EnvironmentImageLayer, SettingsLayer};
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::{
|
||||
BundleTemplateStore, FilesystemTemplateStore, GraphReference, GraphReferenceError,
|
||||
RecordingTemplateStore, TemplateContext, TemplateDependencyClosure, TemplateRenderMode,
|
||||
TemplateSource, validate_static_reference, visit_graph_references,
|
||||
};
|
||||
use fabro_types::ManifestPath;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
|
||||
use crate::{manifest_path_from_absolute, normalize_absolute_path};
|
||||
|
||||
pub(super) struct WorkflowBundler<'a> {
|
||||
cwd: &'a Path,
|
||||
inputs: &'a HashMap<String, toml::Value>,
|
||||
template_store: FilesystemTemplateStore,
|
||||
workflows: HashMap<String, types::ManifestWorkflow>,
|
||||
visited_workflows: HashSet<String>,
|
||||
}
|
||||
|
||||
impl<'a> WorkflowBundler<'a> {
|
||||
pub(super) fn new(cwd: &'a Path, inputs: &'a HashMap<String, toml::Value>) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
inputs,
|
||||
template_store: FilesystemTemplateStore::new(cwd),
|
||||
workflows: HashMap::new(),
|
||||
visited_workflows: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bundle(
|
||||
mut self,
|
||||
workflow: &Path,
|
||||
project_config: Option<(&ManifestPath, &str)>,
|
||||
) -> Result<HashMap<String, types::ManifestWorkflow>> {
|
||||
let root_key = self.collect_workflow_entry(workflow, self.cwd)?;
|
||||
|
||||
if let Some((config_path, source)) = project_config {
|
||||
let mut root = self
|
||||
.workflows
|
||||
.remove(&root_key)
|
||||
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
|
||||
self.collect_config_dockerfile(config_path, source, &mut root.files)?;
|
||||
self.workflows.insert(root_key, root);
|
||||
}
|
||||
|
||||
Ok(self.workflows)
|
||||
}
|
||||
|
||||
/// Collects the workflow at `location` and returns its manifest key.
|
||||
fn collect_workflow_location(&mut self, location: &WorkflowLocation) -> Result<String> {
|
||||
let dot_path = manifest_path_from_absolute(&location.graph, self.cwd)?;
|
||||
let dot_key = dot_path.to_string();
|
||||
if !self.visited_workflows.insert(dot_key.clone()) {
|
||||
return Ok(dot_key);
|
||||
}
|
||||
|
||||
let source = std::fs::read_to_string(&location.graph)
|
||||
.with_context(|| format!("Failed to read {}", location.graph.display()))?;
|
||||
let config = if let Some(workflow_toml_path) = location.toml.as_ref() {
|
||||
Some(types::ManifestWorkflowConfig {
|
||||
path: manifest_path_from_absolute(workflow_toml_path, self.cwd)?.to_string(),
|
||||
source: std::fs::read_to_string(workflow_toml_path)
|
||||
.with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let scan = WorkflowScanInput {
|
||||
absolute_dot_path: location.graph.clone(),
|
||||
dot_path,
|
||||
source: source.clone(),
|
||||
};
|
||||
let mut files = HashMap::new();
|
||||
let mut visited_imports = HashSet::new();
|
||||
if let Some(config) = config.as_ref() {
|
||||
let config_path = ManifestPath::from_wire(&config.path)
|
||||
.ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?;
|
||||
self.collect_config_dockerfile(&config_path, &config.source, &mut files)?;
|
||||
}
|
||||
self.collect_workflow_files(&scan, &mut files, &mut visited_imports)?;
|
||||
|
||||
self.workflows
|
||||
.insert(dot_key.clone(), types::ManifestWorkflow {
|
||||
config,
|
||||
files,
|
||||
source,
|
||||
});
|
||||
|
||||
Ok(dot_key)
|
||||
}
|
||||
|
||||
/// Relative workflow references with an extension are lexically
|
||||
/// normalized (`..` segments resolved without consulting the filesystem,
|
||||
/// `~` rejected) before resolution, so the file read matches the manifest
|
||||
/// key. Returns the collected workflow's manifest key.
|
||||
fn collect_workflow_entry(&mut self, workflow: &Path, resolve_from: &Path) -> Result<String> {
|
||||
let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() {
|
||||
normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"unsupported manifest workflow reference: {}",
|
||||
workflow.display()
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
workflow.to_path_buf()
|
||||
};
|
||||
let location = WorkflowLocation::resolve(&normalized_workflow, resolve_from)?;
|
||||
self.collect_workflow_location(&location)
|
||||
}
|
||||
|
||||
fn collect_workflow_files(
|
||||
&mut self,
|
||||
workflow: &WorkflowScanInput,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
visited_imports: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let graph = parser::parse(&workflow.source)
|
||||
.with_context(|| format!("Failed to parse {}", workflow.absolute_dot_path.display()))?;
|
||||
let workflow_base_dir = workflow
|
||||
.absolute_dot_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let workflow_template_root = manifest_parent_or_dot(&workflow.dot_path)?;
|
||||
|
||||
// Imports and child workflows require a mutable borrow of self, so
|
||||
// collect them during the walk and recurse after the visitor returns.
|
||||
let mut imports = Vec::new();
|
||||
let mut children = Vec::new();
|
||||
|
||||
visit_graph_references(&graph, |reference| -> Result<()> {
|
||||
match reference {
|
||||
GraphReference::GoalFile { reference } => {
|
||||
let bundled = self.collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
reference,
|
||||
types::ManifestFileRefType::FileInline,
|
||||
ReferenceKind::GraphGoalFile,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
self.collect_bundled_template_includes(files, &bundled, &workflow_template_root)
|
||||
}
|
||||
GraphReference::GoalInline { content }
|
||||
| GraphReference::InlinePrompt { content } => self.collect_template_include_files(
|
||||
files,
|
||||
TemplateSource::new(
|
||||
workflow.dot_path.clone(),
|
||||
workflow_template_root.clone(),
|
||||
content.to_owned(),
|
||||
),
|
||||
Some(&workflow.dot_path),
|
||||
),
|
||||
GraphReference::FileInline { key, reference } => {
|
||||
let bundled = self.collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
reference,
|
||||
types::ManifestFileRefType::FileInline,
|
||||
ReferenceKind::FileInline,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
if key == "prompt" {
|
||||
self.collect_bundled_template_includes(
|
||||
files,
|
||||
&bundled,
|
||||
&workflow_template_root,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::Import { reference } => {
|
||||
let imported = self.collect_bundled_file(
|
||||
files,
|
||||
workflow_base_dir,
|
||||
reference,
|
||||
types::ManifestFileRefType::Import,
|
||||
ReferenceKind::Import,
|
||||
Some(workflow.dot_path.clone()),
|
||||
)?;
|
||||
imports.push(imported);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::ChildWorkflow { reference } => {
|
||||
children.push(reference);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
GraphReferenceError::StaticReference(source) => anyhow::Error::new(source),
|
||||
GraphReferenceError::Visit(error) => error,
|
||||
})?;
|
||||
|
||||
for imported in imports {
|
||||
if visited_imports.insert(imported.path.to_string()) {
|
||||
let imported_source = std::fs::read_to_string(&imported.absolute_path)
|
||||
.with_context(|| {
|
||||
format!("Failed to read {}", imported.absolute_path.display())
|
||||
})?;
|
||||
let imported_scan = WorkflowScanInput {
|
||||
absolute_dot_path: imported.absolute_path,
|
||||
dot_path: imported.path,
|
||||
source: imported_source,
|
||||
};
|
||||
self.collect_workflow_files(&imported_scan, files, visited_imports)?;
|
||||
}
|
||||
}
|
||||
for child in children {
|
||||
self.collect_workflow_entry(Path::new(child), workflow_base_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_bundled_template_includes(
|
||||
&self,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
bundled: &BundledFile,
|
||||
workflow_template_root: &ManifestPath,
|
||||
) -> Result<()> {
|
||||
let source = std::fs::read_to_string(&bundled.absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", bundled.absolute_path.display()))?;
|
||||
let template_root = template_root_for_bundled_file(&bundled.path, workflow_template_root)?;
|
||||
self.collect_template_include_files(
|
||||
files,
|
||||
TemplateSource::new(bundled.path.clone(), template_root, source),
|
||||
Some(&bundled.path),
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_template_include_files(
|
||||
&self,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
source: TemplateSource,
|
||||
from: Option<&ManifestPath>,
|
||||
) -> Result<()> {
|
||||
let source_path = source.path.clone();
|
||||
let closure =
|
||||
fabro_template::discover_static_dependency_closure([source], &self.template_store)
|
||||
.context("failed to discover template dependencies")?;
|
||||
self.verify_recorded_template_dependencies(&source_path, &closure, files, from)?;
|
||||
|
||||
for (path, source) in closure.sources {
|
||||
if path == source_path {
|
||||
continue;
|
||||
}
|
||||
let key = path.to_string();
|
||||
files
|
||||
.entry(key)
|
||||
.or_insert_with(|| types::ManifestFileEntry {
|
||||
content: source.content,
|
||||
ref_: types::ManifestFileRef {
|
||||
from: from.map(std::string::ToString::to_string),
|
||||
original: path.to_string(),
|
||||
type_: types::ManifestFileRefType::FileInline,
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_recorded_template_dependencies(
|
||||
&self,
|
||||
source_path: &ManifestPath,
|
||||
closure: &TemplateDependencyClosure,
|
||||
files: &HashMap<String, types::ManifestFileEntry>,
|
||||
from: Option<&ManifestPath>,
|
||||
) -> Result<()> {
|
||||
let Some(source) = closure.sources.get(source_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut bundled_files = closure
|
||||
.sources
|
||||
.iter()
|
||||
.map(|(path, source)| (path.clone(), source.content.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
for (path, entry) in files {
|
||||
if let Some(path) = ManifestPath::from_wire(path) {
|
||||
bundled_files.insert(path, entry.content.clone());
|
||||
}
|
||||
}
|
||||
let allowed = bundled_files.keys().cloned().collect();
|
||||
let store =
|
||||
RecordingTemplateStore::with_allowed(BundleTemplateStore::new(bundled_files), allowed);
|
||||
let context = TemplateContext::for_input_scan(self.inputs.clone());
|
||||
fabro_template::render_source(
|
||||
source,
|
||||
&context,
|
||||
Arc::new(store),
|
||||
TemplateRenderMode::Lenient,
|
||||
)
|
||||
.with_context(|| {
|
||||
let from =
|
||||
from.map_or_else(|| source_path.to_string(), std::string::ToString::to_string);
|
||||
format!("failed to verify template dependencies for {from}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_config_dockerfile(
|
||||
&self,
|
||||
config_path: &ManifestPath,
|
||||
source: &str,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
) -> Result<()> {
|
||||
let layer = source
|
||||
.parse::<SettingsLayer>()
|
||||
.context("Failed to parse run config TOML")?;
|
||||
let absolute_config_path = self.cwd.join(config_path.as_path());
|
||||
let base_dir = absolute_config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
|
||||
for image in layer.environment_images() {
|
||||
self.collect_environment_dockerfile(files, base_dir, config_path, image)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_environment_dockerfile(
|
||||
&self,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
base_dir: &Path,
|
||||
config_path: &ManifestPath,
|
||||
image: &EnvironmentImageLayer,
|
||||
) -> Result<()> {
|
||||
let Some(EnvironmentDockerfileLayer::Path { path }) = image.dockerfile.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.collect_bundled_file(
|
||||
files,
|
||||
base_dir,
|
||||
path,
|
||||
types::ManifestFileRefType::Dockerfile,
|
||||
ReferenceKind::Dockerfile,
|
||||
Some(config_path.clone()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_bundled_file(
|
||||
&self,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
base_dir: &Path,
|
||||
reference: &str,
|
||||
ref_type: types::ManifestFileRefType,
|
||||
reference_kind: ReferenceKind,
|
||||
from: Option<ManifestPath>,
|
||||
) -> Result<BundledFile> {
|
||||
validate_static_reference(reference, reference_kind).map_err(anyhow::Error::new)?;
|
||||
|
||||
let absolute_path = normalize_absolute_path(base_dir, reference)
|
||||
.ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?;
|
||||
let path = manifest_path_from_absolute(&absolute_path, self.cwd)?;
|
||||
let key = path.to_string();
|
||||
if !files.contains_key(&key) {
|
||||
let content = std::fs::read_to_string(&absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", absolute_path.display()))?;
|
||||
files.insert(key.clone(), types::ManifestFileEntry {
|
||||
content,
|
||||
ref_: types::ManifestFileRef {
|
||||
from: from.map(|value| value.to_string()),
|
||||
original: reference.to_owned(),
|
||||
type_: ref_type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Ok(BundledFile {
|
||||
absolute_path,
|
||||
path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkflowScanInput {
|
||||
absolute_dot_path: PathBuf,
|
||||
dot_path: ManifestPath,
|
||||
source: String,
|
||||
}
|
||||
|
||||
struct BundledFile {
|
||||
absolute_path: PathBuf,
|
||||
path: ManifestPath,
|
||||
}
|
||||
|
||||
fn manifest_parent_or_dot(path: &ManifestPath) -> Result<ManifestPath> {
|
||||
let parent = path.parent_or_dot().to_string_lossy();
|
||||
ManifestPath::from_wire(&parent)
|
||||
.ok_or_else(|| anyhow!("invalid manifest parent path for {path}: {parent}"))
|
||||
}
|
||||
|
||||
fn template_root_for_bundled_file(
|
||||
path: &ManifestPath,
|
||||
workflow_template_root: &ManifestPath,
|
||||
) -> Result<ManifestPath> {
|
||||
if manifest_path_is_within_root(path, workflow_template_root) {
|
||||
Ok(workflow_template_root.clone())
|
||||
} else {
|
||||
manifest_parent_or_dot(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_path_is_within_root(path: &ManifestPath, root: &ManifestPath) -> bool {
|
||||
if root.as_path().as_os_str().is_empty() {
|
||||
return !matches!(
|
||||
path.as_path().components().next(),
|
||||
Some(Component::ParentDir)
|
||||
);
|
||||
}
|
||||
path.starts_with(root)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_fixtures::write_file;
|
||||
|
||||
fn bundle_graph(cwd: &Path, graph: &Path) -> Result<HashMap<String, types::ManifestWorkflow>> {
|
||||
let inputs = HashMap::new();
|
||||
WorkflowBundler::new(cwd, &inputs).bundle(graph, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_references_collect_one_file() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
first [prompt="@prompt.md"]
|
||||
second [prompt="@prompt.md"]
|
||||
exit [shape=Msquare]
|
||||
start -> first -> second -> exit
|
||||
}"#,
|
||||
);
|
||||
write_file(&temp.path().join("prompt.md"), "prompt\n");
|
||||
|
||||
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
|
||||
|
||||
assert_eq!(workflows["workflow.fabro"].files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_goal_bundles_filename_with_at_prefix() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
graph [goal="@@goal.md"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#,
|
||||
);
|
||||
write_file(&temp.path().join("@goal.md"), "goal\n");
|
||||
|
||||
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
|
||||
|
||||
let goal = &workflows["workflow.fabro"].files["@goal.md"];
|
||||
assert_eq!(goal.content, "goal\n");
|
||||
assert_eq!(goal.ref_.original, "@goal.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_errors_keep_the_graphviz_error_in_the_source_chain() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(&graph, "not a graph");
|
||||
|
||||
let error = bundle_graph(temp.path(), &graph).expect_err("invalid graph should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.chain()
|
||||
.any(|cause| cause.downcast_ref::<fabro_graphviz::Error>().is_some()),
|
||||
"unexpected error chain: {error:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_errors_keep_the_io_error_in_the_source_chain() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
work [prompt="@missing.md"]
|
||||
exit [shape=Msquare]
|
||||
start -> work -> exit
|
||||
}"#,
|
||||
);
|
||||
|
||||
let error = bundle_graph(temp.path(), &graph).expect_err("missing file should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.chain()
|
||||
.any(|cause| cause.downcast_ref::<std::io::Error>().is_some()),
|
||||
"unexpected error chain: {error:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn root_workflow_normalizes_parent_components_lexically_before_reading() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let cwd = temp.path();
|
||||
let lexical_graph =
|
||||
"digraph Lexical { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
|
||||
let symlinked_graph =
|
||||
"digraph Symlinked { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }";
|
||||
write_file(&cwd.join("wf/workflow.fabro"), lexical_graph);
|
||||
write_file(&cwd.join("nested/wf/workflow.fabro"), symlinked_graph);
|
||||
std::fs::create_dir_all(cwd.join("nested/elsewhere"))
|
||||
.expect("symlink target should be created");
|
||||
// `link` points into `nested/`, so OS resolution of `link/..` lands in
|
||||
// `nested/` while lexical resolution lands in the invocation directory.
|
||||
std::os::unix::fs::symlink(cwd.join("nested/elsewhere"), cwd.join("link"))
|
||||
.expect("symlink should be created");
|
||||
|
||||
let workflows = bundle_graph(cwd, Path::new("link/../wf/workflow.fabro"))
|
||||
.expect("workflow should bundle");
|
||||
|
||||
// `link/..` must resolve lexically to `wf/workflow.fabro`, not through
|
||||
// the symlink to `nested/wf/workflow.fabro`, so the bundled source
|
||||
// matches the file the manifest key names.
|
||||
assert_eq!(workflows["wf/workflow.fabro"].source, lexical_graph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_workflow_rejects_tilde_relative_references() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
|
||||
let error = bundle_graph(temp.path(), Path::new("~/workflow.fabro"))
|
||||
.expect_err("tilde reference should be rejected");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unsupported manifest workflow reference"),
|
||||
"unexpected error: {error:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,8 @@ description = "Sandbox trait and implementations for Fabro agent execution envir
|
|||
[features]
|
||||
default = ["local"]
|
||||
local = []
|
||||
docker = ["dep:bollard", "dep:tar", "dep:fabro-github"]
|
||||
daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-github", "dep:fabro-config", "dep:fabro-http", "dep:reqwest-middleware", "dep:rand", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls"]
|
||||
docker = ["dep:bollard", "dep:tar"]
|
||||
daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-config", "dep:fabro-http", "dep:reqwest-middleware", "dep:rand", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls"]
|
||||
test-support = []
|
||||
|
||||
[lib]
|
||||
|
|
@ -47,7 +47,7 @@ tar = { workspace = true, optional = true }
|
|||
|
||||
# daytona
|
||||
fabro-config = { path = "../../foundation/fabro-config", optional = true }
|
||||
fabro-github = { path = "../fabro-github", optional = true }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-types = { path = "../../foundation/fabro-types" }
|
||||
|
||||
chrono = { workspace = true }
|
||||
|
|
@ -64,6 +64,7 @@ futures-util = { workspace = true, optional = true }
|
|||
rustls = { version = "0.23", default-features = false, features = ["std", "ring"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
fabro-github = { path = "../fabro-github", features = ["test-support"] }
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tempfile = "3"
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use daytona_sdk::api_types::SignedPortPreviewUrl;
|
|||
use daytona_sdk::toolbox_types::Command as SessionCommandResult;
|
||||
use daytona_sdk::{DaytonaError, SessionCommandLogsResult};
|
||||
use fabro_github::GitHubCredentials;
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::{CommandOutputStream, CommandTermination, RunId, SandboxProviderKind};
|
||||
use fabro_util::time::elapsed_ms;
|
||||
|
|
@ -28,6 +29,7 @@ use tokio_util::sync::CancellationToken;
|
|||
|
||||
use crate::clone_retry::{self, CloneRetryReason};
|
||||
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason};
|
||||
use crate::push_credentials::{self, PushCredentialState};
|
||||
use crate::redact::redact_auth_url;
|
||||
use crate::sandbox::{
|
||||
self, BASH_ENV_VAR, BASH_PROBE_MARKER, BASH_PROBE_SCRIPT, BASH_PROBE_TIMEOUT_MS, REMOTE_BASH,
|
||||
|
|
@ -65,11 +67,22 @@ pub(crate) const DAYTONA_DASHBOARD_SANDBOXES_URL: &str =
|
|||
const FABRO_SANDBOX_USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION"));
|
||||
pub const DAYTONA_CREDENTIAL_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const DAYTONA_BASH_SESSION_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const DAYTONA_START_TIMEOUT: Duration = Duration::from_mins(1);
|
||||
/// Upper bound on explicit and Drop-triggered Daytona cleanup calls (session
|
||||
/// deletion, temporary stdin files) so a stalled REST call cannot block
|
||||
/// cancellation/timeout paths indefinitely.
|
||||
const DAYTONA_CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Budget for waiting out an in-flight Daytona lifecycle transition (for
|
||||
/// example an auto-stop racing an activation) before giving up. Transitions
|
||||
/// normally finish within seconds; the budget only bounds a wedged sandbox.
|
||||
const DAYTONA_STATE_CHANGE_TIMEOUT: Duration = Duration::from_mins(2);
|
||||
/// Poll interval while waiting out an in-flight Daytona lifecycle transition.
|
||||
const DAYTONA_STATE_CHANGE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
/// Auto-stop applied when `lifecycle.auto_stop` is unset. Omitting the field
|
||||
/// would inherit Daytona's server-side default of 15 idle minutes, which is
|
||||
/// shorter than a single long inference call and stops the sandbox mid-run;
|
||||
/// 120 minutes clears any realistic call while still reclaiming sandboxes
|
||||
/// leaked by a dead worker. An explicit `0` disables auto-stop entirely.
|
||||
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120;
|
||||
|
||||
/// Permissions a Daytona API key needs for Fabro's snapshot and sandbox flow.
|
||||
pub const REQUIRED_DAYTONA_PERMISSIONS: &[Permissions] = &[
|
||||
|
|
@ -375,12 +388,39 @@ fn command_kind(command: &str) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, strum::Display)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
enum DaytonaLifecycleAction {
|
||||
Start,
|
||||
Stop,
|
||||
}
|
||||
|
||||
impl DaytonaLifecycleAction {
|
||||
async fn execute(
|
||||
self,
|
||||
client: &daytona_sdk::Client,
|
||||
sandbox_name: &str,
|
||||
) -> Result<(), DaytonaError> {
|
||||
match self {
|
||||
Self::Start => client.start(sandbox_name).await.map(drop),
|
||||
Self::Stop => client.stop(sandbox_name).await.map(drop),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(self, state: Option<SandboxState>) -> bool {
|
||||
match self {
|
||||
Self::Start => state == Some(SandboxState::Started),
|
||||
Self::Stop => matches!(state, Some(SandboxState::Stopped | SandboxState::Destroyed)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sandbox that runs all operations inside a Daytona cloud sandbox.
|
||||
pub struct DaytonaSandbox {
|
||||
config: DaytonaConfig,
|
||||
client: daytona_sdk::Client,
|
||||
api_key: Option<String>,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
push_credentials: PushCredentialState,
|
||||
sandbox: OnceCell<daytona_sdk::Sandbox>,
|
||||
snapshot_name: OnceCell<String>,
|
||||
rg_available: OnceCell<bool>,
|
||||
|
|
@ -414,11 +454,15 @@ impl DaytonaSandbox {
|
|||
let client = build_daytona_client(api_key.clone())
|
||||
.await
|
||||
.map_err(|e| crate::Error::context("Failed to create Daytona client", e))?;
|
||||
let push_credentials = PushCredentialState::new(push_credentials::build_token_source(
|
||||
github_app.as_ref(),
|
||||
clone_origin_url.as_deref(),
|
||||
)?);
|
||||
Ok(Self {
|
||||
config,
|
||||
client,
|
||||
api_key,
|
||||
github_app,
|
||||
push_credentials,
|
||||
sandbox: OnceCell::new(),
|
||||
snapshot_name: OnceCell::new(),
|
||||
rg_available: OnceCell::const_new(),
|
||||
|
|
@ -470,7 +514,7 @@ impl DaytonaSandbox {
|
|||
config: DaytonaConfig::default(),
|
||||
client,
|
||||
api_key,
|
||||
github_app: None,
|
||||
push_credentials: PushCredentialState::new(None),
|
||||
sandbox: sandbox_cell,
|
||||
snapshot_name: OnceCell::new(),
|
||||
rg_available: OnceCell::const_new(),
|
||||
|
|
@ -557,6 +601,19 @@ impl DaytonaSandbox {
|
|||
resolve_path(path, self.working_directory())
|
||||
}
|
||||
|
||||
async fn upload_file_content(&self, resolved_path: &str, content: &str) -> crate::Result<()> {
|
||||
let sandbox = self.sandbox()?;
|
||||
let fs_svc = sandbox
|
||||
.fs()
|
||||
.await
|
||||
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
|
||||
|
||||
fs_svc
|
||||
.upload_file_bytes(resolved_path, content.as_bytes())
|
||||
.await
|
||||
.map_err(|e| crate::Error::context(format!("Failed to write file {resolved_path}"), e))
|
||||
}
|
||||
|
||||
/// Verify a Daytona sandbox evaluates commands as non-login Bash.
|
||||
///
|
||||
/// Runs on a freshly created sandbox before any Fabro-owned setup, and
|
||||
|
|
@ -773,7 +830,10 @@ impl DaytonaSandbox {
|
|||
daytona_sdk::SandboxBaseParams {
|
||||
name: Some(name),
|
||||
env_vars: Some(clean_bash_env(None)),
|
||||
auto_stop_interval: self.config.auto_stop_interval,
|
||||
auto_stop_interval: self
|
||||
.config
|
||||
.auto_stop_interval
|
||||
.or(Some(DEFAULT_AUTO_STOP_INTERVAL_MINUTES)),
|
||||
labels: Some(managed_labels::merge_for_run(
|
||||
self.config.labels.as_ref(),
|
||||
self.run_id.as_ref(),
|
||||
|
|
@ -898,6 +958,157 @@ impl DaytonaSandbox {
|
|||
"Timed out waiting for snapshot '{name}' to become active"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn wait_for_stable_state(
|
||||
&self,
|
||||
sandbox_name: &str,
|
||||
) -> Result<Option<SandboxState>, DaytonaError> {
|
||||
loop {
|
||||
time::sleep(DAYTONA_STATE_CHANGE_POLL_INTERVAL).await;
|
||||
let state = self.client.get(sandbox_name).await?.state;
|
||||
if !is_transitional_state(state) {
|
||||
return Ok(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_lifecycle_action(
|
||||
&self,
|
||||
sandbox_name: &str,
|
||||
action: DaytonaLifecycleAction,
|
||||
deadline: time::Instant,
|
||||
) -> crate::Result<()> {
|
||||
loop {
|
||||
let request = time::timeout_at(deadline, action.execute(&self.client, sandbox_name));
|
||||
match request.await {
|
||||
Ok(Ok(())) => return Ok(()),
|
||||
Ok(Err(source)) if is_state_change_in_progress(&source) => {
|
||||
tracing::debug!(
|
||||
action = %action,
|
||||
sandbox = sandbox_name,
|
||||
"Daytona lifecycle request rejected during state change"
|
||||
);
|
||||
match time::timeout_at(deadline, self.wait_for_stable_state(sandbox_name)).await
|
||||
{
|
||||
Ok(Ok(state)) if action.is_complete(state) => return Ok(()),
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(wait_source)) => {
|
||||
return Err(crate::Error::context(
|
||||
format!(
|
||||
"Failed to inspect Daytona sandbox while waiting to {action}"
|
||||
),
|
||||
wait_source,
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(crate::Error::context(
|
||||
format!("Timed out waiting to {action} Daytona sandbox"),
|
||||
source,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(source)) => {
|
||||
return Err(crate::Error::context(
|
||||
format!("Failed to {action} Daytona sandbox"),
|
||||
source,
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Timed out waiting to {action} Daytona sandbox"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_error(&self, error: crate::Error) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StartFailed {
|
||||
provider: "daytona".into(),
|
||||
error: error.to_string(),
|
||||
causes: error.causes(),
|
||||
});
|
||||
Err(error)
|
||||
}
|
||||
|
||||
fn stop_error(&self, error: crate::Error) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StopFailed {
|
||||
provider: "daytona".into(),
|
||||
error: error.to_string(),
|
||||
causes: error.causes(),
|
||||
});
|
||||
Err(error)
|
||||
}
|
||||
|
||||
async fn start_with_deadline(&self, deadline: time::Instant) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StartStarted {
|
||||
provider: "daytona".into(),
|
||||
});
|
||||
let start = Instant::now();
|
||||
let result = async {
|
||||
let sandbox = self.sandbox()?;
|
||||
self.run_lifecycle_action(&sandbox.name, DaytonaLifecycleAction::Start, deadline)
|
||||
.await?;
|
||||
Self::probe_bash(sandbox).await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
return self.start_error(error);
|
||||
}
|
||||
self.emit(SandboxEvent::StartCompleted {
|
||||
provider: "daytona".into(),
|
||||
duration_ms: elapsed_ms(start),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_with_deadline(&self, deadline: time::Instant) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StopStarted {
|
||||
provider: "daytona".into(),
|
||||
});
|
||||
let start = Instant::now();
|
||||
let result = async {
|
||||
let sandbox = self.sandbox()?;
|
||||
self.run_lifecycle_action(&sandbox.name, DaytonaLifecycleAction::Stop, deadline)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
return self.stop_error(error);
|
||||
}
|
||||
self.emit(SandboxEvent::StopCompleted {
|
||||
provider: "daytona".into(),
|
||||
duration_ms: elapsed_ms(start),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_state_change_in_progress(err: &DaytonaError) -> bool {
|
||||
err.status_code() == Some(400)
|
||||
&& err
|
||||
.message()
|
||||
.to_ascii_lowercase()
|
||||
.contains("state change in progress")
|
||||
}
|
||||
|
||||
fn is_transitional_state(state: Option<SandboxState>) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
Some(
|
||||
SandboxState::Creating
|
||||
| SandboxState::Restoring
|
||||
| SandboxState::Destroying
|
||||
| SandboxState::Starting
|
||||
| SandboxState::Stopping
|
||||
| SandboxState::PendingBuild
|
||||
| SandboxState::BuildingSnapshot
|
||||
| SandboxState::PullingSnapshot
|
||||
| SandboxState::Archiving
|
||||
| SandboxState::Resizing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Detect the git remote URL and current branch from a local repository.
|
||||
|
|
@ -1098,37 +1309,39 @@ impl Sandbox for DaytonaSandbox {
|
|||
let layout =
|
||||
clone_source::github_repo_layout(&origin_url, WORKING_DIRECTORY, REPOS_ROOT)
|
||||
.map_err(|err| self.fail_init(init_start, err))?;
|
||||
let token_was_freshly_minted = self
|
||||
.github_app
|
||||
.as_ref()
|
||||
.is_some_and(GitHubCredentials::mints_installation_token);
|
||||
self.emit(SandboxEvent::GitCloneStarted {
|
||||
url: origin_url.clone(),
|
||||
branch: branch.clone(),
|
||||
});
|
||||
let clone_start = Instant::now();
|
||||
|
||||
let (username, password) = match &self.github_app {
|
||||
Some(creds) => fabro_github::resolve_clone_credentials(
|
||||
&fabro_github::GitHubContext::new(
|
||||
creds,
|
||||
&fabro_github::github_api_base_url(),
|
||||
),
|
||||
&layout.owner,
|
||||
&layout.repo,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err = crate::Error::message(format!(
|
||||
"Failed to get GitHub App credentials for clone: {e}"
|
||||
));
|
||||
// The clone mints its own token (never a warm-cache reuse) and
|
||||
// seeds the shared source, so the first refresh compares
|
||||
// against the clone token instead of believing nothing was
|
||||
// ever embedded.
|
||||
let resolved_token = match self.push_credentials.source() {
|
||||
Some(source) => Some(source.mint_for_clone().await.map_err(|source| {
|
||||
let err = crate::Error::context_anyhow(
|
||||
"Failed to get GitHub App credentials for clone",
|
||||
source,
|
||||
);
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: origin_url.clone(),
|
||||
error: err.to_string(),
|
||||
causes: err.causes(),
|
||||
});
|
||||
self.fail_init(init_start, err)
|
||||
})?,
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let token_was_freshly_minted = resolved_token
|
||||
.as_ref()
|
||||
.is_some_and(|token| !token.snapshot.is_static());
|
||||
let (username, password) = match &resolved_token {
|
||||
Some(token) => (
|
||||
Some("x-access-token".to_string()),
|
||||
Some(token.token.expose().to_string()),
|
||||
),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
|
|
@ -1276,8 +1489,11 @@ impl Sandbox for DaytonaSandbox {
|
|||
let _ = self.origin_url.set(origin_url.clone());
|
||||
self.set_working_directory(layout.execution_directory.clone())
|
||||
.map_err(|err| self.fail_init(init_start, err))?;
|
||||
if let Some(token) = password.as_deref() {
|
||||
match fabro_github::embed_token_in_url(&origin_url, token) {
|
||||
if let Some(resolved) = resolved_token {
|
||||
match fabro_github::embed_token_in_url(
|
||||
&origin_url,
|
||||
resolved.token.expose(),
|
||||
) {
|
||||
Ok(auth_url) => {
|
||||
let cmd = format!(
|
||||
"git -c maintenance.auto=0 remote set-url origin {}",
|
||||
|
|
@ -1310,7 +1526,12 @@ impl Sandbox for DaytonaSandbox {
|
|||
sandbox will fail"
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Ok(_) => {
|
||||
// Origin now carries this token;
|
||||
// record it so refreshes compare
|
||||
// against the clone generation.
|
||||
self.push_credentials.record_embedded(resolved).await;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
error_class = "daytona_set_url_exec_failed",
|
||||
|
|
@ -1323,7 +1544,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
origin = %origin_url,
|
||||
origin = %fabro_redact::redacted_url_for_log(&origin_url),
|
||||
error = %e,
|
||||
"Failed to build authenticated origin URL — \
|
||||
subsequent git push from this sandbox will fail"
|
||||
|
|
@ -1332,7 +1553,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
}
|
||||
}
|
||||
Err(e) if self.github_app.is_none() => {
|
||||
Err(e) if self.push_credentials.source().is_none() => {
|
||||
let err = crate::Error::context(
|
||||
"Git clone failed. If this is a private repository, \
|
||||
configure a GitHub App with `fabro install` and install it \
|
||||
|
|
@ -1382,76 +1603,47 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
|
||||
async fn start(&self) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StartStarted {
|
||||
provider: "daytona".into(),
|
||||
});
|
||||
let start = Instant::now();
|
||||
let sandbox = self.sandbox()?;
|
||||
if let Err(e) = self.client.start(&sandbox.name).await {
|
||||
let err = crate::Error::context("Failed to start Daytona sandbox", e);
|
||||
self.emit(SandboxEvent::StartFailed {
|
||||
provider: "daytona".into(),
|
||||
error: err.to_string(),
|
||||
causes: err.causes(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
if let Err(err) = Self::probe_bash(sandbox).await {
|
||||
self.emit(SandboxEvent::StartFailed {
|
||||
provider: "daytona".into(),
|
||||
error: err.to_string(),
|
||||
causes: err.causes(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
let duration_ms = elapsed_ms(start);
|
||||
self.emit(SandboxEvent::StartCompleted {
|
||||
provider: "daytona".into(),
|
||||
duration_ms,
|
||||
});
|
||||
Ok(())
|
||||
self.start_with_deadline(time::Instant::now() + DAYTONA_STATE_CHANGE_TIMEOUT)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn activate(&self) -> crate::Result<()> {
|
||||
let sandbox = self.sandbox()?;
|
||||
let current = self.client.get(&sandbox.name).await.map_err(|e| {
|
||||
crate::Error::context("Failed to inspect Daytona sandbox before activation", e)
|
||||
})?;
|
||||
if current.state == Some(SandboxState::Started) {
|
||||
let deadline = time::Instant::now() + DAYTONA_STATE_CHANGE_TIMEOUT;
|
||||
let current = time::timeout_at(deadline, self.client.get(&sandbox.name))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::Error::message("Timed out inspecting Daytona sandbox before activation")
|
||||
})?
|
||||
.map_err(|e| {
|
||||
crate::Error::context("Failed to inspect Daytona sandbox before activation", e)
|
||||
})?;
|
||||
let state = if is_transitional_state(current.state) {
|
||||
time::timeout_at(deadline, self.wait_for_stable_state(&sandbox.name))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::Error::message(
|
||||
"Timed out waiting for Daytona sandbox state change before activation",
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
crate::Error::context(
|
||||
"Failed to wait for Daytona sandbox state change before activation",
|
||||
e,
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
current.state
|
||||
};
|
||||
if state == Some(SandboxState::Started) {
|
||||
return Ok(());
|
||||
}
|
||||
if current.state == Some(SandboxState::Starting) {
|
||||
return current
|
||||
.wait_for_start(Some(DAYTONA_START_TIMEOUT))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::Error::context("Failed to wait for Daytona sandbox activation", e)
|
||||
});
|
||||
}
|
||||
self.start().await
|
||||
self.start_with_deadline(deadline).await
|
||||
}
|
||||
|
||||
async fn stop(&self) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StopStarted {
|
||||
provider: "daytona".into(),
|
||||
});
|
||||
let start = Instant::now();
|
||||
let sandbox = self.sandbox()?;
|
||||
if let Err(e) = self.client.stop(&sandbox.name).await {
|
||||
let err = crate::Error::context("Failed to stop Daytona sandbox", e);
|
||||
self.emit(SandboxEvent::StopFailed {
|
||||
provider: "daytona".into(),
|
||||
error: err.to_string(),
|
||||
causes: err.causes(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
let duration_ms = elapsed_ms(start);
|
||||
self.emit(SandboxEvent::StopCompleted {
|
||||
provider: "daytona".into(),
|
||||
duration_ms,
|
||||
});
|
||||
Ok(())
|
||||
self.stop_with_deadline(time::Instant::now() + DAYTONA_STATE_CHANGE_TIMEOUT)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete(&self) -> crate::Result<()> {
|
||||
|
|
@ -1566,52 +1758,42 @@ impl Sandbox for DaytonaSandbox {
|
|||
Ok(Some((preview.url, headers)))
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "refresh-credentials"))]
|
||||
async fn refresh_push_credentials(&self) -> crate::Result<RefreshOutcome> {
|
||||
if !self.repo_cloned() {
|
||||
return Ok(RefreshOutcome::Skipped);
|
||||
return Ok(RefreshOutcome::none());
|
||||
}
|
||||
let Some(origin_url) = self.origin_url.get() else {
|
||||
return Ok(RefreshOutcome::Skipped); // no authenticated origin — nothing to refresh
|
||||
return Ok(RefreshOutcome::none()); // no authenticated origin — nothing to refresh
|
||||
};
|
||||
let Some(creds) = &self.github_app else {
|
||||
return Ok(RefreshOutcome::Skipped);
|
||||
};
|
||||
// Only a GitHub App installation token can be re-minted; a static PAT or
|
||||
// a pre-minted Installation token is fixed, so re-embedding it changes
|
||||
// nothing. Short-circuit to Skipped before the resolve + set-url exec.
|
||||
if !creds.mints_installation_token() {
|
||||
return Ok(RefreshOutcome::Skipped);
|
||||
}
|
||||
|
||||
let auth_url = fabro_github::resolve_authenticated_url(
|
||||
&fabro_github::GitHubContext::new(creds, &fabro_github::github_api_base_url()),
|
||||
origin_url,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::Error::message("Failed to refresh push credentials: token_mint_failed")
|
||||
})?;
|
||||
|
||||
let cmd = format!(
|
||||
"git -c maintenance.auto=0 remote set-url origin {}",
|
||||
shell_quote(auth_url.as_raw_url().as_str()),
|
||||
);
|
||||
let result = self
|
||||
.exec_command(&cmd, 10_000, None, None, None)
|
||||
self.push_credentials
|
||||
.refresh(origin_url, |auth_url| async move {
|
||||
let cmd = format!(
|
||||
"git -c maintenance.auto=0 remote set-url origin {}",
|
||||
shell_quote(auth_url.as_raw_url().as_str()),
|
||||
);
|
||||
let result = self
|
||||
.exec_command(&cmd, 10_000, None, None, None)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::Error::context(
|
||||
"Failed to refresh push credentials: set origin URL",
|
||||
err,
|
||||
)
|
||||
})?;
|
||||
if !result.is_success() {
|
||||
return Err(result.into_exec_error_with_redactor(
|
||||
"git remote set-url origin (refresh push credentials)",
|
||||
|s| redact_auth_url(s, Some(&auth_url)),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::Error::message("Failed to refresh push credentials: set_url_exec_failed")
|
||||
})?;
|
||||
if !result.is_success() {
|
||||
return Err(result.into_exec_error_with_redactor(
|
||||
"git remote set-url origin (refresh push credentials)",
|
||||
|s| redact_auth_url(s, Some(&auth_url)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Static creds were short-circuited to Skipped above; reaching here means
|
||||
// a GitHub App installation token was freshly minted.
|
||||
Ok(RefreshOutcome::Refreshed)
|
||||
fn push_token_source(&self) -> Option<Arc<InstallationTokenSource>> {
|
||||
self.push_credentials.source().cloned()
|
||||
}
|
||||
|
||||
async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
|
||||
|
|
@ -1659,17 +1841,12 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
let fs_svc = sandbox
|
||||
.fs()
|
||||
.await
|
||||
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
|
||||
self.upload_file_content(&resolved, content).await
|
||||
}
|
||||
|
||||
fs_svc
|
||||
.upload_file_bytes(&resolved, content.as_bytes())
|
||||
.await
|
||||
.map_err(|e| crate::Error::context(format!("Failed to write file {resolved}"), e))?;
|
||||
|
||||
Ok(())
|
||||
async fn write_existing_file(&self, path: &str, content: &str) -> crate::Result<()> {
|
||||
let resolved = self.resolve_path(path);
|
||||
self.upload_file_content(&resolved, content).await
|
||||
}
|
||||
|
||||
async fn delete_file(&self, path: &str) -> crate::Result<()> {
|
||||
|
|
@ -2802,7 +2979,7 @@ mod tests {
|
|||
config,
|
||||
client,
|
||||
api_key: Some(api_key.to_string()),
|
||||
github_app: None,
|
||||
push_credentials: PushCredentialState::new(None),
|
||||
sandbox: OnceCell::new(),
|
||||
snapshot_name: OnceCell::new(),
|
||||
rg_available: OnceCell::const_new(),
|
||||
|
|
@ -2996,6 +3173,10 @@ mod tests {
|
|||
|
||||
assert_eq!(params.ephemeral, Some(false));
|
||||
assert_eq!(params.auto_delete_interval, Some(-1));
|
||||
assert_eq!(
|
||||
params.auto_stop_interval,
|
||||
Some(DEFAULT_AUTO_STOP_INTERVAL_MINUTES)
|
||||
);
|
||||
assert_eq!(
|
||||
params.env_vars,
|
||||
Some(HashMap::from([(BASH_ENV_VAR.to_string(), String::new())]))
|
||||
|
|
@ -3009,6 +3190,27 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_params_passes_explicit_auto_stop_through() {
|
||||
for interval in [0, 45] {
|
||||
let sandbox = DaytonaSandbox::new(
|
||||
DaytonaConfig {
|
||||
auto_stop_interval: Some(interval),
|
||||
..DaytonaConfig::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dtn_test".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("sandbox config should be valid");
|
||||
|
||||
assert_eq!(sandbox.base_params().auto_stop_interval, Some(interval));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activate_skips_start_when_daytona_reports_started() {
|
||||
let server = MockServer::start_async().await;
|
||||
|
|
@ -3110,6 +3312,201 @@ mod tests {
|
|||
start_sandbox.assert_calls_async(0).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activate_waits_out_a_stop_in_progress() {
|
||||
let server = MockServer::start_async().await;
|
||||
let response_count = Arc::new(AtomicU32::new(0));
|
||||
let get_sandbox = server
|
||||
.mock_async({
|
||||
let response_count = Arc::clone(&response_count);
|
||||
move |when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/test-sandbox")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.respond_with(move |_| {
|
||||
let state = if response_count.fetch_add(1, Ordering::Relaxed) == 1 {
|
||||
SandboxState::Stopping
|
||||
} else {
|
||||
SandboxState::Started
|
||||
};
|
||||
HttpMockResponse::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(sandbox_body("test-sandbox", state).to_string())
|
||||
.build()
|
||||
});
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let start_sandbox = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/sandbox/test-sandbox/start")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(sandbox_body("test-sandbox", SandboxState::Started));
|
||||
})
|
||||
.await;
|
||||
let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await;
|
||||
let sdk_sandbox = sandbox
|
||||
.client
|
||||
.get("test-sandbox")
|
||||
.await
|
||||
.expect("test sandbox should load");
|
||||
sandbox
|
||||
.sandbox
|
||||
.set(sdk_sandbox)
|
||||
.expect("test sandbox should initialize once");
|
||||
|
||||
let get_calls_before = get_sandbox.calls_async().await;
|
||||
sandbox
|
||||
.activate()
|
||||
.await
|
||||
.expect("an in-progress stop should be waited out");
|
||||
|
||||
assert_eq!(get_sandbox.calls_async().await, get_calls_before + 2);
|
||||
start_sandbox.assert_calls_async(0).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_succeeds_when_a_pending_auto_stop_finishes_first() {
|
||||
let server = MockServer::start_async().await;
|
||||
let response_count = Arc::new(AtomicU32::new(0));
|
||||
let get_sandbox = server
|
||||
.mock_async({
|
||||
let response_count = Arc::clone(&response_count);
|
||||
move |when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/test-sandbox")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.respond_with(move |_| {
|
||||
let state = if response_count.fetch_add(1, Ordering::Relaxed) == 0 {
|
||||
SandboxState::Started
|
||||
} else {
|
||||
SandboxState::Stopped
|
||||
};
|
||||
HttpMockResponse::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(sandbox_body("test-sandbox", state).to_string())
|
||||
.build()
|
||||
});
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let stop_sandbox = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/sandbox/test-sandbox/stop")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"message": "Sandbox state change in progress",
|
||||
"statusCode": 400
|
||||
}));
|
||||
})
|
||||
.await;
|
||||
let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await;
|
||||
let sdk_sandbox = sandbox
|
||||
.client
|
||||
.get("test-sandbox")
|
||||
.await
|
||||
.expect("test sandbox should load");
|
||||
sandbox
|
||||
.sandbox
|
||||
.set(sdk_sandbox)
|
||||
.expect("test sandbox should initialize once");
|
||||
|
||||
let get_calls_before = get_sandbox.calls_async().await;
|
||||
sandbox
|
||||
.stop()
|
||||
.await
|
||||
.expect("a stop already in flight should count as stopped");
|
||||
|
||||
stop_sandbox.assert_calls_async(1).await;
|
||||
assert_eq!(get_sandbox.calls_async().await, get_calls_before + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_surfaces_state_change_rejection_after_the_deadline() {
|
||||
let server = MockServer::start_async().await;
|
||||
let _get_sandbox = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/test-sandbox")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(sandbox_body("test-sandbox", SandboxState::Stopping));
|
||||
})
|
||||
.await;
|
||||
let start_sandbox = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/sandbox/test-sandbox/start")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"message": "Sandbox state change in progress",
|
||||
"statusCode": 400
|
||||
}));
|
||||
})
|
||||
.await;
|
||||
let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await;
|
||||
let sdk_sandbox = sandbox
|
||||
.client
|
||||
.get("test-sandbox")
|
||||
.await
|
||||
.expect("test sandbox should load");
|
||||
sandbox
|
||||
.sandbox
|
||||
.set(sdk_sandbox)
|
||||
.expect("test sandbox should initialize once");
|
||||
|
||||
let err = sandbox
|
||||
.start_with_deadline(time::Instant::now() + Duration::from_millis(1500))
|
||||
.await
|
||||
.expect_err("a state change that outlives the deadline should fail");
|
||||
|
||||
assert_eq!(
|
||||
start_sandbox.calls_async().await,
|
||||
1,
|
||||
"start should not be retried while the current transition is in flight"
|
||||
);
|
||||
assert!(
|
||||
err.causes().iter().any(|cause| cause
|
||||
.to_ascii_lowercase()
|
||||
.contains("state change in progress")),
|
||||
"error should carry the Daytona rejection: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_change_in_progress_matcher_ignores_case_and_context() {
|
||||
assert!(is_state_change_in_progress(&DaytonaError::api(
|
||||
400,
|
||||
"Sandbox state change in progress"
|
||||
)));
|
||||
assert!(is_state_change_in_progress(&DaytonaError::api(
|
||||
400,
|
||||
"State Change In Progress"
|
||||
)));
|
||||
assert!(!is_state_change_in_progress(&DaytonaError::api(
|
||||
400,
|
||||
"Sandbox already started"
|
||||
)));
|
||||
assert!(!is_state_change_in_progress(&DaytonaError::api(
|
||||
500,
|
||||
"Sandbox state change in progress"
|
||||
)));
|
||||
assert!(!is_state_change_in_progress(&DaytonaError::general(
|
||||
"Sandbox state change in progress"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_params_merges_managed_daytona_labels() {
|
||||
let run_id: RunId = "01HY0000000000000000000000".parse().unwrap();
|
||||
|
|
@ -3497,6 +3894,62 @@ mod tests {
|
|||
delete.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_existing_file_skips_parent_directory_creation() {
|
||||
let server = MockServer::start_async().await;
|
||||
let server_url = server.base_url();
|
||||
let sandbox_response = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(GET).path("/sandbox/sandbox-edit");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(sandbox_body("sandbox-edit", SandboxState::Started));
|
||||
})
|
||||
.await;
|
||||
let toolbox_response = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/sandbox-edit/toolbox-proxy-url");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({"url": server_url}));
|
||||
})
|
||||
.await;
|
||||
let folder = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST).path("/sandbox-edit/files/folder");
|
||||
then.status(200);
|
||||
})
|
||||
.await;
|
||||
let upload = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/sandbox-edit/files/upload")
|
||||
.query_param("path", "/home/daytona/workspace/src/lib.rs")
|
||||
.body_includes("updated contents");
|
||||
then.status(200);
|
||||
})
|
||||
.await;
|
||||
|
||||
let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await;
|
||||
let sdk_sandbox = sandbox
|
||||
.client
|
||||
.get("sandbox-edit")
|
||||
.await
|
||||
.expect("get mock sandbox");
|
||||
assert!(sandbox.sandbox.set(sdk_sandbox).is_ok());
|
||||
|
||||
sandbox
|
||||
.write_existing_file("src/lib.rs", "updated contents")
|
||||
.await
|
||||
.expect("write existing file");
|
||||
|
||||
sandbox_response.assert_async().await;
|
||||
toolbox_response.assert_async().await;
|
||||
upload.assert_async().await;
|
||||
folder.assert_calls_async(0).await;
|
||||
}
|
||||
|
||||
/// Recover the inner command a wrapper carries, proving it survives the
|
||||
/// base64 transport byte-for-byte.
|
||||
fn decode_wrapped_command(wrapped: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
|
|||
use bollard::image::CreateImageOptions;
|
||||
use bollard::models::{ContainerInspectResponse, HostConfig};
|
||||
use fabro_github::GitHubCredentials;
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use fabro_types::{CommandOutputStream, CommandTermination, RunId, SandboxProviderKind};
|
||||
use fabro_util::time::elapsed_ms;
|
||||
use futures::StreamExt;
|
||||
|
|
@ -27,6 +28,7 @@ use tokio_util::sync::CancellationToken;
|
|||
|
||||
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason};
|
||||
use crate::managed_labels::{self, MANAGED_LABEL, RUN_ID_LABEL};
|
||||
use crate::push_credentials::{self, PushCredentialState};
|
||||
use crate::redact::redact_auth_url;
|
||||
use crate::sandbox::{
|
||||
self, BASH_ENV_VAR, BASH_PROBE_SCRIPT, BASH_PROBE_TIMEOUT_MS, REMOTE_BASH,
|
||||
|
|
@ -131,7 +133,7 @@ impl Default for DockerSandboxOptions {
|
|||
pub struct DockerSandbox {
|
||||
docker: Docker,
|
||||
config: DockerSandboxOptions,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
push_credentials: PushCredentialState,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
|
|
@ -161,34 +163,38 @@ enum ContainerStartAction {
|
|||
impl DockerSandbox {
|
||||
pub fn new(
|
||||
config: DockerSandboxOptions,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
github_app: Option<&GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
) -> crate::Result<Self> {
|
||||
let docker = Docker::connect_with_local_defaults().map_err(crate::Error::docker_connect)?;
|
||||
Ok(Self::with_docker_client(
|
||||
Self::with_docker_client(
|
||||
docker,
|
||||
config,
|
||||
github_app,
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
fn with_docker_client(
|
||||
docker: Docker,
|
||||
config: DockerSandboxOptions,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
github_app: Option<&GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
) -> crate::Result<Self> {
|
||||
let push_credentials = PushCredentialState::new(push_credentials::build_token_source(
|
||||
github_app,
|
||||
clone_origin_url.as_deref(),
|
||||
)?);
|
||||
Ok(Self {
|
||||
docker,
|
||||
config,
|
||||
github_app,
|
||||
push_credentials,
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
|
|
@ -200,7 +206,7 @@ impl DockerSandbox {
|
|||
cached_os_version: std::sync::OnceLock::new(),
|
||||
rg_available: OnceCell::const_new(),
|
||||
event_callback: None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn reconnect(
|
||||
|
|
@ -716,7 +722,7 @@ impl DockerSandbox {
|
|||
) -> crate::Error {
|
||||
let error = result
|
||||
.into_exec_error_with_redactor("git clone", |output| redact_auth_url(output, auth_url));
|
||||
let message = if self.github_app.is_none() {
|
||||
let message = if self.push_credentials.source().is_none() {
|
||||
"Git clone failed. If this is a private repository, configure a GitHub App with \
|
||||
`fabro install` and install it for your organization."
|
||||
} else {
|
||||
|
|
@ -741,23 +747,29 @@ impl DockerSandbox {
|
|||
) -> crate::Result<()> {
|
||||
self.verify_git_available().await?;
|
||||
let layout = clone_source::github_repo_layout(&origin_url, WORKING_DIRECTORY, REPOS_ROOT)?;
|
||||
let token_was_freshly_minted = self
|
||||
.github_app
|
||||
// The clone mints its own token (never a warm-cache reuse) and seeds
|
||||
// the shared source, so the first refresh compares against the clone
|
||||
// token instead of believing nothing was ever embedded.
|
||||
let resolved_token = match self.push_credentials.source() {
|
||||
Some(source) => Some(source.mint_for_clone().await.map_err(|err| {
|
||||
crate::Error::context_anyhow("Failed to get GitHub App credentials for clone", err)
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let token_was_freshly_minted = resolved_token
|
||||
.as_ref()
|
||||
.is_some_and(GitHubCredentials::mints_installation_token);
|
||||
.is_some_and(|token| !token.snapshot.is_static());
|
||||
|
||||
let auth_url = match &self.github_app {
|
||||
Some(creds) => Some(
|
||||
fabro_github::resolve_authenticated_url(
|
||||
&fabro_github::GitHubContext::new(creds, &fabro_github::github_api_base_url()),
|
||||
&origin_url,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::Error::message(format!(
|
||||
"Failed to get GitHub App credentials for clone: {e}"
|
||||
))
|
||||
})?,
|
||||
let auth_url = match &resolved_token {
|
||||
Some(token) => Some(
|
||||
fabro_github::embed_token_in_url(&origin_url, token.token.expose()).map_err(
|
||||
|err| {
|
||||
crate::Error::context_anyhow(
|
||||
"Failed to build authenticated GitHub clone URL",
|
||||
err,
|
||||
)
|
||||
},
|
||||
)?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
|
@ -862,6 +874,11 @@ impl DockerSandbox {
|
|||
let _ = self.repo_cloned.set(true);
|
||||
let _ = self.origin_url.set(origin_url.clone());
|
||||
self.set_working_directory(layout.execution_directory.clone())?;
|
||||
if let Some(token) = resolved_token {
|
||||
// The clone URL embedded this token in `origin`; record it so
|
||||
// refreshes compare against the clone generation.
|
||||
self.push_credentials.record_embedded(token).await;
|
||||
}
|
||||
|
||||
if let Some(auth_url) = auth_url.as_ref() {
|
||||
let command = format!(
|
||||
|
|
@ -2180,49 +2197,36 @@ impl Sandbox for DockerSandbox {
|
|||
self.origin_url.get().map(String::as_str)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "refresh-credentials"))]
|
||||
async fn refresh_push_credentials(&self) -> crate::Result<RefreshOutcome> {
|
||||
if !self.repo_cloned() {
|
||||
return Ok(RefreshOutcome::Skipped);
|
||||
return Ok(RefreshOutcome::none());
|
||||
}
|
||||
let Some(origin_url) = self.origin_url.get() else {
|
||||
return Ok(RefreshOutcome::Skipped);
|
||||
return Ok(RefreshOutcome::none());
|
||||
};
|
||||
let Some(creds) = &self.github_app else {
|
||||
return Ok(RefreshOutcome::Skipped);
|
||||
};
|
||||
// Only a GitHub App installation token can be re-minted; a static PAT or
|
||||
// a pre-minted Installation token is fixed, so re-embedding it changes
|
||||
// nothing. Short-circuit to Skipped before the resolve + set-url exec.
|
||||
if !creds.mints_installation_token() {
|
||||
return Ok(RefreshOutcome::Skipped);
|
||||
}
|
||||
self.push_credentials
|
||||
.refresh(origin_url, |auth_url| async move {
|
||||
let command = format!(
|
||||
"git -c maintenance.auto=0 remote set-url origin {}",
|
||||
shell_quote(auth_url.as_raw_url().as_str())
|
||||
);
|
||||
let result = self
|
||||
.docker_exec_shell(&command, 10_000, Some(self.working_directory()), None, None)
|
||||
.await?;
|
||||
if !result.is_success() {
|
||||
return Err(result.into_exec_error_with_redactor(
|
||||
"git remote set-url origin (refresh push credentials)",
|
||||
|s| redact_auth_url(s, Some(&auth_url)),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
let auth_url = fabro_github::resolve_authenticated_url(
|
||||
&fabro_github::GitHubContext::new(creds, &fabro_github::github_api_base_url()),
|
||||
origin_url,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::Error::message("Failed to refresh push credentials: token_mint_failed")
|
||||
})?;
|
||||
|
||||
let command = format!(
|
||||
"git -c maintenance.auto=0 remote set-url origin {}",
|
||||
shell_quote(auth_url.as_raw_url().as_str())
|
||||
);
|
||||
let result = self
|
||||
.docker_exec_shell(&command, 10_000, Some(self.working_directory()), None, None)
|
||||
.await?;
|
||||
if !result.is_success() {
|
||||
return Err(result.into_exec_error_with_redactor(
|
||||
"git remote set-url origin (refresh push credentials)",
|
||||
|s| redact_auth_url(s, Some(&auth_url)),
|
||||
));
|
||||
}
|
||||
|
||||
// Static creds were short-circuited to Skipped above; reaching here means
|
||||
// a GitHub App installation token was freshly minted.
|
||||
Ok(RefreshOutcome::Refreshed)
|
||||
fn push_token_source(&self) -> Option<Arc<InstallationTokenSource>> {
|
||||
self.push_credentials.source().cloned()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2716,7 +2720,8 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
)
|
||||
.expect("test sandbox should build");
|
||||
sandbox
|
||||
.container_id
|
||||
.set(container_id.to_string())
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ pub enum Error {
|
|||
source: Box<dyn std::error::Error + Send + Sync + 'static>,
|
||||
},
|
||||
|
||||
#[error("{message}")]
|
||||
AnyhowContext {
|
||||
message: String,
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[error("Failed to connect to Docker daemon")]
|
||||
DockerConnect {
|
||||
|
|
@ -68,6 +75,13 @@ impl Error {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn context_anyhow(message: impl Into<String>, source: anyhow::Error) -> Self {
|
||||
Self::AnyhowContext {
|
||||
message: message.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exec(label: impl Into<String>, result: ExecResult) -> Self {
|
||||
Self::Exec {
|
||||
label: label.into(),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ mod clone_retry;
|
|||
#[cfg(any(feature = "docker", feature = "daytona", test))]
|
||||
mod managed_labels;
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona", test))]
|
||||
mod push_credentials;
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona", test))]
|
||||
pub mod redact;
|
||||
|
||||
|
|
@ -39,6 +42,9 @@ pub use details::sandbox_details;
|
|||
#[cfg(feature = "docker")]
|
||||
pub use docker::{DockerSandbox, DockerSandboxOptions};
|
||||
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
|
||||
pub use fabro_github::token_source::{
|
||||
InstallationTokenSource, ResolvedToken, TokenProvenance, TokenSnapshot,
|
||||
};
|
||||
pub use fabro_types::{RunSandboxInstance, SandboxProviderKind};
|
||||
pub use local::LocalSandbox;
|
||||
#[cfg(feature = "daytona")]
|
||||
|
|
@ -53,9 +59,10 @@ pub use reconnect::{reconnect, reconnect_for_run, reconnect_for_run_with_callbac
|
|||
pub use sandbox::{
|
||||
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult,
|
||||
ExecStreamingRequest, ExecStreamingResult, GitRunInfo, GitSetupIntent, GrepOptions,
|
||||
RefreshOutcome, Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, StderrCollector,
|
||||
StdioProcess, StdioProcessHandle, StdioProcessTermination, WalkOptions, format_lines_numbered,
|
||||
git_push_via_exec, redacted_output_tail, setup_git_via_exec, shell_quote,
|
||||
RefreshOutcome, RemoteCredentialAction, Sandbox, SandboxEvent, SandboxEventCallback,
|
||||
SandboxFile, StderrCollector, StdioProcess, StdioProcessHandle, StdioProcessTermination,
|
||||
WalkOptions, format_lines_numbered, git_push_via_exec, redacted_output_tail,
|
||||
setup_git_via_exec, shell_quote,
|
||||
};
|
||||
pub use sandbox_spec::SandboxSpec;
|
||||
pub use terminal::{TerminalSession, TerminalSize, open_terminal_for_run};
|
||||
|
|
|
|||
|
|
@ -98,8 +98,13 @@ impl SandboxProvider for DockerSandboxProvider {
|
|||
));
|
||||
};
|
||||
|
||||
let sandbox =
|
||||
DockerSandbox::new(config, github_app, run_id, clone_origin_url, clone_branch)?;
|
||||
let sandbox = DockerSandbox::new(
|
||||
config,
|
||||
github_app.as_ref(),
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
)?;
|
||||
sandbox.initialize().await?;
|
||||
let container_id = sandbox.container_identifier()?.to_string();
|
||||
self.get(&container_id).await?.ok_or_else(|| {
|
||||
|
|
|
|||
349
lib/components/fabro-sandbox/src/push_credentials.rs
Normal file
349
lib/components/fabro-sandbox/src/push_credentials.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
//! Shared push-credential state for clone-based sandbox providers.
|
||||
//!
|
||||
//! Docker and Daytona embed GitHub credentials into the cloned repository's
|
||||
//! `origin` remote and refresh them before pushes. Both providers hold this
|
||||
//! state so the compare → `set-url` → record sequence, the generation
|
||||
//! tracking, and the refresh-error logging behave identically across
|
||||
//! providers. The token cache itself sits below the providers, in
|
||||
//! [`fabro_github::token_source::InstallationTokenSource`].
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_github::GitHubCredentials;
|
||||
use fabro_github::token_source::{InstallationTokenSource, ResolvedToken};
|
||||
use fabro_redact::DisplaySafeUrl;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::sandbox::RefreshOutcome;
|
||||
|
||||
/// Build the shared installation-token source for a clone-based sandbox.
|
||||
///
|
||||
/// Returns `None` when there are no managed credentials or no GitHub origin
|
||||
/// to scope them to. Minted tokens carry the same `contents: write`
|
||||
/// permission the clone token uses.
|
||||
pub(crate) fn build_token_source(
|
||||
github_app: Option<&GitHubCredentials>,
|
||||
clone_origin_url: Option<&str>,
|
||||
) -> crate::Result<Option<Arc<InstallationTokenSource>>> {
|
||||
let Some(creds) = github_app else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(origin_url) = clone_origin_url.filter(|url| !url.trim().is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let normalized = fabro_github::normalize_repo_origin_url(origin_url);
|
||||
let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&normalized) else {
|
||||
// Non-GitHub origins never clone in these providers, so there is no
|
||||
// remote to keep credentials fresh for.
|
||||
return Ok(None);
|
||||
};
|
||||
InstallationTokenSource::for_repository(
|
||||
creds,
|
||||
owner,
|
||||
repo,
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
.map(Some)
|
||||
.map_err(|err| crate::Error::context_anyhow("Failed to build GitHub token source", err))
|
||||
}
|
||||
|
||||
/// Push-credential state one provider instance tracks for its `origin`
|
||||
/// remote.
|
||||
pub(crate) struct PushCredentialState {
|
||||
source: Option<Arc<InstallationTokenSource>>,
|
||||
/// Serializes compare → `set-url` → record. The token source's
|
||||
/// single-flight ends before the sandbox exec, so without this lock a
|
||||
/// refresh-ahead tick and a push could both see the old embedded
|
||||
/// generation and race on `.git/config.lock`. Holds the last
|
||||
/// successfully embedded token: its secret is already in the remote URL
|
||||
/// inside the sandbox, so retaining it adds no exposure, and it is what
|
||||
/// a push falls back to when a refresh fails. The tracked value is local
|
||||
/// belief, not ground truth — agent code inside the sandbox can rewrite
|
||||
/// `origin`.
|
||||
embedded: Mutex<Option<ResolvedToken>>,
|
||||
}
|
||||
|
||||
impl PushCredentialState {
|
||||
pub(crate) fn new(source: Option<Arc<InstallationTokenSource>>) -> Self {
|
||||
Self {
|
||||
source,
|
||||
embedded: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source(&self) -> Option<&Arc<InstallationTokenSource>> {
|
||||
self.source.as_ref()
|
||||
}
|
||||
|
||||
/// Record the token embedded in `origin` outside the refresh path — the
|
||||
/// clone is the first operation to embed a token, and it seeds this
|
||||
/// state so the first refresh compares against the clone token instead
|
||||
/// of believing nothing was ever embedded.
|
||||
pub(crate) async fn record_embedded(&self, token: ResolvedToken) {
|
||||
*self.embedded.lock().await = Some(token);
|
||||
}
|
||||
|
||||
/// Refresh the credentials embedded in `origin`.
|
||||
///
|
||||
/// Resolves through the shared source, skips the `set-url` exec when the
|
||||
/// resolved generation is already embedded, and records the new
|
||||
/// generation only after `set_url` succeeds. `set_url` receives the
|
||||
/// authenticated URL to embed and runs under the embed lock.
|
||||
pub(crate) async fn refresh<F, Fut>(
|
||||
&self,
|
||||
origin_url: &str,
|
||||
set_url: F,
|
||||
) -> crate::Result<RefreshOutcome>
|
||||
where
|
||||
F: FnOnce(DisplaySafeUrl) -> Fut,
|
||||
Fut: Future<Output = crate::Result<()>>,
|
||||
{
|
||||
let Some(source) = &self.source else {
|
||||
return Ok(RefreshOutcome::none());
|
||||
};
|
||||
let mut embedded = self.embedded.lock().await;
|
||||
let resolved = match source.resolve().await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(err) => {
|
||||
// The refresh-error path is defined, not incidental: the push
|
||||
// proceeds with the last embedded token, so log which one
|
||||
// that is instead of losing the credential state.
|
||||
if let Some(prev) = embedded.as_ref() {
|
||||
tracing::warn!(
|
||||
error = %format!("{err:#}"),
|
||||
generation = prev.snapshot.generation,
|
||||
provenance = %prev.snapshot.provenance,
|
||||
token_age_ms = prev.snapshot.age_ms(),
|
||||
"GitHub token refresh failed; origin keeps the last embedded credentials"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
error = %format!("{err:#}"),
|
||||
"GitHub token refresh failed and no credentials were ever embedded"
|
||||
);
|
||||
}
|
||||
return Err(crate::Error::context_anyhow(
|
||||
"Failed to refresh push credentials",
|
||||
err,
|
||||
));
|
||||
}
|
||||
};
|
||||
if embedded
|
||||
.as_ref()
|
||||
.is_some_and(|prev| prev.snapshot.generation == resolved.snapshot.generation)
|
||||
{
|
||||
return Ok(RefreshOutcome::unchanged(resolved.snapshot));
|
||||
}
|
||||
let auth_url = fabro_github::embed_token_in_url(origin_url, resolved.token.expose())
|
||||
.map_err(|err| {
|
||||
crate::Error::context_anyhow("Failed to build authenticated origin URL", err)
|
||||
})?;
|
||||
set_url(auth_url).await?;
|
||||
let snapshot = resolved.snapshot;
|
||||
*embedded = Some(resolved);
|
||||
Ok(RefreshOutcome::embedded(snapshot))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use chrono::Utc;
|
||||
use fabro_github::InstallationToken;
|
||||
use fabro_github::test_support::{InstallationTokenMinter, installation_token_source};
|
||||
|
||||
use super::*;
|
||||
use crate::sandbox::RemoteCredentialAction;
|
||||
|
||||
struct FixedMinter {
|
||||
calls: AtomicUsize,
|
||||
ttl: chrono::Duration,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InstallationTokenMinter for FixedMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
Ok(InstallationToken {
|
||||
token: format!("ghs_gen{call}"),
|
||||
expires_at: Utc::now() + self.ttl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingMinter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InstallationTokenMinter for FailingMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
Err(anyhow::anyhow!("mint failed"))
|
||||
}
|
||||
}
|
||||
|
||||
fn minting_state(ttl: chrono::Duration) -> PushCredentialState {
|
||||
PushCredentialState::new(Some(installation_token_source(
|
||||
"owner/repo",
|
||||
Arc::new(FixedMinter {
|
||||
calls: AtomicUsize::new(0),
|
||||
ttl,
|
||||
}),
|
||||
)))
|
||||
}
|
||||
|
||||
const ORIGIN: &str = "https://github.com/owner/repo";
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_without_managed_credentials_reports_none() {
|
||||
let state = PushCredentialState::new(None);
|
||||
let outcome = state
|
||||
.refresh(ORIGIN, |_| async { panic!("set-url must not run") })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome, RefreshOutcome::none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_embeds_a_new_generation_and_skips_matching_ones() {
|
||||
let state = minting_state(chrono::Duration::minutes(60));
|
||||
let set_url_calls = AtomicUsize::new(0);
|
||||
|
||||
let first = state
|
||||
.refresh(ORIGIN, |auth_url| {
|
||||
set_url_calls.fetch_add(1, Ordering::SeqCst);
|
||||
assert!(auth_url.as_raw_url().as_str().contains("ghs_gen1"));
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.action(), RemoteCredentialAction::Embedded);
|
||||
assert_eq!(first.token().unwrap().generation, 1);
|
||||
|
||||
// The cached token is fresh, so the second refresh must skip set-url.
|
||||
let second = state
|
||||
.refresh(ORIGIN, |_| {
|
||||
set_url_calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.action(), RemoteCredentialAction::Unchanged);
|
||||
assert_eq!(second.token().unwrap().generation, 1);
|
||||
assert_eq!(set_url_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_embeds_again_when_the_source_mints_a_new_generation() {
|
||||
// Tokens expire inside the margin, so every resolve re-mints.
|
||||
let state = minting_state(chrono::Duration::minutes(5));
|
||||
let set_url_calls = AtomicUsize::new(0);
|
||||
|
||||
let first = state
|
||||
.refresh(ORIGIN, |_| {
|
||||
set_url_calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let second = state
|
||||
.refresh(ORIGIN, |_| {
|
||||
set_url_calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.token().unwrap().generation, 1);
|
||||
assert_eq!(second.action(), RemoteCredentialAction::Embedded);
|
||||
assert_eq!(second.token().unwrap().generation, 2);
|
||||
assert_eq!(set_url_calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_seed_makes_the_first_refresh_a_no_op() {
|
||||
let state = minting_state(chrono::Duration::minutes(60));
|
||||
let clone_token = state.source().unwrap().mint_for_clone().await.unwrap();
|
||||
state.record_embedded(clone_token).await;
|
||||
|
||||
let outcome = state
|
||||
.refresh(ORIGIN, |_| async { panic!("set-url must not run") })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.action(), RemoteCredentialAction::Unchanged);
|
||||
assert_eq!(outcome.token().unwrap().generation, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_set_url_does_not_record_the_new_generation() {
|
||||
let state = minting_state(chrono::Duration::minutes(60));
|
||||
|
||||
let err = state
|
||||
.refresh(ORIGIN, |_| async {
|
||||
Err(crate::Error::message("set-url failed"))
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("set-url failed"));
|
||||
|
||||
// The generation was not recorded, so the retry embeds again instead
|
||||
// of wrongly skipping.
|
||||
let retried = state.refresh(ORIGIN, |_| async { Ok(()) }).await.unwrap();
|
||||
assert_eq!(retried.action(), RemoteCredentialAction::Embedded);
|
||||
assert_eq!(retried.token().unwrap().generation, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_credentials_seeded_at_clone_skip_set_url() {
|
||||
let source = InstallationTokenSource::for_origin(
|
||||
&GitHubCredentials::Pat("ghp_pat".to_string()),
|
||||
ORIGIN,
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
.unwrap();
|
||||
let state = PushCredentialState::new(Some(source));
|
||||
let clone_token = state.source().unwrap().mint_for_clone().await.unwrap();
|
||||
state.record_embedded(clone_token).await;
|
||||
|
||||
let outcome = state
|
||||
.refresh(ORIGIN, |_| async { panic!("set-url must not run") })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.action(), RemoteCredentialAction::Unchanged);
|
||||
assert!(outcome.token().unwrap().is_static());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_failure_preserves_the_mint_error_chain() {
|
||||
let state = PushCredentialState::new(Some(installation_token_source(
|
||||
"owner/repo",
|
||||
Arc::new(FailingMinter),
|
||||
)));
|
||||
|
||||
let err = state
|
||||
.refresh(ORIGIN, |_| async { panic!("set-url must not run") })
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.causes(), vec![
|
||||
"minting GitHub installation access token",
|
||||
"mint failed"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_source_requires_managed_credentials_and_a_github_origin() {
|
||||
assert!(build_token_source(None, Some(ORIGIN)).unwrap().is_none());
|
||||
let pat = GitHubCredentials::Pat("ghp_pat".to_string());
|
||||
assert!(build_token_source(Some(&pat), None).unwrap().is_none());
|
||||
assert!(
|
||||
build_token_source(Some(&pat), Some("https://gitlab.com/owner/repo"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
build_token_source(Some(&pat), Some(ORIGIN))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ use std::sync::Arc;
|
|||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_github::token_source::{InstallationTokenSource, TokenSnapshot};
|
||||
use fabro_types::{CommandOutputStream, CommandTermination};
|
||||
use fabro_util::shell;
|
||||
use fabro_util::workspace_glob::WorkspaceGlob;
|
||||
|
|
@ -280,6 +281,12 @@ macro_rules! delegate_sandbox {
|
|||
self.$field.refresh_push_credentials().await
|
||||
}
|
||||
|
||||
fn push_token_source(
|
||||
&self,
|
||||
) -> Option<std::sync::Arc<$crate::InstallationTokenSource>> {
|
||||
self.$field.push_token_source()
|
||||
}
|
||||
|
||||
async fn set_autostop_interval(&self, minutes: i32) -> $crate::Result<()> {
|
||||
self.$field.set_autostop_interval(minutes).await
|
||||
}
|
||||
|
|
@ -1013,16 +1020,68 @@ pub struct GrepOptions {
|
|||
pub max_results: Option<usize>,
|
||||
}
|
||||
|
||||
/// Outcome of [`Sandbox::refresh_push_credentials`]: whether a fresh token was
|
||||
/// actually minted and applied to the origin remote, or the call was a no-op
|
||||
/// (no clone, no authenticated origin, or no GitHub App credentials to rotate).
|
||||
/// Lets callers log accurately instead of assuming every `Ok` re-minted.
|
||||
/// What [`Sandbox::refresh_push_credentials`] did to the origin remote.
|
||||
///
|
||||
/// Distinct from what the token *is* — the two are independent facts. A token
|
||||
/// minted by another consumer and embedded here for the first time is an
|
||||
/// `Embedded` action carrying a `Reused` provenance.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum RemoteCredentialAction {
|
||||
/// `set-url` ran with a different generation than last embedded.
|
||||
Embedded,
|
||||
/// The resolved generation matched the last embedded one; `set-url` was
|
||||
/// skipped.
|
||||
Unchanged,
|
||||
/// No managed credentials to embed (no clone, no authenticated origin, or
|
||||
/// no GitHub credentials).
|
||||
None,
|
||||
}
|
||||
|
||||
/// Outcome of [`Sandbox::refresh_push_credentials`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshOutcome {
|
||||
/// A fresh token was minted and the origin remote URL was updated.
|
||||
Refreshed,
|
||||
/// Nothing to refresh (no clone / no origin / no managed credentials).
|
||||
Skipped,
|
||||
/// No managed credentials exist for this sandbox.
|
||||
None,
|
||||
/// The remote already carried this token generation.
|
||||
Unchanged(TokenSnapshot),
|
||||
/// The remote was updated to carry this token generation.
|
||||
Embedded(TokenSnapshot),
|
||||
}
|
||||
|
||||
impl RefreshOutcome {
|
||||
/// No managed credentials to refresh.
|
||||
#[must_use]
|
||||
pub const fn none() -> Self {
|
||||
Self::None
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn unchanged(token: TokenSnapshot) -> Self {
|
||||
Self::Unchanged(token)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn embedded(token: TokenSnapshot) -> Self {
|
||||
Self::Embedded(token)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn action(self) -> RemoteCredentialAction {
|
||||
match self {
|
||||
Self::None => RemoteCredentialAction::None,
|
||||
Self::Unchanged(_) => RemoteCredentialAction::Unchanged,
|
||||
Self::Embedded(_) => RemoteCredentialAction::Embedded,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn token(self) -> Option<TokenSnapshot> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Unchanged(token) | Self::Embedded(token) => Some(token),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -1048,6 +1107,16 @@ pub trait Sandbox: Send + Sync {
|
|||
}
|
||||
|
||||
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()>;
|
||||
|
||||
/// Write a file that the caller has already confirmed exists.
|
||||
///
|
||||
/// Providers can override this method to skip setup that is only needed
|
||||
/// when creating a new path. The default preserves the behavior of
|
||||
/// [`Sandbox::write_file`].
|
||||
async fn write_existing_file(&self, path: &str, content: &str) -> crate::Result<()> {
|
||||
self.write_file(path, content).await
|
||||
}
|
||||
|
||||
async fn delete_file(&self, path: &str) -> crate::Result<()>;
|
||||
async fn file_exists(&self, path: &str) -> crate::Result<bool>;
|
||||
async fn list_directory(
|
||||
|
|
@ -1232,11 +1301,22 @@ pub trait Sandbox: Send + Sync {
|
|||
}
|
||||
|
||||
/// Refresh git push credentials (e.g. rotate an expiring GitHub App token).
|
||||
/// Default is a no-op; Docker/Daytona override to update the remote URL
|
||||
/// with a fresh token. Returns [`RefreshOutcome`] so callers can tell
|
||||
/// an actual re-mint from a skipped no-op.
|
||||
/// Default is a no-op; Docker/Daytona override to resolve a token through
|
||||
/// the shared source and update the remote URL when the embedded
|
||||
/// generation is stale. Returns [`RefreshOutcome`] so callers can tell
|
||||
/// what happened to the remote and which token it carries.
|
||||
async fn refresh_push_credentials(&self) -> crate::Result<RefreshOutcome> {
|
||||
Ok(RefreshOutcome::Skipped)
|
||||
Ok(RefreshOutcome::none())
|
||||
}
|
||||
|
||||
/// The shared installation-token source feeding this sandbox's push
|
||||
/// credentials, when the provider manages GitHub credentials.
|
||||
///
|
||||
/// Consumers outside the sandbox (e.g. the run-metadata writer) share
|
||||
/// this source so every GitHub-token consumer for the origin repository
|
||||
/// reuses one cached token instead of minting its own.
|
||||
fn push_token_source(&self) -> Option<Arc<InstallationTokenSource>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Set the auto-stop interval in minutes (0 to disable).
|
||||
|
|
@ -1455,6 +1535,7 @@ pub async fn setup_git_via_exec(
|
|||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "fetch"))]
|
||||
pub(crate) async fn fetch_source_run_ref(
|
||||
sandbox: &dyn Sandbox,
|
||||
source_run_id: &str,
|
||||
|
|
@ -1503,14 +1584,33 @@ pub(crate) async fn fetch_source_run_ref(
|
|||
|
||||
/// Helper for sandbox implementations that manage git internally.
|
||||
/// Pushes a refspec to origin via exec_command inside the sandbox.
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "push"))]
|
||||
pub async fn git_push_via_exec(sandbox: &dyn Sandbox, refspec: &str) -> crate::Result<()> {
|
||||
if let Err(e) = sandbox.refresh_push_credentials().await {
|
||||
tracing::warn!(
|
||||
refspec = %refspec,
|
||||
error = %crate::display_for_log(&e),
|
||||
"Failed to refresh push credentials before git push"
|
||||
);
|
||||
}
|
||||
let token = match sandbox.refresh_push_credentials().await {
|
||||
Ok(outcome) => {
|
||||
if let Some(token) = outcome.token() {
|
||||
tracing::debug!(
|
||||
refspec = %refspec,
|
||||
action = %outcome.action(),
|
||||
generation = token.generation,
|
||||
provenance = %token.provenance,
|
||||
token_age_ms = token.age_ms(),
|
||||
"Resolved push credentials before git push"
|
||||
);
|
||||
}
|
||||
outcome.token()
|
||||
}
|
||||
Err(e) => {
|
||||
// The provider logged which token stays embedded; the push
|
||||
// proceeds with the old origin URL.
|
||||
tracing::warn!(
|
||||
refspec = %refspec,
|
||||
error = %crate::display_for_log(&e),
|
||||
"Failed to refresh push credentials before git push"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let cmd = format!("{GIT} push origin {}", shell_quote(refspec));
|
||||
let label = format!("git push origin {refspec}");
|
||||
sandbox
|
||||
|
|
@ -1518,7 +1618,12 @@ pub async fn git_push_via_exec(sandbox: &dyn Sandbox, refspec: &str) -> crate::R
|
|||
.await
|
||||
.map_err(|e| crate::Error::context(label.clone(), e))?
|
||||
.into_result(&label)?;
|
||||
tracing::info!(refspec = %refspec, "Pushed git ref to origin");
|
||||
tracing::info!(
|
||||
refspec = %refspec,
|
||||
token_generation = token.map(|token| token.generation),
|
||||
token_age_ms = token.and_then(|token| token.age_ms()),
|
||||
"Pushed git ref to origin"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ impl SandboxSpec {
|
|||
} => {
|
||||
let mut sandbox = DockerSandbox::new(
|
||||
config.clone(),
|
||||
github_app.clone(),
|
||||
github_app.as_ref(),
|
||||
*run_id,
|
||||
clone_origin_url.clone(),
|
||||
clone_branch.clone(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -29,6 +29,8 @@ pub struct MockSandbox {
|
|||
pub os_version_str: String,
|
||||
/// Captures (path, content) pairs from `write_file` calls.
|
||||
pub written_files: Mutex<Vec<(String, String)>>,
|
||||
/// Counts calls to `write_existing_file`.
|
||||
pub existing_file_writes: AtomicUsize,
|
||||
/// Captures the `timeout_ms` argument from `exec_command` calls.
|
||||
pub captured_timeout: Mutex<Option<u64>>,
|
||||
/// Captures the `command` argument from `exec_command` calls (last only).
|
||||
|
|
@ -104,6 +106,10 @@ impl MockSandbox {
|
|||
.expect("delete_calls lock poisoned")
|
||||
}
|
||||
|
||||
pub fn existing_file_write_count(&self) -> usize {
|
||||
self.existing_file_writes.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_stdio_process(&self, process: MockStdioProcess) {
|
||||
*self
|
||||
.stdio_process
|
||||
|
|
@ -156,6 +162,7 @@ impl Default for MockSandbox {
|
|||
platform_str: "darwin",
|
||||
os_version_str: "Darwin 24.0.0".into(),
|
||||
written_files: Mutex::new(Vec::new()),
|
||||
existing_file_writes: AtomicUsize::new(0),
|
||||
captured_timeout: Mutex::new(None),
|
||||
captured_command: Mutex::new(None),
|
||||
captured_commands: Mutex::new(Vec::new()),
|
||||
|
|
@ -250,6 +257,11 @@ impl Sandbox for MockSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_existing_file(&self, path: &str, content: &str) -> crate::Result<()> {
|
||||
self.existing_file_writes.fetch_add(1, Ordering::Relaxed);
|
||||
self.write_file(path, content).await
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _path: &str) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
301
lib/components/fabro-store/src/blob_store.rs
Normal file
301
lib/components/fabro-store/src/blob_store.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use fabro_types::BlobHash;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::record::{RawBytesCodec, Record, Repository};
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Blob(pub Bytes);
|
||||
|
||||
impl AsRef<[u8]> for Blob {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Bytes> for Blob {
|
||||
fn from(value: Bytes) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for Blob {
|
||||
type Id = BlobHash;
|
||||
type Codec = RawBytesCodec;
|
||||
|
||||
const PREFIX: &'static str = "blobs/sha256";
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
BlobHash::new(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which storage engine holds the blobs.
|
||||
///
|
||||
/// This enum is a transition vehicle, not a permanent abstraction: `Slate`
|
||||
/// preserves current production behavior while the SQLite backend rolls out.
|
||||
/// Once runtime blob storage switches to SQLite and legacy blobs are
|
||||
/// imported, delete the `Slate` arm (and this enum) and inline the SQLite
|
||||
/// implementation into [`BlobStore`]. The SQLite arm's semantics — verified
|
||||
/// reads and loud failure on hash conflicts — are the intended end state.
|
||||
enum BlobBackend {
|
||||
Slate(Repository<Blob>),
|
||||
Sqlite(SqlitePool),
|
||||
}
|
||||
|
||||
pub struct BlobStore {
|
||||
backend: BlobBackend,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BlobStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let backend = match &self.backend {
|
||||
BlobBackend::Slate(_) => "slate",
|
||||
BlobBackend::Sqlite(_) => "sqlite",
|
||||
};
|
||||
f.debug_struct("BlobStore")
|
||||
.field("backend", &backend)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl BlobStore {
|
||||
/// Creates a blob store backed by a SQLite pool whose migrations have run.
|
||||
#[must_use]
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
backend: BlobBackend::Sqlite(pool),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_slate(db: Arc<slatedb::Db>) -> Self {
|
||||
Self {
|
||||
backend: BlobBackend::Slate(Repository::new(db)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(&self, bytes: &[u8]) -> Result<BlobHash> {
|
||||
match &self.backend {
|
||||
BlobBackend::Slate(repo) => {
|
||||
let blob = Blob(Bytes::copy_from_slice(bytes));
|
||||
let id = blob.id();
|
||||
repo.put(&blob).await?;
|
||||
Ok(id)
|
||||
}
|
||||
BlobBackend::Sqlite(pool) => {
|
||||
let blob_hash = BlobHash::new(bytes);
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO blobs (hash, data) VALUES (?, ?) \
|
||||
ON CONFLICT(hash) DO NOTHING",
|
||||
)
|
||||
.bind(blob_hash.to_string())
|
||||
.bind(bytes)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 1 {
|
||||
return Ok(blob_hash);
|
||||
}
|
||||
|
||||
let stored: Vec<u8> = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
|
||||
.bind(blob_hash.to_string())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if stored == bytes {
|
||||
Ok(blob_hash)
|
||||
} else {
|
||||
Err(Error::BlobHashConflict { blob_hash })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
match &self.backend {
|
||||
BlobBackend::Slate(repo) => Ok(repo.get(blob_hash).await?.map(|blob| blob.0)),
|
||||
BlobBackend::Sqlite(pool) => {
|
||||
let stored: Option<Vec<u8>> =
|
||||
sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
|
||||
.bind(blob_hash.to_string())
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let Some(stored) = stored else {
|
||||
return Ok(None);
|
||||
};
|
||||
if BlobHash::new(&stored) != *blob_hash {
|
||||
return Err(Error::BlobIntegrity {
|
||||
blob_hash: *blob_hash,
|
||||
});
|
||||
}
|
||||
Ok(Some(Bytes::from(stored)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exists(&self, blob_hash: &BlobHash) -> Result<bool> {
|
||||
match &self.backend {
|
||||
BlobBackend::Slate(repo) => repo.exists(blob_hash).await,
|
||||
BlobBackend::Sqlite(pool) => {
|
||||
let exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM blobs WHERE hash = ?)")
|
||||
.bind(blob_hash.to_string())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(exists)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use fabro_types::BlobHash;
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::BlobStore;
|
||||
use crate::keys::SlateKey;
|
||||
use crate::{Database, Error};
|
||||
|
||||
type TestResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
async fn slate_store() -> Arc<BlobStore> {
|
||||
let db = Database::new(
|
||||
Arc::new(InMemory::new()),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
db.blobs().await.unwrap()
|
||||
}
|
||||
|
||||
async fn raw_slate_store(name: &str) -> (Arc<slatedb::Db>, BlobStore) {
|
||||
let raw_db = Arc::new(
|
||||
slatedb::Db::open(name, Arc::new(InMemory::new()))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let store = BlobStore::from_slate(raw_db.clone());
|
||||
(raw_db, store)
|
||||
}
|
||||
|
||||
async fn sqlite_store() -> TestResult<(tempfile::TempDir, fabro_db::Database, BlobStore)> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let database = fabro_db::Database::connect(dir.path().join("fabro.sqlite3")).await?;
|
||||
database.migrate().await?;
|
||||
let store = BlobStore::new(database.clone_pool());
|
||||
Ok((dir, database, store))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slate_writes_reads_and_checks_existence() {
|
||||
let store = slate_store().await;
|
||||
let bytes = b"hello world";
|
||||
let id = store.write(bytes).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.read(&id).await.unwrap(),
|
||||
Some(Bytes::from_static(bytes))
|
||||
);
|
||||
assert_eq!(store.write(bytes).await.unwrap(), id);
|
||||
assert!(store.exists(&id).await.unwrap());
|
||||
assert!(!store.exists(&BlobHash::new(b"missing")).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slate_empty_blobs_round_trip() {
|
||||
let store = slate_store().await;
|
||||
let id = store.write(b"").await.unwrap();
|
||||
|
||||
assert_eq!(store.read(&id).await.unwrap(), Some(Bytes::new()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_slate_db_reads_exact_blob_bytes() {
|
||||
let (raw_db, store) = raw_slate_store("blob-store-tests").await;
|
||||
let bytes = b"{\"ok\":true}";
|
||||
let id = store.write(bytes).await.unwrap();
|
||||
|
||||
let saved = raw_db
|
||||
.get(SlateKey::new("blobs").with("sha256").with(id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(saved.as_ref(), bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_writes_reads_and_checks_existence() -> TestResult<()> {
|
||||
let (_dir, database, store) = sqlite_store().await?;
|
||||
let store = Arc::new(store);
|
||||
|
||||
let binary = [0_u8, 0xff, 0x80, b'a'];
|
||||
let (first_write, concurrent_write) =
|
||||
tokio::join!(store.write(&binary), store.write(&binary));
|
||||
let binary_hash = first_write?;
|
||||
assert_eq!(concurrent_write?, binary_hash);
|
||||
let empty_hash = store.write(b"").await?;
|
||||
|
||||
assert_eq!(store.write(&binary).await?, binary_hash);
|
||||
assert_eq!(
|
||||
store.read(&binary_hash).await?,
|
||||
Some(Bytes::copy_from_slice(&binary))
|
||||
);
|
||||
assert_eq!(store.read(&empty_hash).await?, Some(Bytes::new()));
|
||||
assert!(store.exists(&binary_hash).await?);
|
||||
let missing_hash = BlobHash::new(b"missing");
|
||||
assert_eq!(store.read(&missing_hash).await?, None);
|
||||
assert!(!store.exists(&missing_hash).await?);
|
||||
|
||||
let row_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blobs")
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
assert_eq!(row_count, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_write_rejects_conflicting_stored_bytes() -> TestResult<()> {
|
||||
let (_dir, database, store) = sqlite_store().await?;
|
||||
let expected = b"expected";
|
||||
let blob_hash = BlobHash::new(expected);
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(blob_hash.to_string())
|
||||
.bind(b"different".as_slice())
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
|
||||
let error = store
|
||||
.write(expected)
|
||||
.await
|
||||
.expect_err("conflicting bytes should fail");
|
||||
assert!(
|
||||
matches!(error, Error::BlobHashConflict { blob_hash: value } if value == blob_hash)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_read_rejects_bytes_that_do_not_match_hash() -> TestResult<()> {
|
||||
let (_dir, database, store) = sqlite_store().await?;
|
||||
let blob_hash = BlobHash::new(b"expected");
|
||||
sqlx::query("INSERT INTO blobs (hash, data) VALUES (?, ?)")
|
||||
.bind(blob_hash.to_string())
|
||||
.bind(b"different".as_slice())
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
|
||||
let error = store
|
||||
.read(&blob_hash)
|
||||
.await
|
||||
.expect_err("mismatched stored bytes should fail");
|
||||
assert!(matches!(error, Error::BlobIntegrity { blob_hash: value } if value == blob_hash));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
use fabro_types::BlobHash;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -10,6 +12,10 @@ pub enum Error {
|
|||
Serde(#[from] serde_json::Error),
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(#[from] sqlx::Error),
|
||||
#[error("stored blob {blob_hash} has bytes that conflict with its hash")]
|
||||
BlobHashConflict { blob_hash: BlobHash },
|
||||
#[error("stored blob data does not match requested hash {blob_hash}")]
|
||||
BlobIntegrity { blob_hash: BlobHash },
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Invalid event payload: {0}")]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::fmt::{self, Write};
|
||||
use std::ops::Range;
|
||||
|
||||
use fabro_types::{RunBlobId, RunId, SessionId};
|
||||
use fabro_types::{RunId, SessionId};
|
||||
|
||||
pub(crate) const MAX_EVENT_SEQ: u32 = 999_999;
|
||||
|
||||
|
|
@ -91,10 +91,6 @@ pub(crate) fn run_events_range(run_id: &RunId, start_seq: u32) -> Range<SlateKey
|
|||
run_event_seq_prefix(run_id, start_seq)..end
|
||||
}
|
||||
|
||||
pub(crate) fn blobs_prefix() -> SlateKey {
|
||||
SlateKey::new("blobs").with("sha256").into_prefix()
|
||||
}
|
||||
|
||||
pub(crate) fn sessions_by_id_prefix() -> SlateKey {
|
||||
SlateKey::new("sessions").with("by-id").into_prefix()
|
||||
}
|
||||
|
|
@ -115,21 +111,6 @@ pub(crate) fn parse_event_seq(key: &str) -> Option<u32> {
|
|||
segments.next()?.split_once('-')?.0.parse().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_blob_id(key: &str) -> Option<RunBlobId> {
|
||||
let mut segments = SlateKey::segments(key);
|
||||
if segments.next()? != "blobs" {
|
||||
return None;
|
||||
}
|
||||
if segments.next()? != "sha256" {
|
||||
return None;
|
||||
}
|
||||
let id = segments.next()?;
|
||||
if segments.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
id.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::RunId;
|
||||
|
|
@ -161,14 +142,6 @@ mod tests {
|
|||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_key_segments() {
|
||||
let blob_id = RunBlobId::new(b"summary");
|
||||
let key = SlateKey::new("blobs").with("sha256").with(blob_id);
|
||||
let segments: Vec<&str> = SlateKey::segments(key.as_str()).collect();
|
||||
assert_eq!(segments, ["blobs", "sha256", &blob_id.to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_keys_are_zero_padded() {
|
||||
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
|
||||
|
|
@ -196,20 +169,16 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parse_helpers_roundtrip() {
|
||||
fn parse_event_seq_roundtrips() {
|
||||
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
|
||||
assert_eq!(
|
||||
parse_event_seq(run_event_key(&run_id, 7, 123).as_str()),
|
||||
Some(7)
|
||||
);
|
||||
|
||||
let blob_id = RunBlobId::new(b"summary");
|
||||
let key = SlateKey::new("blobs").with("sha256").with(blob_id);
|
||||
assert_eq!(parse_blob_id(key.as_str()), Some(blob_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_helpers_reject_invalid_keys() {
|
||||
fn parse_event_seq_rejects_invalid_keys() {
|
||||
assert_eq!(
|
||||
parse_event_seq(
|
||||
SlateKey::new("runs")
|
||||
|
|
@ -220,18 +189,5 @@ mod tests {
|
|||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_blob_id(SlateKey::new("blobs").with("not-a-uuid").as_str()),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_blob_id(
|
||||
SlateKey::new("blobs")
|
||||
.with("01JT56VE4Z5NZ814GZN2JZD65A")
|
||||
.with("not-a-blob")
|
||||
.as_str()
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
|
||||
mod artifact_store;
|
||||
mod blob_store;
|
||||
mod error;
|
||||
mod keyed_mutex;
|
||||
mod keys;
|
||||
|
|
@ -18,9 +19,10 @@ pub use artifact_store::{
|
|||
ArtifactKey, ArtifactStore, NodeArtifact, StageArtifactEntry, retry_storage_segment,
|
||||
stage_storage_segment,
|
||||
};
|
||||
pub use blob_store::{Blob, BlobStore};
|
||||
pub use error::{Error, Result};
|
||||
pub use fabro_types::{
|
||||
EventEnvelope, PendingInterviewRecord, Run, RunBlobId, RunProjection, StageId, StageProjection,
|
||||
BlobHash, EventEnvelope, PendingInterviewRecord, Run, RunProjection, StageId, StageProjection,
|
||||
};
|
||||
pub use keyed_mutex::{KeyedMutex, KeyedMutexGuard};
|
||||
pub use run_sessions::{
|
||||
|
|
@ -34,8 +36,8 @@ pub use run_summary_store::{
|
|||
};
|
||||
pub use serializable_projection::SerializableProjection;
|
||||
pub use slate::{
|
||||
AuthCode, AuthCodeStore, Blob, BlobStore, CachedRunProjection, ConsumeOutcome, Database,
|
||||
RefreshToken, RefreshTokenStore, RunCatalogIndex, RunDatabase, Runs, UnreadableRun,
|
||||
AuthCode, AuthCodeStore, CachedRunProjection, ConsumeOutcome, Database, RefreshToken,
|
||||
RefreshTokenStore, RunCatalogIndex, RunDatabase, Runs, UnreadableRun,
|
||||
};
|
||||
pub use types::EventPayload;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use fabro_types::{RunBlobId, RunId};
|
||||
use fabro_types::{BlobHash, RunId};
|
||||
|
||||
use super::RecordId;
|
||||
use crate::{Error, Result};
|
||||
|
|
@ -38,7 +38,7 @@ impl RecordId for String {
|
|||
}
|
||||
}
|
||||
|
||||
impl RecordId for RunBlobId {
|
||||
impl RecordId for BlobHash {
|
||||
fn key_segments(&self) -> Vec<String> {
|
||||
vec![self.to_string()]
|
||||
}
|
||||
|
|
@ -46,13 +46,13 @@ impl RecordId for RunBlobId {
|
|||
fn from_key_segments(segs: &[&str]) -> Result<Self> {
|
||||
let [segment] = segs else {
|
||||
return Err(Error::KeyParse(format!(
|
||||
"expected 1 segment for RunBlobId, got {}",
|
||||
"expected 1 segment for BlobHash, got {}",
|
||||
segs.len()
|
||||
)));
|
||||
};
|
||||
segment
|
||||
.parse()
|
||||
.map_err(|err| Error::KeyParse(format!("invalid RunBlobId segment {segment:?}: {err}")))
|
||||
.map_err(|err| Error::KeyParse(format!("invalid BlobHash segment {segment:?}: {err}")))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1360,8 +1360,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
|||
.conclusion
|
||||
.as_ref()
|
||||
.map(|conclusion| conclusion.timing);
|
||||
let terminal_total = terminal_total_usd_micros(state);
|
||||
let current_total = projected_billing(state).total_usd_micros;
|
||||
let total_usd_micros = projected_billing(state).total_usd_micros;
|
||||
|
||||
Run {
|
||||
id: *run_id,
|
||||
|
|
@ -1405,10 +1404,10 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
|||
completed_at,
|
||||
},
|
||||
timing: run_timing,
|
||||
billing: terminal_total.map(|total_usd_micros| RunBillingSummary {
|
||||
billing: total_usd_micros.map(|total_usd_micros| RunBillingSummary {
|
||||
total_usd_micros: Some(total_usd_micros),
|
||||
}),
|
||||
size: RunSize::from_total_usd_micros(current_total),
|
||||
size: RunSize::from_total_usd_micros(total_usd_micros),
|
||||
ask_fabro: AskFabro::default(),
|
||||
diff: diff_summary,
|
||||
pull_request: state.pull_request.clone(),
|
||||
|
|
@ -1421,14 +1420,6 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
|||
}
|
||||
}
|
||||
|
||||
fn terminal_total_usd_micros(state: &RunProjection) -> Option<i64> {
|
||||
state
|
||||
.conclusion
|
||||
.as_ref()
|
||||
.and_then(|conclusion| conclusion.billing.as_ref())
|
||||
.and_then(|billing| billing.total_usd_micros)
|
||||
}
|
||||
|
||||
pub(crate) fn projected_billing(state: &RunProjection) -> BilledTokenCounts {
|
||||
if let Some(billing) = state
|
||||
.conclusion
|
||||
|
|
@ -1690,15 +1681,16 @@ mod tests {
|
|||
use fabro_types::settings::run::{DockerfileSource, EnvironmentProvider};
|
||||
use fabro_types::{
|
||||
AgentBackend, AgentControlState, AttrValue, AutomationRef, BilledModelUsage,
|
||||
BilledTokenCounts, BlockedReason, Checkpoint, CheckpointRecord, CommandTermination,
|
||||
EventBody, FailureCategory, FailureDetail, FailureReason, Graph, McpServerStatus, Node,
|
||||
Outcome, ParallelBranchId, PendingReason, PermissionLevel, PullRequestCreationStatus,
|
||||
PullRequestLink, QuestionType, ReasoningEffort, RunApprovalState, RunBlobId,
|
||||
RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, RunStatus, Speed,
|
||||
StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
|
||||
StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning,
|
||||
StageHandler, StageModelUsage, StageOutcome, StageState, StageTiming, SubAgentStatus,
|
||||
SuccessReason, WorkflowSettings, first_event_seq, fixtures, test_support,
|
||||
BilledTokenCounts, BlobHash, BlockedReason, Checkpoint, CheckpointRecord,
|
||||
CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph,
|
||||
McpServerStatus, Node, Outcome, ParallelBranchId, PendingReason, PermissionLevel,
|
||||
PullRequestCreationStatus, PullRequestLink, QuestionType, ReasoningEffort,
|
||||
RunApprovalState, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec,
|
||||
RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory,
|
||||
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
|
||||
StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageState,
|
||||
StageTiming, SubAgentStatus, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
|
||||
test_support,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -2281,19 +2273,8 @@ mod tests {
|
|||
|
||||
fn test_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: Some("digraph test {}".to_string()),
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
graph_source: Some("digraph test {}".to_string()),
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4086,19 +4067,9 @@ mod tests {
|
|||
fn summary_synthesizes_submitted_when_run_exists_without_status() {
|
||||
let mut state = initialized_projection();
|
||||
state.spec = fabro_types::RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: fabro_types::Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
automation: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
source_directory: Some("/tmp/repo".to_string()),
|
||||
git: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
};
|
||||
|
||||
let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap();
|
||||
|
|
@ -4112,19 +4083,10 @@ mod tests {
|
|||
fn summary_preserves_absent_workflow_name_and_reports_graph_name() {
|
||||
let mut state = initialized_projection();
|
||||
state.spec = fabro_types::RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: fabro_types::Graph::new("GraphName"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("release-flow".to_string()),
|
||||
automation: None,
|
||||
graph: fabro_types::Graph::new("GraphName"),
|
||||
workflow_slug: Some("release-flow".to_string()),
|
||||
source_directory: Some("/tmp/repo".to_string()),
|
||||
git: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
};
|
||||
|
||||
let summary = build_summary(&state, &fixtures::RUN_1);
|
||||
|
|
@ -4238,9 +4200,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn projection_serialization_includes_manifest_and_definition_blob_refs() {
|
||||
let manifest_blob = RunBlobId::new(br#"{"version":1}"#).to_string();
|
||||
let manifest_blob = BlobHash::new(br#"{"version":1}"#).to_string();
|
||||
let definition_blob =
|
||||
RunBlobId::new(br#"{"version":1,"workflow_path":"workflow.fabro"}"#).to_string();
|
||||
BlobHash::new(br#"{"version":1,"workflow_path":"workflow.fabro"}"#).to_string();
|
||||
let events = vec![
|
||||
EventEnvelope {
|
||||
seq: 1,
|
||||
|
|
@ -5284,7 +5246,12 @@ mod tests {
|
|||
|
||||
let summary = build_summary(&state, &fixtures::RUN_1);
|
||||
assert_eq!(summary.size, RunSize::S);
|
||||
assert_eq!(summary.billing, None);
|
||||
assert_eq!(
|
||||
summary.billing,
|
||||
Some(RunBillingSummary {
|
||||
total_usd_micros: Some(20_000_001),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use fabro_types::RunBlobId;
|
||||
|
||||
use crate::Result;
|
||||
use crate::record::{RawBytesCodec, Record, Repository};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Blob(pub Bytes);
|
||||
|
||||
impl AsRef<[u8]> for Blob {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Bytes> for Blob {
|
||||
fn from(value: Bytes) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Record for Blob {
|
||||
type Id = RunBlobId;
|
||||
type Codec = RawBytesCodec;
|
||||
|
||||
const PREFIX: &'static str = "blobs/sha256";
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
RunBlobId::new(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BlobStore {
|
||||
repo: Repository<Blob>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BlobStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BlobStore").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl BlobStore {
|
||||
pub(crate) fn new(db: Arc<slatedb::Db>) -> Self {
|
||||
Self {
|
||||
repo: Repository::new(db),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(&self, bytes: &[u8]) -> Result<RunBlobId> {
|
||||
let blob = Blob(Bytes::copy_from_slice(bytes));
|
||||
let id = blob.id();
|
||||
self.repo.put(&blob).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn read(&self, id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
Ok(self.repo.get(id).await?.map(|blob| blob.0))
|
||||
}
|
||||
|
||||
pub async fn exists(&self, id: &RunBlobId) -> Result<bool> {
|
||||
self.repo.exists(id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use fabro_types::RunBlobId;
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::BlobStore;
|
||||
use crate::Database;
|
||||
use crate::keys::SlateKey;
|
||||
|
||||
async fn store() -> Arc<BlobStore> {
|
||||
let db = Database::new(
|
||||
Arc::new(InMemory::new()),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
db.blobs().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn writes_reads_and_checks_existence() {
|
||||
let store = store().await;
|
||||
let bytes = b"hello world";
|
||||
let id = store.write(bytes).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.read(&id).await.unwrap(),
|
||||
Some(Bytes::from_static(bytes))
|
||||
);
|
||||
assert_eq!(store.write(bytes).await.unwrap(), id);
|
||||
assert!(store.exists(&id).await.unwrap());
|
||||
assert!(!store.exists(&RunBlobId::new(b"missing")).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_blobs_round_trip() {
|
||||
let store = store().await;
|
||||
let id = store.write(b"").await.unwrap();
|
||||
|
||||
assert_eq!(store.read(&id).await.unwrap(), Some(Bytes::new()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_db_reads_exact_blob_bytes() {
|
||||
let raw_db = Arc::new(
|
||||
slatedb::Db::open("blob-store-tests", Arc::new(InMemory::new()))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let store = BlobStore::new(Arc::clone(&raw_db));
|
||||
let bytes = b"{\"ok\":true}";
|
||||
let id = store.write(bytes).await.unwrap();
|
||||
|
||||
let saved = raw_db
|
||||
.get(SlateKey::new("blobs").with("sha256").with(id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(saved.as_ref(), bytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
mod auth_codes;
|
||||
mod auth_tokens;
|
||||
mod blob_store;
|
||||
mod projection_cache;
|
||||
mod run_catalog_index;
|
||||
mod run_store;
|
||||
|
|
@ -12,7 +11,6 @@ use std::time::Duration;
|
|||
|
||||
pub use auth_codes::{AuthCode, AuthCodeStore};
|
||||
pub use auth_tokens::{ConsumeOutcome, RefreshToken, RefreshTokenStore};
|
||||
pub use blob_store::{Blob, BlobStore};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::{Run, RunId, SessionId};
|
||||
use object_store::ObjectStore;
|
||||
|
|
@ -25,7 +23,7 @@ use slatedb::config::{CompressionCodec, Settings};
|
|||
use tokio::sync::{Mutex, OnceCell};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{Error, ListRunsQuery, Result, RunProjection, RunSummaryStore, keys};
|
||||
use crate::{BlobStore, Error, ListRunsQuery, Result, RunProjection, RunSummaryStore, keys};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnreadableRun {
|
||||
|
|
@ -143,18 +141,23 @@ impl Database {
|
|||
.map(RunDatabase::from_inner)
|
||||
}
|
||||
|
||||
pub async fn create_run(&self, run_id: &RunId) -> Result<RunDatabase> {
|
||||
self.warm_projection_cache().await?;
|
||||
let db = self.open_db().await?;
|
||||
|
||||
self.catalog_index().await?.add(run_id).await?;
|
||||
let run_store = RunDatabase::open_writer(
|
||||
/// Builds a run handle wired to the Database-owned shared stores.
|
||||
async fn open_run_database(&self, run_id: &RunId, read_only: bool) -> Result<RunDatabase> {
|
||||
RunDatabase::build(
|
||||
*run_id,
|
||||
db,
|
||||
self.open_db().await?,
|
||||
read_only,
|
||||
self.blobs().await?,
|
||||
Arc::clone(&self.projection_cache),
|
||||
Arc::clone(&self.run_summary_store),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_run(&self, run_id: &RunId) -> Result<RunDatabase> {
|
||||
self.warm_projection_cache().await?;
|
||||
self.catalog_index().await?.add(run_id).await?;
|
||||
let run_store = self.open_run_database(run_id, false).await?;
|
||||
let mut active_runs = self.active_runs.lock().await;
|
||||
Self::cache_active_run(&mut active_runs, &run_store);
|
||||
Ok(run_store)
|
||||
|
|
@ -178,13 +181,7 @@ impl Database {
|
|||
if !RunDatabase::has_any_events(&db, run_id).await? {
|
||||
return Err(Error::RunNotFound(run_id.to_string()));
|
||||
}
|
||||
let run_store = RunDatabase::open_writer(
|
||||
*run_id,
|
||||
db,
|
||||
Arc::clone(&self.projection_cache),
|
||||
Arc::clone(&self.run_summary_store),
|
||||
)
|
||||
.await?;
|
||||
let run_store = self.open_run_database(run_id, false).await?;
|
||||
Self::cache_active_run(&mut active_runs, &run_store);
|
||||
Ok(run_store)
|
||||
}
|
||||
|
|
@ -202,13 +199,7 @@ impl Database {
|
|||
if !RunDatabase::has_any_events(&db, run_id).await? {
|
||||
return Err(Error::RunNotFound(run_id.to_string()));
|
||||
}
|
||||
RunDatabase::open_reader(
|
||||
*run_id,
|
||||
db,
|
||||
Arc::clone(&self.projection_cache),
|
||||
Arc::clone(&self.run_summary_store),
|
||||
)
|
||||
.await
|
||||
self.open_run_database(run_id, true).await
|
||||
}
|
||||
|
||||
pub async fn list_runs(&self, query: &ListRunsQuery, now: DateTime<Utc>) -> Result<Vec<Run>> {
|
||||
|
|
@ -456,7 +447,7 @@ impl Database {
|
|||
.blobs
|
||||
.get_or_try_init(|| async {
|
||||
let db = Arc::new(self.open_db().await?);
|
||||
Ok::<_, Error>(Arc::new(BlobStore::new(db)))
|
||||
Ok::<_, Error>(Arc::new(BlobStore::from_slate(db)))
|
||||
})
|
||||
.await?;
|
||||
Ok(Arc::clone(store))
|
||||
|
|
@ -843,12 +834,12 @@ mod tests {
|
|||
append_created(&run_2, "run-2", dt("2026-03-27T12:00:10Z")).await;
|
||||
|
||||
let shared_blob = br#"{"summary":"shared"}"#;
|
||||
let shared_blob_id = run_1.write_blob(shared_blob).await.unwrap();
|
||||
let shared_blob_hash = run_1.write_blob(shared_blob).await.unwrap();
|
||||
|
||||
store.delete_run(&test_run_id("run-1")).await.unwrap();
|
||||
|
||||
let reopened = store.open_run(&test_run_id("run-2")).await.unwrap();
|
||||
let read = reopened.read_blob(&shared_blob_id).await.unwrap();
|
||||
let read = reopened.read_blob(&shared_blob_hash).await.unwrap();
|
||||
assert_eq!(read.as_deref(), Some(shared_blob.as_slice()));
|
||||
}
|
||||
|
||||
|
|
@ -857,8 +848,21 @@ mod tests {
|
|||
let (_object_store, store) = make_store();
|
||||
let run = store.create_run(&test_run_id("run-1")).await.unwrap();
|
||||
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
|
||||
let blob = br#"{"summary":"readable"}"#;
|
||||
let blob_hash = run.write_blob(blob).await.unwrap();
|
||||
|
||||
// Evict the cached writer so the reader is built through the real
|
||||
// `open_run_reader` construction path, not a clone of the writer.
|
||||
let _ = store.remove_active_run(&test_run_id("run-1")).await;
|
||||
|
||||
let reader = store.open_run_reader(&test_run_id("run-1")).await.unwrap();
|
||||
assert_eq!(
|
||||
reader.read_blob(&blob_hash).await.unwrap().as_deref(),
|
||||
Some(blob.as_slice())
|
||||
);
|
||||
let err = reader.write_blob(b"blocked").await.unwrap_err();
|
||||
assert!(matches!(err, Error::ReadOnly));
|
||||
|
||||
let err = reader
|
||||
.append_event(&event_payload(
|
||||
"run-1",
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ use std::sync::{Arc, OnceLock};
|
|||
|
||||
use bytes::Bytes;
|
||||
use chrono::Utc;
|
||||
use fabro_types::{RunBlobId, RunEvent, RunId, SessionId};
|
||||
use fabro_types::{BlobHash, RunEvent, RunId, SessionId};
|
||||
use futures::Stream;
|
||||
use slatedb::{Db, DbIterator, DbRead};
|
||||
use tokio::sync::{Mutex, broadcast, mpsc};
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
use tracing::warn;
|
||||
|
||||
use super::blob_store::BlobStore;
|
||||
use super::projection_cache::{CachedRunProjection, RunProjectionCache};
|
||||
use crate::run_state::{EventProjectionCache, RunProjectionReducer};
|
||||
use crate::{
|
||||
Error, EventEnvelope, EventPayload, Result, RunProjection, RunSummaryStore, StageId, keys,
|
||||
BlobStore, Error, EventEnvelope, EventPayload, Result, RunProjection, RunSummaryStore, StageId,
|
||||
keys,
|
||||
};
|
||||
|
||||
const DEFAULT_EVENT_TAIL_LIMIT: usize = 1024;
|
||||
|
|
@ -37,7 +37,7 @@ impl std::fmt::Debug for RunDatabase {
|
|||
pub(crate) struct RunDatabaseInner {
|
||||
run_id: RunId,
|
||||
db: Db,
|
||||
blob_store: BlobStore,
|
||||
blob_store: Arc<BlobStore>,
|
||||
// `None` for reader-built inners: readers never append, so they carry no
|
||||
// next-write sequence and any append through them fails as read-only.
|
||||
event_seq: Option<AtomicU32>,
|
||||
|
|
@ -54,35 +54,11 @@ pub(crate) struct RunDatabaseInner {
|
|||
}
|
||||
|
||||
impl RunDatabase {
|
||||
pub(crate) async fn open_writer(
|
||||
run_id: RunId,
|
||||
db: Db,
|
||||
shared_projection_cache: Arc<RunProjectionCache>,
|
||||
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
|
||||
) -> Result<Self> {
|
||||
Self::build(
|
||||
run_id,
|
||||
db,
|
||||
false,
|
||||
shared_projection_cache,
|
||||
run_summary_store,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn open_reader(
|
||||
run_id: RunId,
|
||||
db: Db,
|
||||
shared_projection_cache: Arc<RunProjectionCache>,
|
||||
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
|
||||
) -> Result<Self> {
|
||||
Self::build(run_id, db, true, shared_projection_cache, run_summary_store).await
|
||||
}
|
||||
|
||||
async fn build(
|
||||
pub(crate) async fn build(
|
||||
run_id: RunId,
|
||||
db: Db,
|
||||
read_only: bool,
|
||||
blob_store: Arc<BlobStore>,
|
||||
shared_projection_cache: Arc<RunProjectionCache>,
|
||||
run_summary_store: Arc<OnceLock<Arc<RunSummaryStore>>>,
|
||||
) -> Result<Self> {
|
||||
|
|
@ -106,7 +82,6 @@ impl RunDatabase {
|
|||
Some(AtomicU32::new(next_seq))
|
||||
};
|
||||
let (event_tx, _) = broadcast::channel(DEFAULT_EVENT_TAIL_LIMIT.max(16));
|
||||
let blob_store = BlobStore::new(Arc::new(db.clone()));
|
||||
Ok(Self {
|
||||
inner: Arc::new(RunDatabaseInner {
|
||||
run_id,
|
||||
|
|
@ -579,19 +554,15 @@ impl RunDatabase {
|
|||
Ok(Box::pin(UnboundedReceiverStream::new(receiver)))
|
||||
}
|
||||
|
||||
pub async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
pub async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
if self.read_only {
|
||||
return Err(Error::ReadOnly);
|
||||
}
|
||||
self.inner.blob_store.write(data).await
|
||||
}
|
||||
|
||||
pub async fn read_blob(&self, id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
self.inner.blob_store.read(id).await
|
||||
}
|
||||
|
||||
pub async fn list_blobs(&self) -> Result<Vec<RunBlobId>> {
|
||||
list_blobs(&self.inner.db).await
|
||||
pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.inner.blob_store.read(blob_hash).await
|
||||
}
|
||||
|
||||
pub async fn state(&self) -> Result<RunProjection> {
|
||||
|
|
@ -904,23 +875,6 @@ where
|
|||
Ok(events)
|
||||
}
|
||||
|
||||
async fn list_blobs<R>(db: &R) -> Result<Vec<RunBlobId>>
|
||||
where
|
||||
R: DbRead + Sync,
|
||||
{
|
||||
let mut iter = db.scan_prefix(keys::blobs_prefix()).await?;
|
||||
let mut blob_ids = Vec::new();
|
||||
while let Some(entry) = iter.next().await? {
|
||||
let key = key_to_str(&entry.key)?;
|
||||
let Some(blob_id) = keys::parse_blob_id(key) else {
|
||||
continue;
|
||||
};
|
||||
blob_ids.push(blob_id);
|
||||
}
|
||||
blob_ids.sort();
|
||||
Ok(blob_ids)
|
||||
}
|
||||
|
||||
fn key_to_str(key: &Bytes) -> Result<&str> {
|
||||
std::str::from_utf8(key)
|
||||
.map_err(|err| Error::Other(format!("stored key is not valid UTF-8: {err}")))
|
||||
|
|
@ -938,23 +892,6 @@ mod tests {
|
|||
|
||||
use crate::{Database, Error, EventPayload, keys};
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_blobs_reads_global_cas_namespace() {
|
||||
let object_store = Arc::new(InMemory::new());
|
||||
let store = Database::new(object_store, "", Duration::from_millis(1), None);
|
||||
let run_id = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
|
||||
let run = store.create_run(&run_id).await.unwrap();
|
||||
let first_blob = br#"{"a":1}"#;
|
||||
let second_blob = br#"{"b":2}"#;
|
||||
|
||||
let first_id = run.write_blob(first_blob).await.unwrap();
|
||||
let second_id = run.write_blob(second_blob).await.unwrap();
|
||||
let mut blob_ids = run.list_blobs().await.unwrap();
|
||||
blob_ids.sort();
|
||||
|
||||
assert_eq!(blob_ids, vec![first_id, second_id]);
|
||||
}
|
||||
|
||||
fn stage_prompt_payload(run_id: &RunId, idx: u32, node_id: Option<&str>) -> EventPayload {
|
||||
stage_prompt_payload_for_stage(run_id, idx, node_id, None)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,30 +8,23 @@ use fabro_types::{
|
|||
BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord,
|
||||
ParallelBranchResult, QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan,
|
||||
RunSandboxRuntime, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,
|
||||
StageOutcome, StartRecord, WorkflowSettings, first_event_seq, fixtures, test_support,
|
||||
StageOutcome, StartRecord, first_event_seq, fixtures, test_support,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("ship"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
automation: None,
|
||||
graph: Graph::new("ship"),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: Some(fabro_types::GitContext {
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
git: Some(fabro_types::GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
sha: None,
|
||||
dirty: fabro_types::DirtyStatus::Clean,
|
||||
}),
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
27
lib/components/fabro-workflow-version/Cargo.toml
Normal file
27
lib/components/fabro-workflow-version/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "fabro-workflow-version"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "Semantic validation and storage for immutable workflow versions"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
fabro-config = { path = "../../foundation/fabro-config" }
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
fabro-store = { path = "../fabro-store" }
|
||||
fabro-template = { path = "../../foundation/fabro-template" }
|
||||
fabro-types = { path = "../../foundation/fabro-types" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
object_store.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
857
lib/components/fabro-workflow-version/src/lib.rs
Normal file
857
lib/components/fabro-workflow-version/src/lib.rs
Normal file
|
|
@ -0,0 +1,857 @@
|
|||
//! Semantic validation for immutable workflow versions.
|
||||
//!
|
||||
//! The wire type ([`fabro_types::WorkflowVersion`]) enforces structural
|
||||
//! invariants at construction. This crate owns the expensive semantic
|
||||
//! validation — graph closure, config, and template checks — behind the
|
||||
//! [`ValidatedWorkflowVersion`] newtype, and the content-addressed
|
||||
//! [`WorkflowVersionStore`] that only accepts and returns validated versions.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
|
||||
use fabro_config::parse::{SettingsSource, validate_settings_source};
|
||||
use fabro_config::{
|
||||
EnvironmentDockerfileLayer, EnvironmentImageLayer, RunGoalLayer, SettingsLayer,
|
||||
};
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::{
|
||||
BundleTemplateStore, GraphReference, GraphReferenceError, StaticReferenceError,
|
||||
TemplateDiscoveryError, TemplateSource, discover_static_dependency_closure,
|
||||
validate_static_reference, visit_graph_references,
|
||||
};
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::{ManifestPath, WorkflowPath, WorkflowPathParseError, WorkflowVersion};
|
||||
use thiserror::Error;
|
||||
|
||||
mod store;
|
||||
|
||||
pub use store::{LoadedWorkflowVersionClosure, WorkflowVersionStore, WorkflowVersionStoreError};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorkflowVersionError {
|
||||
#[error("workflow graph `{path}` is invalid")]
|
||||
GraphParse {
|
||||
path: WorkflowPath,
|
||||
#[source]
|
||||
source: fabro_graphviz::Error,
|
||||
},
|
||||
#[error("invalid {kind} in `{path}`: `{reference}`")]
|
||||
InvalidReference {
|
||||
path: WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
reference: String,
|
||||
#[source]
|
||||
source: WorkflowPathParseError,
|
||||
},
|
||||
#[error("invalid static reference in `{path}`")]
|
||||
StaticReference {
|
||||
path: WorkflowPath,
|
||||
#[source]
|
||||
source: StaticReferenceError,
|
||||
},
|
||||
#[error("{kind} in `{path}` references missing file `{target}`")]
|
||||
MissingFile {
|
||||
path: WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
target: WorkflowPath,
|
||||
},
|
||||
#[error("template dependencies for `{path}` are invalid")]
|
||||
Template {
|
||||
path: WorkflowPath,
|
||||
#[source]
|
||||
source: Box<TemplateDiscoveryError>,
|
||||
},
|
||||
#[error("workflow.toml is invalid")]
|
||||
Config {
|
||||
#[source]
|
||||
source: fabro_config::ParseError,
|
||||
},
|
||||
#[error(
|
||||
"workflow.toml selects graph `{configured}`, but the version entrypoint is `{entrypoint}`"
|
||||
)]
|
||||
ConfigEntrypointMismatch {
|
||||
configured: WorkflowPath,
|
||||
entrypoint: WorkflowPath,
|
||||
},
|
||||
#[error("workflow dependencies do not match child workflow references")]
|
||||
DependencyMismatch {
|
||||
missing: Vec<WorkflowPath>,
|
||||
unused: Vec<WorkflowPath>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A workflow version whose graph, config, and template content passed
|
||||
/// semantic validation.
|
||||
///
|
||||
/// This is the only door: functions that require a semantically valid
|
||||
/// version take this type, and the only way to obtain one is [`Self::new`]
|
||||
/// (or loading through [`WorkflowVersionStore`], which validates on read).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ValidatedWorkflowVersion(WorkflowVersion);
|
||||
|
||||
impl ValidatedWorkflowVersion {
|
||||
pub fn new(version: WorkflowVersion) -> Result<Self, WorkflowVersionError> {
|
||||
let mut template_roots = TemplateRoots::new();
|
||||
validate_config(&version, &mut template_roots)?;
|
||||
validate_graph_closure(&version, &mut template_roots)?;
|
||||
validate_template_closure(&version, template_roots.sources)?;
|
||||
Ok(Self(version))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn version(&self) -> &WorkflowVersion {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_version(self) -> WorkflowVersion {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Template sources that anchor static dependency discovery, all rooted at
|
||||
/// the workflow package root.
|
||||
struct TemplateRoots {
|
||||
package_root: ManifestPath,
|
||||
sources: Vec<TemplateSource>,
|
||||
}
|
||||
|
||||
impl TemplateRoots {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
package_root: ManifestPath::from_wire(".")
|
||||
.expect("the template package root must be a valid manifest path"),
|
||||
sources: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, path: &WorkflowPath, content: impl Into<String>) {
|
||||
self.sources.push(TemplateSource::new(
|
||||
manifest_path(path),
|
||||
self.package_root.clone(),
|
||||
content,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config(
|
||||
version: &WorkflowVersion,
|
||||
template_roots: &mut TemplateRoots,
|
||||
) -> Result<(), WorkflowVersionError> {
|
||||
let config_path =
|
||||
WorkflowPath::new("workflow.toml").expect("the static workflow config path must be valid");
|
||||
let Some(source) = version.files().get(&config_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let layer = source
|
||||
.parse::<SettingsLayer>()
|
||||
.map_err(|source| WorkflowVersionError::Config { source })?;
|
||||
validate_settings_source(&layer, SettingsSource::Workflow)
|
||||
.map_err(|source| WorkflowVersionError::Config { source })?;
|
||||
|
||||
if let Some(configured) = layer
|
||||
.workflow
|
||||
.as_ref()
|
||||
.and_then(|workflow| workflow.graph.as_deref())
|
||||
{
|
||||
let configured = resolve_reference(&config_path, ReferenceKind::FileInline, configured)?;
|
||||
if configured != *version.entrypoint() {
|
||||
return Err(WorkflowVersionError::ConfigEntrypointMismatch {
|
||||
configured,
|
||||
entrypoint: version.entrypoint().clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for image in layer.environment_images() {
|
||||
validate_dockerfile(version, &config_path, image)?;
|
||||
}
|
||||
|
||||
// The run engine inlines the effective goal (file contents included) into
|
||||
// the entrypoint graph and renders it under the entrypoint's template
|
||||
// source, so goal includes anchor at the entrypoint for both goal forms.
|
||||
match layer.run.as_ref().and_then(|run| run.goal.as_ref()) {
|
||||
Some(RunGoalLayer::Inline(goal)) => {
|
||||
template_roots.push(version.entrypoint(), unresolved_source(goal));
|
||||
}
|
||||
Some(RunGoalLayer::File { file }) => {
|
||||
let (_, content) = validate_config_file_reference(
|
||||
version,
|
||||
&config_path,
|
||||
ReferenceKind::RunGoalFile,
|
||||
&unresolved_source(file),
|
||||
)?;
|
||||
template_roots.push(version.entrypoint(), content);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "workflow-version validation preserves authored template source for dependency discovery"
|
||||
)]
|
||||
fn unresolved_source(value: &InterpString) -> String {
|
||||
value.as_source()
|
||||
}
|
||||
|
||||
fn validate_dockerfile(
|
||||
version: &WorkflowVersion,
|
||||
config_path: &WorkflowPath,
|
||||
image: &EnvironmentImageLayer,
|
||||
) -> Result<(), WorkflowVersionError> {
|
||||
let Some(EnvironmentDockerfileLayer::Path { path }) = image.dockerfile.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
validate_config_file_reference(version, config_path, ReferenceKind::Dockerfile, path)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Validate a static file reference in `workflow.toml` and require its target
|
||||
/// to exist in the version, returning the target path and its content.
|
||||
fn validate_config_file_reference<'version>(
|
||||
version: &'version WorkflowVersion,
|
||||
config_path: &WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
reference: &str,
|
||||
) -> Result<(WorkflowPath, &'version str), WorkflowVersionError> {
|
||||
validate_static_reference(reference, kind).map_err(|source| {
|
||||
WorkflowVersionError::StaticReference {
|
||||
path: config_path.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let target = resolve_reference(config_path, kind, reference)?;
|
||||
let content = require_file(version, config_path, kind, target.clone())?;
|
||||
Ok((target, content))
|
||||
}
|
||||
|
||||
fn validate_graph_closure(
|
||||
version: &WorkflowVersion,
|
||||
template_roots: &mut TemplateRoots,
|
||||
) -> Result<(), WorkflowVersionError> {
|
||||
let mut queue = VecDeque::from([version.entrypoint().clone()]);
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut child_workflows = BTreeSet::new();
|
||||
|
||||
while let Some(path) = queue.pop_front() {
|
||||
if !visited.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
let source =
|
||||
version
|
||||
.files()
|
||||
.get(&path)
|
||||
.ok_or_else(|| WorkflowVersionError::MissingFile {
|
||||
path: path.clone(),
|
||||
kind: ReferenceKind::Import,
|
||||
target: path.clone(),
|
||||
})?;
|
||||
let graph = parser::parse(source).map_err(|source| WorkflowVersionError::GraphParse {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
visit_graph_references(&graph, |reference| match reference {
|
||||
GraphReference::GoalFile { reference } => {
|
||||
let target = resolve_reference(&path, ReferenceKind::GraphGoalFile, reference)?;
|
||||
let content =
|
||||
require_file(version, &path, ReferenceKind::GraphGoalFile, target.clone())?;
|
||||
template_roots.push(&target, content);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::GoalInline { content } | GraphReference::InlinePrompt { content } => {
|
||||
template_roots.push(&path, content);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::Import { reference } => {
|
||||
let target = resolve_reference(&path, ReferenceKind::Import, reference)?;
|
||||
require_file(version, &path, ReferenceKind::Import, target.clone())?;
|
||||
queue.push_back(target);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::ChildWorkflow { reference } => {
|
||||
let target = resolve_reference(&path, ReferenceKind::ChildWorkflow, reference)?;
|
||||
child_workflows.insert(target);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::FileInline { key, reference } => {
|
||||
let target = resolve_reference(&path, ReferenceKind::FileInline, reference)?;
|
||||
let content =
|
||||
require_file(version, &path, ReferenceKind::FileInline, target.clone())?;
|
||||
if key == "prompt" {
|
||||
template_roots.push(&target, content);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
GraphReferenceError::StaticReference(source) => WorkflowVersionError::StaticReference {
|
||||
path: path.clone(),
|
||||
source,
|
||||
},
|
||||
GraphReferenceError::Visit(error) => error,
|
||||
})?;
|
||||
}
|
||||
|
||||
let configured = version
|
||||
.workflow_dependencies()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if child_workflows != configured {
|
||||
return Err(WorkflowVersionError::DependencyMismatch {
|
||||
missing: child_workflows.difference(&configured).cloned().collect(),
|
||||
unused: configured.difference(&child_workflows).cloned().collect(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_template_closure(
|
||||
version: &WorkflowVersion,
|
||||
roots: Vec<TemplateSource>,
|
||||
) -> Result<(), WorkflowVersionError> {
|
||||
discover_static_dependency_closure(roots, &template_store(version)).map_err(|source| {
|
||||
WorkflowVersionError::Template {
|
||||
path: template_discovery_path(&source),
|
||||
source: Box::new(source),
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn template_discovery_path(error: &TemplateDiscoveryError) -> WorkflowPath {
|
||||
WorkflowPath::new(error.source_path().to_string())
|
||||
.expect("template paths sourced from a workflow version must be valid")
|
||||
}
|
||||
|
||||
fn template_store(version: &WorkflowVersion) -> BundleTemplateStore {
|
||||
BundleTemplateStore::new(
|
||||
version
|
||||
.files()
|
||||
.iter()
|
||||
.map(|(path, content)| (manifest_path(path), content.clone()))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_reference(
|
||||
path: &WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
reference: &str,
|
||||
) -> Result<WorkflowPath, WorkflowVersionError> {
|
||||
path.resolve_reference(reference)
|
||||
.map_err(|source| WorkflowVersionError::InvalidReference {
|
||||
path: path.clone(),
|
||||
kind,
|
||||
reference: reference.to_owned(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_file<'version>(
|
||||
version: &'version WorkflowVersion,
|
||||
path: &WorkflowPath,
|
||||
kind: ReferenceKind,
|
||||
target: WorkflowPath,
|
||||
) -> Result<&'version str, WorkflowVersionError> {
|
||||
version
|
||||
.files()
|
||||
.get(&target)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| WorkflowVersionError::MissingFile {
|
||||
path: path.clone(),
|
||||
kind,
|
||||
target,
|
||||
})
|
||||
}
|
||||
|
||||
fn manifest_path(path: &WorkflowPath) -> ManifestPath {
|
||||
ManifestPath::from_wire(path.as_str())
|
||||
.expect("validated workflow paths must also be valid manifest paths")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fabro_template::{TemplateDiscoveryError, TemplateLoadError};
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_types::{BlobHash, WorkflowPath, WorkflowVersion, WorkflowVersionId};
|
||||
|
||||
use super::{ValidatedWorkflowVersion, WorkflowVersionError};
|
||||
|
||||
fn path(value: &str) -> WorkflowPath {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn dependency_id(value: &[u8]) -> WorkflowVersionId {
|
||||
BlobHash::new(value).into()
|
||||
}
|
||||
|
||||
fn version_with(
|
||||
files: impl IntoIterator<Item = (&'static str, &'static str)>,
|
||||
dependencies: impl IntoIterator<Item = (&'static str, WorkflowVersionId)>,
|
||||
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {
|
||||
ValidatedWorkflowVersion::new(
|
||||
WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
files
|
||||
.into_iter()
|
||||
.map(|(path_value, content)| (path(path_value), content.to_owned()))
|
||||
.collect(),
|
||||
dependencies
|
||||
.into_iter()
|
||||
.map(|(path_value, id)| (path(path_value), id))
|
||||
.collect(),
|
||||
)
|
||||
.expect("test fixtures must be structurally valid"),
|
||||
)
|
||||
}
|
||||
|
||||
fn version_with_config(
|
||||
config: impl Into<String>,
|
||||
extra_files: impl IntoIterator<Item = (&'static str, &'static str)>,
|
||||
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {
|
||||
let mut files = extra_files
|
||||
.into_iter()
|
||||
.map(|(path_value, content)| (path(path_value), content.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
files.insert(path("workflow.fabro"), "digraph W {}".to_owned());
|
||||
files.insert(path("workflow.toml"), config.into());
|
||||
ValidatedWorkflowVersion::new(
|
||||
WorkflowVersion::new(path("workflow.fabro"), files, BTreeMap::default())
|
||||
.expect("test fixtures must be structurally valid"),
|
||||
)
|
||||
}
|
||||
|
||||
fn version_with_goal_file(
|
||||
reference: &str,
|
||||
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {
|
||||
let reference = serde_json::to_string(reference).unwrap();
|
||||
let config = format!("_version = 1\n[run.goal]\nfile = {reference}\n");
|
||||
version_with_config(config, [])
|
||||
}
|
||||
|
||||
fn version_with_inline_goal(
|
||||
goal: &str,
|
||||
extra_files: impl IntoIterator<Item = (&'static str, &'static str)>,
|
||||
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {
|
||||
let goal = serde_json::to_string(goal).unwrap();
|
||||
version_with_config(format!("_version = 1\n[run]\ngoal = {goal}\n"), extra_files)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_imports_templates_file_refs_and_dependencies() {
|
||||
let version = version_with(
|
||||
[
|
||||
(
|
||||
"workflow.fabro",
|
||||
r#"digraph W {
|
||||
graph [goal="@prompts/goal.md"]
|
||||
start [shape=Mdiamond]
|
||||
imported [import="graphs/imported.fabro"]
|
||||
child [stack.child_workflow="children/check.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> imported -> child -> exit
|
||||
}"#,
|
||||
),
|
||||
(
|
||||
"graphs/imported.fabro",
|
||||
r#"digraph I { step [prompt="{% include \"../prompts/partial.md\" %}"] }"#,
|
||||
),
|
||||
("prompts/goal.md", "{% include \"partial.md\" %}"),
|
||||
("prompts/partial.md", "Do the work"),
|
||||
],
|
||||
[("children/check.fabro", dependency_id(b"child"))],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().workflow_dependencies().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_and_unused_dependencies() {
|
||||
let error = version_with(
|
||||
[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { child [stack.child_workflow="child.fabro"] }"#,
|
||||
)],
|
||||
[("unused.fabro", dependency_id(b"unused"))],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
let WorkflowVersionError::DependencyMismatch { missing, unused } = error else {
|
||||
panic!("expected dependency mismatch");
|
||||
};
|
||||
assert_eq!(missing, vec![path("child.fabro")]);
|
||||
assert_eq!(unused, vec![path("unused.fabro")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_config_entrypoint_and_missing_dockerfile() {
|
||||
let error = version_with(
|
||||
[
|
||||
(
|
||||
"workflow.fabro",
|
||||
"digraph W { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
|
||||
),
|
||||
(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[workflow]\ngraph = \"other.fabro\"\n",
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::ConfigEntrypointMismatch { .. }
|
||||
));
|
||||
|
||||
let missing_dockerfile = version_with(
|
||||
[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[run.environment.image]\ndockerfile = { path = \"docker/Dockerfile\" }\n",
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
missing_dockerfile,
|
||||
WorkflowVersionError::MissingFile { .. }
|
||||
));
|
||||
|
||||
let invalid_config = version_with(
|
||||
[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
("workflow.toml", "not valid toml = ["),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
invalid_config,
|
||||
WorkflowVersionError::Config { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_workflow_goal_file() {
|
||||
let error = version_with_goal_file("prompts/goal.md").unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::MissingFile {
|
||||
path: source_path,
|
||||
kind,
|
||||
target,
|
||||
}
|
||||
if source_path == path("workflow.toml")
|
||||
&& kind == ReferenceKind::RunGoalFile
|
||||
&& target == path("prompts/goal.md")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_inline_workflow_goal_with_static_template_closure() {
|
||||
let version = version_with_inline_goal(
|
||||
r#"Review {{ vars.target }} with {{ inputs.mode }} after {{ goal }}. {% include "prompts/shared.md" %}"#,
|
||||
[("prompts/shared.md", "Use {{ vars.detail }}")],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().files().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_file_workflow_goal_with_transitive_template_closure() {
|
||||
// The goal file's own includes anchor at the entrypoint's directory
|
||||
// (the package root here), not at the goal file's directory; loaded
|
||||
// dependencies then anchor at their own directories as usual.
|
||||
let version =
|
||||
version_with_config("_version = 1\n[run.goal]\nfile = \"prompts/goal.md\"\n", [
|
||||
("prompts/goal.md", r#"{% include "prompts/partial.md" %}"#),
|
||||
("prompts/partial.md", r#"{% include "nested/detail.md" %}"#),
|
||||
("prompts/nested/detail.md", "Use {{ vars.detail }}"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().files().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_broken_transitive_includes_under_a_workflow_goal_file() {
|
||||
// Guards the root push for file goals: without it the goal file is
|
||||
// never parsed and the broken include below is silently accepted.
|
||||
let error =
|
||||
version_with_config("_version = 1\n[run.goal]\nfile = \"prompts/goal.md\"\n", [
|
||||
("prompts/goal.md", r#"{% include "prompts/partial.md" %}"#),
|
||||
("prompts/partial.md", r#"{% include "missing.md" %}"#),
|
||||
])
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::Template { path: source_path, source }
|
||||
if source_path == path("prompts/partial.md")
|
||||
&& matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Missing { reference, .. } if reference == "missing.md"
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_workflow_goal_includes_at_the_entrypoint() {
|
||||
let version_with_entrypoint = |goal_include_target: &'static str| {
|
||||
ValidatedWorkflowVersion::new(
|
||||
WorkflowVersion::new(
|
||||
path("graphs/main.fabro"),
|
||||
BTreeMap::from([
|
||||
(path("graphs/main.fabro"), "digraph W {}".to_owned()),
|
||||
(
|
||||
path("workflow.toml"),
|
||||
"_version = 1\n[run]\ngoal = \"{% include \\\"shared.md\\\" %}\"\n"
|
||||
.to_owned(),
|
||||
),
|
||||
(path(goal_include_target), "shared".to_owned()),
|
||||
]),
|
||||
BTreeMap::default(),
|
||||
)
|
||||
.expect("test fixtures must be structurally valid"),
|
||||
)
|
||||
};
|
||||
|
||||
// The include resolves beside the entrypoint graph, matching where
|
||||
// the run engine renders the inlined goal.
|
||||
version_with_entrypoint("graphs/shared.md").unwrap();
|
||||
|
||||
let error = version_with_entrypoint("shared.md").unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::Template { path: source_path, source }
|
||||
if source_path == path("graphs/main.fabro")
|
||||
&& matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Missing { reference, .. } if reference == "shared.md"
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_static_or_nonportable_workflow_goal_file_references() {
|
||||
for reference in ["{{ vars.NAME }}", "{% include \"goal.md\" %}"] {
|
||||
let error = version_with_goal_file(reference).unwrap_err();
|
||||
let WorkflowVersionError::StaticReference {
|
||||
path: source_path,
|
||||
source,
|
||||
} = error
|
||||
else {
|
||||
panic!("expected static-reference error for {reference:?}");
|
||||
};
|
||||
assert_eq!(source_path, path("workflow.toml"));
|
||||
assert_eq!(source.kind(), ReferenceKind::RunGoalFile);
|
||||
}
|
||||
|
||||
for reference in [
|
||||
"",
|
||||
"/absolute.md",
|
||||
"../outside.md",
|
||||
"~/goal.md",
|
||||
"C:/goal.md",
|
||||
"prompts\\goal.md",
|
||||
"prompts//goal.md",
|
||||
"prompts/",
|
||||
"prompts/goal\n.md",
|
||||
] {
|
||||
let error = version_with_goal_file(reference).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
&error,
|
||||
WorkflowVersionError::InvalidReference {
|
||||
path: source_path,
|
||||
kind: ReferenceKind::RunGoalFile,
|
||||
..
|
||||
} if *source_path == path("workflow.toml")
|
||||
),
|
||||
"expected invalid-reference error for {reference:?}, got {error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_workflow_goal_template_closure() {
|
||||
let missing = version_with_inline_goal(r#"{% include "missing.md" %}"#, []).unwrap_err();
|
||||
let WorkflowVersionError::Template {
|
||||
path: source_path,
|
||||
source,
|
||||
} = missing
|
||||
else {
|
||||
panic!("expected missing template dependency");
|
||||
};
|
||||
assert_eq!(source_path, path("workflow.fabro"));
|
||||
assert!(matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Missing { parent, reference }
|
||||
if parent.to_string() == "workflow.fabro" && reference == "missing.md"
|
||||
));
|
||||
|
||||
let dynamic = version_with_inline_goal(r"{% include inputs.partial %}", []).unwrap_err();
|
||||
let WorkflowVersionError::Template { source, .. } = dynamic else {
|
||||
panic!("expected dynamic template dependency");
|
||||
};
|
||||
assert!(matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Dynamic { parent }
|
||||
if parent.to_string() == "workflow.fabro"
|
||||
));
|
||||
|
||||
let escaping =
|
||||
version_with_inline_goal(r#"{% include "../outside.md" %}"#, []).unwrap_err();
|
||||
let WorkflowVersionError::Template { source, .. } = escaping else {
|
||||
panic!("expected escaping template dependency");
|
||||
};
|
||||
assert!(matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Load {
|
||||
source: TemplateLoadError::EscapesRoot { parent, .. },
|
||||
..
|
||||
} if parent.to_string() == "workflow.fabro"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_all_inline_graph_roots_that_share_the_graph_path() {
|
||||
let error = version_with(
|
||||
[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W {
|
||||
graph [goal="valid"]
|
||||
step [prompt="{% include inputs.partial %}"]
|
||||
}"#,
|
||||
)],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::Template {
|
||||
source,
|
||||
..
|
||||
} if matches!(source.as_ref(), TemplateDiscoveryError::Dynamic { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_graph_files_included_from_goal_templates() {
|
||||
// The graph file's inline prompt anchors a template root at the graph
|
||||
// path; that root must not shadow the raw graph content when a goal
|
||||
// template includes the graph file itself.
|
||||
let error = version_with(
|
||||
[
|
||||
(
|
||||
"workflow.fabro",
|
||||
r#"digraph W {
|
||||
graph [goal="@goal.md"]
|
||||
step [prompt="hello", note="{% include 'missing.md' %}"]
|
||||
}"#,
|
||||
),
|
||||
("goal.md", r#"{% include "workflow.fabro" %}"#),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionError::Template { path: source_path, source }
|
||||
if source_path == path("workflow.fabro")
|
||||
&& matches!(
|
||||
source.as_ref(),
|
||||
TemplateDiscoveryError::Missing { reference, .. } if reference == "missing.md"
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_root_config_and_all_dockerfile_path_sources() {
|
||||
let version = version_with(
|
||||
[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
(
|
||||
"workflow.toml",
|
||||
r#"_version = 1
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[environments.cloud]
|
||||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
dockerfile = { path = "docker/named.Dockerfile" }
|
||||
|
||||
[run.environment.image]
|
||||
dockerfile = { path = "docker/run.Dockerfile" }
|
||||
"#,
|
||||
),
|
||||
("docker/named.Dockerfile", "FROM alpine\n"),
|
||||
("docker/run.Dockerfile", "FROM ubuntu\n"),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().entrypoint(), &path("workflow.fabro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_server_managed_environment_cwd_in_workflow_config() {
|
||||
let error = version_with(
|
||||
[
|
||||
("workflow.fabro", "digraph W {}"),
|
||||
(
|
||||
"workflow.toml",
|
||||
"_version = 1\n[environments.local]\nprovider = \"local\"\ncwd = \"/tmp\"\n",
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, WorkflowVersionError::Config { .. }));
|
||||
assert!(error.to_string().contains("workflow.toml is invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_escaping_and_dynamic_template_references() {
|
||||
let escaping = version_with(
|
||||
[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { imported [import="../outside.fabro"] }"#,
|
||||
)],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
escaping,
|
||||
WorkflowVersionError::InvalidReference { .. }
|
||||
));
|
||||
|
||||
let dynamic = version_with(
|
||||
[(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { step [prompt="{% include template_name %}"] }"#,
|
||||
)],
|
||||
[],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(dynamic, WorkflowVersionError::Template { .. }));
|
||||
}
|
||||
}
|
||||
495
lib/components/fabro-workflow-version/src/store.rs
Normal file
495
lib/components/fabro-workflow-version/src/store.rs
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
use std::collections::{BTreeMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_store::BlobStore;
|
||||
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId, WorkflowVersionShapeError};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{ValidatedWorkflowVersion, WorkflowVersionError};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorkflowVersionStoreError {
|
||||
#[error(transparent)]
|
||||
InvalidVersion(#[from] WorkflowVersionError),
|
||||
#[error(transparent)]
|
||||
InvalidShape(#[from] WorkflowVersionShapeError),
|
||||
#[error("workflow-version dependency `{id}` at `{path}` is not stored")]
|
||||
DependencyNotFound {
|
||||
path: WorkflowPath,
|
||||
id: WorkflowVersionId,
|
||||
},
|
||||
#[error("workflow-version dependency `{id}` at `{path}` is invalid")]
|
||||
DependencyInvalid {
|
||||
path: WorkflowPath,
|
||||
id: WorkflowVersionId,
|
||||
#[source]
|
||||
source: Box<Self>,
|
||||
},
|
||||
#[error("workflow-version blob `{id}` cannot be decoded as a valid workflow version")]
|
||||
Decode {
|
||||
id: WorkflowVersionId,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("workflow-version blob `{id}` is not canonical")]
|
||||
NonCanonical { id: WorkflowVersionId },
|
||||
#[error("workflow-version storage operation failed")]
|
||||
Storage {
|
||||
#[source]
|
||||
source: fabro_store::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// A fully loaded and validated workflow-version dependency graph: the
|
||||
/// requested root alongside every unique transitive dependency, keyed by
|
||||
/// canonical content ID.
|
||||
///
|
||||
/// Deliberately not `Clone`: a closure owns the full file contents of every
|
||||
/// version in the graph, so copies should be explicit and deliberate.
|
||||
#[derive(Debug)]
|
||||
pub struct LoadedWorkflowVersionClosure {
|
||||
root_id: WorkflowVersionId,
|
||||
root: ValidatedWorkflowVersion,
|
||||
dependencies: BTreeMap<WorkflowVersionId, ValidatedWorkflowVersion>,
|
||||
}
|
||||
|
||||
impl LoadedWorkflowVersionClosure {
|
||||
#[must_use]
|
||||
pub fn root_id(&self) -> WorkflowVersionId {
|
||||
self.root_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &WorkflowVersion {
|
||||
self.root.version()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &WorkflowVersionId) -> Option<&WorkflowVersion> {
|
||||
if *id == self.root_id {
|
||||
return Some(self.root.version());
|
||||
}
|
||||
self.dependencies
|
||||
.get(id)
|
||||
.map(ValidatedWorkflowVersion::version)
|
||||
}
|
||||
|
||||
pub fn versions(&self) -> impl Iterator<Item = (WorkflowVersionId, &WorkflowVersion)> + '_ {
|
||||
std::iter::once((self.root_id, self.root.version())).chain(
|
||||
self.dependencies
|
||||
.iter()
|
||||
.map(|(id, version)| (*id, version.version())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-addressed storage for validated workflow versions.
|
||||
///
|
||||
/// `put` only accepts semantically validated versions; `get` re-validates
|
||||
/// blobs on read because the blob namespace is shared and storage is not
|
||||
/// trusted to contain only canonical versions.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkflowVersionStore {
|
||||
blobs: Arc<BlobStore>,
|
||||
}
|
||||
|
||||
impl WorkflowVersionStore {
|
||||
#[must_use]
|
||||
pub fn new(blobs: Arc<BlobStore>) -> Self {
|
||||
Self { blobs }
|
||||
}
|
||||
|
||||
pub async fn put(
|
||||
&self,
|
||||
version: &ValidatedWorkflowVersion,
|
||||
) -> Result<WorkflowVersionId, WorkflowVersionStoreError> {
|
||||
let canonical = version.version().canonical_bytes()?;
|
||||
self.walk_dependency_closure(version.version().workflow_dependencies(), |_, _| ())
|
||||
.await?;
|
||||
self.blobs
|
||||
.write(&canonical)
|
||||
.await
|
||||
.map(WorkflowVersionId::from)
|
||||
.map_err(|source| WorkflowVersionStoreError::Storage { source })
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
&self,
|
||||
id: &WorkflowVersionId,
|
||||
) -> Result<Option<ValidatedWorkflowVersion>, WorkflowVersionStoreError> {
|
||||
let Some(version) = self.load_one(id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.walk_dependency_closure(version.version().workflow_dependencies(), |_, _| ())
|
||||
.await?;
|
||||
Ok(Some(version))
|
||||
}
|
||||
|
||||
pub async fn get_closure(
|
||||
&self,
|
||||
root_id: &WorkflowVersionId,
|
||||
) -> Result<Option<LoadedWorkflowVersionClosure>, WorkflowVersionStoreError> {
|
||||
let Some(root) = self.load_one(root_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut dependencies = BTreeMap::new();
|
||||
self.walk_dependency_closure(root.version().workflow_dependencies(), |id, version| {
|
||||
dependencies.insert(id, version);
|
||||
})
|
||||
.await?;
|
||||
Ok(Some(LoadedWorkflowVersionClosure {
|
||||
root_id: *root_id,
|
||||
root,
|
||||
dependencies,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn load_one(
|
||||
&self,
|
||||
id: &WorkflowVersionId,
|
||||
) -> Result<Option<ValidatedWorkflowVersion>, WorkflowVersionStoreError> {
|
||||
let blob_hash = (*id).into();
|
||||
let Some(bytes) = self
|
||||
.blobs
|
||||
.read(&blob_hash)
|
||||
.await
|
||||
.map_err(|source| WorkflowVersionStoreError::Storage { source })?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let version = serde_json::from_slice::<WorkflowVersion>(&bytes)
|
||||
.map_err(|source| WorkflowVersionStoreError::Decode { id: *id, source })?;
|
||||
let validated = ValidatedWorkflowVersion::new(version)?;
|
||||
let canonical = validated.version().canonical_bytes()?;
|
||||
if canonical.as_slice() != bytes.as_ref() {
|
||||
return Err(WorkflowVersionStoreError::NonCanonical { id: *id });
|
||||
}
|
||||
Ok(Some(validated))
|
||||
}
|
||||
|
||||
/// Walk the transitive dependency closure, validating every dependency
|
||||
/// and handing each loaded version to `visit` exactly once.
|
||||
async fn walk_dependency_closure(
|
||||
&self,
|
||||
dependencies: &BTreeMap<WorkflowPath, WorkflowVersionId>,
|
||||
mut visit: impl FnMut(WorkflowVersionId, ValidatedWorkflowVersion),
|
||||
) -> Result<(), WorkflowVersionStoreError> {
|
||||
let mut pending = dependencies
|
||||
.iter()
|
||||
.map(|(path, id)| (path.clone(), *id))
|
||||
.collect::<VecDeque<_>>();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
while let Some((path, id)) = pending.pop_front() {
|
||||
if !visited.insert(id) {
|
||||
continue;
|
||||
}
|
||||
match self.load_one(&id).await {
|
||||
Ok(Some(dependency)) => {
|
||||
pending.extend(
|
||||
dependency
|
||||
.version()
|
||||
.workflow_dependencies()
|
||||
.iter()
|
||||
.map(|(path, id)| (path.clone(), *id)),
|
||||
);
|
||||
visit(id, dependency);
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(WorkflowVersionStoreError::DependencyNotFound { path, id });
|
||||
}
|
||||
// Persistence failures are server faults, not evidence that
|
||||
// the caller supplied an invalid dependency.
|
||||
Err(source @ WorkflowVersionStoreError::Storage { .. }) => return Err(source),
|
||||
Err(source) => {
|
||||
return Err(WorkflowVersionStoreError::DependencyInvalid {
|
||||
path,
|
||||
id,
|
||||
source: Box::new(source),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_store::{BlobStore, Database};
|
||||
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::{WorkflowVersionStore, WorkflowVersionStoreError};
|
||||
use crate::ValidatedWorkflowVersion;
|
||||
|
||||
fn path(value: &str) -> WorkflowPath {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn version(
|
||||
graph: &str,
|
||||
dependencies: BTreeMap<WorkflowPath, WorkflowVersionId>,
|
||||
) -> ValidatedWorkflowVersion {
|
||||
ValidatedWorkflowVersion::new(
|
||||
WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
BTreeMap::from([(path("workflow.fabro"), graph.to_owned())]),
|
||||
dependencies,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn version_id(version: &ValidatedWorkflowVersion) -> WorkflowVersionId {
|
||||
WorkflowVersionId::from(fabro_types::BlobHash::new(
|
||||
&version.version().canonical_bytes().unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn stores() -> (Arc<BlobStore>, WorkflowVersionStore) {
|
||||
let database = Database::new(
|
||||
Arc::new(InMemory::new()),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
);
|
||||
let blobs = database.blobs().await.unwrap();
|
||||
let versions = WorkflowVersionStore::new(Arc::clone(&blobs));
|
||||
(blobs, versions)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_get_reuses_exact_blob_digest() {
|
||||
let (blobs, store) = stores().await;
|
||||
let version = version("digraph W {}", BTreeMap::new());
|
||||
let expected_bytes = version.version().canonical_bytes().unwrap();
|
||||
let expected_id = version_id(&version);
|
||||
|
||||
let id = store.put(&version).await.unwrap();
|
||||
assert_eq!(id, expected_id);
|
||||
let blob_hash = id.into();
|
||||
assert_eq!(
|
||||
blobs.read(&blob_hash).await.unwrap().unwrap(),
|
||||
expected_bytes
|
||||
);
|
||||
assert_eq!(store.get(&id).await.unwrap(), Some(version));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identical_content_is_idempotent() {
|
||||
let (_, store) = stores().await;
|
||||
let original = version("digraph W {}", BTreeMap::new());
|
||||
|
||||
assert_eq!(
|
||||
store.put(&original).await.unwrap(),
|
||||
store.put(&original).await.unwrap()
|
||||
);
|
||||
|
||||
let changed = version("digraph W { changed [label=\"yes\"] }", BTreeMap::new());
|
||||
assert_ne!(
|
||||
store.put(&original).await.unwrap(),
|
||||
store.put(&changed).await.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dependency_must_be_stored_first() {
|
||||
let (blobs, store) = stores().await;
|
||||
let child = version("digraph Child {}", BTreeMap::new());
|
||||
let child_id = version_id(&child);
|
||||
let root = version(
|
||||
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
|
||||
BTreeMap::from([(path("child.fabro"), child_id)]),
|
||||
);
|
||||
let root_id = version_id(&root);
|
||||
|
||||
let error = store.put(&root).await.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkflowVersionStoreError::DependencyNotFound { .. }
|
||||
));
|
||||
assert!(!blobs.exists(&root_id.into()).await.unwrap());
|
||||
assert_eq!(store.put(&child).await.unwrap(), child_id);
|
||||
assert!(store.put(&root).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dependency_closure_must_be_complete_before_root_write() {
|
||||
let (blobs, store) = stores().await;
|
||||
let missing_grandchild_id = WorkflowVersionId::from(fabro_types::BlobHash::new(b"missing"));
|
||||
let child = version(
|
||||
r#"digraph Child { grandchild [stack.child_workflow="grandchild.fabro"] }"#,
|
||||
BTreeMap::from([(path("grandchild.fabro"), missing_grandchild_id)]),
|
||||
);
|
||||
let child_bytes = child.version().canonical_bytes().unwrap();
|
||||
let child_id = WorkflowVersionId::from(blobs.write(&child_bytes).await.unwrap());
|
||||
let root = version(
|
||||
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
|
||||
BTreeMap::from([(path("child.fabro"), child_id)]),
|
||||
);
|
||||
let root_id = version_id(&root);
|
||||
|
||||
assert!(matches!(
|
||||
store.put(&root).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::DependencyNotFound { id, .. }
|
||||
if id == missing_grandchild_id
|
||||
));
|
||||
assert!(!blobs.exists(&root_id.into()).await.unwrap());
|
||||
assert!(matches!(
|
||||
store.get_closure(&child_id).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::DependencyNotFound { id, .. }
|
||||
if id == missing_grandchild_id
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_closure_returns_root_and_transitive_dependencies() {
|
||||
let (_, store) = stores().await;
|
||||
let grandchild = version("digraph Grandchild {}", BTreeMap::new());
|
||||
let grandchild_id = store.put(&grandchild).await.unwrap();
|
||||
let child = version(
|
||||
r#"digraph Child { grandchild [stack.child_workflow="grandchild.fabro"] }"#,
|
||||
BTreeMap::from([(path("grandchild.fabro"), grandchild_id)]),
|
||||
);
|
||||
let child_id = store.put(&child).await.unwrap();
|
||||
let root = version(
|
||||
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
|
||||
BTreeMap::from([(path("child.fabro"), child_id)]),
|
||||
);
|
||||
let root_id = store.put(&root).await.unwrap();
|
||||
|
||||
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(closure.root_id(), root_id);
|
||||
assert_eq!(closure.root(), root.version());
|
||||
assert_eq!(closure.get(&child_id), Some(child.version()));
|
||||
assert_eq!(closure.get(&grandchild_id), Some(grandchild.version()));
|
||||
assert_eq!(
|
||||
closure
|
||||
.versions()
|
||||
.map(|(id, version)| (id, version.clone()))
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
BTreeMap::from([
|
||||
(root_id, root.version().clone()),
|
||||
(child_id, child.version().clone()),
|
||||
(grandchild_id, grandchild.version().clone()),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_closure_deduplicates_a_diamond() {
|
||||
let (_, store) = stores().await;
|
||||
let leaf = version("digraph Leaf {}", BTreeMap::new());
|
||||
let leaf_id = store.put(&leaf).await.unwrap();
|
||||
let left = version(
|
||||
r#"digraph Left { leaf [stack.child_workflow="leaf.fabro"] }"#,
|
||||
BTreeMap::from([(path("leaf.fabro"), leaf_id)]),
|
||||
);
|
||||
let left_id = store.put(&left).await.unwrap();
|
||||
let right = version(
|
||||
r#"digraph Right { leaf [stack.child_workflow="leaf.fabro"] }"#,
|
||||
BTreeMap::from([(path("leaf.fabro"), leaf_id)]),
|
||||
);
|
||||
let right_id = store.put(&right).await.unwrap();
|
||||
let root = version(
|
||||
r#"digraph Root {
|
||||
left [stack.child_workflow="left.fabro"]
|
||||
right [stack.child_workflow="right.fabro"]
|
||||
}"#,
|
||||
BTreeMap::from([
|
||||
(path("left.fabro"), left_id),
|
||||
(path("right.fabro"), right_id),
|
||||
]),
|
||||
);
|
||||
let root_id = store.put(&root).await.unwrap();
|
||||
|
||||
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
|
||||
let ids = closure.versions().map(|(id, _)| id).collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(ids.len(), 4);
|
||||
assert_eq!(ids.iter().filter(|&&id| id == leaf_id).count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_closure_preserves_noncanonical_dependency_errors() {
|
||||
let (blobs, store) = stores().await;
|
||||
let dependency = version("digraph Dependency {}", BTreeMap::new());
|
||||
let pretty = serde_json::to_vec_pretty(dependency.version()).unwrap();
|
||||
let dependency_id = WorkflowVersionId::from(blobs.write(&pretty).await.unwrap());
|
||||
let root = version(
|
||||
r#"digraph Root { dependency [stack.child_workflow="dependency.fabro"] }"#,
|
||||
BTreeMap::from([(path("dependency.fabro"), dependency_id)]),
|
||||
);
|
||||
let root_id = WorkflowVersionId::from(
|
||||
blobs
|
||||
.write(&root.version().canonical_bytes().unwrap())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let error = store.get_closure(&root_id).await.unwrap_err();
|
||||
let WorkflowVersionStoreError::DependencyInvalid { source, .. } = error else {
|
||||
panic!("expected invalid dependency error");
|
||||
};
|
||||
assert!(matches!(
|
||||
source.as_ref(),
|
||||
WorkflowVersionStoreError::NonCanonical { id } if *id == dependency_id
|
||||
));
|
||||
assert!(matches!(
|
||||
store.get_closure(&dependency_id).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::NonCanonical { id } if id == dependency_id
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_projects_the_same_validated_root_as_get_closure() {
|
||||
let (_, store) = stores().await;
|
||||
let child = version("digraph Child {}", BTreeMap::new());
|
||||
let child_id = store.put(&child).await.unwrap();
|
||||
let root = version(
|
||||
r#"digraph Root { child [stack.child_workflow="child.fabro"] }"#,
|
||||
BTreeMap::from([(path("child.fabro"), child_id)]),
|
||||
);
|
||||
let root_id = store.put(&root).await.unwrap();
|
||||
|
||||
let closure = store.get_closure(&root_id).await.unwrap().unwrap();
|
||||
let projected = store.get(&root_id).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(projected.version(), closure.root());
|
||||
let absent = version_id(&version("digraph Absent {}", BTreeMap::new()));
|
||||
assert!(store.get_closure(&absent).await.unwrap().is_none());
|
||||
assert!(store.get(&absent).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_rejects_arbitrary_and_noncanonical_blobs() {
|
||||
let (blobs, store) = stores().await;
|
||||
let arbitrary = WorkflowVersionId::from(blobs.write(b"not json").await.unwrap());
|
||||
assert!(matches!(
|
||||
store.get(&arbitrary).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::Decode { .. }
|
||||
));
|
||||
|
||||
let invalid_bytes = br#"{"entrypoint":"missing.fabro","files":{"workflow.fabro":"digraph W {}"},"workflow_dependencies":{}}"#;
|
||||
let invalid = WorkflowVersionId::from(blobs.write(invalid_bytes).await.unwrap());
|
||||
assert!(matches!(
|
||||
store.get(&invalid).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::Decode { .. }
|
||||
));
|
||||
|
||||
let version = version("digraph W {}", BTreeMap::new());
|
||||
let pretty = serde_json::to_vec_pretty(version.version()).unwrap();
|
||||
let noncanonical = WorkflowVersionId::from(blobs.write(&pretty).await.unwrap());
|
||||
assert!(matches!(
|
||||
store.get(&noncanonical).await.unwrap_err(),
|
||||
WorkflowVersionStoreError::NonCanonical { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ toml.workspace = true
|
|||
fabro-vault = { path = "../../foundation/fabro-vault" }
|
||||
[dev-dependencies]
|
||||
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
|
||||
fabro-github = { path = "../fabro-github", features = ["test-support"] }
|
||||
base64.workspace = true
|
||||
fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
|
||||
fabro-workflow = { path = ".", features = ["test-support"] }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
|||
use fabro_agent::Sandbox;
|
||||
use fabro_config::RunScratch;
|
||||
use fabro_types::{
|
||||
ParallelBranchResult, RunBlobId, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref,
|
||||
BlobHash, ParallelBranchResult, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref,
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::Value;
|
||||
|
|
@ -26,7 +26,7 @@ const ARTIFACT_POINTER_PREFIX: &str = "file://";
|
|||
///
|
||||
/// For each entry in `updates` whose serialized JSON exceeds
|
||||
/// `BLOB_OFFLOAD_THRESHOLD`, the value is persisted as a blob in `run_store`
|
||||
/// and replaced with a `"blob://sha256/{blob_id}"` reference.
|
||||
/// and replaced with a `"blob://sha256/{blob_hash}"` reference.
|
||||
/// Small values are left untouched.
|
||||
///
|
||||
/// `parallel.results` is offloaded at each branch context-update boundary
|
||||
|
|
@ -102,11 +102,11 @@ async fn offload_value(value: &mut Value, run_store: &RunStoreHandle) -> Result<
|
|||
.map_err(|e| Error::engine_with_source("artifact serialize failed", e))?;
|
||||
|
||||
if bytes.len() > BLOB_OFFLOAD_THRESHOLD {
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&bytes)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_anyhow("artifact blob write failed", e))?;
|
||||
*value = Value::String(format_blob_ref(&blob_id));
|
||||
*value = Value::String(format_blob_ref(&blob_hash));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -204,8 +204,16 @@ pub async fn resolve_outcomes_for_execution(
|
|||
run_dir: &Path,
|
||||
) -> Result<HashMap<String, Outcome>> {
|
||||
let mut resolved = node_outcomes.clone();
|
||||
let mut locality = SandboxLocality::default();
|
||||
for outcome in resolved.values_mut() {
|
||||
resolve_execution_values(&mut outcome.context_updates, run_store, env, run_dir).await?;
|
||||
resolve_execution_values(
|
||||
&mut outcome.context_updates,
|
||||
run_store,
|
||||
env,
|
||||
run_dir,
|
||||
&mut locality,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
|
@ -217,7 +225,8 @@ pub async fn resolved_context_snapshot(
|
|||
run_dir: &Path,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
let mut values = context.snapshot();
|
||||
resolve_execution_values(&mut values, run_store, env, run_dir).await?;
|
||||
let mut locality = SandboxLocality::default();
|
||||
resolve_execution_values(&mut values, run_store, env, run_dir, &mut locality).await?;
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
|
|
@ -232,17 +241,17 @@ pub async fn resolve_text_or_blob_ref(value: &Value, run_store: &RunStoreHandle)
|
|||
/// blob reference.
|
||||
///
|
||||
/// Managed `file://` references are normalized through their content-addressed
|
||||
/// blob id instead of reading an execution-local path. Ordinary strings and
|
||||
/// blob hash instead of reading an execution-local path. Ordinary strings and
|
||||
/// ordinary file references remain unchanged for the caller to validate.
|
||||
pub(crate) async fn resolve_json_value(value: Value, run_store: &RunStoreHandle) -> Result<Value> {
|
||||
let blob_id = value.as_str().and_then(|reference| {
|
||||
let blob_hash = value.as_str().and_then(|reference| {
|
||||
parse_blob_ref(reference).or_else(|| parse_managed_blob_file_ref(reference))
|
||||
});
|
||||
let Some(blob_id) = blob_id else {
|
||||
let Some(blob_hash) = blob_hash else {
|
||||
return Ok(value);
|
||||
};
|
||||
|
||||
let bytes = read_required_blob(&blob_id, run_store).await?;
|
||||
let bytes = read_required_blob(&blob_hash, run_store).await?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|err| Error::engine_with_source("artifact blob was not valid JSON", err))
|
||||
}
|
||||
|
|
@ -267,14 +276,14 @@ pub async fn resolve_text_or_blob_ref_str(
|
|||
current: &str,
|
||||
run_store: &RunStoreHandle,
|
||||
) -> Result<String> {
|
||||
let Some(blob_id) = parse_blob_ref(current) else {
|
||||
let Some(blob_hash) = parse_blob_ref(current) else {
|
||||
return Ok(current.to_string());
|
||||
};
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.read_blob(&blob_hash)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_anyhow("text blob read failed", e))?
|
||||
.ok_or_else(|| Error::engine(format!("text blob missing: {blob_id}")))?;
|
||||
.ok_or_else(|| Error::engine(format!("text blob missing: {blob_hash}")))?;
|
||||
serde_json::from_slice::<String>(&bytes)
|
||||
.map_err(|e| Error::engine_with_source("text blob was not a JSON string", e))
|
||||
}
|
||||
|
|
@ -334,8 +343,8 @@ pub async fn sync_artifacts_to_env(
|
|||
fn normalize_durable_value(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(current) => {
|
||||
if let Some(blob_id) = parse_managed_blob_file_ref(current) {
|
||||
*current = format_blob_ref(&blob_id);
|
||||
if let Some(blob_hash) = parse_managed_blob_file_ref(current) {
|
||||
*current = format_blob_ref(&blob_hash);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
|
|
@ -357,10 +366,12 @@ fn resolve_execution_values<'a>(
|
|||
run_store: &'a RunStoreHandle,
|
||||
env: &'a dyn Sandbox,
|
||||
run_dir: &'a Path,
|
||||
locality: &'a mut SandboxLocality,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
for (key, value) in values.iter_mut() {
|
||||
resolve_execution_value(Some(key.as_str()), value, run_store, env, run_dir).await?;
|
||||
resolve_execution_value(Some(key.as_str()), value, run_store, env, run_dir, locality)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -376,14 +387,16 @@ fn resolve_execution_value<'a>(
|
|||
run_store: &'a RunStoreHandle,
|
||||
env: &'a dyn Sandbox,
|
||||
run_dir: &'a Path,
|
||||
locality: &'a mut SandboxLocality,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
match value {
|
||||
Value::String(current) => {
|
||||
if key.is_some_and(is_text_context_key) {
|
||||
*current = resolve_text_or_blob_ref_str(current, run_store).await?;
|
||||
} else if let Some(blob_id) = parse_blob_ref(current) {
|
||||
*current = materialize_blob_ref(&blob_id, run_store, env, run_dir).await?;
|
||||
} else if let Some(blob_hash) = parse_blob_ref(current) {
|
||||
*current =
|
||||
materialize_blob_ref(&blob_hash, run_store, env, run_dir, locality).await?;
|
||||
} else if current.starts_with(ARTIFACT_POINTER_PREFIX)
|
||||
&& parse_managed_blob_file_ref(current).is_none()
|
||||
{
|
||||
|
|
@ -392,7 +405,7 @@ fn resolve_execution_value<'a>(
|
|||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
resolve_execution_value(key, item, run_store, env, run_dir).await?;
|
||||
resolve_execution_value(key, item, run_store, env, run_dir, locality).await?;
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
|
|
@ -402,8 +415,15 @@ fn resolve_execution_value<'a>(
|
|||
} else {
|
||||
Some(child_key.as_str())
|
||||
};
|
||||
resolve_execution_value(child_context_key, item, run_store, env, run_dir)
|
||||
.await?;
|
||||
resolve_execution_value(
|
||||
child_context_key,
|
||||
item,
|
||||
run_store,
|
||||
env,
|
||||
run_dir,
|
||||
locality,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => {}
|
||||
|
|
@ -413,17 +433,18 @@ fn resolve_execution_value<'a>(
|
|||
}
|
||||
|
||||
async fn materialize_blob_ref(
|
||||
blob_id: &RunBlobId,
|
||||
blob_hash: &BlobHash,
|
||||
run_store: &RunStoreHandle,
|
||||
env: &dyn Sandbox,
|
||||
run_dir: &Path,
|
||||
locality: &mut SandboxLocality,
|
||||
) -> Result<String> {
|
||||
// Blobs are content-addressed, so an existing materialized file is always
|
||||
// current — check before paying for the store read.
|
||||
if is_local_execution(env, run_dir).await? {
|
||||
let path = local_materialized_blob_path(run_dir, blob_id);
|
||||
if locality.is_local(env, run_dir).await? {
|
||||
let path = local_materialized_blob_path(run_dir, blob_hash);
|
||||
if !path.exists() {
|
||||
let bytes = read_required_blob(blob_id, run_store).await?;
|
||||
let bytes = read_required_blob(blob_hash, run_store).await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await.map_err(|err| {
|
||||
Error::Io(format!(
|
||||
|
|
@ -439,13 +460,13 @@ async fn materialize_blob_ref(
|
|||
return Ok(format!("{ARTIFACT_POINTER_PREFIX}{}", path.display()));
|
||||
}
|
||||
|
||||
let remote_path = format!("{}/.fabro/blobs/{blob_id}.json", env.working_directory());
|
||||
let remote_path = format!("{}/.fabro/blobs/{blob_hash}.json", env.working_directory());
|
||||
if !env
|
||||
.file_exists(&remote_path)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("failed to check blob existence", e))?
|
||||
{
|
||||
let bytes = read_required_blob(blob_id, run_store).await?;
|
||||
let bytes = read_required_blob(blob_hash, run_store).await?;
|
||||
let content = String::from_utf8(bytes.to_vec())
|
||||
.map_err(|e| Error::engine_with_source("artifact blob was not valid UTF-8 JSON", e))?;
|
||||
env.write_file(&remote_path, &content).await.map_err(|e| {
|
||||
|
|
@ -457,14 +478,14 @@ async fn materialize_blob_ref(
|
|||
}
|
||||
|
||||
async fn read_required_blob(
|
||||
blob_id: &RunBlobId,
|
||||
blob_hash: &BlobHash,
|
||||
run_store: &RunStoreHandle,
|
||||
) -> Result<bytes::Bytes> {
|
||||
run_store
|
||||
.read_blob(blob_id)
|
||||
.read_blob(blob_hash)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_anyhow("artifact blob read failed", e))?
|
||||
.ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_id}")))
|
||||
.ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_hash}")))
|
||||
}
|
||||
|
||||
async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result<String> {
|
||||
|
|
@ -502,17 +523,33 @@ async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result<Str
|
|||
Ok(format!("{ARTIFACT_POINTER_PREFIX}{remote_path}"))
|
||||
}
|
||||
|
||||
async fn is_local_execution(env: &dyn Sandbox, run_dir: &Path) -> Result<bool> {
|
||||
env.file_exists(&run_dir.to_string_lossy())
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("failed to inspect sandbox locality", e))
|
||||
/// Memoized sandbox locality for one resolution pass. The sandbox and run
|
||||
/// directory are invariant across a pass, so the (possibly remote) probe is
|
||||
/// paid at most once instead of once per blob reference.
|
||||
#[derive(Default)]
|
||||
struct SandboxLocality {
|
||||
cached: Option<bool>,
|
||||
}
|
||||
|
||||
fn local_materialized_blob_path(run_dir: &Path, blob_id: &RunBlobId) -> PathBuf {
|
||||
impl SandboxLocality {
|
||||
async fn is_local(&mut self, env: &dyn Sandbox, run_dir: &Path) -> Result<bool> {
|
||||
if let Some(local) = self.cached {
|
||||
return Ok(local);
|
||||
}
|
||||
let local = env
|
||||
.file_exists(&run_dir.to_string_lossy())
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("failed to inspect sandbox locality", e))?;
|
||||
self.cached = Some(local);
|
||||
Ok(local)
|
||||
}
|
||||
}
|
||||
|
||||
fn local_materialized_blob_path(run_dir: &Path, blob_hash: &BlobHash) -> PathBuf {
|
||||
RunScratch::new(run_dir)
|
||||
.runtime_dir()
|
||||
.join("blobs")
|
||||
.join(format!("{blob_id}.json"))
|
||||
.join(format!("{blob_hash}.json"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -549,7 +586,7 @@ mod tests {
|
|||
|
||||
let large_string = "x".repeat(BLOB_OFFLOAD_THRESHOLD + 1);
|
||||
let serialized = serde_json::to_vec(&serde_json::json!(large_string.clone())).unwrap();
|
||||
let expected_blob_id = fabro_types::RunBlobId::new(&serialized);
|
||||
let expected_blob_hash = fabro_types::BlobHash::new(&serialized);
|
||||
|
||||
let mut updates = HashMap::new();
|
||||
updates.insert("response.plan".to_string(), serde_json::json!(large_string));
|
||||
|
|
@ -561,11 +598,11 @@ mod tests {
|
|||
let pointer = updates.get("response.plan").unwrap();
|
||||
assert_eq!(
|
||||
pointer,
|
||||
&serde_json::json!(fabro_types::format_blob_ref(&expected_blob_id))
|
||||
&serde_json::json!(fabro_types::format_blob_ref(&expected_blob_hash))
|
||||
);
|
||||
|
||||
let blob = run_store
|
||||
.read_blob(&expected_blob_id)
|
||||
.read_blob(&expected_blob_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("blob should exist");
|
||||
|
|
@ -591,21 +628,21 @@ mod tests {
|
|||
async fn resolve_json_value_hydrates_blob_and_managed_file_references() {
|
||||
let run_store = make_run_store("structured-json-resolution").await;
|
||||
let value = serde_json::json!([{"name": "api"}, {"name": "web"}]);
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&serde_json::to_vec(&value).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let handle = run_store.clone().into();
|
||||
|
||||
assert_eq!(
|
||||
resolve_json_value(serde_json::json!(format_blob_ref(&blob_id)), &handle)
|
||||
resolve_json_value(serde_json::json!(format_blob_ref(&blob_hash)), &handle)
|
||||
.await
|
||||
.unwrap(),
|
||||
value
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_json_value(
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_hash}.json")),
|
||||
&handle,
|
||||
)
|
||||
.await
|
||||
|
|
@ -636,7 +673,7 @@ mod tests {
|
|||
Value::String("small".to_string());
|
||||
BLOB_OFFLOAD_THRESHOLD / 4
|
||||
]);
|
||||
let expected_report_blob = RunBlobId::new(&serde_json::to_vec(&large_report).unwrap());
|
||||
let expected_report_blob = BlobHash::new(&serde_json::to_vec(&large_report).unwrap());
|
||||
let mut typed_results = vec![ParallelBranchResult {
|
||||
id: "branch_a".to_string(),
|
||||
index: Some(0),
|
||||
|
|
@ -787,15 +824,43 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_context_probes_sandbox_locality_once_per_pass() {
|
||||
let run_store = make_run_store("locality-probe-memoization").await;
|
||||
let first_blob = run_store
|
||||
.write_blob(&serde_json::to_vec(&serde_json::json!({"a": 1})).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let second_blob = run_store
|
||||
.write_blob(&serde_json::to_vec(&serde_json::json!({"b": 2})).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let context = Context::new();
|
||||
context.set("first", fabro_types::format_blob_ref(&first_blob).into());
|
||||
context.set("second", fabro_types::format_blob_ref(&second_blob).into());
|
||||
let env = TestSyncEnv::new(true, "/workspace");
|
||||
let run_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
resolved_context_snapshot(&context, &run_store.clone().into(), &env, run_dir.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
*env.exists_calls.lock().unwrap(),
|
||||
1,
|
||||
"sandbox locality should be probed once per resolution pass"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_durable_updates_rewrites_managed_blob_file_refs_recursively() {
|
||||
let blob_id = fabro_types::RunBlobId::new(b"hello");
|
||||
let blob_hash = fabro_types::BlobHash::new(b"hello");
|
||||
let mut updates = HashMap::from([(
|
||||
"nested".to_string(),
|
||||
serde_json::json!({
|
||||
"items": [
|
||||
format!("file:///tmp/run/runtime/blobs/{blob_id}.json"),
|
||||
format!("file:///sandbox/.fabro/blobs/{blob_id}.json"),
|
||||
format!("file:///tmp/run/runtime/blobs/{blob_hash}.json"),
|
||||
format!("file:///sandbox/.fabro/blobs/{blob_hash}.json"),
|
||||
"file:///tmp/report.json",
|
||||
]
|
||||
}),
|
||||
|
|
@ -807,8 +872,8 @@ mod tests {
|
|||
updates["nested"],
|
||||
serde_json::json!({
|
||||
"items": [
|
||||
fabro_types::format_blob_ref(&blob_id),
|
||||
fabro_types::format_blob_ref(&blob_id),
|
||||
fabro_types::format_blob_ref(&blob_hash),
|
||||
fabro_types::format_blob_ref(&blob_hash),
|
||||
"file:///tmp/report.json",
|
||||
]
|
||||
})
|
||||
|
|
@ -870,7 +935,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn normalize_checkpoint_for_resume_converts_managed_blob_file_refs_and_drops_preamble() {
|
||||
let blob_id = fabro_types::RunBlobId::new(b"managed");
|
||||
let blob_hash = fabro_types::BlobHash::new(b"managed");
|
||||
let mut checkpoint = crate::records::Checkpoint {
|
||||
timestamp: chrono::Utc::now(),
|
||||
current_node: "work".to_string(),
|
||||
|
|
@ -883,7 +948,7 @@ mod tests {
|
|||
),
|
||||
(
|
||||
"response.work".to_string(),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_hash}.json")),
|
||||
),
|
||||
]),
|
||||
node_outcomes: HashMap::from([(
|
||||
|
|
@ -891,7 +956,7 @@ mod tests {
|
|||
crate::outcome::Outcome {
|
||||
context_updates: HashMap::from([(
|
||||
"response.work".to_string(),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_hash}.json")),
|
||||
)]),
|
||||
..crate::outcome::Outcome::success()
|
||||
},
|
||||
|
|
@ -912,14 +977,14 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
checkpoint.context_values.get("response.work"),
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_id)))
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_hash)))
|
||||
);
|
||||
assert_eq!(
|
||||
checkpoint
|
||||
.node_outcomes
|
||||
.get("work")
|
||||
.and_then(|outcome| outcome.context_updates.get("response.work")),
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_id)))
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_hash)))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -928,9 +993,10 @@ mod tests {
|
|||
use std::sync::Mutex;
|
||||
|
||||
struct TestSyncEnv {
|
||||
accessible: bool,
|
||||
written: Mutex<Vec<(String, String)>>,
|
||||
working_dir: String,
|
||||
accessible: bool,
|
||||
written: Mutex<Vec<(String, String)>>,
|
||||
working_dir: String,
|
||||
exists_calls: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl TestSyncEnv {
|
||||
|
|
@ -939,6 +1005,7 @@ mod tests {
|
|||
accessible,
|
||||
written: Mutex::new(Vec::new()),
|
||||
working_dir: working_dir.to_string(),
|
||||
exists_calls: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -962,6 +1029,7 @@ mod tests {
|
|||
}
|
||||
|
||||
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
|
||||
*self.exists_calls.lock().unwrap() += 1;
|
||||
Ok(self.accessible)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,12 +126,10 @@ pub fn billing_rollup_from_projection(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fabro_model::{Catalog, ModelRef, ProviderId};
|
||||
use fabro_types::{
|
||||
AttrValue, BilledTokenCounts, Graph, Node, RunProjection, RunSpec, StageCompletion,
|
||||
StageOutcome, WorkflowSettings, first_event_seq, fixtures, test_support,
|
||||
StageOutcome, first_event_seq, test_support,
|
||||
};
|
||||
|
||||
use super::billing_rollup_from_projection;
|
||||
|
|
@ -311,19 +309,8 @@ mod tests {
|
|||
});
|
||||
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph,
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,14 +109,14 @@ pub async fn read_json_string_blob(
|
|||
run_store: &RunStoreHandle,
|
||||
blob_ref: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(blob_id) = fabro_types::parse_blob_ref(blob_ref) else {
|
||||
let Some(blob_hash) = fabro_types::parse_blob_ref(blob_ref) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.read_blob(&blob_hash)
|
||||
.await
|
||||
.map_err(|err| Error::engine_with_anyhow("command log blob read failed", err))?
|
||||
.ok_or_else(|| Error::engine(format!("command log blob missing: {blob_id}")))?;
|
||||
.ok_or_else(|| Error::engine(format!("command log blob missing: {blob_hash}")))?;
|
||||
let text = serde_json::from_slice::<String>(&bytes)
|
||||
.map_err(|err| Error::engine_with_source("command log blob was not a JSON string", err))?;
|
||||
Ok(Some(text))
|
||||
|
|
@ -155,9 +155,9 @@ async fn write_json_string_blob(run_store: &RunStoreHandle, text: &str) -> Resul
|
|||
let value = Value::String(text.to_string());
|
||||
let bytes = serde_json::to_vec(&value)
|
||||
.map_err(|err| Error::engine_with_source("command log JSON serialization failed", err))?;
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&bytes)
|
||||
.await
|
||||
.map_err(|err| Error::engine_with_anyhow("command log blob write failed", err))?;
|
||||
Ok(format_blob_ref(&blob_id))
|
||||
Ok(format_blob_ref(&blob_hash))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ const TRANSIENT_INFRA_HINTS: &[&str] = &[
|
|||
"cross-device link",
|
||||
"invalid cross-device link",
|
||||
"os error 18",
|
||||
"state change in progress",
|
||||
"sandbox stop still in progress",
|
||||
];
|
||||
|
||||
const BUDGET_EXHAUSTED_HINTS: &[&str] = &[
|
||||
|
|
@ -807,6 +809,18 @@ mod tests {
|
|||
assert_eq!(err.failure_category(), FailureCategory::TransientInfra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_error_with_sandbox_state_change_cause_classifies_transient() {
|
||||
let source = TestOuterError {
|
||||
message: "Failed to start Daytona sandbox",
|
||||
source: TestCause("Sandbox state change in progress"),
|
||||
};
|
||||
let err = Error::engine_with_source("Pipeline lifecycle operation failed", source);
|
||||
|
||||
assert_eq!(err.failure_category(), FailureCategory::TransientInfra);
|
||||
assert!(err.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_error_display() {
|
||||
let err = Error::handler("LLM call failed");
|
||||
|
|
@ -1281,7 +1295,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn transient_infra_hints_count() {
|
||||
assert_eq!(TRANSIENT_INFRA_HINTS.len(), 38);
|
||||
assert_eq!(TRANSIENT_INFRA_HINTS.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1450,6 +1464,25 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reason_sandbox_state_change_in_progress() {
|
||||
assert_eq!(
|
||||
classify_failure_reason(
|
||||
"Pipeline lifecycle operation failed: failed to activate sandbox after node \
|
||||
attempt survey: Failed to start Daytona sandbox: Sandbox state change in progress"
|
||||
),
|
||||
FailureCategory::TransientInfra
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reason_sandbox_stop_still_in_progress() {
|
||||
assert_eq!(
|
||||
classify_failure_reason("Daytona sandbox stop still in progress after 120s"),
|
||||
FailureCategory::TransientInfra
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reason_500() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use ::fabro_types::{
|
||||
AutomationRef, BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary,
|
||||
AutomationRef, BilledTokenCounts, BlobHash, BlockedReason, CommandTermination, DiffSummary,
|
||||
FailureReason, ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind,
|
||||
PairTarget, ParallelBranchId, ParallelBranchResult, PendingReason, PermissionLevel, Principal,
|
||||
PullRequestCreationId, PullRequestLink, ReviewTarget, RunBlobId, RunFailure, RunId,
|
||||
RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource,
|
||||
RunTiming, SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason,
|
||||
PullRequestCreationId, PullRequestLink, ReviewTarget, RunFailure, RunId, RunNoticeLevel,
|
||||
RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, RunTiming,
|
||||
SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason,
|
||||
run_event as fabro_types,
|
||||
};
|
||||
use fabro_agent::{AgentEvent, SandboxEvent};
|
||||
|
|
@ -39,7 +39,7 @@ pub enum Event {
|
|||
automation: Option<AutomationRef>,
|
||||
provenance: RunProvenance,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
manifest_blob: Option<RunBlobId>,
|
||||
manifest_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
git: Option<GitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -67,7 +67,7 @@ pub enum Event {
|
|||
},
|
||||
RunSubmitted {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
definition_blob: Option<RunBlobId>,
|
||||
definition_blob: Option<BlobHash>,
|
||||
},
|
||||
RunStartRequested {
|
||||
resume: bool,
|
||||
|
|
|
|||
|
|
@ -1,274 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use fabro_github::{GitHubAppCredentials, InstallationToken};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::warn;
|
||||
|
||||
const REFRESH_THRESHOLD: Duration = Duration::from_mins(15);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait IatMinter: Send + Sync {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken>;
|
||||
}
|
||||
|
||||
pub struct AppIatMinter {
|
||||
creds: GitHubAppCredentials,
|
||||
http: fabro_http::HttpClient,
|
||||
owner: String,
|
||||
repo: String,
|
||||
api_base: String,
|
||||
install_url: Option<String>,
|
||||
permissions: serde_json::Value,
|
||||
}
|
||||
|
||||
impl AppIatMinter {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
creds: GitHubAppCredentials,
|
||||
http: fabro_http::HttpClient,
|
||||
owner: String,
|
||||
repo: String,
|
||||
api_base: String,
|
||||
install_url: Option<String>,
|
||||
permissions: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
creds,
|
||||
http,
|
||||
owner,
|
||||
repo,
|
||||
api_base,
|
||||
install_url,
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IatMinter for AppIatMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.creds
|
||||
.mint_installation_token(
|
||||
&self.http,
|
||||
&self.owner,
|
||||
&self.repo,
|
||||
&self.api_base,
|
||||
self.permissions.clone(),
|
||||
self.install_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GitHubTokenSource {
|
||||
state: SourceState,
|
||||
}
|
||||
|
||||
enum SourceState {
|
||||
Pat(String),
|
||||
StaticIat(InstallationToken),
|
||||
Mintable {
|
||||
minter: Arc<dyn IatMinter>,
|
||||
cache: Mutex<Option<InstallationToken>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl GitHubTokenSource {
|
||||
#[must_use]
|
||||
pub fn pat(token: String) -> Self {
|
||||
Self {
|
||||
state: SourceState::Pat(token),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn static_iat(token: InstallationToken) -> Self {
|
||||
Self {
|
||||
state: SourceState::StaticIat(token),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn mintable(minter: Arc<dyn IatMinter>) -> Self {
|
||||
Self {
|
||||
state: SourceState::Mintable {
|
||||
minter,
|
||||
cache: Mutex::new(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_refreshable(&self) -> bool {
|
||||
matches!(self.state, SourceState::Mintable { .. })
|
||||
}
|
||||
|
||||
pub async fn current_token(&self) -> anyhow::Result<String> {
|
||||
match &self.state {
|
||||
SourceState::Pat(token) => Ok(token.clone()),
|
||||
SourceState::StaticIat(token) => token.valid_token().map(str::to_owned),
|
||||
SourceState::Mintable { minter, cache } => {
|
||||
let mut cache = cache.lock().await;
|
||||
let cached_is_fresh = cache
|
||||
.as_ref()
|
||||
.is_some_and(|token| !token.near_expiry(REFRESH_THRESHOLD));
|
||||
|
||||
if !cached_is_fresh {
|
||||
match minter.mint().await {
|
||||
Ok(token) => *cache = Some(token),
|
||||
Err(err) => {
|
||||
if let Some(token) = cache.as_ref() {
|
||||
if let Ok(value) = token.valid_token() {
|
||||
warn!(
|
||||
error = %err,
|
||||
"GitHub installation token refresh failed; using cached token"
|
||||
);
|
||||
return Ok(value.to_owned());
|
||||
}
|
||||
}
|
||||
return Err(err)
|
||||
.context("failed to mint GitHub installation access token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let token = cache
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("mintable token source has no cached token"))?;
|
||||
token.valid_token().map(str::to_owned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::*;
|
||||
|
||||
enum MintAction {
|
||||
Token(&'static str, chrono::DateTime<chrono::Utc>),
|
||||
Error(&'static str),
|
||||
}
|
||||
|
||||
struct MockMinter {
|
||||
calls: AtomicUsize,
|
||||
script: Mutex<VecDeque<MintAction>>,
|
||||
}
|
||||
|
||||
impl MockMinter {
|
||||
fn new(script: Vec<MintAction>) -> Self {
|
||||
Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
script: Mutex::new(script.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IatMinter for MockMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
match self.script.lock().await.pop_front().expect("mint script") {
|
||||
MintAction::Token(token, expires_at) => Ok(InstallationToken {
|
||||
token: token.to_string(),
|
||||
expires_at,
|
||||
}),
|
||||
MintAction::Error(message) => Err(anyhow!(message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pat_returns_same_token_without_minting() {
|
||||
let source = GitHubTokenSource::pat("ghp_pat".to_string());
|
||||
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghp_pat");
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghp_pat");
|
||||
assert!(!source.is_refreshable());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_iat_returns_valid_token_and_rejects_expired_token() {
|
||||
let valid = GitHubTokenSource::static_iat(InstallationToken {
|
||||
token: "ghs_valid".to_string(),
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
});
|
||||
assert_eq!(valid.current_token().await.unwrap(), "ghs_valid");
|
||||
assert!(!valid.is_refreshable());
|
||||
|
||||
let expired = GitHubTokenSource::static_iat(InstallationToken {
|
||||
token: "ghs_expired".to_string(),
|
||||
expires_at: chrono::Utc::now() - chrono::Duration::seconds(1),
|
||||
});
|
||||
assert!(expired.current_token().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mintable_reuses_cached_token_until_refresh_threshold() {
|
||||
let minter = Arc::new(MockMinter::new(vec![MintAction::Token(
|
||||
"ghs_cached",
|
||||
chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
)]));
|
||||
let source = GitHubTokenSource::mintable(minter.clone());
|
||||
|
||||
assert!(source.is_refreshable());
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
|
||||
assert_eq!(minter.calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mintable_refreshes_cached_token_near_expiry() {
|
||||
let minter = Arc::new(MockMinter::new(vec![
|
||||
MintAction::Token(
|
||||
"ghs_first",
|
||||
chrono::Utc::now() + chrono::Duration::minutes(10),
|
||||
),
|
||||
MintAction::Token(
|
||||
"ghs_second",
|
||||
chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
),
|
||||
]));
|
||||
let source = GitHubTokenSource::mintable(minter.clone());
|
||||
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_first");
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_second");
|
||||
assert_eq!(minter.calls(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mintable_uses_valid_cached_token_when_refresh_fails() {
|
||||
let minter = Arc::new(MockMinter::new(vec![
|
||||
MintAction::Token(
|
||||
"ghs_cached",
|
||||
chrono::Utc::now() + chrono::Duration::minutes(10),
|
||||
),
|
||||
MintAction::Error("mint failed"),
|
||||
]));
|
||||
let source = GitHubTokenSource::mintable(minter.clone());
|
||||
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
|
||||
assert_eq!(minter.calls(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mintable_errors_when_no_cached_token_can_cover_mint_failure() {
|
||||
let minter = Arc::new(MockMinter::new(vec![MintAction::Error("mint failed")]));
|
||||
let source = GitHubTokenSource::mintable(minter);
|
||||
|
||||
let err = format!("{:#}", source.current_token().await.unwrap_err());
|
||||
assert!(err.contains("mint failed"), "got: {err}");
|
||||
}
|
||||
}
|
||||
|
|
@ -354,7 +354,7 @@ mod tests {
|
|||
|
||||
#[derive(Default)]
|
||||
struct MemoryRunStoreBackend {
|
||||
blobs: Mutex<std::collections::HashMap<fabro_types::RunBlobId, Bytes>>,
|
||||
blobs: Mutex<std::collections::HashMap<fabro_types::BlobHash, Bytes>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -389,17 +389,20 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> anyhow::Result<fabro_types::RunBlobId> {
|
||||
let blob_id = fabro_types::RunBlobId::new(data);
|
||||
async fn write_blob(&self, data: &[u8]) -> anyhow::Result<fabro_types::BlobHash> {
|
||||
let blob_hash = fabro_types::BlobHash::new(data);
|
||||
self.blobs
|
||||
.lock()
|
||||
.await
|
||||
.insert(blob_id, Bytes::copy_from_slice(data));
|
||||
Ok(blob_id)
|
||||
.insert(blob_hash, Bytes::copy_from_slice(data));
|
||||
Ok(blob_hash)
|
||||
}
|
||||
|
||||
async fn read_blob(&self, id: &fabro_types::RunBlobId) -> anyhow::Result<Option<Bytes>> {
|
||||
Ok(self.blobs.lock().await.get(id).cloned())
|
||||
async fn read_blob(
|
||||
&self,
|
||||
blob_hash: &fabro_types::BlobHash,
|
||||
) -> anyhow::Result<Option<Bytes>> {
|
||||
Ok(self.blobs.lock().await.get(blob_hash).cloned())
|
||||
}
|
||||
|
||||
async fn read_run_log(&self) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
|
|
@ -1690,7 +1693,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::github_token_source::IatMinter for RefreshingMinter {
|
||||
impl fabro_github::test_support::InstallationTokenMinter for RefreshingMinter {
|
||||
async fn mint(&self) -> anyhow::Result<fabro_github::InstallationToken> {
|
||||
let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
Ok(fabro_github::InstallationToken {
|
||||
|
|
@ -1831,8 +1834,9 @@ mod tests {
|
|||
calls: std::sync::atomic::AtomicUsize::new(0),
|
||||
});
|
||||
let mut services = make_sandbox_services(spy.clone());
|
||||
services.github_token = Some(std::sync::Arc::new(
|
||||
crate::github_token_source::GitHubTokenSource::mintable(minter.clone()),
|
||||
services.github_token = Some(fabro_github::test_support::installation_token_source(
|
||||
"owner/repo",
|
||||
minter.clone(),
|
||||
));
|
||||
|
||||
let handler = CommandHandler;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use fabro_acp::{
|
|||
use fabro_agent::{
|
||||
AgentEvent, RefreshOutcome, Sandbox, StaticEnvProvider, SteeringItem, ToolEnvProvider,
|
||||
};
|
||||
use fabro_github::token_source::REFRESH_MARGIN;
|
||||
use fabro_graphviz::graph::Node;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::{
|
||||
|
|
@ -32,8 +33,13 @@ use crate::handler::NodeTimeoutPolicy;
|
|||
use crate::steering_hub::{ActiveControlHandle, SteeringHub};
|
||||
|
||||
/// Default refresh-ahead interval — comfortably under the ~60-min GitHub App
|
||||
/// installation-token TTL.
|
||||
/// installation-token TTL. Used as the loop cadence when a tick reports no
|
||||
/// managed credentials; ticks that see a real token reschedule from its
|
||||
/// expiry instead.
|
||||
const REFRESH_INTERVAL_DEFAULT: Duration = Duration::from_mins(45);
|
||||
/// Floor for expiry-driven rescheduling, so a token already inside the cache
|
||||
/// margin cannot pin the loop in a hot cycle.
|
||||
const REFRESH_RESCHEDULE_FLOOR: Duration = Duration::from_secs(30);
|
||||
/// Upper bound on a single push-credential refresh (token mint + `git remote
|
||||
/// set-url` exec). The turn-entry refresh runs before the ACP process spawns
|
||||
/// and the ACP node uses `NodeTimeoutPolicy::HandlerManaged`, so without this
|
||||
|
|
@ -100,19 +106,39 @@ fn push_cred_refresh_interval() -> Option<Duration> {
|
|||
)
|
||||
}
|
||||
|
||||
/// Background loop that re-mints the sandbox's push credentials every
|
||||
/// `interval` for the duration of one ACP turn, so a single turn that outlives
|
||||
/// the installation-token TTL still pushes with a fresh token. Bounded by
|
||||
/// `cancel` (the drop-guard cancels it at turn end). A failed or timed-out tick
|
||||
/// retries after a shorter delay so a transient error does not leave a
|
||||
/// longer-than-interval window with an expired token.
|
||||
/// Delay until the next refresh-ahead tick after a successful refresh.
|
||||
///
|
||||
/// With a cached token source, a fixed interval is unsafe: a tick landing
|
||||
/// just outside the cache margin returns a reused token, and a fixed
|
||||
/// 45-minute sleep would leave the embedded token expired until the next
|
||||
/// tick. Schedule from the token's own `expires_at` instead: wake when the
|
||||
/// cache margin opens, so that tick re-mints. `None` disables the loop —
|
||||
/// static credentials cannot be re-minted by waiting.
|
||||
fn next_refresh_delay(outcome: &RefreshOutcome) -> Option<Duration> {
|
||||
let token = outcome.token()?;
|
||||
let expires_at = token.expires_at()?;
|
||||
let margin = chrono::Duration::from_std(REFRESH_MARGIN).unwrap_or(chrono::Duration::MAX);
|
||||
let until_margin = ((expires_at - margin) - chrono::Utc::now())
|
||||
.to_std()
|
||||
.unwrap_or(Duration::ZERO);
|
||||
Some(until_margin.max(REFRESH_RESCHEDULE_FLOOR))
|
||||
}
|
||||
|
||||
/// Background loop that keeps the sandbox's push credentials fresh for the
|
||||
/// duration of one ACP turn, so a single turn that outlives the
|
||||
/// installation-token TTL still pushes with a fresh token. Bounded by
|
||||
/// `cancel` (the drop-guard cancels it at turn end). Each successful tick
|
||||
/// reschedules from the embedded token's expiry ([`next_refresh_delay`]); a
|
||||
/// failed or timed-out tick retries after a shorter delay so a transient
|
||||
/// error does not leave a longer-than-interval window with an expired token.
|
||||
async fn refresh_ahead_loop(
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
cancel: CancellationToken,
|
||||
interval: Duration,
|
||||
initial_delay: Duration,
|
||||
) {
|
||||
let retry_delay = interval.min(Duration::from_mins(1));
|
||||
let mut delay = interval;
|
||||
let mut delay = initial_delay;
|
||||
loop {
|
||||
tokio::select! {
|
||||
() = cancel.cancelled() => break,
|
||||
|
|
@ -120,19 +146,34 @@ async fn refresh_ahead_loop(
|
|||
match timeout(REFRESH_MINT_TIMEOUT, sandbox.refresh_push_credentials())
|
||||
.await
|
||||
{
|
||||
Ok(Ok(RefreshOutcome::Refreshed)) => {
|
||||
tracing::info!(
|
||||
interval_secs = interval.as_secs(),
|
||||
"refresh-ahead re-minted push credentials mid-turn"
|
||||
);
|
||||
delay = interval;
|
||||
}
|
||||
Ok(Ok(RefreshOutcome::Skipped)) => {
|
||||
tracing::debug!(
|
||||
interval_secs = interval.as_secs(),
|
||||
"refresh-ahead tick: no managed push credentials to refresh"
|
||||
);
|
||||
delay = interval;
|
||||
Ok(Ok(outcome)) => {
|
||||
match outcome {
|
||||
RefreshOutcome::Embedded(token) => {
|
||||
tracing::info!(
|
||||
generation = token.generation,
|
||||
"refresh-ahead re-embedded push credentials mid-turn"
|
||||
);
|
||||
}
|
||||
RefreshOutcome::Unchanged(token) => {
|
||||
tracing::debug!(
|
||||
generation = token.generation,
|
||||
"refresh-ahead tick: embedded push credentials still fresh"
|
||||
);
|
||||
}
|
||||
RefreshOutcome::None => {
|
||||
tracing::debug!(
|
||||
"refresh-ahead tick: no managed push credentials to refresh"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(next) = next_refresh_delay(&outcome) {
|
||||
delay = next;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"refresh-ahead loop stopped: static credentials cannot be re-minted"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
|
|
@ -267,64 +308,57 @@ impl AgentAcpBackend {
|
|||
}) as Arc<dyn Fn(String, Option<Principal>) + Send + Sync>
|
||||
});
|
||||
|
||||
// Keep the sandbox's push credentials fresh for the duration of this ACP
|
||||
// turn so the agent's own `git push` uses a live token instead of the one
|
||||
// baked into the clone at run start.
|
||||
//
|
||||
// Part 2 (turn-entry): re-mint + rewrite the origin URL before the ACP
|
||||
// process spawns, covering a push early in the turn. Non-fatal and
|
||||
// timeout-bounded — a stalled mint must neither fail nor hang node entry.
|
||||
// Part 3 (loop): a background task re-mints every ~45 min so a single turn
|
||||
// that itself outlives the ~60-min installation-token TTL still pushes
|
||||
// with a fresh token; a normal sub-interval turn never ticks (the
|
||||
// drop-guard aborts the task at turn end before the first tick).
|
||||
//
|
||||
// FABRO_PUSH_CRED_REFRESH_AHEAD=0 (or false/off/no/empty, case-
|
||||
// insensitive) disables the WHOLE feature — turn-entry re-mint AND loop —
|
||||
// so an operator who manages `origin` themselves can opt out of all
|
||||
// fabro-side origin rewriting. FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS
|
||||
// overrides the loop interval; 0 disables just the loop.
|
||||
//
|
||||
// Known limitations tracked as follow-ups (not addressed here): (a)
|
||||
// resumed/parked runs reconnect the sandbox with no GitHub App creds, so
|
||||
// refresh no-ops until those creds are threaded through the reconnect
|
||||
// path; (b) the turn-entry re-mint has no freshness check, so it mints
|
||||
// once per node entry even when the current token is still fresh; (c) the
|
||||
// background `git remote set-url` can contend with the agent's own git on
|
||||
// `.git/config.lock`; (d) parallel ACP branches each run their own loop;
|
||||
// (e) this refresh lives in the ACP handler only, though the stale-origin
|
||||
// problem is stage-type-agnostic (native/command stages that push are not
|
||||
// covered); (f) refresh failures are logged via tracing but not surfaced
|
||||
// as a RunNotice event on the run stream.
|
||||
// Refresh before launch for early pushes. Schedule later refreshes from
|
||||
// token expiry so the loop cannot sleep past the cache margin.
|
||||
let refresh_enabled = push_cred_refresh_enabled();
|
||||
if refresh_enabled {
|
||||
let refresh_interval = refresh_enabled.then(push_cred_refresh_interval).flatten();
|
||||
let refresh_schedule = if refresh_enabled {
|
||||
match timeout(REFRESH_MINT_TIMEOUT, sandbox.refresh_push_credentials()).await {
|
||||
Ok(Ok(RefreshOutcome::Refreshed)) => {
|
||||
tracing::debug!("refreshed sandbox push credentials at ACP turn entry");
|
||||
Ok(Ok(outcome)) => {
|
||||
match outcome {
|
||||
RefreshOutcome::Embedded(token) => {
|
||||
tracing::debug!(
|
||||
generation = token.generation,
|
||||
"refreshed sandbox push credentials at ACP turn entry"
|
||||
);
|
||||
}
|
||||
RefreshOutcome::Unchanged(token) => {
|
||||
tracing::debug!(
|
||||
generation = token.generation,
|
||||
"sandbox push credentials already fresh at ACP turn entry"
|
||||
);
|
||||
}
|
||||
RefreshOutcome::None => {}
|
||||
}
|
||||
refresh_interval.zip(next_refresh_delay(&outcome))
|
||||
}
|
||||
Ok(Ok(RefreshOutcome::Skipped)) => {}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
error = %fabro_sandbox::display_for_log(&e),
|
||||
"node-entry push-credential refresh failed (non-fatal)"
|
||||
);
|
||||
refresh_interval
|
||||
.map(|interval| (interval, interval.min(Duration::from_mins(1))))
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::warn!(
|
||||
timeout_secs = REFRESH_MINT_TIMEOUT.as_secs(),
|
||||
"node-entry push-credential refresh timed out (non-fatal)"
|
||||
);
|
||||
refresh_interval
|
||||
.map(|interval| (interval, interval.min(Duration::from_mins(1))))
|
||||
}
|
||||
}
|
||||
}
|
||||
let _refresh_ahead_guard: Option<AbortOnDrop> = refresh_enabled
|
||||
.then(push_cred_refresh_interval)
|
||||
.flatten()
|
||||
.map(|interval| {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _refresh_ahead_guard: Option<AbortOnDrop> =
|
||||
refresh_schedule.map(|(interval, initial_delay)| {
|
||||
AbortOnDrop(tokio::spawn(refresh_ahead_loop(
|
||||
Arc::clone(sandbox),
|
||||
cancel_token.child_token(),
|
||||
interval,
|
||||
initial_delay,
|
||||
)))
|
||||
});
|
||||
|
||||
|
|
@ -611,14 +645,18 @@ mod tests {
|
|||
|
||||
use fabro_acp::test_support::fake_acp_agent_script;
|
||||
use fabro_acp::{AcpError, AcpProcessExit};
|
||||
use fabro_agent::{LocalSandbox, RefreshOutcome, Sandbox, shell_quote};
|
||||
use fabro_agent::{
|
||||
LocalSandbox, RefreshOutcome, RemoteCredentialAction, Sandbox, TokenProvenance,
|
||||
TokenSnapshot, shell_quote,
|
||||
};
|
||||
use fabro_graphviz::graph::{AttrValue, Node};
|
||||
use fabro_sandbox::test_support::MockSandbox;
|
||||
use fabro_types::{CommandTermination, EventBody, ExecOutputTail};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{
|
||||
AgentAcpBackend, acp_error_to_workflow, parse_refresh_enabled, parse_refresh_interval,
|
||||
AgentAcpBackend, REFRESH_RESCHEDULE_FLOOR, acp_error_to_workflow, next_refresh_delay,
|
||||
parse_refresh_enabled, parse_refresh_interval, refresh_ahead_loop,
|
||||
};
|
||||
use crate::context::Context;
|
||||
use crate::event::Emitter;
|
||||
|
|
@ -671,17 +709,312 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_reports_skipped_without_managed_credentials() {
|
||||
// MockSandbox uses the trait default (no GitHub App creds), so refresh is
|
||||
// a no-op that must report Skipped — the signal the refresh-ahead loop
|
||||
// relies on to log at debug rather than falsely claim a re-mint.
|
||||
async fn refresh_reports_no_action_without_managed_credentials() {
|
||||
// MockSandbox uses the trait default (no GitHub App creds), so refresh
|
||||
// is a no-op that must report no remote action and no token — the
|
||||
// signal the refresh-ahead loop relies on to log at debug rather than
|
||||
// falsely claim a re-embed.
|
||||
let sandbox = MockSandbox::linux();
|
||||
assert_eq!(
|
||||
sandbox.refresh_push_credentials().await.unwrap(),
|
||||
RefreshOutcome::Skipped
|
||||
RefreshOutcome::none()
|
||||
);
|
||||
}
|
||||
|
||||
fn minted_outcome(
|
||||
action: RemoteCredentialAction,
|
||||
generation: u64,
|
||||
minted_ago: chrono::Duration,
|
||||
expires_in: chrono::Duration,
|
||||
reused: bool,
|
||||
) -> RefreshOutcome {
|
||||
let now = chrono::Utc::now();
|
||||
let minted_at = now - minted_ago;
|
||||
let expires_at = now + expires_in;
|
||||
let provenance = if reused {
|
||||
TokenProvenance::Reused {
|
||||
minted_at,
|
||||
expires_at,
|
||||
}
|
||||
} else {
|
||||
TokenProvenance::Minted {
|
||||
minted_at,
|
||||
expires_at,
|
||||
}
|
||||
};
|
||||
let token = TokenSnapshot {
|
||||
generation,
|
||||
provenance,
|
||||
};
|
||||
match action {
|
||||
RemoteCredentialAction::Embedded => RefreshOutcome::embedded(token),
|
||||
RemoteCredentialAction::Unchanged => RefreshOutcome::unchanged(token),
|
||||
RemoteCredentialAction::None => RefreshOutcome::none(),
|
||||
}
|
||||
}
|
||||
|
||||
fn static_outcome() -> RefreshOutcome {
|
||||
RefreshOutcome::unchanged(TokenSnapshot {
|
||||
generation: 0,
|
||||
provenance: TokenProvenance::Static,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_refresh_delay_schedules_from_token_expiry_minus_margin() {
|
||||
let outcome = minted_outcome(
|
||||
RemoteCredentialAction::Embedded,
|
||||
1,
|
||||
chrono::Duration::zero(),
|
||||
chrono::Duration::minutes(60),
|
||||
false,
|
||||
);
|
||||
let delay = next_refresh_delay(&outcome).unwrap();
|
||||
// Expiry minus the 10-minute refresh margin: ~50 minutes out.
|
||||
assert!(delay > Duration::from_mins(49), "{delay:?}");
|
||||
assert!(delay <= Duration::from_mins(50), "{delay:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_refresh_delay_floors_when_the_margin_is_already_open() {
|
||||
let outcome = minted_outcome(
|
||||
RemoteCredentialAction::Unchanged,
|
||||
1,
|
||||
chrono::Duration::minutes(55),
|
||||
chrono::Duration::minutes(5),
|
||||
true,
|
||||
);
|
||||
assert_eq!(next_refresh_delay(&outcome), Some(REFRESH_RESCHEDULE_FLOOR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_refresh_delay_disables_the_loop_for_static_credentials() {
|
||||
assert_eq!(next_refresh_delay(&static_outcome()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_refresh_delay_disables_the_loop_without_managed_credentials() {
|
||||
assert_eq!(next_refresh_delay(&RefreshOutcome::none()), None);
|
||||
}
|
||||
|
||||
/// Sandbox stub whose refresh outcomes are scripted, recording when each
|
||||
/// refresh tick lands on the (paused) tokio clock.
|
||||
struct ScriptedRefreshSandbox {
|
||||
script: Mutex<std::collections::VecDeque<RefreshOutcome>>,
|
||||
ticks: Mutex<Vec<tokio::time::Instant>>,
|
||||
}
|
||||
|
||||
impl ScriptedRefreshSandbox {
|
||||
fn new(script: Vec<RefreshOutcome>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
script: Mutex::new(script.into()),
|
||||
ticks: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn ticks(&self) -> Vec<tokio::time::Instant> {
|
||||
self.ticks.lock().expect("ticks lock").clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Sandbox for ScriptedRefreshSandbox {
|
||||
async fn refresh_push_credentials(&self) -> fabro_sandbox::Result<RefreshOutcome> {
|
||||
self.ticks
|
||||
.lock()
|
||||
.expect("ticks lock")
|
||||
.push(tokio::time::Instant::now());
|
||||
Ok(self
|
||||
.script
|
||||
.lock()
|
||||
.expect("script lock")
|
||||
.pop_front()
|
||||
.expect("refresh script exhausted"))
|
||||
}
|
||||
|
||||
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
|
||||
unimplemented!("refresh loop only calls refresh_push_credentials")
|
||||
}
|
||||
|
||||
async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_directory(
|
||||
&self,
|
||||
_path: &str,
|
||||
_depth: Option<usize>,
|
||||
) -> fabro_sandbox::Result<Vec<fabro_sandbox::DirEntry>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn exec_command(
|
||||
&self,
|
||||
_command: &str,
|
||||
_timeout_ms: u64,
|
||||
_working_dir: Option<&str>,
|
||||
_env_vars: Option<&HashMap<String, String>>,
|
||||
_cancel_token: Option<CancellationToken>,
|
||||
) -> fabro_sandbox::Result<fabro_sandbox::ExecResult> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn grep(
|
||||
&self,
|
||||
_pattern: &str,
|
||||
_path: &str,
|
||||
_options: &fabro_sandbox::GrepOptions,
|
||||
) -> fabro_sandbox::Result<Vec<String>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn download_file_to_local(
|
||||
&self,
|
||||
_remote_path: &str,
|
||||
_local_path: &std::path::Path,
|
||||
) -> fabro_sandbox::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
_local_path: &std::path::Path,
|
||||
_remote_path: &str,
|
||||
) -> fabro_sandbox::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> fabro_sandbox::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn working_directory(&self) -> &str {
|
||||
"/workspace"
|
||||
}
|
||||
|
||||
fn platform(&self) -> &str {
|
||||
"linux"
|
||||
}
|
||||
|
||||
fn os_version(&self) -> String {
|
||||
"linux".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Long-turn timeline: the clone/turn-entry mint happened at minute 0 with
|
||||
/// a 60-minute TTL. The loop's first tick at minute 45 sees the cached
|
||||
/// token reused with ~15 minutes left and must NOT sleep another fixed 45
|
||||
/// minutes (that would cross expiry at minute 60) — it reschedules for the
|
||||
/// margin opening (~5 minutes out). That margin-crossing tick re-mints and
|
||||
/// reschedules from the fresh token's expiry (~50 minutes out).
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn refresh_ahead_reschedules_from_token_expiry_across_a_long_turn() {
|
||||
let interval = Duration::from_mins(45);
|
||||
let sandbox = ScriptedRefreshSandbox::new(vec![
|
||||
// Minute 45: cache still fresh (expires minute 60, margin opens
|
||||
// minute 50).
|
||||
minted_outcome(
|
||||
RemoteCredentialAction::Unchanged,
|
||||
1,
|
||||
chrono::Duration::minutes(45),
|
||||
chrono::Duration::minutes(15),
|
||||
true,
|
||||
),
|
||||
// Minute ~50: margin open → the source minted generation 2.
|
||||
minted_outcome(
|
||||
RemoteCredentialAction::Embedded,
|
||||
2,
|
||||
chrono::Duration::zero(),
|
||||
chrono::Duration::minutes(60),
|
||||
false,
|
||||
),
|
||||
// Minute ~100: generation 2 still fresh.
|
||||
minted_outcome(
|
||||
RemoteCredentialAction::Unchanged,
|
||||
2,
|
||||
chrono::Duration::minutes(50),
|
||||
chrono::Duration::minutes(10),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
let cancel = CancellationToken::new();
|
||||
let start = tokio::time::Instant::now();
|
||||
let loop_task = tokio::spawn(refresh_ahead_loop(
|
||||
Arc::clone(&sandbox) as Arc<dyn Sandbox>,
|
||||
cancel.clone(),
|
||||
interval,
|
||||
interval,
|
||||
));
|
||||
|
||||
while sandbox.ticks().len() < 3 {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
cancel.cancel();
|
||||
loop_task.await.expect("refresh loop should exit cleanly");
|
||||
|
||||
let ticks = sandbox.ticks();
|
||||
assert_eq!(ticks[0] - start, interval, "first tick uses the interval");
|
||||
// Reused token expiring in 15 minutes → next tick when the 10-minute
|
||||
// margin opens, ~5 minutes later (never another fixed 45 minutes).
|
||||
let second_gap = ticks[1] - ticks[0];
|
||||
assert!(second_gap <= Duration::from_mins(5), "{second_gap:?}");
|
||||
assert!(second_gap > Duration::from_mins(4), "{second_gap:?}");
|
||||
// Fresh 60-minute token → next tick ~50 minutes out.
|
||||
let third_gap = ticks[2] - ticks[1];
|
||||
assert!(third_gap <= Duration::from_mins(50), "{third_gap:?}");
|
||||
assert!(third_gap > Duration::from_mins(49), "{third_gap:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn refresh_ahead_honors_the_expiry_based_initial_delay() {
|
||||
let interval = Duration::from_mins(45);
|
||||
let entry_outcome = minted_outcome(
|
||||
RemoteCredentialAction::Unchanged,
|
||||
1,
|
||||
chrono::Duration::minutes(45),
|
||||
chrono::Duration::minutes(15),
|
||||
true,
|
||||
);
|
||||
let initial_delay = next_refresh_delay(&entry_outcome).unwrap();
|
||||
let sandbox = ScriptedRefreshSandbox::new(vec![minted_outcome(
|
||||
RemoteCredentialAction::Embedded,
|
||||
2,
|
||||
chrono::Duration::zero(),
|
||||
chrono::Duration::minutes(60),
|
||||
false,
|
||||
)]);
|
||||
let cancel = CancellationToken::new();
|
||||
let start = tokio::time::Instant::now();
|
||||
let loop_task = tokio::spawn(refresh_ahead_loop(
|
||||
Arc::clone(&sandbox) as Arc<dyn Sandbox>,
|
||||
cancel.clone(),
|
||||
interval,
|
||||
initial_delay,
|
||||
));
|
||||
|
||||
while sandbox.ticks().is_empty() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
cancel.cancel();
|
||||
loop_task.await.expect("refresh loop should exit cleanly");
|
||||
|
||||
let first_tick = sandbox.ticks()[0] - start;
|
||||
assert!(first_tick <= Duration::from_mins(5), "{first_tick:?}");
|
||||
assert!(first_tick > Duration::from_mins(4), "{first_tick:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acp_backend_run_sends_prompt_and_returns_text() {
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use std::time::Duration;
|
|||
use async_trait::async_trait;
|
||||
use fabro_graphviz::graph::{AttrValue, Graph, Node};
|
||||
use fabro_store::{ArtifactStore, Database};
|
||||
use fabro_template::validate_static_reference;
|
||||
use fabro_types::WorkflowSettings;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use object_store::memory::InMemory;
|
||||
use tokio::fs;
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
|
@ -20,7 +22,6 @@ use crate::operations::{ValidateInput, WorkflowInput, validate_with_catalog};
|
|||
use crate::outcome::{Outcome, OutcomeExt, StageOutcome};
|
||||
use crate::pipeline::types::Initialized;
|
||||
use crate::run_options::RunOptions;
|
||||
use crate::static_reference::{ReferenceKind, validate_static_reference};
|
||||
use crate::{ManifestPath, pipeline, stage_scope};
|
||||
|
||||
/// Orchestrates a child workflow engine, polling for completion or stop
|
||||
|
|
|
|||
|
|
@ -1847,7 +1847,7 @@ mod tests {
|
|||
Some(serde_json::json!({"not": "an array"})),
|
||||
Some(serde_json::json!("ordinary string")),
|
||||
Some(serde_json::json!(format_blob_ref(
|
||||
&fabro_types::RunBlobId::new(b"missing")
|
||||
&fabro_types::BlobHash::new(b"missing")
|
||||
))),
|
||||
] {
|
||||
let (handler, calls) = ScriptedHandler::new(Scripted::Succeed);
|
||||
|
|
@ -1917,7 +1917,7 @@ mod tests {
|
|||
"name": "large-item",
|
||||
"body": "x".repeat(101 * 1024)
|
||||
}]);
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&serde_json::to_vec(&items).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1933,7 +1933,7 @@ mod tests {
|
|||
)));
|
||||
let (node, graph) = for_each_graph("items", 1);
|
||||
let context = test_context();
|
||||
context.set("items", serde_json::json!(format_blob_ref(&blob_id)));
|
||||
context.set("items", serde_json::json!(format_blob_ref(&blob_hash)));
|
||||
|
||||
let outcome = ParallelHandler
|
||||
.execute(&node, &context, &graph, sandbox_dir.path(), &services)
|
||||
|
|
|
|||
|
|
@ -293,7 +293,6 @@ pub mod error;
|
|||
pub mod event;
|
||||
pub mod file_resolver;
|
||||
pub mod git;
|
||||
pub mod github_token_source;
|
||||
pub(crate) mod graph;
|
||||
pub mod handler;
|
||||
mod hook_context;
|
||||
|
|
@ -332,7 +331,6 @@ pub(crate) mod sandbox_git_runtime;
|
|||
pub mod services;
|
||||
pub(crate) mod stage_execution;
|
||||
mod stage_scope;
|
||||
pub mod static_reference;
|
||||
pub mod steering_hub;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
|
|
|
|||
|
|
@ -613,7 +613,7 @@ mod tests {
|
|||
use fabro_model::Catalog;
|
||||
use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection};
|
||||
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
|
||||
use fabro_types::{EventBody, RunBlobId, RunEvent, WorkflowSettings, fixtures, test_support};
|
||||
use fabro_types::{BlobHash, EventBody, RunEvent, WorkflowSettings, fixtures, test_support};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -1324,11 +1324,11 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
Ok(RunBlobId::new(data))
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
Ok(BlobHash::new(data))
|
||||
}
|
||||
|
||||
async fn read_blob(&self, _id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
async fn read_blob(&self, _blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,8 +117,8 @@ mod tests {
|
|||
|
||||
use fabro_store::{Database, RunProjectionReducer};
|
||||
use fabro_types::{
|
||||
AuthMethod, DirtyStatus, FailureReason, ForkSourceRef, GitContext, Graph, IdpIdentity,
|
||||
Principal, PullRequestLink, RunBlobId, RunRunnableSource, RunServerProvenance, RunTiming,
|
||||
AuthMethod, BlobHash, DirtyStatus, FailureReason, ForkSourceRef, GitContext, Graph,
|
||||
IdpIdentity, Principal, PullRequestLink, RunRunnableSource, RunServerProvenance, RunTiming,
|
||||
WorkflowSettings, fixtures,
|
||||
};
|
||||
use object_store::memory::InMemory;
|
||||
|
|
@ -164,7 +164,7 @@ mod tests {
|
|||
async fn append_created(
|
||||
store: &fabro_store::RunDatabase,
|
||||
run_id: RunId,
|
||||
manifest_blob: Option<RunBlobId>,
|
||||
manifest_blob: Option<BlobHash>,
|
||||
fork_source_ref: Option<ForkSourceRef>,
|
||||
) {
|
||||
let mut settings = WorkflowSettings::default();
|
||||
|
|
@ -248,7 +248,7 @@ mod tests {
|
|||
async fn seed_retryable_failed_source(
|
||||
store: &Database,
|
||||
source_run_id: RunId,
|
||||
) -> (Option<RunBlobId>, Option<RunBlobId>, ForkSourceRef) {
|
||||
) -> (Option<BlobHash>, Option<BlobHash>, ForkSourceRef) {
|
||||
let source_store = store.create_run(&source_run_id).await.unwrap();
|
||||
let manifest_blob = Some(
|
||||
source_store
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result<
|
|||
|
||||
/// Resolve the `run.goal` override for a direct (non-manifest) workflow
|
||||
/// run. Reads the file from disk if the goal layer is the `file` variant.
|
||||
/// Relative paths that survived config load (e.g. env-interpolated ones)
|
||||
/// are anchored at `working_directory`.
|
||||
/// Relative paths that survived config load are anchored at
|
||||
/// `working_directory`.
|
||||
fn resolve_goal_override(
|
||||
settings: &WorkflowSettings,
|
||||
working_directory: &Path,
|
||||
|
|
|
|||
|
|
@ -359,8 +359,8 @@ impl RunSession {
|
|||
let git = git_checkpoint_options_from_start(settings, &record.run_id, state.start);
|
||||
let definition_blob = state.spec.definition_blob;
|
||||
let accepted_definition = match definition_blob {
|
||||
Some(blob_id) => {
|
||||
Some(load_accepted_run_definition(&services.run_store, blob_id).await?)
|
||||
Some(blob_hash) => {
|
||||
Some(load_accepted_run_definition(&services.run_store, blob_hash).await?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
|
@ -570,15 +570,15 @@ fn vault_token_lookup(vault: &Vault, name: &str) -> Option<String> {
|
|||
|
||||
async fn load_accepted_run_definition(
|
||||
run_store: &RunStoreHandle,
|
||||
blob_id: fabro_types::RunBlobId,
|
||||
blob_hash: fabro_types::BlobHash,
|
||||
) -> Result<RunDefinition, Error> {
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.read_blob(&blob_hash)
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
Error::engine(format!(
|
||||
"run definition blob is missing from the run store: {blob_id}"
|
||||
"run definition blob is missing from the run store: {blob_hash}"
|
||||
))
|
||||
})?;
|
||||
serde_json::from_slice(&bytes).map_err(|err| Error::Parse(err.to_string()))
|
||||
|
|
|
|||
|
|
@ -751,11 +751,11 @@ impl HandlerTrait for BlobCommandOutputHandler {
|
|||
services: &crate::handler::EngineServices,
|
||||
) -> std::result::Result<Outcome, Error> {
|
||||
let blob = serde_json::to_vec("routed-ok").unwrap();
|
||||
let blob_id = services.run.run_store.write_blob(&blob).await.unwrap();
|
||||
let blob_hash = services.run.run_store.write_blob(&blob).await.unwrap();
|
||||
let mut outcome = Outcome::success();
|
||||
outcome.context_updates.insert(
|
||||
context::keys::COMMAND_OUTPUT.to_string(),
|
||||
serde_json::json!(format_blob_ref(&blob_id)),
|
||||
serde_json::json!(format_blob_ref(&blob_hash)),
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -684,7 +684,7 @@ mod tests {
|
|||
use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection};
|
||||
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
|
||||
use fabro_types::{
|
||||
BilledTokenCounts, EventBody, RunBlobId, RunEvent, RunId, RunSpec, StageCompletion,
|
||||
BilledTokenCounts, BlobHash, EventBody, RunEvent, RunId, RunSpec, StageCompletion,
|
||||
WorkflowSettings, first_event_seq, fixtures, test_support,
|
||||
};
|
||||
use object_store::memory::InMemory;
|
||||
|
|
@ -1819,11 +1819,11 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
Ok(RunBlobId::new(data))
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
Ok(BlobHash::new(data))
|
||||
}
|
||||
|
||||
async fn read_blob(&self, _id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
async fn read_blob(&self, _blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use fabro_agent::{Sandbox, ToolSecrets};
|
|||
use fabro_auth::{
|
||||
CredentialSource, ExtraHeadersCredentialSource, VaultCredentialSource, auth_issue_message,
|
||||
};
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use fabro_graphviz::graph;
|
||||
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner};
|
||||
use fabro_model::Catalog;
|
||||
|
|
@ -23,7 +24,6 @@ use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec}
|
|||
use crate::error::Error;
|
||||
use crate::event::{Event, RunNoticeCode, RunNoticeLevel};
|
||||
use crate::git::GitAuthor;
|
||||
use crate::github_token_source::{AppIatMinter, GitHubTokenSource};
|
||||
use crate::handler::llm::{AgentAcpBackend, AgentApiBackend, BackendRouter, routing};
|
||||
use crate::handler::{HandlerRegistry, default_registry};
|
||||
#[cfg(test)]
|
||||
|
|
@ -37,7 +37,10 @@ use crate::services::{
|
|||
use crate::stage_execution::{StageExecutionSeed, StageExecutionTracker};
|
||||
use crate::steering_hub::SteeringHub;
|
||||
|
||||
type BuiltSandboxEnv = (HashMap<String, String>, Option<Arc<GitHubTokenSource>>);
|
||||
type BuiltSandboxEnv = (
|
||||
HashMap<String, String>,
|
||||
Option<Arc<InstallationTokenSource>>,
|
||||
);
|
||||
|
||||
async fn run_hooks(
|
||||
hook_runner: Option<&HookRunner>,
|
||||
|
|
@ -99,12 +102,12 @@ fn build_sandbox_env(
|
|||
|
||||
let source = match creds {
|
||||
fabro_github::GitHubCredentials::Pat(token) => {
|
||||
Some(Arc::new(GitHubTokenSource::pat(token.clone())))
|
||||
Some(InstallationTokenSource::pat(token.clone()))
|
||||
}
|
||||
fabro_github::GitHubCredentials::Installation(token) => {
|
||||
Some(Arc::new(GitHubTokenSource::static_iat(token.clone())))
|
||||
Some(InstallationTokenSource::installation(token.clone()))
|
||||
}
|
||||
fabro_github::GitHubCredentials::App(app) => {
|
||||
fabro_github::GitHubCredentials::App(_) => {
|
||||
let Some(origin_url) = spec.origin_url.as_deref() else {
|
||||
return Ok((env, None));
|
||||
};
|
||||
|
|
@ -114,19 +117,11 @@ fn build_sandbox_env(
|
|||
let permissions = serde_json::to_value(permissions).map_err(|err| {
|
||||
Error::engine_with_source("Failed to serialize GitHub permissions", err)
|
||||
})?;
|
||||
let http = fabro_http::http_client()
|
||||
.map_err(|err| Error::engine_with_source("Failed to build HTTP client", err))?;
|
||||
let install_url = app.installation_url(&owner);
|
||||
let minter = AppIatMinter::new(
|
||||
app.clone(),
|
||||
http,
|
||||
owner,
|
||||
repo,
|
||||
fabro_github::github_api_base_url(),
|
||||
install_url,
|
||||
permissions,
|
||||
);
|
||||
Some(Arc::new(GitHubTokenSource::mintable(Arc::new(minter))))
|
||||
Some(
|
||||
InstallationTokenSource::for_repository(creds, owner, repo, permissions).map_err(
|
||||
|err| Error::engine_with_anyhow("Failed to build GitHub token source", err),
|
||||
)?,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -458,7 +453,7 @@ pub async fn initialize(
|
|||
});
|
||||
let github_token_refresh_managed = github_token
|
||||
.as_deref()
|
||||
.is_some_and(GitHubTokenSource::is_refreshable);
|
||||
.is_some_and(InstallationTokenSource::mints_installation_tokens);
|
||||
let (registry, effective_dry_run) = if let Some(registry) = options.registry_override.clone() {
|
||||
// A caller-supplied registry owns execution behavior for its handlers.
|
||||
(registry, options.dry_run)
|
||||
|
|
@ -600,20 +595,21 @@ pub async fn initialize(
|
|||
});
|
||||
}
|
||||
|
||||
let metadata_writer = match build_metadata_writer(&options.run_options) {
|
||||
Ok(writer) => writer,
|
||||
Err(err) => {
|
||||
let message = format!("failed to initialize checkpoint metadata writer: {err}");
|
||||
if metadata_runtime.mark_metadata_degraded() {
|
||||
options.emitter.notice(
|
||||
RunNoticeLevel::Warn,
|
||||
RunNoticeCode::CheckpointMetadataWriteFailed,
|
||||
message,
|
||||
);
|
||||
let metadata_writer =
|
||||
match build_metadata_writer(&options.run_options, sandbox.push_token_source()) {
|
||||
Ok(writer) => writer,
|
||||
Err(err) => {
|
||||
let message = format!("failed to initialize checkpoint metadata writer: {err}");
|
||||
if metadata_runtime.mark_metadata_degraded() {
|
||||
options.emitter.notice(
|
||||
RunNoticeLevel::Warn,
|
||||
RunNoticeCode::CheckpointMetadataWriteFailed,
|
||||
message,
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let run_services = RunServices::new(
|
||||
options.run_store.clone(),
|
||||
|
|
|
|||
|
|
@ -445,13 +445,11 @@ fn run_id_matches(run_id: RunId, prefix: &str) -> bool {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_graphviz::graph::Graph;
|
||||
use fabro_store::Database;
|
||||
use fabro_types::{RunStatus, WorkflowSettings, fixtures, test_support};
|
||||
use fabro_types::{RunStatus, fixtures, test_support};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::scan_runs_combined;
|
||||
|
|
@ -470,24 +468,15 @@ mod tests {
|
|||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
automation: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
git: Some(fabro_types::GitContext {
|
||||
git: Some(fabro_types::GitContext {
|
||||
origin_url: String::new(),
|
||||
branch: "main".to_string(),
|
||||
sha: None,
|
||||
dirty: fabro_types::DirtyStatus::Clean,
|
||||
}),
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use async_trait::async_trait;
|
||||
use fabro_checkpoint::git::{FileMode, Store, TreeEntries};
|
||||
use fabro_dump::RunDump;
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use git2::{
|
||||
Cred, Direction, ErrorClass, ErrorCode, FetchOptions, Oid, PushOptions, RemoteCallbacks,
|
||||
Repository, Signature,
|
||||
|
|
@ -16,13 +15,6 @@ use tokio::task::{self, JoinError};
|
|||
use crate::git::{GitAuthor, META_BRANCH_PREFIX};
|
||||
use crate::run_options::RunOptions;
|
||||
|
||||
static METADATA_PERMISSIONS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
|
||||
[("contents", "write")]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||
.collect()
|
||||
});
|
||||
|
||||
pub(crate) fn metadata_branch_name(run_id: &str) -> String {
|
||||
format!("{META_BRANCH_PREFIX}{run_id}")
|
||||
}
|
||||
|
|
@ -92,22 +84,22 @@ pub(crate) trait AuthProvider: Send + Sync {
|
|||
}
|
||||
|
||||
struct GitHubAuthProvider {
|
||||
creds: fabro_github::GitHubCredentials,
|
||||
origin_url: String,
|
||||
source: Arc<InstallationTokenSource>,
|
||||
}
|
||||
|
||||
impl GitHubAuthProvider {
|
||||
fn new(creds: fabro_github::GitHubCredentials, origin_url: String) -> Self {
|
||||
Self { creds, origin_url }
|
||||
fn new(source: Arc<InstallationTokenSource>) -> Self {
|
||||
Self { source }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthProvider for GitHubAuthProvider {
|
||||
async fn token(&self) -> Result<Option<String>, RunMetadataError> {
|
||||
mint_token(&self.creds, &self.origin_url, &METADATA_PERMISSIONS)
|
||||
self.source
|
||||
.resolve()
|
||||
.await
|
||||
.map(Some)
|
||||
.map(|resolved| Some(resolved.token.expose().to_owned()))
|
||||
.map_err(RunMetadataError::TokenMint)
|
||||
}
|
||||
}
|
||||
|
|
@ -184,6 +176,7 @@ impl RunMetadataWriterHandle {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "metadata-push"))]
|
||||
pub(crate) async fn write_snapshot(
|
||||
&self,
|
||||
dump: &RunDump,
|
||||
|
|
@ -207,6 +200,7 @@ impl RunMetadataWriterHandle {
|
|||
|
||||
pub(crate) fn build_metadata_writer(
|
||||
run_options: &RunOptions,
|
||||
token_source: Option<Arc<InstallationTokenSource>>,
|
||||
) -> Result<Option<RunMetadataWriterHandle>, RunMetadataError> {
|
||||
if !run_options.settings.run.meta_branch.enabled {
|
||||
return Ok(None);
|
||||
|
|
@ -229,14 +223,21 @@ pub(crate) fn build_metadata_writer(
|
|||
if !normalized_url.starts_with("https://") {
|
||||
return Ok(None);
|
||||
}
|
||||
if fabro_github::parse_github_owner_repo(&normalized_url).is_err() {
|
||||
let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&normalized_url) else {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let auth = Arc::new(GitHubAuthProvider::new(
|
||||
creds.clone(),
|
||||
normalized_url.clone(),
|
||||
));
|
||||
let source = match token_source {
|
||||
Some(source) => source,
|
||||
None => InstallationTokenSource::for_repository(
|
||||
creds,
|
||||
owner,
|
||||
repo,
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
.map_err(RunMetadataError::TokenMint)?,
|
||||
};
|
||||
let auth = Arc::new(GitHubAuthProvider::new(source));
|
||||
let writer = RunMetadataWriter::new(
|
||||
normalized_url,
|
||||
meta_branch.clone(),
|
||||
|
|
@ -247,28 +248,6 @@ pub(crate) fn build_metadata_writer(
|
|||
Ok(Some(RunMetadataWriterHandle::new(writer, auth)))
|
||||
}
|
||||
|
||||
pub(crate) async fn mint_token(
|
||||
creds: &fabro_github::GitHubCredentials,
|
||||
origin_url: &str,
|
||||
permissions: &HashMap<String, String>,
|
||||
) -> anyhow::Result<String> {
|
||||
let normalized_url = fabro_github::normalize_repo_origin_url(origin_url);
|
||||
let (owner, repo) =
|
||||
fabro_github::parse_github_owner_repo(&normalized_url).context("parsing GitHub origin")?;
|
||||
let client = fabro_http::http_client().map_err(anyhow::Error::new)?;
|
||||
let permissions =
|
||||
serde_json::to_value(permissions).context("serializing GitHub permissions")?;
|
||||
creds
|
||||
.resolve_bearer_token(
|
||||
&client,
|
||||
&owner,
|
||||
&repo,
|
||||
&fabro_github::github_api_base_url(),
|
||||
permissions,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) struct RunMetadataWriter {
|
||||
store: Store,
|
||||
tempdir: tempfile::TempDir,
|
||||
|
|
@ -1010,16 +989,19 @@ mod tests {
|
|||
|
||||
for (origin, expected) in cases {
|
||||
let options = run_options_for_origin(origin);
|
||||
let handle = build_metadata_writer(&options).unwrap().unwrap();
|
||||
let handle = build_metadata_writer(&options, None).unwrap().unwrap();
|
||||
assert_eq!(handle.remote_url_for_test(), expected);
|
||||
assert!(!handle.remote_url_for_test().contains("ghs_aaaaaa"));
|
||||
assert!(!handle.remote_url_for_test().contains('@'));
|
||||
}
|
||||
|
||||
assert!(
|
||||
build_metadata_writer(&run_options_for_origin("https://gitlab.com/owner/repo.git"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
build_metadata_writer(
|
||||
&run_options_for_origin("https://gitlab.com/owner/repo.git"),
|
||||
None
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1028,6 +1010,6 @@ mod tests {
|
|||
let mut options = run_options_for_origin("https://github.com/owner/repo.git");
|
||||
options.settings.run.meta_branch.enabled = false;
|
||||
|
||||
assert!(build_metadata_writer(&options).unwrap().is_none());
|
||||
assert!(build_metadata_writer(&options, None).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ pub struct LifecycleOptions {
|
|||
}
|
||||
|
||||
/// A single setup (prepare) command and the per-step environment it runs with.
|
||||
/// Both the command string and the env values are already fully resolved (their
|
||||
/// `{{ env.* }}` tokens replaced at the run boundary) by the time they reach
|
||||
/// Both the command string and the env values are already fully resolved
|
||||
/// (interpolation tokens replaced at the run boundary) by the time they reach
|
||||
/// the sandbox.
|
||||
pub struct SetupCommand {
|
||||
pub command: String,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use anyhow::Result;
|
|||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use fabro_store::{EventEnvelope, RunDatabase, RunProjection};
|
||||
use fabro_types::{RunBlobId, RunEvent};
|
||||
use fabro_types::{BlobHash, RunEvent};
|
||||
|
||||
use crate::event::build_redacted_event_payload;
|
||||
|
||||
|
|
@ -13,8 +13,8 @@ pub trait RunStoreBackend: Send + Sync {
|
|||
async fn load_state(&self) -> Result<RunProjection>;
|
||||
async fn list_events(&self) -> Result<Vec<EventEnvelope>>;
|
||||
async fn append_run_event(&self, event: &RunEvent) -> Result<()>;
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId>;
|
||||
async fn read_blob(&self, id: &RunBlobId) -> Result<Option<Bytes>>;
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash>;
|
||||
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>>;
|
||||
async fn read_run_log(&self) -> Result<Option<Vec<u8>>>;
|
||||
}
|
||||
|
||||
|
|
@ -46,12 +46,12 @@ impl RunStoreHandle {
|
|||
self.backend.append_run_event(event).await
|
||||
}
|
||||
|
||||
pub async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
pub async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
self.backend.write_blob(data).await
|
||||
}
|
||||
|
||||
pub async fn read_blob(&self, id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
self.backend.read_blob(id).await
|
||||
pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.backend.read_blob(blob_hash).await
|
||||
}
|
||||
|
||||
pub async fn read_run_log(&self) -> Result<Option<Vec<u8>>> {
|
||||
|
|
@ -91,16 +91,16 @@ impl RunStoreBackend for LocalRunStoreBackend {
|
|||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
|
||||
self.run_store
|
||||
.write_blob(data)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
async fn read_blob(&self, id: &RunBlobId) -> Result<Option<Bytes>> {
|
||||
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.run_store
|
||||
.read_blob(id)
|
||||
.read_blob(blob_hash)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
|
@ -112,15 +112,13 @@ impl RunStoreBackend for LocalRunStoreBackend {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use fabro_graphviz::graph::Graph;
|
||||
use fabro_store::Database;
|
||||
use fabro_types::run_event::RunSubmittedProps;
|
||||
use fabro_types::{EventBody, RunEvent, WorkflowSettings, fixtures, test_support};
|
||||
use fabro_types::{EventBody, RunEvent, fixtures, test_support};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::RunStoreHandle;
|
||||
|
|
@ -139,19 +137,9 @@ mod tests {
|
|||
|
||||
fn test_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
automation: None,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
source_directory: Some("/tmp/test".to_string()),
|
||||
git: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
fork_source_ref: None,
|
||||
..test_support::test_run_spec()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,8 +205,8 @@ mod tests {
|
|||
};
|
||||
handle.append_run_event(&event).await.unwrap();
|
||||
|
||||
let blob_id = handle.write_blob(br#"{"ok":true}"#).await.unwrap();
|
||||
let blob = handle.read_blob(&blob_id).await.unwrap().unwrap();
|
||||
let blob_hash = handle.write_blob(br#"{"ok":true}"#).await.unwrap();
|
||||
let blob = handle.read_blob(&blob_hash).await.unwrap().unwrap();
|
||||
let events = handle.list_events().await.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 2);
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ pub async fn git_checkpoint(
|
|||
clippy::too_many_arguments,
|
||||
reason = "Checkpointing needs explicit run metadata, checkpoint settings, and author inputs."
|
||||
)]
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "checkpoint-commit"))]
|
||||
pub(crate) async fn checked_git_checkpoint(
|
||||
runtime: &SandboxGitRuntime,
|
||||
sandbox: &dyn Sandbox,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use fabro_agent::{Sandbox, ToolEnvProvider};
|
|||
use fabro_auth::CredentialSource;
|
||||
#[cfg(test)]
|
||||
use fabro_auth::ResolvedCredentials;
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner};
|
||||
use fabro_interview::Interviewer;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
|
|
@ -15,7 +16,6 @@ use fabro_types::{ManifestPath, RunId};
|
|||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::event::Emitter;
|
||||
use crate::github_token_source::GitHubTokenSource;
|
||||
use crate::handler::HandlerRegistry;
|
||||
use crate::interview_runtime::RunInterviewBlocker;
|
||||
use crate::run_metadata::{RunMetadataRuntime, RunMetadataWriterHandle};
|
||||
|
|
@ -238,7 +238,7 @@ pub struct EngineServices {
|
|||
/// Environment variables from `[sandbox.env]` config.
|
||||
pub base_env: HashMap<String, String>,
|
||||
/// GitHub token source used to inject `GITHUB_TOKEN` at the point of use.
|
||||
pub github_token: Option<Arc<GitHubTokenSource>>,
|
||||
pub github_token: Option<Arc<InstallationTokenSource>>,
|
||||
/// Typed values from `[run.inputs]`, available to prompt templates.
|
||||
pub inputs: HashMap<String, toml::Value>,
|
||||
/// When true, handlers should skip real execution and return simulated
|
||||
|
|
@ -342,7 +342,7 @@ impl EngineServices {
|
|||
|
||||
pub struct WorkflowToolEnvProvider {
|
||||
pub base_env: HashMap<String, String>,
|
||||
pub github_token: Option<Arc<GitHubTokenSource>>,
|
||||
pub github_token: Option<Arc<InstallationTokenSource>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -354,11 +354,15 @@ impl ToolEnvProvider for WorkflowToolEnvProvider {
|
|||
|
||||
async fn resolve_workflow_env(
|
||||
base_env: &HashMap<String, String>,
|
||||
github_token: Option<&Arc<GitHubTokenSource>>,
|
||||
github_token: Option<&Arc<InstallationTokenSource>>,
|
||||
) -> anyhow::Result<HashMap<String, String>> {
|
||||
let mut env = base_env.clone();
|
||||
if let Some(source) = github_token {
|
||||
env.insert("GITHUB_TOKEN".to_string(), source.current_token().await?);
|
||||
let resolved = source.resolve().await?;
|
||||
env.insert(
|
||||
"GITHUB_TOKEN".to_string(),
|
||||
resolved.token.expose().to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(env)
|
||||
}
|
||||
|
|
@ -371,9 +375,10 @@ mod tests {
|
|||
use anyhow::anyhow;
|
||||
use fabro_agent::ToolEnvProvider as _;
|
||||
use fabro_github::InstallationToken;
|
||||
use fabro_github::test_support::{InstallationTokenMinter, installation_token_source};
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
|
||||
use super::{EngineServices, WorkflowToolEnvProvider};
|
||||
use crate::github_token_source::{GitHubTokenSource, IatMinter};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_uses_stub_credential_source() {
|
||||
|
|
@ -406,7 +411,7 @@ mod tests {
|
|||
async fn workflow_tool_env_provider_merges_current_github_token() {
|
||||
let provider = WorkflowToolEnvProvider {
|
||||
base_env: HashMap::from([("FOO".to_string(), "bar".to_string())]),
|
||||
github_token: Some(Arc::new(GitHubTokenSource::pat("ghp_pat".to_string()))),
|
||||
github_token: Some(InstallationTokenSource::pat("ghp_pat".to_string())),
|
||||
};
|
||||
|
||||
let env = provider.resolve().await.unwrap();
|
||||
|
|
@ -418,7 +423,7 @@ mod tests {
|
|||
struct FailingMinter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IatMinter for FailingMinter {
|
||||
impl InstallationTokenMinter for FailingMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
Err(anyhow!("GITHUB_TOKEN refresh failed"))
|
||||
}
|
||||
|
|
@ -428,9 +433,10 @@ mod tests {
|
|||
async fn workflow_tool_env_provider_propagates_token_refresh_errors() {
|
||||
let provider = WorkflowToolEnvProvider {
|
||||
base_env: HashMap::new(),
|
||||
github_token: Some(Arc::new(GitHubTokenSource::mintable(Arc::new(
|
||||
FailingMinter,
|
||||
)))),
|
||||
github_token: Some(installation_token_source(
|
||||
"owner/repo",
|
||||
Arc::new(FailingMinter),
|
||||
)),
|
||||
};
|
||||
|
||||
let err = format!("{:#}", provider.resolve().await.unwrap_err());
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
use std::fmt;
|
||||
|
||||
use fabro_template::contains_template_syntax;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ReferenceKind {
|
||||
FileInline,
|
||||
Import,
|
||||
ChildWorkflow,
|
||||
Dockerfile,
|
||||
GraphGoalFile,
|
||||
}
|
||||
|
||||
impl fmt::Display for ReferenceKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let label = match self {
|
||||
Self::FileInline => "file inline reference",
|
||||
Self::Import => "import reference",
|
||||
Self::ChildWorkflow => "child workflow reference",
|
||||
Self::Dockerfile => "Dockerfile reference",
|
||||
Self::GraphGoalFile => "graph goal file reference",
|
||||
};
|
||||
f.write_str(label)
|
||||
}
|
||||
}
|
||||
|
||||
impl ReferenceKind {
|
||||
pub fn validate(self, value: &str) -> Result<(), StaticReferenceError> {
|
||||
validate_static_reference(value, self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AttributeScope {
|
||||
Graph,
|
||||
Node,
|
||||
Edge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("templates are not supported in {kind}s: {value}")]
|
||||
pub struct StaticReferenceError {
|
||||
kind: ReferenceKind,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl StaticReferenceError {
|
||||
#[must_use]
|
||||
pub fn new(kind: ReferenceKind, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> ReferenceKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_static_reference(
|
||||
value: &str,
|
||||
kind: ReferenceKind,
|
||||
) -> Result<(), StaticReferenceError> {
|
||||
if contains_template_syntax(value) {
|
||||
return Err(StaticReferenceError::new(kind, value));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn reference_kind_for_attribute(
|
||||
scope: AttributeScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Option<ReferenceKind> {
|
||||
match key {
|
||||
"import" => Some(ReferenceKind::Import),
|
||||
"stack.child_workflow" | "stack.child_dotfile" => Some(ReferenceKind::ChildWorkflow),
|
||||
"goal" if matches!(scope, AttributeScope::Graph) && value.starts_with('@') => {
|
||||
Some(ReferenceKind::GraphGoalFile)
|
||||
}
|
||||
"prompt" | "output_schema"
|
||||
if matches!(scope, AttributeScope::Node) && value.starts_with('@') =>
|
||||
{
|
||||
Some(ReferenceKind::FileInline)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn output_schema_at_value_is_file_inline_reference() {
|
||||
assert_eq!(
|
||||
reference_kind_for_attribute(
|
||||
AttributeScope::Node,
|
||||
"output_schema",
|
||||
"@schemas/result.schema.json",
|
||||
),
|
||||
Some(ReferenceKind::FileInline),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_builtin_keyword_is_not_file_inline_reference() {
|
||||
assert_eq!(
|
||||
reference_kind_for_attribute(AttributeScope::Node, "output_schema", "routing"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_reference_rejects_template_syntax() {
|
||||
let error = reference_kind_for_attribute(
|
||||
AttributeScope::Node,
|
||||
"output_schema",
|
||||
"@schemas/{{ inputs.schema }}.json",
|
||||
)
|
||||
.expect("output_schema @ references should be static references")
|
||||
.validate("@schemas/{{ inputs.schema }}.json")
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.kind(), ReferenceKind::FileInline);
|
||||
assert_eq!(error.value(), "@schemas/{{ inputs.schema }}.json");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("templates are not supported in file inline references"),
|
||||
"unexpected error: {error}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,14 +4,14 @@ use std::sync::Arc;
|
|||
|
||||
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::TemplateContext;
|
||||
use fabro_template::{TemplateContext, validate_static_reference};
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_validate::Diagnostic;
|
||||
|
||||
use super::file_inlining::template_render_store;
|
||||
use super::{FileInliningTransform, Transform};
|
||||
use crate::error::Error;
|
||||
use crate::file_resolver::{FileResolver, ResolvedFile};
|
||||
use crate::static_reference::{ReferenceKind, validate_static_reference};
|
||||
use crate::transforms::variable_expansion::{
|
||||
RenderMode, TemplateRenderTarget, TemplateTransform, render_template_for_target,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@
|
|||
//! [`super::file_inlining`], where the `FileResolver` and current-dir context
|
||||
//! live.
|
||||
|
||||
use fabro_template::validate_static_reference;
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::static_reference::{ReferenceKind, validate_static_reference};
|
||||
|
||||
/// A field value that is either inline content or an `@path` file import.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ use std::sync::Arc;
|
|||
use fabro_graphviz::graph::{AttrValue, Graph, Node};
|
||||
use fabro_template::{
|
||||
TemplateContext, TemplateError, TemplateRenderMode, TemplateSource, TemplateSourceOrigin,
|
||||
TemplateStore,
|
||||
TemplateStore, validate_static_reference,
|
||||
};
|
||||
use fabro_types::graph::{AttributeScope, ReferenceKind, reference_kind_for_attribute};
|
||||
use fabro_types::settings::interp::Namespace;
|
||||
use fabro_types::settings::{InterpString, ResolveCtx, ResolveError, ResolveErrorKind};
|
||||
use fabro_util::error::collect_chain;
|
||||
|
|
@ -17,9 +18,6 @@ use fabro_validate::{Diagnostic, Severity};
|
|||
use super::Transform;
|
||||
use crate::error::Error;
|
||||
use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE};
|
||||
use crate::static_reference::{
|
||||
AttributeScope, ReferenceKind, reference_kind_for_attribute, validate_static_reference,
|
||||
};
|
||||
|
||||
/// How the template-expansion pass should treat undefined input variables.
|
||||
///
|
||||
|
|
@ -521,7 +519,7 @@ impl TemplateTransform {
|
|||
continue;
|
||||
}
|
||||
if let Some(kind) = reference_kind_for_attribute(scope, attr_name, text) {
|
||||
validate_static_reference(text, kind)
|
||||
validate_static_reference(text, kind.into())
|
||||
.map_err(|error| Error::Validation(error.to_string()))?;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
|||
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::{ArtifactKey, ArtifactStore, Database};
|
||||
use fabro_types::{RunId, StageId, WorkflowSettings};
|
||||
use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref};
|
||||
use fabro_util::shell;
|
||||
use fabro_workflow::artifact::sync_artifacts_to_env;
|
||||
use fabro_workflow::artifact;
|
||||
use fabro_workflow::context::Context;
|
||||
use fabro_workflow::error::Error;
|
||||
use fabro_workflow::event::Emitter;
|
||||
|
|
@ -39,6 +39,7 @@ use fabro_workflow::handler::{Handler, HandlerRegistry};
|
|||
use fabro_workflow::outcome::{Outcome, StageOutcome};
|
||||
use fabro_workflow::records::Checkpoint;
|
||||
use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions};
|
||||
use fabro_workflow::runtime_store::RunStoreHandle;
|
||||
use fabro_workflow::test_support::{WorkflowRunner, test_store_dir};
|
||||
use object_store::local::LocalFileSystem;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
|
@ -159,6 +160,25 @@ fn load_run_checkpoint(run_dir: &Path) -> Result<Checkpoint, Box<dyn std::error:
|
|||
.ok_or_else(|| "checkpoint should exist in run store".into())
|
||||
}
|
||||
|
||||
async fn resolve_checkpoint_text(
|
||||
run_dir: &Path,
|
||||
run_id: &RunId,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let Some(current) = value.as_str() else {
|
||||
return Ok(value.to_string());
|
||||
};
|
||||
if parse_blob_ref(current).is_none() {
|
||||
return Ok(current.to_string());
|
||||
}
|
||||
|
||||
let object_store = Arc::new(LocalFileSystem::new_with_prefix(test_store_dir(run_dir))?);
|
||||
let store = Database::new(object_store, "", std::time::Duration::from_millis(1), None);
|
||||
let run = store.open_run_reader(run_id).await?;
|
||||
let run_store = RunStoreHandle::from(run);
|
||||
Ok(artifact::resolve_text_or_blob_ref_str(current, &run_store).await?)
|
||||
}
|
||||
|
||||
async fn create_env() -> DaytonaSandbox {
|
||||
let creds = load_github_app_credentials();
|
||||
create_env_with_github_app(Some(creds)).await
|
||||
|
|
@ -419,7 +439,9 @@ async fn daytona_artifact_sync_uploads_and_rewrites_pointer() {
|
|||
|
||||
// Sync — the local file doesn't exist in the Daytona sandbox, so it should
|
||||
// upload
|
||||
sync_artifacts_to_env(&mut updates, &env).await.unwrap();
|
||||
artifact::sync_artifacts_to_env(&mut updates, &env)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Pointer should be rewritten to the Daytona working directory
|
||||
let new_pointer = updates["response.plan"].as_str().unwrap();
|
||||
|
|
@ -544,15 +566,18 @@ async fn daytona_pipeline_artifact_offload_and_sync() {
|
|||
.get("response.big_output")
|
||||
.expect("context should have response.big_output");
|
||||
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
||||
let expected_blob_id = fabro_types::RunBlobId::new(
|
||||
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
|
||||
.expect("large value should serialize"),
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_str,
|
||||
fabro_types::format_blob_ref(&expected_blob_id),
|
||||
assert!(
|
||||
parse_blob_ref(pointer_str).is_some(),
|
||||
"checkpoint should persist a blob ref"
|
||||
);
|
||||
let resolved = resolve_checkpoint_text(dir.path(), &run_options.run_id, pointer_value)
|
||||
.await
|
||||
.expect("offloaded value should resolve through the run store");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
"x".repeat(150 * 1024),
|
||||
"offloaded value should round-trip through the run store"
|
||||
);
|
||||
|
||||
env.cleanup().await.unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ use fabro_model::{Catalog, ProviderId};
|
|||
use fabro_store::{ArtifactKey, ArtifactStore, Database};
|
||||
use fabro_types::{EventBody, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref};
|
||||
use fabro_validate::{Severity, validate, validate_or_raise};
|
||||
use fabro_workflow::artifact;
|
||||
use fabro_workflow::context::Context;
|
||||
use fabro_workflow::error::{Error, FailureSignatureExt};
|
||||
use fabro_workflow::event::{Emitter, Event};
|
||||
|
|
@ -54,6 +55,7 @@ use fabro_workflow::model_fallback::ModelFallbackPolicy;
|
|||
use fabro_workflow::outcome::{Outcome, OutcomeExt, StageOutcome};
|
||||
use fabro_workflow::records::{Checkpoint, CheckpointExt};
|
||||
use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions};
|
||||
use fabro_workflow::runtime_store::RunStoreHandle;
|
||||
use fabro_workflow::test_support::{
|
||||
WorkflowRunner, collect_events, run_graph_with_hooks, test_store_dir,
|
||||
};
|
||||
|
|
@ -233,10 +235,11 @@ fn resolve_checkpoint_text(
|
|||
let Some(current) = value.as_str() else {
|
||||
return Ok(value.to_string());
|
||||
};
|
||||
let Some(blob_id) = parse_blob_ref(current) else {
|
||||
if parse_blob_ref(current).is_none() {
|
||||
return Ok(current.to_string());
|
||||
};
|
||||
}
|
||||
|
||||
let current = current.to_string();
|
||||
let run_dir = run_dir.to_path_buf();
|
||||
let (store_dir, uses_shared_store) = run_store_dir_and_mode(&run_dir)?;
|
||||
std::thread::spawn(
|
||||
|
|
@ -271,10 +274,8 @@ fn resolve_checkpoint_text(
|
|||
.id
|
||||
};
|
||||
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
||||
let bytes = runtime
|
||||
.block_on(run.read_blob(&blob_id))?
|
||||
.ok_or("checkpoint blob should exist")?;
|
||||
Ok(serde_json::from_slice::<String>(&bytes)?)
|
||||
let run_store = RunStoreHandle::from(run);
|
||||
Ok(runtime.block_on(artifact::resolve_text_or_blob_ref_str(¤t, &run_store))?)
|
||||
},
|
||||
)
|
||||
.join()
|
||||
|
|
@ -10059,15 +10060,17 @@ async fn large_context_values_are_offloaded_to_artifact_store() {
|
|||
.expect("context should have response.big_output");
|
||||
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
||||
|
||||
let expected_blob_id = fabro_types::RunBlobId::new(
|
||||
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
|
||||
.expect("large value should serialize"),
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_str,
|
||||
fabro_types::format_blob_ref(&expected_blob_id),
|
||||
assert!(
|
||||
parse_blob_ref(pointer_str).is_some(),
|
||||
"value should be a durable blob ref"
|
||||
);
|
||||
let resolved = resolve_checkpoint_text(dir.path(), pointer_value)
|
||||
.expect("offloaded value should resolve through the run store");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
"x".repeat(150 * 1024),
|
||||
"offloaded value should round-trip through the run store"
|
||||
);
|
||||
|
||||
// WorkflowRunCompleted artifact_count now tracks captured artifacts, not
|
||||
// offloaded values.
|
||||
|
|
@ -10258,15 +10261,17 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() {
|
|||
.get("response.big_output")
|
||||
.expect("context should have response.big_output");
|
||||
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
||||
let expected_blob_id = fabro_types::RunBlobId::new(
|
||||
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
|
||||
.expect("large value should serialize"),
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_str,
|
||||
fabro_types::format_blob_ref(&expected_blob_id),
|
||||
assert!(
|
||||
parse_blob_ref(pointer_str).is_some(),
|
||||
"checkpoint should persist a blob ref"
|
||||
);
|
||||
let resolved = resolve_checkpoint_text(dir.path(), pointer_value)
|
||||
.expect("offloaded value should resolve through the run store");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
"x".repeat(150 * 1024),
|
||||
"offloaded value should round-trip through the run store"
|
||||
);
|
||||
|
||||
let written = remote_env.written.lock().unwrap();
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -722,6 +722,10 @@ fn main() {
|
|||
("CompletionMessage", "fabro_types::Message", &[]),
|
||||
("CompletionMessageRole", "fabro_types::Role", &[]),
|
||||
("CompletionContentPart", "fabro_types::ContentPart", &[]),
|
||||
("WorkflowVersion", "fabro_types::WorkflowVersion", &[]),
|
||||
("WorkflowPath", "fabro_types::WorkflowPath", &[]),
|
||||
("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]),
|
||||
("BlobHash", "fabro_types::BlobHash", &[]),
|
||||
("CostSource", "fabro_model::CostSource", &[]),
|
||||
];
|
||||
for (name, path, impls) in replacements {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ pub mod types {
|
|||
pub use fabro_types::{
|
||||
ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource,
|
||||
AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary,
|
||||
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts,
|
||||
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
|
||||
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
|
||||
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
|
||||
FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,
|
||||
|
|
@ -73,7 +73,8 @@ pub mod types {
|
|||
StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection,
|
||||
SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus,
|
||||
SystemIntegrationsResponse, TodoListProjection, TurnId, UpdateVariableRequest,
|
||||
UserPrincipal, Variable, VariableListResponse, WorkflowSettings,
|
||||
UserPrincipal, Variable, VariableListResponse, WorkflowPath, WorkflowSettings,
|
||||
WorkflowVersion, WorkflowVersionId,
|
||||
};
|
||||
|
||||
pub use crate::generated::types::*;
|
||||
|
|
|
|||
61
lib/foundation/fabro-api/tests/blob_hash_round_trip.rs
Normal file
61
lib/foundation/fabro-api/tests/blob_hash_round_trip.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::{BlobHash as ApiBlobHash, WriteBlobResponse};
|
||||
use fabro_types::BlobHash;
|
||||
use serde_json::json;
|
||||
|
||||
const BLOB_HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
|
||||
#[test]
|
||||
fn blob_hash_schema_reuses_domain_type() {
|
||||
assert_same_type::<ApiBlobHash, BlobHash>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_blob_response_round_trips_exact_wire_shape() {
|
||||
let value = json!({ "hash": BLOB_HASH });
|
||||
|
||||
let response: WriteBlobResponse = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&response).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_hash_accepts_any_case_and_emits_lowercase() {
|
||||
for input in [
|
||||
BLOB_HASH.to_string(),
|
||||
BLOB_HASH.to_uppercase(),
|
||||
alternating_hex_case(BLOB_HASH),
|
||||
] {
|
||||
let hash: ApiBlobHash = serde_json::from_value(json!(input)).unwrap();
|
||||
assert_eq!(serde_json::to_value(hash).unwrap(), json!(BLOB_HASH));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_hash_rejects_non_hex_values() {
|
||||
assert!(serde_json::from_value::<ApiBlobHash>(json!("not-a-blob-hash")).is_err());
|
||||
}
|
||||
|
||||
fn assert_same_type<Api: 'static, Domain: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<Api>(),
|
||||
TypeId::of::<Domain>(),
|
||||
"{} must be the domain type {}",
|
||||
type_name::<Api>(),
|
||||
type_name::<Domain>()
|
||||
);
|
||||
}
|
||||
|
||||
fn alternating_hex_case(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, character)| {
|
||||
if index % 2 == 0 {
|
||||
character.to_ascii_uppercase()
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::RunProjection as ApiRunProjection;
|
||||
use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings, test_support};
|
||||
use fabro_types::{RunProjection, RunSpec, test_support};
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn run_projection_reuses_canonical_type() {
|
||||
|
|
@ -129,19 +129,8 @@ fn run_projection_round_trips_with_pending_control_unset() {
|
|||
|
||||
fn run_spec_json() -> serde_json::Value {
|
||||
serde_json::to_value(RunSpec {
|
||||
run_id: fabro_types::fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
graph_source: Some("digraph test {}".to_string()),
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: std::collections::HashMap::new(),
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
graph_source: Some("digraph test {}".to_string()),
|
||||
..test_support::test_run_spec()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::{
|
||||
CreateWorkflowVersionResponse, WorkflowPath as ApiWorkflowPath,
|
||||
WorkflowVersion as ApiWorkflowVersion, WorkflowVersionId as ApiWorkflowVersionId,
|
||||
};
|
||||
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId};
|
||||
use serde_json::json;
|
||||
|
||||
const DEPENDENCY_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
#[test]
|
||||
fn workflow_version_schemas_reuse_domain_types() {
|
||||
assert_same_type::<ApiWorkflowPath, WorkflowPath>();
|
||||
assert_same_type::<ApiWorkflowVersionId, WorkflowVersionId>();
|
||||
assert_same_type::<ApiWorkflowVersion, WorkflowVersion>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_round_trips_exact_wire_shape() {
|
||||
let value = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"prompts/goal.md": "Ship it",
|
||||
"workflow.fabro": "digraph W { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }"
|
||||
},
|
||||
"workflow_dependencies": { "children/check.fabro": DEPENDENCY_ID }
|
||||
});
|
||||
|
||||
let version: ApiWorkflowVersion = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(version).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_workflow_version_response_round_trips_exact_wire_shape() {
|
||||
let value = json!({ "workflow_version_id": DEPENDENCY_ID });
|
||||
|
||||
let response: CreateWorkflowVersionResponse = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&response).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_id_accepts_any_case_and_emits_lowercase() {
|
||||
let id = serde_json::from_value::<ApiWorkflowVersionId>(json!(DEPENDENCY_ID.to_uppercase()))
|
||||
.unwrap();
|
||||
let emitted = serde_json::to_value(id).unwrap();
|
||||
assert_eq!(emitted, json!(DEPENDENCY_ID));
|
||||
|
||||
let text = emitted.as_str().unwrap();
|
||||
assert_eq!(text.len(), 64);
|
||||
assert!(
|
||||
text.bytes()
|
||||
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_replacement_rejects_unknown_fields() {
|
||||
let value = json!({
|
||||
"entrypoint": "workflow.fabro",
|
||||
"files": {
|
||||
"workflow.fabro": "digraph W {}"
|
||||
},
|
||||
"workflow_dependencies": {},
|
||||
"metadata": {}
|
||||
});
|
||||
|
||||
assert!(serde_json::from_value::<ApiWorkflowVersion>(value).is_err());
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} and {} should be the same type",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -13,8 +13,8 @@ use fabro_http::multipart::{Form, Part};
|
|||
use fabro_model::{Model, ModelTestMode, ProviderId};
|
||||
use fabro_types::settings::run::MergeStrategy;
|
||||
use fabro_types::{
|
||||
ArtifactUpload, EventEnvelope, PairId, PairMessageRecord, PairMessageRequest, PairRecord,
|
||||
PairStartRequest, PairTranscriptResponse, Run, RunBlobId, RunEvent, RunEventDetailResponse,
|
||||
ArtifactUpload, BlobHash, EventEnvelope, PairId, PairMessageRecord, PairMessageRequest,
|
||||
PairRecord, PairStartRequest, PairTranscriptResponse, Run, RunEvent, RunEventDetailResponse,
|
||||
RunId, RunPairStatusResponse, RunProjection, SessionId, SessionRecord, StageId,
|
||||
};
|
||||
use fabro_util::exit::{ErrorExt, ExitClass};
|
||||
|
|
@ -1828,7 +1828,7 @@ impl Client {
|
|||
u32::try_from(response.into_inner().seq).context("append_run_event returned invalid seq")
|
||||
}
|
||||
|
||||
pub async fn write_run_blob(&self, run_id: &RunId, data: &[u8]) -> Result<RunBlobId> {
|
||||
pub async fn write_run_blob(&self, run_id: &RunId, data: &[u8]) -> Result<BlobHash> {
|
||||
let response = self
|
||||
.send_api(|client| async move {
|
||||
client
|
||||
|
|
@ -1839,24 +1839,20 @@ impl Client {
|
|||
.await
|
||||
})
|
||||
.await?;
|
||||
response
|
||||
.into_inner()
|
||||
.id
|
||||
.parse()
|
||||
.context("write_run_blob returned invalid blob id")
|
||||
Ok(response.into_inner().hash)
|
||||
}
|
||||
|
||||
pub async fn read_run_blob(
|
||||
&self,
|
||||
run_id: &RunId,
|
||||
blob_id: &RunBlobId,
|
||||
blob_hash: &BlobHash,
|
||||
) -> Result<Option<Bytes>> {
|
||||
let response = self
|
||||
.current_state()
|
||||
.client
|
||||
.read_run_blob()
|
||||
.id(run_id.to_string())
|
||||
.blob_id(blob_id.to_string())
|
||||
.blob_hash(*blob_hash)
|
||||
.send()
|
||||
.await;
|
||||
match response {
|
||||
|
|
|
|||
|
|
@ -116,10 +116,8 @@ impl Combine for RunIntegrationsGithubLayer {
|
|||
///
|
||||
/// Relative paths inside the `file` variant are resolved against the
|
||||
/// directory of the config file that declared them at load time (see
|
||||
/// `fabro_config::resolve_goal_file_paths`). `{{ env.NAME }}` interpolation is
|
||||
/// supported inside the `file` path; env-tokenized relative paths stay
|
||||
/// unresolved until consume time and are then resolved against the run's
|
||||
/// effective working directory.
|
||||
/// `fabro_config::resolve_goal_file_paths`). Interpolation tokens are not
|
||||
/// supported inside the `file` path; a tokenized path fails to resolve.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged, deny_unknown_fields)]
|
||||
pub enum RunGoalLayer {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use std::str::FromStr;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::cli::CliLayer;
|
||||
use super::environment::EnvironmentLayer;
|
||||
use super::environment::{EnvironmentImageLayer, EnvironmentLayer};
|
||||
use super::llm::LlmLayer;
|
||||
use super::maps::MergeMap;
|
||||
use super::project::ProjectLayer;
|
||||
|
|
@ -102,6 +102,40 @@ impl From<WorkflowLayer> for SettingsLayer {
|
|||
}
|
||||
}
|
||||
|
||||
impl SettingsLayer {
|
||||
/// Every environment image a settings layer can carry: the image of each
|
||||
/// named `[environments.*]` entry plus the `[run.environment]` image.
|
||||
///
|
||||
/// This is the single definition of "where images live in a settings
|
||||
/// layer". The dockerfile walkers (run compilation, manifest bundling,
|
||||
/// workflow-version validation) all iterate through here so a new
|
||||
/// image-bearing location only needs to be added once.
|
||||
pub fn environment_images(&self) -> impl Iterator<Item = &EnvironmentImageLayer> {
|
||||
self.environments
|
||||
.values()
|
||||
.filter_map(|environment| environment.image.as_ref())
|
||||
.chain(
|
||||
self.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.environment.as_ref())
|
||||
.and_then(|environment| environment.image.as_ref()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Mutable variant of [`Self::environment_images`].
|
||||
pub fn environment_images_mut(&mut self) -> impl Iterator<Item = &mut EnvironmentImageLayer> {
|
||||
self.environments
|
||||
.values_mut()
|
||||
.filter_map(|environment| environment.image.as_mut())
|
||||
.chain(
|
||||
self.run
|
||||
.as_mut()
|
||||
.and_then(|run| run.environment.as_mut())
|
||||
.and_then(|environment| environment.image.as_mut()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl SettingsLayer {
|
||||
/// A default layer that resolves cleanly: populates `server.auth.methods`
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue