Merge remote-tracking branch 'origin/main'

# Conflicts:
#	lib/crates/fabro-api/build.rs
#	lib/crates/fabro-server/src/install.rs
#	lib/crates/fabro-server/src/serve.rs
#	lib/packages/fabro-api-client/src/.openapi-generator/FILES
#	lib/packages/fabro-api-client/src/models/index.ts
This commit is contained in:
Bryan Helmkamp 2026-04-22 21:18:50 -04:00
commit fa62da5d9d
No known key found for this signature in database
115 changed files with 8964 additions and 4295 deletions

2
Cargo.lock generated
View file

@ -2057,6 +2057,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"sha2",
"strum",
"tempfile",
"thiserror 2.0.18",
"tokio",
@ -2072,6 +2073,7 @@ dependencies = [
"url",
"uuid",
"walkdir",
"zeroize",
]
[[package]]

View file

@ -81,6 +81,7 @@ minijinja = "2"
fabro-http = { path = "lib/crates/fabro-http" }
graphviz-sys = { git = "https://github.com/fabro-sh/graphviz-sys" }
strum = { version = "0.28", features = ["derive"] }
zeroize = "1"
[workspace.lints.rust]
unsafe_code = "deny"

View file

@ -18,7 +18,7 @@ describe("mapRunListItem", () => {
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
repository: { name: "myrepo" },
status: "paused",
status: { kind: "paused", prior_block: null },
labels: {},
column: "running",
elapsed_secs: 65,
@ -26,7 +26,6 @@ describe("mapRunListItem", () => {
total_usd_micros: 500000,
created_at: "2026-04-08T12:00:00Z",
start_time: "2026-04-08T12:00:00Z",
status_reason: null,
pending_control: null,
} as const;
const item = mapRunListItem(summary);
@ -48,7 +47,7 @@ describe("mapRunListItem", () => {
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
repository: { name: "myrepo" },
status: "running",
status: { kind: "running" },
labels: {},
column: "running",
elapsed_secs: null,
@ -56,7 +55,6 @@ describe("mapRunListItem", () => {
total_usd_micros: null,
created_at: "2026-04-08T12:00:00Z",
start_time: null,
status_reason: null,
pending_control: null,
} as const;
@ -74,14 +72,13 @@ describe("mapRunSummaryToRunItem", () => {
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
repository: { name: "myrepo" },
status: "running",
status: { kind: "running" },
duration_ms: 65000,
elapsed_secs: 65,
total_usd_micros: 500000,
labels: {},
created_at: "2026-04-08T12:00:00Z",
start_time: "2026-04-08T12:00:00Z",
status_reason: null,
pending_control: null,
};
const item = mapRunSummaryToRunItem(summary);
@ -102,14 +99,13 @@ describe("mapRunSummaryToRunItem", () => {
workflow_name: null,
host_repo_path: null,
repository: { name: "unknown" },
status: "submitted",
status: { kind: "submitted" },
duration_ms: null,
elapsed_secs: null,
total_usd_micros: null,
labels: {},
created_at: "2026-04-08T12:00:00Z",
start_time: null,
status_reason: null,
pending_control: null,
};
const item = mapRunSummaryToRunItem(summary);

View file

@ -1,5 +1,9 @@
import { formatElapsedSecs, formatDurationSecs } from "../lib/format";
import type { RunListItem, StoreRunSummary } from "@qltysh/fabro-api-client";
import type {
RunListItem,
RunStatus as ApiRunStatus,
StoreRunSummary,
} from "@qltysh/fabro-api-client";
export type CiStatus = "passing" | "failing" | "pending";
@ -17,7 +21,7 @@ export interface RunItem {
title: string;
workflow: string;
column?: ColumnStatus;
lifecycleStatus?: string | null;
lifecycleStatus?: RunStatus | null;
lifecycleStatusLabel?: string;
number?: number;
additions?: number;
@ -50,14 +54,19 @@ function displayRunTitle(title: string | null | undefined): string {
return title?.trim() ? title : "Untitled run";
}
function runStatusKind(status: ApiRunStatus | null | undefined): RunStatus | null {
return status?.kind ?? null;
}
export function mapRunListItem(item: RunListItem): RunItem {
const lifecycleStatus = runStatusKind(item.status);
return {
id: item.run_id,
repo: item.repository.name,
title: displayRunTitle(item.title),
workflow: item.workflow_slug ?? item.workflow_name ?? "unknown",
column: item.column,
lifecycleStatus: item.status,
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(item.status),
number: item.pull_request?.number,
additions: item.pull_request?.additions,
@ -78,12 +87,13 @@ export function mapRunListItem(item: RunListItem): RunItem {
export type RunSummaryResponse = StoreRunSummary;
export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem {
const lifecycleStatus = runStatusKind(summary.status);
return {
id: summary.run_id,
repo: summary.repository.name,
title: displayRunTitle(summary.title),
workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown",
lifecycleStatus: summary.status,
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(summary.status),
elapsed:
summary.elapsed_secs != null
@ -94,8 +104,8 @@ export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem {
};
}
export function columnForStatus(status: string | null | undefined): ColumnStatus | null {
switch (status) {
export function columnForStatus(status: ApiRunStatus | null | undefined): ColumnStatus | null {
switch (status?.kind) {
case "submitted":
case "queued":
case "starting":
@ -155,9 +165,10 @@ export function isRunStatus(s: string): s is RunStatus {
return knownRunStatuses.has(s);
}
function lifecycleStatusLabel(status: string | null | undefined): string | undefined {
if (!status) return undefined;
return isRunStatus(status) ? runStatusDisplay[status].label : status;
function lifecycleStatusLabel(status: ApiRunStatus | null | undefined): string | undefined {
const kind = runStatusKind(status);
if (!kind) return undefined;
return runStatusDisplay[kind].label;
}
/** Graph control nodes hidden from stage lists in the UI. */

View file

@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test";
import { readInstallError } from "./install-api";
import {
putInstallObjectStore,
readInstallError,
testInstallObjectStore,
} from "./install-api";
describe("readInstallError", () => {
test("prefers the structured install error payload", async () => {
@ -31,3 +35,60 @@ describe("readInstallError", () => {
});
});
describe("install object-store requests", () => {
test("testInstallObjectStore posts the install payload to the validation endpoint", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
}) as typeof fetch;
await testInstallObjectStore("test-install-token", {
provider: "s3",
bucket: "fabro-data",
region: "us-east-1",
credential_mode: "runtime",
});
expect(calls).toHaveLength(1);
expect(String(calls[0]!.input)).toBe("/install/object-store/test");
expect(calls[0]!.init?.method).toBe("POST");
expect(calls[0]!.init?.headers).toEqual({
Authorization: "Bearer test-install-token",
"Content-Type": "application/json",
});
expect(calls[0]!.init?.body).toBe(
JSON.stringify({
provider: "s3",
bucket: "fabro-data",
region: "us-east-1",
credential_mode: "runtime",
}),
);
});
test("putInstallObjectStore surfaces structured API errors", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
errors: [
{
status: "422",
title: "Unprocessable Entity",
detail: "Bucket is required.",
},
],
}),
{
status: 422,
headers: { "Content-Type": "application/json" },
},
),
)) as typeof fetch;
await expect(
putInstallObjectStore("test-install-token", { provider: "s3" }),
).rejects.toThrow("Bucket is required.");
});
});

View file

@ -4,6 +4,8 @@ import type {
InstallGithubAppManifestResponse,
InstallGithubAppOwner,
InstallLlmProviderInput,
InstallObjectStoreInput,
InstallObjectStoreSummary,
InstallSessionResponse,
} from "@qltysh/fabro-api-client";
@ -13,6 +15,8 @@ export type {
InstallGithubAppManifestResponse,
InstallGithubAppOwner,
InstallLlmProviderInput,
InstallObjectStoreInput,
InstallObjectStoreSummary,
InstallSessionResponse,
};
@ -38,16 +42,6 @@ export function persistInstallToken(token: string | null): void {
}
}
async function installFetch(path: string, token: string, init?: RequestInit): Promise<Response> {
return fetch(path, {
...init,
headers: {
...(init?.headers ?? {}),
Authorization: `Bearer ${token}`,
},
});
}
export async function readInstallError(
response: Response,
fallback: string,
@ -64,68 +58,115 @@ export async function readInstallError(
return `${fallback} (${response.status})`;
}
export async function getInstallSession(token: string): Promise<InstallSessionResponse> {
const response = await installFetch("/install/session", token);
if (!response.ok) {
throw new Error(await readInstallError(response, "install session request failed"));
type InstallRequestOptions = {
path: string;
method: "GET" | "POST" | "PUT";
body?: unknown;
errorFallback: string;
};
async function installRequest(
token: string,
opts: InstallRequestOptions,
): Promise<Response> {
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
}
return response.json() as Promise<InstallSessionResponse>;
const response = await fetch(opts.path, {
method: opts.method,
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (!response.ok) {
throw new Error(await readInstallError(response, opts.errorFallback));
}
return response;
}
async function installJsonRequest<T>(
token: string,
opts: InstallRequestOptions,
): Promise<T> {
const response = await installRequest(token, opts);
return response.json() as Promise<T>;
}
export async function getInstallSession(token: string): Promise<InstallSessionResponse> {
return installJsonRequest<InstallSessionResponse>(token, {
path: "/install/session",
method: "GET",
errorFallback: "install session request failed",
});
}
export async function testInstallLlm(
token: string,
provider: InstallLlmProviderInput,
): Promise<void> {
const response = await installFetch("/install/llm/test", token, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(provider),
await installRequest(token, {
path: "/install/llm/test",
method: "POST",
body: provider,
errorFallback: "install llm validation failed",
});
if (!response.ok) {
throw new Error(await readInstallError(response, "install llm validation failed"));
}
}
export async function putInstallLlm(
token: string,
providers: InstallLlmProviderInput[],
): Promise<void> {
const response = await installFetch("/install/llm", token, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providers }),
await installRequest(token, {
path: "/install/llm",
method: "PUT",
body: { providers },
errorFallback: "install llm request failed",
});
if (!response.ok) {
throw new Error(await readInstallError(response, "install llm request failed"));
}
}
export async function putInstallServer(token: string, canonicalUrl: string): Promise<void> {
const response = await installFetch("/install/server", token, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ canonical_url: canonicalUrl }),
await installRequest(token, {
path: "/install/server",
method: "PUT",
body: { canonical_url: canonicalUrl },
errorFallback: "install server request failed",
});
}
export async function testInstallObjectStore(
token: string,
input: InstallObjectStoreInput,
): Promise<void> {
await installRequest(token, {
path: "/install/object-store/test",
method: "POST",
body: input,
errorFallback: "install object store validation failed",
});
}
export async function putInstallObjectStore(
token: string,
input: InstallObjectStoreInput,
): Promise<void> {
await installRequest(token, {
path: "/install/object-store",
method: "PUT",
body: input,
errorFallback: "install object store request failed",
});
if (!response.ok) {
throw new Error(await readInstallError(response, "install server request failed"));
}
}
export async function testInstallGithubToken(
token: string,
githubToken: string,
): Promise<string> {
const response = await installFetch("/install/github/token/test", token, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: githubToken }),
const body = await installJsonRequest<{ username: string }>(token, {
path: "/install/github/token/test",
method: "POST",
body: { token: githubToken },
errorFallback: "install github token validation failed",
});
if (!response.ok) {
throw new Error(
await readInstallError(response, "install github token validation failed"),
);
}
const body = (await response.json()) as { username: string };
return body.username;
}
@ -134,39 +175,30 @@ export async function putInstallGithubToken(
githubToken: string,
username: string,
): Promise<void> {
const response = await installFetch("/install/github/token", token, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: githubToken, username }),
await installRequest(token, {
path: "/install/github/token",
method: "PUT",
body: { token: githubToken, username },
errorFallback: "install github token request failed",
});
if (!response.ok) {
throw new Error(await readInstallError(response, "install github token request failed"));
}
}
export async function createInstallGithubAppManifest(
token: string,
input: InstallGithubAppManifestInput,
): Promise<InstallGithubAppManifestResponse> {
const response = await installFetch("/install/github/app/manifest", token, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
return installJsonRequest<InstallGithubAppManifestResponse>(token, {
path: "/install/github/app/manifest",
method: "POST",
body: input,
errorFallback: "install github app manifest request failed",
});
if (!response.ok) {
throw new Error(
await readInstallError(response, "install github app manifest request failed"),
);
}
return response.json() as Promise<InstallGithubAppManifestResponse>;
}
export async function finishInstall(token: string): Promise<InstallFinishResponse> {
const response = await installFetch("/install/finish", token, {
method: "POST",
return installJsonRequest<InstallFinishResponse>(token, {
path: "/install/finish",
method: "POST",
errorFallback: "install finish request failed",
});
if (!response.ok) {
throw new Error(await readInstallError(response, "install finish request failed"));
}
return response.json() as Promise<InstallFinishResponse>;
}

View file

@ -8,9 +8,10 @@ const INSTALL_ERROR_MESSAGE =
"GitHub App setup failed before Fabro could save the app credentials. Continue again to retry the callback.";
const SESSION_RESPONSE = {
completed_steps: ["llm", "server"],
completed_steps: ["server", "object_store", "llm"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
github: null,
prefill: { canonical_url: "https://fabro.example.com" },
};
@ -189,9 +190,10 @@ describe("InstallApp", () => {
// Simulate the server-side /install/github/app/redirect handler having
// just run — session returns a fully-populated `github.app` payload.
const sessionResponse = {
completed_steps: ["llm", "server", "github"],
completed_steps: ["server", "object_store", "llm", "github"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
github: {
strategy: "app",
owner: { kind: "personal" },
@ -242,4 +244,261 @@ describe("InstallApp", () => {
console.error = originalConsoleError;
}
});
test("saves local disk object-store settings and advances to the LLM step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const fetchMock = mock((input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
if (String(input) === "/install/session" && fetchCalls.length === 1) {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: null,
github: null,
prefill: { canonical_url: "https://fabro.example.com" },
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
if (String(input) === "/install/object-store") {
return Promise.resolve(new Response(null, { status: 204 }));
}
if (String(input) === "/install/session" && fetchCalls.length === 3) {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
github: null,
prefill: { canonical_url: "https://fabro.example.com" },
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/object-store");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/object-store"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain(
"Choose the shared object store",
);
});
const form = renderer!.root.findByType("form");
await act(async () => {
form.props.onSubmit({ preventDefault() {} });
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
});
const backLink = renderer!.root.findAll(
(node) =>
node.type === "a" &&
node.props.href === "/install/object-store" &&
node.children.includes("Back"),
);
expect(backLink).toHaveLength(1);
expect(fetchCalls.map((call) => String(call.input))).toEqual([
"/install/session",
"/install/object-store",
"/install/session",
]);
expect(fetchCalls[1]?.init?.body).toBe(JSON.stringify({ provider: "local" }));
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("rehydrates saved manual S3 credentials without exposing the secrets", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchMock = mock((input: RequestInfo | URL) => {
expect(String(input)).toBe("/install/session");
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: {
provider: "s3",
bucket: "fabro-data",
region: "us-east-1",
credential_mode: "access_key",
manual_credentials_saved: true,
},
github: null,
prefill: { canonical_url: "https://fabro.example.com" },
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
});
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/object-store");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/object-store"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain(
"Credentials saved. Leave both fields blank to keep them, or enter both fields to replace them.",
);
});
expect(renderer!.root.findByProps({ name: "aws_access_key_id" }).props.value).toBe("");
expect(renderer!.root.findByProps({ name: "aws_secret_access_key" }).props.value).toBe("");
expect(renderTreeText(renderer!.toJSON())).not.toContain("AKIA");
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("shows the redacted object-store summary on the review step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchMock = mock((input: RequestInfo | URL) => {
expect(String(input)).toBe("/install/session");
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "llm", "github"],
llm: {
providers: [{ provider: "anthropic" }],
},
server: { canonical_url: "https://fabro.example.com" },
object_store: {
provider: "s3",
bucket: "fabro-data",
region: "us-east-1",
credential_mode: "access_key",
manual_credentials_saved: true,
},
github: { strategy: "token", username: "octocat" },
prefill: { canonical_url: "https://fabro.example.com" },
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
});
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/review");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/review"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
const text = renderTreeText(renderer!.toJSON());
expect(text).toContain("AWS S3");
expect(text).toContain("fabro-data");
expect(text).toContain("us-east-1");
expect(text).toContain("Access key");
expect(text).toContain("slatedb/, artifacts/");
});
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
});

View file

@ -1,5 +1,5 @@
import { startTransition, useEffect, useMemo, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, ReactNode, Ref } from "react";
import { Link, Navigate, useLocation, useNavigate } from "react-router";
import {
ArrowLeftIcon,
@ -18,6 +18,7 @@ import {
type InstallFinishResponse,
type InstallGithubAppOwner,
type InstallLlmProviderInput,
type InstallObjectStoreInput,
type InstallSessionResponse,
createInstallGithubAppManifest,
finishInstall,
@ -25,10 +26,12 @@ import {
persistInstallToken,
putInstallGithubToken,
putInstallLlm,
putInstallObjectStore,
putInstallServer,
readStoredInstallToken,
testInstallGithubToken,
testInstallLlm,
testInstallObjectStore,
} from "./install-api";
import { INSTALL_PROVIDERS } from "./install-config";
import { shouldRedirectAfterHealthPoll } from "./install-flow";
@ -49,6 +52,7 @@ import { LoadingState } from "./components/state";
const INSTALL_STEPS = [
{ id: "welcome", label: "Welcome", href: "/install/welcome" },
{ id: "server", label: "Server", href: "/install/server" },
{ id: "object_store", label: "Object store", href: "/install/object-store" },
{ id: "llm", label: "LLMs", href: "/install/llm" },
{ id: "github", label: "GitHub", href: "/install/github" },
{ id: "review", label: "Review", href: "/install/review" },
@ -76,6 +80,17 @@ type AppForm = {
};
type ProviderSelection = Record<string, { apiKey: string }>;
type ObjectStoreProvider = "local" | "s3";
type ObjectStoreCredentialMode = "runtime" | "access_key";
type ObjectStoreForm = {
provider: ObjectStoreProvider;
bucket: string;
region: string;
credentialMode: ObjectStoreCredentialMode;
accessKeyId: string;
secretAccessKey: string;
manualCredentialsSaved: boolean;
};
export default function InstallApp() {
const navigate = useNavigate();
@ -89,6 +104,9 @@ export default function InstallApp() {
const [llmSelection, setLlmSelection] = useState<ProviderSelection>(() =>
defaultProviderSelection(),
);
const [objectStoreForm, setObjectStoreForm] = useState<ObjectStoreForm>(() =>
defaultObjectStoreForm(),
);
const [canonicalUrl, setCanonicalUrl] = useState("");
const [githubStrategy, setGithubStrategy] = useState<GithubStrategy>("token");
const [tokenForm, setTokenForm] = useState<TokenForm>({ token: "", username: "" });
@ -101,6 +119,11 @@ export default function InstallApp() {
const [submitting, setSubmitting] = useState(false);
const [finishState, setFinishState] = useState<FinishState>(null);
const [timedOut, setTimedOut] = useState(false);
const canonicalUrlInputRef = useRef<HTMLInputElement>(null);
const bucketInputRef = useRef<HTMLInputElement>(null);
const regionInputRef = useRef<HTMLInputElement>(null);
const accessKeyIdInputRef = useRef<HTMLInputElement>(null);
const secretAccessKeyInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href);
@ -138,6 +161,7 @@ export default function InstallApp() {
setCanonicalUrl((current) =>
current || nextSession.server?.canonical_url || nextSession.prefill.canonical_url,
);
setObjectStoreForm(hydrateObjectStoreForm(nextSession));
setLlmSelection((current) =>
hydrateProviderSelection(current, nextSession),
);
@ -223,13 +247,12 @@ export default function InstallApp() {
};
}, [finishState]);
const currentStep = useMemo<StepId>(() => {
if (location.pathname.startsWith("/install/llm")) return "llm";
if (location.pathname.startsWith("/install/server")) return "server";
if (location.pathname.startsWith("/install/github")) return "github";
if (location.pathname.startsWith("/install/review")) return "review";
return "welcome";
}, [location.pathname]);
const currentStep = useMemo<StepId>(
() =>
STEPPER_STEPS.find((step) => location.pathname.startsWith(step.href))?.id ??
"welcome",
[location.pathname],
);
const completedSteps = new Set(session?.completed_steps ?? []);
@ -259,6 +282,27 @@ export default function InstallApp() {
);
}
const runStepSubmit = async (args: {
action: () => Promise<void>;
fallback: string;
next?: string;
}) => {
setSubmitting(true);
setSaveError(null);
try {
await args.action();
if (args.next) {
const nextSession = await getInstallSession(installToken);
setSessionState({ status: "ready", data: nextSession });
navigate(args.next);
}
} catch (error) {
setSaveError(error instanceof Error ? error.message : args.fallback);
} finally {
setSubmitting(false);
}
};
if (sessionState.status === "error") {
return (
<TokenEntryScreen
@ -300,13 +344,13 @@ export default function InstallApp() {
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank."
error={saveError}
submitting={submitting}
backHref="/install/server"
backHref="/install/object-store"
onSubmit={async () => {
const providers = INSTALL_PROVIDERS.map(({ id }) => {
const current = llmSelection[id] ?? { apiKey: "" };
return {
provider: id,
api_key: current.apiKey.trim(),
api_key: current.apiKey.trim(),
};
}).filter((provider) => provider.api_key.length > 0);
@ -315,23 +359,16 @@ export default function InstallApp() {
return;
}
setSubmitting(true);
setSaveError(null);
try {
await Promise.all(
providers.map((provider) => testInstallLlm(installToken, provider)),
);
await putInstallLlm(installToken, providers);
const nextSession = await getInstallSession(installToken);
setSessionState({ status: "ready", data: nextSession });
navigate("/install/github");
} catch (error) {
setSaveError(
error instanceof Error ? error.message : "Failed to save LLM settings.",
);
} finally {
setSubmitting(false);
}
await runStepSubmit({
action: async () => {
await Promise.all(
providers.map((provider) => testInstallLlm(installToken, provider)),
);
await putInstallLlm(installToken, providers);
},
fallback: "Failed to save LLM settings.",
next: "/install/github",
});
}}
>
<ProviderFields value={llmSelection} onChange={setLlmSelection} />
@ -346,22 +383,14 @@ export default function InstallApp() {
onSubmit={async () => {
if (!canonicalUrl.trim()) {
setSaveError("Enter the canonical server URL before continuing.");
focusInput(canonicalUrlInputRef);
return;
}
setSubmitting(true);
setSaveError(null);
try {
await putInstallServer(installToken, canonicalUrl.trim());
const nextSession = await getInstallSession(installToken);
setSessionState({ status: "ready", data: nextSession });
navigate("/install/llm");
} catch (error) {
setSaveError(
error instanceof Error ? error.message : "Failed to save server settings.",
);
} finally {
setSubmitting(false);
}
await runStepSubmit({
action: () => putInstallServer(installToken, canonicalUrl.trim()),
fallback: "Failed to save server settings.",
next: "/install/object-store",
});
}}
>
<Field
@ -371,6 +400,7 @@ export default function InstallApp() {
<input
type="url"
name="canonical_url"
ref={canonicalUrlInputRef}
value={canonicalUrl}
onChange={(event) => setCanonicalUrl(event.target.value)}
className={INPUT_CLASS}
@ -380,6 +410,171 @@ export default function InstallApp() {
/>
</Field>
</StepPanel>
) : location.pathname === "/install/object-store" ? (
<StepPanel
title="Choose the shared object store"
description="This configures the shared backend for both SlateDB and run artifacts. Fabro still keeps its local storage root on disk."
error={saveError}
submitting={submitting}
submittingLabel={
objectStoreForm.provider === "s3" ? "Checking access..." : "Saving..."
}
backHref="/install/server"
onSubmit={async () => {
if (objectStoreForm.provider === "s3") {
if (!objectStoreForm.bucket.trim()) {
setSaveError("Enter the S3 bucket before continuing.");
focusInput(bucketInputRef);
return;
}
if (!objectStoreForm.region.trim()) {
setSaveError("Enter the AWS region before continuing.");
focusInput(regionInputRef);
return;
}
if (objectStoreForm.credentialMode === "access_key") {
const accessKeyId = objectStoreForm.accessKeyId.trim();
const secretAccessKey = objectStoreForm.secretAccessKey.trim();
const keepStoredCredentials =
objectStoreForm.manualCredentialsSaved &&
!accessKeyId &&
!secretAccessKey;
if (!keepStoredCredentials && !accessKeyId) {
setSaveError("Enter the AWS access key ID before continuing.");
focusInput(accessKeyIdInputRef);
return;
}
if (!keepStoredCredentials && !secretAccessKey) {
setSaveError("Enter the AWS secret access key before continuing.");
focusInput(secretAccessKeyInputRef);
return;
}
}
}
const payload = buildObjectStorePayload(objectStoreForm);
await runStepSubmit({
action: async () => {
if (objectStoreForm.provider === "s3") {
await testInstallObjectStore(installToken, payload);
}
await putInstallObjectStore(installToken, payload);
},
fallback: "Failed to save object-store settings.",
next: "/install/llm",
});
}}
>
<CardPicker
legend="Object store"
options={OBJECT_STORE_PROVIDER_OPTIONS}
value={objectStoreForm.provider}
onChange={(provider) => {
setObjectStoreForm((current) => ({ ...current, provider }));
if (provider === "s3") {
focusInput(bucketInputRef);
}
}}
/>
{objectStoreForm.provider === "s3" ? (
<div className="space-y-5">
<Field label="Bucket">
<input
ref={bucketInputRef}
name="object_store_bucket"
value={objectStoreForm.bucket}
onChange={(event) =>
setObjectStoreForm((current) => ({
...current,
bucket: event.target.value,
}))
}
className={`${INPUT_CLASS} font-mono`}
placeholder="my-fabro-data"
spellCheck={false}
autoCapitalize="off"
/>
</Field>
<Field label="Region">
<input
ref={regionInputRef}
name="object_store_region"
value={objectStoreForm.region}
onChange={(event) =>
setObjectStoreForm((current) => ({
...current,
region: event.target.value,
}))
}
className={`${INPUT_CLASS} font-mono`}
placeholder="us-east-1"
spellCheck={false}
autoCapitalize="off"
/>
</Field>
<CardPicker
legend="Credentials"
options={OBJECT_STORE_CREDENTIAL_MODE_OPTIONS}
value={objectStoreForm.credentialMode}
onChange={(credentialMode) => {
setObjectStoreForm((current) => ({ ...current, credentialMode }));
if (credentialMode === "access_key") {
focusInput(accessKeyIdInputRef);
}
}}
/>
{objectStoreForm.credentialMode === "access_key" ? (
<div className="space-y-5">
<Field label="AWS access key ID">
<PasswordInput
inputRef={accessKeyIdInputRef}
id="aws_access_key_id"
name="aws_access_key_id"
value={objectStoreForm.accessKeyId}
onChange={(value) =>
setObjectStoreForm((current) => ({
...current,
accessKeyId: value,
}))
}
placeholder="AKIA..."
/>
</Field>
<Field label="AWS secret access key">
<PasswordInput
inputRef={secretAccessKeyInputRef}
id="aws_secret_access_key"
name="aws_secret_access_key"
value={objectStoreForm.secretAccessKey}
onChange={(value) =>
setObjectStoreForm((current) => ({
...current,
secretAccessKey: value,
}))
}
placeholder="Secret access key"
/>
</Field>
{objectStoreForm.manualCredentialsSaved ? (
<p className="text-xs text-fg-muted">
Credentials saved. Leave both fields blank to keep them, or
enter both fields to replace them.
</p>
) : null}
</div>
) : (
<p className="text-xs/5 text-fg-muted">
Fabro will use AWS credentials already provided by the runtime,
such as EC2, ECS, or IRSA-based auth.
</p>
)}
</div>
) : (
<p className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-3 outline-1 -outline-offset-1 outline-white/10">
Fabro will keep using local disk for both SlateDB and run artifacts.
</p>
)}
</StepPanel>
) : location.pathname === "/install/github/done" ? (
<GithubAppDoneScreen github={session?.github} />
) : location.pathname === "/install/github" ? (
@ -389,63 +584,66 @@ export default function InstallApp() {
error={saveError}
submitting={submitting}
submitLabel={githubStrategy === "app" ? "Continue on GitHub" : "Continue"}
backHref="/install/server"
backHref="/install/llm"
onSubmit={async () => {
setSubmitting(true);
setSaveError(null);
try {
if (githubStrategy === "token") {
const trimmedToken = tokenForm.token.trim();
if (!trimmedToken) {
setSaveError("Provide the GitHub token before continuing.");
return;
}
const username = await testInstallGithubToken(installToken, trimmedToken);
setTokenForm({ token: trimmedToken, username });
await putInstallGithubToken(installToken, trimmedToken, username);
const nextSession = await getInstallSession(installToken);
setSessionState({ status: "ready", data: nextSession });
navigate("/install/review");
if (githubStrategy === "token") {
const trimmedToken = tokenForm.token.trim();
if (!trimmedToken) {
setSaveError("Provide the GitHub token before continuing.");
return;
}
const { owner, appName, allowedUsername } = appForm;
if (owner.kind === "org" && !(owner.slug ?? "").trim()) {
setSaveError("Enter the organization slug for the GitHub App.");
return;
}
if (!appName.trim()) {
setSaveError("Enter the GitHub App name before continuing.");
return;
}
if (!allowedUsername.trim()) {
setSaveError("Enter the GitHub username that should be allowed to log in.");
return;
}
const manifest = await createInstallGithubAppManifest(installToken, {
owner:
owner.kind === "org"
? { kind: "org", slug: (owner.slug ?? "").trim() }
: { kind: "personal" },
app_name: appName.trim(),
allowed_username: allowedUsername.trim(),
await runStepSubmit({
action: async () => {
const username = await testInstallGithubToken(installToken, trimmedToken);
setTokenForm({ token: trimmedToken, username });
await putInstallGithubToken(installToken, trimmedToken, username);
},
fallback: "Failed to start GitHub setup.",
next: "/install/review",
});
submitGithubManifest(
manifest.github_form_action,
manifest.manifest,
manifest.state,
);
} catch (error) {
setSaveError(
error instanceof Error ? error.message : "Failed to start GitHub setup.",
);
} finally {
setSubmitting(false);
return;
}
const { owner, appName, allowedUsername } = appForm;
if (owner.kind === "org" && !(owner.slug ?? "").trim()) {
setSaveError("Enter the organization slug for the GitHub App.");
return;
}
if (!appName.trim()) {
setSaveError("Enter the GitHub App name before continuing.");
return;
}
if (!allowedUsername.trim()) {
setSaveError("Enter the GitHub username that should be allowed to log in.");
return;
}
await runStepSubmit({
action: async () => {
const manifest = await createInstallGithubAppManifest(installToken, {
owner:
owner.kind === "org"
? { kind: "org", slug: (owner.slug ?? "").trim() }
: { kind: "personal" },
app_name: appName.trim(),
allowed_username: allowedUsername.trim(),
});
submitGithubManifest(
manifest.github_form_action,
manifest.manifest,
manifest.state,
);
},
fallback: "Failed to start GitHub setup.",
});
}}
>
<GithubStrategyPicker strategy={githubStrategy} onChange={setGithubStrategy} />
<CardPicker
legend="Authentication"
options={GITHUB_STRATEGY_OPTIONS}
value={githubStrategy}
onChange={(strategy) => setGithubStrategy(strategy)}
/>
{githubStrategy === "token" ? (
<div className="space-y-5">
<div>
@ -486,9 +684,11 @@ export default function InstallApp() {
</div>
) : (
<div className="space-y-5">
<OwnerPicker
ownerKind={appForm.owner.kind}
setOwnerKind={(kind) =>
<CardPicker
legend="Owner"
options={GITHUB_OWNER_OPTIONS}
value={appForm.owner.kind}
onChange={(kind) =>
setAppForm((current) => ({
...current,
owner:
@ -778,13 +978,17 @@ function WelcomeScreen() {
Set up your Fabro server
</h1>
<p className="mt-4 max-w-[56ch] text-base/7 text-fg-3 text-pretty sm:text-[0.9375rem]/7">
A short walkthrough to validate your LLM credentials, confirm the public
server URL, and connect GitHub. When you finish, Fabro restarts into
normal mode.
A short walkthrough to confirm the public server URL, choose the shared
object store, validate your LLM credentials, and connect GitHub. When
you finish, Fabro restarts into normal mode.
</p>
<ol role="list" className="mt-10 divide-y divide-line border-y border-line">
{[
["Server URL", "Confirm where operators will reach Fabro."],
[
"Object store",
"Choose local disk or AWS S3 for SlateDB and artifacts.",
],
["LLMs", "Validate API keys for Anthropic, OpenAI, or Gemini."],
["GitHub", "Choose a personal access token or a GitHub App."],
["Review", "Double-check the plan, then write the files."],
@ -820,6 +1024,7 @@ function StepPanel({
error,
submitting,
submitLabel = "Continue",
submittingLabel = "Saving...",
backHref,
onSubmit,
}: {
@ -829,6 +1034,7 @@ function StepPanel({
error: string | null;
submitting: boolean;
submitLabel?: string;
submittingLabel?: string;
backHref?: string;
onSubmit: () => Promise<void>;
}) {
@ -864,7 +1070,7 @@ function StepPanel({
{submitting ? (
<>
<Spinner />
Saving
{submittingLabel}
</>
) : (
<>
@ -913,13 +1119,14 @@ function ReviewScreen({
</p>
</header>
<dl className="divide-y divide-line border-y border-line">
<SummaryRow label="LLM providers" value={providers || "Not configured"} />
<SummaryRow
label="Server URL"
value={serverUrl}
mono
action={<CopyButton value={serverUrl} label="Copy server URL" />}
/>
{renderObjectStoreSummaryRows(session?.object_store)}
<SummaryRow label="LLM providers" value={providers || "Not configured"} />
{renderGithubSummaryRows(session?.github)}
</dl>
{error ? <ErrorMessage message={error} /> : null}
@ -1043,33 +1250,27 @@ function ProviderFields({
);
}
function GithubStrategyPicker({
strategy,
type CardOption<T extends string> = { id: T; title: string; body: string };
function CardPicker<T extends string>({
legend,
options,
value,
onChange,
}: {
strategy: GithubStrategy;
onChange: (value: GithubStrategy) => void;
legend: string;
options: ReadonlyArray<CardOption<T>>;
value: T;
onChange: (value: T) => void;
}) {
const options: Array<{ id: GithubStrategy; title: string; body: string }> = [
{
id: "token",
title: "Personal access token",
body: "Quickest path. Validates a PAT and stores it in the vault.",
},
{
id: "app",
title: "GitHub App",
body: "Recommended for teams. Enables OAuth.",
},
];
return (
<fieldset>
<legend className="text-sm font-medium text-fg">Authentication</legend>
<legend className="text-sm font-medium text-fg">{legend}</legend>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{options.map((option) => (
<OptionCard
key={option.id}
selected={strategy === option.id}
selected={value === option.id}
onSelect={() => onChange(option.id)}
title={option.title}
body={option.body}
@ -1080,42 +1281,59 @@ function GithubStrategyPicker({
);
}
function OwnerPicker({
ownerKind,
setOwnerKind,
}: {
ownerKind: GithubOwnerKind;
setOwnerKind: (value: GithubOwnerKind) => void;
}) {
const options: Array<{ id: GithubOwnerKind; title: string; body: string }> = [
{
id: "personal",
title: "Personal account",
body: "GitHub's personal app creation flow.",
},
{
id: "org",
title: "Organization",
body: "GitHub's org flow — requires the org slug.",
},
];
return (
<fieldset>
<legend className="text-sm font-medium text-fg">Owner</legend>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{options.map((option) => (
<OptionCard
key={option.id}
selected={ownerKind === option.id}
onSelect={() => setOwnerKind(option.id)}
title={option.title}
body={option.body}
/>
))}
</div>
</fieldset>
);
}
const GITHUB_STRATEGY_OPTIONS: ReadonlyArray<CardOption<GithubStrategy>> = [
{
id: "token",
title: "Personal access token",
body: "Quickest path. Validates a PAT and stores it in the vault.",
},
{
id: "app",
title: "GitHub App",
body: "Recommended for teams. Enables OAuth.",
},
];
const OBJECT_STORE_PROVIDER_OPTIONS: ReadonlyArray<CardOption<ObjectStoreProvider>> = [
{
id: "local",
title: "Local disk",
body: "Uses the host filesystem for SlateDB and run artifacts.",
},
{
id: "s3",
title: "AWS S3",
body: "Uses one S3 bucket with fixed slatedb/ and artifacts/ prefixes.",
},
];
const OBJECT_STORE_CREDENTIAL_MODE_OPTIONS: ReadonlyArray<
CardOption<ObjectStoreCredentialMode>
> = [
{
id: "runtime",
title: "Use AWS runtime credentials",
body: "Use credentials already supplied by the deployment environment.",
},
{
id: "access_key",
title: "Enter AWS access key credentials",
body: "Store an access key pair in server.env for startup and validation.",
},
];
const GITHUB_OWNER_OPTIONS: ReadonlyArray<CardOption<GithubOwnerKind>> = [
{
id: "personal",
title: "Personal account",
body: "GitHub's personal app creation flow.",
},
{
id: "org",
title: "Organization",
body: "GitHub's org flow — requires the org slug.",
},
];
function OptionCard({
selected,
@ -1253,17 +1471,20 @@ function PasswordInput({
value,
onChange,
placeholder,
inputRef,
}: {
id?: string;
name: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
inputRef?: Ref<HTMLInputElement>;
}) {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<input
ref={inputRef}
type={visible ? "text" : "password"}
id={id}
name={name}
@ -1386,6 +1607,18 @@ function defaultProviderSelection(): ProviderSelection {
);
}
function defaultObjectStoreForm(): ObjectStoreForm {
return {
provider: "local",
bucket: "",
region: "",
credentialMode: "runtime",
accessKeyId: "",
secretAccessKey: "",
manualCredentialsSaved: false,
};
}
function hydrateProviderSelection(
current: ProviderSelection,
session: InstallSessionResponse,
@ -1400,6 +1633,50 @@ function hydrateProviderSelection(
return next;
}
function hydrateObjectStoreForm(session: InstallSessionResponse): ObjectStoreForm {
const summary = session.object_store;
if (!summary || summary.provider === "local") {
return defaultObjectStoreForm();
}
return {
provider: "s3",
bucket: summary.bucket ?? "",
region: summary.region ?? "",
credentialMode: summary.credential_mode === "access_key" ? "access_key" : "runtime",
accessKeyId: "",
secretAccessKey: "",
manualCredentialsSaved: Boolean(summary.manual_credentials_saved),
};
}
function buildObjectStorePayload(form: ObjectStoreForm): InstallObjectStoreInput {
if (form.provider === "local") {
return { provider: "local" };
}
const payload: InstallObjectStoreInput = {
provider: "s3",
bucket: form.bucket.trim(),
region: form.region.trim(),
credential_mode: form.credentialMode,
};
const accessKeyId = form.accessKeyId.trim();
const secretAccessKey = form.secretAccessKey.trim();
if (form.credentialMode === "access_key") {
if (accessKeyId) {
payload.access_key_id = accessKeyId;
}
if (secretAccessKey) {
payload.secret_access_key = secretAccessKey;
}
}
return payload;
}
function focusInput(ref: { current: HTMLInputElement | null }): void {
window.setTimeout(() => ref.current?.focus(), 0);
}
function describeProvider(id: string): string {
const match = INSTALL_PROVIDERS.find((provider) => provider.id === id);
return match?.label ?? id;
@ -1436,6 +1713,33 @@ function renderGithubSummaryRows(
);
}
function renderObjectStoreSummaryRows(
objectStore: InstallSessionResponse["object_store"],
): ReactNode {
if (!objectStore) {
return <SummaryRow label="Object store" value="Not configured" />;
}
if (objectStore.provider === "local") {
return <SummaryRow label="Object store" value="Local disk" />;
}
return (
<>
<SummaryRow label="Object store" value="AWS S3" />
<SummaryRow label="Bucket" value={objectStore.bucket ?? "Not set"} mono />
<SummaryRow label="Region" value={objectStore.region ?? "Not set"} mono />
<SummaryRow
label="Credentials"
value={
objectStore.credential_mode === "access_key"
? "Access key"
: "Runtime credentials"
}
/>
<SummaryRow label="Prefixes" value="slatedb/, artifacts/" mono />
</>
);
}
function describeGithubAppOwner(
owner: InstallGithubAppOwner | undefined,
): string {
@ -1468,4 +1772,3 @@ function submitGithubManifest(
document.body.appendChild(form);
form.submit();
}

View file

@ -58,15 +58,16 @@ describe("run lifecycle actions", () => {
status: 200,
body: JSON.stringify({
id: "run-1",
status: "failed",
status_reason: "cancelled",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
}),
});
const result = await cancelRun("run-1");
expect(result.status).toBe("failed");
expect(result.status_reason).toBe("cancelled");
expect(result.status.kind).toBe("failed");
if (result.status.kind === "failed") {
expect(result.status.reason).toBe("cancelled");
}
});
test("archiveRun parses a 200 response", async () => {
@ -74,13 +75,16 @@ describe("run lifecycle actions", () => {
status: 200,
body: JSON.stringify({
id: "run-1",
status: "archived",
status: {
kind: "archived",
prior: { kind: "succeeded", reason: "completed" },
},
created_at: "2026-04-20T12:00:00Z",
}),
});
const result = await archiveRun("run-1");
expect(result.status).toBe("archived");
expect(result.status.kind).toBe("archived");
});
test("unarchiveRun parses a 200 response", async () => {
@ -88,13 +92,13 @@ describe("run lifecycle actions", () => {
status: 200,
body: JSON.stringify({
id: "run-1",
status: "succeeded",
status: { kind: "succeeded", reason: "completed" },
created_at: "2026-04-20T12:00:00Z",
}),
});
const result = await unarchiveRun("run-1");
expect(result.status).toBe("succeeded");
expect(result.status.kind).toBe("succeeded");
});
test("404 and 409 preserve the parsed error envelope", async () => {
@ -162,15 +166,14 @@ describe("run lifecycle actions", () => {
expect(
isTerminalCancelledRun({
id: "run-1",
status: "failed",
status_reason: "cancelled",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
}),
).toBe(true);
expect(
isTerminalCancelledRun({
id: "run-1",
status: "running",
status: { kind: "running" },
pending_control: "cancel",
created_at: "2026-04-20T12:00:00Z",
}),

View file

@ -49,7 +49,7 @@ export function canUnarchive(status: string | null | undefined): boolean {
}
export function isTerminalCancelledRun(run: RunStatusResponse): boolean {
return (run.status === "failed" || run.status === "dead") && run.status_reason === "cancelled";
return run.status.kind === "failed" && run.status.reason === "cancelled";
}
export function mapError(error: unknown, action: LifecycleAction): string {

View file

@ -64,7 +64,7 @@ describe("run-detail loader", () => {
run_id: "run-1",
title: "Blocked run",
repository: { name: "repo" },
status: "blocked",
status: { kind: "blocked", blocked_reason: "human_input_required" },
workflow_name: "review",
},
},
@ -94,7 +94,7 @@ describe("run-detail loader", () => {
run_id: "run-1",
title: "Blocked run",
repository: { name: "repo" },
status: "blocked",
status: { kind: "blocked", blocked_reason: "human_input_required" },
workflow_name: "review",
},
},
@ -154,8 +154,7 @@ describe("run-detail action", () => {
status: 200,
body: {
id: "run-1",
status: "failed",
status_reason: "cancelled",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
},
},
@ -171,8 +170,7 @@ describe("run-detail action", () => {
ok: true,
run: {
id: "run-1",
status: "failed",
status_reason: "cancelled",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
},
});
@ -235,7 +233,11 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: { id: "run-1", status: "failed", status_reason: "cancelled", created_at: "2026-04-20T12:00:00Z" },
run: {
id: "run-1",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
},
};
const firstState = handleLifecycleToastResult("cancel", result, initialState, api);
@ -255,7 +257,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: { id: "run-1", status: "running", created_at: "2026-04-20T12:00:00Z" },
run: { id: "run-1", status: { kind: "running" }, created_at: "2026-04-20T12:00:00Z" },
};
handleLifecycleToastResult("cancel", result, initialState, api);
@ -269,7 +271,14 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "archive",
ok: true,
run: { id: "run-1", status: "archived", created_at: "2026-04-20T12:00:00Z" },
run: {
id: "run-1",
status: {
kind: "archived",
prior: { kind: "succeeded", reason: "completed" },
},
created_at: "2026-04-20T12:00:00Z",
},
};
const firstState = handleLifecycleToastResult("archive", result, initialState, api, () => {
@ -297,7 +306,11 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "unarchive",
ok: true,
run: { id: "run-1", status: "succeeded", created_at: "2026-04-20T12:00:00Z" },
run: {
id: "run-1",
status: { kind: "succeeded", reason: "completed" },
created_at: "2026-04-20T12:00:00Z",
},
};
const stateWithActiveToast: LifecycleToastState = {
activeArchiveToastId: "toast-9",

View file

@ -128,9 +128,10 @@ export async function loader({ request, params }: any): Promise<RunDetailLoaderD
const summary: RunSummaryResponse = await response.json();
const item = mapRunSummaryToRunItem(summary);
const rawStatus = summary.status;
const display = isRunStatus(rawStatus)
? runStatusDisplay[rawStatus]
: { label: rawStatus, dot: "bg-fg-muted", text: "text-fg-muted" };
const statusKind = rawStatus.kind;
const display = isRunStatus(statusKind)
? runStatusDisplay[statusKind]
: { label: statusKind, dot: "bg-fg-muted", text: "text-fg-muted" };
return {
run: {
@ -140,7 +141,7 @@ export async function loader({ request, params }: any): Promise<RunDetailLoaderD
statusText: display.text,
},
blockedQuestionText:
rawStatus === "blocked"
statusKind === "blocked"
? await loadBlockedQuestionText(params.id, request?.signal)
: null,
};

View file

@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import TestRenderer from "react-test-renderer";
import { RunStatus } from "@qltysh/fabro-api-client";
import {
deriveEmptyKind,
emptyStateCopy,
@ -106,12 +105,22 @@ describe("deriveEmptyKind", () => {
});
test("every documented RunStatus gets a non-unknown empty kind", () => {
// Regression guard sourced from the generated API client enum so any
// new RunStatus added to the OpenAPI spec fails this test until the
// decision table grows a branch. Without this guard, unhandled
// statuses silently render as "unknown" ("not available right now") —
// misleading copy for e.g. a paused or archived run.
for (const status of Object.values(RunStatus)) {
// Regression guard sourced from the documented wire discriminators. If
// a new RunStatus kind lands in the API, this list should be updated
// alongside the decision table below.
for (const status of [
"submitted",
"queued",
"starting",
"running",
"blocked",
"paused",
"removing",
"succeeded",
"failed",
"dead",
"archived",
]) {
const result = deriveEmptyKind({
runStatus: status,
totalChanged: 0,

View file

@ -34,7 +34,14 @@ This starts the server on a Unix socket at `~/.fabro/fabro.sock` by default. Use
### First run: web install wizard
If `~/.fabro/settings.toml` does not yet exist, `fabro server start` enters **install mode**: it prints an install URL, attempts to open the URL in your default browser, and serves a web wizard that walks you through configuring your LLM provider, server URL, and GitHub integration.
If `~/.fabro/settings.toml` does not yet exist, `fabro server start` enters **install mode**: it prints an install URL, attempts to open the URL in your default browser, and serves a web wizard that walks you through configuring your server URL, shared object store, LLM provider, and GitHub integration.
The `Object store` step offers two wizard-managed modes:
- `Local disk` for the default host-local SlateDB + artifact storage path
- `AWS S3` for one shared bucket with fixed `slatedb/` and `artifacts/` prefixes
The wizard's manual-credential path stores only `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in `server.env`. It does not collect STS/session tokens or S3-compatible endpoint settings. If you need MinIO, Cloudflare R2, path-style options, or custom endpoints, finish install with local defaults and then edit `[server.slatedb]` / `[server.artifacts]` in `settings.toml` manually.
When you finish the wizard, the server writes `~/.fabro/settings.toml` and exits cleanly. Start it again to boot in configured mode:

View file

@ -56,8 +56,17 @@ strategy = "tailscale_funnel"
[server.storage]
root = "/var/lib/fabro"
[server.artifacts]
provider = "s3"
prefix = "artifacts"
[server.artifacts.s3]
bucket = "my-fabro-data"
region = "us-east-1"
[server.slatedb]
provider = "s3"
prefix = "slatedb"
disk_cache = true
[server.slatedb.s3]
@ -182,6 +191,20 @@ bucket = "{{ env.SLATEDB_BUCKET }}"
region = "us-east-1"
```
The browser install wizard's `Object store` step manages both `[server.slatedb]` and
`[server.artifacts]` together. `Local disk` leaves both sections implicit and keeps the built-in
local defaults. `AWS S3` writes one shared bucket with fixed prefixes `slatedb` and `artifacts`.
The wizard only covers AWS S3 bucket/region plus one of:
- runtime credentials already supplied by the deployment environment
- manually-entered `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`
Advanced S3-compatible settings such as custom `endpoint` or `path_style` remain a manual
configuration path. If you need MinIO, R2, or another S3-compatible backend, configure
`[server.slatedb]` and `[server.artifacts]` directly in `settings.toml`. The runtime still
honors those hand-edited values even though the browser wizard does not manage them.
### Run defaults
The `[run.*]` sections in `settings.toml` act as defaults for every run.
@ -277,6 +300,8 @@ For the auth model above, the main server runtime secrets are:
- `FABRO_DEV_TOKEN` when `"dev-token"` auth is enabled
- `GITHUB_APP_CLIENT_SECRET` when `"github"` auth is enabled
- `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY`, provisioned during install for future CLI login flows
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` when the install wizard or a manual config uses
static S3 object-store credentials
Fabro no longer auto-loads `.env` files. Provider API keys are required for the models you want to use; everything else is optional.
@ -311,6 +336,32 @@ Fabro resolves these from `process env -> server.env`.
| `FABRO_JWT_PUBLIC_KEY` | Ed25519 public key (base64-encoded PEM) for JWT verification |
| `SESSION_SECRET` | Session encryption secret (64-character hex string) |
### Object store runtime secrets (optional)
Fabro resolves these from `process env -> server.env`.
| Variable | Description |
|---|---|
| `AWS_ACCESS_KEY_ID` | Static AWS access key ID for S3-backed `[server.slatedb]` / `[server.artifacts]` |
| `AWS_SECRET_ACCESS_KEY` | Matching static AWS secret access key |
The browser install wizard can write these into `server.env` for the AWS S3 manual-credential
path. It does not support manual STS/session-token input; use runtime credentials instead for ECS,
EC2 instance profiles, IRSA, or web-identity flows.
For the narrowest production policy, scope access to one bucket and the `slatedb/` and
`artifacts/` prefixes with `s3:ListBucket` plus `s3:GetObject`, `s3:PutObject`, and
`s3:DeleteObject`. Prefer a dedicated IAM user or role for Fabro instead of reusing broad AWS
credentials.
If `POST /install/finish` fails after mutating `server.env`, the error response may include
`leftover_env_keys` or `removed_env_keys`. For AWS object-store keys, treat `removed_env_keys` as
informational: the prior managed key path was already cleared, so complete the retry before
restart. If `leftover_env_keys` includes `AWS_ACCESS_KEY_ID` or `AWS_SECRET_ACCESS_KEY`, retry the
install or remove the managed object-store lines from `server.env` before abandoning the host.
Rotation is not required solely because finish failed after an atomic `0600` rewrite, but it
remains the fallback if you no longer trust the host boundary.
### GitHub integration (optional)
| Variable | Description |

View file

@ -185,6 +185,68 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/install/object-store/test:
post:
operationId: testInstallObjectStore
tags: [Install]
summary: Validate install object-store configuration
description: Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/InstallObjectStoreInput"
responses:
"200":
description: Object-store configuration validated successfully
content:
application/json:
schema:
$ref: "#/components/schemas/InstallObjectStoreValidationResponse"
"401":
description: Invalid or missing install token
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Object-store validation failed
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/install/object-store:
put:
operationId: putInstallObjectStore
tags: [Install]
summary: Save install object-store configuration
description: Records the object-store mode selected during browser install. Requires the one-time install token.
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/InstallObjectStoreInput"
responses:
"204":
description: Object-store configuration recorded
"401":
description: Invalid or missing install token
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Invalid install input
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/install/github/token/test:
post:
operationId: testInstallGithubToken
@ -2164,6 +2226,10 @@ components:
oneOf:
- $ref: "#/components/schemas/InstallServerConfigInput"
- type: "null"
object_store:
oneOf:
- $ref: "#/components/schemas/InstallObjectStoreSummary"
- type: "null"
github:
oneOf:
- $ref: "#/components/schemas/InstallGithubSummary"
@ -2256,6 +2322,56 @@ components:
type: string
format: uri
InstallObjectStoreValidationResponse:
description: Successful response from install-time object-store validation.
type: object
required:
- ok
properties:
ok:
type: boolean
example: true
InstallObjectStoreInput:
description: Object-store mode selected during browser install.
type: object
required:
- provider
properties:
provider:
type: string
enum: [local, s3]
bucket:
type: string
region:
type: string
credential_mode:
type: string
enum: [runtime, access_key]
access_key_id:
type: string
secret_access_key:
type: string
InstallObjectStoreSummary:
description: Redacted summary of the object-store mode selected during browser install.
type: object
required:
- provider
properties:
provider:
type: string
enum: [local, s3]
bucket:
type: string
region:
type: string
credential_mode:
type: string
enum: [runtime, access_key]
manual_credentials_saved:
type: boolean
InstallGithubTokenTestInput:
description: Input for install-time GitHub token validation.
type: object
@ -2793,19 +2909,192 @@ components:
an explicit user action on a previously terminal (`succeeded`, `failed`,
or `dead`) run; archived runs are hidden from default listings and are
read-only until unarchived.
oneOf:
- $ref: "#/components/schemas/RunStatusSubmitted"
- $ref: "#/components/schemas/RunStatusQueued"
- $ref: "#/components/schemas/RunStatusStarting"
- $ref: "#/components/schemas/RunStatusRunning"
- $ref: "#/components/schemas/RunStatusBlocked"
- $ref: "#/components/schemas/RunStatusPaused"
- $ref: "#/components/schemas/RunStatusRemoving"
- $ref: "#/components/schemas/RunStatusSucceeded"
- $ref: "#/components/schemas/RunStatusFailed"
- $ref: "#/components/schemas/RunStatusDead"
- $ref: "#/components/schemas/RunStatusArchived"
discriminator:
propertyName: kind
mapping:
submitted: "#/components/schemas/RunStatusSubmitted"
queued: "#/components/schemas/RunStatusQueued"
starting: "#/components/schemas/RunStatusStarting"
running: "#/components/schemas/RunStatusRunning"
blocked: "#/components/schemas/RunStatusBlocked"
paused: "#/components/schemas/RunStatusPaused"
removing: "#/components/schemas/RunStatusRemoving"
succeeded: "#/components/schemas/RunStatusSucceeded"
failed: "#/components/schemas/RunStatusFailed"
dead: "#/components/schemas/RunStatusDead"
archived: "#/components/schemas/RunStatusArchived"
RunStatusSubmitted:
type: object
required:
- kind
properties:
kind:
type: string
enum:
- submitted
RunStatusQueued:
type: object
required:
- kind
properties:
kind:
type: string
enum:
- queued
RunStatusStarting:
type: object
required:
- kind
properties:
kind:
type: string
enum:
- starting
RunStatusRunning:
type: object
required:
- kind
properties:
kind:
type: string
enum:
- running
RunStatusBlocked:
type: object
required:
- kind
- blocked_reason
properties:
kind:
type: string
enum:
- blocked
blocked_reason:
$ref: "#/components/schemas/BlockedReason"
RunStatusPaused:
type: object
required:
- kind
- prior_block
properties:
kind:
type: string
enum:
- paused
prior_block:
oneOf:
- $ref: "#/components/schemas/BlockedReason"
- type: "null"
RunStatusRemoving:
type: object
required:
- kind
properties:
kind:
type: string
enum:
- removing
RunStatusSucceeded:
type: object
required:
- kind
- reason
properties:
kind:
type: string
enum:
- succeeded
reason:
$ref: "#/components/schemas/SuccessReason"
RunStatusFailed:
type: object
required:
- kind
- reason
properties:
kind:
type: string
enum:
- failed
reason:
$ref: "#/components/schemas/FailureReason"
RunStatusDead:
type: object
required:
- kind
properties:
kind:
type: string
enum:
- dead
RunStatusArchived:
type: object
required:
- kind
- prior
properties:
kind:
type: string
enum:
- archived
prior:
$ref: "#/components/schemas/TerminalStatus"
TerminalStatus:
description: Terminal run status captured inside an archived run.
oneOf:
- $ref: "#/components/schemas/RunStatusSucceeded"
- $ref: "#/components/schemas/RunStatusFailed"
- $ref: "#/components/schemas/RunStatusDead"
discriminator:
propertyName: kind
mapping:
succeeded: "#/components/schemas/RunStatusSucceeded"
failed: "#/components/schemas/RunStatusFailed"
dead: "#/components/schemas/RunStatusDead"
SuccessReason:
description: Reason attached to a successful terminal run status.
type: string
enum:
- submitted
- queued
- starting
- running
- blocked
- paused
- removing
- succeeded
- failed
- dead
- archived
- completed
- partial_success
FailureReason:
description: Reason attached to a failed terminal run status.
type: string
enum:
- workflow_error
- cancelled
- terminated
- transient_infra
- budget_exhausted
- launch_failed
- bootstrap_failed
- sandbox_init_failed
RunManifest:
description: Self-contained workflow run manifest.
@ -3178,16 +3467,8 @@ components:
$ref: "#/components/schemas/RunError"
queue_position:
type: integer
description: Position in the queue (1-based). Only present when status is `queued`.
description: Position in the queue (1-based). Only present when the status kind is `queued`.
example: 3
status_reason:
oneOf:
- $ref: "#/components/schemas/StatusReason"
- type: "null"
blocked_reason:
oneOf:
- $ref: "#/components/schemas/BlockedReason"
- type: "null"
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
@ -3325,6 +3606,22 @@ components:
description: List of error entries.
items:
$ref: "#/components/schemas/ErrorResponseEntry"
leftover_env_keys:
type: array
description: >-
Optional list of runtime env keys that were written before an install
failure. Currently populated by `POST /install/finish` failure
responses only.
items:
type: string
removed_env_keys:
type: array
description: >-
Optional list of runtime env keys that were actually removed before
an install failure. Currently populated by `POST /install/finish`
failure responses only.
items:
type: string
ActorKind:
description: High-level category of an event actor.
@ -3569,22 +3866,6 @@ components:
items:
$ref: "#/components/schemas/RunArtifactEntry"
StatusReason:
description: Optional reason attached to a run status transition.
type: string
enum:
- completed
- partial_success
- workflow_error
- cancelled
- terminated
- transient_infra
- budget_exhausted
- launch_failed
- bootstrap_failed
- sandbox_init_failed
- sandbox_initializing
BlockedReason:
description: Specific reason a run is blocked on external intervention.
type: string
@ -3599,27 +3880,6 @@ components:
- pause
- unpause
RunStatusRecord:
description: Internal run status record from the event projection.
type: object
required:
- status
- updated_at
properties:
status:
$ref: "#/components/schemas/RunStatus"
status_reason:
oneOf:
- $ref: "#/components/schemas/StatusReason"
- type: "null"
blocked_reason:
oneOf:
- $ref: "#/components/schemas/BlockedReason"
- type: "null"
updated_at:
type: string
format: date-time
InternalStageStatus:
description: Internal stage status from outcomes and node status records.
type: string
@ -3696,7 +3956,12 @@ components:
additionalProperties: true
status:
oneOf:
- $ref: "#/components/schemas/RunStatusRecord"
- $ref: "#/components/schemas/RunStatus"
- type: "null"
status_updated_at:
oneOf:
- type: string
format: date-time
- type: "null"
checkpoint:
oneOf:
@ -3779,14 +4044,6 @@ components:
format: date-time
status:
$ref: "#/components/schemas/RunStatus"
status_reason:
oneOf:
- $ref: "#/components/schemas/StatusReason"
- type: "null"
blocked_reason:
oneOf:
- $ref: "#/components/schemas/BlockedReason"
- type: "null"
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
@ -4146,7 +4403,7 @@ components:
description: Human-readable title describing the run's goal.
example: Add rate limiting to auth endpoints
status:
type: string
$ref: "#/components/schemas/RunStatus"
labels:
type: object
additionalProperties:
@ -4156,10 +4413,6 @@ components:
start_time:
type: ["string", "null"]
format: date-time
status_reason:
oneOf:
- $ref: "#/components/schemas/StatusReason"
- type: "null"
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"

View file

@ -0,0 +1,95 @@
---
date: 2026-04-22
topic: web-install-object-store-choice
---
# Web Install Object Store Choice
## Problem Frame
The browser install wizard currently configures server URL, LLM credentials, and GitHub integration, but it does not let the operator choose where Fabro's object-store-backed data lives. Fabro already supports a local object store and AWS S3 in server configuration, and the runtime model keeps a separate local storage root for host-local state. The missing product behavior is a guided first-run choice for the shared object store used by both SlateDB and artifacts.
Without this step, operators who want S3-backed storage must finish install and then hand-edit server configuration and startup secrets. That creates avoidable setup drift between "works locally" and "production-like" deployments, especially for remote-first installs where the web wizard is supposed to be the primary setup path.
```mermaid
flowchart TB
A[Welcome]
B[Server]
C[Object store]
D[LLM]
E[GitHub]
F[Review]
G[Finish]
A --> B --> C --> D --> E --> F --> G
```
## Requirements
**Wizard flow**
- R1. The web install wizard must add a new `Object store` step after `Server` and before `LLM`, making the operator-visible order `Welcome → Server → Object store → LLM → GitHub → Review`.
- R2. The sidebar/progress UI, route flow, back/next behavior, and review screen must treat `Object store` as a first-class step, with refresh/re-entry behavior matching the existing install steps.
- R3. The wizard must explain that Fabro always keeps a local storage directory on the host and that this step chooses only the shared object store for object-store-backed server data.
**Storage modes**
- R4. The `Object store` step must offer exactly two wizard-managed modes: `Local disk` and `AWS S3`.
- R5. Choosing `Local disk` must configure both `server.slatedb` and `server.artifacts` to use the local object store provider.
- R6. Choosing `Local disk` must not prompt for S3-only fields such as bucket, region, or AWS credentials.
- R7. Choosing `AWS S3` must configure both `server.slatedb` and `server.artifacts` to use AWS S3, with one shared bucket and separate fixed prefixes so SlateDB data lives under `slatedb/` and artifact data lives under `artifacts/`.
- R8. The wizard must not expose separate object-store choices for SlateDB and artifacts. Operators who need split backends or more advanced layouts must configure them manually after install.
- R9. The wizard must not expose S3-compatible endpoint or path-style options in v1. The step must clearly note that non-AWS S3-compatible backends are out of scope for the wizard and require manual configuration.
**S3 fields and credentials**
- R10. When `AWS S3` is selected, the wizard must collect `bucket` and `region`.
- R11. The `AWS S3` path must offer exactly two credential modes: `Use AWS runtime credentials` and `Enter AWS access key credentials`.
- R12. `Use AWS runtime credentials` must be described as the path for deployments where AWS credentials come from the runtime environment or attached role, and it must not prompt for AWS secret material.
- R13. `Enter AWS access key credentials` must prompt only for `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`.
- R14. Manual AWS access-key credentials are server-only startup secrets. They must be persisted as server startup env secrets, not as workflow-visible vault secrets.
- R15. The wizard must not support session-token or STS inputs in v1. Operators who need temporary-session credential flows must configure object-store access manually outside the wizard.
**Validation and review**
- R16. The `AWS S3` path must perform live validation before the operator can continue past the `Object store` step.
- R17. Live validation must confirm that the configured bucket, region, chosen credential mode, and fixed `slatedb/` / `artifacts/` prefixes permit Fabro to reach the target S3 bucket with the intended object-store setup.
- R18. Validation errors must render inline on the `Object store` step and make it clear whether the failure is due to bucket/region mismatch, missing runtime credentials, or invalid manually-entered credentials when that distinction is available.
- R19. The review step must summarize the chosen object-store mode. For S3, it must show the bucket, region, credential mode, and that Fabro will use the fixed `slatedb/` and `artifacts/` prefixes in the shared bucket without revealing secret values.
## Success Criteria
- Operators can complete first-run setup for either local object storage or AWS S3 without editing `settings.toml` by hand.
- An install completed with `Local disk` produces the expected local-provider config for both SlateDB and artifacts.
- An install completed with `AWS S3` produces the expected shared-bucket S3 config for both SlateDB and artifacts, with `slatedb/` and `artifacts/` prefixes.
- Manual AWS credentials, when provided, are stored only as server startup secrets and are not exposed in workflow-visible secret surfaces.
- Misconfigured S3 details are caught during the install wizard instead of surfacing only after install completes and the server restarts.
## Scope Boundaries
- The local host storage root remains separate and is not replaced by S3.
- The wizard does not support separate object-store backends for SlateDB and artifacts.
- The wizard does not support non-AWS S3-compatible backends.
- The wizard does not support session-token or STS credential inputs.
- Advanced object-store layouts, custom prefixes beyond the fixed `slatedb/` and `artifacts/`, and other specialized storage topologies remain manual-configuration workflows.
## Key Decisions
- One shared object-store choice for both SlateDB and artifacts: keeps the wizard simple and avoids first-run misconfiguration.
- AWS S3 only in the wizard: reduces surface area and avoids exposing endpoint/path-style details meant for manual advanced setups.
- Two S3 auth paths: ambient runtime credentials for AWS-native deployments, or manually-entered access keys for non-AWS deployments that still target S3.
- Live validation on the wizard step: catches storage errors before the install succeeds and the server exits.
- `Object store` comes before provider/integration setup: keeps server-level infrastructure decisions together near the server URL step.
## Dependencies / Assumptions
- The existing server config model continues to treat `server.storage.root` as host-local storage and `server.slatedb` / `server.artifacts` as separate object-store-backed domains.
- The runtime continues to initialize S3 access from startup environment credentials or the ambient AWS credential chain.
- Using one shared S3 bucket with distinct `slatedb/` and `artifacts/` prefixes is sufficient for the wizard-managed path.
## Outstanding Questions
### Resolve Before Planning
(none)
### Deferred to Planning
- [Affects R16-R18] [Technical] Define the exact validation probe shape for local vs S3 so the install flow checks the real runtime path without creating misleading side effects in the bucket.
- [Affects R14] [Technical] Confirm the exact persistence path and lifecycle for manual AWS credentials in `server.env`, including whether existing install rollback behavior is sufficient for newly-added AWS env keys.
- [Affects R19] [Technical] Decide how the review screen and session payload represent the credential mode without leaking secret values and while preserving edit/re-entry behavior.
## Next Steps
→ `/prompts:ce-plan` for structured implementation planning

View file

@ -0,0 +1,472 @@
---
title: "feat: Add object-store step to the browser install wizard"
type: feat
status: completed
date: 2026-04-22
origin: docs/brainstorms/2026-04-22-web-install-object-store-choice-requirements.md
---
# feat: Add object-store step to the browser install wizard
## Overview
Add a first-run `Object store` step to the browser install wizard between `Server` and `LLM`. The step lets the operator choose one shared object-store mode for both SlateDB and artifacts: `Local disk` or `AWS S3`. The S3 path collects `bucket`, `region`, and one of two credential modes: runtime credentials (deployment-provided AWS auth such as ECS/EKS/IRSA metadata flows, instance profiles, or AWS env vars already present in the server process) or manually-entered `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`.
> **Terminology.** This plan uses **runtime credentials** to refer to the deployment-provided AWS auth path (what the UI labels `Use AWS runtime credentials`). Earlier drafts called this "ambient", "ambient runtime", or "default chain"; those were shorthand for "credentials supplied by the runtime rather than typed into the wizard". **Manual credentials** refers to the static access-key-pair path.
The implementation is broader than a UI form. It touches the install API contract, server-side install session state, settings/env persistence, the browser wizard flow, and the server's runtime object-store initialization. The key technical requirement is that manual AWS credentials stored in `server.env` must actually be consumed by the S3 object-store builder at install-finish time and on subsequent server boots.
The wizard's goal is that `POST /install/finish` produces a fully bootable server with no subsequent operator env-editing step. A UI-only variant that writes `settings.toml` but requires the operator to separately place `AWS_*` into process env or `server.env` defeats that goal — it leaves the wizard claiming completion without persisting the secrets it just collected, and turns the "type keys into the wizard" affordance into a trap. That UX goal is what keeps Unit 2's runtime bridge load-bearing; the existing `process env -> server.env` precedence in `server_secrets.rs` is the precedence mechanism we reuse, not a standalone architectural invariant.
## Problem Frame
The browser install flow currently covers canonical URL, LLM credentials, and GitHub, but it does not expose the existing object-store configuration surface that already exists in server settings. Operators who want S3-backed SlateDB/artifact storage have to finish the wizard and then hand-edit `settings.toml` plus startup secrets.
That defeats the point of a remote-first web install flow. The object-store choice belongs in the install wizard because it is foundational server infrastructure, not an advanced follow-up tweak. The plan keeps the local storage root model unchanged: `server.storage.root` remains host-local storage, while the new step chooses only the shared object store used by `server.slatedb` and `server.artifacts` (see origin: `docs/brainstorms/2026-04-22-web-install-object-store-choice-requirements.md`).
## Requirements Trace
### Wizard placement and step ordering (R1-R3)
- Add a first-class `Object store` wizard step after `Server` and before `LLM`, and make the UI copy explicit that the local host storage root still exists and this step only chooses the shared object store.
### Storage mode and scope (R4-R9)
- Offer exactly two wizard-managed modes: `Local disk` and `AWS S3`. The choice applies to both `server.slatedb` and `server.artifacts`. The S3 path uses one shared bucket with fixed prefixes `slatedb` and `artifacts` in config, which correspond to `slatedb/` and `artifacts/` keys in the bucket at runtime. No split backends, custom prefixes, endpoint, or path-style options are exposed in the wizard.
### Credential collection and persistence (R10-R15)
- The S3 path collects `bucket`, `region`, and one of two credential modes: runtime credentials or manual access-key credentials. Manual mode collects only `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, persists them as server-only startup secrets, and explicitly excludes STS/session-token inputs.
### Validation and session handling (R16-R18)
- S3 configuration is live-validated before the wizard advances. Validation errors render inline and should distinguish shape errors from access failures where practical. The review/session surfaces summarize the chosen object-store mode without echoing secret values.
### Review display (R19)
- The review step shows the chosen mode, and for S3 shows bucket, region, credential mode, and the fixed `slatedb` / `artifacts` prefixes.
## Scope Boundaries
- No changes to the terminal `fabro install` flow in this pass.
- No attempt to make `server.storage.root` remote or optional. Host-local storage remains required.
- No support for separate object-store backends for SlateDB and artifacts.
- No S3-compatible endpoint/path-style options in the wizard.
- No STS/session-token/manual-profile flows.
- No SlateDB tuning changes such as `disk_cache` defaults; existing defaults remain in force.
- No generic install-wizard refactor from the current monolithic `install-app.tsx` into route-local files unless a small local helper extraction is needed to keep the patch readable.
## Context & Research
### Relevant Code and Patterns
- **Install API contract:** `docs/api-reference/fabro-api.yaml` currently exposes install session, LLM, server, GitHub, and finish endpoints. `InstallSessionResponse` has `llm`, `server`, `github`, and `prefill`, but no storage/object-store field.
- **Browser install client:** `apps/fabro-web/app/install-api.ts` is a thin raw-fetch wrapper over the install routes. It already follows the pattern `POST /test` then `PUT /step` for LLM and GitHub token validation.
- **Browser install UI:** `apps/fabro-web/app/install-app.tsx` is the install wizard host. Step order, current-step routing, back/next links, session hydration, and review summary are all hard-coded there today.
- **Server install session/router:** `lib/crates/fabro-server/src/install.rs` owns `PendingInstall`, the per-step DTOs, the `/install/*` handlers, `completed_steps`, and the `post_install_finish` persistence path.
- **Shared install persistence:** `lib/crates/fabro-install/src/lib.rs` already centralizes pure install-time settings mutation (`merge_server_settings`, `write_token_settings`, `write_github_app_settings`) and direct persistence (`persist_install_outputs_direct`).
- **Runtime object-store wiring:** `lib/crates/fabro-server/src/serve.rs` builds local/S3 object stores from resolved settings. The current S3 path uses `AmazonS3Builder::from_env()` directly, so it sees process env but does not currently consult `server.env`.
- **Server secret precedence:** `lib/crates/fabro-server/src/server_secrets.rs` already defines the server runtime precedence model as `process env -> server.env`. `docs/administration/server-configuration.mdx` documents the same rule.
- **Install router tests:** `lib/crates/fabro-server/tests/it/api/install.rs` covers install session/auth, finish semantics, and artifact metadata creation. `apps/fabro-web/app/install-app.test.tsx` already exercises wizard rehydration and GitHub callback flows.
### Existing Patterns to Reuse
- **Separate validation and persistence endpoints:** `POST /install/llm/test` and `POST /install/github/token/test` validate external credentials without mutating session state; `PUT /install/llm` and `PUT /install/github/token` persist only after client-side success.
- **Redacted session snapshots:** `install.rs` stores raw secrets in memory but exposes only redacted summaries through `GET /install/session`. The LLM and GitHub flows already prove the pattern for "configured but not rehydrated" secret inputs.
- **Shared settings helpers:** `fabro-install` is already the right place for pure config/env mutation helpers used by both CLI/server install surfaces.
- **Server-env precedence tests:** `lib/crates/fabro-server/src/server.rs` already has coverage proving process env wins over `server.env`.
### External References
- `object_store::aws::AmazonS3Builder` supports `new()`, explicit access-key setters, and explicit env-driven credential-provider inputs. That is the right primitive for preserving AWS role-based runtime auth while avoiding accidental leakage from unrelated `AWS_*` process env settings such as endpoint overrides.
- `object_store` list operations are a good fit for install-time validation because they exercise the real bucket/prefix access path without writing metadata during the wizard.
### Institutional Learnings
- No `docs/solutions/` entry currently covers object-store-backed install flows or S3 credential bridging. This is net-new planning ground.
## Key Technical Decisions
- **Use a dedicated object-store install contract.** Add a new install step and API shape instead of overloading `InstallServerConfigInput`. The server session response gets a separate redacted `object_store` summary field so the review screen and re-entry flow do not have to infer object-store state from raw settings text.
- **Use `object_store` as the session/completion token, and `/install/object-store` as the route.** The user-facing label stays `Object store`, while the route stays readable and the completed-step token matches current string-based step handling.
- **Keep `Local disk` as a settings no-op.** For the local path, do not write new `[server.slatedb]` / `[server.artifacts]` sections just to restate current defaults. `post_install_finish` already rebuilds `settings.toml` from a fresh TOML document on every run, so selecting `Local disk` naturally drops previously-written S3 sections without needing subtractive settings edits. The session/review surfaces still record the operator's choice during the wizard.
- **Write explicit S3 config only when S3 is selected.** The S3 path writes `[server.artifacts] provider = "s3"`, `prefix = "artifacts"`, `[server.artifacts.s3] bucket/region`, and the analogous `[server.slatedb]` config with `prefix = "slatedb"`. Prefix values are stored without trailing slashes because artifact paths are joined by `ArtifactStore::prefixed_raw()` in `lib/crates/fabro-store/src/artifact_store.rs`, while SlateDB base prefixes are normalized by `normalize_base_prefix()` in `lib/crates/fabro-store/src/slate/mod.rs`.
- **Extend `fabro-install` instead of hand-building new TOML in `install.rs`.** Add pure helper(s) in `lib/crates/fabro-install/src/lib.rs` for object-store settings mutation and server-env edits so the server install code stays declarative and future CLI parity has a reusable base.
- **Support both env sets and env removals during direct install persistence, scoped to wizard-managed keys.** This feature needs explicit removal support for `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` when the operator switches away from manual-credential mode. However, `server.env` may contain AWS keys the operator set for reasons unrelated to Fabro's object store (workflow-level tools, other integrations). Removal must be scoped to keys the wizard itself placed: wizard-written env lines carry a marker comment (for example, `# managed by fabro-install: object-store`) and the removal path only deletes keys that still carry that marker. Keys in `server.env` without the marker survive untouched, even when the current wizard mode would not write them.
- **Keep `server.env` persistence convergent on retry, not atomic at the install-flow level, but atomic at the filesystem level.** `server.env` mutations are applied immediately and persist even if later settings/vault writes fail. This plan does not widen scope into changing that broader install behavior. The new AWS-key removal path should follow the same model: retries must converge the file to the newly chosen state, and tests should make that lifecycle explicit. Each individual write must be atomic at the filesystem level — `envfile::merge_env_file` should write to a sibling tempfile in the same directory, `fchmod` it to `0600`, `fsync`, `rename(2)` it into place, and `fsync` the parent directory, so readers never observe a truncated or partial-credentials file.
- **Make the runtime object-store builder env-aware without inheriting unrelated `AWS_*` process env.** Refactor the S3 builder path to start from `AmazonS3Builder::new()`, apply bucket/region/path-style/endpoint from resolved settings, and feed credential-related lookup values through the existing `process env -> server.env` precedence model. Reuse `ServerSecrets::{with_env_lookup,get}` rather than introducing a second precedence system; `server_secrets.rs` should only change if a tiny helper extraction is needed for readability.
- **Allow only the credential-provider env inputs the runtime actually needs.** The env-aware builder should explicitly map the supported static-key, web-identity, container-credential, and metadata-provider inputs that `object_store` understands. It must not honor `AWS_ENDPOINT`, `AWS_ENDPOINT_URL_S3`, or similar endpoint-override env vars from process env or `server.env`; endpoint continues to come only from resolved settings, which keeps the wizard on the AWS-only path and avoids validation probing arbitrary internal hosts.
- **Use explicit static credentials only when both halves are present, but scope that rule to the static access-key path.** If exactly one of `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` is available from the combined lookup, return a clear install/runtime error for the static-key path instead of falling back. Do not reinterpret other runtime-credential inputs or widen the rule into a generic failure mode for non-static provider flows.
- **Use non-mutating prefix probes for S3 validation.** The validation endpoint should build the same object-store shape the runtime will use, then perform non-writing access probes against both `artifacts` and `slatedb` prefixes. Avoid using `write_metadata()` during the wizard because that adds avoidable side effects to a step the user may back out of.
- **Keep wizard secret re-entry behavior consistent with existing install steps.** Manual AWS credentials are never returned in `GET /install/session`. If the user re-enters the step after saving manual credentials, the credential mode and bucket/region are shown, the secret fields remain blank, blank submit preserves the stored pair, and entering either secret field switches into replacement mode that requires both fields.
- **Enforce `0600` permissions on `server.env`.** `fabro-config::envfile::set_private_permissions` already applies `0600` on unix and propagates the `io::Error` when the `chmod` fails; Unit 1's test only needs to assert the resulting permission bits on both create and update paths and decide whether the current non-unix no-op is acceptable for this feature. AWS access keys are long-lived, high-value credentials; world-readable or group-readable persistence would be an obvious pivot path for local attackers or low-privilege co-tenants.
- **Hold raw AWS credentials in a redaction-safe wrapper, zeroized on session end.** The raw `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` values are reachable only through an `expose_secret()`-style accessor used exclusively at the builder boundary and at the `server.env` write boundary. The wrapper's `Serialize`/`Debug`/`Display` must print a fixed redaction token, never the raw value. Every error path — including those produced by `AmazonS3Builder` / `object_store` / `reqwest` — must be rewritten into install-domain error types that do not interpolate the raw secret; `format!("{}", raw_key)` and direct `tracing::*` field capture of the unwrapped value are forbidden. Assertions on at least two paths (validation-error response and finish-error response with a fabricated but structurally-realistic key) must confirm the raw value does not appear in response bodies or captured tracing output. The in-memory copy on `PendingInstall` uses the same `Zeroize`/`SecretString` wrapper and must be cleared on finish success, explicit session reset, install-session expiry, and when the operator submits a provider/mode change that no longer needs the stored pair.
- **Do not widen scope into CLI parity or S3-compatible backends.** The plan stays anchored to the browser wizard requirements document and does not pull in `fabro install`, endpoint/path-style UI, or STS.
## Open Questions
### Resolved During Planning
- **How should local object-store choice persist?** Leave local object-store config implicit through existing defaults; only the S3 path writes explicit object-store sections.
- **Where should manual AWS credentials live?** In `server.env`, not in the vault, and they must be removable when the chosen mode no longer needs them.
- **How does the server actually consume manual AWS credentials?** Through an env-aware object-store builder that follows the existing `process env -> server.env` precedence model instead of relying on raw process env alone.
- **What should install-time validation probe do?** Build the real object-store configuration and perform side-effect-free access probes against the fixed `artifacts` and `slatedb` prefixes.
- **Do we need CLI install changes now?** No. This plan is browser-install-only.
### Deferred to Implementation
- **Exact OpenAPI schema factoring:** Whether `credential_mode` becomes a dedicated schema or remains an inline string enum is mechanical. The important part is that the save payload and session summary are separate types.
- **Exact shape of the validation success response:** `204 No Content` or `{ ok: true }` are both workable. Match the existing install API style chosen by the implementer, but keep the error path aligned with `readInstallError()`.
## High-Level Technical Design
> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
### User flow
```mermaid
flowchart TB
A[Welcome]
B[Server]
C[Object store]
D[LLM]
E[GitHub]
F[Review]
G[Finish]
A --> B --> C --> D --> E --> F --> G
```
### Mode matrix
| Wizard choice | settings.toml | server.env | Validation |
|---|---|---|---|
| `Local disk` | No new object-store sections; current defaults continue to resolve local SlateDB + local artifacts | Remove stale wizard-managed `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` entries if present | No network probe; validation is a no-op beyond schema checks |
| `AWS S3` + `Use AWS runtime credentials` | Write explicit S3 sections for both `server.artifacts` and `server.slatedb` with fixed prefixes `artifacts` / `slatedb` and shared bucket/region | Remove stale wizard-managed static AWS key vars if present | Probe S3 access using runtime credentials supplied by the deployment environment |
| `AWS S3` + `Enter AWS access key credentials` | Same explicit S3 sections as above | Set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` | Probe S3 access using the submitted explicit key pair |
### Dependency graph
```mermaid
flowchart TB
U1[U1 Contract and shared install helpers]
U2[U2 Env-aware object-store runtime and validation]
U3[U3 Install router/session/finish integration]
U4[U4 Browser wizard step and review UI]
U5[U5 Docs and generated surfaces]
U1 --> U3
U2 --> U3
U1 --> U4
U3 --> U4
U3 --> U5
U4 --> U5
```
## Implementation Units
- [x] **Unit 1: Contract and shared install helpers**
**Goal:** Add the durable install contract for the new object-store step and extend shared install persistence helpers so the server install flow can write/remove object-store env keys without ad hoc logic.
**Requirements:** R1-R3, R4-R15, R19
**Dependencies:** None
**Files:**
- Modify: `docs/api-reference/fabro-api.yaml`
- Modify: `lib/crates/fabro-install/src/lib.rs`
- Regenerate: `lib/packages/fabro-api-client/` (via `cd lib/packages/fabro-api-client && bun run generate`; includes new/modified models under `src/api/install-api.ts`, `src/models/install-session-response.ts`, and `src/models/install-object-store*.ts`)
- Test: `lib/crates/fabro-install/src/lib.rs`
**Approach:**
- Add a dedicated object-store save payload and redacted session summary to the install OpenAPI surface, plus `POST /install/object-store/test` and `PUT /install/object-store`.
- Model the save payload with one shared choice for both storage domains:
- `provider: "local" | "s3"`
- optional S3 payload with `bucket`, `region`, `credential_mode`
- manual mode only: `access_key_id`, `secret_access_key`
- Keep the session summary separate from the save payload so secrets never appear in `InstallSessionResponse`.
- Add a pure `fabro-install` helper for writing the S3 settings shape into a mutable TOML doc, including fixed prefixes `artifacts` and `slatedb`.
- Expand direct install persistence so it can remove stale env keys as well as merge new ones. The current CLI path (`server_env_remove` inside `fabro-cli`) and the shared `fabro_install::persist_install_outputs_direct` / `envfile::merge_env_file` are not symmetric today; extending `persist_install_outputs_direct` to accept a remove-list is part of this unit, not a free reuse. Hoisting the CLI's existing removal code into the shared crate is acceptable.
- After the OpenAPI edit, run `cd lib/packages/fabro-api-client && bun run generate` so downstream units (and the web app) can consume the new `InstallObjectStore*` models.
- Treat the local path as a no-op for object-store config but still return the AWS key removals so retries over stale `server.env` state converge correctly.
- `persist_install_outputs_direct` must write `server.env` with `0600` permissions (create or rewrite) and must fail closed if it cannot set them. A unit test must assert the permission bits on both create and update paths.
**Patterns to follow:**
- `merge_server_settings`, `write_token_settings`, and `write_github_app_settings` in `lib/crates/fabro-install/src/lib.rs`
- Existing install OpenAPI shapes in `docs/api-reference/fabro-api.yaml`
**Test scenarios:**
- Happy path: local object-store selection writes no explicit object-store sections and requests removal of stale AWS key vars.
- Happy path: S3 runtime-credential selection writes both `server.artifacts` and `server.slatedb` S3 sections with `bucket`, `region`, and fixed prefixes `artifacts` / `slatedb`.
- Happy path: S3 manual-credential selection writes the same S3 config and returns `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` env edits.
- Edge case: prefixes are written without trailing `/` in TOML and still represent `artifacts/` / `slatedb/` at runtime.
- Edge case: unrelated `server.env` keys survive the set/remove rewrite unchanged.
- Error path: malformed helper input (for example, S3 mode without bucket/region) fails before serializing install output.
- Integration: direct persistence removes stale wizard-managed AWS keys when the selected mode no longer needs them while leaving unmarked operator-managed keys untouched.
**Verification:**
- `fabro-install` helper tests cover local, S3 runtime, S3 manual, and env-removal behavior.
- The generated TypeScript client exposes the new session field and object-store models expected by the web app.
- [x] **Unit 2: Env-aware object-store runtime and install-time validation**
**Goal:** Refactor object-store creation so manual AWS credentials stored in `server.env` are actually consumable at runtime and during install-finish metadata writes, while also providing a shared, side-effect-free validation path for the new install step.
**Requirements:** R7-R15 primary (shared S3 shape and credential-mode runtime consumption). R16-R18 only at the validation-helper layer, where Unit 2 translates object-store failures into install-domain validation outcomes that Units 3 and 4 surface.
**Dependencies:** None
**Files:**
- Modify: `lib/crates/fabro-server/src/serve.rs`
- Modify: `lib/crates/fabro-server/src/install.rs`
- Test: `lib/crates/fabro-server/src/serve.rs`
- Test: `lib/crates/fabro-server/src/install.rs` or `lib/crates/fabro-server/tests/it/api/install.rs`
**Approach:**
- Refactor the S3 builder path in `serve.rs` so it accepts the resolved `ObjectStoreSettings` plus an env lookup following `process env -> server.env`. The existing `ServerSecrets::{with_env_lookup,get}` API already matches the needed precedence shape; Unit 2 should reuse it rather than inventing a second lookup type.
- Start from `AmazonS3Builder::new()`, not `from_env()`. Apply bucket/region from resolved settings, continue to honor settings-driven `endpoint` / `path_style` for hand-configured advanced deployments, and feed only the explicit credential-provider env inputs the runtime needs through the injected lookup.
- Runtime-credential path: populate the builder from the lookup only for static-key vars and the `object_store`-supported runtime provider hints (web identity, ECS/EKS container credentials, IMDS-related inputs). Do not load endpoint-override env vars from process env or `server.env`.
- Manual validation path: do not add a one-off explicit-credentials parameter. Instead, build a temporary overlay lookup that returns the submitted `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` and suppresses any `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` values that would otherwise come from process env or `server.env`. Non-credential lookups (for example region defaults) delegate to the same `ServerSecrets::get` the runtime uses, so the overlay composes through `ServerSecrets`, not around it. This keeps validation and startup on the same builder path and ensures the probe uses exactly the typed key pair rather than silently authenticating with stronger ambient credentials that happen to be available in the environment.
- If both `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are available from the effective lookup, apply them explicitly with builder setters. If only one of the two keys is present, return a clear static-credential error instead of falling back.
- Route `build_artifact_object_store`, `build_slatedb_store`, and `write_artifact_store_metadata()` through the env-aware path so manual credentials work immediately after `/install/finish`, not only on a later restart.
- Add a shared install validation helper that:
- short-circuits `Local disk`
- for S3, builds the real object-store configuration for the chosen credential mode
- pins the client to the submitted region (no global-endpoint fallback), so a bucket that lives in a different region surfaces as a distinct error rather than succeeding via transparent cross-region redirect
- performs non-mutating access probes on both `artifacts` and `slatedb` prefixes via `object_store.list_with_delimiter(Some(&Path::from(prefix.trim_end_matches('/').to_string())))`
- applies a short connect timeout plus a 20-second total timeout for the probe path, with no silent retry in v1
- treats any successful response, including an empty result set, as "prefix reachable"
- distinguishes, in the install-domain error translation, between `bucket_not_found`, `bucket_region_mismatch`, `access_denied`, and `prefix_access_failed` where the underlying SDK response makes the distinction possible
- lives in `install.rs` as install-specific orchestration over the shared builder path in `serve.rs`
- wraps lower-level failures in install-step-specific messages such as `Bucket is required.`, `Region is required. Use a value like us-east-1.`, `Enter both AWS access key fields or switch to runtime credentials.`, `Could not access bucket <bucket> in region <region> with the selected credentials.`, and `Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes.`
**Patterns to follow:**
- Existing builder path in `lib/crates/fabro-server/src/serve.rs`
- `ServerSecrets` precedence rules in `lib/crates/fabro-server/src/server_secrets.rs`
**Technical design:** *(directional only)*
```text
runtime startup:
server_secrets = ServerSecrets::load(server_env_path)
env_lookup(name) = server_secrets.get(name) // process env first, then server.env
build_object_store_from_settings_with_lookup(settings, env_lookup)
install validation, manual mode:
server_secrets = ServerSecrets::load(server_env_path)
env_lookup(name) = overlay_aws_static_keys(
submitted_keys, // only AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
suppress = {AWS_SESSION_TOKEN}, // do not leak ambient STS tokens
base = server_secrets.get // same lookup runtime uses
)
build_object_store_from_settings_with_lookup(settings, env_lookup)
install validation, runtime mode:
server_secrets = ServerSecrets::load(server_env_path)
env_lookup(name) = server_secrets.get(name)
build_object_store_from_settings_with_lookup(settings, env_lookup)
```
**Test scenarios:**
- Happy path: env-aware S3 builder succeeds when credentials are supplied only through the injected lookup, not process env.
- Happy path: manual validation overlay uses the same lookup semantics as runtime and overrides only the submitted static-key pair.
- Happy path: runtime-credential mode still succeeds when the deployment provides auth through the supported env/metadata provider paths.
- Edge case: the fixed prefixes `artifacts` and `slatedb` (stored without trailing `/` in TOML) still probe the intended `artifacts/` and `slatedb/` key namespaces in the bucket.
- Error path: only one of `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` is present on the effective lookup -> clear static-credential error.
- Error path: S3 validation failure is reported as an install-step access failure, not an opaque panic.
- Error path: `AWS_ENDPOINT`, `AWS_ENDPOINT_URL_S3`, or similar endpoint-override env vars are ignored by the wizard-managed validation/runtime path.
- Error path: manual-mode validation with intentionally-wrong submitted keys fails even when a valid distinct AWS key pair (or session token) is available in process env — the overlay must suppress the ambient keys.
- Error path: validation against a bucket that lives in a different region than the submitted region reports `bucket_region_mismatch` rather than succeeding via redirect.
- Integration: artifact-store metadata writing after `/install/finish` uses the same env-aware path and no longer ignores manual AWS credentials in `server.env`.
**Verification:**
- `serve.rs` tests cover static-credential injection and partial-credential failure.
- Install-side validation tests cover local no-op validation and S3 access failure surfacing.
- [x] **Unit 3: Install router, session state, and finish integration**
**Goal:** Extend the server-side browser install flow with a new object-store step, redacted session summary, and finish-time persistence that requires the new step.
**Requirements:** R10-R18 primary (route exposure, session redaction, finish-time persistence and env set/remove, validation surface). Secondary: R1 (step completion order) and R14-R15 (secret lifecycle). Units 1 and 2 own R4-R9 and R7-R15 respectively at the contract/runtime layer.
**Dependencies:** Unit 1, Unit 2
**Files:**
- Modify: `lib/crates/fabro-server/src/install.rs`
- Test: `lib/crates/fabro-server/tests/it/api/install.rs`
**Approach:**
- Add object-store state to `PendingInstall` and introduce the server-side DTOs/enums needed for the new step.
- Add:
- `POST /install/object-store/test`
- `PUT /install/object-store`
- redacted `object_store` data in `GET /install/session`
- Keep the new object-store routes behind the same install-token auth boundary as the existing install endpoints, and make sure neither session snapshots nor validation/save errors ever echo submitted AWS secret material.
- Update `completed_steps()` to return step tokens in wizard order: `server`, `object_store`, `llm`, `github`.
- Require the object-store step in `POST /install/finish` before config is written.
- Use the new `fabro-install` helper(s) to:
- mutate the settings doc for S3 mode
- set or remove AWS key vars according to credential mode
- Keep the secret-handling pattern aligned with existing install steps:
- raw access keys stay only in server memory until finish
- session snapshots return only bucket/region/credential mode/configured state
- Preserve the current finish-error ergonomics, but split env mutation reporting by direction once removals become first-class: on finish failure, keep `leftover_env_keys` for keys that were inserted before the failure, and add `removed_env_keys` for keys that were successfully removed before the failure. Successful finish responses remain unchanged; the new field is a failure-path recovery signal only.
- Keep the broader install rollback behavior unchanged: settings/vault writes still roll back on later failure, while `server.env` mutations remain non-atomic and converge on retry rather than being restored automatically.
- If the session already holds manual AWS credentials in memory and the operator revisits the step, blank secret fields on re-submit preserve the stored pair; entering one or both fields switches into replacement mode, and replacement requires both fields.
- Any `PUT /install/object-store` with `provider = "local"` or `credential_mode = "runtime"` must clear any previously-stored manual AWS key pair from the in-memory session before returning, so a subsequent `POST /install/object-store/test` does not construct an overlay from stale submitted keys. `POST /install/object-store/test` must reject overlay application when the submitted mode is not `manual`.
**Patterns to follow:**
- `post_install_llm_test` + `put_install_llm` in `lib/crates/fabro-server/src/install.rs`
- `redacted_llm()` / `redacted_github()` session-shaping helpers
- Existing `POST /install/finish` persistence/error pattern
**Test scenarios:**
- Happy path: `POST /install/object-store/test` returns success for `Local disk` without issuing a network probe.
- Happy path: `POST /install/object-store/test` validates S3 runtime-credential and manual-credential payloads through the shared builder path.
- Happy path: `PUT /install/object-store` with `Local disk` stores session state and marks `object_store` complete.
- Happy path: `GET /install/session` includes the redacted object-store summary after save.
- Happy path: `POST /install/finish` with local object-store selection writes no AWS key vars.
- Happy path: `POST /install/finish` with S3 manual credentials writes both AWS key vars into `server.env`.
- Happy path: `POST /install/finish` with S3 runtime credentials removes stale wizard-managed AWS key vars from an existing `server.env` while leaving unmarked keys untouched.
- Edge case: reloading the session after manual-credential save shows the chosen mode and bucket/region but not the credential values, and a blank re-submit preserves the in-memory pair.
- Error path: `POST /install/finish` before the object-store step is complete returns the existing missing-step style error.
- Error path: failed finish after env merge includes AWS key names in `leftover_env_keys`.
- Error path: failed finish after a stale-key removal reports the removed names in `removed_env_keys`, leaves the file-level mutation in place, and a retry still converges to the selected mode.
- Integration: the object-store step does not disturb existing GitHub token/app finish behavior.
**Verification:**
- Install API integration tests cover new step save/session/finish semantics.
- Finish tests prove both env-set and env-remove behavior for AWS keys.
- [x] **Unit 4: Browser wizard step and review summary**
**Goal:** Add the new `Object store` UI step, reorder the browser wizard, and surface the redacted object-store summary in the review screen without refactoring the entire install app.
**Requirements:** R1-R3 (wizard placement/copy), R12-R13 (form inputs for manual credentials), R16 (inline validation errors), R17-R18 (redacted session re-entry), R19 (review rows). Server-side validation and persistence come from Units 2 and 3.
**Dependencies:** Unit 1, Unit 3
**Files:**
- Modify: `apps/fabro-web/app/install-app.tsx`
- Modify: `apps/fabro-web/app/install-api.ts`
- Test: `apps/fabro-web/app/install-app.test.tsx`
- Test: `apps/fabro-web/app/install-api.test.ts`
**Approach:**
- Insert the new step into `INSTALL_STEPS` and `currentStep` routing, using `/install/object-store` between `/install/server` and `/install/llm`.
- Update navigation:
- `Server` next -> `Object store`
- `Object store` back -> `Server`, next -> `LLM`
- `LLM` back -> `Object store`
- `GitHub` back -> `LLM`
- Update the step-preview list in `WelcomeScreen` to include 'Object store — choose local disk or AWS S3 for SlateDB and artifacts' between 'Server URL' and 'LLMs'.
- Render provider choice (`Local disk` / `AWS S3`) and credential mode (`Use AWS runtime credentials` / `Enter AWS access key credentials`) using the existing `OptionCard` pattern inside `fieldset`/`legend` wrappers, matching `GithubStrategyPicker`. Selecting `Local disk` hides the S3 sub-form entirely; selecting `Use AWS runtime credentials` hides the access-key inputs.
- Default provider on first entry is `Local disk` (the common path becomes a one-click Continue). The S3 sub-form is collapsed until the operator picks `AWS S3`. Even with default-selected `Local disk`, the step must still be completed (`PUT /install/object-store`) before finish.
- Add local component state for:
- provider choice (`local` vs `s3`)
- bucket/region
- credential mode (`runtime` vs `access_key`)
- access key id / secret access key
- Within one unsaved visit to the step, toggling `AWS S3 -> Local disk -> AWS S3` preserves the typed bucket/region/credential fields in local component state so accidental toggles do not wipe work. After a successful save or a session rehydrate, the session snapshot becomes the source of truth and the manual secret inputs return blank.
- Client-side preflight before submit: when `AWS S3` is selected, require non-empty `bucket` and `region`; when `Enter AWS access key credentials` is selected, additionally require both `access_key_id` and `secret_access_key`. Error copy matches the tone of the existing `"Enter the canonical server URL before continuing."` style string.
- Keep manual credential fields blank on rehydrate; only hydrate redacted summary data from the session response. If the session summary indicates manual credentials are already stored in the pending install state, show helper text such as `Credentials saved. Leave both fields blank to keep them, or enter both fields to replace them.` Blank submit preserves the stored pair; entering either field requires both and replaces the stored pair.
- Extend the thin install API wrapper with new test/save helpers for the object-store step.
- Reuse the existing `StepPanel` pattern and inline error handling; do not split the install wizard into new route files in this pass.
- Submit behavior: local mode saves immediately and shows `Saving...` with the primary action disabled while the request is in flight. S3 mode runs `POST /install/object-store/test` before save and shows `Checking access...` on the primary action while validation/save is in flight. If the validation probe exceeds the server-side 20-second timeout, show `Timed out while checking S3 access. Verify the bucket, region, and network path, then try again.`
- Field order and copy are fixed for consistency: provider choice first; if `AWS S3` is selected, show `Bucket` (placeholder `my-fabro-data`), `Region` (placeholder `us-east-1`), then the credential-mode picker. If manual credentials are selected, show `AWS access key ID` and `AWS secret access key` in that order. Secret inputs are password-masked and use `autocomplete="off"`, `autocapitalize="none"`, and `spellcheck={false}`.
- Keep the form behavior aligned with the existing wizard's accessibility/responsiveness baseline: explicit labels, keyboard-reachable provider/credential-mode controls, password-masked secret input, and a single-column layout that still reads cleanly on narrow screens. Inline validation errors should render in an `aria-live` region with `role="alert"`. Client-side preflight focuses the first invalid field. Selecting `AWS S3` moves focus to the `Bucket` input, selecting manual credentials moves focus to `AWS access key ID`, and `OptionCard` controls remain keyboard activatable via Enter/Space.
- Extend `ReviewScreen` with object-store summary rows, implemented with `SummaryRow`:
- Local: one row `Object store` -> `Local disk`.
- S3: `Object store` -> `AWS S3`; `Bucket` -> value (monospaced); `Region` -> value (monospaced); `Credentials` -> `Runtime credentials` or `Access key` (static label only; no redacted key identifier is shown); `Prefixes` -> `slatedb/, artifacts/` (single row).
**Patterns to follow:**
- Existing `LLM` and `GitHub token` submit flows in `apps/fabro-web/app/install-app.tsx`
- Existing "configured but redacted" hydration pattern from `hydrateProviderSelection()` and GitHub username rehydrate behavior
**Test scenarios:**
- Happy path: wizard stepper/order now includes `Object store` between `Server` and `LLM`.
- Happy path: local selection advances to `LLM` and does not require bucket/region/AWS credential fields.
- Happy path: S3 runtime-credential selection requires bucket/region and surfaces runtime-credential copy.
- Happy path: S3 manual-credential selection requires bucket/region/access key id/secret access key before save.
- Edge case: rehydrated session with S3 manual mode preserves bucket/region/mode, leaves secret fields blank, and shows the "credentials saved" helper text.
- Edge case: review screen shows `Local disk` or the redacted S3 summary as appropriate.
- Error path: object-store validation errors from the server render inline on the step.
- Error path: client-side preflight errors, access-denied errors, and timeout errors use the agreed copy and land focus on the appropriate field/error region.
- Integration: existing GitHub callback/done tests still pass after `completed_steps` and step order change.
**Verification:**
- Web tests cover new step routing, validation gating, rehydration, and review summary.
- The new step integrates without breaking the existing install token and GitHub callback flows.
- [x] **Unit 5: Documentation and generated surfaces**
**Goal:** Bring the user-facing docs and generated/bundled outputs into sync with the new wizard step and server-runtime secret surface.
**Requirements:** R14-R15 documentation (server.env runtime secret surface for AWS keys) and R8-R9 (scope of the wizard-managed modes); user-facing docs for the new step exist because Units 3 and 4 introduce the feature.
**Dependencies:** Unit 3, Unit 4
**Files:**
- Modify: `docs/administration/deploy-server.mdx`
- Modify: `docs/administration/server-configuration.mdx`
- Modify (generated): `lib/crates/fabro-spa/assets/`
**Approach:**
- Update the "first run: web install wizard" section in `deploy-server.mdx` so it no longer says the wizard only configures LLMs, server URL, and GitHub.
- Update the server-runtime secret documentation to include the optional AWS object-store keys and explain that they resolve with `process env -> server.env`.
- Keep the wizard docs explicit that S3-compatible backends still require manual configuration.
- Document that custom `endpoint` / `path_style` object-store settings remain a manual advanced-configuration path. The browser wizard neither prompts for them nor shows them on the review screen, even though the runtime still honors them for hand-edited configurations outside the wizard-managed flow.
- Document a recommended minimum IAM policy for manual-credential mode (bucket-scoped, prefix-scoped to `artifacts/` and `slatedb/`, `s3:ListBucket` + `s3:GetObject`/`PutObject`/`DeleteObject` only) and recommend provisioning an IAM user dedicated to Fabro rather than reusing broad credentials. Call out that STS/session tokens are not supported in manual mode.
- Add an operator recovery runbook entry for failed finish requests only: if `POST /install/finish` returns `leftover_env_keys` containing AWS key names, the managed object-store keys likely remain in `server.env`; the operator should retry the install or explicitly remove the managed object-store lines before abandoning the host. Rotation is not required solely because finish failed after an atomic `0600` write, but remains the operator's fallback if they no longer trust the host boundary. If the failure response returns `removed_env_keys` for AWS key names instead, treat it as informational: the previous manual-key path was already cleared and the operator should simply complete the retry before restart.
- Regenerate the bundled SPA assets after the TypeScript change so the shipped binary stays in sync with the source.
**Test expectation:** none -- documentation and generated bundle refresh only. Behavioral verification comes from Units 3 and 4.
**Verification:**
- User-facing docs mention the new object-store step and the AWS-only/non-STS constraints.
- The committed SPA bundle matches the updated `apps/fabro-web` source.
## System-Wide Impact
- **Interaction graph:** `GET /install/session` now returns one more redacted section. The browser wizard adds one more stateful step before LLMs. `POST /install/finish` now feeds object-store settings/env edits into `fabro-install`, and the same env-aware object-store builder is used by install-time validation, post-install artifact metadata writing, and normal server startup.
- **Attack surface:** the new install endpoints stay within the existing install-token-protected router, but they handle AWS credential input. Secret values therefore need to stay out of session payloads, response bodies, and log messages across validation, save, and finish paths.
- **Error propagation:** object-store validation failures should stay as `422` install-step errors that `readInstallError()` can display inline, with stable install-domain strings for missing fields, partial static credentials, access failures, permission failures, and timeouts. Finish-time persistence failures continue to return `500` plus env-mutation summaries for inserted vs removed AWS env keys.
- **State lifecycle risks:** stale manual AWS keys are the main lifecycle hazard. The plan addresses that with explicit removal support for non-manual modes and by keeping finish failure reporting explicit when env merges have already happened.
- **Persistence semantics:** install-finish remains partially non-atomic because `server.env` is not rolled back after later failures. This feature keeps that existing behavior, so the implementation needs explicit tests showing that both AWS-key insertion and AWS-key removal converge correctly on retry.
- **API surface parity:** this change affects the browser install contract, the generated TypeScript client, and the user-visible wizard flow. The terminal install flow is intentionally unchanged in this pass.
- **Integration coverage:** the critical cross-layer scenario is "manual AWS credentials entered in the wizard -> validated through the same lookup semantics runtime uses -> persisted to `server.env` -> artifact metadata write and later startup both use them." Unit tests on one side or the other are not enough by themselves.
- **Unchanged invariants:** `server.storage.root` remains local disk, the wizard still exits the server on successful finish, GitHub and LLM install semantics are unchanged aside from step ordering, and non-AWS/S3-compatible options remain manual-only.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| Manual AWS keys are persisted but not consumed by the runtime object-store builder | Refactor the builder path first and cover both startup and post-install metadata writing with the same env-aware helper |
| Stale AWS keys in `server.env` silently override the chosen runtime-credential mode on a later boot | Add explicit env-key removal support and exercise it in finish-path tests |
| Install-time validation accidentally mutates the bucket | Use non-writing prefix probes instead of metadata writes during the wizard step |
| The monolithic `install-app.tsx` change set becomes brittle | Keep the feature local to the existing `StepPanel` pattern and add focused route/rehydration/review tests |
| Generated contract drift between OpenAPI, TS client, and UI wrapper | Treat the regenerated `fabro-api-client` files as part of the unit, and keep `apps/fabro-web/app/install-api.ts` aligned with the new session/save shapes |
| Validation relies on list permissions that some custom IAM policies may omit | Before selecting the probe shape, enumerate the exact S3 actions the runtime performs at steady state (SlateDB + artifact writers) and pick a probe that uses a strict subset of those permissions. If `s3:ListBucket` cannot be avoided, document it in the wizard error copy ("access to the configured bucket/prefix could not be verified — validation requires `s3:ListBucket`") and in the recommended IAM policy in Unit 5 |
## Documentation / Operational Notes
- The operator docs should explicitly distinguish workflow-visible secrets (vault) from server runtime secrets (`server.env`), since this feature adds a new runtime-secret case.
- The implementation should refresh the embedded SPA bundle after the TypeScript changes so the Rust binary ships the updated install UI.
- If the feature ships before CLI parity, the docs should avoid implying that `fabro install` and the browser wizard offer the same object-store prompts.
## Sources & References
- **Origin document:** [docs/brainstorms/2026-04-22-web-install-object-store-choice-requirements.md](/Users/bhelmkamp/p/fabro-sh/fabro-4/docs/brainstorms/2026-04-22-web-install-object-store-choice-requirements.md:1)
- Related code: [lib/crates/fabro-server/src/install.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-server/src/install.rs:1)
- Related code: [lib/crates/fabro-server/src/serve.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-server/src/serve.rs:323)
- Related code: [lib/crates/fabro-server/src/server_secrets.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-server/src/server_secrets.rs:1)
- Related code: [lib/crates/fabro-server/src/security_headers.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-server/src/security_headers.rs:1)
- Related code: [lib/crates/fabro-install/src/lib.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-install/src/lib.rs:123)
- Related code: [lib/crates/fabro-store/src/artifact_store.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-store/src/artifact_store.rs:42)
- Related code: [lib/crates/fabro-store/src/slate/mod.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-store/src/slate/mod.rs:53)
- Related code: [apps/fabro-web/app/install-app.tsx](/Users/bhelmkamp/p/fabro-sh/fabro-4/apps/fabro-web/app/install-app.tsx:43)
- Related code: [docs/administration/deploy-server.mdx](/Users/bhelmkamp/p/fabro-sh/fabro-4/docs/administration/deploy-server.mdx:30)
- Related code: [docs/administration/server-configuration.mdx](/Users/bhelmkamp/p/fabro-sh/fabro-4/docs/administration/server-configuration.mdx:270)
- External docs: https://docs.rs/object_store/latest/object_store/
- External docs: https://docs.rs/object_store/latest/object_store/aws/struct.AmazonS3Builder.html

View file

@ -161,11 +161,10 @@ fn main() {
let mut settings = GenerationSettings::default();
settings.with_interface(InterfaceStyle::Builder);
let replacements: &[(&str, &str, &[TypeImpl])] = &[
("RunStatus", "fabro_types::status::RunStatus", &[
TypeImpl::FromStr,
TypeImpl::Display,
]),
("StatusReason", "fabro_types::status::StatusReason", &[]),
("RunStatus", "fabro_types::status::RunStatus", &[]),
("SuccessReason", "fabro_types::status::SuccessReason", &[]),
("FailureReason", "fabro_types::status::FailureReason", &[]),
("TerminalStatus", "fabro_types::status::TerminalStatus", &[]),
("BlockedReason", "fabro_types::status::BlockedReason", &[]),
(
"RunControlAction",

View file

@ -26,7 +26,7 @@ pub mod types {
};
pub use fabro_types::settings::{FeaturesNamespace, ServerNamespace};
pub use fabro_types::status::{
BlockedReason, RunControlAction, RunStatus, RunStatusRecord, StatusReason,
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
};
pub use crate::generated::types::*;

View file

@ -1,13 +1,12 @@
use std::any::{TypeId, type_name};
use chrono::{TimeZone, Utc};
use fabro_api::types::{
BlockedReason as ApiBlockedReason, RunControlAction as ApiRunControlAction,
RunStatus as ApiRunStatus, RunStatusRecord as ApiRunStatusRecord,
StatusReason as ApiStatusReason,
BlockedReason as ApiBlockedReason, FailureReason as ApiFailureReason,
RunControlAction as ApiRunControlAction, RunStatus as ApiRunStatus,
SuccessReason as ApiSuccessReason, TerminalStatus as ApiTerminalStatus,
};
use fabro_types::status::{
BlockedReason, RunControlAction, RunStatus, RunStatusRecord, StatusReason,
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
};
use serde::Serialize;
use serde_json::{Value, json};
@ -15,44 +14,117 @@ use serde_json::{Value, json};
#[test]
fn status_family_reuses_domain_types() {
assert_same_type::<ApiRunStatus, RunStatus>();
assert_same_type::<ApiStatusReason, StatusReason>();
assert_same_type::<ApiSuccessReason, SuccessReason>();
assert_same_type::<ApiFailureReason, FailureReason>();
assert_same_type::<ApiTerminalStatus, TerminalStatus>();
assert_same_type::<ApiBlockedReason, BlockedReason>();
assert_same_type::<ApiRunControlAction, RunControlAction>();
assert_same_type::<ApiRunStatusRecord, RunStatusRecord>();
}
// The `status_family_reuses_domain_types` assertions above prove each API type
// is the same type as its domain counterpart, so each variant below only needs
// to be asserted once to lock in the OpenAPI string token.
#[test]
fn run_status_json_tokens_match_openapi() {
assert_string_json(RunStatus::Submitted, "submitted");
assert_string_json(RunStatus::Queued, "queued");
assert_string_json(RunStatus::Starting, "starting");
assert_string_json(RunStatus::Running, "running");
assert_string_json(RunStatus::Blocked, "blocked");
assert_string_json(RunStatus::Paused, "paused");
assert_string_json(RunStatus::Removing, "removing");
assert_string_json(RunStatus::Succeeded, "succeeded");
assert_string_json(RunStatus::Failed, "failed");
assert_string_json(RunStatus::Dead, "dead");
assert_string_json(RunStatus::Archived, "archived");
}
#[test]
fn status_reason_json_tokens_match_openapi() {
assert_string_json(StatusReason::Completed, "completed");
assert_string_json(StatusReason::PartialSuccess, "partial_success");
assert_string_json(StatusReason::WorkflowError, "workflow_error");
assert_string_json(StatusReason::Cancelled, "cancelled");
assert_string_json(StatusReason::Terminated, "terminated");
assert_string_json(StatusReason::TransientInfra, "transient_infra");
assert_string_json(StatusReason::BudgetExhausted, "budget_exhausted");
assert_string_json(StatusReason::LaunchFailed, "launch_failed");
assert_string_json(StatusReason::BootstrapFailed, "bootstrap_failed");
assert_string_json(StatusReason::SandboxInitFailed, "sandbox_init_failed");
assert_string_json(StatusReason::SandboxInitializing, "sandbox_initializing");
fn run_status_json_matches_openapi_shape() {
assert_json(
RunStatus::Submitted,
json!({
"kind": "submitted"
}),
);
assert_json(
RunStatus::Queued,
json!({
"kind": "queued"
}),
);
assert_json(
RunStatus::Starting,
json!({
"kind": "starting"
}),
);
assert_json(
RunStatus::Running,
json!({
"kind": "running"
}),
);
assert_json(
RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
},
json!({
"kind": "blocked",
"blocked_reason": "human_input_required"
}),
);
assert_json(
RunStatus::Paused { prior_block: None },
json!({
"kind": "paused",
"prior_block": null
}),
);
assert_json(
RunStatus::Removing,
json!({
"kind": "removing"
}),
);
assert_json(
RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
json!({
"kind": "succeeded",
"reason": "completed"
}),
);
assert_json(
RunStatus::Failed {
reason: FailureReason::Cancelled,
},
json!({
"kind": "failed",
"reason": "cancelled"
}),
);
assert_json(
RunStatus::Dead,
json!({
"kind": "dead"
}),
);
assert_json(
RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::PartialSuccess,
},
},
json!({
"kind": "archived",
"prior": {
"kind": "succeeded",
"reason": "partial_success"
}
}),
);
}
#[test]
fn success_reason_json_tokens_match_openapi() {
assert_string_json(SuccessReason::Completed, "completed");
assert_string_json(SuccessReason::PartialSuccess, "partial_success");
}
#[test]
fn failure_reason_json_tokens_match_openapi() {
assert_string_json(FailureReason::WorkflowError, "workflow_error");
assert_string_json(FailureReason::Cancelled, "cancelled");
assert_string_json(FailureReason::Terminated, "terminated");
assert_string_json(FailureReason::TransientInfra, "transient_infra");
assert_string_json(FailureReason::BudgetExhausted, "budget_exhausted");
assert_string_json(FailureReason::LaunchFailed, "launch_failed");
assert_string_json(FailureReason::BootstrapFailed, "bootstrap_failed");
assert_string_json(FailureReason::SandboxInitFailed, "sandbox_init_failed");
}
#[test]
@ -68,34 +140,31 @@ fn run_control_action_json_tokens_match_openapi() {
}
#[test]
fn run_status_record_json_matches_openapi_shape() {
let updated_at = Utc
.with_ymd_and_hms(2026, 1, 2, 3, 4, 5)
.single()
.expect("fixed timestamp should be valid");
let expected = json!({
"status": "failed",
"status_reason": "cancelled",
"blocked_reason": "human_input_required",
"updated_at": "2026-01-02T03:04:05Z"
});
let record = RunStatusRecord {
status: RunStatus::Failed,
status_reason: Some(StatusReason::Cancelled),
blocked_reason: Some(BlockedReason::HumanInputRequired),
updated_at,
};
assert_eq!(serde_json::to_value(&record).unwrap(), expected);
let round_trip: RunStatusRecord = serde_json::from_value(expected).unwrap();
assert_eq!(round_trip.status, RunStatus::Failed);
assert_eq!(round_trip.status_reason, Some(StatusReason::Cancelled));
assert_eq!(
round_trip.blocked_reason,
Some(BlockedReason::HumanInputRequired)
fn terminal_status_json_matches_openapi_shape() {
assert_json(
TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
json!({
"kind": "succeeded",
"reason": "completed"
}),
);
assert_json(
TerminalStatus::Failed {
reason: FailureReason::Cancelled,
},
json!({
"kind": "failed",
"reason": "cancelled"
}),
);
assert_json(
TerminalStatus::Dead,
json!({
"kind": "dead"
}),
);
assert_eq!(round_trip.updated_at, updated_at);
}
fn assert_same_type<T: 'static, U: 'static>() {
@ -114,3 +183,7 @@ fn assert_string_json<T: Serialize>(value: T, expected: &str) {
Value::String(expected.into())
);
}
fn assert_json<T: Serialize>(value: T, expected: Value) {
assert_eq!(serde_json::to_value(value).unwrap(), expected);
}

View file

@ -353,7 +353,7 @@ fn state_is_terminal(state: &server_client::RunProjection) -> bool {
|| state
.status
.as_ref()
.is_some_and(|record| record.status.is_terminal())
.is_some_and(|status| status.is_terminal())
}
fn emit_progress_line(
@ -452,9 +452,9 @@ fn state_exit_code(state: &server_client::RunProjection) -> Option<ExitCode> {
});
}
match state.status.as_ref() {
Some(record) if record.status == RunStatus::Succeeded => Some(ExitCode::from(0)),
Some(record) if record.status.is_terminal() => Some(ExitCode::from(1)),
match state.status {
Some(RunStatus::Succeeded { .. }) => Some(ExitCode::from(0)),
Some(status) if status.is_terminal() => Some(ExitCode::from(1)),
Some(_) | None => None,
}
}
@ -500,10 +500,10 @@ mod tests {
"graph_source": null,
"start": null,
"status": {
"status": "failed",
"reason": "cancelled",
"updated_at": "2026-04-05T12:00:02Z"
"kind": "failed",
"reason": "cancelled"
},
"status_updated_at": "2026-04-05T12:00:02Z",
"checkpoint": null,
"checkpoints": [],
"conclusion": null,
@ -520,10 +520,12 @@ mod tests {
fn cancel_run_response(run_id: RunId) -> serde_json::Value {
serde_json::json!({
"id": run_id,
"status": "failed",
"status": {
"kind": "failed",
"reason": "cancelled"
},
"error": null,
"queue_position": null,
"status_reason": "cancelled",
"pending_control": null,
"created_at": "2026-04-05T12:00:00Z"
})

View file

@ -235,7 +235,7 @@ async fn run_concluded(
Ok(state.conclusion.is_some()
|| state
.status
.is_some_and(|record| record.status.is_terminal()))
.is_some_and(fabro_types::RunStatus::is_terminal))
}
async fn flush_remaining_store_events(

View file

@ -44,8 +44,6 @@ pub(crate) async fn run(
let state = client.get_run_state(&run_id).await?;
let current_status = state
.status
.as_ref()
.map(|record| record.status)
.context("run has no recorded status — cannot rewind")?;
let run_spec = state.spec.context("Failed to load run spec from store")?;
ensure_matching_repo_origin(run_spec.repo_origin_url.as_deref(), "rewind")?;
@ -127,7 +125,7 @@ async fn reset_rewound_run_state(
let checkpoint = MetadataStore::read_run_projection(git_store.repo_dir(), &run_id.to_string())?
.and_then(|projection| projection.checkpoint)
.context("rewound metadata branch is missing run.json checkpoint state")?;
let previous_status = state.status.map(|status| status.status.to_string());
let previous_status = state.status.map(|status| status.to_string());
client
.append_run_event(
@ -156,10 +154,7 @@ async fn reset_rewound_run_state(
&run_event(
*run_id,
None,
EventBody::RunSubmitted(RunSubmittedProps {
reason: None,
definition_blob,
}),
EventBody::RunSubmitted(RunSubmittedProps { definition_blob }),
),
)
.await

View file

@ -17,7 +17,7 @@ use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMe
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_types::settings::run::RunMode;
use fabro_types::settings::{InterpString, SettingsLayer};
use fabro_types::{ArtifactUpload, EventBody, RunBlobId, RunEvent, RunId, StatusReason};
use fabro_types::{ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId};
use fabro_vault::Vault;
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
use fabro_workflow::event::{Emitter, RunEventSink};
@ -485,7 +485,7 @@ fn worker_title_phase_for_event(body: &EventBody) -> Option<WorkerTitlePhase> {
}
EventBody::RunPaused(_) => Some(WorkerTitlePhase::Paused),
EventBody::RunCompleted(_) => Some(WorkerTitlePhase::Succeeded),
EventBody::RunFailed(props) => Some(if props.reason == Some(StatusReason::Cancelled) {
EventBody::RunFailed(props) => Some(if props.reason == FailureReason::Cancelled {
WorkerTitlePhase::Cancelled
} else {
WorkerTitlePhase::Failed
@ -593,7 +593,7 @@ mod tests {
InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps,
RunFailedProps, RunStatusTransitionProps,
};
use fabro_types::{EventBody, StatusReason, fixtures};
use fabro_types::{EventBody, FailureReason, SuccessReason, fixtures};
use fabro_vault::{SecretType, Vault};
use fabro_workflow::artifact_upload::StageArtifactUploader;
@ -633,9 +633,7 @@ mod tests {
#[test]
fn worker_title_phase_tracks_lifecycle_events() {
assert_eq!(
worker_title_phase_for_event(&EventBody::RunStarting(RunStatusTransitionProps {
reason: None,
})),
worker_title_phase_for_event(&EventBody::RunStarting(RunStatusTransitionProps {})),
Some(WorkerTitlePhase::Init)
);
assert_eq!(
@ -669,7 +667,7 @@ mod tests {
duration_ms: 10,
artifact_count: 0,
status: "success".to_string(),
reason: None,
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
@ -681,7 +679,7 @@ mod tests {
worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps {
error: "cancelled".to_string(),
duration_ms: 10,
reason: Some(StatusReason::Cancelled),
reason: FailureReason::Cancelled,
git_commit_sha: None,
final_patch: None,
})),
@ -691,7 +689,7 @@ mod tests {
worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps {
error: "boom".to_string(),
duration_ms: 10,
reason: Some(StatusReason::Terminated),
reason: FailureReason::Terminated,
git_commit_sha: None,
final_patch: None,
})),

View file

@ -22,7 +22,7 @@ use tracing::info;
use crate::args::WaitArgs;
use crate::command_context::CommandContext;
use crate::shared::{format_duration_ms, format_usd_micros};
use crate::shared::{format_duration_ms, format_usd_micros, run_status_kind};
pub(crate) async fn run(
args: &WaitArgs,
@ -72,7 +72,7 @@ pub(crate) async fn run(
print_human_output(final_status, &run_id, conclusion.as_ref(), styles, printer);
}
if final_status == RunStatus::Succeeded {
if matches!(final_status, RunStatus::Succeeded { .. }) {
Ok(())
} else {
std::process::exit(1);
@ -86,7 +86,7 @@ fn build_json_output(
) -> serde_json::Value {
let mut value = serde_json::json!({
"run_id": run_id,
"status": status.to_string(),
"status": run_status_kind(status),
});
if let Some(c) = conclusion {
value["duration_ms"] = c.duration_ms.into();
@ -109,10 +109,10 @@ fn print_human_output(
printer: Printer,
) {
let (style, label) = match status {
RunStatus::Succeeded => (&styles.bold_green, "Succeeded"),
RunStatus::Failed => (&styles.bold_red, "Failed"),
RunStatus::Succeeded { .. } => (&styles.bold_green, "Succeeded"),
RunStatus::Failed { .. } => (&styles.bold_red, "Failed"),
RunStatus::Dead => (&styles.bold_red, "Dead"),
RunStatus::Archived => (&styles.dim, "Archived"),
RunStatus::Archived { .. } => (&styles.dim, "Archived"),
// Poll loop only breaks on is_terminal() which is the four arms above.
_ => unreachable!(),
};
@ -142,10 +142,9 @@ fn print_human_output(
#[cfg(test)]
mod tests {
use fabro_types::{BilledTokenCounts, fixtures};
use fabro_types::{BilledTokenCounts, RunStatus, SuccessReason, fixtures};
use fabro_workflow::outcome::StageStatus;
use fabro_workflow::records::Conclusion;
use fabro_workflow::run_status::RunStatusRecord;
use super::*;
@ -174,7 +173,13 @@ mod tests {
}),
total_retries: 0,
};
let json = build_json_output(RunStatus::Succeeded, &run_id, Some(&conclusion));
let json = build_json_output(
RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
&run_id,
Some(&conclusion),
);
assert_eq!(json["run_id"], run_id.to_string());
assert_eq!(json["status"], "succeeded");
assert_eq!(json["duration_ms"], 12345);
@ -184,7 +189,13 @@ mod tests {
#[test]
fn json_output_failed_without_conclusion() {
let run_id = fixtures::RUN_2;
let json = build_json_output(RunStatus::Failed, &run_id, None);
let json = build_json_output(
RunStatus::Failed {
reason: fabro_types::FailureReason::WorkflowError,
},
&run_id,
None,
);
assert_eq!(json["run_id"], run_id.to_string());
assert_eq!(json["status"], "failed");
assert!(json.get("duration_ms").is_none());
@ -210,7 +221,13 @@ mod tests {
billing: None,
total_retries: 0,
};
let json = build_json_output(RunStatus::Failed, &run_id, Some(&conclusion));
let json = build_json_output(
RunStatus::Failed {
reason: fabro_types::FailureReason::WorkflowError,
},
&run_id,
Some(&conclusion),
);
assert!(json.get("total_usd_micros").is_none());
assert_eq!(json["duration_ms"], 500);
}
@ -239,7 +256,9 @@ mod tests {
};
// Just verify no panic; actual stderr output is hard to capture
print_human_output(
RunStatus::Succeeded,
RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
&run_id,
Some(&conclusion),
&styles,
@ -251,7 +270,9 @@ mod tests {
fn human_output_failed_no_conclusion() {
let styles = no_color_styles();
print_human_output(
RunStatus::Failed,
RunStatus::Failed {
reason: fabro_types::FailureReason::WorkflowError,
},
&fixtures::RUN_6,
None,
&styles,
@ -263,16 +284,18 @@ mod tests {
fn poll_terminal_immediately() {
let dir = tempfile::tempdir().unwrap();
let status_path = dir.path().join("status.json");
let record = RunStatusRecord::new(RunStatus::Succeeded, None);
let record = RunStatus::Succeeded {
reason: SuccessReason::Completed,
};
std::fs::write(&status_path, serde_json::to_string_pretty(&record).unwrap()).unwrap();
// Simulate what the poll loop does
let status = serde_json::from_str::<RunStatusRecord>(
&std::fs::read_to_string(&status_path).unwrap(),
)
.unwrap()
.status;
let status =
serde_json::from_str::<RunStatus>(&std::fs::read_to_string(&status_path).unwrap())
.unwrap();
assert!(status.is_terminal());
assert_eq!(status, RunStatus::Succeeded);
assert_eq!(status, RunStatus::Succeeded {
reason: SuccessReason::Completed,
});
}
}

View file

@ -35,10 +35,7 @@ pub(crate) async fn run(args: &InspectArgs, cli_layer: &CliLayer, printer: Print
fn inspect_run_state(run: &ServerRunSummaryInfo, state: RunProjection) -> InspectOutput {
InspectOutput {
run_id: run.run_id().to_string(),
status: state
.status
.as_ref()
.map_or(run.status(), |record| record.status),
status: state.status.unwrap_or(run.status()),
run_spec: state
.spec
.and_then(|record| serde_json::to_value(record).ok()),

View file

@ -15,7 +15,7 @@ use super::short_run_id;
use crate::args::RunsListArgs;
use crate::command_context::CommandContext;
use crate::server_runs::{ServerSummaryLookup, filter_server_runs};
use crate::shared::{color_if, format_duration_ms, tilde_path};
use crate::shared::{color_if, format_duration_ms, run_status_kind, tilde_path};
pub(crate) async fn list_command(
args: &RunsListArgs,
@ -44,7 +44,6 @@ pub(crate) async fn list_command(
"workflow_name": run.workflow_name(),
"workflow_slug": run.workflow_slug(),
"status": run.status(),
"status_reason": run.status_reason(),
"start_time": run.start_time(),
"labels": run.labels(),
"duration_ms": run.duration_ms(),
@ -142,14 +141,14 @@ pub(crate) async fn list_command(
}
fn status_cell(status: RunStatus, use_color: bool) -> CellStruct {
let text = status.to_string();
let text = run_status_kind(status);
let color = match status {
RunStatus::Succeeded => Some(Color::Green),
RunStatus::Failed => Some(Color::Red),
RunStatus::Succeeded { .. } => Some(Color::Green),
RunStatus::Failed { .. } => Some(Color::Red),
RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan),
RunStatus::Queued | RunStatus::Dead | RunStatus::Archived => Some(Color::Ansi256(8)),
RunStatus::Blocked | RunStatus::Removing => Some(Color::Yellow),
RunStatus::Paused => Some(Color::Magenta),
RunStatus::Queued | RunStatus::Dead | RunStatus::Archived { .. } => Some(Color::Ansi256(8)),
RunStatus::Blocked { .. } | RunStatus::Removing => Some(Color::Yellow),
RunStatus::Paused { .. } => Some(Color::Magenta),
};
text.cell()
.bold(use_color && color != Some(Color::Ansi256(8)))

View file

@ -317,8 +317,8 @@ mod tests {
use fabro_types::settings::SettingsLayer;
use fabro_types::{
AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph,
NodeStatusRecord, Retro, RunId, RunSpec, RunStatus, RunStatusRecord, SandboxRecord,
StageStatus, StartRecord, StatusReason, fixtures,
NodeStatusRecord, Retro, RunId, RunSpec, RunStatus, SandboxRecord, StageStatus,
StartRecord, SuccessReason, fixtures,
};
use fabro_workflow::event::{Event, append_event};
use object_store::ObjectStore;
@ -379,15 +379,6 @@ mod tests {
}
}
fn sample_status() -> RunStatusRecord {
RunStatusRecord {
status: RunStatus::Running,
status_reason: Some(StatusReason::SandboxInitializing),
blocked_reason: None,
updated_at: dt("2026-03-27T12:05:00Z"),
}
}
fn sample_checkpoint(current_node: &str, visit: u32) -> Checkpoint {
Checkpoint {
timestamp: dt("2026-03-27T12:10:00Z"),
@ -480,7 +471,6 @@ mod tests {
let run = store.create_run(&run_id).await.unwrap();
let run_spec = sample_run_spec(run_id, created_at);
let start_record = sample_start_record(run_id, created_at);
let status_record = sample_status();
let mut first_checkpoint = sample_checkpoint("plan", 1);
let mut second_checkpoint = sample_checkpoint("code", 2);
let conclusion = sample_conclusion();
@ -528,11 +518,9 @@ mod tests {
})
.await
.unwrap();
append_event(&run, &run_id, &Event::RunRunning {
reason: status_record.status_reason,
})
.await
.unwrap();
append_event(&run, &run_id, &Event::RunRunning)
.await
.unwrap();
for checkpoint in [&first_checkpoint, &second_checkpoint] {
append_event(&run, &run_id, &Event::CheckpointCompleted {
node_id: checkpoint.current_node.clone(),
@ -654,7 +642,7 @@ mod tests {
duration_ms: conclusion.duration_ms,
artifact_count: 0,
status: "success".to_string(),
reason: None,
reason: SuccessReason::Completed,
total_usd_micros: conclusion
.billing
.as_ref()
@ -719,8 +707,10 @@ mod tests {
Some(run_id)
);
assert_eq!(
exported_run.status.as_ref().map(|status| status.status),
Some(RunStatus::Succeeded)
exported_run.status,
Some(RunStatus::Succeeded {
reason: SuccessReason::Completed,
})
);
assert_eq!(
exported_run

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use anyhow::Result;
use chrono::{DateTime, Utc};
use fabro_types::{RunId, RunStatus, RunSummary, StatusReason};
use fabro_types::{RunId, RunStatus, RunSummary};
use crate::server_client::Client;
@ -36,10 +36,6 @@ impl ServerRunSummaryInfo {
self.summary.status
}
pub(crate) fn status_reason(&self) -> Option<StatusReason> {
self.summary.status_reason
}
pub(crate) fn start_time(&self) -> String {
self.start_time_dt()
.map(|time| time.to_rfc3339())

View file

@ -12,6 +12,7 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use cli_table::Color;
use fabro_types::RunStatus;
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use fabro_validate::{Diagnostic, Severity};
@ -112,6 +113,22 @@ pub(crate) fn color_if(use_color: bool, color: Color) -> Option<Color> {
if use_color { Some(color) } else { None }
}
pub(crate) fn run_status_kind(status: RunStatus) -> &'static str {
match status {
RunStatus::Submitted => "submitted",
RunStatus::Queued => "queued",
RunStatus::Starting => "starting",
RunStatus::Running => "running",
RunStatus::Blocked { .. } => "blocked",
RunStatus::Paused { .. } => "paused",
RunStatus::Removing => "removing",
RunStatus::Succeeded { .. } => "succeeded",
RunStatus::Failed { .. } => "failed",
RunStatus::Dead => "dead",
RunStatus::Archived { .. } => "archived",
}
}
pub(crate) fn split_run_path(s: &str) -> Option<(&str, &str)> {
if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") {
return None;

View file

@ -96,7 +96,9 @@ fn archive_succeeded_run_hides_it_from_default_ps() {
assert!(output.status.success());
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
assert_eq!(runs.len(), 1, "ps -a should show the archived run");
assert_eq!(runs[0]["status"], "archived");
assert_eq!(runs[0]["status"]["kind"], "archived");
assert_eq!(runs[0]["status"]["prior"]["kind"], "succeeded");
assert_eq!(runs[0]["status"]["prior"]["reason"], "completed");
assert_eq!(runs[0]["run_id"], run.run_id);
}
@ -223,9 +225,10 @@ fn archive_resolves_selector_via_server_endpoint() {
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "succeeded",
"status_reason": null,
"blocked_reason": null,
"status": {
"kind": "succeeded",
"reason": "completed"
},
"pending_control": null,
"duration_ms": 123,
"elapsed_secs": 0,
@ -242,11 +245,15 @@ fn archive_resolves_selector_via_server_endpoint() {
.body(
serde_json::json!({
"id": run_id,
"status": "archived",
"blocked_reason": null,
"status": {
"kind": "archived",
"prior": {
"kind": "succeeded",
"reason": "completed"
}
},
"error": null,
"queue_position": null,
"status_reason": null,
"pending_control": null,
"created_at": "2026-04-05T12:00:00Z"
})

View file

@ -645,9 +645,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
{
"event": "run.starting",
"id": "[EVENT_ID]",
"properties": {
"reason": "sandbox_initializing"
},
"properties": {},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},

View file

@ -13,6 +13,10 @@ fn resolved_run(
}
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
let status = match status {
"submitted" => json!({ "kind": "submitted" }),
other => panic!("unsupported test status {other:?}"),
};
serde_json::json!({
"id": run_id,
"status": status,

View file

@ -9,7 +9,7 @@ use super::support::{
};
use crate::support::unique_run_id;
fn remote_run_summary(run_id: &str, status: &str) -> serde_json::Value {
fn remote_run_summary(run_id: &str, status: &serde_json::Value) -> serde_json::Value {
json!({
"run_id": run_id,
"workflow_name": "Nightly Build",
@ -22,8 +22,6 @@ fn remote_run_summary(run_id: &str, status: &str) -> serde_json::Value {
"start_time": "2026-04-19T12:00:00Z",
"created_at": "2026-04-19T12:00:00Z",
"status": status,
"status_reason": null,
"blocked_reason": null,
"pending_control": null,
"duration_ms": null,
"elapsed_secs": null,
@ -64,7 +62,13 @@ fn inspect_resolves_selector_via_server_endpoint() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let summary = remote_run_summary(run_id.as_str(), "succeeded");
let summary = remote_run_summary(
run_id.as_str(),
&json!({
"kind": "succeeded",
"reason": "completed"
}),
);
let resolve_run = server.mock(|when, then| {
when.method("GET")
@ -97,7 +101,10 @@ fn inspect_resolves_selector_via_server_endpoint() {
[
{
"run_id": "[ULID]",
"status": "succeeded",
"status": {
"kind": "succeeded",
"reason": "completed"
},
"run_spec": null,
"start_record": null,
"conclusion": null,
@ -122,7 +129,9 @@ fn inspect_created_run_shows_run_spec_without_start_or_conclusion() {
[
{
"run_id": "[ULID]",
"status": "submitted",
"status": {
"kind": "submitted"
},
"run_spec": {
"goal": "Run tests and report results",
"workflow_name": "Simple",
@ -155,7 +164,10 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() {
[
{
"run_id": "[ULID]",
"status": "succeeded",
"status": {
"kind": "succeeded",
"reason": "completed"
},
"run_spec": {
"goal": "Run tests and report results",
"workflow_name": "Simple",
@ -221,7 +233,10 @@ fn inspect_completed_run_reads_store_without_disk_metadata_files() {
[
{
"run_id": "[ULID]",
"status": "succeeded",
"status": {
"kind": "succeeded",
"reason": "completed"
},
"run_spec": {
"goal": "Run tests and report results",
"workflow_name": "Simple",
@ -272,7 +287,10 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() {
[
{
"run_id": "[ULID]",
"status": "succeeded",
"status": {
"kind": "succeeded",
"reason": "completed"
},
"run_spec": {
"goal": "Edit a tracked file",
"workflow_name": "Flow",

View file

@ -204,11 +204,13 @@ fn ps_all_json_lists_created_and_completed_runs() {
"all runs should be scoped to the current test session: {runs:#?}"
);
assert!(
runs.iter().any(|run| run["status"] == "submitted"),
runs.iter().any(|run| run["status"]["kind"] == "submitted"),
"ps should include the created run: {runs:#?}"
);
assert!(
runs.iter().any(|run| run["status"] == "succeeded"),
runs.iter().any(|run| {
run["status"]["kind"] == "succeeded" && run["status"]["reason"] == "completed"
}),
"ps should include the completed run: {runs:#?}"
);
}
@ -319,7 +321,8 @@ fn ps_filters_by_workflow_and_label() {
);
let run = &runs[0];
assert_eq!(run["workflow_name"], "Simple");
assert_eq!(run["status"], "succeeded");
assert_eq!(run["status"]["kind"], "succeeded");
assert_eq!(run["status"]["reason"], "completed");
assert_eq!(run["labels"]["suite"], "alpha");
assert_eq!(run["labels"]["fabro_test_case"], context.test_case_id());
assert_eq!(run["labels"]["fabro_test_run"], context.test_run_id());
@ -349,8 +352,10 @@ fn ps_uses_configured_server_target_without_server_flag() {
"repository": { "name": "repo" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "succeeded",
"status_reason": null,
"status": {
"kind": "succeeded",
"reason": "completed"
},
"duration_ms": 123,
"total_usd_micros": null
}],
@ -403,8 +408,10 @@ fn ps_explicit_remote_target_ignores_broken_local_storage_settings() {
"repository": { "name": "repo" },
"start_time": "2026-04-20T12:00:00Z",
"created_at": "2026-04-20T12:00:00Z",
"status": "succeeded",
"status_reason": null,
"status": {
"kind": "succeeded",
"reason": "completed"
},
"duration_ms": 123,
"total_usd_micros": null
}],

View file

@ -183,10 +183,7 @@ fn rewind_preserves_event_history_and_clears_terminal_snapshot_state() {
);
let state = run_state(&setup.run.run_dir);
assert_eq!(
state.status.as_ref().map(|status| &status.status),
Some(&fabro_types::RunStatus::Submitted)
);
assert_eq!(state.status, Some(fabro_types::RunStatus::Submitted));
assert!(state.conclusion.is_none(), "rewind should clear conclusion");
assert!(
state.final_patch.is_none(),

View file

@ -170,9 +170,9 @@ fn rm_force_removes_active_run() {
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "running",
"status_reason": null,
"blocked_reason": null,
"status": {
"kind": "running"
},
"pending_control": null,
"duration_ms": 123,
"elapsed_secs": 0,
@ -236,9 +236,9 @@ fn rm_without_force_uses_resolve_then_surfaces_server_conflict() {
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "running",
"status_reason": null,
"blocked_reason": null,
"status": {
"kind": "running"
},
"pending_control": null,
"duration_ms": 123,
"elapsed_secs": 0,
@ -370,8 +370,10 @@ fn rm_uses_configured_server_target_without_local_run_dir() {
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "succeeded",
"status_reason": null,
"status": {
"kind": "succeeded",
"reason": "completed"
},
"duration_ms": 123,
"total_usd_micros": null
})

View file

@ -15,6 +15,11 @@ use super::support::{output_stderr, wait_for_event_names};
use crate::support::{fabro_json_snapshot, run_output_filters, unique_run_id};
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
let status = match status {
"submitted" => serde_json::json!({ "kind": "submitted" }),
"queued" => serde_json::json!({ "kind": "queued" }),
other => panic!("unsupported test status {other:?}"),
};
serde_json::json!({
"id": run_id,
"status": status,
@ -90,7 +95,8 @@ fn run_completed_event(run_id: &str) -> serde_json::Value {
"properties": {
"duration_ms": 12,
"artifact_count": 0,
"status": "success"
"status": "success",
"reason": "completed"
}
})
}

View file

@ -13,7 +13,7 @@ use std::time::{Duration, Instant};
use fabro_store::EventEnvelope;
use fabro_test::{assert_reqwest_status, expect_reqwest_json, fabro_snapshot, test_context};
use fabro_types::{EventBody, RunEvent, StatusReason};
use fabro_types::{EventBody, FailureReason, RunEvent};
use httpmock::MockServer;
use super::support::{
@ -757,6 +757,7 @@ fn worker_exits_after_sigterm_cancel_even_when_stdin_stays_open() {
let status_record = run_state(&run_dir)
.status
.expect("cancelled run should have a status record");
assert_eq!(status_record.status.to_string(), "failed");
assert_eq!(status_record.status_reason, Some(StatusReason::Cancelled));
assert_eq!(status_record, fabro_types::RunStatus::Failed {
reason: FailureReason::Cancelled,
});
}

View file

@ -185,7 +185,7 @@ fn start_rejects_already_active_or_completed_run() {
exit_code: 1
----- stdout -----
----- stderr -----
error: cannot start run: status is Succeeded, expected submitted
error: cannot start run: status is succeeded(completed), expected submitted
");
}

View file

@ -539,12 +539,21 @@ pub(crate) fn write_gated_workflow(path: &Path, name: &str, goal: &str) -> Workf
pub(crate) fn wait_for_status(run_dir: &Path, expected: &[&str]) -> String {
let deadline = Instant::now() + COMMAND_TIMEOUT;
loop {
if let Some(status) = run_state(run_dir)
.status
.map(|record| record.status.to_string())
{
if expected.iter().any(|candidate| *candidate == status) {
return status;
if let Some(status) = run_state(run_dir).status.map(|status| match status {
fabro_types::RunStatus::Submitted => "submitted",
fabro_types::RunStatus::Queued => "queued",
fabro_types::RunStatus::Starting => "starting",
fabro_types::RunStatus::Running => "running",
fabro_types::RunStatus::Blocked { .. } => "blocked",
fabro_types::RunStatus::Paused { .. } => "paused",
fabro_types::RunStatus::Removing => "removing",
fabro_types::RunStatus::Succeeded { .. } => "succeeded",
fabro_types::RunStatus::Failed { .. } => "failed",
fabro_types::RunStatus::Dead => "dead",
fabro_types::RunStatus::Archived { .. } => "archived",
}) {
if expected.contains(&status) {
return status.to_string();
}
}
assert!(

View file

@ -94,7 +94,8 @@ fn unarchive_archived_run_restores_prior_terminal_status() {
assert!(output.status.success());
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
assert_eq!(runs.len(), 1, "ps -a should show the unarchived run");
assert_eq!(runs[0]["status"], "succeeded");
assert_eq!(runs[0]["status"]["kind"], "succeeded");
assert_eq!(runs[0]["status"]["reason"], "completed");
}
#[test]
@ -229,9 +230,13 @@ fn unarchive_resolves_selector_via_server_endpoint() {
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "archived",
"status_reason": null,
"blocked_reason": null,
"status": {
"kind": "archived",
"prior": {
"kind": "succeeded",
"reason": "completed"
}
},
"pending_control": null,
"duration_ms": 123,
"elapsed_secs": 0,
@ -248,11 +253,12 @@ fn unarchive_resolves_selector_via_server_endpoint() {
.body(
serde_json::json!({
"id": run_id,
"status": "succeeded",
"blocked_reason": null,
"status": {
"kind": "succeeded",
"reason": "completed"
},
"error": null,
"queue_position": null,
"status_reason": null,
"pending_control": null,
"created_at": "2026-04-05T12:00:00Z"
})

View file

@ -5,7 +5,7 @@ use serde_json::json;
use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run};
use crate::support::unique_run_id;
fn remote_run_summary(run_id: &str, status: &str) -> serde_json::Value {
fn remote_run_summary(run_id: &str, status: &serde_json::Value) -> serde_json::Value {
json!({
"run_id": run_id,
"workflow_name": "Blocked Remote Workflow",
@ -18,8 +18,6 @@ fn remote_run_summary(run_id: &str, status: &str) -> serde_json::Value {
"start_time": "2026-04-19T12:00:00Z",
"created_at": "2026-04-19T12:00:00Z",
"status": status,
"status_reason": null,
"blocked_reason": null,
"duration_ms": null,
"total_usd_micros": null
})
@ -143,7 +141,13 @@ fn wait_blocked_run_times_out_without_treating_it_as_terminal() {
let context = test_context!();
let run_id = unique_run_id();
let server = MockServer::start();
let summary = remote_run_summary(run_id.as_str(), "blocked");
let summary = remote_run_summary(
run_id.as_str(),
&json!({
"kind": "blocked",
"blocked_reason": "human_input_required"
}),
);
let resolve_run = server.mock(|when, then| {
when.method("GET")

View file

@ -28,7 +28,8 @@ fn archive_lifecycle_end_to_end() {
let visible = ps_runs(&context, true);
assert_eq!(visible.len(), 1);
assert_eq!(visible[0]["run_id"], run.run_id);
assert_eq!(visible[0]["status"], "succeeded");
assert_eq!(visible[0]["status"]["kind"], "succeeded");
assert_eq!(visible[0]["status"]["reason"], "completed");
let archive = context
.command()
@ -50,7 +51,9 @@ fn archive_lifecycle_end_to_end() {
let with_archived = ps_runs(&context, true);
assert_eq!(with_archived.len(), 1);
assert_eq!(with_archived[0]["run_id"], run.run_id);
assert_eq!(with_archived[0]["status"], "archived");
assert_eq!(with_archived[0]["status"]["kind"], "archived");
assert_eq!(with_archived[0]["status"]["prior"]["kind"], "succeeded");
assert_eq!(with_archived[0]["status"]["prior"]["reason"], "completed");
let unarchive = context
.command()
@ -63,7 +66,8 @@ fn archive_lifecycle_end_to_end() {
String::from_utf8_lossy(&unarchive.stderr)
);
let restored = ps_runs(&context, true);
assert_eq!(restored[0]["status"], "succeeded");
assert_eq!(restored[0]["status"]["kind"], "succeeded");
assert_eq!(restored[0]["status"]["reason"], "completed");
// `rm` must remain available on archived runs — archive and delete are
// orthogonal per the plan's Scope Boundaries.

View file

@ -147,7 +147,19 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
fabro_json_snapshot!(
context,
serde_json::json!({
"status": state.status.map(|status| status.status),
"status": state.status.map(|status| match status {
fabro_types::RunStatus::Submitted => "submitted",
fabro_types::RunStatus::Queued => "queued",
fabro_types::RunStatus::Starting => "starting",
fabro_types::RunStatus::Running => "running",
fabro_types::RunStatus::Blocked { .. } => "blocked",
fabro_types::RunStatus::Paused { .. } => "paused",
fabro_types::RunStatus::Removing => "removing",
fabro_types::RunStatus::Succeeded { .. } => "succeeded",
fabro_types::RunStatus::Failed { .. } => "failed",
fabro_types::RunStatus::Dead => "dead",
fabro_types::RunStatus::Archived { .. } => "archived",
}),
"has_conclusion": state.conclusion.is_some(),
}),
@r#"

View file

@ -9,10 +9,9 @@ fn live_run_state_response() -> serde_json::Value {
"graph_source": null,
"start": null,
"status": {
"status": "running",
"reason": null,
"updated_at": "2026-04-05T12:00:01Z"
"kind": "running"
},
"status_updated_at": "2026-04-05T12:00:01Z",
"checkpoint": null,
"checkpoints": [],
"conclusion": null,
@ -36,7 +35,8 @@ fn run_sse_body(run_id: &str) -> String {
"properties": {
"duration_ms": 12,
"artifact_count": 0,
"status": "success"
"status": "success",
"reason": "completed"
}
});
@ -247,9 +247,9 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "running",
"status_reason": null,
"blocked_reason": null,
"status": {
"kind": "running"
},
"pending_control": null,
"duration_ms": 12,
"elapsed_secs": 0,
@ -351,9 +351,9 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "running",
"status_reason": null,
"blocked_reason": null,
"status": {
"kind": "running"
},
"pending_control": null,
"duration_ms": 12,
"elapsed_secs": 0,

View file

@ -1,23 +1,175 @@
#![expect(
clippy::disallowed_methods,
clippy::disallowed_types,
reason = "sync env-file load/save used at CLI and server startup; not on a Tokio path"
)]
use std::collections::HashMap;
use std::io;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvFileUpdate {
pub key: String,
pub value: String,
pub comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvFileRemoval {
pub key: String,
pub comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EnvFileEntry {
value: String,
comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EnvFileRecord {
key: String,
value: String,
comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvFileUpdateReport {
pub entries: HashMap<String, String>,
pub removed_keys: Vec<String>,
}
pub fn read_env_file(path: &Path) -> io::Result<HashMap<String, String>> {
Ok(records_to_values(&read_env_records(path)?))
}
pub fn merge_env_file<I, K, V>(path: &Path, updates: I) -> io::Result<HashMap<String, String>>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let mut entries = read_env_entries(path)?;
for (key, value) in updates {
entries.insert(key.into(), EnvFileEntry {
value: value.into(),
comment: None,
});
}
write_env_entries(path, &entries)?;
Ok(entries
.into_iter()
.map(|(key, entry)| (key, entry.value))
.collect())
}
pub fn update_env_file<I, J>(
path: &Path,
removals: I,
updates: J,
) -> io::Result<HashMap<String, String>>
where
I: IntoIterator<Item = EnvFileRemoval>,
J: IntoIterator<Item = EnvFileUpdate>,
{
Ok(update_env_file_with_report(path, removals, updates)?.entries)
}
pub fn write_env_file(path: &Path, entries: &HashMap<String, String>) -> io::Result<()> {
let entries = entries
.iter()
.map(|(key, value)| {
(key.clone(), EnvFileEntry {
value: value.clone(),
comment: None,
})
})
.collect::<HashMap<_, _>>();
write_env_entries(path, &entries)
}
pub fn update_env_file_with_report<I, J>(
path: &Path,
removals: I,
updates: J,
) -> io::Result<EnvFileUpdateReport>
where
I: IntoIterator<Item = EnvFileRemoval>,
J: IntoIterator<Item = EnvFileUpdate>,
{
let mut records = read_env_records(path)?;
let mut removed_keys = Vec::new();
for removal in removals {
let mut removed_this_key = false;
records.retain(|record| {
let should_remove = record.key == removal.key
&& (removal.comment.is_none() || record.comment == removal.comment);
if should_remove {
removed_this_key = true;
}
!should_remove
});
if removed_this_key && !removed_keys.contains(&removal.key) {
removed_keys.push(removal.key);
}
}
for update in updates {
match update.comment.as_deref() {
Some(comment) => {
records.retain(|record| {
!(record.key == update.key && record.comment.as_deref() == Some(comment))
});
}
None => {
records.retain(|record| record.key != update.key);
}
}
records.push(EnvFileRecord {
key: update.key,
value: update.value,
comment: update.comment,
});
}
if records.is_empty() {
remove_optional_file(path)?;
return Ok(EnvFileUpdateReport {
entries: HashMap::new(),
removed_keys,
});
}
write_env_records(path, &records)?;
Ok(EnvFileUpdateReport {
entries: records_to_values(&records),
removed_keys,
})
}
fn read_env_entries(path: &Path) -> io::Result<HashMap<String, EnvFileEntry>> {
Ok(records_to_entries(&read_env_records(path)?))
}
fn read_env_records(path: &Path) -> io::Result<Vec<EnvFileRecord>> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(HashMap::new()),
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err),
};
let mut entries = HashMap::new();
let mut records = Vec::new();
let mut pending_comment: Option<String> = None;
for (index, raw_line) in contents.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
if line.is_empty() {
pending_comment = None;
continue;
}
if let Some(comment) = line.strip_prefix('#') {
pending_comment = Some(comment.trim().to_string());
continue;
}
@ -39,27 +191,29 @@ pub fn read_env_file(path: &Path) -> io::Result<HashMap<String, String>> {
)));
}
entries.insert(key.to_string(), decode_value(raw_value.trim())?);
records.push(EnvFileRecord {
key: key.to_string(),
value: decode_value(raw_value.trim())?,
comment: pending_comment.take(),
});
}
Ok(entries)
Ok(records)
}
pub fn merge_env_file<I, K, V>(path: &Path, updates: I) -> io::Result<HashMap<String, String>>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let mut entries = read_env_file(path)?;
for (key, value) in updates {
entries.insert(key.into(), value.into());
}
write_env_file(path, &entries)?;
Ok(entries)
fn write_env_entries(path: &Path, entries: &HashMap<String, EnvFileEntry>) -> io::Result<()> {
let records = entries
.iter()
.map(|(key, entry)| EnvFileRecord {
key: key.clone(),
value: entry.value.clone(),
comment: entry.comment.clone(),
})
.collect::<Vec<_>>();
write_env_records(path, &records)
}
pub fn write_env_file(path: &Path, entries: &HashMap<String, String>) -> io::Result<()> {
fn write_env_records(path: &Path, records: &[EnvFileRecord]) -> io::Result<()> {
let parent = path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
@ -71,20 +225,57 @@ pub fn write_env_file(path: &Path, entries: &HashMap<String, String>) -> io::Res
.unwrap_or("server.env");
let tmp_path = parent.join(format!(".{file_name}.tmp-{}", ulid::Ulid::new()));
let mut data = entries.iter().collect::<Vec<_>>();
data.sort_by_key(|(left, _)| *left);
let contents = data
.into_iter()
.map(|(key, value)| format!("{key}={}", encode_value(value)))
.collect::<Vec<_>>()
.join("\n");
let mut data = records.to_vec();
data.sort_by(|left, right| left.key.cmp(&right.key));
let mut contents = String::new();
for record in data {
if let Some(comment) = record.comment.as_deref() {
if comment.contains('\n') {
return Err(invalid_data(format!(
"env comments must be single-line in {}",
path.display()
)));
}
contents.push_str("# ");
contents.push_str(comment);
contents.push('\n');
}
contents.push_str(&record.key);
contents.push('=');
contents.push_str(&encode_value(&record.value));
contents.push('\n');
}
std::fs::write(&tmp_path, format!("{contents}\n"))?;
let mut file = std::fs::File::create(&tmp_path)?;
file.write_all(contents.as_bytes())?;
set_private_permissions(&tmp_path)?;
file.sync_all()?;
std::fs::rename(&tmp_path, path)?;
sync_parent_directory(&parent)?;
Ok(())
}
fn records_to_entries(records: &[EnvFileRecord]) -> HashMap<String, EnvFileEntry> {
records
.iter()
.cloned()
.map(|record| {
(record.key, EnvFileEntry {
value: record.value,
comment: record.comment,
})
})
.collect()
}
fn records_to_values(records: &[EnvFileRecord]) -> HashMap<String, String> {
records
.iter()
.cloned()
.map(|record| (record.key, record.value))
.collect()
}
fn decode_value(raw: &str) -> io::Result<String> {
if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
return serde_json::from_str(raw).map_err(|err| invalid_data(err.to_string()));
@ -112,6 +303,29 @@ fn invalid_data(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
fn remove_optional_file(path: &Path) -> io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => {
let parent = path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
sync_parent_directory(&parent)
}
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> io::Result<()> {
std::fs::File::open(path)?.sync_all()
}
#[cfg(not(unix))]
fn sync_parent_directory(_path: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn set_private_permissions(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
@ -176,4 +390,71 @@ mod tests {
let reloaded = read_env_file(&path).unwrap();
assert_eq!(reloaded, entries);
}
#[test]
fn update_env_file_only_removes_matching_marked_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.env");
std::fs::write(
&path,
"AWS_ACCESS_KEY_ID=operator\n# managed by fabro-install: object-store\nAWS_ACCESS_KEY_ID=managed\n# managed by fabro-install: object-store\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n",
)
.unwrap();
let report = update_env_file_with_report(
&path,
[EnvFileRemoval {
key: "AWS_ACCESS_KEY_ID".to_string(),
comment: Some("managed by fabro-install: object-store".to_string()),
}],
[EnvFileUpdate {
key: "AWS_SECRET_ACCESS_KEY".to_string(),
value: "replaced".to_string(),
comment: Some("managed by fabro-install: object-store".to_string()),
}],
)
.unwrap();
let entries = report.entries;
assert_eq!(
entries.get("AWS_SECRET_ACCESS_KEY").map(String::as_str),
Some("replaced")
);
assert_eq!(
entries.get("AWS_ACCESS_KEY_ID").map(String::as_str),
Some("operator")
);
assert_eq!(entries.get("KEEP_ME").map(String::as_str), Some("1"));
assert_eq!(report.removed_keys, vec!["AWS_ACCESS_KEY_ID".to_string()]);
}
#[test]
fn update_env_file_reports_only_keys_actually_removed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.env");
std::fs::write(
&path,
"# managed by fabro-install: object-store\nAWS_ACCESS_KEY_ID=managed\nKEEP_ME=1\n",
)
.unwrap();
let report = update_env_file_with_report(
&path,
[
EnvFileRemoval {
key: "AWS_ACCESS_KEY_ID".to_string(),
comment: Some("managed by fabro-install: object-store".to_string()),
},
EnvFileRemoval {
key: "AWS_SECRET_ACCESS_KEY".to_string(),
comment: Some("managed by fabro-install: object-store".to_string()),
},
],
[],
)
.unwrap();
assert_eq!(report.removed_keys, vec!["AWS_ACCESS_KEY_ID".to_string()]);
assert_eq!(report.entries.get("KEEP_ME").map(String::as_str), Some("1"));
}
}

View file

@ -24,6 +24,10 @@ pub struct PendingSettingsWrite<'a> {
pub previous_contents: Option<&'a str>,
}
pub const OBJECT_STORE_MANAGED_COMMENT: &str = "managed by fabro-install: object-store";
pub const OBJECT_STORE_ACCESS_KEY_ID_ENV: &str = "AWS_ACCESS_KEY_ID";
pub const OBJECT_STORE_SECRET_ACCESS_KEY_ENV: &str = "AWS_SECRET_ACCESS_KEY";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VaultSecretWrite {
pub name: String,
@ -38,6 +42,59 @@ pub enum InstallListenConfig {
Unix(PathBuf),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallObjectStoreCredentialMode {
Runtime,
AccessKey,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallObjectStoreSelection {
Local,
S3 {
bucket: String,
region: String,
credential_mode: InstallObjectStoreCredentialMode,
access_key_id: Option<String>,
secret_access_key: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallObjectStoreEnvPlan {
pub writes: Vec<envfile::EnvFileUpdate>,
pub removals: Vec<envfile::EnvFileRemoval>,
}
#[derive(Debug)]
pub struct PersistInstallOutputsError {
source: anyhow::Error,
pub server_env_applied: bool,
pub removed_env_keys: Vec<String>,
}
impl PersistInstallOutputsError {
fn new(source: anyhow::Error, server_env_applied: bool, removed_env_keys: Vec<String>) -> Self {
Self {
source,
server_env_applied,
removed_env_keys,
}
}
}
impl std::fmt::Display for PersistInstallOutputsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.source.fmt(f)
}
}
impl std::error::Error for PersistInstallOutputsError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.source()
}
}
fn pem_encode(label: &str, bytes: &[u8]) -> String {
let body = BASE64_STANDARD.encode(bytes);
let mut pem = String::new();
@ -239,6 +296,107 @@ pub fn write_github_app_settings(
Ok(())
}
fn object_store_env_removals() -> Vec<envfile::EnvFileRemoval> {
[
OBJECT_STORE_ACCESS_KEY_ID_ENV,
OBJECT_STORE_SECRET_ACCESS_KEY_ENV,
]
.into_iter()
.map(|key| envfile::EnvFileRemoval {
key: key.to_string(),
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
})
.collect()
}
fn write_s3_store_settings(
server: &mut toml::Table,
domain: &str,
prefix: &str,
bucket: &str,
region: &str,
) -> Result<()> {
let store = ensure_table(server, domain)?;
store.insert(
"provider".to_string(),
toml::Value::String("s3".to_string()),
);
store.insert(
"prefix".to_string(),
toml::Value::String(prefix.to_string()),
);
let s3 = ensure_table(store, "s3")?;
s3.insert(
"bucket".to_string(),
toml::Value::String(bucket.to_string()),
);
s3.insert(
"region".to_string(),
toml::Value::String(region.to_string()),
);
Ok(())
}
pub fn write_object_store_settings(
doc: &mut toml::Value,
selection: &InstallObjectStoreSelection,
) -> Result<InstallObjectStoreEnvPlan> {
match selection {
InstallObjectStoreSelection::Local => Ok(InstallObjectStoreEnvPlan {
writes: Vec::new(),
removals: object_store_env_removals(),
}),
InstallObjectStoreSelection::S3 {
bucket,
region,
credential_mode,
access_key_id,
secret_access_key,
} => {
let bucket = bucket.trim();
anyhow::ensure!(!bucket.is_empty(), "bucket is required");
let region = region.trim();
anyhow::ensure!(!region.is_empty(), "region is required");
let root = root_table_mut(doc)?;
let server = ensure_table(root, "server")?;
write_s3_store_settings(server, "artifacts", "artifacts", bucket, region)?;
write_s3_store_settings(server, "slatedb", "slatedb", bucket, region)?;
let removals = object_store_env_removals();
let writes = match credential_mode {
InstallObjectStoreCredentialMode::Runtime => Vec::new(),
InstallObjectStoreCredentialMode::AccessKey => {
let access_key_id = access_key_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.context("access_key_id is required for manual credentials")?;
let secret_access_key = secret_access_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.context("secret_access_key is required for manual credentials")?;
vec![
envfile::EnvFileUpdate {
key: OBJECT_STORE_ACCESS_KEY_ID_ENV.to_string(),
value: access_key_id.to_string(),
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
},
envfile::EnvFileUpdate {
key: OBJECT_STORE_SECRET_ACCESS_KEY_ENV.to_string(),
value: secret_access_key.to_string(),
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
},
]
}
};
Ok(InstallObjectStoreEnvPlan { writes, removals })
}
}
}
fn restore_optional_file(path: &Path, previous_contents: Option<&str>) -> Result<()> {
match previous_contents {
Some(contents) => {
@ -261,15 +419,25 @@ fn restore_optional_file(path: &Path, previous_contents: Option<&str>) -> Result
Ok(())
}
fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)]) -> Result<()> {
if secrets.is_empty() {
return Ok(());
fn persist_server_env_secrets(
storage_dir: &Path,
writes: &[envfile::EnvFileUpdate],
removals: &[envfile::EnvFileRemoval],
) -> Result<envfile::EnvFileUpdateReport> {
if writes.is_empty() && removals.is_empty() {
return Ok(envfile::EnvFileUpdateReport {
entries: std::collections::HashMap::new(),
removed_keys: Vec::new(),
});
}
let env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&env_path, secrets.iter().cloned())
.with_context(|| format!("merging server env secrets into {}", env_path.display()))?;
Ok(())
envfile::update_env_file_with_report(
&env_path,
removals.iter().cloned(),
writes.iter().cloned(),
)
.with_context(|| format!("updating server env file {}", env_path.display()))
}
fn persist_vault_secrets_direct(storage_dir: &Path, secrets: &[VaultSecretWrite]) -> Result<()> {
@ -294,19 +462,27 @@ fn persist_vault_secrets_direct(storage_dir: &Path, secrets: &[VaultSecretWrite]
pub fn persist_install_outputs_direct(
storage_dir: &Path,
server_env_secrets: &[(String, String)],
server_env_writes: &[envfile::EnvFileUpdate],
server_env_removals: &[envfile::EnvFileRemoval],
vault_secrets: &[VaultSecretWrite],
settings_write: Option<&PendingSettingsWrite<'_>>,
) -> Result<()> {
persist_server_env_secrets(storage_dir, server_env_secrets)?;
) -> std::result::Result<(), PersistInstallOutputsError> {
let server_env_report =
persist_server_env_secrets(storage_dir, server_env_writes, server_env_removals)
.map_err(|err| PersistInstallOutputsError::new(err, false, Vec::new()))?;
let removed_env_keys = server_env_report.removed_keys;
if let Some(write) = settings_write {
if let Some(parent) = write.path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating settings directory {}", parent.display()))?;
.with_context(|| format!("creating settings directory {}", parent.display()))
.map_err(|err| {
PersistInstallOutputsError::new(err, true, removed_env_keys.clone())
})?;
}
std::fs::write(write.path, write.contents)
.with_context(|| format!("writing settings file {}", write.path.display()))?;
.with_context(|| format!("writing settings file {}", write.path.display()))
.map_err(|err| PersistInstallOutputsError::new(err, true, removed_env_keys.clone()))?;
}
let vault_path = Storage::new(storage_dir).secrets_path();
@ -330,7 +506,11 @@ pub fn persist_install_outputs_direct(
rollback_failures.join("; ")
))
};
return Err(error);
return Err(PersistInstallOutputsError::new(
error,
true,
removed_env_keys,
));
}
Ok(())
@ -342,8 +522,11 @@ mod tests {
use fabro_vault::{SecretType as VaultSecretType, Vault};
use super::{
InstallListenConfig, PendingSettingsWrite, VaultSecretWrite, default_web_url,
merge_server_settings, persist_install_outputs_direct, write_github_app_settings,
InstallListenConfig, InstallObjectStoreCredentialMode, InstallObjectStoreSelection,
OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_MANAGED_COMMENT,
OBJECT_STORE_SECRET_ACCESS_KEY_ENV, PendingSettingsWrite, VaultSecretWrite,
default_web_url, merge_server_settings, persist_install_outputs_direct,
write_github_app_settings, write_object_store_settings,
};
fn format_config_toml() -> String {
@ -476,7 +659,12 @@ name = "custom"
let result = persist_install_outputs_direct(
dir.path(),
&[("SESSION_SECRET".to_string(), "session".to_string())],
&[envfile::EnvFileUpdate {
key: "SESSION_SECRET".to_string(),
value: "session".to_string(),
comment: None,
}],
&[],
&[VaultSecretWrite {
name: "bad-secret-name".to_string(),
value: "boom".to_string(),
@ -534,4 +722,171 @@ name = "custom"
}
}
}
#[test]
fn write_object_store_settings_keeps_local_defaults_and_removes_managed_keys() {
let mut doc = toml::Value::Table(toml::Table::default());
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::Local)
.expect("local object store selection should succeed");
assert!(
doc.get("server")
.and_then(toml::Value::as_table)
.and_then(|server| server.get("artifacts"))
.is_none()
);
assert!(plan.writes.is_empty());
assert_eq!(plan.removals.len(), 2);
}
#[test]
fn write_object_store_settings_configures_s3_runtime_credentials() {
let mut doc = toml::Value::Table(toml::Table::default());
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::S3 {
bucket: "fabro-data".to_string(),
region: "us-east-1".to_string(),
credential_mode: InstallObjectStoreCredentialMode::Runtime,
access_key_id: None,
secret_access_key: None,
})
.expect("runtime-credential object store selection should succeed");
let server = doc
.get("server")
.and_then(toml::Value::as_table)
.expect("server table should exist");
assert_eq!(
server
.get("artifacts")
.and_then(toml::Value::as_table)
.and_then(|artifacts| artifacts.get("prefix"))
.and_then(toml::Value::as_str),
Some("artifacts")
);
assert_eq!(
server
.get("slatedb")
.and_then(toml::Value::as_table)
.and_then(|slatedb| slatedb.get("prefix"))
.and_then(toml::Value::as_str),
Some("slatedb")
);
assert!(plan.writes.is_empty());
}
#[test]
fn write_object_store_settings_configures_s3_manual_credentials() {
let mut doc = toml::Value::Table(toml::Table::default());
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::S3 {
bucket: "fabro-data".to_string(),
region: "us-east-1".to_string(),
credential_mode: InstallObjectStoreCredentialMode::AccessKey,
access_key_id: Some("AKIA_TEST".to_string()),
secret_access_key: Some("secret-test".to_string()),
})
.expect("manual-credential object store selection should succeed");
assert_eq!(plan.writes.len(), 2);
assert!(
plan.writes
.iter()
.all(|write| write.comment.as_deref() == Some(OBJECT_STORE_MANAGED_COMMENT))
);
assert_eq!(
plan.writes
.iter()
.find(|write| write.key == OBJECT_STORE_ACCESS_KEY_ID_ENV)
.map(|write| write.value.as_str()),
Some("AKIA_TEST")
);
assert_eq!(
plan.writes
.iter()
.find(|write| write.key == OBJECT_STORE_SECRET_ACCESS_KEY_ENV)
.map(|write| write.value.as_str()),
Some("secret-test")
);
}
#[test]
fn persist_install_outputs_direct_only_removes_marked_object_store_keys() {
let dir = tempfile::tempdir().unwrap();
let storage = Storage::new(dir.path());
let env_path = storage.runtime_directory().env_path();
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
std::fs::write(
&env_path,
format!(
"{OBJECT_STORE_ACCESS_KEY_ID_ENV}=operator-access\n# {OBJECT_STORE_MANAGED_COMMENT}\n{OBJECT_STORE_ACCESS_KEY_ID_ENV}=managed-access\n{OBJECT_STORE_SECRET_ACCESS_KEY_ENV}=operator-secret\nKEEP_ME=1\n"
),
)
.unwrap();
persist_install_outputs_direct(
dir.path(),
&[],
&[envfile::EnvFileRemoval {
key: OBJECT_STORE_ACCESS_KEY_ID_ENV.to_string(),
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
}],
&[],
None,
)
.expect("env-only persistence should succeed");
let server_env = envfile::read_env_file(&env_path).unwrap();
assert_eq!(
server_env
.get(OBJECT_STORE_ACCESS_KEY_ID_ENV)
.map(String::as_str),
Some("operator-access")
);
assert_eq!(
server_env
.get(OBJECT_STORE_SECRET_ACCESS_KEY_ENV)
.map(String::as_str),
Some("operator-secret")
);
assert_eq!(server_env.get("KEEP_ME").map(String::as_str), Some("1"));
}
#[cfg(unix)]
#[test]
fn persist_install_outputs_direct_writes_private_server_env_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let storage = Storage::new(dir.path());
let env_path = storage.runtime_directory().env_path();
persist_install_outputs_direct(
dir.path(),
&[envfile::EnvFileUpdate {
key: "SESSION_SECRET".to_string(),
value: "first".to_string(),
comment: None,
}],
&[],
&[],
None,
)
.expect("initial env write should succeed");
let create_mode = std::fs::metadata(&env_path).unwrap().permissions().mode() & 0o777;
assert_eq!(create_mode, 0o600);
persist_install_outputs_direct(
dir.path(),
&[envfile::EnvFileUpdate {
key: "SESSION_SECRET".to_string(),
value: "second".to_string(),
comment: None,
}],
&[],
&[],
None,
)
.expect("rewrite env write should succeed");
let update_mode = std::fs::metadata(&env_path).unwrap().permissions().mode() & 0o777;
assert_eq!(update_mode, 0o600);
}
}

View file

@ -76,6 +76,8 @@ thiserror.workspace = true
ipnet = "2.11.0"
percent-encoding.workspace = true
url = "2"
zeroize.workspace = true
strum.workspace = true
[build-dependencies]
chrono = { workspace = true }

View file

@ -378,8 +378,7 @@ pub(crate) async fn cancel_stub(
StatusCode::OK,
Json(serde_json::json!({
"id": id,
"status": "failed",
"status_reason": "cancelled",
"status": { "kind": "failed", "reason": "cancelled" },
"created_at": "2026-03-06T14:30:00Z"
})),
)
@ -752,7 +751,6 @@ fn ts(s: &str) -> DateTime<Utc> {
mod runs {
use std::collections::HashMap;
use std::str::FromStr;
use std::time::Duration;
use fabro_api::types::*;
@ -781,8 +779,6 @@ mod runs {
total_usd_micros: Option<i64>,
entries: &[(&str, &str)],
) -> StoreRunSummary {
let status_reason = status_reason.and_then(parse_status_reason);
StoreRunSummary {
created_at: ts(created_at),
duration_ms: elapsed_secs.and_then(duration_ms_from_secs),
@ -796,10 +792,8 @@ mod runs {
},
run_id: run_id.into(),
start_time: Some(ts(created_at)),
status: RunStatus::from_str(status)
.unwrap_or_else(|_| panic!("invalid demo run status: {status}")),
status_reason,
blocked_reason: None,
status: parse_run_status(status, status_reason)
.unwrap_or_else(|| panic!("invalid demo run status: {status}")),
title: truncate_goal(goal),
total_usd_micros,
workflow_name: Some(workflow_name.into()),
@ -807,8 +801,52 @@ mod runs {
}
}
fn parse_status_reason(reason: &str) -> Option<StatusReason> {
StatusReason::from_str(reason).ok()
fn parse_run_status(status: &str, status_reason: Option<&str>) -> Option<RunStatus> {
match status {
"submitted" => Some(RunStatus::Submitted),
"queued" => Some(RunStatus::Queued),
"starting" => Some(RunStatus::Starting),
"running" => Some(RunStatus::Running),
"blocked" => Some(RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
}),
"paused" => Some(RunStatus::Paused { prior_block: None }),
"removing" => Some(RunStatus::Removing),
"succeeded" => Some(RunStatus::Succeeded {
reason: status_reason
.and_then(parse_success_reason)
.unwrap_or(SuccessReason::Completed),
}),
"failed" => Some(RunStatus::Failed {
reason: status_reason
.and_then(parse_failure_reason)
.unwrap_or(FailureReason::WorkflowError),
}),
"dead" => Some(RunStatus::Dead),
_ => None,
}
}
fn parse_success_reason(reason: &str) -> Option<SuccessReason> {
match reason {
"completed" => Some(SuccessReason::Completed),
"partial_success" => Some(SuccessReason::PartialSuccess),
_ => None,
}
}
fn parse_failure_reason(reason: &str) -> Option<FailureReason> {
match reason {
"workflow_error" => Some(FailureReason::WorkflowError),
"cancelled" => Some(FailureReason::Cancelled),
"terminated" => Some(FailureReason::Terminated),
"transient_infra" => Some(FailureReason::TransientInfra),
"budget_exhausted" => Some(FailureReason::BudgetExhausted),
"launch_failed" => Some(FailureReason::LaunchFailed),
"bootstrap_failed" => Some(FailureReason::BootstrapFailed),
"sandbox_init_failed" => Some(FailureReason::SandboxInitFailed),
_ => None,
}
}
fn duration_ms_from_secs(secs: f64) -> Option<i64> {
@ -847,8 +885,7 @@ mod runs {
run_id: summary.run_id,
sandbox,
start_time: summary.start_time,
status: summary.status.to_string(),
status_reason: summary.status_reason,
status: summary.status,
title: summary.title,
total_usd_micros: summary.total_usd_micros,
workflow_name: summary.workflow_name,
@ -1357,7 +1394,9 @@ mod runs {
&[],
);
assert_eq!(summary.status_reason, Some(StatusReason::Cancelled));
assert_eq!(summary.status, RunStatus::Failed {
reason: FailureReason::Cancelled,
});
}
#[test]
@ -1377,7 +1416,9 @@ mod runs {
&[],
);
assert_eq!(summary.status_reason, None);
assert_eq!(summary.status, RunStatus::Failed {
reason: FailureReason::WorkflowError,
});
}
#[test]

View file

@ -15,26 +15,35 @@ use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_P
use fabro_auth::{AuthCredential, AuthDetails, credential_id_for};
use fabro_config::Storage;
use fabro_config::bind::{Bind, BindRequest};
use fabro_config::envfile::EnvFileUpdate;
use fabro_install::{
InstallListenConfig, PendingSettingsWrite, VaultSecretWrite, generate_jwt_keypair,
merge_server_settings, persist_install_outputs_direct, write_github_app_settings,
InstallListenConfig, OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV,
PendingSettingsWrite, VaultSecretWrite, generate_jwt_keypair, merge_server_settings,
persist_install_outputs_direct, write_github_app_settings, write_object_store_settings,
write_token_settings,
};
use fabro_model::Provider;
use fabro_store::ArtifactStore;
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_util::version::FABRO_VERSION;
use fabro_util::{Home, dev_token, session_secret};
use fabro_vault::SecretType as VaultSecretType;
use object_store::aws::resolve_bucket_region;
use object_store::path::Path as ObjectStorePath;
use object_store::{ClientOptions, RetryConfig};
use serde::{Deserialize, Serialize};
use tokio::net::{TcpListener, UnixListener};
use tokio::sync::watch;
use tokio::time::sleep;
use tokio::time::{sleep, timeout};
use tower::service_fn;
use tracing::{error, info, warn};
use zeroize::Zeroizing;
use crate::error::ApiError;
use crate::serve::{self, DEFAULT_TCP_PORT};
use crate::server_secrets::ServerSecrets;
use crate::{security_headers, static_files};
#[derive(Clone)]
@ -68,6 +77,10 @@ const DEFAULT_INSTALL_TCP_LISTEN_ADDRESS: &str = "127.0.0.1:32276";
const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com/v1";
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
const DEFAULT_GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
const REDACTED_SECRET_VALUE: &str = "[REDACTED]";
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20);
const VALIDATION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const AWS_SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN";
impl InstallAppState {
#[must_use]
@ -162,6 +175,7 @@ struct InstallTokenQuery {
struct PendingInstall {
llm: Option<LlmProvidersInput>,
server: Option<ServerConfigInput>,
object_store: Option<InstallObjectStoreState>,
github: Option<GithubInstallState>,
pending_github_app: Option<PendingGithubApp>,
}
@ -182,6 +196,155 @@ struct ServerConfigInput {
canonical_url: String,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
enum InstallObjectStoreProvider {
Local,
S3,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
enum InstallObjectStoreCredentialMode {
Runtime,
AccessKey,
}
#[derive(Clone, Debug, Deserialize)]
struct InstallObjectStoreInput {
provider: InstallObjectStoreProvider,
bucket: Option<String>,
region: Option<String>,
credential_mode: Option<InstallObjectStoreCredentialMode>,
access_key_id: Option<String>,
secret_access_key: Option<String>,
}
#[derive(Clone)]
struct InstallSecret(Zeroizing<String>);
impl InstallSecret {
fn new(value: impl Into<String>) -> Self {
Self(Zeroizing::new(value.into()))
}
fn expose_secret(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for InstallSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(REDACTED_SECRET_VALUE)
}
}
impl std::fmt::Display for InstallSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(REDACTED_SECRET_VALUE)
}
}
impl Serialize for InstallSecret {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(REDACTED_SECRET_VALUE)
}
}
#[derive(Clone)]
struct InstallAwsCredentialPair {
access_key_id: InstallSecret,
secret_access_key: InstallSecret,
}
impl InstallAwsCredentialPair {
fn new(access_key_id: impl Into<String>, secret_access_key: impl Into<String>) -> Self {
Self {
access_key_id: InstallSecret::new(access_key_id),
secret_access_key: InstallSecret::new(secret_access_key),
}
}
}
impl std::fmt::Debug for InstallAwsCredentialPair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InstallAwsCredentialPair")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &self.secret_access_key)
.finish()
}
}
#[derive(Clone, Debug)]
enum InstallObjectStoreState {
Local,
S3 {
bucket: String,
region: String,
credential_mode: InstallObjectStoreCredentialMode,
manual_credentials: Option<InstallAwsCredentialPair>,
},
}
impl InstallObjectStoreState {
fn as_session_value(&self) -> serde_json::Value {
match self {
Self::Local => serde_json::json!({
"provider": "local",
}),
Self::S3 {
bucket,
region,
credential_mode,
manual_credentials,
} => serde_json::json!({
"provider": "s3",
"bucket": bucket,
"region": region,
"credential_mode": <&'static str>::from(*credential_mode),
"manual_credentials_saved": matches!(
credential_mode,
InstallObjectStoreCredentialMode::AccessKey
) && manual_credentials.is_some(),
}),
}
}
fn to_persistence_selection(&self) -> fabro_install::InstallObjectStoreSelection {
match self {
Self::Local => fabro_install::InstallObjectStoreSelection::Local,
Self::S3 {
bucket,
region,
credential_mode,
manual_credentials,
} => fabro_install::InstallObjectStoreSelection::S3 {
bucket: bucket.clone(),
region: region.clone(),
credential_mode: match credential_mode {
InstallObjectStoreCredentialMode::Runtime => {
fabro_install::InstallObjectStoreCredentialMode::Runtime
}
InstallObjectStoreCredentialMode::AccessKey => {
fabro_install::InstallObjectStoreCredentialMode::AccessKey
}
},
access_key_id: manual_credentials
.as_ref()
.map(|credentials| credentials.access_key_id.expose_secret().to_string()),
secret_access_key: manual_credentials
.as_ref()
.map(|credentials| credentials.secret_access_key.expose_secret().to_string()),
},
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct GithubTokenInput {
token: String,
@ -325,6 +488,14 @@ pub async fn build_install_router(state: InstallAppState) -> Router {
"/install/server",
get(render_install_shell).put(put_install_server),
)
.route(
"/install/object-store/test",
post(post_install_object_store_test),
)
.route(
"/install/object-store",
get(render_install_shell).put(put_install_object_store),
)
.route(
"/install/github/token/test",
post(post_install_github_token_test),
@ -458,6 +629,7 @@ async fn get_install_session(
"completed_steps": completed_steps(&pending_install),
"llm": redacted_llm(&pending_install),
"server": pending_install.server,
"object_store": redacted_object_store(&pending_install),
"github": redacted_github(&pending_install),
"prefill": {
"canonical_url": detect_canonical_url(&headers),
@ -566,6 +738,311 @@ async fn put_install_server(
StatusCode::NO_CONTENT.into_response()
}
async fn post_install_object_store_test(
State(state): State<InstallAppState>,
headers: HeaderMap,
Query(query): Query<InstallTokenQuery>,
Json(input): Json<InstallObjectStoreInput>,
) -> Response {
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
return response;
}
observe_operator(&state, &headers);
let selection = {
let pending_install = lock_unpoisoned(&state.pending_install, "install session");
match resolve_install_object_store_state(pending_install.object_store.as_ref(), input) {
Ok(selection) => selection,
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
}
};
match validate_install_object_store_selection(&state, &selection).await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(err) => {
warn!(error = %err, "install object store validation failed");
install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err)
}
}
}
async fn put_install_object_store(
State(state): State<InstallAppState>,
headers: HeaderMap,
Query(query): Query<InstallTokenQuery>,
Json(input): Json<InstallObjectStoreInput>,
) -> Response {
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
return response;
}
observe_operator(&state, &headers);
let mut pending_install = lock_unpoisoned(&state.pending_install, "install session");
let selection =
match resolve_install_object_store_state(pending_install.object_store.as_ref(), input) {
Ok(selection) => selection,
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
};
pending_install.object_store = Some(selection);
info!(step = "object_store", "install step completed");
StatusCode::NO_CONTENT.into_response()
}
fn trim_install_field(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn resolve_install_object_store_state(
current: Option<&InstallObjectStoreState>,
input: InstallObjectStoreInput,
) -> Result<InstallObjectStoreState, String> {
let bucket = trim_install_field(input.bucket);
let region = trim_install_field(input.region);
let access_key_id = trim_install_field(input.access_key_id);
let secret_access_key = trim_install_field(input.secret_access_key);
match input.provider {
InstallObjectStoreProvider::Local => {
if bucket.is_some()
|| region.is_some()
|| input.credential_mode.is_some()
|| access_key_id.is_some()
|| secret_access_key.is_some()
{
return Err(
"Local disk does not accept S3 bucket, region, or AWS credential fields."
.to_string(),
);
}
Ok(InstallObjectStoreState::Local)
}
InstallObjectStoreProvider::S3 => {
let bucket = bucket.ok_or_else(|| "Bucket is required.".to_string())?;
let region = region
.ok_or_else(|| "Region is required. Use a value like us-east-1.".to_string())?;
let credential_mode = input
.credential_mode
.ok_or_else(|| "Choose how Fabro should authenticate to AWS.".to_string())?;
let manual_credentials = match credential_mode {
InstallObjectStoreCredentialMode::Runtime => {
if access_key_id.is_some() || secret_access_key.is_some() {
return Err(
"AWS access key fields are only allowed when using manual AWS access key credentials."
.to_string(),
);
}
None
}
InstallObjectStoreCredentialMode::AccessKey => Some(resolve_s3_manual_credentials(
access_key_id,
secret_access_key,
current,
)?),
};
Ok(InstallObjectStoreState::S3 {
bucket,
region,
credential_mode,
manual_credentials,
})
}
}
}
fn resolve_s3_manual_credentials(
access_key_id: Option<String>,
secret_access_key: Option<String>,
current: Option<&InstallObjectStoreState>,
) -> Result<InstallAwsCredentialPair, String> {
match (access_key_id, secret_access_key) {
(Some(access_key_id), Some(secret_access_key)) => Ok(InstallAwsCredentialPair::new(
access_key_id,
secret_access_key,
)),
(None, None) => current
.and_then(|state| match state {
InstallObjectStoreState::S3 {
credential_mode: InstallObjectStoreCredentialMode::AccessKey,
manual_credentials,
..
} => manual_credentials.clone(),
_ => None,
})
.ok_or_else(|| {
"Enter both AWS access key fields or switch to runtime credentials.".to_string()
}),
(Some(_), None) | (None, Some(_)) => {
Err("Enter both AWS access key fields or switch to runtime credentials.".to_string())
}
}
}
fn object_store_validation_settings(
selection: &InstallObjectStoreState,
) -> Option<ObjectStoreSettings> {
match selection {
InstallObjectStoreState::Local => None,
InstallObjectStoreState::S3 { bucket, region, .. } => Some(ObjectStoreSettings::S3 {
bucket: InterpString::parse(bucket),
region: InterpString::parse(region),
endpoint: None,
path_style: false,
}),
}
}
fn install_object_store_lookup<'a>(
server_secrets: &'a ServerSecrets,
manual_credentials: Option<&'a InstallAwsCredentialPair>,
) -> impl Fn(&str) -> Option<String> + 'a {
move |name| match (manual_credentials, name) {
(Some(credentials), OBJECT_STORE_ACCESS_KEY_ID_ENV) => {
Some(credentials.access_key_id.expose_secret().to_string())
}
(Some(credentials), OBJECT_STORE_SECRET_ACCESS_KEY_ENV) => {
Some(credentials.secret_access_key.expose_secret().to_string())
}
(Some(_), AWS_SESSION_TOKEN_ENV) => None,
_ => server_secrets.get(name),
}
}
async fn validate_install_object_store_selection(
state: &InstallAppState,
selection: &InstallObjectStoreState,
) -> Result<(), String> {
let Some(settings) = object_store_validation_settings(selection) else {
return Ok(());
};
let (bucket, region, manual_credentials) = match selection {
InstallObjectStoreState::Local => return Ok(()),
InstallObjectStoreState::S3 {
bucket,
region,
credential_mode: _,
manual_credentials,
} => (
bucket.as_str(),
region.as_str(),
manual_credentials.as_ref(),
),
};
let client_options = ClientOptions::new()
.with_connect_timeout(VALIDATION_CONNECT_TIMEOUT)
.with_timeout(VALIDATION_TIMEOUT);
match timeout(
VALIDATION_TIMEOUT,
resolve_bucket_region(bucket, &client_options),
)
.await
{
Ok(Ok(actual_region)) if actual_region != region => {
return Err(format!(
"Bucket {bucket} is in region {actual_region}, not {region}. Use the bucket's AWS region and try again."
));
}
Ok(Err(err)) => {
let rendered = err.to_string();
if rendered.contains("not found") {
return Err(format!("Bucket {bucket} was not found."));
}
}
Err(_) => {
return Err(VALIDATION_TIMEOUT_MSG.to_string());
}
Ok(Ok(_)) => {}
}
let server_env_path = Storage::new(state.storage_dir.as_ref())
.runtime_directory()
.env_path();
let server_secrets = ServerSecrets::load(server_env_path).map_err(|err| err.to_string())?;
let build_options = serve::ObjectStoreBuildOptions {
client_options,
retry_config: RetryConfig {
max_retries: 0,
retry_timeout: VALIDATION_TIMEOUT,
..RetryConfig::default()
},
};
let env_lookup = install_object_store_lookup(&server_secrets, manual_credentials);
let object_store = serve::build_object_store_from_settings_with_lookup(
&settings,
&env_lookup,
Some(&build_options),
)
.map_err(|err| err.to_string())?;
let probe_prefix = |index: usize, prefix: &'static str| {
let object_store = &object_store;
async move {
let path = ObjectStorePath::from(prefix);
object_store
.list_with_delimiter(Some(&path))
.await
.map(|_| ())
.map_err(|err| (index, err))
}
};
let probe = async {
tokio::try_join!(probe_prefix(0, "artifacts"), probe_prefix(1, "slatedb")).map(|_| ())
};
match timeout(VALIDATION_TIMEOUT, probe).await {
Ok(Ok(())) => Ok(()),
Err(_) => Err(VALIDATION_TIMEOUT_MSG.to_string()),
Ok(Err((index, err))) => Err(classify_object_store_validation_error(
bucket, region, index, &err,
)),
}
}
const PREFIX_ACCESS_ERROR_MSG: &str = "Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes.";
const VALIDATION_TIMEOUT_MSG: &str = "Timed out while checking S3 access. Verify the bucket, region, and network path, then try again.";
fn bucket_credentials_error(bucket: &str, region: &str) -> String {
format!("Could not access bucket {bucket} in region {region} with the selected credentials.")
}
fn classify_object_store_validation_error(
bucket: &str,
region: &str,
prefix_index: usize,
err: &object_store::Error,
) -> String {
let credentials_or_prefix_error = || {
if prefix_index == 0 {
bucket_credentials_error(bucket, region)
} else {
PREFIX_ACCESS_ERROR_MSG.to_string()
}
};
match err {
object_store::Error::PermissionDenied { .. }
| object_store::Error::Unauthenticated { .. } => credentials_or_prefix_error(),
object_store::Error::NotFound { .. } => format!("Bucket {bucket} was not found."),
object_store::Error::Generic { .. } => {
let rendered = err.to_string();
if rendered.contains("incorrectly configured region") {
format!(
"Bucket {bucket} is not reachable in region {region}. Verify the AWS region and try again."
)
} else if rendered.contains("not found") {
format!("Bucket {bucket} was not found.")
} else {
credentials_or_prefix_error()
}
}
_ => PREFIX_ACCESS_ERROR_MSG.to_string(),
}
}
async fn post_install_github_token_test(
State(state): State<InstallAppState>,
headers: HeaderMap,
@ -758,12 +1235,15 @@ async fn post_install_finish(
let pending_install = lock_unpoisoned(&state.pending_install, "install session").clone();
let Some(llm) = pending_install.llm else {
return missing_step_response("llm");
};
let Some(server) = pending_install.server else {
return missing_step_response("server");
};
let Some(object_store) = pending_install.object_store else {
return missing_step_response("object_store");
};
let Some(llm) = pending_install.llm else {
return missing_step_response("llm");
};
let Some(github) = pending_install.github else {
return missing_step_response("github");
};
@ -775,6 +1255,15 @@ async fn post_install_finish(
{
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
let object_store_env_plan = match write_object_store_settings(
&mut settings_doc,
&object_store.to_persistence_selection(),
) {
Ok(plan) => plan,
Err(err) => {
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
};
let mut vault_secrets = Vec::new();
for provider in llm.providers {
let credential = AuthCredential {
@ -801,7 +1290,13 @@ async fn post_install_finish(
});
}
let mut server_env_secrets = Vec::new();
let make_env_write = |key: &str, value: String| EnvFileUpdate {
key: key.to_string(),
value,
comment: None,
};
let mut server_env_writes = object_store_env_plan.writes;
let server_env_removals = object_store_env_plan.removals;
let mut dev_token: Option<String> = None;
match github {
GithubInstallState::Token(github) => {
@ -845,13 +1340,16 @@ async fn post_install_finish(
) {
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
server_env_secrets.push((
"GITHUB_APP_PRIVATE_KEY".to_string(),
server_env_writes.push(make_env_write(
"GITHUB_APP_PRIVATE_KEY",
BASE64_STANDARD.encode(github.pem.as_bytes()),
));
server_env_secrets.push(("GITHUB_APP_CLIENT_SECRET".to_string(), github.client_secret));
server_env_writes.push(make_env_write(
"GITHUB_APP_CLIENT_SECRET",
github.client_secret,
));
if let Some(secret) = github.webhook_secret {
server_env_secrets.push(("GITHUB_APP_WEBHOOK_SECRET".to_string(), secret));
server_env_writes.push(make_env_write("GITHUB_APP_WEBHOOK_SECRET", secret));
}
}
}
@ -870,19 +1368,19 @@ async fn post_install_finish(
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
};
server_env_secrets.extend([
(
"FABRO_JWT_PRIVATE_KEY".to_string(),
server_env_writes.extend([
make_env_write(
"FABRO_JWT_PRIVATE_KEY",
BASE64_STANDARD.encode(jwt_private_pem.as_bytes()),
),
(
"FABRO_JWT_PUBLIC_KEY".to_string(),
make_env_write(
"FABRO_JWT_PUBLIC_KEY",
BASE64_STANDARD.encode(jwt_public_pem.as_bytes()),
),
("SESSION_SECRET".to_string(), session_secret),
make_env_write("SESSION_SECRET", session_secret),
]);
if let Some(token) = dev_token.as_ref() {
server_env_secrets.push(("FABRO_DEV_TOKEN".to_string(), token.clone()));
server_env_writes.push(make_env_write("FABRO_DEV_TOKEN", token.clone()));
}
#[expect(
@ -894,7 +1392,8 @@ async fn post_install_finish(
if let Err(err) = persist_install_outputs_direct(
state.storage_dir.as_ref(),
&server_env_secrets,
&server_env_writes,
&server_env_removals,
&vault_secrets,
Some(&PendingSettingsWrite {
path: state.config_path.as_ref(),
@ -906,10 +1405,19 @@ async fn post_install_finish(
let status = StatusCode::INTERNAL_SERVER_ERROR;
let detail = err.to_string();
let title = status.canonical_reason().unwrap_or("Unknown").to_string();
let leftover_env_keys: Vec<String> = server_env_secrets
.iter()
.map(|(key, _)| key.clone())
.collect();
let leftover_env_keys: Vec<String> = if err.server_env_applied {
server_env_writes
.iter()
.map(|write| write.key.clone())
.collect()
} else {
Vec::new()
};
let removed_env_keys: Vec<String> = if err.server_env_applied {
err.removed_env_keys.clone()
} else {
Vec::new()
};
return (
status,
Json(serde_json::json!({
@ -919,6 +1427,7 @@ async fn post_install_finish(
"detail": detail,
}],
"leftover_env_keys": leftover_env_keys,
"removed_env_keys": removed_env_keys,
})),
)
.into_response();
@ -930,6 +1439,16 @@ async fn post_install_finish(
warn!(error = %err, "failed to write artifact store metadata after install");
}
}
if let Some(InstallObjectStoreState::S3 {
credential_mode: InstallObjectStoreCredentialMode::AccessKey,
manual_credentials,
..
}) = lock_unpoisoned(&state.pending_install, "install session")
.object_store
.as_mut()
{
*manual_credentials = None;
}
if let Some(on_finish) = state.on_finish.clone() {
info!(restart_url = %server.canonical_url, "install finish succeeded");
@ -1054,12 +1573,15 @@ fn detect_canonical_url(headers: &HeaderMap) -> String {
fn completed_steps(pending_install: &PendingInstall) -> Vec<&'static str> {
let mut steps = Vec::new();
if pending_install.llm.is_some() {
steps.push("llm");
}
if pending_install.server.is_some() {
steps.push("server");
}
if pending_install.object_store.is_some() {
steps.push("object_store");
}
if pending_install.llm.is_some() {
steps.push("llm");
}
if pending_install.github.is_some() {
steps.push("github");
}
@ -1099,6 +1621,13 @@ fn redacted_github(pending_install: &PendingInstall) -> serde_json::Value {
)
}
fn redacted_object_store(pending_install: &PendingInstall) -> serde_json::Value {
pending_install.object_store.as_ref().map_or_else(
|| serde_json::Value::Null,
InstallObjectStoreState::as_session_value,
)
}
fn missing_step_response(step: &str) -> Response {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
@ -1428,14 +1957,21 @@ async fn wait_for_shutdown(mut shutdown_rx: watch::Receiver<bool>) {
#[cfg(test)]
mod tests {
use std::io;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use axum::http::HeaderMap;
use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV};
use object_store::Error as ObjectStoreError;
use serde_json::json;
use super::{
DEFAULT_INSTALL_GITHUB_API_BASE_URL, InstallAppState, InstallFinishGuard, PendingInstall,
detect_canonical_url, lock_unpoisoned, token_is_valid,
AWS_SESSION_TOKEN_ENV, DEFAULT_INSTALL_GITHUB_API_BASE_URL, InstallAppState,
InstallAwsCredentialPair, InstallFinishGuard, InstallObjectStoreCredentialMode,
InstallObjectStoreInput, InstallObjectStoreProvider, PendingInstall, ServerSecrets,
classify_object_store_validation_error, detect_canonical_url, install_object_store_lookup,
lock_unpoisoned, resolve_install_object_store_state, token_is_valid,
};
#[test]
@ -1507,4 +2043,112 @@ mod tests {
"https://api.github.com"
);
}
#[test]
fn resolve_install_object_store_state_rejects_local_with_s3_fields() {
let err = resolve_install_object_store_state(None, InstallObjectStoreInput {
provider: InstallObjectStoreProvider::Local,
bucket: Some("fabro-data".to_string()),
region: None,
credential_mode: None,
access_key_id: None,
secret_access_key: None,
})
.expect_err("local mode should reject S3-only fields");
assert_eq!(
err,
"Local disk does not accept S3 bucket, region, or AWS credential fields."
);
}
#[test]
fn resolve_install_object_store_state_rejects_runtime_with_submitted_access_keys() {
let err = resolve_install_object_store_state(None, InstallObjectStoreInput {
provider: InstallObjectStoreProvider::S3,
bucket: Some("fabro-data".to_string()),
region: Some("us-east-1".to_string()),
credential_mode: Some(InstallObjectStoreCredentialMode::Runtime),
access_key_id: Some("AKIA_FAKE_VALUE".to_string()),
secret_access_key: Some("fake-secret-value".to_string()),
})
.expect_err("runtime mode should reject submitted access keys");
assert_eq!(
err,
"AWS access key fields are only allowed when using manual AWS access key credentials."
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "sync std::fs::write in a unit test fixture; not on a Tokio path"
)]
fn install_object_store_lookup_overrides_static_keys_and_suppresses_session_token() {
let temp_dir = tempfile::tempdir().unwrap();
let env_path = temp_dir.path().join("server.env");
std::fs::write(
&env_path,
"\
AWS_ACCESS_KEY_ID=ambient-access\n\
AWS_SECRET_ACCESS_KEY=ambient-secret\n\
AWS_SESSION_TOKEN=ambient-session\n\
AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/fabro-web-identity-token\n",
)
.unwrap();
let server_secrets = ServerSecrets::with_env_lookup(env_path.clone(), |_| None).unwrap();
let manual_credentials =
InstallAwsCredentialPair::new("submitted-access", "submitted-secret");
let lookup = install_object_store_lookup(&server_secrets, Some(&manual_credentials));
assert_eq!(
lookup(OBJECT_STORE_ACCESS_KEY_ID_ENV).as_deref(),
Some("submitted-access")
);
assert_eq!(
lookup(OBJECT_STORE_SECRET_ACCESS_KEY_ENV).as_deref(),
Some("submitted-secret")
);
assert_eq!(lookup(AWS_SESSION_TOKEN_ENV), None);
assert_eq!(
lookup("AWS_WEB_IDENTITY_TOKEN_FILE").as_deref(),
Some("/tmp/fabro-web-identity-token")
);
}
#[test]
fn classify_object_store_validation_error_reports_region_mismatch() {
let err = ObjectStoreError::Generic {
store: "AmazonS3",
source: Box::new(io::Error::other(
"Received redirect without LOCATION, this normally indicates an incorrectly configured region",
)),
};
assert_eq!(
classify_object_store_validation_error("fabro-data", "us-east-1", 0, &err),
"Bucket fabro-data is not reachable in region us-east-1. Verify the AWS region and try again."
);
}
#[test]
fn install_secret_debug_display_and_json_are_redacted() {
let manual_credentials =
InstallAwsCredentialPair::new("AKIA_STRUCTURALLY_REALISTIC", "secret-value-123");
let debug = format!("{manual_credentials:?}");
let rendered = json!({
"access_key_id": &manual_credentials.access_key_id,
"secret_access_key": &manual_credentials.secret_access_key,
})
.to_string();
assert!(!debug.contains("AKIA_STRUCTURALLY_REALISTIC"));
assert!(!debug.contains("secret-value-123"));
assert!(!rendered.contains("AKIA_STRUCTURALLY_REALISTIC"));
assert!(!rendered.contains("secret-value-123"));
assert!(rendered.contains("[REDACTED]"));
}
}

View file

@ -385,7 +385,7 @@ fn reason_for_fallback(projection: &fabro_store::RunProjection) -> RunFilesMetaD
let is_terminal = projection
.status
.as_ref()
.is_some_and(|s| s.status.is_terminal());
.is_some_and(|status| status.is_terminal());
if is_terminal {
RunFilesMetaDegradedReason::SandboxGone
} else {

View file

@ -9,6 +9,7 @@ use fabro_config::bind::{self, Bind, BindRequest};
use fabro_config::merge::combine_files;
use fabro_config::user::load_settings_config;
use fabro_config::{ServerSettings, Storage};
use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV};
use fabro_sandbox::SandboxProvider;
use fabro_types::settings::server::{
GithubIntegrationStrategy, ServerLayer, ServerListenLayer, WebhookStrategy,
@ -18,10 +19,10 @@ use fabro_types::settings::{
ServerNamespace, SettingsLayer,
};
use fabro_util::terminal::Styles;
use object_store::ObjectStore;
use object_store::aws::AmazonS3Builder;
use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
use object_store::local::LocalFileSystem;
use object_store::memory::InMemory;
use object_store::{ClientOptions, ObjectStore, RetryConfig};
use tokio::net::{TcpListener, UnixListener};
use tokio::sync::watch;
use tokio::time::interval;
@ -38,9 +39,25 @@ use crate::server::{
use crate::server_secrets::ServerSecrets;
const TEST_IN_MEMORY_STORE_ENV: &str = "FABRO_TEST_IN_MEMORY_STORE";
const AWS_SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN";
pub const DEFAULT_TCP_PORT: u16 = 32276;
type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
#[derive(Debug, Clone)]
pub(crate) struct ObjectStoreBuildOptions {
pub client_options: ClientOptions,
pub retry_config: RetryConfig,
}
impl Default for ObjectStoreBuildOptions {
fn default() -> Self {
Self {
client_options: ClientOptions::new(),
retry_config: RetryConfig::default(),
}
}
}
#[derive(Clone, Copy)]
enum ServerTitlePhase {
Boot,
@ -320,13 +337,82 @@ fn build_local_object_store_with_preference(
Ok(Arc::new(LocalFileSystem::new_with_prefix(store_path)?))
}
fn build_object_store_from_settings(
fn configure_s3_builder_from_env_lookup<F>(
mut builder: AmazonS3Builder,
env_lookup: &F,
build_options: &ObjectStoreBuildOptions,
) -> anyhow::Result<AmazonS3Builder>
where
F: Fn(&str) -> Option<String>,
{
builder = builder
.with_client_options(build_options.client_options.clone())
.with_retry(build_options.retry_config.clone());
let access_key_id = env_lookup(OBJECT_STORE_ACCESS_KEY_ID_ENV);
let secret_access_key = env_lookup(OBJECT_STORE_SECRET_ACCESS_KEY_ENV);
let session_token = env_lookup(AWS_SESSION_TOKEN_ENV);
match (access_key_id, secret_access_key) {
(Some(access_key_id), Some(secret_access_key)) => {
builder = builder
.with_access_key_id(access_key_id)
.with_secret_access_key(secret_access_key);
if let Some(session_token) = session_token {
builder = builder.with_token(session_token);
}
}
(Some(_), None) | (None, Some(_)) => {
anyhow::bail!(
"AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must both be set when using static AWS credentials"
);
}
(None, None) => {}
}
for (name, key) in [
(
"AWS_WEB_IDENTITY_TOKEN_FILE",
AmazonS3ConfigKey::WebIdentityTokenFile,
),
("AWS_ROLE_ARN", AmazonS3ConfigKey::RoleArn),
("AWS_ROLE_SESSION_NAME", AmazonS3ConfigKey::RoleSessionName),
("AWS_ENDPOINT_URL_STS", AmazonS3ConfigKey::StsEndpoint),
(
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
AmazonS3ConfigKey::ContainerCredentialsRelativeUri,
),
(
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
AmazonS3ConfigKey::ContainerCredentialsFullUri,
),
(
"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
AmazonS3ConfigKey::ContainerAuthorizationTokenFile,
),
("AWS_METADATA_ENDPOINT", AmazonS3ConfigKey::MetadataEndpoint),
("AWS_IMDSV1_FALLBACK", AmazonS3ConfigKey::ImdsV1Fallback),
] {
if let Some(value) = env_lookup(name) {
builder = builder.with_config(key, value);
}
}
Ok(builder)
}
pub(crate) fn build_object_store_from_settings_with_lookup<F>(
settings: &ObjectStoreSettings,
) -> anyhow::Result<Arc<dyn ObjectStore>> {
env_lookup: &F,
build_options: Option<&ObjectStoreBuildOptions>,
) -> anyhow::Result<Arc<dyn ObjectStore>>
where
F: Fn(&str) -> Option<String>,
{
if use_in_memory_store() {
return Ok(Arc::new(InMemory::new()));
}
let build_options = build_options.cloned().unwrap_or_default();
match settings {
ObjectStoreSettings::Local { root } => {
build_local_object_store_with_preference(&resolve_interp_path(root)?, false)
@ -337,13 +423,14 @@ fn build_object_store_from_settings(
endpoint,
path_style,
} => {
let mut builder = AmazonS3Builder::from_env()
let mut builder = AmazonS3Builder::new()
.with_bucket_name(resolve_interp(bucket)?)
.with_region(resolve_interp(region)?)
.with_virtual_hosted_style_request(!*path_style);
if let Some(endpoint) = endpoint.as_ref() {
builder = builder.with_endpoint(resolve_interp(endpoint)?);
}
builder = configure_s3_builder_from_env_lookup(builder, env_lookup, &build_options)?;
Ok(Arc::new(builder.build()?))
}
}
@ -410,19 +497,42 @@ fn resolve_interp_path(value: &InterpString) -> anyhow::Result<PathBuf> {
Ok(PathBuf::from(resolve_interp(value)?))
}
pub fn build_artifact_object_store(
fn load_server_secrets_for_settings(settings: &ServerNamespace) -> anyhow::Result<ServerSecrets> {
let storage_root = resolve_interp_path(&settings.storage.root)?;
let server_env_path = Storage::new(&storage_root).runtime_directory().env_path();
ServerSecrets::load(server_env_path).map_err(anyhow::Error::from)
}
pub(crate) fn build_artifact_object_store_with_server_secrets(
settings: &ServerNamespace,
server_secrets: &ServerSecrets,
) -> anyhow::Result<(Arc<dyn ObjectStore>, String)> {
let prefix = resolve_interp(&settings.artifacts.prefix)?;
let object_store = build_object_store_from_settings(&settings.artifacts.store)?;
let object_store = build_object_store_from_settings_with_lookup(
&settings.artifacts.store,
&|name| server_secrets.get(name),
None,
)?;
Ok((object_store, prefix))
}
fn build_slatedb_store(
pub fn build_artifact_object_store(
settings: &ServerNamespace,
) -> anyhow::Result<(Arc<dyn ObjectStore>, String)> {
let server_secrets = load_server_secrets_for_settings(settings)?;
build_artifact_object_store_with_server_secrets(settings, &server_secrets)
}
fn build_slatedb_store_with_server_secrets(
settings: &ServerNamespace,
server_secrets: &ServerSecrets,
) -> anyhow::Result<(Arc<dyn ObjectStore>, String, Duration, bool)> {
let prefix = resolve_interp(&settings.slatedb.prefix)?;
let object_store = build_object_store_from_settings(&settings.slatedb.store)?;
let object_store = build_object_store_from_settings_with_lookup(
&settings.slatedb.store,
&|name| server_secrets.get(name),
None,
)?;
Ok((
object_store,
prefix,
@ -431,6 +541,14 @@ fn build_slatedb_store(
))
}
#[cfg(test)]
fn build_slatedb_store(
settings: &ServerNamespace,
) -> anyhow::Result<(Arc<dyn ObjectStore>, String, Duration, bool)> {
let server_secrets = load_server_secrets_for_settings(settings)?;
build_slatedb_store_with_server_secrets(settings, &server_secrets)
}
/// Start the HTTP API server.
///
/// # Errors
@ -486,7 +604,7 @@ where
let github_meta_resolver = GitHubMetaResolver::from_cache_dir(&storage.cache_dir())?;
let (object_store, slatedb_prefix, flush_interval, disk_cache) =
build_slatedb_store(&resolved_server_settings)?;
build_slatedb_store_with_server_secrets(&resolved_server_settings, &server_secrets)?;
let cache_path = if disk_cache {
Some(storage.slatedb_cache_dir())
} else {
@ -500,8 +618,10 @@ where
));
let auth_code_store = store.auth_codes().await?;
let auth_token_store = store.refresh_tokens().await?;
let (artifact_object_store, artifact_prefix) =
build_artifact_object_store(&resolved_server_settings)?;
let (artifact_object_store, artifact_prefix) = build_artifact_object_store_with_server_secrets(
&resolved_server_settings,
&server_secrets,
)?;
let artifact_store = fabro_store::ArtifactStore::new(artifact_object_store, artifact_prefix);
let env_lookup: EnvLookup = Arc::new(|name| std::env::var(name).ok());
resolve_canonical_origin(&resolved_server_settings, &env_lookup).map_err(anyhow::Error::msg)?;
@ -892,11 +1012,14 @@ mod tests {
use fabro_config::bind::{Bind, BindRequest};
use fabro_config::parse_settings_layer;
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_util::Home;
use super::{
GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_runtime_settings,
bind_tcp_host_with_fallback, build_local_object_store_with_preference, build_slatedb_store,
bind_tcp_host_with_fallback, build_local_object_store_with_preference,
build_object_store_from_settings_with_lookup, build_slatedb_store,
resolve_bind_request_from_settings, resolve_github_webhook_ip_allowlist,
resolve_server_settings, resolve_startup_github_webhook_ip_allowlist, router_web_enabled,
server_bind_title, server_title,
@ -1193,6 +1316,81 @@ disk_cache = true
assert!(disk_cache);
}
#[test]
fn build_object_store_from_settings_uses_injected_static_credentials() {
let settings = ObjectStoreSettings::S3 {
bucket: InterpString::parse("fabro-data"),
region: InterpString::parse("us-east-1"),
endpoint: None,
path_style: false,
};
let store = build_object_store_from_settings_with_lookup(
&settings,
&|name| match name {
"AWS_ACCESS_KEY_ID" => Some("AKIA_TEST_VALUE".to_string()),
"AWS_SECRET_ACCESS_KEY" => Some("secret-test-value".to_string()),
_ => None,
},
None,
);
assert!(store.is_ok(), "injected static credentials should build");
}
#[test]
fn build_object_store_from_settings_rejects_partial_static_credentials() {
let settings = ObjectStoreSettings::S3 {
bucket: InterpString::parse("fabro-data"),
region: InterpString::parse("us-east-1"),
endpoint: None,
path_style: false,
};
let err = build_object_store_from_settings_with_lookup(
&settings,
&|name| match name {
"AWS_ACCESS_KEY_ID" => Some("AKIA_TEST_VALUE".to_string()),
_ => None,
},
None,
)
.expect_err("partial static credentials must fail");
assert!(
err.to_string()
.contains("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must both be set")
);
}
#[test]
fn build_object_store_from_settings_ignores_endpoint_override_env_vars() {
let settings = ObjectStoreSettings::S3 {
bucket: InterpString::parse("fabro-data"),
region: InterpString::parse("us-east-1"),
endpoint: None,
path_style: false,
};
let store = build_object_store_from_settings_with_lookup(
&settings,
&|name| match name {
"AWS_ACCESS_KEY_ID" => Some("AKIA_TEST_VALUE".to_string()),
"AWS_SECRET_ACCESS_KEY" => Some("secret-test-value".to_string()),
"AWS_ENDPOINT" | "AWS_ENDPOINT_URL_S3" => {
Some("://not-a-valid-endpoint".to_string())
}
_ => None,
},
None,
);
assert!(
store.is_ok(),
"unsupported endpoint env vars should be ignored"
);
}
#[tokio::test]
async fn tcp_host_request_uses_preferred_port_when_available() {
let preferred = std::net::TcpListener::bind("127.0.0.1:0").unwrap();

File diff suppressed because it is too large Load diff

View file

@ -3,13 +3,15 @@
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use std::sync::Arc;
use std::fmt::Write as _;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_config::{Storage, parse_settings_layer, resolve_server_from_file};
use fabro_install::OBJECT_STORE_MANAGED_COMMENT;
use fabro_model::Provider;
use fabro_server::install::{InstallAppState, build_install_router};
use fabro_util::{Home, dev_token};
@ -17,11 +19,81 @@ use fabro_vault::Vault;
use httpmock::MockServer;
use tokio::time::sleep;
use tower::ServiceExt;
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::{Layer, Registry};
use crate::helpers::{checked_response, response_json, response_status, response_text};
async fn configure_token_install(app: &axum::Router, token: &str) {
let llm_response = app
#[derive(Default)]
struct EventCapture {
fields: Vec<(String, String)>,
}
impl Visit for EventCapture {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.fields
.push((field.name().to_string(), format!("{value:?}")));
}
fn record_str(&mut self, field: &Field, value: &str) {
self.fields
.push((field.name().to_string(), value.to_string()));
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.fields
.push((field.name().to_string(), value.to_string()));
}
}
struct CaptureLayer {
lines: Arc<StdMutex<Vec<String>>>,
}
impl<S: Subscriber> Layer<S> for CaptureLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
if !event
.metadata()
.target()
.starts_with("fabro_server::install")
{
return;
}
let mut capture = EventCapture::default();
event.record(&mut capture);
let mut line = event.metadata().level().to_string();
for (field, value) in capture.fields {
let _ = write!(line, " {field}={value}");
}
self.lines.lock().unwrap().push(line);
}
}
async fn put_install_server(app: &axum::Router, token: &str, canonical_url: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"canonical_url":"{canonical_url}"}}"#
)))
.expect("server install request should build"),
)
.await
.unwrap();
response_status(response, StatusCode::NO_CONTENT, "PUT /install/server").await;
}
async fn put_install_llm(app: &axum::Router, token: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
@ -36,31 +108,11 @@ async fn configure_token_install(app: &axum::Router, token: &str) {
)
.await
.unwrap();
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
}
let server_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"canonical_url":"https://fabro.example.com"}"#,
))
.expect("server install request should build"),
)
.await
.unwrap();
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let github_response = app
async fn put_install_github_token(app: &axum::Router, token: &str, username: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
@ -68,21 +120,54 @@ async fn configure_token_install(app: &axum::Router, token: &str) {
.uri("/install/github/token")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"token":"ghp_test_token","username":"brynary"}"#,
))
.body(Body::from(format!(
r#"{{"token":"ghp_test_token","username":"{username}"}}"#
)))
.expect("GitHub token install request should build"),
)
.await
.unwrap();
response_status(
github_response,
response,
StatusCode::NO_CONTENT,
"PUT /install/github/token",
)
.await;
}
async fn put_install_object_store(app: &axum::Router, token: &str, body: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/object-store")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("object-store install request should build"),
)
.await
.unwrap();
response_status(
response,
StatusCode::NO_CONTENT,
"PUT /install/object-store",
)
.await;
}
async fn put_install_object_store_local(app: &axum::Router, token: &str) {
put_install_object_store(app, token, r#"{"provider":"local"}"#).await;
}
async fn configure_token_install(app: &axum::Router, token: &str) {
put_install_server(app, token, "https://fabro.example.com").await;
put_install_object_store_local(app, token).await;
put_install_llm(app, token).await;
put_install_github_token(app, token, "brynary").await;
}
#[tokio::test]
async fn install_router_isolated_from_normal_api_surface() {
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
@ -195,6 +280,16 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() {
"/install/server",
Some(r#"{"canonical_url":"https://fabro.example.com"}"#),
),
(
"POST",
"/install/object-store/test",
Some(r#"{"provider":"local"}"#),
),
(
"PUT",
"/install/object-store",
Some(r#"{"provider":"local"}"#),
),
(
"POST",
"/install/github/token/test",
@ -280,7 +375,124 @@ async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong
}
#[tokio::test]
async fn token_install_finish_persists_settings_env_and_vault() {
async fn object_store_local_validation_and_save_update_install_session() {
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
let validation_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/object-store/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"local"}"#))
.unwrap(),
)
.await
.unwrap();
let validation_body = response_json(
validation_response,
StatusCode::OK,
"POST /install/object-store/test",
)
.await;
assert_eq!(validation_body["ok"], true);
put_install_object_store_local(&app, "test-install-token").await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["object_store"]["provider"], "local");
assert!(
session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "object_store")
);
}
#[tokio::test]
async fn object_store_validation_rejects_runtime_mode_access_keys_without_echoing_secrets() {
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
let access_key_id = "AKIA_RUNTIME_SHOULD_NOT_LEAK";
let secret_access_key = "runtime-secret-should-not-leak";
let validation_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/object-store/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime","access_key_id":"{access_key_id}","secret_access_key":"{secret_access_key}"}}"#
)))
.unwrap(),
)
.await
.unwrap();
let validation_body = response_json(
validation_response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/object-store/test",
)
.await;
assert_eq!(
validation_body["errors"][0]["detail"],
"AWS access key fields are only allowed when using manual AWS access key credentials."
);
let rendered = validation_body.to_string();
assert!(!rendered.contains(access_key_id));
assert!(!rendered.contains(secret_access_key));
}
#[tokio::test]
async fn install_finish_requires_object_store_step() {
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/finish",
)
.await;
assert_eq!(
finish_body["errors"][0]["detail"],
"install step 'object_store' is incomplete"
);
}
#[tokio::test]
async fn manual_object_store_session_is_redacted_and_blank_resubmit_preserves_credentials() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
@ -290,67 +502,220 @@ async fn token_install_finish_persists_settings_env_and_vault() {
))
.await;
let llm_response = app
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"AKIA_TEST_VALUE","secret_access_key":"secret-test-value"}"#,
)
.await;
let session_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/llm")
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["object_store"]["provider"], "s3");
assert_eq!(session_body["object_store"]["bucket"], "fabro-data");
assert_eq!(session_body["object_store"]["region"], "us-east-1");
assert_eq!(
session_body["object_store"]["credential_mode"],
"access_key"
);
assert_eq!(
session_body["object_store"]["manual_credentials_saved"],
true
);
let rendered_session = session_body.to_string();
assert!(!rendered_session.contains("AKIA_TEST_VALUE"));
assert!(!rendered_session.contains("secret-test-value"));
let server_response = app
.clone()
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key"}"#,
)
.await;
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"canonical_url":"https://fabro.example.com"}"#,
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let github_response = app
let server_env =
std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
.unwrap();
assert!(server_env.contains("AWS_ACCESS_KEY_ID=AKIA_TEST_VALUE"));
assert!(server_env.contains("AWS_SECRET_ACCESS_KEY=secret-test-value"));
}
#[tokio::test]
async fn switching_object_store_from_manual_to_runtime_clears_saved_manual_credentials() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
))
.await;
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"AKIA_SWITCH_ME","secret_access_key":"switch-secret-value"}"#,
)
.await;
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
)
.await;
let session_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/github/token")
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"token":"ghp_test_token","username":"brynary"}"#,
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["object_store"]["provider"], "s3");
assert_eq!(session_body["object_store"]["credential_mode"], "runtime");
assert_eq!(
session_body["object_store"]["manual_credentials_saved"],
false
);
let rendered_session = session_body.to_string();
assert!(!rendered_session.contains("AKIA_SWITCH_ME"));
assert!(!rendered_session.contains("switch-secret-value"));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
github_response,
StatusCode::NO_CONTENT,
"PUT /install/github/token",
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let server_env =
std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
.unwrap();
assert!(!server_env.contains("AWS_ACCESS_KEY_ID="));
assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY="));
}
#[tokio::test]
async fn runtime_object_store_finish_removes_managed_aws_keys_but_keeps_unmarked_entries() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let storage = Storage::new(temp_dir.path());
std::fs::write(
storage.runtime_directory().env_path(),
format!(
"AWS_ACCESS_KEY_ID=operator-id\n# {OBJECT_STORE_MANAGED_COMMENT}\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n"
),
)
.unwrap();
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
))
.await;
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
)
.await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
assert!(server_env.contains("AWS_ACCESS_KEY_ID=operator-id"));
assert!(!server_env.contains("managed-secret"));
assert!(server_env.contains("KEEP_ME=1"));
}
#[tokio::test]
async fn token_install_finish_persists_settings_env_and_vault() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
))
.await;
configure_token_install(&app, "test-install-token").await;
let finish_response = app
.oneshot(
Request::builder()
@ -403,6 +768,8 @@ async fn token_install_finish_persists_settings_env_and_vault() {
assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY="));
assert!(server_env.contains("SESSION_SECRET="));
assert!(server_env.contains("FABRO_DEV_TOKEN="));
assert!(!server_env.contains("AWS_ACCESS_KEY_ID="));
assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY="));
let vault = Vault::load(fabro_config::Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert!(vault.get("anthropic").is_some());
@ -480,6 +847,8 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
)
.await;
put_install_object_store_local(&app, "test-install-token").await;
let manifest_response = app
.clone()
.oneshot(
@ -1400,6 +1769,146 @@ async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys(
);
}
#[tokio::test(flavor = "current_thread")]
async fn install_finish_failure_with_manual_credentials_does_not_leak_values() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
let storage = Storage::new(temp_dir.path());
let vault_path = storage.secrets_path();
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
std::fs::write(&vault_path, "{ not valid json").unwrap();
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
))
.await;
let access_key_id = "AKIA_FINISH_SHOULD_NOT_LEAK";
let secret_access_key = "finish-secret-should-not-leak";
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store(
&app,
"test-install-token",
&format!(
r#"{{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"{access_key_id}","secret_access_key":"{secret_access_key}"}}"#
),
)
.await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let lines = Arc::new(StdMutex::new(Vec::new()));
let subscriber = Registry::default().with(CaptureLayer {
lines: Arc::clone(&lines),
});
let _guard = tracing::subscriber::set_default(subscriber);
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::INTERNAL_SERVER_ERROR,
"POST /install/finish",
)
.await;
let rendered = finish_body.to_string();
assert!(!rendered.contains(access_key_id));
assert!(!rendered.contains(secret_access_key));
let captured = lines.lock().unwrap().join("\n");
assert!(
captured.contains("install persistence failed"),
"expected finish failure logs to be captured, got: {captured}"
);
assert!(!captured.contains(access_key_id));
assert!(!captured.contains(secret_access_key));
}
#[tokio::test]
async fn install_finish_failure_reports_only_env_keys_actually_removed() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
let storage = Storage::new(temp_dir.path());
let env_path = storage.runtime_directory().env_path();
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
std::fs::write(
&env_path,
format!(
"#{OBJECT_STORE_MANAGED_COMMENT}\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n"
),
)
.unwrap();
let vault_path = storage.secrets_path();
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
std::fs::write(&vault_path, "{ not valid json").unwrap();
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
))
.await;
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
)
.await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::INTERNAL_SERVER_ERROR,
"POST /install/finish",
)
.await;
assert_eq!(
finish_body["removed_env_keys"]
.as_array()
.expect("removed_env_keys should be present"),
&vec![serde_json::Value::String(
"AWS_SECRET_ACCESS_KEY".to_string()
)]
);
let server_env = std::fs::read_to_string(env_path).unwrap();
assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY=managed-secret"));
assert!(server_env.contains("KEEP_ME=1"));
}
#[tokio::test]
async fn install_finish_failure_leaves_home_dev_token_mirror_written() {
let temp_dir = tempfile::tempdir().unwrap();

View file

@ -234,9 +234,9 @@ pub(crate) async fn wait_for_run_status(
) -> String {
for _ in 0..POLL_ATTEMPTS {
let body = run_json(app, run_id).await;
let status = body["status"]
let status = body["status"]["kind"]
.as_str()
.expect("run response should include a string status")
.expect("run response should include a tagged status kind")
.to_string();
if expected.iter().any(|candidate| *candidate == status) {
return status;
@ -253,9 +253,9 @@ pub(crate) async fn wait_for_run_status_not_in(
) -> String {
for _ in 0..POLL_ATTEMPTS {
let body = run_json(app, run_id).await;
let status = body["status"]
let status = body["status"]["kind"]
.as_str()
.expect("run response should include a string status")
.expect("run response should include a tagged status kind")
.to_string();
if unexpected.iter().all(|candidate| *candidate != status) {
return status;

View file

@ -31,7 +31,7 @@ async fn archived_runs_reject_mutations_with_actionable_body() {
format!("POST /api/v1/runs/{run_id}/archive"),
)
.await;
assert_eq!(body["status"], "archived");
assert_eq!(body["status"]["kind"], "archived");
for path in &["/cancel", "/pause", "/unpause", "/start"] {
let req = Request::builder()
@ -143,7 +143,7 @@ async fn archived_runs_reject_mutations_with_actionable_body() {
format!("POST /api/v1/runs/{run_id}/unarchive"),
)
.await;
assert_eq!(body["status"], "succeeded");
assert_eq!(body["status"]["kind"], "succeeded");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View file

@ -91,8 +91,8 @@ async fn wait_for_run_state(
) -> serde_json::Value {
for _ in 0..POLL_ATTEMPTS {
let body = run_json(app, run_id).await;
if body["status"].as_str() == Some(expected_status)
&& body["status_reason"].as_str() == Some(expected_reason)
if body["status"]["kind"].as_str() == Some(expected_status)
&& body["status"]["reason"].as_str() == Some(expected_reason)
{
return body;
}
@ -248,7 +248,7 @@ async fn full_http_lifecycle_cancel() {
format!("POST /api/v1/runs/{run_id}/cancel"),
)
.await;
assert_eq!(body["status"], "running");
assert_eq!(body["status"]["kind"], "running");
// `pending_control` is computed from the store projection after the cancel
// event is appended AND the worker is signaled. The worker is sitting at a
// human gate; once notified it can emit a clearing event before this
@ -263,7 +263,7 @@ async fn full_http_lifecycle_cancel() {
// Verify the durable store view converges to cancelled failure.
let body = wait_for_run_state(&app, &run_id, "failed", "cancelled").await;
assert_eq!(body["status_reason"], "cancelled");
assert_eq!(body["status"]["reason"], "cancelled");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -58,7 +58,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-q11nrnd3.js"></script>
<script type="module" src="/assets/entry-1addt5fg.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>

View file

@ -20,6 +20,8 @@ pub enum Error {
InvalidKeySegment { segment: String },
#[error("failed to parse key: {0}")]
KeyParse(String),
#[error("invalid status transition: {0}")]
InvalidTransition(#[from] fabro_types::InvalidTransition),
#[error("{0}")]
Other(String),
}

View file

@ -8,10 +8,10 @@ use fabro_types::run_event::{
RunFailedProps, StageCompletedProps, StagePromptProps,
};
use fabro_types::{
BilledModelUsage, BlockedReason, Checkpoint, Conclusion, EventBody, FailureSignature,
InterviewQuestionRecord, InterviewQuestionType, NodeStatusRecord, Outcome,
PendingInterviewRecord, PullRequestRecord, RunControlAction, RunId, RunProjection, RunSpec,
RunStatus, RunStatusRecord, RunSummary, SandboxRecord, StageStatus, StartRecord, StatusReason,
BilledModelUsage, Checkpoint, Conclusion, EventBody, FailureSignature, InterviewQuestionRecord,
InterviewQuestionType, NodeStatusRecord, Outcome, PendingInterviewRecord, PullRequestRecord,
RunControlAction, RunId, RunProjection, RunSpec, RunStatus, RunSummary, SandboxRecord,
StageStatus, StartRecord, TerminalStatus,
};
use serde_json::Value;
@ -77,59 +77,43 @@ impl RunProjectionReducer for RunProjection {
if let Some(spec) = self.spec.as_mut() {
spec.definition_blob = props.definition_blob;
}
self.status = Some(run_status_record(RunStatus::Submitted, props.reason, ts));
self.try_apply_status(RunStatus::Submitted, ts)?;
}
EventBody::RunQueued(_) => {
self.status = Some(run_status_record(RunStatus::Queued, None, ts));
self.try_apply_status(RunStatus::Queued, ts)?;
}
EventBody::RunStarting(props) => {
self.status = Some(run_status_record(RunStatus::Starting, props.reason, ts));
EventBody::RunStarting(_) => {
self.try_apply_status(RunStatus::Starting, ts)?;
}
EventBody::RunRunning(props) => {
self.status = Some(run_status_record(RunStatus::Running, props.reason, ts));
EventBody::RunRunning(_) => {
self.try_apply_status(RunStatus::Running, ts)?;
}
EventBody::RunBlocked(props) => {
let visible_status = if self
.status
.as_ref()
.is_some_and(|status| status.status == RunStatus::Paused)
{
RunStatus::Paused
let next = if matches!(self.status, Some(RunStatus::Paused { .. })) {
RunStatus::Paused {
prior_block: Some(props.blocked_reason),
}
} else {
RunStatus::Blocked
RunStatus::Blocked {
blocked_reason: props.blocked_reason,
}
};
self.status = Some(run_status_record_with_blocked_reason(
visible_status,
None,
Some(props.blocked_reason),
ts,
));
self.try_apply_status(next, ts)?;
}
EventBody::RunUnblocked(_) => {
self.status = Some(match self.status.as_ref() {
Some(status) if status.status == RunStatus::Paused => {
run_status_record_with_blocked_reason(
RunStatus::Paused,
status.status_reason,
None,
ts,
)
let next = match self.status {
Some(RunStatus::Paused {
prior_block: Some(_),
}) => RunStatus::Paused { prior_block: None },
Some(RunStatus::Paused { prior_block: None }) => {
RunStatus::Paused { prior_block: None }
}
Some(status) => run_status_record_with_blocked_reason(
if status.status == RunStatus::Blocked {
RunStatus::Running
} else {
status.status
},
status.status_reason,
None,
ts,
),
None => run_status_record(RunStatus::Running, None, ts),
});
_ => RunStatus::Running,
};
self.try_apply_status(next, ts)?;
}
EventBody::RunRemoving(props) => {
self.status = Some(run_status_record(RunStatus::Removing, props.reason, ts));
EventBody::RunRemoving(_) => {
self.try_apply_status(RunStatus::Removing, ts)?;
}
EventBody::RunCancelRequested(_) => {
self.pending_control = Some(RunControlAction::Cancel);
@ -141,36 +125,43 @@ impl RunProjectionReducer for RunProjection {
self.pending_control = Some(RunControlAction::Unpause);
}
EventBody::RunPaused(_) => {
self.status = Some(run_status_record_with_blocked_reason(
RunStatus::Paused,
None,
self.status
.as_ref()
.and_then(|status| status.blocked_reason),
self.try_apply_status(
RunStatus::Paused {
prior_block: self.status().and_then(RunStatus::blocked_reason),
},
ts,
));
)?;
self.pending_control = None;
}
EventBody::RunUnpaused(_) => {
self.status = Some(run_status_record_with_blocked_reason(
RunStatus::Running,
None,
self.status
.as_ref()
.and_then(|status| status.blocked_reason),
ts,
));
let next = match self.status {
Some(RunStatus::Paused {
prior_block: Some(blocked_reason),
}) => RunStatus::Blocked { blocked_reason },
_ => RunStatus::Running,
};
self.try_apply_status(next, ts)?;
self.pending_control = None;
}
EventBody::RunCompleted(props) => {
self.status = Some(run_status_record(RunStatus::Succeeded, props.reason, ts));
self.try_apply_status(
RunStatus::Succeeded {
reason: props.reason,
},
ts,
)?;
self.pending_control = None;
self.conclusion = Some(conclusion_from_completed(props, ts)?);
self.final_patch.clone_from(&props.final_patch);
self.pending_interviews.clear();
}
EventBody::RunFailed(props) => {
self.status = Some(run_status_record(RunStatus::Failed, props.reason, ts));
self.try_apply_status(
RunStatus::Failed {
reason: props.reason,
},
ts,
)?;
self.pending_control = None;
self.conclusion = Some(conclusion_from_failed(props, ts));
self.final_patch.clone_from(&props.final_patch);
@ -180,40 +171,26 @@ impl RunProjectionReducer for RunProjection {
self.reset_for_rewind();
}
EventBody::RunArchived(_props) => {
// Idempotent on replay: a second RunArchived (from a concurrent
// archive race or a tampered log) must not overwrite
// prior_status — doing so would permanently corrupt the run
// (unarchive would then restore status=Archived and never
// recover). We also ignore the archive attempt when current
// status is not a terminal non-archived — the operations
// layer is the only legitimate emitter and it validates
// there, but replay must be defensive.
let current = self.status.as_ref().map(|record| record.status);
if matches!(
current,
Some(RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead)
) {
self.prior_status = current;
self.status = Some(run_status_record(RunStatus::Archived, None, ts));
if let Some(current) = self.status {
if matches!(current, RunStatus::Archived { .. }) {
return Ok(());
}
let Some(prior) = current.terminal_status() else {
return Err(fabro_types::InvalidTransition {
from: current,
to: RunStatus::Archived {
prior: TerminalStatus::Dead,
},
}
.into());
};
self.try_apply_status(RunStatus::Archived { prior }, ts)?;
}
}
EventBody::RunUnarchived(props)
if matches!(
self.status.as_ref().map(|record| record.status),
Some(RunStatus::Archived)
) && matches!(
props.restored_status,
RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead
) =>
{
// Defensive: restored_status must be a real terminal status.
// Accepting e.g. Running here would produce a projection
// whose status is Running without any RunRunning event in
// the log, breaking the is_active / is_terminal invariants.
// The operations layer only ever writes validated terminals,
// but replay must enforce it.
self.status = Some(run_status_record(props.restored_status, None, ts));
self.prior_status = None;
EventBody::RunUnarchived(_props) => {
if let Some(RunStatus::Archived { prior }) = self.status {
self.try_apply_status(prior.into(), ts)?;
}
}
EventBody::CheckpointCompleted(props) => {
let checkpoint = checkpoint_from_props(props, ts);
@ -416,18 +393,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
.as_ref()
.and_then(|spec| spec.host_repo_path.clone()),
start_time: state.start.as_ref().map(|start| start.start_time),
status: state
.status
.as_ref()
.map_or(RunStatus::Submitted, |status| status.status),
status_reason: state
.status
.as_ref()
.and_then(|status| status.status_reason),
blocked_reason: state
.status
.as_ref()
.and_then(|status| status.blocked_reason),
status: state.status.unwrap_or(RunStatus::Submitted),
pending_control: state.pending_control,
duration_ms: state
.conclusion
@ -441,28 +407,6 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
}
}
fn run_status_record(
status: RunStatus,
status_reason: Option<StatusReason>,
updated_at: DateTime<Utc>,
) -> RunStatusRecord {
run_status_record_with_blocked_reason(status, status_reason, None, updated_at)
}
fn run_status_record_with_blocked_reason(
status: RunStatus,
status_reason: Option<StatusReason>,
blocked_reason: Option<BlockedReason>,
updated_at: DateTime<Utc>,
) -> RunStatusRecord {
RunStatusRecord {
status,
status_reason,
blocked_reason,
updated_at,
}
}
fn checkpoint_from_props(props: &CheckpointCompletedProps, timestamp: DateTime<Utc>) -> Checkpoint {
let loop_failure_signatures = props
.loop_failure_signatures
@ -617,13 +561,13 @@ mod tests {
};
use fabro_types::settings::SettingsLayer;
use fabro_types::{
Checkpoint, EventBody, InterviewQuestionType, NodeState, RunBlobId, RunControlAction,
RunEvent, fixtures,
BlockedReason, Checkpoint, EventBody, FailureReason, InterviewQuestionType, NodeState,
RunBlobId, RunControlAction, RunEvent, RunStatus, SuccessReason, TerminalStatus, fixtures,
};
use serde_json::json;
use super::{RunProjection, RunProjectionReducer, build_summary};
use crate::{EventEnvelope, StageId};
use crate::{Error, EventEnvelope, StageId};
fn test_event(seq: u32, body: EventBody, node_id: Option<&str>) -> EventEnvelope {
let event = RunEvent {
@ -665,6 +609,27 @@ mod tests {
}
}
fn test_raw_event_at(
seq: u32,
ts: &str,
event: &str,
properties: &serde_json::Value,
node_id: Option<&str>,
) -> EventEnvelope {
EventEnvelope {
seq,
event: RunEvent::from_value(json!({
"id": format!("evt-{seq}"),
"ts": ts,
"run_id": fixtures::RUN_1,
"event": event,
"node_id": node_id,
"properties": properties,
}))
.unwrap(),
}
}
#[test]
fn deserialize_projection_defaults_missing_nodes_and_checkpoints() {
let state: RunProjection = serde_json::from_value(serde_json::json!({
@ -851,40 +816,54 @@ mod tests {
state
.apply_event(&test_raw_event(1, "run.queued", &json!({}), None))
.unwrap();
assert_eq!(
state
.status
.as_ref()
.map(|status| status.status.to_string()),
Some("queued".to_string())
);
assert_eq!(state.status(), Some(RunStatus::Queued));
state
.apply_event(&test_raw_event(2, "run.starting", &json!({}), None))
.unwrap();
state
.apply_event(&test_raw_event(3, "run.running", &json!({}), None))
.unwrap();
state
.apply_event(&test_event(
2,
4,
EventBody::RunPaused(RunControlEffectProps::default()),
None,
))
.unwrap();
state
.apply_event(&test_raw_event(
3,
5,
"run.blocked",
&json!({ "blocked_reason": "human_input_required" }),
None,
))
.unwrap();
let status_json = serde_json::to_value(state.status.as_ref().unwrap()).unwrap();
assert_eq!(status_json["status"], "paused");
assert_eq!(status_json["status_reason"], serde_json::Value::Null);
assert_eq!(status_json["blocked_reason"], "human_input_required");
let status_json = serde_json::to_value(state.status().unwrap()).unwrap();
assert_eq!(
state.status(),
Some(RunStatus::Paused {
prior_block: Some(BlockedReason::HumanInputRequired),
})
);
assert_eq!(
status_json,
json!({
"kind": "paused",
"prior_block": "human_input_required"
})
);
let summary = build_summary(&state, &fixtures::RUN_1);
let summary_json = serde_json::to_value(summary).unwrap();
assert_eq!(summary_json["status"], "paused");
assert_eq!(summary_json["status_reason"], serde_json::Value::Null);
assert_eq!(summary_json["blocked_reason"], "human_input_required");
assert_eq!(
summary_json["status"],
json!({
"kind": "paused",
"prior_block": "human_input_required"
})
);
}
#[test]
@ -903,9 +882,9 @@ mod tests {
.apply_event(&test_raw_event(2, "run.unblocked", &json!({}), None))
.unwrap();
let status_json = serde_json::to_value(state.status.as_ref().unwrap()).unwrap();
assert_eq!(status_json["status"], "running");
assert_eq!(status_json["blocked_reason"], serde_json::Value::Null);
assert_eq!(state.status(), Some(RunStatus::Running));
let status_json = serde_json::to_value(state.status().unwrap()).unwrap();
assert_eq!(status_json, json!({ "kind": "running" }));
}
#[test]
@ -931,9 +910,18 @@ mod tests {
.apply_event(&test_raw_event(3, "run.unblocked", &json!({}), None))
.unwrap();
let status_json = serde_json::to_value(state.status.as_ref().unwrap()).unwrap();
assert_eq!(status_json["status"], "paused");
assert_eq!(status_json["blocked_reason"], serde_json::Value::Null);
assert_eq!(
state.status(),
Some(RunStatus::Paused { prior_block: None })
);
let status_json = serde_json::to_value(state.status().unwrap()).unwrap();
assert_eq!(
status_json,
json!({
"kind": "paused",
"prior_block": null
})
);
}
#[test]
@ -971,9 +959,20 @@ mod tests {
))
.unwrap();
let status_json = serde_json::to_value(state.status.as_ref().unwrap()).unwrap();
assert_eq!(status_json["status"], "blocked");
assert_eq!(status_json["blocked_reason"], "human_input_required");
assert_eq!(
state.status(),
Some(RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
})
);
let status_json = serde_json::to_value(state.status().unwrap()).unwrap();
assert_eq!(
status_json,
json!({
"kind": "blocked",
"blocked_reason": "human_input_required"
})
);
}
#[test]
@ -995,7 +994,7 @@ mod tests {
});
let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap();
assert_eq!(summary_json["status"], "submitted");
assert_eq!(summary_json["status"], json!({ "kind": "submitted" }));
}
#[test]
@ -1065,7 +1064,7 @@ mod tests {
EventBody::RunFailed(RunFailedProps {
error: "boom".to_string(),
duration_ms: 42,
reason: None,
reason: FailureReason::WorkflowError,
git_commit_sha: Some("abc123".to_string()),
final_patch: Some(patch.to_string()),
}),
@ -1077,121 +1076,177 @@ mod tests {
}
#[test]
fn legacy_run_failed_event_without_final_patch_replays_as_none() {
fn run_archived_captures_prior_status_and_preserves_reason() {
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps};
let mut state = RunProjection::default();
// Existing SlateDB events predating the final_patch field should deserialize
// cleanly as None — no backfill required.
state
.apply_event(&test_event(
1,
EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
artifact_count: 0,
status: "success".to_string(),
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
billing: None,
}),
None,
))
.unwrap();
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
None,
))
.unwrap();
assert_eq!(
state.status(),
Some(RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
})
);
}
#[test]
fn run_unarchived_restores_prior_status() {
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps};
let mut state = RunProjection::default();
state
.apply_event(&test_event(
1,
EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
artifact_count: 0,
status: "success".to_string(),
reason: SuccessReason::PartialSuccess,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
billing: None,
}),
None,
))
.unwrap();
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
None,
))
.unwrap();
state
.apply_event(&test_event(
3,
EventBody::RunUnarchived(RunUnarchivedProps { actor: None }),
None,
))
.unwrap();
assert_eq!(
state.status(),
Some(RunStatus::Succeeded {
reason: SuccessReason::PartialSuccess,
})
);
}
#[test]
fn duplicate_event_noops_without_bumping_status_updated_at() {
let mut state = RunProjection::default();
state
.apply_event(&test_raw_event_at(
1,
"2026-04-07T12:00:00Z",
"run.running",
&json!({}),
None,
))
.unwrap();
let first_updated_at = state.status_updated_at;
state
.apply_event(&test_raw_event_at(
2,
"2026-04-07T12:01:00Z",
"run.running",
&json!({}),
None,
))
.unwrap();
assert_eq!(state.status(), Some(RunStatus::Running));
assert_eq!(state.status_updated_at, first_updated_at);
}
#[test]
fn paused_over_blocked_round_trips_back_to_blocked() {
let mut state = RunProjection::default();
state
.apply_event(&test_raw_event(1, "run.running", &json!({}), None))
.unwrap();
state
.apply_event(&test_raw_event(
1,
"run.failed",
&json!({
"error": "boom",
"duration_ms": 1,
}),
None,
))
.unwrap();
assert!(state.final_patch.is_none());
}
#[test]
fn run_archived_captures_prior_status_and_sets_archived() {
use fabro_types::RunStatus;
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps};
let mut state = RunProjection::default();
state
.apply_event(&test_event(
1,
EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
billing: None,
}),
None,
))
.unwrap();
assert_eq!(
state.status.as_ref().map(|record| record.status),
Some(RunStatus::Succeeded)
);
assert_eq!(state.prior_status, None);
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
None,
))
.unwrap();
assert_eq!(
state.status.as_ref().map(|record| record.status),
Some(RunStatus::Archived)
);
assert_eq!(state.prior_status, Some(RunStatus::Succeeded));
}
#[test]
fn run_unarchived_restores_status_and_clears_prior_status() {
use fabro_types::RunStatus;
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps};
let mut state = RunProjection::default();
state
.apply_event(&test_event(
1,
EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
billing: None,
}),
None,
))
.unwrap();
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
"run.blocked",
&json!({ "blocked_reason": "human_input_required" }),
None,
))
.unwrap();
state
.apply_event(&test_event(
3,
EventBody::RunUnarchived(RunUnarchivedProps {
actor: None,
restored_status: RunStatus::Succeeded,
}),
EventBody::RunPaused(RunControlEffectProps::default()),
None,
))
.unwrap();
state
.apply_event(&test_event(
4,
EventBody::RunUnpaused(RunControlEffectProps::default()),
None,
))
.unwrap();
assert_eq!(
state.status.as_ref().map(|record| record.status),
Some(RunStatus::Succeeded)
state.status(),
Some(RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
})
);
assert_eq!(state.prior_status, None);
}
#[test]
fn run_unarchived_uses_event_payload_even_when_prior_status_differs() {
// The event payload is authoritative: the unarchive apply arm sets status
// from `restored_status`, ignoring whatever `prior_status` was captured.
use fabro_types::RunStatus;
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps};
fn run_archived_on_non_terminal_projection_is_rejected() {
use fabro_types::run_event::RunArchivedProps;
let mut state = RunProjection::default();
state
.apply_event(&test_raw_event(1, "run.running", &json!({}), None))
.unwrap();
let err = state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
None,
))
.unwrap_err();
assert!(matches!(err, Error::InvalidTransition(_)));
assert_eq!(state.status(), Some(RunStatus::Running));
}
#[test]
fn run_unarchived_replayed_on_non_archived_projection_is_ignored() {
use fabro_types::run_event::{RunCompletedProps, RunUnarchivedProps};
let mut state = RunProjection::default();
state
@ -1201,7 +1256,7 @@ mod tests {
duration_ms: 10,
artifact_count: 0,
status: "success".to_string(),
reason: None,
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
@ -1210,158 +1265,22 @@ mod tests {
None,
))
.unwrap();
let updated_at = state.status_updated_at;
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
None,
))
.unwrap();
state
.apply_event(&test_event(
3,
EventBody::RunUnarchived(RunUnarchivedProps {
actor: None,
restored_status: RunStatus::Failed,
}),
EventBody::RunUnarchived(RunUnarchivedProps { actor: None }),
None,
))
.unwrap();
assert_eq!(
state.status.as_ref().map(|record| record.status),
Some(RunStatus::Failed)
state.status(),
Some(RunStatus::Succeeded {
reason: SuccessReason::Completed,
})
);
assert_eq!(state.prior_status, None);
}
#[test]
fn double_archive_preserves_prior_status() {
// Two RunArchived events in sequence — possible under a concurrent
// archive race or a replayed/retried write — must not overwrite
// prior_status with Archived. Otherwise unarchive would emit
// restored_status=Archived, the apply arm would set status=Archived +
// prior_status=None, and the run would be permanently unrecoverable.
use fabro_types::RunStatus;
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps};
let mut state = RunProjection::default();
state
.apply_event(&test_event(
1,
EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
billing: None,
}),
None,
))
.unwrap();
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
None,
))
.unwrap();
state
.apply_event(&test_event(
3,
EventBody::RunArchived(RunArchivedProps { actor: None }),
None,
))
.unwrap();
assert_eq!(
state.status.as_ref().map(|record| record.status),
Some(RunStatus::Archived)
);
assert_eq!(state.prior_status, Some(RunStatus::Succeeded));
}
#[test]
fn run_unarchived_with_non_terminal_restored_status_is_ignored() {
// The operations layer never emits this, but a corrupt log or an
// imported event must not drag the projection into a nonsense state
// (status=Running without any RunRunning event preceding it).
use fabro_types::RunStatus;
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps};
let mut state = RunProjection::default();
state
.apply_event(&test_event(
1,
EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
billing: None,
}),
None,
))
.unwrap();
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
None,
))
.unwrap();
state
.apply_event(&test_event(
3,
EventBody::RunUnarchived(RunUnarchivedProps {
actor: None,
restored_status: RunStatus::Running,
}),
None,
))
.unwrap();
assert_eq!(
state.status.as_ref().map(|record| record.status),
Some(RunStatus::Archived)
);
assert_eq!(state.prior_status, Some(RunStatus::Succeeded));
}
#[test]
fn run_archived_on_non_terminal_projection_is_ignored() {
// Same defensive posture on the other side: an event log that
// somehow contains RunArchived on a Running run must not produce
// a projection with status=Archived and prior_status=Some(Running).
use fabro_types::RunStatus;
use fabro_types::run_event::{RunArchivedProps, RunStatusTransitionProps};
let mut state = RunProjection::default();
state
.apply_event(&test_event(
1,
EventBody::RunRunning(RunStatusTransitionProps { reason: None }),
None,
))
.unwrap();
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
None,
))
.unwrap();
assert_eq!(
state.status.as_ref().map(|record| record.status),
Some(RunStatus::Running)
);
assert_eq!(state.prior_status, None);
assert_eq!(state.status_updated_at, updated_at);
}
}

View file

@ -303,7 +303,9 @@ mod tests {
use chrono::{DateTime, Utc};
use fabro_types::settings::SettingsLayer;
use fabro_types::{AttrValue, Graph, RunControlAction, RunSpec, RunStatus, StatusReason};
use fabro_types::{
AttrValue, FailureReason, Graph, RunControlAction, RunSpec, RunStatus, SuccessReason,
};
use futures::TryStreamExt;
use object_store::memory::InMemory;
use object_store::path::Path;
@ -462,8 +464,9 @@ mod tests {
assert_eq!(summary[1].run_id, test_run_id("run-1"));
assert_eq!(summary[1].workflow_name, Some("night-sky".to_string()));
assert_eq!(summary[1].goal, Some("map the constellations".to_string()));
assert_eq!(summary[1].status, RunStatus::Succeeded);
assert_eq!(summary[1].status_reason, Some(StatusReason::Completed));
assert_eq!(summary[1].status, RunStatus::Succeeded {
reason: SuccessReason::Completed,
});
let reopened = store.open_run(&test_run_id("run-1")).await.unwrap();
let stored = reopened.state().await.unwrap().spec.unwrap();
@ -596,8 +599,9 @@ mod tests {
let summary = store.list_runs(&ListRunsQuery::default()).await.unwrap();
assert_eq!(summary.len(), 1);
assert_eq!(summary[0].status, RunStatus::Failed);
assert_eq!(summary[0].status_reason, Some(StatusReason::Cancelled));
assert_eq!(summary[0].status, RunStatus::Failed {
reason: FailureReason::Cancelled,
});
assert_eq!(summary[0].pending_control, None);
}
@ -641,6 +645,8 @@ mod tests {
let summary = reopened.list_runs(&ListRunsQuery::default()).await.unwrap();
assert_eq!(summary.len(), 1);
assert_eq!(summary[0].run_id, test_run_id("run-1"));
assert_eq!(summary[0].status, RunStatus::Succeeded);
assert_eq!(summary[0].status, RunStatus::Succeeded {
reason: SuccessReason::Completed,
});
}
}

View file

@ -7,8 +7,8 @@ use fabro_types::graph::Graph;
use fabro_types::run::RunSpec;
use fabro_types::settings::SettingsLayer;
use fabro_types::{
Checkpoint, NodeStatusRecord, RunStatus, RunStatusRecord, SandboxRecord, StageStatus,
StartRecord, fixtures,
Checkpoint, NodeStatusRecord, RunStatus, SandboxRecord, StageStatus, StartRecord,
TerminalStatus, fixtures,
};
use serde_json::json;
@ -62,7 +62,7 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
run_branch: Some("fabro/run/demo".to_string()),
base_sha: Some("deadbeef".to_string()),
});
projection.status = Some(RunStatusRecord::new(RunStatus::Running, None));
projection.status = Some(RunStatus::Running);
projection.checkpoint = Some(sample_checkpoint());
projection.sandbox = Some(SandboxRecord {
provider: "local".to_string(),
@ -133,7 +133,9 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
fn projection_query_methods_expose_common_state() {
let mut projection = RunProjection::default();
projection.spec = Some(sample_run_spec());
projection.status = Some(RunStatusRecord::new(RunStatus::Archived, None));
projection.status = Some(RunStatus::Archived {
prior: TerminalStatus::Dead,
});
projection.checkpoint = Some(sample_checkpoint());
projection.pending_interviews = BTreeMap::from([(
"q-1".to_string(),
@ -144,7 +146,12 @@ fn projection_query_methods_expose_common_state() {
projection.spec().map(RunSpec::workflow_slug),
Some(Some("demo"))
);
assert_eq!(projection.status(), Some(RunStatus::Archived));
assert_eq!(
projection.status(),
Some(RunStatus::Archived {
prior: TerminalStatus::Dead,
})
);
assert!(projection.is_terminal());
assert_eq!(
projection

View file

@ -15,7 +15,9 @@ mod tests {
use super::EventEnvelope;
use crate::run_event::RunCompletedProps;
use crate::{ActorRef, EventBody, ParallelBranchId, RunEvent, StageId, fixtures};
use crate::{
ActorRef, EventBody, ParallelBranchId, RunEvent, StageId, SuccessReason, fixtures,
};
#[test]
fn wire_event_envelope_round_trips() {
@ -36,7 +38,7 @@ mod tests {
duration_ms: 42,
artifact_count: 0,
status: "success".to_string(),
reason: None,
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
@ -78,7 +80,7 @@ mod tests {
duration_ms: 100,
artifact_count: 1,
status: "success".to_string(),
reason: None,
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,

View file

@ -63,5 +63,6 @@ pub use sandbox_record::SandboxRecord;
pub use stage_id::{ParallelBranchId, StageId};
pub use start::StartRecord;
pub use status::{
BlockedReason, InvalidTransition, RunControlAction, RunStatus, RunStatusRecord, StatusReason,
BlockedReason, FailureReason, InvalidTransition, ParseFailureReasonError,
ParseSuccessReasonError, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
};

View file

@ -1129,14 +1129,11 @@ mod tests {
}
#[test]
fn run_unarchived_serializes_with_restored_status() {
let body = EventBody::RunUnarchived(RunUnarchivedProps {
actor: None,
restored_status: crate::RunStatus::Failed,
});
fn run_unarchived_serializes_with_actor_only() {
let body = EventBody::RunUnarchived(RunUnarchivedProps { actor: None });
let value = serde_json::to_value(&body).unwrap();
assert_eq!(value["event"], "run.unarchived");
assert_eq!(value["properties"]["restored_status"], "failed");
assert_eq!(value["properties"], json!({}));
}
#[test]
@ -1172,15 +1169,12 @@ mod tests {
"ts": "2026-04-19T12:00:00.000Z",
"run_id": fixtures::RUN_1,
"event": "run.unarchived",
"properties": {
"restored_status": "succeeded"
}
"properties": {}
});
let parsed = RunEvent::from_value(value.clone()).unwrap();
match &parsed.body {
EventBody::RunUnarchived(props) => {
assert_eq!(props.restored_status, crate::RunStatus::Succeeded);
assert!(props.actor.is_none());
}
other => panic!("expected RunUnarchived body, got {other:?}"),

View file

@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize};
use super::{ActorRef, BilledTokenCounts, RunNoticeLevel};
use crate::settings::SettingsLayer;
use crate::status::{BlockedReason, RunStatus};
use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, StatusReason};
use crate::status::{BlockedReason, FailureReason, SuccessReason};
use crate::{Graph, RunBlobId, RunControlAction, RunProvenance};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCreatedProps {
@ -50,11 +50,12 @@ pub struct RunStartedProps {
pub goal: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStatusTransitionProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<StatusReason>,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunStatusTransitionProps {}
#[allow(
clippy::empty_structs_with_brackets,
@ -65,8 +66,6 @@ pub struct RunStatusEffectProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSubmittedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<StatusReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub definition_blob: Option<RunBlobId>,
}
@ -108,8 +107,7 @@ pub struct RunArchivedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunUnarchivedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<ActorRef>,
pub restored_status: RunStatus,
pub actor: Option<ActorRef>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -117,8 +115,7 @@ pub struct RunCompletedProps {
pub duration_ms: u64,
pub artifact_count: usize,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<StatusReason>,
pub reason: SuccessReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_usd_micros: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -133,8 +130,7 @@ pub struct RunCompletedProps {
pub struct RunFailedProps {
pub error: String,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<StatusReason>,
pub reason: FailureReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_commit_sha: Option<String>,
// Optional unified-patch text captured at run end. Additive for back-compat:

View file

@ -3,8 +3,9 @@ use std::collections::{BTreeMap, HashMap};
use chrono::{DateTime, Utc};
use crate::{
Checkpoint, Conclusion, InterviewQuestionRecord, NodeStatusRecord, PullRequestRecord, Retro,
RunControlAction, RunSpec, RunStatus, RunStatusRecord, SandboxRecord, StageId, StartRecord,
Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition, NodeStatusRecord,
PullRequestRecord, Retro, RunControlAction, RunSpec, RunStatus, SandboxRecord, StageId,
StartRecord,
};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
@ -13,9 +14,8 @@ pub struct RunProjection {
pub spec: Option<RunSpec>,
pub graph_source: Option<String>,
pub start: Option<StartRecord>,
pub status: Option<RunStatusRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prior_status: Option<RunStatus>,
pub status: Option<RunStatus>,
pub status_updated_at: Option<DateTime<Utc>>,
pub pending_control: Option<RunControlAction>,
pub checkpoint: Option<Checkpoint>,
pub checkpoints: Vec<(u32, Checkpoint)>,
@ -84,7 +84,7 @@ impl RunProjection {
}
pub fn status(&self) -> Option<RunStatus> {
self.status.as_ref().map(|status| status.status)
self.status
}
pub fn is_terminal(&self) -> bool {
@ -111,8 +111,29 @@ impl RunProjection {
.max()
}
pub fn try_apply_status(
&mut self,
new: RunStatus,
ts: DateTime<Utc>,
) -> Result<(), InvalidTransition> {
match self.status {
Some(current) if current == new => Ok(()),
Some(current) => {
self.status = Some(current.transition_to(new)?);
self.status_updated_at = Some(ts);
Ok(())
}
None => {
self.status = Some(new);
self.status_updated_at = Some(ts);
Ok(())
}
}
}
pub fn reset_for_rewind(&mut self) {
self.status = None;
self.status_updated_at = None;
self.pending_control = None;
self.checkpoint = None;
self.checkpoints.clear();

View file

@ -3,7 +3,7 @@ use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{BlockedReason, RunControlAction, RunId, RunStatus, StatusReason};
use crate::{RunControlAction, RunId, RunStatus};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSummary {
@ -15,8 +15,6 @@ pub struct RunSummary {
pub host_repo_path: Option<String>,
pub start_time: Option<DateTime<Utc>>,
pub status: RunStatus,
pub status_reason: Option<StatusReason>,
pub blocked_reason: Option<BlockedReason>,
pub pending_control: Option<RunControlAction>,
pub duration_ms: Option<u64>,
pub total_usd_micros: Option<i64>,
@ -29,7 +27,7 @@ mod tests {
use chrono::{TimeZone, Utc};
use super::RunSummary;
use crate::{BlockedReason, RunControlAction, RunStatus, StatusReason, fixtures};
use crate::{BlockedReason, RunControlAction, RunStatus, fixtures};
#[test]
fn round_trips_through_serde_json() {
@ -41,9 +39,9 @@ mod tests {
labels: HashMap::from([("team".to_string(), "core".to_string())]),
host_repo_path: Some("/tmp/repo".to_string()),
start_time: Some(Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap()),
status: RunStatus::Blocked,
status_reason: Some(StatusReason::SandboxInitializing),
blocked_reason: Some(BlockedReason::HumanInputRequired),
status: RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
},
pending_control: Some(RunControlAction::Pause),
duration_ms: Some(42),
total_usd_micros: Some(123),

View file

@ -1,24 +1,22 @@
use std::fmt;
use std::str::FromStr;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RunStatus {
Submitted,
Queued,
Starting,
Running,
Blocked,
Paused,
Blocked { blocked_reason: BlockedReason },
Paused { prior_block: Option<BlockedReason> },
Removing,
Succeeded,
Failed,
Succeeded { reason: SuccessReason },
Failed { reason: FailureReason },
Dead,
Archived,
Archived { prior: TerminalStatus },
}
impl RunStatus {
@ -28,7 +26,7 @@ impl RunStatus {
pub fn is_terminal(self) -> bool {
matches!(
self,
Self::Succeeded | Self::Failed | Self::Dead | Self::Archived
Self::Succeeded { .. } | Self::Failed { .. } | Self::Dead | Self::Archived { .. }
)
}
@ -37,7 +35,10 @@ impl RunStatus {
/// NOT immutable — it can transition back to its prior terminal status
/// via `unarchive`.
pub fn is_immutable(self) -> bool {
matches!(self, Self::Succeeded | Self::Failed | Self::Dead)
matches!(
self,
Self::Succeeded { .. } | Self::Failed { .. } | Self::Dead
)
}
pub fn is_active(self) -> bool {
@ -47,39 +48,86 @@ impl RunStatus {
| Self::Queued
| Self::Starting
| Self::Running
| Self::Blocked
| Self::Paused
| Self::Blocked { .. }
| Self::Paused { .. }
| Self::Removing
)
}
pub fn blocked_reason(self) -> Option<BlockedReason> {
match self {
Self::Blocked { blocked_reason } => Some(blocked_reason),
Self::Paused { prior_block } => prior_block,
_ => None,
}
}
pub fn terminal_status(self) -> Option<TerminalStatus> {
match self {
Self::Succeeded { reason } => Some(TerminalStatus::Succeeded { reason }),
Self::Failed { reason } => Some(TerminalStatus::Failed { reason }),
Self::Dead => Some(TerminalStatus::Dead),
_ => None,
}
}
pub fn can_transition_to(self, to: Self) -> bool {
if to == Self::Dead {
if matches!(to, Self::Dead) {
return true;
}
if matches!((self, to), (Self::Failed { .. }, Self::Submitted)) {
return true;
}
if self.is_immutable() {
// Allow immutable terminal statuses to archive.
return matches!(to, Self::Archived);
return matches!(to, Self::Archived { .. });
}
if self == Self::Archived {
// Unarchive: restore to any prior terminal status.
return matches!(to, Self::Succeeded | Self::Failed);
if matches!(self, Self::Archived { .. }) {
return matches!(
to,
Self::Succeeded { .. } | Self::Failed { .. } | Self::Dead
);
}
matches!(
(self, to),
(Self::Submitted, Self::Queued)
| (Self::Queued, Self::Starting)
| (Self::Starting | Self::Paused | Self::Blocked, Self::Running)
(Self::Submitted, Self::Queued | Self::Starting)
| (
Self::Starting | Self::Running | Self::Blocked | Self::Paused | Self::Removing,
Self::Failed
Self::Queued
| Self::Starting
| Self::Running
| Self::Blocked { .. }
| Self::Paused { .. }
| Self::Removing
| Self::Failed { .. },
Self::Submitted
)
| (Self::Queued, Self::Starting)
| (Self::Submitted | Self::Queued, Self::Failed {
reason: FailureReason::Cancelled,
})
| (
Self::Starting | Self::Paused { .. } | Self::Blocked { .. },
Self::Running
)
| (
Self::Starting
| Self::Queued
| Self::Running
| Self::Blocked { .. }
| Self::Paused { .. }
| Self::Removing,
Self::Failed { .. }
)
| (
Self::Running,
Self::Succeeded | Self::Blocked | Self::Paused | Self::Removing
Self::Succeeded { .. }
| Self::Blocked { .. }
| Self::Paused { .. }
| Self::Removing
)
| (Self::Blocked, Self::Paused)
| (Self::Paused, Self::Removing)
| (Self::Blocked { .. }, Self::Paused { .. })
| (Self::Paused { .. }, Self::Paused { .. })
| (Self::Paused { .. }, Self::Blocked { .. })
| (Self::Paused { .. }, Self::Removing)
)
}
@ -92,6 +140,26 @@ impl RunStatus {
}
}
impl fmt::Display for RunStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Submitted => f.write_str("submitted"),
Self::Queued => f.write_str("queued"),
Self::Starting => f.write_str("starting"),
Self::Running => f.write_str("running"),
Self::Blocked { blocked_reason } => write!(f, "blocked({blocked_reason})"),
Self::Paused {
prior_block: Some(blocked_reason),
} => write!(f, "paused({blocked_reason})"),
Self::Paused { prior_block: None } => f.write_str("paused"),
Self::Removing => f.write_str("removing"),
Self::Succeeded { reason } => write!(f, "succeeded({reason})"),
Self::Failed { reason } => write!(f, "failed({reason})"),
Self::Dead => f.write_str("dead"),
Self::Archived { prior } => write!(f, "archived({prior})"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct InvalidTransition {
pub from: RunStatus,
@ -106,12 +174,48 @@ impl fmt::Display for InvalidTransition {
impl std::error::Error for InvalidTransition {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum StatusReason {
pub enum SuccessReason {
Completed,
PartialSuccess,
}
impl fmt::Display for SuccessReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Completed => "completed",
Self::PartialSuccess => "partial_success",
})
}
}
impl FromStr for SuccessReason {
type Err = ParseSuccessReasonError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"completed" => Ok(Self::Completed),
"partial_success" => Ok(Self::PartialSuccess),
_ => Err(ParseSuccessReasonError(s.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub struct ParseSuccessReasonError(String);
impl fmt::Display for ParseSuccessReasonError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid success reason: {:?}", self.0)
}
}
impl std::error::Error for ParseSuccessReasonError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureReason {
WorkflowError,
Cancelled,
Terminated,
@ -120,15 +224,93 @@ pub enum StatusReason {
LaunchFailed,
BootstrapFailed,
SandboxInitFailed,
SandboxInitializing,
}
impl fmt::Display for FailureReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::WorkflowError => "workflow_error",
Self::Cancelled => "cancelled",
Self::Terminated => "terminated",
Self::TransientInfra => "transient_infra",
Self::BudgetExhausted => "budget_exhausted",
Self::LaunchFailed => "launch_failed",
Self::BootstrapFailed => "bootstrap_failed",
Self::SandboxInitFailed => "sandbox_init_failed",
})
}
}
impl FromStr for FailureReason {
type Err = ParseFailureReasonError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"workflow_error" => Ok(Self::WorkflowError),
"cancelled" => Ok(Self::Cancelled),
"terminated" => Ok(Self::Terminated),
"transient_infra" => Ok(Self::TransientInfra),
"budget_exhausted" => Ok(Self::BudgetExhausted),
"launch_failed" => Ok(Self::LaunchFailed),
"bootstrap_failed" => Ok(Self::BootstrapFailed),
"sandbox_init_failed" => Ok(Self::SandboxInitFailed),
_ => Err(ParseFailureReasonError(s.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub struct ParseFailureReasonError(String);
impl fmt::Display for ParseFailureReasonError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid failure reason: {:?}", self.0)
}
}
impl std::error::Error for ParseFailureReasonError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TerminalStatus {
Succeeded { reason: SuccessReason },
Failed { reason: FailureReason },
Dead,
}
impl fmt::Display for TerminalStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Succeeded { reason } => write!(f, "succeeded({reason})"),
Self::Failed { reason } => write!(f, "failed({reason})"),
Self::Dead => f.write_str("dead"),
}
}
}
impl From<TerminalStatus> for RunStatus {
fn from(value: TerminalStatus) -> Self {
match value {
TerminalStatus::Succeeded { reason } => Self::Succeeded { reason },
TerminalStatus::Failed { reason } => Self::Failed { reason },
TerminalStatus::Dead => Self::Dead,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlockedReason {
HumanInputRequired,
}
impl fmt::Display for BlockedReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::HumanInputRequired => "human_input_required",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunControlAction {
@ -137,115 +319,166 @@ pub enum RunControlAction {
Unpause,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunStatusRecord {
pub status: RunStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_reason: Option<StatusReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocked_reason: Option<BlockedReason>,
pub updated_at: DateTime<Utc>,
}
impl RunStatusRecord {
pub fn new(status: RunStatus, status_reason: Option<StatusReason>) -> Self {
Self {
status,
status_reason,
blocked_reason: None,
updated_at: Utc::now(),
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::{InvalidTransition, RunStatus, StatusReason};
use super::{
BlockedReason, FailureReason, InvalidTransition, RunStatus, SuccessReason, TerminalStatus,
};
#[test]
fn queued_and_blocked_parse_and_format() {
for status in ["queued", "blocked"] {
let parsed = RunStatus::from_str(status)
.unwrap_or_else(|_| panic!("expected {status} to parse"));
assert_eq!(parsed.to_string(), status);
assert!(parsed.is_active(), "{status} should be active");
assert!(!parsed.is_terminal(), "{status} should not be terminal");
}
fn queued_and_blocked_are_active() {
let queued = RunStatus::Queued;
let blocked = RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
};
assert_eq!(queued.to_string(), "queued");
assert!(queued.is_active());
assert!(!queued.is_terminal());
assert_eq!(blocked.to_string(), "blocked(human_input_required)");
assert!(blocked.is_active());
assert!(!blocked.is_terminal());
}
#[test]
fn canonical_blocked_transitions_are_allowed() {
let submitted = RunStatus::from_str("submitted").unwrap();
let queued =
RunStatus::from_str("queued").unwrap_or_else(|_| panic!("expected queued to parse"));
let running = RunStatus::from_str("running").unwrap();
let blocked =
RunStatus::from_str("blocked").unwrap_or_else(|_| panic!("expected blocked to parse"));
let paused = RunStatus::from_str("paused").unwrap();
let submitted = RunStatus::Submitted;
let queued = RunStatus::Queued;
let running = RunStatus::Running;
let blocked = RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
};
let paused = RunStatus::Paused {
prior_block: Some(BlockedReason::HumanInputRequired),
};
let failed = RunStatus::Failed {
reason: FailureReason::WorkflowError,
};
assert!(submitted.can_transition_to(queued));
assert!(queued.can_transition_to(RunStatus::from_str("starting").unwrap()));
assert!(submitted.can_transition_to(RunStatus::Starting));
assert!(submitted.can_transition_to(RunStatus::Failed {
reason: FailureReason::Cancelled,
}));
assert!(queued.can_transition_to(RunStatus::Submitted));
assert!(failed.can_transition_to(RunStatus::Submitted));
assert!(queued.can_transition_to(RunStatus::Starting));
assert!(queued.can_transition_to(RunStatus::Failed {
reason: FailureReason::Cancelled,
}));
assert!(queued.can_transition_to(RunStatus::Failed {
reason: FailureReason::Terminated,
}));
assert!(running.can_transition_to(blocked));
assert!(blocked.can_transition_to(running));
assert!(blocked.can_transition_to(paused));
assert!(blocked.can_transition_to(RunStatus::from_str("failed").unwrap()));
assert!(blocked.can_transition_to(RunStatus::Failed {
reason: FailureReason::WorkflowError,
}));
}
#[test]
fn archived_parses_and_round_trips() {
let parsed = RunStatus::from_str("archived").expect("archived should parse");
assert_eq!(parsed, RunStatus::Archived);
assert_eq!(parsed.to_string(), "archived");
fn success_and_failure_reasons_parse_and_round_trip() {
let success = SuccessReason::from_str("completed").expect("completed should parse");
assert_eq!(success, SuccessReason::Completed);
assert_eq!(success.to_string(), "completed");
let failure = FailureReason::from_str("cancelled").expect("cancelled should parse");
assert_eq!(failure, FailureReason::Cancelled);
assert_eq!(failure.to_string(), "cancelled");
}
#[test]
fn status_reason_parses_and_round_trips() {
let parsed = StatusReason::from_str("cancelled").expect("cancelled should parse");
assert_eq!(parsed, StatusReason::Cancelled);
assert_eq!(parsed.to_string(), "cancelled");
fn archived_display_includes_prior_terminal_status() {
let archived = RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
};
assert_eq!(archived.to_string(), "archived(succeeded(completed))");
}
#[test]
fn terminal_statuses_can_transition_to_archived() {
assert!(RunStatus::Succeeded.can_transition_to(RunStatus::Archived));
assert!(RunStatus::Failed.can_transition_to(RunStatus::Archived));
assert!(RunStatus::Dead.can_transition_to(RunStatus::Archived));
let archived = RunStatus::Archived {
prior: TerminalStatus::Dead,
};
assert!(
RunStatus::Succeeded {
reason: SuccessReason::Completed,
}
.can_transition_to(archived)
);
assert!(
RunStatus::Failed {
reason: FailureReason::Cancelled,
}
.can_transition_to(archived)
);
assert!(RunStatus::Dead.can_transition_to(archived));
}
#[test]
fn archived_can_transition_back_to_terminal() {
assert!(RunStatus::Archived.can_transition_to(RunStatus::Succeeded));
assert!(RunStatus::Archived.can_transition_to(RunStatus::Failed));
// Dead is always reachable via the escape hatch.
assert!(RunStatus::Archived.can_transition_to(RunStatus::Dead));
let archived = RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
};
assert!(archived.can_transition_to(RunStatus::Succeeded {
reason: SuccessReason::Completed,
}));
assert!(archived.can_transition_to(RunStatus::Failed {
reason: FailureReason::Cancelled,
}));
assert!(archived.can_transition_to(RunStatus::Dead));
}
#[test]
fn running_cannot_transition_to_archived() {
assert!(!RunStatus::Running.can_transition_to(RunStatus::Archived));
assert!(!RunStatus::Queued.can_transition_to(RunStatus::Archived));
assert!(!RunStatus::Submitted.can_transition_to(RunStatus::Archived));
assert!(!RunStatus::Paused.can_transition_to(RunStatus::Archived));
let archived = RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
};
assert!(!RunStatus::Running.can_transition_to(archived));
assert!(!RunStatus::Queued.can_transition_to(archived));
assert!(!RunStatus::Submitted.can_transition_to(archived));
assert!(!RunStatus::Paused { prior_block: None }.can_transition_to(archived));
}
#[test]
fn archived_to_archived_is_rejected() {
// Idempotency of archive is handled at the operation layer, not the guard.
assert!(!RunStatus::Archived.can_transition_to(RunStatus::Archived));
let archived = RunStatus::Archived {
prior: TerminalStatus::Dead,
};
assert!(!archived.can_transition_to(archived));
}
#[test]
fn archived_is_terminal_but_not_immutable() {
assert!(RunStatus::Archived.is_terminal());
assert!(!RunStatus::Archived.is_immutable());
assert!(!RunStatus::Archived.is_active());
let archived = RunStatus::Archived {
prior: TerminalStatus::Dead,
};
assert!(archived.is_terminal());
assert!(!archived.is_immutable());
assert!(!archived.is_active());
}
#[test]
fn immutable_terminal_statuses_are_also_terminal() {
for status in [RunStatus::Succeeded, RunStatus::Failed, RunStatus::Dead] {
for status in [
RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
RunStatus::Failed {
reason: FailureReason::Cancelled,
},
RunStatus::Dead,
] {
assert!(status.is_terminal(), "{status} should be terminal");
assert!(status.is_immutable(), "{status} should be immutable");
}
@ -254,7 +487,9 @@ mod tests {
#[test]
fn invalid_transition_carries_from_and_to() {
let from = RunStatus::Running;
let to = RunStatus::Archived;
let to = RunStatus::Archived {
prior: TerminalStatus::Dead,
};
let err = from.transition_to(to).expect_err("should reject");
assert_eq!(err, InvalidTransition { from, to });
}

View file

@ -5,8 +5,8 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use ::fabro_types::{
ActorRef, BilledTokenCounts, BlockedReason, ParallelBranchId, RunBlobId, RunControlAction,
RunEvent, RunId, RunProvenance, RunStatus, StageId, StageStatus, StatusReason,
ActorRef, BilledTokenCounts, BlockedReason, FailureReason, ParallelBranchId, RunBlobId,
RunControlAction, RunEvent, RunId, RunProvenance, StageId, StageStatus, SuccessReason,
run_event as fabro_types,
};
use anyhow::{Context, Result};
@ -77,28 +77,17 @@ pub enum Event {
goal: Option<String>,
},
RunSubmitted {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
definition_blob: Option<RunBlobId>,
},
RunQueued,
RunStarting {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
},
RunRunning {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
},
RunStarting,
RunRunning,
RunBlocked {
blocked_reason: BlockedReason,
},
RunUnblocked,
RunRemoving {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
},
RunRemoving,
RunCancelRequested {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<ActorRef>,
@ -128,16 +117,14 @@ pub enum Event {
},
RunUnarchived {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<ActorRef>,
restored_status: RunStatus,
actor: Option<ActorRef>,
},
WorkflowRunCompleted {
duration_ms: u64,
artifact_count: usize,
#[serde(default)]
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
reason: SuccessReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
total_usd_micros: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -150,8 +137,7 @@ pub enum Event {
WorkflowRunFailed {
error: Error,
duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
reason: FailureReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -588,20 +574,17 @@ impl Event {
Self::WorkflowRunStarted { name, run_id, .. } => {
info!(workflow = name.as_str(), run_id = %run_id, "Workflow run started");
}
Self::RunSubmitted {
reason,
definition_blob,
} => {
info!(?reason, ?definition_blob, "Run submitted");
Self::RunSubmitted { definition_blob } => {
info!(?definition_blob, "Run submitted");
}
Self::RunQueued => {
info!("Run queued");
}
Self::RunStarting { reason } => {
info!(?reason, "Run starting");
Self::RunStarting => {
info!("Run starting");
}
Self::RunRunning { reason } => {
info!(?reason, "Run running");
Self::RunRunning => {
info!("Run running");
}
Self::RunBlocked { blocked_reason } => {
info!(?blocked_reason, "Run blocked");
@ -609,8 +592,8 @@ impl Event {
Self::RunUnblocked => {
info!("Run unblocked");
}
Self::RunRemoving { reason } => {
info!(?reason, "Run removing");
Self::RunRemoving => {
info!("Run removing");
}
Self::RunCancelRequested { .. } => {
info!("Run cancel requested");
@ -646,11 +629,8 @@ impl Event {
Self::RunArchived { actor } => {
info!(?actor, "Run archived");
}
Self::RunUnarchived {
actor,
restored_status,
} => {
info!(?actor, ?restored_status, "Run unarchived");
Self::RunUnarchived { actor } => {
info!(?actor, "Run unarchived");
}
Self::WorkflowRunCompleted {
duration_ms,
@ -1185,11 +1165,11 @@ pub fn event_name(event: &Event) -> &'static str {
Event::WorkflowRunStarted { .. } => "run.started",
Event::RunSubmitted { .. } => "run.submitted",
Event::RunQueued => "run.queued",
Event::RunStarting { .. } => "run.starting",
Event::RunRunning { .. } => "run.running",
Event::RunStarting => "run.starting",
Event::RunRunning => "run.running",
Event::RunBlocked { .. } => "run.blocked",
Event::RunUnblocked => "run.unblocked",
Event::RunRemoving { .. } => "run.removing",
Event::RunRemoving => "run.removing",
Event::RunCancelRequested { .. } => "run.cancel.requested",
Event::RunPauseRequested { .. } => "run.pause.requested",
Event::RunUnpauseRequested { .. } => "run.unpause.requested",
@ -1563,19 +1543,17 @@ fn event_body_from_event(event: &Event) -> EventBody {
worktree_dir: worktree_dir.clone(),
goal: goal.clone(),
}),
Event::RunSubmitted {
reason,
definition_blob,
} => EventBody::RunSubmitted(fabro_types::RunSubmittedProps {
reason: *reason,
definition_blob: *definition_blob,
}),
Event::RunQueued => EventBody::RunQueued(fabro_types::RunStatusEffectProps::default()),
Event::RunStarting { reason } => {
EventBody::RunStarting(fabro_types::RunStatusTransitionProps { reason: *reason })
Event::RunSubmitted { definition_blob } => {
EventBody::RunSubmitted(fabro_types::RunSubmittedProps {
definition_blob: *definition_blob,
})
}
Event::RunRunning { reason } => {
EventBody::RunRunning(fabro_types::RunStatusTransitionProps { reason: *reason })
Event::RunQueued => EventBody::RunQueued(fabro_types::RunStatusEffectProps::default()),
Event::RunStarting => {
EventBody::RunStarting(fabro_types::RunStatusTransitionProps::default())
}
Event::RunRunning => {
EventBody::RunRunning(fabro_types::RunStatusTransitionProps::default())
}
Event::RunBlocked { blocked_reason } => {
EventBody::RunBlocked(fabro_types::RunBlockedProps {
@ -1585,8 +1563,8 @@ fn event_body_from_event(event: &Event) -> EventBody {
Event::RunUnblocked => {
EventBody::RunUnblocked(fabro_types::RunStatusEffectProps::default())
}
Event::RunRemoving { reason } => {
EventBody::RunRemoving(fabro_types::RunStatusTransitionProps { reason: *reason })
Event::RunRemoving => {
EventBody::RunRemoving(fabro_types::RunStatusTransitionProps::default())
}
Event::RunCancelRequested { .. } => {
EventBody::RunCancelRequested(fabro_types::RunControlRequestedProps {
@ -1621,13 +1599,11 @@ fn event_body_from_event(event: &Event) -> EventBody {
Event::RunArchived { actor } => EventBody::RunArchived(fabro_types::RunArchivedProps {
actor: actor.clone(),
}),
Event::RunUnarchived {
actor,
restored_status,
} => EventBody::RunUnarchived(fabro_types::RunUnarchivedProps {
actor: actor.clone(),
restored_status: *restored_status,
}),
Event::RunUnarchived { actor } => {
EventBody::RunUnarchived(fabro_types::RunUnarchivedProps {
actor: actor.clone(),
})
}
Event::WorkflowRunCompleted {
duration_ms,
artifact_count,
@ -3140,7 +3116,7 @@ mod tests {
let stored = to_run_event(&fixtures::RUN_6, &Event::WorkflowRunFailed {
error: Error::handler("boom"),
duration_ms: 900,
reason: Some(StatusReason::WorkflowError),
reason: FailureReason::WorkflowError,
git_commit_sha: Some("abc123".to_string()),
final_patch: None,
});
@ -3414,7 +3390,7 @@ mod tests {
#[test]
fn run_level_events_without_scope_leave_stage_id_absent() {
let stored = to_run_event(&fixtures::RUN_1, &Event::RunRunning { reason: None });
let stored = to_run_event(&fixtures::RUN_1, &Event::RunRunning);
assert!(stored.stage_id.is_none());
assert!(stored.parallel_group_id.is_none());
assert!(stored.parallel_branch_id.is_none());
@ -3452,10 +3428,7 @@ mod tests {
"run.archived"
);
assert_eq!(
event_name(&Event::RunUnarchived {
actor: None,
restored_status: RunStatus::Succeeded,
}),
event_name(&Event::RunUnarchived { actor: None }),
"run.unarchived"
);
}
@ -3477,7 +3450,7 @@ mod tests {
}
#[test]
fn run_unarchived_round_trips_actor_and_restored_status() {
fn run_unarchived_round_trips_actor_in_envelope() {
let actor = ActorRef {
kind: ActorKind::User,
id: Some("bob".to_string()),
@ -3485,14 +3458,12 @@ mod tests {
};
let unarchived = to_run_event(&fixtures::RUN_1, &Event::RunUnarchived {
actor: Some(actor.clone()),
restored_status: RunStatus::Failed,
actor: Some(actor.clone()),
});
assert_eq!(unarchived.event_name(), "run.unarchived");
assert_eq!(unarchived.actor.as_ref().expect("actor set"), &actor);
match &unarchived.body {
EventBody::RunUnarchived(props) => {
assert_eq!(props.restored_status, RunStatus::Failed);
assert_eq!(props.actor.as_ref().expect("actor set"), &actor);
}
other => panic!("expected RunUnarchived body, got {other:?}"),

View file

@ -11,7 +11,7 @@ use fabro_core::lifecycle::{
};
use fabro_core::outcome::NodeResult;
use fabro_core::state::ExecutionState;
use fabro_types::{BilledTokenCounts, RunId, StatusReason};
use fabro_types::{BilledTokenCounts, FailureReason, RunId, SuccessReason};
use super::circuit_breaker::CircuitBreakerLifecycle;
use super::git::GitCheckpointResult;
@ -117,7 +117,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
worktree_dir: self.worktree_dir.clone(),
goal: self.goal.clone(),
});
self.emitter.emit(&Event::RunRunning { reason: None });
self.emitter.emit(&Event::RunRunning);
Ok(())
}
@ -451,7 +451,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
self.emitter.emit(&Event::WorkflowRunFailed {
error: Error::Cancelled,
duration_ms,
reason: Some(StatusReason::Cancelled),
reason: FailureReason::Cancelled,
git_commit_sha: last_sha,
final_patch: final_patch.clone(),
});
@ -463,10 +463,10 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
duration_ms,
artifact_count,
status: outcome.status.to_string(),
reason: Some(match outcome.status {
StageStatus::PartialSuccess => StatusReason::PartialSuccess,
_ => StatusReason::Completed,
}),
reason: match outcome.status {
StageStatus::PartialSuccess => SuccessReason::PartialSuccess,
_ => SuccessReason::Completed,
},
total_usd_micros,
final_git_commit_sha: last_sha,
final_patch,
@ -480,7 +480,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
self.emitter.emit(&Event::WorkflowRunFailed {
error: Error::engine(error_msg),
duration_ms,
reason: Some(StatusReason::WorkflowError),
reason: FailureReason::WorkflowError,
git_commit_sha: last_sha,
final_patch,
});

View file

@ -1,5 +1,5 @@
use fabro_store::{Database, Error as StoreError};
use fabro_types::{ActorRef, RunId, RunStatus};
use fabro_types::{ActorRef, RunId, RunStatus, TerminalStatus};
use crate::error::Error;
use crate::event::{self, Event};
@ -23,7 +23,7 @@ pub fn archived_rejection_message(run_id: &RunId) -> String {
/// archived run. Use this at any mutation entry point that would otherwise
/// transition or emit events against the run (rewind, resume, etc.).
pub fn ensure_not_archived(status: Option<RunStatus>, run_id: &RunId) -> Result<(), Error> {
if status == Some(RunStatus::Archived) {
if matches!(status, Some(RunStatus::Archived { .. })) {
Err(Error::Precondition(archived_rejection_message(run_id)))
} else {
Ok(())
@ -34,7 +34,7 @@ pub fn ensure_not_archived(status: Option<RunStatus>, run_id: &RunId) -> Result<
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArchiveOutcome {
/// Event was appended; projection transitions to `Archived`.
Archived { prior_status: RunStatus },
Archived { prior_status: TerminalStatus },
/// Run was already archived; no event emitted.
AlreadyArchived,
}
@ -43,7 +43,7 @@ pub enum ArchiveOutcome {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnarchiveOutcome {
/// Event was appended; projection transitions back to `restored_status`.
Unarchived { restored_status: RunStatus },
Unarchived { restored_status: TerminalStatus },
/// Run was terminal but not archived; no event emitted. Symmetric with
/// `ArchiveOutcome::AlreadyArchived`.
NotArchived { status: RunStatus },
@ -63,21 +63,17 @@ pub async fn archive(
.state()
.await
.map_err(|err| Error::engine(err.to_string()))?;
let current = projection
.status
.as_ref()
.map(|record| record.status)
.ok_or_else(|| {
Error::Precondition(format!("run {run_id} has no status; cannot archive"))
})?;
let current = projection.status.ok_or_else(|| {
Error::Precondition(format!("run {run_id} has no status; cannot archive"))
})?;
if current == RunStatus::Archived {
if matches!(current, RunStatus::Archived { .. }) {
return Ok(ArchiveOutcome::AlreadyArchived);
}
if !matches!(
current,
RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead
RunStatus::Succeeded { .. } | RunStatus::Failed { .. } | RunStatus::Dead
) {
return Err(Error::Precondition(format!(
"run {run_id} must be terminal (succeeded, failed, or dead) to archive; \
@ -89,9 +85,13 @@ pub async fn archive(
.await
.map_err(|err| Error::engine(err.to_string()))?;
Ok(ArchiveOutcome::Archived {
prior_status: current,
})
let prior_status = current.terminal_status().ok_or_else(|| {
Error::engine(format!(
"run {run_id} passed archive precondition but had non-terminal status {current}"
))
})?;
Ok(ArchiveOutcome::Archived { prior_status })
}
/// Unarchive a previously archived run, restoring its prior terminal status.
@ -110,34 +110,22 @@ pub async fn unarchive(
.state()
.await
.map_err(|err| Error::engine(err.to_string()))?;
let current = projection
.status
.as_ref()
.map(|record| record.status)
.ok_or_else(|| {
Error::Precondition(format!("run {run_id} has no status; cannot unarchive"))
})?;
let current = projection.status.ok_or_else(|| {
Error::Precondition(format!("run {run_id} has no status; cannot unarchive"))
})?;
if current == RunStatus::Archived {
let restored = projection.prior_status.ok_or_else(|| {
Error::engine(format!(
"run {run_id} is archived but prior_status is missing; projection is corrupt"
))
})?;
event::append_event(&run_store, run_id, &Event::RunUnarchived {
actor,
restored_status: restored,
})
.await
.map_err(|err| Error::engine(err.to_string()))?;
if let RunStatus::Archived { prior } = current {
event::append_event(&run_store, run_id, &Event::RunUnarchived { actor })
.await
.map_err(|err| Error::engine(err.to_string()))?;
return Ok(UnarchiveOutcome::Unarchived {
restored_status: restored,
restored_status: prior,
});
}
if matches!(
current,
RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead
RunStatus::Succeeded { .. } | RunStatus::Failed { .. } | RunStatus::Dead
) {
return Ok(UnarchiveOutcome::NotArchived { status: current });
}
@ -153,7 +141,7 @@ mod tests {
use std::time::Duration;
use fabro_store::Database;
use fabro_types::{RunId, fixtures};
use fabro_types::{FailureReason, RunId, SuccessReason, TerminalStatus, fixtures};
use object_store::memory::InMemory;
use super::*;
@ -173,7 +161,7 @@ mod tests {
duration_ms: 10,
artifact_count: 0,
status: "success".to_string(),
reason: None,
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
@ -188,7 +176,7 @@ mod tests {
event::append_event(&run_store, run_id, &Event::WorkflowRunFailed {
error: crate::error::Error::engine("boom"),
duration_ms: 10,
reason: None,
reason: FailureReason::WorkflowError,
git_commit_sha: None,
final_patch: None,
})
@ -198,14 +186,14 @@ mod tests {
async fn seed_running(store: &Database, run_id: &RunId) {
let run_store = store.create_run(run_id).await.unwrap();
event::append_event(&run_store, run_id, &Event::RunRunning { reason: None })
event::append_event(&run_store, run_id, &Event::RunRunning)
.await
.unwrap();
}
async fn current_status(store: &Database, run_id: &RunId) -> RunStatus {
let run_store = store.open_run_reader(run_id).await.unwrap();
run_store.state().await.unwrap().status.unwrap().status
run_store.state().await.unwrap().status.unwrap()
}
async fn event_count(store: &Database, run_id: &RunId) -> usize {
@ -221,9 +209,15 @@ mod tests {
let outcome = archive(&store, &run_id, None).await.unwrap();
assert_eq!(outcome, ArchiveOutcome::Archived {
prior_status: RunStatus::Succeeded,
prior_status: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
});
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
});
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived);
let projection = store
.open_run_reader(&run_id)
@ -232,7 +226,14 @@ mod tests {
.state()
.await
.unwrap();
assert_eq!(projection.prior_status, Some(RunStatus::Succeeded));
assert_eq!(
projection.status,
Some(RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
})
);
}
#[tokio::test]
@ -243,9 +244,15 @@ mod tests {
let outcome = archive(&store, &run_id, None).await.unwrap();
assert_eq!(outcome, ArchiveOutcome::Archived {
prior_status: RunStatus::Failed,
prior_status: TerminalStatus::Failed {
reason: FailureReason::WorkflowError,
},
});
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived {
prior: TerminalStatus::Failed {
reason: FailureReason::WorkflowError,
},
});
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived);
}
#[tokio::test]
@ -288,7 +295,9 @@ mod tests {
let outcome = unarchive(&store, &run_id, None).await.unwrap();
assert_eq!(outcome, UnarchiveOutcome::Unarchived {
restored_status: RunStatus::Succeeded,
restored_status: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
});
let projection = store
.open_run_reader(&run_id)
@ -298,10 +307,11 @@ mod tests {
.await
.unwrap();
assert_eq!(
projection.status.as_ref().map(|r| r.status),
Some(RunStatus::Succeeded)
projection.status,
Some(RunStatus::Succeeded {
reason: SuccessReason::Completed,
})
);
assert_eq!(projection.prior_status, None);
}
#[tokio::test]
@ -313,9 +323,13 @@ mod tests {
let outcome = unarchive(&store, &run_id, None).await.unwrap();
assert_eq!(outcome, UnarchiveOutcome::Unarchived {
restored_status: RunStatus::Failed,
restored_status: TerminalStatus::Failed {
reason: FailureReason::WorkflowError,
},
});
assert_eq!(current_status(&store, &run_id).await, RunStatus::Failed {
reason: FailureReason::WorkflowError,
});
assert_eq!(current_status(&store, &run_id).await, RunStatus::Failed);
}
#[tokio::test]
@ -329,7 +343,9 @@ mod tests {
let events_after = event_count(&store, &run_id).await;
assert_eq!(outcome, UnarchiveOutcome::NotArchived {
status: RunStatus::Succeeded,
status: RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
});
assert_eq!(events_before, events_after);
}
@ -387,6 +403,10 @@ mod tests {
let events_after = event_count(&store, &run_id).await;
assert_eq!(events_after - events_before, 3);
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived);
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
});
}
}

View file

@ -274,7 +274,6 @@ async fn persist_created_run(
.map(|_| ())
.map_err(store_error)?;
append_event(&run_store, &record.run_id, &Event::RunSubmitted {
reason: None,
definition_blob,
})
.await
@ -875,7 +874,7 @@ mod tests {
);
let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();
assert_eq!(
run_store.state().await.unwrap().status.unwrap().status,
run_store.state().await.unwrap().status.unwrap(),
crate::run_status::RunStatus::Submitted
);
assert_eq!(created.run_dir, default_run_dir(&fixtures::RUN_1));

View file

@ -14,9 +14,9 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result<Started,
.await
.map_err(|err| Error::engine(err.to_string()))?;
if let Some(record) = state.status {
super::archive::ensure_not_archived(Some(record.status), &services.run_id)?;
if record.status == RunStatus::Succeeded {
if let Some(status) = state.status {
super::archive::ensure_not_archived(Some(status), &services.run_id)?;
if matches!(status, RunStatus::Succeeded { .. }) {
return Err(Error::Precondition(
"run already finished successfully — nothing to resume".to_string(),
));
@ -42,10 +42,7 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result<Started,
append_event_to_sink(
&services.event_sink,
&services.run_id,
&Event::RunSubmitted {
reason: None,
definition_blob,
},
&Event::RunSubmitted { definition_blob },
)
.await
.map_err(|err| Error::engine(err.to_string()))?;

View file

@ -395,7 +395,7 @@ fn read_projection_at_commit(store: &Store, oid: Oid) -> Result<Option<RunProjec
#[cfg(test)]
mod tests {
use fabro_store::RunProjection;
use fabro_types::{RunId, fixtures};
use fabro_types::{RunId, RunStatus, SuccessReason, TerminalStatus, fixtures};
use super::super::test_support::*;
use super::*;
@ -546,7 +546,9 @@ mod tests {
run_id: fixtures::RUN_1,
target: RewindTarget::Ordinal(1),
push: false,
current_status: RunStatus::Succeeded,
current_status: RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
})
.unwrap();
@ -562,7 +564,11 @@ mod tests {
run_id: fixtures::RUN_1,
target: RewindTarget::Ordinal(1),
push: false,
current_status: RunStatus::Archived,
current_status: RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
},
})
.unwrap_err();
let message = err.to_string();

View file

@ -47,7 +47,7 @@ use crate::pipeline::{
use crate::records::Checkpoint;
use crate::run_control::RunControlState;
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::run_status::{RunStatus, StatusReason};
use crate::run_status::{FailureReason, RunStatus};
use crate::runtime_store::RunStoreHandle;
use crate::workflow_bundle::{RunDefinition, WorkflowBundle};
@ -124,14 +124,13 @@ pub async fn start(run_dir: &Path, services: StartServices) -> Result<Started, E
));
}
if let Some(record) = state.status {
if let Some(status) = state.status {
if !matches!(
record.status,
status,
RunStatus::Submitted | RunStatus::Queued | RunStatus::Starting
) {
return Err(Error::Precondition(format!(
"cannot start run: status is {:?}, expected submitted",
record.status
"cannot start run: status is {status}, expected submitted"
)));
}
}
@ -155,24 +154,20 @@ pub(super) async fn execute_persisted_run(
&event_sink,
run_dir,
"bootstrap",
StatusReason::BootstrapFailed,
FailureReason::BootstrapFailed,
&error,
)
.await;
return Err(error);
}
if let Err(err) = append_event_to_sink(&event_sink, &run_id, &Event::RunStarting {
reason: Some(StatusReason::SandboxInitializing),
})
.await
{
if let Err(err) = append_event_to_sink(&event_sink, &run_id, &Event::RunStarting).await {
let error = Error::engine(err.to_string());
let _ = persist_detached_failure(
run_id,
&event_sink,
run_dir,
"bootstrap",
StatusReason::BootstrapFailed,
FailureReason::BootstrapFailed,
&error,
)
.await;
@ -190,7 +185,7 @@ pub(super) async fn execute_persisted_run(
&event_sink,
run_dir,
"bootstrap",
StatusReason::BootstrapFailed,
FailureReason::BootstrapFailed,
&err,
)
.await;
@ -207,7 +202,7 @@ pub(super) async fn execute_persisted_run(
&event_sink,
run_dir,
"bootstrap",
StatusReason::BootstrapFailed,
FailureReason::BootstrapFailed,
&err,
)
.await;
@ -252,8 +247,7 @@ async fn persist_terminal_engine_failure(
duration: Duration,
) {
let engine_result: Result<Outcome, Error> = Err(error.clone());
let (final_status, failure_reason, _run_status, status_reason) =
classify_engine_result(&engine_result);
let (final_status, failure_reason, run_status) = classify_engine_result(&engine_result);
let _conclusion = build_conclusion_from_store(
run_store,
final_status,
@ -262,12 +256,16 @@ async fn persist_terminal_engine_failure(
None,
)
.await;
let reason = match run_status {
RunStatus::Failed { reason } => reason,
_ => FailureReason::WorkflowError,
};
if let Err(err) = append_event_to_sink(event_sink, &run_id, &Event::WorkflowRunFailed {
error: error.clone(),
duration_ms: crate::millis_u64(duration),
reason: status_reason,
error: error.clone(),
duration_ms: crate::millis_u64(duration),
reason,
git_commit_sha: None,
final_patch: None,
final_patch: None,
})
.await
{
@ -856,20 +854,20 @@ impl Drop for DetachedRunBootstrapGuard {
.as_ref()
.is_some_and(|token| token.load(Ordering::SeqCst));
let reason = if cancelled {
StatusReason::Cancelled
FailureReason::Cancelled
} else {
StatusReason::SandboxInitFailed
FailureReason::SandboxInitFailed
};
let run_id = self.run_id;
let event_sink = self.event_sink.clone();
if let Ok(handle) = Handle::try_current() {
handle.spawn(async move {
let _ = append_event_to_sink(&event_sink, &run_id, &Event::WorkflowRunFailed {
error: Error::engine(format!("{reason:?}")),
duration_ms: 0,
reason: Some(reason),
error: Error::engine(reason.to_string()),
duration_ms: 0,
reason,
git_commit_sha: None,
final_patch: None,
final_patch: None,
})
.await;
});
@ -914,9 +912,9 @@ impl Drop for DetachedRunCompletionGuard {
.as_ref()
.is_some_and(|token| token.load(Ordering::SeqCst));
let reason = if cancelled {
StatusReason::Cancelled
FailureReason::Cancelled
} else {
StatusReason::WorkflowError
FailureReason::WorkflowError
};
let message = if cancelled {
POSTRUN_CANCELLED_MESSAGE
@ -933,11 +931,11 @@ impl Drop for DetachedRunCompletionGuard {
if let Ok(handle) = Handle::try_current() {
handle.spawn(async move {
let _ = append_event_to_sink(&event_sink, &run_id, &Event::WorkflowRunFailed {
error: Error::engine(message.to_string()),
duration_ms: 0,
reason: Some(reason),
error: Error::engine(message.to_string()),
duration_ms: 0,
reason,
git_commit_sha: None,
final_patch: None,
final_patch: None,
})
.await;
let _ = append_event_to_sink(&event_sink, &run_id, &Event::RunNotice {
@ -956,17 +954,17 @@ async fn persist_detached_failure(
event_sink: &RunEventSink,
_run_dir: &Path,
phase: &'static str,
reason: StatusReason,
reason: FailureReason,
error: &Error,
) -> Result<(), Error> {
let message = error.to_string();
if let Err(err) = append_event_to_sink(event_sink, &run_id, &Event::WorkflowRunFailed {
error: error.clone(),
duration_ms: 0,
reason: Some(reason),
error: error.clone(),
duration_ms: 0,
reason,
git_commit_sha: None,
final_patch: None,
final_patch: None,
})
.await
{
@ -1429,11 +1427,17 @@ mod tests {
})
.await
.unwrap();
crate::event::append_event(&run_store, &fixtures::RUN_1, &Event::RunStarting)
.await
.unwrap();
crate::event::append_event(&run_store, &fixtures::RUN_1, &Event::RunRunning)
.await
.unwrap();
crate::event::append_event(&run_store, &fixtures::RUN_1, &Event::WorkflowRunCompleted {
duration_ms: conclusion.duration_ms,
artifact_count: 0,
status: "success".to_string(),
reason: None,
reason: crate::run_status::SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,

View file

@ -32,7 +32,7 @@ use crate::pipeline::initialize;
use crate::pipeline::types::{InitOptions, LlmSpec, Persisted, SandboxEnvSpec};
use crate::records::RunSpec;
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::run_status::{RunStatus, StatusReason};
use crate::run_status::{FailureReason, RunStatus};
use crate::test_support::run_graph;
fn local_env() -> Arc<dyn Sandbox> {
@ -791,8 +791,9 @@ async fn execute_cancelled_mid_run_persists_cancelled_status() {
assert!(matches!(executed.outcome, Err(Error::Cancelled)));
let status = executed.run_store.state().await.unwrap().status.unwrap();
assert_eq!(status.status, RunStatus::Failed);
assert_eq!(status.status_reason, Some(StatusReason::Cancelled));
assert_eq!(status, RunStatus::Failed {
reason: FailureReason::Cancelled,
});
}
#[tokio::test]

View file

@ -11,7 +11,7 @@ use crate::outcome::{Outcome, OutcomeExt, StageStatus};
use crate::records::{Checkpoint, Conclusion, StageSummary};
use crate::run_dump::RunDump;
use crate::run_options::RunOptions;
use crate::run_status::{RunStatus, StatusReason};
use crate::run_status::{FailureReason, RunStatus, SuccessReason};
use crate::runtime_store::RunStoreHandle;
use crate::sandbox_git::git_push_host;
@ -30,35 +30,37 @@ fn emit_run_notice(
pub fn classify_engine_result(
engine_result: &Result<Outcome, Error>,
) -> (StageStatus, Option<String>, RunStatus, Option<StatusReason>) {
) -> (StageStatus, Option<String>, RunStatus) {
match engine_result {
Ok(outcome) => {
let status = outcome.status.clone();
let failure_reason = outcome.failure_reason().map(String::from);
let (run_status, status_reason) = match status {
StageStatus::Success | StageStatus::Skipped => {
(RunStatus::Succeeded, Some(StatusReason::Completed))
}
StageStatus::PartialSuccess => {
(RunStatus::Succeeded, Some(StatusReason::PartialSuccess))
}
StageStatus::Fail | StageStatus::Retry => {
(RunStatus::Failed, Some(StatusReason::WorkflowError))
}
let run_status = match status {
StageStatus::Success | StageStatus::Skipped => RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
StageStatus::PartialSuccess => RunStatus::Succeeded {
reason: SuccessReason::PartialSuccess,
},
StageStatus::Fail | StageStatus::Retry => RunStatus::Failed {
reason: FailureReason::WorkflowError,
},
};
(status, failure_reason, run_status, status_reason)
(status, failure_reason, run_status)
}
Err(Error::Cancelled) => (
StageStatus::Fail,
Some("Cancelled".to_string()),
RunStatus::Failed,
Some(StatusReason::Cancelled),
RunStatus::Failed {
reason: FailureReason::Cancelled,
},
),
Err(err) => (
StageStatus::Fail,
Some(err.to_string()),
RunStatus::Failed,
Some(StatusReason::WorkflowError),
RunStatus::Failed {
reason: FailureReason::WorkflowError,
},
),
}
}
@ -235,8 +237,7 @@ pub async fn finalize(retroed: Retroed, options: &FinalizeOptions) -> Result<Con
retro: _,
} = retroed;
let (final_status, failure_reason, _run_status, _status_reason) =
classify_engine_result(&outcome);
let (final_status, failure_reason, _run_status) = classify_engine_result(&outcome);
let conclusion = build_conclusion_from_store(
&options.run_store,
final_status,

View file

@ -597,7 +597,7 @@ mod tests {
};
use fabro_store::Database;
use fabro_types::settings::SettingsLayer;
use fabro_types::{BilledTokenCounts, RunSpec, fixtures};
use fabro_types::{BilledTokenCounts, RunSpec, SuccessReason, fixtures};
use futures::stream;
use object_store::memory::InMemory;
@ -1406,7 +1406,7 @@ mod tests {
duration_ms: 1,
artifact_count: 0,
status: "success".to_string(),
reason: None,
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: Some(

View file

@ -429,8 +429,8 @@ mod tests {
use fabro_types::run::RunSpec;
use fabro_types::settings::SettingsLayer;
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, RunStatus, RunStatusRecord, SandboxRecord,
StageStatus, StartRecord, fixtures,
Checkpoint, Conclusion, NodeStatusRecord, RunStatus, SandboxRecord, StageStatus,
StartRecord, SuccessReason, fixtures,
};
use super::RunDump;
@ -487,7 +487,9 @@ mod tests {
run_branch: Some("fabro/run/demo".to_string()),
base_sha: Some("deadbeef".to_string()),
});
projection.status = Some(RunStatusRecord::new(RunStatus::Succeeded, None));
projection.status = Some(RunStatus::Succeeded {
reason: SuccessReason::Completed,
});
projection.checkpoint = Some(sample_checkpoint());
projection.conclusion = Some(Conclusion {
timestamp: Utc

View file

@ -16,7 +16,7 @@ use fabro_types::{RunId, RunSummary};
use serde::Serialize;
use crate::operations::make_run_dir;
use crate::run_status::{RunStatus, StatusReason};
use crate::run_status::RunStatus;
#[derive(Debug, Clone)]
struct RunLocalState {
@ -81,10 +81,12 @@ impl RunInfo {
.map_or(RunStatus::Submitted, |summary| summary.status)
}
pub fn status_reason(&self) -> Option<StatusReason> {
self.summary
.as_ref()
.and_then(|summary| summary.status_reason)
pub fn status_reason(&self) -> Option<String> {
match self.status() {
RunStatus::Succeeded { reason } => Some(reason.to_string()),
RunStatus::Failed { reason } => Some(reason.to_string()),
_ => None,
}
}
pub fn start_time(&self) -> String {
@ -464,7 +466,6 @@ mod tests {
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunSubmitted {
reason: None,
definition_blob: None,
})
.await

View file

@ -1 +1 @@
pub use fabro_types::status::{RunStatus, RunStatusRecord, StatusReason};
pub use fabro_types::status::{FailureReason, RunStatus, SuccessReason, TerminalStatus};

View file

@ -198,7 +198,6 @@ mod tests {
tool_call_id: None,
actor: None,
body: EventBody::RunSubmitted(RunSubmittedProps {
reason: None,
definition_blob: None,
}),
};

View file

@ -65,6 +65,7 @@ models/event-seq.ts
models/execute-query-request.ts
models/execute-query-response-rows-inner-inner.ts
models/execute-query-response.ts
models/failure-reason.ts
models/features-namespace.ts
models/file-checkpoint.ts
models/file-diff.ts
@ -88,6 +89,9 @@ models/install-llm-summary-providers-inner.ts
models/install-llm-summary.ts
models/install-llm-test-input.ts
models/install-llm-validation-response.ts
models/install-object-store-input.ts
models/install-object-store-summary.ts
models/install-object-store-validation-response.ts
models/install-prefill.ts
models/install-server-config-input.ts
models/install-session-response.ts
@ -167,8 +171,18 @@ models/run-question.ts
models/run-reference.ts
models/run-sandbox.ts
models/run-stage.ts
models/run-status-record.ts
models/run-status-archived.ts
models/run-status-blocked.ts
models/run-status-dead.ts
models/run-status-failed.ts
models/run-status-paused.ts
models/run-status-queued.ts
models/run-status-removing.ts
models/run-status-response.ts
models/run-status-running.ts
models/run-status-starting.ts
models/run-status-submitted.ts
models/run-status-succeeded.ts
models/run-status.ts
models/run-timings.ts
models/sandbox-file-entry.ts
@ -203,14 +217,15 @@ models/ssh-access-response.ts
models/stage-status.ts
models/stage-turn.ts
models/start-run-request.ts
models/status-reason.ts
models/store-run-summary.ts
models/submit-answer-request.ts
models/success-reason.ts
models/system-features.ts
models/system-info-response.ts
models/system-run-counts.ts
models/system-stage-turn.ts
models/teams-integration-settings.ts
models/terminal-status.ts
models/tool-stage-turn.ts
models/tool-use.ts
models/user-response.ts

View file

@ -42,6 +42,10 @@ import type { InstallLlmTestInput } from '../models';
// @ts-ignore
import type { InstallLlmValidationResponse } from '../models';
// @ts-ignore
import type { InstallObjectStoreInput } from '../models';
// @ts-ignore
import type { InstallObjectStoreValidationResponse } from '../models';
// @ts-ignore
import type { InstallServerConfigInput } from '../models';
// @ts-ignore
import type { InstallSessionResponse } from '../models';
@ -259,6 +263,41 @@ export const InstallApiAxiosParamCreator = function (configuration?: Configurati
options: localVarRequestOptions,
};
},
/**
* Records the object-store mode selected during browser install. Requires the one-time install token.
* @summary Save install object-store configuration
* @param {InstallObjectStoreInput} installObjectStoreInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
putInstallObjectStore: async (installObjectStoreInput: InstallObjectStoreInput, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'installObjectStoreInput' is not null or undefined
assertParamExists('putInstallObjectStore', 'installObjectStoreInput', installObjectStoreInput)
const localVarPath = `/install/object-store`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(installObjectStoreInput, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
* @summary Save install server configuration
@ -359,6 +398,41 @@ export const InstallApiAxiosParamCreator = function (configuration?: Configurati
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(installLlmTestInput, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
* @summary Validate install object-store configuration
* @param {InstallObjectStoreInput} installObjectStoreInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
testInstallObjectStore: async (installObjectStoreInput: InstallObjectStoreInput, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'installObjectStoreInput' is not null or undefined
assertParamExists('testInstallObjectStore', 'installObjectStoreInput', installObjectStoreInput)
const localVarPath = `/install/object-store/test`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(installObjectStoreInput, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@ -450,6 +524,19 @@ export const InstallApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['InstallApi.putInstallLlm']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Records the object-store mode selected during browser install. Requires the one-time install token.
* @summary Save install object-store configuration
* @param {InstallObjectStoreInput} installObjectStoreInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async putInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.putInstallObjectStore(installObjectStoreInput, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['InstallApi.putInstallObjectStore']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
* @summary Save install server configuration
@ -489,6 +576,19 @@ export const InstallApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['InstallApi.testInstallLlmCredentials']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
* @summary Validate install object-store configuration
* @param {InstallObjectStoreInput} installObjectStoreInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async testInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<InstallObjectStoreValidationResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.testInstallObjectStore(installObjectStoreInput, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['InstallApi.testInstallObjectStore']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
@ -557,6 +657,16 @@ export const InstallApiFactory = function (configuration?: Configuration, basePa
putInstallLlm(installLlmProvidersInput: InstallLlmProvidersInput, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.putInstallLlm(installLlmProvidersInput, options).then((request) => request(axios, basePath));
},
/**
* Records the object-store mode selected during browser install. Requires the one-time install token.
* @summary Save install object-store configuration
* @param {InstallObjectStoreInput} installObjectStoreInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
putInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.putInstallObjectStore(installObjectStoreInput, options).then((request) => request(axios, basePath));
},
/**
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
* @summary Save install server configuration
@ -587,6 +697,16 @@ export const InstallApiFactory = function (configuration?: Configuration, basePa
testInstallLlmCredentials(installLlmTestInput: InstallLlmTestInput, options?: RawAxiosRequestConfig): AxiosPromise<InstallLlmValidationResponse> {
return localVarFp.testInstallLlmCredentials(installLlmTestInput, options).then((request) => request(axios, basePath));
},
/**
* Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
* @summary Validate install object-store configuration
* @param {InstallObjectStoreInput} installObjectStoreInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
testInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): AxiosPromise<InstallObjectStoreValidationResponse> {
return localVarFp.testInstallObjectStore(installObjectStoreInput, options).then((request) => request(axios, basePath));
},
};
};
@ -659,6 +779,17 @@ export class InstallApi extends BaseAPI {
return InstallApiFp(this.configuration).putInstallLlm(installLlmProvidersInput, options).then((request) => request(this.axios, this.basePath));
}
/**
* Records the object-store mode selected during browser install. Requires the one-time install token.
* @summary Save install object-store configuration
* @param {InstallObjectStoreInput} installObjectStoreInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public putInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig) {
return InstallApiFp(this.configuration).putInstallObjectStore(installObjectStoreInput, options).then((request) => request(this.axios, this.basePath));
}
/**
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
* @summary Save install server configuration
@ -691,5 +822,16 @@ export class InstallApi extends BaseAPI {
public testInstallLlmCredentials(installLlmTestInput: InstallLlmTestInput, options?: RawAxiosRequestConfig) {
return InstallApiFp(this.configuration).testInstallLlmCredentials(installLlmTestInput, options).then((request) => request(this.axios, this.basePath));
}
/**
* Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
* @summary Validate install object-store configuration
* @param {InstallObjectStoreInput} installObjectStoreInput
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public testInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig) {
return InstallApiFp(this.configuration).testInstallObjectStore(installObjectStoreInput, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -25,5 +25,13 @@ export interface ErrorResponse {
* List of error entries.
*/
'errors': Array<ErrorResponseEntry>;
/**
* Optional list of runtime env keys that were written before an install failure. Currently populated by `POST /install/finish` failure responses only.
*/
'leftover_env_keys'?: Array<string>;
/**
* Optional list of runtime env keys that were actually removed before an install failure. Currently populated by `POST /install/finish` failure responses only.
*/
'removed_env_keys'?: Array<string>;
}

View file

@ -15,12 +15,10 @@
/**
* Optional reason attached to a run status transition.
* Reason attached to a failed terminal run status.
*/
export const StatusReason = {
COMPLETED: 'completed',
PARTIAL_SUCCESS: 'partial_success',
export const FailureReason = {
WORKFLOW_ERROR: 'workflow_error',
CANCELLED: 'cancelled',
TERMINATED: 'terminated',
@ -28,11 +26,10 @@ export const StatusReason = {
BUDGET_EXHAUSTED: 'budget_exhausted',
LAUNCH_FAILED: 'launch_failed',
BOOTSTRAP_FAILED: 'bootstrap_failed',
SANDBOX_INIT_FAILED: 'sandbox_init_failed',
SANDBOX_INITIALIZING: 'sandbox_initializing'
SANDBOX_INIT_FAILED: 'sandbox_init_failed'
} as const;
export type StatusReason = typeof StatusReason[keyof typeof StatusReason];
export type FailureReason = typeof FailureReason[keyof typeof FailureReason];

View file

@ -45,6 +45,7 @@ export * from './event-seq';
export * from './execute-query-request';
export * from './execute-query-response';
export * from './execute-query-response-rows-inner-inner';
export * from './failure-reason';
export * from './features-namespace';
export * from './file-checkpoint';
export * from './file-diff';
@ -67,6 +68,9 @@ export * from './install-llm-summary';
export * from './install-llm-summary-providers-inner';
export * from './install-llm-test-input';
export * from './install-llm-validation-response';
export * from './install-object-store-input';
export * from './install-object-store-summary';
export * from './install-object-store-validation-response';
export * from './install-prefill';
export * from './install-server-config-input';
export * from './install-session-response';
@ -147,8 +151,18 @@ export * from './run-reference';
export * from './run-sandbox';
export * from './run-stage';
export * from './run-status';
export * from './run-status-record';
export * from './run-status-archived';
export * from './run-status-blocked';
export * from './run-status-dead';
export * from './run-status-failed';
export * from './run-status-paused';
export * from './run-status-queued';
export * from './run-status-removing';
export * from './run-status-response';
export * from './run-status-running';
export * from './run-status-starting';
export * from './run-status-submitted';
export * from './run-status-succeeded';
export * from './run-timings';
export * from './sandbox-file-entry';
export * from './sandbox-file-list-response';
@ -182,14 +196,15 @@ export * from './ssh-access-response';
export * from './stage-status';
export * from './stage-turn';
export * from './start-run-request';
export * from './status-reason';
export * from './store-run-summary';
export * from './submit-answer-request';
export * from './success-reason';
export * from './system-features';
export * from './system-info-response';
export * from './system-run-counts';
export * from './system-stage-turn';
export * from './teams-integration-settings';
export * from './terminal-status';
export * from './tool-stage-turn';
export * from './tool-use';
export * from './user-response';

View file

@ -0,0 +1,42 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Object-store mode selected during browser install.
*/
export interface InstallObjectStoreInput {
'provider': InstallObjectStoreInputProviderEnum;
'bucket'?: string;
'region'?: string;
'credential_mode'?: InstallObjectStoreInputCredentialModeEnum;
'access_key_id'?: string;
'secret_access_key'?: string;
}
export const InstallObjectStoreInputProviderEnum = {
LOCAL: 'local',
S3: 's3'
} as const;
export type InstallObjectStoreInputProviderEnum = typeof InstallObjectStoreInputProviderEnum[keyof typeof InstallObjectStoreInputProviderEnum];
export const InstallObjectStoreInputCredentialModeEnum = {
RUNTIME: 'runtime',
ACCESS_KEY: 'access_key'
} as const;
export type InstallObjectStoreInputCredentialModeEnum = typeof InstallObjectStoreInputCredentialModeEnum[keyof typeof InstallObjectStoreInputCredentialModeEnum];

View file

@ -0,0 +1,41 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Redacted summary of the object-store mode selected during browser install.
*/
export interface InstallObjectStoreSummary {
'provider': InstallObjectStoreSummaryProviderEnum;
'bucket'?: string;
'region'?: string;
'credential_mode'?: InstallObjectStoreSummaryCredentialModeEnum;
'manual_credentials_saved'?: boolean;
}
export const InstallObjectStoreSummaryProviderEnum = {
LOCAL: 'local',
S3: 's3'
} as const;
export type InstallObjectStoreSummaryProviderEnum = typeof InstallObjectStoreSummaryProviderEnum[keyof typeof InstallObjectStoreSummaryProviderEnum];
export const InstallObjectStoreSummaryCredentialModeEnum = {
RUNTIME: 'runtime',
ACCESS_KEY: 'access_key'
} as const;
export type InstallObjectStoreSummaryCredentialModeEnum = typeof InstallObjectStoreSummaryCredentialModeEnum[keyof typeof InstallObjectStoreSummaryCredentialModeEnum];

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Successful response from install-time object-store validation.
*/
export interface InstallObjectStoreValidationResponse {
'ok': boolean;
}

View file

@ -21,6 +21,9 @@ import type { InstallGithubSummary } from './install-github-summary';
import type { InstallLlmSummary } from './install-llm-summary';
// May contain unused imports in some cases
// @ts-ignore
import type { InstallObjectStoreSummary } from './install-object-store-summary';
// May contain unused imports in some cases
// @ts-ignore
import type { InstallPrefill } from './install-prefill';
// May contain unused imports in some cases
// @ts-ignore
@ -33,6 +36,7 @@ export interface InstallSessionResponse {
'completed_steps': Array<string>;
'llm'?: InstallLlmSummary | null;
'server'?: InstallServerConfigInput | null;
'object_store'?: InstallObjectStoreSummary | null;
'github'?: InstallGithubSummary | null;
'prefill': InstallPrefill;
}

View file

@ -33,7 +33,7 @@ import type { RunQuestion } from './run-question';
import type { RunSandbox } from './run-sandbox';
// May contain unused imports in some cases
// @ts-ignore
import type { StatusReason } from './status-reason';
import type { RunStatus } from './run-status';
/**
* Canonical run summary shown in the board view, extended with board-specific metadata.
@ -51,11 +51,10 @@ export interface RunListItem {
* Human-readable title describing the run\'s goal.
*/
'title': string;
'status': string;
'status': RunStatus;
'labels': { [key: string]: string; };
'host_repo_path'?: string | null;
'start_time'?: string | null;
'status_reason'?: StatusReason | null;
'pending_control'?: RunControlAction | null;
'duration_ms'?: number | null;
'elapsed_secs'?: number | null;

View file

@ -27,7 +27,7 @@ import type { RunCheckpoint } from './run-checkpoint';
import type { RunProjectionCheckpointsInnerInner } from './run-projection-checkpoints-inner-inner';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatusRecord } from './run-status-record';
import type { RunStatus } from './run-status';
/**
* Raw internal run projection derived from the event log.
@ -36,7 +36,8 @@ export interface RunProjection {
'spec'?: { [key: string]: any; } | null;
'graph_source'?: string | null;
'start'?: { [key: string]: any; } | null;
'status'?: RunStatusRecord | null;
'status'?: RunStatus | null;
'status_updated_at'?: string | null;
'checkpoint'?: RunCheckpoint | null;
/**
* Sequence-tagged checkpoint history entries as `[seq, checkpoint]`.

View file

@ -0,0 +1,31 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { TerminalStatus } from './terminal-status';
export interface RunStatusArchived {
'kind': RunStatusArchivedKindEnum;
'prior': TerminalStatus;
}
export const RunStatusArchivedKindEnum = {
ARCHIVED: 'archived'
} as const;
export type RunStatusArchivedKindEnum = typeof RunStatusArchivedKindEnum[keyof typeof RunStatusArchivedKindEnum];

View file

@ -16,22 +16,16 @@
// May contain unused imports in some cases
// @ts-ignore
import type { BlockedReason } from './blocked-reason';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatus } from './run-status';
// May contain unused imports in some cases
// @ts-ignore
import type { StatusReason } from './status-reason';
/**
* Internal run status record from the event projection.
*/
export interface RunStatusRecord {
'status': RunStatus;
'status_reason'?: StatusReason | null;
'blocked_reason'?: BlockedReason | null;
'updated_at': string;
export interface RunStatusBlocked {
'kind': RunStatusBlockedKindEnum;
'blocked_reason': BlockedReason;
}
export const RunStatusBlockedKindEnum = {
BLOCKED: 'blocked'
} as const;
export type RunStatusBlockedKindEnum = typeof RunStatusBlockedKindEnum[keyof typeof RunStatusBlockedKindEnum];

Some files were not shown because too many files have changed in this diff Show more