mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
Merge remote-tracking branch 'origin/main' into refactor/remove-retro-stage
# Conflicts: # Cargo.lock # docs/public/agents/outputs.mdx # docs/public/execution/checkpoints.mdx # docs/public/reference/run-directory.mdx # lib/crates/fabro-retro/src/retro_agent.rs # lib/crates/fabro-types/src/run_projection.rs # lib/crates/fabro-workflow/src/pipeline/retro.rs
This commit is contained in:
commit
d40a5b71d5
216 changed files with 11238 additions and 2885 deletions
|
|
@ -1 +1 @@
|
|||
5a9f568e4630751d4ebfa06595bced73f07eb24e
|
||||
9a3ab8bbba2e71d72ff7703a68e9892e8ce94d8d
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
588f546b6c54006bc1e634f0510d32f6d89cf7c3
|
||||
2f10ee39afe6bd9a1c1df987da52fb82c91b422e
|
||||
|
|
|
|||
60
.fabro/workflows/interview/workflow.fabro
Normal file
60
.fabro/workflows/interview/workflow.fabro
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
digraph Interview {
|
||||
graph [goal="Run a progressive human interview and summarize the answers"]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
yes_no [
|
||||
shape=hexagon,
|
||||
label="Is this interview workflow easy to follow so far?",
|
||||
question_type="yes_no"
|
||||
]
|
||||
confirmation [
|
||||
shape=hexagon,
|
||||
label="Confirm that you want to continue into more structured questions.",
|
||||
question_type="confirmation"
|
||||
]
|
||||
multiple_choice [
|
||||
shape=hexagon,
|
||||
label="Which theme should be the center of the final summary?",
|
||||
question_type="multiple_choice"
|
||||
]
|
||||
multi_select [
|
||||
shape=hexagon,
|
||||
label="Which supporting areas should the final summary emphasize?",
|
||||
question_type="multi_select"
|
||||
]
|
||||
freeform [
|
||||
shape=hexagon,
|
||||
label="Add any final context, constraints, or nuance for the summary.",
|
||||
question_type="freeform"
|
||||
]
|
||||
summarize [
|
||||
shape=tab,
|
||||
label="Summarize Interview",
|
||||
fidelity="summary:high",
|
||||
prompt="Summarize the full human interview. Include each question and answer in order, then synthesize the user's priorities, constraints, and open questions. Use the human.gate.<node>.question and human.gate.<node>.answer context keys when present. Do not invent missing answers."
|
||||
]
|
||||
|
||||
start -> yes_no
|
||||
|
||||
yes_no -> confirmation [label="[Y] Yes"]
|
||||
yes_no -> confirmation [label="[N] No"]
|
||||
|
||||
confirmation -> multiple_choice [label="[Y] Continue"]
|
||||
confirmation -> multiple_choice [label="[N] Stop after this, but still summarize"]
|
||||
|
||||
multiple_choice -> multi_select [label="[G] Goals"]
|
||||
multiple_choice -> multi_select [label="[R] Risks"]
|
||||
multiple_choice -> multi_select [label="[T] Tradeoffs"]
|
||||
multiple_choice -> multi_select [label="[Q] Open questions"]
|
||||
|
||||
multi_select -> freeform [label="[S] Success criteria"]
|
||||
multi_select -> freeform [label="[B] Blockers"]
|
||||
multi_select -> freeform [label="[D] Decisions needed"]
|
||||
multi_select -> freeform [label="[N] Next steps"]
|
||||
|
||||
freeform -> summarize [freeform=true]
|
||||
summarize -> exit
|
||||
}
|
||||
1
.fabro/workflows/interview/workflow.toml
Normal file
1
.fabro/workflows/interview/workflow.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
_version = 1
|
||||
11
.fabro/workflows/sleeper/workflow.fabro
Normal file
11
.fabro/workflows/sleeper/workflow.fabro
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
digraph Sleeper {
|
||||
graph [goal="Sleep long enough for a human to practice interrupts and steering"]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
nap [label="Nap", timeout="360s", prompt="Run exactly one shell command using the Bash tool: `sleep 300`, with the tool `timeout_ms` parameter set to 310000 or higher. Wait for it to return. Do not run any other commands, do not read or edit any files, do not check the environment first. After `sleep 300` returns, reply with the single word 'done' and stop."]
|
||||
|
||||
start -> nap -> exit
|
||||
}
|
||||
1
.fabro/workflows/sleeper/workflow.toml
Normal file
1
.fabro/workflows/sleeper/workflow.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
_version = 1
|
||||
85
Cargo.lock
generated
85
Cargo.lock
generated
|
|
@ -1536,7 +1536,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -1575,7 +1575,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -1596,7 +1596,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -1620,7 +1620,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -1636,7 +1636,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -1697,6 +1697,7 @@ dependencies = [
|
|||
"jsonwebtoken",
|
||||
"libc",
|
||||
"miette",
|
||||
"nix 0.30.1",
|
||||
"object_store",
|
||||
"openssl",
|
||||
"paste",
|
||||
|
|
@ -1731,7 +1732,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1760,7 +1761,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -1787,7 +1788,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
|
|
@ -1802,7 +1803,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -1821,7 +1822,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-devcontainer"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-static",
|
||||
|
|
@ -1838,7 +1839,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1852,7 +1853,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1874,7 +1875,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -1888,7 +1889,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -1912,7 +1913,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http",
|
||||
|
|
@ -1922,7 +1923,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1937,7 +1938,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -1952,7 +1953,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -1984,7 +1985,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -1995,7 +1996,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-config",
|
||||
|
|
@ -2011,7 +2012,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"insta",
|
||||
|
|
@ -2022,7 +2023,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2044,7 +2045,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2052,7 +2053,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2061,7 +2062,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2077,7 +2078,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2117,7 +2118,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2197,7 +2198,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -2218,18 +2219,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -2256,7 +2257,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2282,7 +2283,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-util",
|
||||
|
|
@ -2294,7 +2295,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"assert_cmd",
|
||||
"axum",
|
||||
|
|
@ -2317,7 +2318,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2331,7 +2332,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -2352,7 +2353,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -2372,7 +2373,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-graphviz",
|
||||
"fabro-model",
|
||||
|
|
@ -2382,7 +2383,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-types",
|
||||
|
|
@ -2394,7 +2395,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -7097,7 +7098,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -7116,7 +7117,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.225.0-nightly.0"
|
||||
version = "0.228.0-nightly.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
289
apps/fabro-web/app/components/event-debug.tsx
Normal file
289
apps/fabro-web/app/components/event-debug.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpDownIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from "@heroicons/react/16/solid";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { Tooltip } from "./ui";
|
||||
import { formatAbsoluteTs } from "../lib/format";
|
||||
|
||||
const DEBUG_CATEGORY_TONE: Record<string, string> = {
|
||||
agent: "bg-teal-500/15 text-teal-500",
|
||||
command: "bg-mint/15 text-mint",
|
||||
interview: "bg-coral/15 text-coral",
|
||||
run: "bg-overlay-strong text-fg-2",
|
||||
stage: "bg-amber/15 text-amber",
|
||||
tool: "bg-mint/15 text-mint",
|
||||
};
|
||||
|
||||
export function debugCategory(eventName: string): string {
|
||||
const dot = eventName.indexOf(".");
|
||||
return dot < 0 ? eventName : eventName.slice(0, dot);
|
||||
}
|
||||
|
||||
export function debugCategoryLabel(category: string): string {
|
||||
if (!category) return "Other";
|
||||
return category.charAt(0).toUpperCase() + category.slice(1);
|
||||
}
|
||||
|
||||
export function debugCategoryTone(category: string): string {
|
||||
return DEBUG_CATEGORY_TONE[category] ?? "bg-overlay text-fg-muted";
|
||||
}
|
||||
|
||||
export function formatElapsed(eventTs: string, runStart: string | undefined): string {
|
||||
if (!runStart) return "";
|
||||
const startMs = Date.parse(runStart);
|
||||
const eventMs = Date.parse(eventTs);
|
||||
if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return "";
|
||||
const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000));
|
||||
const hours = Math.floor(delta / 3600);
|
||||
const minutes = Math.floor((delta % 3600) / 60);
|
||||
const seconds = delta % 60;
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const JSON_TOKEN_RE =
|
||||
/"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g;
|
||||
|
||||
export function highlightJson(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let key = 0;
|
||||
JSON_TOKEN_RE.lastIndex = 0;
|
||||
while ((match = JSON_TOKEN_RE.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const token = match[0];
|
||||
let cls: string;
|
||||
if (token.startsWith('"')) {
|
||||
const after = text.slice(JSON_TOKEN_RE.lastIndex);
|
||||
cls = /^\s*:/.test(after) ? "text-teal-300" : "text-mint";
|
||||
} else if (token === "true" || token === "false") {
|
||||
cls = "text-coral";
|
||||
} else if (token === "null") {
|
||||
cls = "text-fg-muted";
|
||||
} else {
|
||||
cls = "text-amber";
|
||||
}
|
||||
parts.push(
|
||||
<span key={key++} className={cls}>
|
||||
{token}
|
||||
</span>,
|
||||
);
|
||||
lastIndex = JSON_TOKEN_RE.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function DebugEventRow({
|
||||
event,
|
||||
runStart,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
event: EventEnvelope;
|
||||
runStart: string | undefined;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const eventName = event.event ?? "";
|
||||
const category = debugCategory(eventName);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={selected}
|
||||
className={`grid w-full grid-cols-[5rem_1fr_auto] items-center gap-4 px-5 py-2.5 text-left transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500 ${
|
||||
selected ? "bg-overlay" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${debugCategoryTone(category)}`}
|
||||
>
|
||||
{debugCategoryLabel(category)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-fg-2">
|
||||
{eventName}
|
||||
</span>
|
||||
<Tooltip label={formatAbsoluteTs(event.ts)}>
|
||||
<span className="font-mono text-xs tabular-nums text-fg-muted">
|
||||
{formatElapsed(event.ts, runStart)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({
|
||||
title,
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative shrink-0 self-stretch overflow-hidden transition-[width] duration-200 ease-out ${
|
||||
isOpen ? "w-[28rem]" : "w-0"
|
||||
}`}
|
||||
aria-hidden={isOpen ? undefined : true}
|
||||
>
|
||||
<div className="absolute inset-y-0 right-0 flex w-[28rem] flex-col border-l border-line bg-panel">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-line px-5 py-3">
|
||||
<h2 className="text-sm font-medium text-fg">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-4 pb-[calc(1rem+var(--fabro-interview-dock-clearance,0px))]">
|
||||
{isOpen ? children : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugEventDetailsPanel({
|
||||
event,
|
||||
onClose,
|
||||
}: {
|
||||
event: EventEnvelope | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DetailsPanel
|
||||
title={event?.event ?? ""}
|
||||
isOpen={event != null}
|
||||
onClose={onClose}
|
||||
>
|
||||
{event ? <DebugEventDetails event={event} /> : null}
|
||||
</DetailsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugEventDetails({ event }: { event: EventEnvelope }) {
|
||||
const text = useMemo(() => JSON.stringify(event, null, 2), [event]);
|
||||
const tokens = useMemo(() => highlightJson(text), [text]);
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-overlay-strong p-3 font-mono text-xs leading-relaxed text-fg-3">
|
||||
{tokens}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectFilter<T extends string>({
|
||||
selected,
|
||||
options,
|
||||
labelOf,
|
||||
onChange,
|
||||
emptyMeansAll = false,
|
||||
}: {
|
||||
selected: T[];
|
||||
options: readonly T[];
|
||||
labelOf: (item: T) => string;
|
||||
onChange: (next: T[]) => void;
|
||||
emptyMeansAll?: boolean;
|
||||
}) {
|
||||
const allSelected = selected.length === options.length;
|
||||
const summary = useMemo(() => {
|
||||
if (allSelected || (emptyMeansAll && selected.length === 0)) return "All types";
|
||||
if (selected.length === 0) return "No types";
|
||||
if (selected.length <= 2) {
|
||||
return options
|
||||
.filter((o) => selected.includes(o))
|
||||
.map(labelOf)
|
||||
.join(", ");
|
||||
}
|
||||
return `${selected.length} types`;
|
||||
}, [allSelected, emptyMeansAll, selected, options, labelOf]);
|
||||
|
||||
return (
|
||||
<Listbox value={selected} onChange={onChange} multiple>
|
||||
<ListboxButton className="inline-flex items-center gap-2 rounded-md bg-panel px-2.5 py-1.5 text-xs text-fg-2 outline-1 -outline-offset-1 outline-line-strong transition-colors hover:bg-overlay-strong focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500">
|
||||
<FunnelIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
|
||||
<span className="tabular-nums">{summary}</span>
|
||||
<ChevronUpDownIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions
|
||||
transition
|
||||
anchor={{ to: "bottom start", gap: 4 }}
|
||||
className="z-20 w-44 rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<ListboxOption
|
||||
key={option}
|
||||
value={option}
|
||||
className="group flex cursor-pointer items-center gap-2.5 px-3 py-1.5 text-xs text-fg-3 data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden"
|
||||
>
|
||||
<span className="flex size-3.5 items-center justify-center rounded-sm border border-line-strong bg-panel-alt group-data-selected:border-teal-500 group-data-selected:bg-teal-500">
|
||||
<CheckIcon
|
||||
className="size-2.5 text-on-primary opacity-0 group-data-selected:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span>{labelOf(option)}</span>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full max-w-sm min-w-48 flex-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
name="event-search"
|
||||
aria-label="Search events"
|
||||
placeholder="Search events"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="block w-full rounded-md bg-panel py-1.5 pl-8 pr-2.5 text-xs text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 max-sm:text-base/5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
apps/fabro-web/app/components/icons.tsx
Normal file
7
apps/fabro-web/app/components/icons.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function GitPullRequestIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className={className} aria-hidden="true">
|
||||
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
110
apps/fabro-web/app/components/inline-markdown.test.tsx
Normal file
110
apps/fabro-web/app/components/inline-markdown.test.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
|
||||
import { InlineMarkdown } from "./inline-markdown";
|
||||
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
|
||||
async function render(content: string, className?: string) {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: TestRenderer.ReactTestRenderer | undefined;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<InlineMarkdown content={content} className={className} />,
|
||||
);
|
||||
});
|
||||
mountedRenderers.push(renderer!);
|
||||
return renderer!;
|
||||
}
|
||||
|
||||
function textFromInstance(node: TestRenderer.ReactTestInstance): string {
|
||||
return node.children
|
||||
.map((child) => (typeof child === "string" ? child : textFromInstance(child)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("InlineMarkdown", () => {
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
for (const renderer of mountedRenderers.splice(0)) {
|
||||
renderer.unmount();
|
||||
}
|
||||
});
|
||||
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
});
|
||||
|
||||
test("renders backtick spans as <code> without visible backticks", async () => {
|
||||
const renderer = await render(
|
||||
"Move from `[server.integrations.github]` to `[run.integrations.github]`",
|
||||
);
|
||||
|
||||
const codes = renderer.root.findAllByType("code");
|
||||
expect(codes).toHaveLength(2);
|
||||
expect(textFromInstance(codes[0]!)).toBe("[server.integrations.github]");
|
||||
expect(textFromInstance(codes[1]!)).toBe("[run.integrations.github]");
|
||||
|
||||
const fullText = textFromInstance(renderer.root);
|
||||
expect(fullText).not.toContain("`");
|
||||
});
|
||||
|
||||
test("renders **bold** as <strong> and _italic_ as <em>", async () => {
|
||||
const renderer = await render("**bold** and _italic_");
|
||||
|
||||
const strongs = renderer.root.findAllByType("strong");
|
||||
expect(strongs).toHaveLength(1);
|
||||
expect(textFromInstance(strongs[0]!)).toBe("bold");
|
||||
|
||||
const ems = renderer.root.findAllByType("em");
|
||||
expect(ems).toHaveLength(1);
|
||||
expect(textFromInstance(ems[0]!)).toBe("italic");
|
||||
});
|
||||
|
||||
test("renders *italic* (asterisk form) as <em>", async () => {
|
||||
const renderer = await render("*italic*");
|
||||
const ems = renderer.root.findAllByType("em");
|
||||
expect(ems).toHaveLength(1);
|
||||
expect(textFromInstance(ems[0]!)).toBe("italic");
|
||||
});
|
||||
|
||||
test("block markdown like headings, lists, blockquotes stays text", async () => {
|
||||
const renderer = await render("# heading - item > quote");
|
||||
|
||||
expect(renderer.root.findAllByType("h1")).toHaveLength(0);
|
||||
expect(renderer.root.findAllByType("h2")).toHaveLength(0);
|
||||
expect(renderer.root.findAllByType("ul")).toHaveLength(0);
|
||||
expect(renderer.root.findAllByType("li")).toHaveLength(0);
|
||||
expect(renderer.root.findAllByType("blockquote")).toHaveLength(0);
|
||||
|
||||
expect(textFromInstance(renderer.root)).toContain("# heading");
|
||||
expect(textFromInstance(renderer.root)).toContain("> quote");
|
||||
});
|
||||
|
||||
test("link syntax renders the label as text without an <a>", async () => {
|
||||
const renderer = await render("[label](javascript:alert(1))");
|
||||
|
||||
expect(renderer.root.findAllByType("a")).toHaveLength(0);
|
||||
expect(textFromInstance(renderer.root)).toContain("label");
|
||||
expect(textFromInstance(renderer.root)).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
test("image syntax renders the alt text without an <img>", async () => {
|
||||
const renderer = await render("");
|
||||
|
||||
expect(renderer.root.findAllByType("img")).toHaveLength(0);
|
||||
expect(textFromInstance(renderer.root)).toContain("alt");
|
||||
});
|
||||
|
||||
test("raw HTML is shown literally as text, not interpreted", async () => {
|
||||
const renderer = await render("<script>x</script>");
|
||||
|
||||
expect(renderer.root.findAllByType("script")).toHaveLength(0);
|
||||
expect(textFromInstance(renderer.root)).toContain("<script>");
|
||||
expect(textFromInstance(renderer.root)).toContain("</script>");
|
||||
});
|
||||
|
||||
test("applies a className to the wrapper span", async () => {
|
||||
const renderer = await render("hello", "text-fg-2");
|
||||
const wrapper = renderer.root.findByType("span");
|
||||
expect(wrapper.props.className).toBe("text-fg-2");
|
||||
});
|
||||
});
|
||||
51
apps/fabro-web/app/components/inline-markdown.tsx
Normal file
51
apps/fabro-web/app/components/inline-markdown.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Fragment, useMemo, type ReactNode } from "react";
|
||||
import { Lexer, type Token } from "marked";
|
||||
|
||||
const CODE_CLASSNAME =
|
||||
"rounded bg-overlay-strong px-1 py-0.5 font-mono text-[0.85em] text-fg-2";
|
||||
|
||||
function renderTokens(tokens: Token[]): ReactNode[] {
|
||||
return tokens.map((token, index) => (
|
||||
<Fragment key={index}>{renderToken(token)}</Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
function renderToken(token: Token): ReactNode {
|
||||
switch (token.type) {
|
||||
case "codespan":
|
||||
return <code className={CODE_CLASSNAME}>{token.text}</code>;
|
||||
case "strong":
|
||||
return <strong>{renderTokens(token.tokens)}</strong>;
|
||||
case "em":
|
||||
return <em>{renderTokens(token.tokens)}</em>;
|
||||
case "del":
|
||||
return renderTokens(token.tokens);
|
||||
case "link":
|
||||
return token.tokens.length > 0 ? renderTokens(token.tokens) : token.text;
|
||||
case "image":
|
||||
return token.text;
|
||||
case "html":
|
||||
return token.raw;
|
||||
case "br":
|
||||
return " ";
|
||||
case "escape":
|
||||
return token.text;
|
||||
case "text":
|
||||
return token.tokens && token.tokens.length > 0
|
||||
? renderTokens(token.tokens)
|
||||
: token.text;
|
||||
default:
|
||||
return "raw" in token ? token.raw : "";
|
||||
}
|
||||
}
|
||||
|
||||
export function InlineMarkdown({
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
content: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const children = useMemo(() => renderTokens(Lexer.lexInline(content)), [content]);
|
||||
return <span className={className}>{children}</span>;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import { InterviewDock, displayLabel } from "./interview-dock";
|
||||
import { generatedAxios } from "../lib/api-client";
|
||||
|
||||
function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
|
||||
let tree: TestRenderer.ReactTestRenderer | undefined;
|
||||
|
|
@ -81,6 +82,41 @@ describe("InterviewDock", () => {
|
|||
expect(buttons.No).toBeDefined();
|
||||
});
|
||||
|
||||
test("yes/no question submits typed yes and no answers", async () => {
|
||||
const submitted: unknown[] = [];
|
||||
const originalAdapter = generatedAxios.defaults.adapter;
|
||||
generatedAxios.defaults.adapter = async (config) => {
|
||||
submitted.push(JSON.parse(String(config.data)));
|
||||
return {
|
||||
data: undefined,
|
||||
status: 204,
|
||||
statusText: "No Content",
|
||||
headers: {},
|
||||
config,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const tree = render(
|
||||
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
|
||||
);
|
||||
const buttons = buttonsByText(tree);
|
||||
|
||||
await act(async () => {
|
||||
buttons.Yes.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
buttons.No.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(submitted).toEqual([{ kind: "yes" }, { kind: "no" }]);
|
||||
} finally {
|
||||
generatedAxios.defaults.adapter = originalAdapter;
|
||||
}
|
||||
});
|
||||
|
||||
test("multiple choice question renders option buttons with stripped accelerator prefixes", () => {
|
||||
const question = makeQuestion({
|
||||
question_type: QuestionType.MULTIPLE_CHOICE,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
useSubmitInterviewAnswer,
|
||||
type SubmitInterviewAnswerArg,
|
||||
} from "../lib/mutations";
|
||||
import { ApiError } from "../lib/api-client";
|
||||
import { ErrorMessage } from "./ui";
|
||||
|
||||
const PRIMARY_BUTTON =
|
||||
|
|
@ -33,6 +34,8 @@ const CHOICE_BUTTON =
|
|||
const CHOICE_BUTTON_SELECTED =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500/15 px-3.5 py-2 text-sm font-medium text-fg outline-1 -outline-offset-1 outline-teal-500/60 transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
|
||||
|
||||
type SubmitInterviewAnswer = SubmitInterviewAnswerArg["answer"];
|
||||
|
||||
export interface InterviewDockProps {
|
||||
runId: string;
|
||||
questions: ApiQuestion[];
|
||||
|
|
@ -52,15 +55,13 @@ export function InterviewDock({ runId, questions }: InterviewDockProps) {
|
|||
}, [question?.id, submitMutation.reset]);
|
||||
|
||||
const submit = useCallback(
|
||||
async (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => {
|
||||
async (answer: SubmitInterviewAnswer) => {
|
||||
if (!question) return;
|
||||
setError(null);
|
||||
try {
|
||||
await submitMutation.trigger({ ...arg, questionId: question.id });
|
||||
await submitMutation.trigger({ questionId: question.id, answer });
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Couldn't submit your answer.",
|
||||
);
|
||||
setError(interviewSubmitErrorMessage(caught));
|
||||
}
|
||||
},
|
||||
[question, submitMutation],
|
||||
|
|
@ -172,7 +173,7 @@ function QuestionBody({
|
|||
}: {
|
||||
question: ApiQuestion;
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
switch (question.question_type) {
|
||||
case QuestionType.YES_NO:
|
||||
|
|
@ -216,14 +217,14 @@ function YesNoBody({
|
|||
onSubmit,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void onSubmit({ value: "no" })}
|
||||
onClick={() => void onSubmit({ kind: "no" })}
|
||||
className={CHOICE_BUTTON}
|
||||
>
|
||||
No
|
||||
|
|
@ -231,7 +232,7 @@ function YesNoBody({
|
|||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void onSubmit({ value: "yes" })}
|
||||
onClick={() => void onSubmit({ kind: "yes" })}
|
||||
className={PRIMARY_BUTTON}
|
||||
>
|
||||
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
|
||||
|
|
@ -246,14 +247,14 @@ function ConfirmationBody({
|
|||
onSubmit,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void onSubmit({ value: "yes" })}
|
||||
onClick={() => void onSubmit({ kind: "yes" })}
|
||||
className={PRIMARY_BUTTON}
|
||||
>
|
||||
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
|
||||
|
|
@ -272,7 +273,7 @@ function ChoiceBody({
|
|||
options: ApiQuestionOption[];
|
||||
allowFreeform: boolean;
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -283,7 +284,7 @@ function ChoiceBody({
|
|||
key={option.key}
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void onSubmit({ selected_option_key: option.key })}
|
||||
onClick={() => void onSubmit({ kind: "selected", option_key: option.key })}
|
||||
className={CHOICE_BUTTON}
|
||||
>
|
||||
{displayLabel(option.label)}
|
||||
|
|
@ -315,7 +316,7 @@ function MultiSelectBody({
|
|||
}: {
|
||||
options: ApiQuestionOption[];
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
|
|
@ -357,7 +358,7 @@ function MultiSelectBody({
|
|||
<button
|
||||
type="button"
|
||||
disabled={submitting || selectedKeys.length === 0}
|
||||
onClick={() => void onSubmit({ selected_option_keys: selectedKeys })}
|
||||
onClick={() => void onSubmit({ kind: "multi_selected", option_keys: selectedKeys })}
|
||||
className={PRIMARY_BUTTON}
|
||||
>
|
||||
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
|
||||
|
|
@ -377,7 +378,7 @@ function FreeformBody({
|
|||
divider = false,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
|
||||
onSubmit: (answer: SubmitInterviewAnswer) => Promise<void>;
|
||||
placeholder: string;
|
||||
submitLabel: string;
|
||||
autoFocus?: boolean;
|
||||
|
|
@ -394,7 +395,7 @@ function FreeformBody({
|
|||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || submitting) return;
|
||||
await onSubmit({ value: trimmed });
|
||||
await onSubmit({ kind: "text", text: trimmed });
|
||||
setValue("");
|
||||
}
|
||||
|
||||
|
|
@ -484,3 +485,12 @@ export function displayLabel(label: string): string {
|
|||
.trim();
|
||||
return stripped || trimmed;
|
||||
}
|
||||
|
||||
function interviewSubmitErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.requestId
|
||||
? `${error.message} Request ID: ${error.requestId}`
|
||||
: error.message;
|
||||
}
|
||||
return error instanceof Error ? error.message : "Couldn't submit your answer.";
|
||||
}
|
||||
|
|
|
|||
40
apps/fabro-web/app/components/pull-request-chip.tsx
Normal file
40
apps/fabro-web/app/components/pull-request-chip.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
import { GitPullRequestIcon } from "./icons";
|
||||
|
||||
export function PullRequestChip({
|
||||
number,
|
||||
url,
|
||||
className = "inline-flex items-center gap-1.5 font-mono text-xs text-fg-muted",
|
||||
iconClassName = "size-3",
|
||||
children,
|
||||
}: {
|
||||
number: number;
|
||||
url?: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<GitPullRequestIcon className={iconClassName} />
|
||||
#{number}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
if (url == null) {
|
||||
return <span className={className}>{content}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`${className} hover:text-fg`}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, type ComponentType } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { StageState } from "@qltysh/fabro-api-client";
|
||||
import type { StageHandler, StageState } from "@qltysh/fabro-api-client";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
|
|
@ -9,7 +9,13 @@ import {
|
|||
PauseCircleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Bars3BottomLeftIcon, DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Bars3BottomLeftIcon,
|
||||
BoltIcon,
|
||||
DocumentTextIcon,
|
||||
MapIcon,
|
||||
PaperClipIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import { ACTIVE_STAGE_STATES, formatStageLabel } from "../lib/stage-sidebar";
|
||||
import { useTickingNow } from "../lib/time";
|
||||
|
|
@ -17,6 +23,7 @@ import { useTickingNow } from "../lib/time";
|
|||
export interface Stage {
|
||||
id: string;
|
||||
name: string;
|
||||
handler: StageHandler;
|
||||
status: StageState;
|
||||
duration: string;
|
||||
nodeId: string;
|
||||
|
|
@ -39,7 +46,7 @@ interface StageSidebarProps {
|
|||
stages: Stage[];
|
||||
runId: string;
|
||||
selectedStageId?: string;
|
||||
activeLink?: "settings" | "graph" | "logs";
|
||||
activeLink?: "settings" | "source" | "logs" | "artifacts" | "events";
|
||||
}
|
||||
|
||||
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
|
||||
|
|
@ -114,28 +121,15 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
|
|||
<ul className="mt-2 space-y-0.5">
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/settings`}
|
||||
to={`/runs/${runId}/source`}
|
||||
className={`${linkBase} ${
|
||||
activeLink === "settings"
|
||||
? "bg-overlay text-fg"
|
||||
: "text-fg-3 hover:bg-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<DocumentTextIcon className="size-4 shrink-0 text-fg-muted" />
|
||||
Run Settings
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/graph`}
|
||||
className={`${linkBase} ${
|
||||
activeLink === "graph"
|
||||
activeLink === "source"
|
||||
? "bg-overlay text-fg"
|
||||
: "text-fg-3 hover:bg-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<MapIcon className="size-4 shrink-0 text-fg-muted" />
|
||||
Workflow Graph
|
||||
Graph Source
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
|
|
@ -151,6 +145,45 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
|
|||
Run Logs
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/events`}
|
||||
className={`${linkBase} ${
|
||||
activeLink === "events"
|
||||
? "bg-overlay text-fg"
|
||||
: "text-fg-3 hover:bg-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<BoltIcon className="size-4 shrink-0 text-fg-muted" />
|
||||
Run Events
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/artifacts`}
|
||||
className={`${linkBase} ${
|
||||
activeLink === "artifacts"
|
||||
? "bg-overlay text-fg"
|
||||
: "text-fg-3 hover:bg-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<PaperClipIcon className="size-4 shrink-0 text-fg-muted" />
|
||||
Artifacts
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/settings`}
|
||||
className={`${linkBase} ${
|
||||
activeLink === "settings"
|
||||
? "bg-overlay text-fg"
|
||||
: "text-fg-3 hover:bg-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<DocumentTextIcon className="size-4 shrink-0 text-fg-muted" />
|
||||
Run Settings
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import {
|
|||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { CheckIcon } from "@heroicons/react/16/solid";
|
||||
|
||||
import { ApiError } from "../lib/api-client";
|
||||
import { useInterruptRun, useSteerRun } from "../lib/mutations";
|
||||
import { ErrorMessage } from "./ui";
|
||||
|
||||
export interface SteerBarProps {
|
||||
runId: string;
|
||||
|
|
@ -17,32 +20,55 @@ export interface SteerBarHandle {
|
|||
}
|
||||
|
||||
export const SteerBar = forwardRef<SteerBarHandle, SteerBarProps>(function SteerBar(
|
||||
{ runId: _runId },
|
||||
{ runId },
|
||||
ref,
|
||||
) {
|
||||
const [text, setText] = useState("");
|
||||
const [interrupt, setInterrupt] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const steer = useSteerRun(runId);
|
||||
const interrupt = useInterruptRun(runId);
|
||||
const pending = steer.isMutating || interrupt.isMutating;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus() {
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
const trimmed = text.trim();
|
||||
const canSubmit = trimmed.length > 0;
|
||||
const canSend = trimmed.length > 0 && !pending;
|
||||
|
||||
async function sendSteering() {
|
||||
if (!canSend) return;
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await steer.trigger({ text: trimmed, interrupt: false });
|
||||
setText("");
|
||||
} catch (err) {
|
||||
setErrorMessage(formatSteerError(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function fireInterrupt() {
|
||||
if (pending) return;
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await interrupt.trigger();
|
||||
} catch (err) {
|
||||
setErrorMessage(formatInterruptError(err));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setText("");
|
||||
void sendSteering();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSubmit) {
|
||||
setText("");
|
||||
}
|
||||
void sendSteering();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,51 +76,63 @@ export const SteerBar = forwardRef<SteerBarHandle, SteerBarProps>(function Steer
|
|||
<form
|
||||
onSubmit={handleSubmit}
|
||||
aria-label="Steer running agent"
|
||||
className="mx-auto flex max-w-4xl items-end gap-2 px-4 py-3 sm:px-6 lg:px-8"
|
||||
className="mx-auto max-w-4xl px-4 py-3 sm:px-6 lg:px-8"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Steer the agent…"
|
||||
rows={1}
|
||||
maxLength={8192}
|
||||
aria-label="Steering message"
|
||||
className="flex-1 resize-none rounded-md bg-overlay px-3 py-2 text-sm text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={interrupt}
|
||||
onClick={() => setInterrupt((v) => !v)}
|
||||
className={`inline-flex shrink-0 items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 ${
|
||||
interrupt
|
||||
? "bg-amber/15 text-amber outline-1 -outline-offset-1 outline-amber/60 hover:bg-amber/20"
|
||||
: "bg-overlay text-fg-2 outline-1 -outline-offset-1 outline-line-strong hover:bg-overlay-strong hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex size-3.5 items-center justify-center rounded-sm border ${
|
||||
interrupt
|
||||
? "border-amber bg-amber"
|
||||
: "border-line-strong bg-panel-alt"
|
||||
}`}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Steer the agent…"
|
||||
rows={1}
|
||||
maxLength={8192}
|
||||
aria-label="Steering message"
|
||||
className="flex-1 resize-none rounded-md bg-overlay px-3 py-2 text-sm text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fireInterrupt()}
|
||||
disabled={pending}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-md bg-overlay px-3 py-2 text-sm font-medium text-amber outline-1 -outline-offset-1 outline-amber/40 transition-colors hover:bg-amber/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<CheckIcon
|
||||
className={`size-2.5 text-on-primary ${interrupt ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
</span>
|
||||
Interrupt
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-md bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
{interrupt.isMutating ? "Interrupting…" : "Interrupt"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSend}
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-md bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"
|
||||
>
|
||||
{steer.isMutating ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<div className="mt-2">
|
||||
<ErrorMessage message={errorMessage} />
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
function formatSteerError(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
const body = err.body as { code?: string; detail?: string } | null;
|
||||
if (body?.code === "cli_agent_not_steerable") {
|
||||
return "All running agent stages are CLI-mode and can't be steered.";
|
||||
}
|
||||
if (body?.code === "use_answer_endpoint") {
|
||||
return "Run is blocked on a question; answer the question first.";
|
||||
}
|
||||
return body?.detail ?? err.message ?? "Steer failed.";
|
||||
}
|
||||
return "Steer failed; try again.";
|
||||
}
|
||||
|
||||
function formatInterruptError(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
const body = err.body as { detail?: string } | null;
|
||||
return body?.detail ?? err.message ?? "Interrupt failed.";
|
||||
}
|
||||
return "Interrupt failed; try again.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ApiError } from "../lib/api-client";
|
||||
import { useSteerRun } from "../lib/mutations";
|
||||
import { ErrorMessage } from "./ui";
|
||||
|
||||
interface SteerComposerProps {
|
||||
runId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SteerComposer({ runId, open, onClose }: SteerComposerProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const { trigger, isMutating } = useSteerRun(runId);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
} else {
|
||||
setText("");
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const canSubmit = trimmed.length > 0 && !isMutating;
|
||||
|
||||
async function send(interrupt: boolean) {
|
||||
if (!canSubmit) return;
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await trigger({ text: trimmed, interrupt });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
// Try to surface the well-known 409 codes inline.
|
||||
const body = err.body as { code?: string; detail?: string } | null;
|
||||
if (body?.code === "cli_agent_not_steerable") {
|
||||
setErrorMessage(
|
||||
"All running agent stages are CLI-mode and can't be steered.",
|
||||
);
|
||||
} else if (body?.code === "use_answer_endpoint") {
|
||||
setErrorMessage(
|
||||
"Run is blocked on a question; answer the question first.",
|
||||
);
|
||||
} else {
|
||||
setErrorMessage(body?.detail ?? err.message ?? "Steer failed.");
|
||||
}
|
||||
} else {
|
||||
setErrorMessage("Steer failed; try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Steer running agent"
|
||||
className="w-full max-w-md rounded-lg border border-line bg-bg-elevated p-4 shadow-lg"
|
||||
>
|
||||
<div className="mb-2 text-sm font-semibold text-fg">Steer agent</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={4}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a steering message…"
|
||||
className="w-full resize-none rounded-md border border-line bg-bg p-2 text-sm text-fg outline-none focus:border-teal-500/50"
|
||||
maxLength={8192}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<div className="mt-2">
|
||||
<ErrorMessage message={errorMessage} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-fg-muted">
|
||||
Enter to send · Shift+Enter for newline
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-line px-3 py-1 text-xs text-fg-2 hover:border-line-strong"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void send(true)}
|
||||
disabled={!canSubmit}
|
||||
className="rounded-md border border-amber/30 px-3 py-1 text-xs text-amber hover:border-amber/60 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Interrupt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void send(false)}
|
||||
disabled={!canSubmit}
|
||||
className="rounded-md bg-teal-500 px-3 py-1 text-xs font-medium text-white hover:bg-teal-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
// exposes the primary button, secondary button, input, error message, and
|
||||
// copy button so the auth and in-app surfaces can match.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useId, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
|
||||
import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
ClipboardIcon,
|
||||
|
|
@ -15,7 +17,10 @@ export const PRIMARY_BUTTON_CLASS =
|
|||
"inline-flex items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500";
|
||||
|
||||
export const SECONDARY_BUTTON_CLASS =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
export const DANGER_BUTTON_CLASS =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-coral px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-coral/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-coral disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-coral";
|
||||
|
||||
export function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
|
|
@ -62,3 +67,109 @@ export function CopyButton({
|
|||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
pendingLabel,
|
||||
cancelLabel = "Cancel",
|
||||
pending = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
confirmLabel: string;
|
||||
pendingLabel?: string;
|
||||
cancelLabel?: string;
|
||||
pending?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!pending) onCancel();
|
||||
}}
|
||||
className="relative z-50"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" aria-hidden="true" />
|
||||
<div className="fixed inset-0 flex items-start justify-center pt-[20vh] px-4">
|
||||
<DialogPanel className="w-full max-w-md rounded-lg border border-line-strong bg-panel shadow-2xl shadow-black/40">
|
||||
<div className="px-5 py-4">
|
||||
<DialogTitle className="text-sm font-semibold text-fg">{title}</DialogTitle>
|
||||
<div className="mt-2 text-sm text-fg-3">{description}</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={pending}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={pending}
|
||||
className={DANGER_BUTTON_CLASS}
|
||||
>
|
||||
{pending ? (pendingLabel ?? `${confirmLabel}…`) : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
const id = useId();
|
||||
const rect = open ? triggerRef.current?.getBoundingClientRect() : null;
|
||||
const portalTarget = typeof document === "undefined" ? null : document.body;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={triggerRef}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
aria-describedby={open ? id : undefined}
|
||||
className="inline-flex"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{rect && portalTarget
|
||||
? createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
id={id}
|
||||
style={{
|
||||
top: rect.top - 6,
|
||||
left: rect.left + rect.width / 2,
|
||||
}}
|
||||
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md bg-panel px-2 py-1 text-xs text-fg-2 shadow-lg outline-1 -outline-offset-1 outline-line-strong"
|
||||
>
|
||||
{label}
|
||||
</div>,
|
||||
portalTarget,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ describe("mapRunListItem", () => {
|
|||
created_at: "2026-04-08T12:00:00Z",
|
||||
start_time: "2026-04-08T12:00:00Z",
|
||||
pending_control: null,
|
||||
pull_request: {
|
||||
number: 123,
|
||||
html_url: "https://github.com/fabro-sh/fabro/pull/123",
|
||||
},
|
||||
} as const;
|
||||
const item = mapRunListItem(summary);
|
||||
expect(item.id).toBe("01ABC");
|
||||
|
|
@ -37,6 +41,8 @@ describe("mapRunListItem", () => {
|
|||
expect(item.elapsed).toBeDefined();
|
||||
expect(item.column).toBe("running");
|
||||
expect(item.lifecycleStatus).toBe("paused");
|
||||
expect(item.number).toBe(123);
|
||||
expect(item.pullRequestUrl).toBe("https://github.com/fabro-sh/fabro/pull/123");
|
||||
});
|
||||
|
||||
test("uses a fallback title when the server title is blank", () => {
|
||||
|
|
@ -81,6 +87,15 @@ describe("mapRunSummaryToRunItem", () => {
|
|||
created_at: "2026-04-08T12:00:00Z",
|
||||
start_time: "2026-04-08T12:00:00Z",
|
||||
pending_control: null,
|
||||
pull_request: {
|
||||
html_url: "https://github.com/fabro-sh/fabro/pull/456",
|
||||
number: 456,
|
||||
owner: "fabro-sh",
|
||||
repo: "fabro",
|
||||
base_branch: "main",
|
||||
head_branch: "fabro/run/demo",
|
||||
title: "Add run PR chip",
|
||||
},
|
||||
};
|
||||
const item = mapRunSummaryToRunItem(summary);
|
||||
expect(item.id).toBe("01ABC");
|
||||
|
|
@ -90,6 +105,8 @@ describe("mapRunSummaryToRunItem", () => {
|
|||
expect(item.sourceDirectory).toBe("/home/user/myrepo");
|
||||
expect(item.elapsed).toBeDefined();
|
||||
expect(item.lifecycleStatus).toBe("running");
|
||||
expect(item.number).toBe(456);
|
||||
expect(item.pullRequestUrl).toBe("https://github.com/fabro-sh/fabro/pull/456");
|
||||
});
|
||||
|
||||
test("handles missing optional fields", () => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface RunItem {
|
|||
lifecycleStatus?: RunStatus | null;
|
||||
lifecycleStatusLabel?: string;
|
||||
number?: number;
|
||||
pullRequestUrl?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
checks?: CheckRun[];
|
||||
|
|
@ -72,6 +73,11 @@ function displayRunTitle(title: string | null | undefined): string {
|
|||
return title?.trim() ? title : "Untitled run";
|
||||
}
|
||||
|
||||
function displayRepoName(name: string): string {
|
||||
const slash = name.lastIndexOf("/");
|
||||
return slash >= 0 ? name.slice(slash + 1) : name;
|
||||
}
|
||||
|
||||
function runStatusKind(status: ApiRunStatus | null | undefined): RunStatus | null {
|
||||
return status?.kind ?? null;
|
||||
}
|
||||
|
|
@ -80,13 +86,14 @@ export function mapRunListItem(item: RunListItem): RunItem {
|
|||
const lifecycleStatus = runStatusKind(item.status);
|
||||
return {
|
||||
id: item.run_id,
|
||||
repo: item.repository.name,
|
||||
repo: displayRepoName(item.repository.name),
|
||||
title: displayRunTitle(item.title),
|
||||
workflow: item.workflow_slug ?? item.workflow_name ?? "unknown",
|
||||
column: item.column,
|
||||
lifecycleStatus,
|
||||
lifecycleStatusLabel: lifecycleStatusLabel(item.status),
|
||||
number: item.pull_request?.number,
|
||||
pullRequestUrl: item.pull_request?.html_url,
|
||||
additions: item.pull_request?.additions,
|
||||
deletions: item.pull_request?.deletions,
|
||||
checks: item.pull_request?.checks?.map((c) => ({
|
||||
|
|
@ -112,11 +119,13 @@ export function mapRunSummaryToRunItem(summary: RunSummary): RunItem {
|
|||
const lifecycleStatus = runStatusKind(summary.status);
|
||||
return {
|
||||
id: summary.run_id,
|
||||
repo: summary.repository.name,
|
||||
repo: displayRepoName(summary.repository.name),
|
||||
title: displayRunTitle(summary.title),
|
||||
workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown",
|
||||
lifecycleStatus,
|
||||
lifecycleStatusLabel: lifecycleStatusLabel(summary.status),
|
||||
number: summary.pull_request?.number,
|
||||
pullRequestUrl: summary.pull_request?.html_url,
|
||||
sourceDirectory: summary.source_directory ?? undefined,
|
||||
elapsed:
|
||||
summary.elapsed_secs != null
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client";
|
|||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { SWRConfig } from "swr";
|
||||
import { installRoutes } from "./install-router";
|
||||
import { apiFetcher } from "./lib/api-client";
|
||||
import { resolveFabroMode } from "./mode";
|
||||
import { routes } from "./router";
|
||||
|
||||
|
|
@ -26,7 +25,6 @@ createRoot(rootElement).render(
|
|||
<StrictMode>
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: apiFetcher,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 2000,
|
||||
shouldRetryOnError: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import type { AxiosAdapter, AxiosRequestConfig } from "axios";
|
||||
|
||||
import {
|
||||
putInstallObjectStore,
|
||||
|
|
@ -7,6 +8,52 @@ import {
|
|||
testInstallObjectStore,
|
||||
testInstallSandbox,
|
||||
} from "./install-api";
|
||||
import { generatedAxios } from "./lib/api-client";
|
||||
|
||||
type StubGeneratedResponse = {
|
||||
status: number;
|
||||
data?: unknown;
|
||||
statusText?: string;
|
||||
};
|
||||
|
||||
const originalAdapter = generatedAxios.defaults.adapter;
|
||||
|
||||
afterEach(() => {
|
||||
generatedAxios.defaults.adapter = originalAdapter;
|
||||
});
|
||||
|
||||
function stubGeneratedAxios(response: StubGeneratedResponse) {
|
||||
const calls: AxiosRequestConfig[] = [];
|
||||
generatedAxios.defaults.adapter = (async (config) => {
|
||||
calls.push(config);
|
||||
if (response.status >= 400) {
|
||||
throw {
|
||||
isAxiosError: true,
|
||||
message: response.statusText ?? `HTTP ${response.status}`,
|
||||
response: {
|
||||
status: response.status,
|
||||
statusText: response.statusText ?? "",
|
||||
data: response.data,
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
statusText: response.statusText ?? "",
|
||||
headers: {},
|
||||
config,
|
||||
};
|
||||
}) as AxiosAdapter;
|
||||
return calls;
|
||||
}
|
||||
|
||||
function headerValue(config: AxiosRequestConfig, name: string): string | undefined {
|
||||
const headers = config.headers as { get?: (key: string) => unknown } | undefined;
|
||||
const value = headers?.get?.(name);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
describe("readInstallError", () => {
|
||||
test("prefers the structured install error payload", async () => {
|
||||
|
|
@ -39,11 +86,7 @@ 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;
|
||||
const calls = stubGeneratedAxios({ status: 200, data: { ok: true } });
|
||||
|
||||
await testInstallObjectStore("test-install-token", {
|
||||
provider: "s3",
|
||||
|
|
@ -53,13 +96,11 @@ describe("install object-store requests", () => {
|
|||
});
|
||||
|
||||
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(
|
||||
expect(calls[0]!.url).toBe("/install/object-store/test");
|
||||
expect(calls[0]!.method).toBe("post");
|
||||
expect(headerValue(calls[0]!, "Authorization")).toBe("Bearer test-install-token");
|
||||
expect(headerValue(calls[0]!, "Content-Type")).toContain("application/json");
|
||||
expect(calls[0]!.data).toBe(
|
||||
JSON.stringify({
|
||||
provider: "s3",
|
||||
bucket: "fabro-data",
|
||||
|
|
@ -70,24 +111,19 @@ describe("install object-store requests", () => {
|
|||
});
|
||||
|
||||
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.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
stubGeneratedAxios({
|
||||
status: 422,
|
||||
statusText: "Unprocessable Entity",
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
status: 422,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: "422",
|
||||
title: "Unprocessable Entity",
|
||||
detail: "Bucket is required.",
|
||||
},
|
||||
),
|
||||
)) as typeof fetch;
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
putInstallObjectStore("test-install-token", { provider: "s3" }),
|
||||
|
|
@ -97,41 +133,35 @@ describe("install object-store requests", () => {
|
|||
|
||||
describe("install sandbox requests", () => {
|
||||
test("testInstallSandbox 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;
|
||||
const calls = stubGeneratedAxios({ status: 200, data: { ok: true } });
|
||||
|
||||
await testInstallSandbox("test-install-token", {
|
||||
provider: "daytona",
|
||||
api_key: "dtn_test",
|
||||
api_key: "dtn_test",
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(String(calls[0]!.input)).toBe("/install/sandbox/test");
|
||||
expect(calls[0]!.init?.method).toBe("POST");
|
||||
expect(calls[0]!.init?.body).toBe(
|
||||
expect(calls[0]!.url).toBe("/install/sandbox/test");
|
||||
expect(calls[0]!.method).toBe("post");
|
||||
expect(calls[0]!.data).toBe(
|
||||
JSON.stringify({ provider: "daytona", api_key: "dtn_test" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("putInstallSandbox surfaces structured API errors", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [
|
||||
{
|
||||
status: "422",
|
||||
title: "Unprocessable Entity",
|
||||
detail: "api_key is required for daytona",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 422, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
)) as typeof fetch;
|
||||
stubGeneratedAxios({
|
||||
status: 422,
|
||||
statusText: "Unprocessable Entity",
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
status: "422",
|
||||
title: "Unprocessable Entity",
|
||||
detail: "api_key is required for daytona",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
putInstallSandbox("test-install-token", { provider: "daytona" }),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import type {
|
|||
InstallSessionResponse,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import { ApiError, apiData, installApi } from "./lib/api-client";
|
||||
|
||||
export type {
|
||||
InstallFinishResponse,
|
||||
InstallGithubAppManifestInput,
|
||||
|
|
@ -62,139 +64,133 @@ export async function readInstallError(
|
|||
return `${fallback} (${response.status})`;
|
||||
}
|
||||
|
||||
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";
|
||||
export function readInstallApiError(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError) {
|
||||
const detail = installErrorDetail(error.body);
|
||||
if (detail) return detail;
|
||||
return `${fallback} (${error.status})`;
|
||||
}
|
||||
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;
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
async function installJsonRequest<T>(
|
||||
token: string,
|
||||
opts: InstallRequestOptions,
|
||||
function installErrorDetail(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const errors = (body as { errors?: unknown }).errors;
|
||||
if (!Array.isArray(errors)) return null;
|
||||
const first = errors[0];
|
||||
if (!first || typeof first !== "object") return null;
|
||||
const detail = (first as { detail?: unknown }).detail;
|
||||
return typeof detail === "string" && detail.trim() ? detail : null;
|
||||
}
|
||||
|
||||
function installOptions(token: string) {
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
};
|
||||
}
|
||||
|
||||
async function installCall<T>(
|
||||
fallback: string,
|
||||
call: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const response = await installRequest(token, opts);
|
||||
return response.json() as Promise<T>;
|
||||
try {
|
||||
return await call();
|
||||
} catch (error) {
|
||||
throw new Error(readInstallApiError(error, fallback));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInstallSession(token: string): Promise<InstallSessionResponse> {
|
||||
return installJsonRequest<InstallSessionResponse>(token, {
|
||||
path: "/install/session",
|
||||
method: "GET",
|
||||
errorFallback: "install session request failed",
|
||||
});
|
||||
return installCall("install session request failed", () =>
|
||||
apiData(() => installApi.getInstallSession(installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function testInstallLlm(
|
||||
token: string,
|
||||
provider: InstallLlmProviderInput,
|
||||
): Promise<void> {
|
||||
await installRequest(token, {
|
||||
path: "/install/llm/test",
|
||||
method: "POST",
|
||||
body: provider,
|
||||
errorFallback: "install llm validation failed",
|
||||
});
|
||||
await installCall("install llm validation failed", () =>
|
||||
apiData(() => installApi.testInstallLlmCredentials(provider, installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function putInstallLlm(
|
||||
token: string,
|
||||
providers: InstallLlmProviderInput[],
|
||||
): Promise<void> {
|
||||
await installRequest(token, {
|
||||
path: "/install/llm",
|
||||
method: "PUT",
|
||||
body: { providers },
|
||||
errorFallback: "install llm request failed",
|
||||
});
|
||||
await installCall("install llm request failed", () =>
|
||||
apiData(() => installApi.putInstallLlm({ providers }, installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function putInstallServer(token: string, canonicalUrl: string): Promise<void> {
|
||||
await installRequest(token, {
|
||||
path: "/install/server",
|
||||
method: "PUT",
|
||||
body: { canonical_url: canonicalUrl },
|
||||
errorFallback: "install server request failed",
|
||||
});
|
||||
await installCall("install server request failed", () =>
|
||||
apiData(() => installApi.putInstallServer({ canonical_url: canonicalUrl }, installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
await installCall("install object store validation failed", () =>
|
||||
apiData(() => installApi.testInstallObjectStore(input, installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
await installCall("install object store request failed", () =>
|
||||
apiData(() => installApi.putInstallObjectStore(input, installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function testInstallSandbox(
|
||||
token: string,
|
||||
input: InstallSandboxInput,
|
||||
): Promise<void> {
|
||||
await installRequest(token, {
|
||||
path: "/install/sandbox/test",
|
||||
method: "POST",
|
||||
body: input,
|
||||
errorFallback: "install sandbox validation failed",
|
||||
});
|
||||
await installCall("install sandbox validation failed", () =>
|
||||
apiData(() => installApi.testInstallSandbox(input, installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function putInstallSandbox(
|
||||
token: string,
|
||||
input: InstallSandboxInput,
|
||||
): Promise<void> {
|
||||
await installRequest(token, {
|
||||
path: "/install/sandbox",
|
||||
method: "PUT",
|
||||
body: input,
|
||||
errorFallback: "install sandbox request failed",
|
||||
});
|
||||
await installCall("install sandbox request failed", () =>
|
||||
apiData(() => installApi.putInstallSandbox(input, installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function testInstallGithubToken(
|
||||
token: string,
|
||||
githubToken: string,
|
||||
): Promise<string> {
|
||||
const body = await installJsonRequest<{ username: string }>(token, {
|
||||
path: "/install/github/token/test",
|
||||
method: "POST",
|
||||
body: { token: githubToken },
|
||||
errorFallback: "install github token validation failed",
|
||||
});
|
||||
const body = await installCall("install github token validation failed", () =>
|
||||
apiData(() => installApi.testInstallGithubToken({ token: githubToken }, installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
return body.username;
|
||||
}
|
||||
|
||||
|
|
@ -203,30 +199,31 @@ export async function putInstallGithubToken(
|
|||
githubToken: string,
|
||||
username: string,
|
||||
): Promise<void> {
|
||||
await installRequest(token, {
|
||||
path: "/install/github/token",
|
||||
method: "PUT",
|
||||
body: { token: githubToken, username },
|
||||
errorFallback: "install github token request failed",
|
||||
});
|
||||
await installCall("install github token request failed", () =>
|
||||
apiData(() => installApi.putInstallGithubToken(
|
||||
{ token: githubToken, username },
|
||||
installOptions(token),
|
||||
), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createInstallGithubAppManifest(
|
||||
token: string,
|
||||
input: InstallGithubAppManifestInput,
|
||||
): Promise<InstallGithubAppManifestResponse> {
|
||||
return installJsonRequest<InstallGithubAppManifestResponse>(token, {
|
||||
path: "/install/github/app/manifest",
|
||||
method: "POST",
|
||||
body: input,
|
||||
errorFallback: "install github app manifest request failed",
|
||||
});
|
||||
return installCall("install github app manifest request failed", () =>
|
||||
apiData(() => installApi.createInstallGithubAppManifest(input, installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function finishInstall(token: string): Promise<InstallFinishResponse> {
|
||||
return installJsonRequest<InstallFinishResponse>(token, {
|
||||
path: "/install/finish",
|
||||
method: "POST",
|
||||
errorFallback: "install finish request failed",
|
||||
});
|
||||
return installCall("install finish request failed", () =>
|
||||
apiData(() => installApi.finishInstall(installOptions(token)), {
|
||||
redirectOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||
import type { AxiosAdapter } from "axios";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
|
||||
import InstallApp from "./install-app";
|
||||
import { generatedAxios } from "./lib/api-client";
|
||||
|
||||
const INSTALL_ERROR_MESSAGE =
|
||||
"GitHub App setup failed before Fabro could save the app credentials. Continue again to retry the callback.";
|
||||
|
|
@ -20,6 +22,8 @@ const SESSION_RESPONSE = {
|
|||
prefill: INSTALL_PREFILL,
|
||||
};
|
||||
|
||||
const originalAdapter = generatedAxios.defaults.adapter;
|
||||
|
||||
type TestWindow = {
|
||||
history: {
|
||||
state: unknown;
|
||||
|
|
@ -121,6 +125,34 @@ function findOptionButton(
|
|||
return button;
|
||||
}
|
||||
|
||||
function useInstallFetchMock(fetchMock: typeof fetch) {
|
||||
generatedAxios.defaults.adapter = (async (config) => {
|
||||
const response = await fetchMock(config.url ?? "", {
|
||||
method: config.method?.toUpperCase(),
|
||||
headers: config.headers as HeadersInit,
|
||||
body: config.data as BodyInit | undefined,
|
||||
signal: config.signal,
|
||||
});
|
||||
const text = response.status === 204 ? "" : await response.text();
|
||||
const data = text ? JSON.parse(text) : undefined;
|
||||
const axiosResponse = {
|
||||
data,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
config,
|
||||
};
|
||||
if (response.status >= 400) {
|
||||
throw {
|
||||
isAxiosError: true,
|
||||
message: response.statusText || `HTTP ${response.status}`,
|
||||
response: axiosResponse,
|
||||
};
|
||||
}
|
||||
return axiosResponse;
|
||||
}) as AxiosAdapter;
|
||||
}
|
||||
|
||||
async function waitFor(assertion: () => void, timeoutMs = 1000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
|
|
@ -138,6 +170,7 @@ async function waitFor(assertion: () => void, timeoutMs = 1000): Promise<void> {
|
|||
|
||||
describe("InstallApp", () => {
|
||||
afterEach(() => {
|
||||
generatedAxios.defaults.adapter = originalAdapter;
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
mock.restore();
|
||||
|
|
@ -165,7 +198,7 @@ describe("InstallApp", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow(
|
||||
"https://fabro.example.com/install/github?error=github-app-manifest-conversion-failed",
|
||||
|
|
@ -235,7 +268,7 @@ describe("InstallApp", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow(
|
||||
"https://fabro.example.com/install/github/done?token=test-install-token",
|
||||
|
|
@ -325,7 +358,7 @@ describe("InstallApp", () => {
|
|||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/object-store");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
|
|
@ -421,7 +454,7 @@ describe("InstallApp", () => {
|
|||
),
|
||||
);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/object-store");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
|
|
@ -496,7 +529,7 @@ describe("InstallApp", () => {
|
|||
),
|
||||
);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/review");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
|
|
@ -562,7 +595,7 @@ describe("InstallApp", () => {
|
|||
),
|
||||
),
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/review");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
|
|
@ -659,7 +692,7 @@ describe("InstallApp", () => {
|
|||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/sandbox");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
|
|
@ -753,7 +786,7 @@ describe("InstallApp", () => {
|
|||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/sandbox");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
|
|
@ -841,7 +874,7 @@ describe("InstallApp", () => {
|
|||
),
|
||||
);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/review");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
|
|
|
|||
|
|
@ -151,6 +151,14 @@ export default function AppShell() {
|
|||
transition
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button
|
||||
|
|
@ -237,8 +245,15 @@ export default function AppShell() {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{provider !== "tailscale" && (
|
||||
<div className="mt-3 space-y-1 px-2">
|
||||
<div className="mt-3 space-y-1 px-2">
|
||||
<DisclosureButton
|
||||
as={Link}
|
||||
to="/profile"
|
||||
className="block w-full rounded-md px-3 py-2 text-left text-base font-medium text-fg-muted hover:bg-overlay hover:text-fg"
|
||||
>
|
||||
Profile
|
||||
</DisclosureButton>
|
||||
{provider !== "tailscale" && (
|
||||
<form method="POST" action="/auth/logout">
|
||||
<DisclosureButton
|
||||
as="button"
|
||||
|
|
@ -248,8 +263,8 @@ export default function AppShell() {
|
|||
Sign out
|
||||
</DisclosureButton>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
|
|||
|
||||
import {
|
||||
ApiError,
|
||||
apiFetcher,
|
||||
apiNullableFetcher,
|
||||
apiPaginatedFetcher,
|
||||
apiRequest,
|
||||
apiData,
|
||||
extractRequestId,
|
||||
fetchAllPages,
|
||||
generatedAxios,
|
||||
stageArtifactDownloadUrl,
|
||||
} from "./api-client";
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -14,105 +14,116 @@ afterEach(() => {
|
|||
delete (globalThis as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
describe("apiRequest", () => {
|
||||
test("includes credentials on API requests", async () => {
|
||||
const fetchMock = mock(() => Promise.resolve(new Response("{}", { status: 200 })));
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
function axiosFailure({
|
||||
status,
|
||||
statusText,
|
||||
data,
|
||||
headers = {},
|
||||
}: {
|
||||
status: number;
|
||||
statusText?: string;
|
||||
data?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}) {
|
||||
return {
|
||||
isAxiosError: true,
|
||||
message: statusText ?? `HTTP ${status}`,
|
||||
response: {
|
||||
status,
|
||||
statusText: statusText ?? "",
|
||||
data,
|
||||
headers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await apiRequest("/api/v1/runs/run-1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/v1/runs/run-1", {
|
||||
credentials: "include",
|
||||
headers: undefined,
|
||||
});
|
||||
describe("generated Axios adapter", () => {
|
||||
test("uses same-origin requests with browser credentials", () => {
|
||||
expect(generatedAxios.defaults.baseURL).toBe("");
|
||||
expect(generatedAxios.defaults.withCredentials).toBe(true);
|
||||
});
|
||||
|
||||
test("401 responses throw a typed ApiError", async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ errors: [{ detail: "Request ID: req_401" }] }), {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
await expect(apiRequest("/api/v1/auth/me")).rejects.toMatchObject({
|
||||
status: 401,
|
||||
requestId: "req_401",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiFetcher", () => {
|
||||
test("throws ApiError with status, body, and request id on non-2xx responses", async () => {
|
||||
test("normalizes generated client failures into ApiError", async () => {
|
||||
const body = {
|
||||
errors: [{ status: "500", title: "Internal", request_id: "req_500" }],
|
||||
errors: [{
|
||||
status: "500",
|
||||
title: "Internal",
|
||||
detail: "Database is unavailable.",
|
||||
request_id: "body-req",
|
||||
}],
|
||||
};
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
try {
|
||||
await apiFetcher("/api/v1/runs/run-1/files");
|
||||
throw new Error("expected apiFetcher to reject");
|
||||
await apiData(() =>
|
||||
Promise.reject(
|
||||
axiosFailure({
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
data: body,
|
||||
headers: { "x-request-id": "header-req" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
throw new Error("expected apiData to reject");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(error).toMatchObject({
|
||||
status: 500,
|
||||
message: "Internal Server Error",
|
||||
requestId: "req_500",
|
||||
message: "Database is unavailable.",
|
||||
requestId: "header-req",
|
||||
body,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiNullableFetcher", () => {
|
||||
test("returns null only for explicit availability statuses", async () => {
|
||||
const fetchMock = mock(() => Promise.resolve(new Response("", { status: 501 })));
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
test("redirects normal authenticated 401 responses to login", async () => {
|
||||
const location = { href: "" };
|
||||
(globalThis as unknown as { window: { location: typeof location } }).window = {
|
||||
location,
|
||||
};
|
||||
|
||||
await expect(apiNullableFetcher("/api/v1/runs/run-1/files")).resolves.toBeNull();
|
||||
await expect(() =>
|
||||
apiData(() => Promise.reject(axiosFailure({ status: 401 }))),
|
||||
).toThrow(ApiError);
|
||||
|
||||
expect(location.href).toBe("/login");
|
||||
});
|
||||
|
||||
test("can suppress login redirects for login and install calls", async () => {
|
||||
const location = { href: "" };
|
||||
(globalThis as unknown as { window: { location: typeof location } }).window = {
|
||||
location,
|
||||
};
|
||||
|
||||
await expect(() =>
|
||||
apiData(
|
||||
() => Promise.reject(axiosFailure({ status: 401 })),
|
||||
{ redirectOnUnauthorized: false },
|
||||
),
|
||||
).toThrow(ApiError);
|
||||
|
||||
expect(location.href).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiPaginatedFetcher", () => {
|
||||
describe("fetchAllPages", () => {
|
||||
test("preserves first-page extras and stops at the page cap", async () => {
|
||||
const warnMock = mock(() => {});
|
||||
const originalWarn = console.warn;
|
||||
console.warn = warnMock;
|
||||
let calls = 0;
|
||||
const fetchMock = mock(() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
|
||||
try {
|
||||
const result = await fetchAllPages<{ id: string }, { columns: { id: string; name: string }[] }>(
|
||||
"board runs",
|
||||
async () => {
|
||||
calls += 1;
|
||||
return {
|
||||
columns: [{ id: "running", name: "Running" }],
|
||||
data: [{ id: `run-${calls}` }],
|
||||
meta: { has_more: true },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await apiPaginatedFetcher<{ id: string }, { columns: { id: string; name: string }[] }>(
|
||||
"/api/v1/boards/runs",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.columns).toEqual([{ id: "running", name: "Running" }]);
|
||||
|
|
@ -125,6 +136,16 @@ describe("apiPaginatedFetcher", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("stageArtifactDownloadUrl", () => {
|
||||
test("builds the download href through generated client metadata", async () => {
|
||||
await expect(
|
||||
stageArtifactDownloadUrl("run 1", "stage@1", "logs/output.txt", 2),
|
||||
).resolves.toBe(
|
||||
"/api/v1/runs/run%201/stages/stage%401/artifacts/download?filename=logs%2Foutput.txt&retry=2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractRequestId", () => {
|
||||
test("supports top-level, error-level, and detail-embedded request ids", () => {
|
||||
expect(extractRequestId({ request_id: "top" })).toBe("top");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,23 @@
|
|||
export interface ApiOptions {
|
||||
init?: RequestInit;
|
||||
request?: Request;
|
||||
}
|
||||
import axios, {
|
||||
isAxiosError,
|
||||
type AxiosPromise,
|
||||
type AxiosResponse,
|
||||
type RawAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import {
|
||||
AuthApi,
|
||||
Configuration,
|
||||
HumanInTheLoopApi,
|
||||
InsightsApi,
|
||||
InstallApi,
|
||||
RunInternalsApi,
|
||||
RunInternalsApiAxiosParamCreator,
|
||||
RunOutputsApi,
|
||||
RunsApi,
|
||||
SettingsApi,
|
||||
SystemApi,
|
||||
WorkflowsApi,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
export interface PaginatedEnvelope<T> {
|
||||
data: T[];
|
||||
|
|
@ -32,13 +48,75 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const API_PREFIX = "/api/v1";
|
||||
interface ApiCallOptions {
|
||||
redirectOnUnauthorized?: boolean;
|
||||
}
|
||||
|
||||
const PAGINATED_API_MAX_PAGES = 50;
|
||||
const PAGINATED_API_MAX_ITEMS = 5000;
|
||||
|
||||
export function apiPath(path: string): string {
|
||||
return path.startsWith(API_PREFIX) ? path : `${API_PREFIX}${path}`;
|
||||
}
|
||||
export const generatedAxios = axios.create({
|
||||
baseURL: "",
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const generatedApiConfiguration = new Configuration({
|
||||
basePath: "",
|
||||
baseOptions: {
|
||||
withCredentials: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const authApi = new AuthApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const humanInTheLoopApi = new HumanInTheLoopApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const insightsApi = new InsightsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const installApi = new InstallApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const runInternalsApi = new RunInternalsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const runOutputsApi = new RunOutputsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const runsApi = new RunsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const settingsApi = new SettingsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const systemApi = new SystemApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const workflowsApi = new WorkflowsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
|
||||
export function isNotAvailable(status: number): boolean {
|
||||
return status === 404 || status === 501;
|
||||
|
|
@ -65,106 +143,106 @@ export function extractRequestId(body: unknown): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function requestIdFromHeaders(headers: Headers): string | null {
|
||||
function requestIdFromHeaders(headers: unknown): string | null {
|
||||
return (
|
||||
headers.get("x-request-id") ??
|
||||
headers.get("x-fabro-request-id") ??
|
||||
headers.get("request-id")
|
||||
headerValue(headers, "x-request-id")
|
||||
?? headerValue(headers, "x-fabro-request-id")
|
||||
?? headerValue(headers, "request-id")
|
||||
);
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const text = await response.text().catch(() => "");
|
||||
if (!text) return null;
|
||||
if (contentType.includes("json")) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
function headerValue(headers: unknown, name: string): string | null {
|
||||
if (!headers || typeof headers !== "object") return null;
|
||||
|
||||
const getter = (headers as { get?: (key: string) => unknown }).get;
|
||||
if (typeof getter === "function") {
|
||||
const value = getter.call(headers, name);
|
||||
if (typeof value === "string") return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
|
||||
const wanted = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (key.toLowerCase() !== wanted) continue;
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function apiErrorFromResponse(response: Response): Promise<ApiError> {
|
||||
const body = await parseResponseBody(response);
|
||||
const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(body);
|
||||
function apiErrorFromAxios(error: unknown): ApiError | null {
|
||||
if (!isAxiosError(error) || !error.response) return null;
|
||||
|
||||
const { response } = error;
|
||||
const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(response.data);
|
||||
return new ApiError({
|
||||
status: response.status,
|
||||
message: response.statusText || `HTTP ${response.status}`,
|
||||
message: extractErrorDetail(response.data) ?? (response.statusText || `HTTP ${response.status}`),
|
||||
requestId,
|
||||
body,
|
||||
body: response.data ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiRequest(path: string, options?: ApiOptions): Promise<Response> {
|
||||
const { init, request } = options ?? {};
|
||||
const response = await fetch(apiPath(path), {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: init?.headers,
|
||||
...(request?.signal ? { signal: request.signal } : {}),
|
||||
});
|
||||
function extractErrorDetail(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const errors = (body as Record<string, unknown>).errors;
|
||||
if (!Array.isArray(errors) || errors.length === 0) return null;
|
||||
|
||||
if (response.status === 401) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
throw await apiErrorFromResponse(response);
|
||||
const first = errors[0];
|
||||
if (!first || typeof first !== "object") return null;
|
||||
const detail = (first as Record<string, unknown>).detail;
|
||||
return typeof detail === "string" && detail.length > 0 ? detail : null;
|
||||
}
|
||||
|
||||
function redirectToLogin(error: ApiError, options: ApiCallOptions) {
|
||||
if (error.status !== 401 || options.redirectOnUnauthorized === false) return;
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function apiFetcher<T>(key: string): Promise<T> {
|
||||
const response = await apiRequest(key);
|
||||
if (!response.ok) {
|
||||
throw await apiErrorFromResponse(response);
|
||||
export async function apiData<T>(
|
||||
call: () => AxiosPromise<T>,
|
||||
options: ApiCallOptions = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response = await call();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const apiError = apiErrorFromAxios(error);
|
||||
if (!apiError) throw error;
|
||||
redirectToLogin(apiError, options);
|
||||
throw apiError;
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiTextFetcher(key: string): Promise<string> {
|
||||
const response = await apiRequest(key);
|
||||
if (!response.ok) {
|
||||
throw await apiErrorFromResponse(response);
|
||||
export async function apiResponse<T>(
|
||||
call: () => AxiosPromise<T>,
|
||||
options: ApiCallOptions = {},
|
||||
): Promise<AxiosResponse<T>> {
|
||||
try {
|
||||
return await call();
|
||||
} catch (error) {
|
||||
const apiError = apiErrorFromAxios(error);
|
||||
if (!apiError) throw error;
|
||||
redirectToLogin(apiError, options);
|
||||
throw apiError;
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export async function apiNullableFetcher<T>(key: string): Promise<T | null> {
|
||||
const response = await apiRequest(key);
|
||||
if (isNotAvailable(response.status)) return null;
|
||||
if (!response.ok) {
|
||||
throw await apiErrorFromResponse(response);
|
||||
export async function apiNullableData<T>(
|
||||
call: () => AxiosPromise<T>,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await apiData(call);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && isNotAvailable(error.status)) return null;
|
||||
throw error;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiNullableTextFetcher(key: string): Promise<string | null> {
|
||||
const response = await apiRequest(key);
|
||||
if (isNotAvailable(response.status)) return null;
|
||||
if (!response.ok) {
|
||||
throw await apiErrorFromResponse(response);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function paginatedApiPath(key: string, limit: number, offset: number): string {
|
||||
const url = new URL(apiPath(key), "http://fabro.local");
|
||||
url.searchParams.set("page[limit]", String(limit));
|
||||
url.searchParams.set("page[offset]", String(offset));
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
|
||||
key: string,
|
||||
export async function fetchAllPages<TItem, TExtra extends object = {}>(
|
||||
label: string,
|
||||
loadPage: (limit: number, offset: number) => Promise<PaginatedEnvelope<TItem> & TExtra>,
|
||||
): Promise<PaginatedEnvelope<TItem> & TExtra> {
|
||||
const limit = 100;
|
||||
let offset = 0;
|
||||
|
|
@ -173,12 +251,7 @@ export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
|
|||
let pagesLoaded = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await apiRequest(paginatedApiPath(key, limit, offset));
|
||||
if (!response.ok) {
|
||||
throw await apiErrorFromResponse(response);
|
||||
}
|
||||
|
||||
const page = (await response.json()) as PaginatedEnvelope<TItem> & TExtra;
|
||||
const page = await loadPage(limit, offset);
|
||||
if (extras == null) {
|
||||
const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope<TItem> &
|
||||
Record<string, unknown>;
|
||||
|
|
@ -199,12 +272,12 @@ export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
|
|||
}
|
||||
|
||||
if (
|
||||
pagesLoaded >= PAGINATED_API_MAX_PAGES ||
|
||||
pageItems.length < page.data.length ||
|
||||
data.length >= PAGINATED_API_MAX_ITEMS
|
||||
pagesLoaded >= PAGINATED_API_MAX_PAGES
|
||||
|| pageItems.length < page.data.length
|
||||
|| data.length >= PAGINATED_API_MAX_ITEMS
|
||||
) {
|
||||
console.warn(
|
||||
`Stopped paginated API fetch for ${key} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
|
||||
`Stopped paginated API fetch for ${label} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
|
||||
);
|
||||
return {
|
||||
...(extras ?? ({} as TExtra)),
|
||||
|
|
@ -217,24 +290,9 @@ export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
|
|||
}
|
||||
}
|
||||
|
||||
function stageEventsPagePath(key: string, sinceSeq: number, limit: number): string {
|
||||
const url = new URL(apiPath(key), "http://fabro.local");
|
||||
url.searchParams.set("since_seq", String(sinceSeq));
|
||||
url.searchParams.set("limit", String(limit));
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-paginated fetcher for `/runs/{id}/stages/{stageId}/events`.
|
||||
*
|
||||
* Loops from `since_seq=1` with a 1000-event page size, advancing the cursor
|
||||
* to `highestSeq + 1` until the server reports `meta.has_more === false`.
|
||||
* The empty-page guard mirrors `apiPaginatedFetcher`: if the server claims
|
||||
* `has_more` but returns no rows we exit and `console.warn` to surface the
|
||||
* server invariant violation without spinning the UI.
|
||||
*/
|
||||
export async function fetchAllStageEvents<TItem extends { seq: number }>(
|
||||
key: string,
|
||||
label: string,
|
||||
loadPage: (sinceSeq: number, limit: number) => Promise<PaginatedEnvelope<TItem>>,
|
||||
): Promise<TItem[]> {
|
||||
const PAGE_LIMIT = 1000;
|
||||
const MAX_PAGES = 50;
|
||||
|
|
@ -243,17 +301,13 @@ export async function fetchAllStageEvents<TItem extends { seq: number }>(
|
|||
let pagesLoaded = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await apiRequest(stageEventsPagePath(key, sinceSeq, PAGE_LIMIT));
|
||||
if (!response.ok) {
|
||||
throw await apiErrorFromResponse(response);
|
||||
}
|
||||
const page = (await response.json()) as PaginatedEnvelope<TItem>;
|
||||
const page = await loadPage(sinceSeq, PAGE_LIMIT);
|
||||
pagesLoaded += 1;
|
||||
|
||||
if (page.data.length === 0) {
|
||||
if (page.meta.has_more) {
|
||||
console.warn(
|
||||
`Stage events fetch for ${key} returned an empty page with has_more=true; stopping at ${data.length} items to avoid spinning.`,
|
||||
`Stage events fetch for ${label} returned an empty page with has_more=true; stopping at ${data.length} items to avoid spinning.`,
|
||||
);
|
||||
}
|
||||
return data;
|
||||
|
|
@ -264,7 +318,7 @@ export async function fetchAllStageEvents<TItem extends { seq: number }>(
|
|||
|
||||
if (pagesLoaded >= MAX_PAGES) {
|
||||
console.warn(
|
||||
`Stopped stage events fetch for ${key} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
|
||||
`Stopped stage events fetch for ${label} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
|
@ -272,7 +326,7 @@ export async function fetchAllStageEvents<TItem extends { seq: number }>(
|
|||
const highestSeq = page.data.reduce((max, event) => Math.max(max, event.seq), sinceSeq - 1);
|
||||
if (highestSeq < sinceSeq) {
|
||||
console.warn(
|
||||
`Stage events fetch for ${key} returned a non-advancing page at since_seq=${sinceSeq}; stopping at ${data.length} items to avoid spinning.`,
|
||||
`Stage events fetch for ${label} returned a non-advancing page at since_seq=${sinceSeq}; stopping at ${data.length} items to avoid spinning.`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
|
@ -280,20 +334,18 @@ export async function fetchAllStageEvents<TItem extends { seq: number }>(
|
|||
}
|
||||
}
|
||||
|
||||
export async function apiJsonMutation<TResponse, TArg = unknown>(
|
||||
key: string,
|
||||
{ arg }: { arg: TArg },
|
||||
): Promise<TResponse> {
|
||||
const response = await apiRequest(key, {
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: arg === undefined ? undefined : JSON.stringify(arg),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await apiErrorFromResponse(response);
|
||||
}
|
||||
if (response.status === 204) return undefined as TResponse;
|
||||
return response.json() as Promise<TResponse>;
|
||||
}
|
||||
export function requestSignalOptions(request?: Request): RawAxiosRequestConfig {
|
||||
return request?.signal ? { signal: request.signal } : {};
|
||||
}
|
||||
|
||||
export async function stageArtifactDownloadUrl(
|
||||
id: string,
|
||||
stageId: string,
|
||||
filename: string,
|
||||
retry: number,
|
||||
): Promise<string> {
|
||||
const requestArgs = await RunInternalsApiAxiosParamCreator(
|
||||
generatedApiConfiguration,
|
||||
).getStageArtifact(id, stageId, filename, retry);
|
||||
return `${generatedApiConfiguration.basePath ?? ""}${requestArgs.url}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
type BroadcastChannelLike,
|
||||
} from "./cross-tab-sse";
|
||||
import { queryKeys } from "./query-keys";
|
||||
import type { EventSourceLike } from "./sse";
|
||||
import type { EventSourceLike, SseKey } from "./sse";
|
||||
|
||||
type MessageHandler = ((event: { data: string }) => void) | null;
|
||||
|
||||
|
|
@ -47,12 +47,12 @@ describe("subscribeToBoardEvents", () => {
|
|||
test("coordinated mode shares one global source and invalidates the board runs key", async () => {
|
||||
const source = new FakeEventSource();
|
||||
const created: string[] = [];
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createCoordinator((url) => {
|
||||
created.push(url);
|
||||
return source;
|
||||
});
|
||||
const mutate = (key: string) => {
|
||||
const mutate = (key: SseKey) => {
|
||||
keys.push(key);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
|
@ -82,9 +82,9 @@ describe("subscribeToBoardEvents", () => {
|
|||
test("fallback mode preserves the existing shared board EventSource", () => {
|
||||
const source = new FakeEventSource();
|
||||
const created: string[] = [];
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createFallbackCoordinator();
|
||||
const mutate = (key: string) => {
|
||||
const mutate = (key: SseKey) => {
|
||||
keys.push(key);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function subscribeToBoardEvents(
|
|||
subscribeToSharedEventSource<EventPayload>({
|
||||
subscriptions,
|
||||
subscriptionKey: BOARD_SUBSCRIPTION_KEY,
|
||||
url: queryKeys.system.attach(),
|
||||
url: queryKeys.system.attachUrl(),
|
||||
mutate,
|
||||
eventSourceFactory,
|
||||
debounceMs,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
type EventPayload,
|
||||
type EventSourceLike,
|
||||
type MutateFn,
|
||||
type SseKey,
|
||||
sseKeyDedupeId,
|
||||
} from "./sse";
|
||||
import { getNumber, getString, isRecord, type UnknownRecord } from "./unknown";
|
||||
|
||||
|
|
@ -111,7 +113,7 @@ interface SubscribeOptions<TPayload extends EventPayload> {
|
|||
subscriptionKey: string;
|
||||
mutate: MutateFn;
|
||||
resolveInvalidation: (payload: TPayload) => EventInvalidation;
|
||||
resyncKeys: () => string[];
|
||||
resyncKeys: () => SseKey[];
|
||||
fallbackSubscribe: () => () => void;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
|
@ -131,11 +133,11 @@ interface LocalSubscription {
|
|||
refcount: number;
|
||||
mutators: Map<MutateFn, number>;
|
||||
fallbacks: Map<MutateFn, FallbackEntry>;
|
||||
pendingKeys: Set<string>;
|
||||
pendingKeys: Map<string, SseKey>;
|
||||
debounceTimer: ReturnType<typeof setTimeout> | null;
|
||||
debounceMs: number;
|
||||
resolveInvalidation: (payload: EventPayload) => EventInvalidation;
|
||||
resyncKeys: () => string[];
|
||||
resyncKeys: () => SseKey[];
|
||||
}
|
||||
|
||||
interface LeaderState {
|
||||
|
|
@ -290,7 +292,7 @@ export class CrossTabSseCoordinator {
|
|||
refcount: 0,
|
||||
mutators: new Map(),
|
||||
fallbacks: new Map(),
|
||||
pendingKeys: new Set(),
|
||||
pendingKeys: new Map(),
|
||||
debounceTimer: null,
|
||||
debounceMs: options.debounceMs ?? 300,
|
||||
resolveInvalidation: options.resolveInvalidation as (payload: EventPayload) => EventInvalidation,
|
||||
|
|
@ -633,7 +635,7 @@ export class CrossTabSseCoordinator {
|
|||
lastSeen: this.now(),
|
||||
};
|
||||
|
||||
const source = this.sourceFactory(queryKeys.system.attach());
|
||||
const source = this.sourceFactory(queryKeys.system.attachUrl());
|
||||
this.source = source;
|
||||
source.onmessage = (message) => {
|
||||
this.handleLeaderEventSourceMessage(message.data);
|
||||
|
|
@ -687,12 +689,12 @@ export class CrossTabSseCoordinator {
|
|||
|
||||
private queueInvalidations(
|
||||
subscription: LocalSubscription,
|
||||
keys: string[],
|
||||
keys: SseKey[],
|
||||
{ immediate = false }: { immediate?: boolean } = {},
|
||||
) {
|
||||
if (keys.length === 0) return;
|
||||
for (const key of keys) {
|
||||
subscription.pendingKeys.add(key);
|
||||
subscription.pendingKeys.set(sseKeyDedupeId(key), key);
|
||||
}
|
||||
|
||||
if (immediate || subscription.debounceMs <= 0) {
|
||||
|
|
@ -711,7 +713,7 @@ export class CrossTabSseCoordinator {
|
|||
|
||||
private flushInvalidations(subscription: LocalSubscription) {
|
||||
if (subscription.pendingKeys.size === 0) return;
|
||||
const keys = [...subscription.pendingKeys];
|
||||
const keys = [...subscription.pendingKeys.values()];
|
||||
subscription.pendingKeys.clear();
|
||||
|
||||
for (const mutator of subscription.mutators.keys()) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,24 @@ export function formatRelativeTime(iso: string, now: number = Date.now()): strin
|
|||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO 8601 timestamp as an absolute, human-readable datetime
|
||||
* (e.g., "04/24/2026, 1:23:40 PM"). Falls back to the input if unparseable.
|
||||
*/
|
||||
export function formatAbsoluteTs(iso: string): string {
|
||||
const ms = Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return iso;
|
||||
return new Date(ms).toLocaleString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds into a duration string for display (e.g., "1m 12s", "23s").
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import type {
|
|||
SubmitAnswerRequest,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import { apiJsonMutation } from "./api-client";
|
||||
import {
|
||||
apiData,
|
||||
authApi,
|
||||
humanInTheLoopApi,
|
||||
} from "./api-client";
|
||||
import { queryKeys } from "./query-keys";
|
||||
import type { LifecycleAction, LifecycleActionError } from "./run-actions";
|
||||
import {
|
||||
|
|
@ -42,8 +46,10 @@ export type LifecycleMutationResult =
|
|||
export function usePreviewRun(id: string | undefined) {
|
||||
return useSWRMutation(
|
||||
id ? queryKeys.runs.preview(id) : null,
|
||||
async (key: string, { arg }: { arg: PreviewRunArg }): Promise<PreviewMutationResult> => {
|
||||
const result = await apiJsonMutation<PreviewUrlResponse, PreviewRunArg>(key, { arg });
|
||||
async (_key, { arg }: { arg: PreviewRunArg }): Promise<PreviewMutationResult> => {
|
||||
const result = await apiData<PreviewUrlResponse>(() =>
|
||||
humanInTheLoopApi.generatePreviewUrl(id!, arg),
|
||||
);
|
||||
return { intent: "preview", url: result.url };
|
||||
},
|
||||
);
|
||||
|
|
@ -95,7 +101,10 @@ function useLifecycleMutation(
|
|||
);
|
||||
}
|
||||
|
||||
export type SubmitInterviewAnswerArg = SubmitAnswerRequest & { questionId: string };
|
||||
export type SubmitInterviewAnswerArg = {
|
||||
questionId: string;
|
||||
answer: SubmitAnswerRequest;
|
||||
};
|
||||
|
||||
export function useSubmitInterviewAnswer(runId: string | undefined) {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
|
@ -103,9 +112,9 @@ export function useSubmitInterviewAnswer(runId: string | undefined) {
|
|||
runId ? `interview-answer:${runId}` : null,
|
||||
async (_key: string, { arg }: { arg: SubmitInterviewAnswerArg }) => {
|
||||
if (!runId) throw new Error("runId is required");
|
||||
const { questionId, ...body } = arg;
|
||||
const path = `/api/v1/runs/${encodeURIComponent(runId)}/questions/${encodeURIComponent(questionId)}/answer`;
|
||||
await apiJsonMutation<void, SubmitAnswerRequest>(path, { arg: body });
|
||||
await apiData(() =>
|
||||
humanInTheLoopApi.submitRunAnswer(runId, arg.questionId, arg.answer),
|
||||
);
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
|
@ -125,8 +134,7 @@ export function useInterruptRun(runId: string | undefined) {
|
|||
runId ? `interrupt-run:${runId}` : null,
|
||||
async (_key: string) => {
|
||||
if (!runId) throw new Error("runId is required");
|
||||
const path = `/api/v1/runs/${encodeURIComponent(runId)}/interrupt`;
|
||||
await apiJsonMutation<void, undefined>(path, { arg: undefined });
|
||||
await apiData(() => humanInTheLoopApi.interruptRun(runId));
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
|
@ -143,8 +151,7 @@ export function useSteerRun(runId: string | undefined) {
|
|||
runId ? `steer-run:${runId}` : null,
|
||||
async (_key: string, { arg }: { arg: SteerRunArg }) => {
|
||||
if (!runId) throw new Error("runId is required");
|
||||
const path = `/api/v1/runs/${encodeURIComponent(runId)}/steer`;
|
||||
await apiJsonMutation<void, SteerRunRequest>(path, { arg });
|
||||
await apiData(() => humanInTheLoopApi.steerRun(runId, arg));
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
|
@ -159,8 +166,8 @@ export function useToggleDemoMode() {
|
|||
const { mutate } = useSWRConfig();
|
||||
return useSWRMutation(
|
||||
queryKeys.demo.toggle(),
|
||||
async (key: string, { arg }: { arg: { enabled: boolean } }) => {
|
||||
await apiJsonMutation<void, { enabled: boolean }>(key, { arg });
|
||||
async (_key, { arg }: { arg: { enabled: boolean } }) => {
|
||||
await apiData(() => authApi.toggleDemo(arg));
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
|
@ -172,18 +179,11 @@ export function useToggleDemoMode() {
|
|||
|
||||
export function useLoginDevToken() {
|
||||
return useSWRMutation(
|
||||
"/auth/login/dev-token",
|
||||
async (key: string, { arg }: { arg: { token: string } }) => {
|
||||
const response = await fetch(key, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(arg),
|
||||
queryKeys.auth.loginDevToken(),
|
||||
async (_key, { arg }: { arg: { token: string } }) => {
|
||||
return apiData(() => authApi.loginDevToken(arg), {
|
||||
redirectOnUnauthorized: false,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<{ ok: boolean }>;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,42 @@
|
|||
import useSWR, { type SWRConfiguration } from "swr";
|
||||
import type {
|
||||
ApiQuestion,
|
||||
AuthConfigResponse,
|
||||
AuthMeResponse,
|
||||
CommandLogResponse,
|
||||
EventEnvelope,
|
||||
PaginatedBoardRunList,
|
||||
PaginatedRunFileList,
|
||||
PaginatedRunList,
|
||||
PaginatedRunStageList,
|
||||
CommandLogResponse,
|
||||
CommandOutputStream,
|
||||
PaginatedWorkflowListResponse,
|
||||
RunArtifactListResponse,
|
||||
RunBilling,
|
||||
RunProjection,
|
||||
ServerSettings,
|
||||
RunSummary,
|
||||
ServerSettings,
|
||||
SystemInfoResponse,
|
||||
WorkflowDetailResponse,
|
||||
WorkflowSettings,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import type { PaginatedWorkflowListResponse, WorkflowDetailResponse } from "./workflow-api";
|
||||
import {
|
||||
apiFetcher,
|
||||
apiNullableFetcher,
|
||||
apiNullableTextFetcher,
|
||||
apiPaginatedFetcher,
|
||||
apiTextFetcher,
|
||||
apiData,
|
||||
apiNullableData,
|
||||
authApi,
|
||||
fetchAllPages,
|
||||
fetchAllStageEvents,
|
||||
humanInTheLoopApi,
|
||||
insightsApi,
|
||||
runInternalsApi,
|
||||
runOutputsApi,
|
||||
runsApi,
|
||||
settingsApi,
|
||||
systemApi,
|
||||
workflowsApi,
|
||||
type PaginatedEnvelope,
|
||||
} from "./api-client";
|
||||
import { queryKeys } from "./query-keys";
|
||||
import { queryKeys, type RunGraphDirection } from "./query-keys";
|
||||
|
||||
const immutableOptions: SWRConfiguration = {
|
||||
revalidateIfStale: false,
|
||||
|
|
@ -38,27 +48,25 @@ type BoardRunsEnvelope = PaginatedEnvelope<PaginatedBoardRunList["data"][number]
|
|||
Pick<PaginatedBoardRunList, "columns">;
|
||||
|
||||
export function useAuthConfig() {
|
||||
return useSWR<{ methods: string[] }>(queryKeys.auth.config(), apiFetcher, immutableOptions);
|
||||
return useSWR<AuthConfigResponse>(
|
||||
queryKeys.auth.config(),
|
||||
() => apiData(() => authApi.getAuthConfig()),
|
||||
immutableOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthMe() {
|
||||
return useSWR<{
|
||||
user: {
|
||||
login: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
userUrl: string;
|
||||
};
|
||||
provider: string;
|
||||
demoMode: boolean;
|
||||
}>(queryKeys.auth.me(), apiFetcher, { dedupingInterval: 10_000 });
|
||||
return useSWR<AuthMeResponse>(
|
||||
queryKeys.auth.me(),
|
||||
() => apiData(() => authApi.getAuthMe()),
|
||||
{ dedupingInterval: 10_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function useSystemInfo() {
|
||||
return useSWR<SystemInfoResponse>(
|
||||
queryKeys.system.info(),
|
||||
apiFetcher,
|
||||
() => apiData(() => systemApi.getSystemInfo()),
|
||||
immutableOptions,
|
||||
);
|
||||
}
|
||||
|
|
@ -66,28 +74,31 @@ export function useSystemInfo() {
|
|||
export function useBoardsRuns(includeArchived: boolean = false) {
|
||||
return useSWR<BoardRunsEnvelope>(
|
||||
queryKeys.boards.runs(includeArchived),
|
||||
apiPaginatedFetcher,
|
||||
() =>
|
||||
fetchAllPages("board runs", (limit, offset) =>
|
||||
apiData(() => runsApi.listBoardRuns(limit, offset, includeArchived)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRun(id: string | undefined) {
|
||||
return useSWR<RunSummary | null>(
|
||||
id ? queryKeys.runs.detail(id) : null,
|
||||
apiNullableFetcher,
|
||||
() => apiNullableData(() => runsApi.retrieveRun(id!)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunState(id: string | undefined) {
|
||||
return useSWR<RunProjection | null>(
|
||||
id ? queryKeys.runs.state(id) : null,
|
||||
apiNullableFetcher,
|
||||
() => apiNullableData(() => runInternalsApi.getRunState(id!)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunFiles(id: string | undefined) {
|
||||
return useSWR<PaginatedRunFileList | null>(
|
||||
id ? queryKeys.runs.files(id) : null,
|
||||
apiNullableFetcher,
|
||||
() => apiNullableData(() => runOutputsApi.listRunFiles(id!)),
|
||||
{ keepPreviousData: true },
|
||||
);
|
||||
}
|
||||
|
|
@ -95,49 +106,59 @@ export function useRunFiles(id: string | undefined) {
|
|||
export function useRunStages(id: string | undefined) {
|
||||
return useSWR<PaginatedRunStageList | null>(
|
||||
id ? queryKeys.runs.stages(id) : null,
|
||||
apiNullableFetcher,
|
||||
() => apiNullableData(() => runInternalsApi.listRunStages(id!)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunGraph(id: string | undefined, direction?: "LR" | "TB") {
|
||||
export function useRunGraph(id: string | undefined, direction?: RunGraphDirection) {
|
||||
return useSWR<string | null>(
|
||||
id ? queryKeys.runs.graph(id, direction) : null,
|
||||
apiNullableTextFetcher,
|
||||
() => apiNullableData(() => runsApi.retrieveRunGraph(id!, direction)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunGraphSource(id: string | undefined, enabled: boolean) {
|
||||
return useSWR<string | null>(
|
||||
id && enabled ? queryKeys.runs.graphSource(id) : null,
|
||||
apiNullableTextFetcher,
|
||||
() => apiNullableData(() => runsApi.retrieveRunGraphSource(id!)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunLogs(id: string | undefined, refreshInterval?: number) {
|
||||
return useSWR<string | null>(
|
||||
id ? queryKeys.runs.logs(id) : null,
|
||||
apiNullableTextFetcher,
|
||||
() => apiNullableData(() => runInternalsApi.getRunLogs(id!)),
|
||||
refreshInterval ? { refreshInterval } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunArtifacts(id: string | undefined) {
|
||||
return useSWR<RunArtifactListResponse | null>(
|
||||
id ? queryKeys.runs.artifacts(id) : null,
|
||||
() => apiNullableData(() => runInternalsApi.listRunArtifacts(id!)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunSettings<T = WorkflowSettings>(id: string | undefined) {
|
||||
return useSWR<T>(
|
||||
id ? queryKeys.runs.settings(id) : null,
|
||||
apiFetcher,
|
||||
() => apiData(() => runInternalsApi.retrieveRunSettings(id!)) as Promise<T>,
|
||||
immutableOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunBilling(id: string | undefined) {
|
||||
return useSWR<RunBilling>(id ? queryKeys.runs.billing(id) : null, apiFetcher);
|
||||
return useSWR<RunBilling>(
|
||||
id ? queryKeys.runs.billing(id) : null,
|
||||
() => apiData(() => runOutputsApi.retrieveRunBilling(id!)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunQuestions(id: string | undefined, enabled: boolean) {
|
||||
return useSWR<ApiQuestion[]>(
|
||||
id && enabled ? queryKeys.runs.questions(id, 25, 0) : null,
|
||||
async (key) => {
|
||||
const payload = await apiNullableFetcher<{ data: ApiQuestion[] }>(key);
|
||||
async () => {
|
||||
const payload = await apiNullableData(() => humanInTheLoopApi.listRunQuestions(id!, 25, 0));
|
||||
return payload?.data ?? [];
|
||||
},
|
||||
);
|
||||
|
|
@ -146,26 +167,49 @@ export function useRunQuestions(id: string | undefined, enabled: boolean) {
|
|||
export function useRunStageEvents(id: string | undefined, stageId: string | undefined) {
|
||||
return useSWR<EventEnvelope[]>(
|
||||
id && stageId ? queryKeys.runs.stageEvents(id, stageId) : null,
|
||||
fetchAllStageEvents<EventEnvelope>,
|
||||
() =>
|
||||
fetchAllStageEvents(`run ${id} stage ${stageId}`, (sinceSeq, limit) =>
|
||||
apiData(() => runInternalsApi.listStageEvents(id!, stageId!, sinceSeq, limit)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunEventsList(id: string | undefined) {
|
||||
return useSWR<EventEnvelope[]>(
|
||||
id ? queryKeys.runs.events(id, 1000) : null,
|
||||
() =>
|
||||
fetchAllStageEvents(`run ${id} events`, (sinceSeq, limit) =>
|
||||
apiData(() => runInternalsApi.listRunEvents(id!, sinceSeq, limit)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchRunCommandLog(
|
||||
id: string,
|
||||
stageId: string,
|
||||
stream: CommandOutputStream,
|
||||
offset: number,
|
||||
limit?: number,
|
||||
) {
|
||||
return apiFetcher<CommandLogResponse>(
|
||||
queryKeys.runs.stageLog(id, stageId, stream, offset, limit),
|
||||
return apiData<CommandLogResponse>(() =>
|
||||
runInternalsApi.getRunStageCommandLog(id, stageId, offset, limit),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunStageLog(
|
||||
id: string | undefined,
|
||||
stageId: string | undefined,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useSWR<CommandLogResponse>(
|
||||
enabled && id && stageId ? queryKeys.runs.stageLog(id, stageId) : null,
|
||||
() => apiData(() => runInternalsApi.getRunStageCommandLog(id!, stageId!)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkflows() {
|
||||
return useSWR<PaginatedWorkflowListResponse | null>(
|
||||
queryKeys.workflows.list(),
|
||||
apiNullableFetcher,
|
||||
() => apiNullableData(() => workflowsApi.listWorkflows()),
|
||||
immutableOptions,
|
||||
);
|
||||
}
|
||||
|
|
@ -173,7 +217,7 @@ export function useWorkflows() {
|
|||
export function useWorkflow(name: string | undefined) {
|
||||
return useSWR<WorkflowDetailResponse | null>(
|
||||
name ? queryKeys.workflows.detail(name) : null,
|
||||
apiNullableFetcher,
|
||||
() => apiNullableData(() => workflowsApi.retrieveWorkflow(name!)),
|
||||
immutableOptions,
|
||||
);
|
||||
}
|
||||
|
|
@ -181,20 +225,30 @@ export function useWorkflow(name: string | undefined) {
|
|||
export function useWorkflowRuns(name: string | undefined) {
|
||||
return useSWR<PaginatedRunList | null>(
|
||||
name ? queryKeys.workflows.runs(name) : null,
|
||||
apiNullableFetcher,
|
||||
() => apiNullableData(() => workflowsApi.listWorkflowRuns(name!)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useInsightsQueries() {
|
||||
return useSWR(queryKeys.insights.queries(), apiFetcher, immutableOptions);
|
||||
return useSWR(
|
||||
queryKeys.insights.queries(),
|
||||
() => apiData(() => insightsApi.listSavedQueries()),
|
||||
immutableOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export function useInsightsHistory() {
|
||||
return useSWR(queryKeys.insights.history(), apiFetcher, immutableOptions);
|
||||
return useSWR(
|
||||
queryKeys.insights.history(),
|
||||
() => apiData(() => insightsApi.listQueryHistory()),
|
||||
immutableOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export function useServerSettings() {
|
||||
return useSWR<ServerSettings>(queryKeys.settings.server(), apiFetcher, immutableOptions);
|
||||
return useSWR<ServerSettings>(
|
||||
queryKeys.settings.server(),
|
||||
() => apiData(() => settingsApi.retrieveServerSettings()),
|
||||
immutableOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export { apiTextFetcher };
|
||||
|
|
@ -4,16 +4,26 @@ import { queryKeys } from "./query-keys";
|
|||
import { queryKeysForRunEvent } from "./run-events";
|
||||
|
||||
describe("queryKeys", () => {
|
||||
test("uses API path strings as stable SWR keys", () => {
|
||||
expect(queryKeys.auth.me()).toBe("/api/v1/auth/me");
|
||||
expect(queryKeys.runs.files("run 1")).toBe("/api/v1/runs/run%201/files");
|
||||
expect(queryKeys.runs.graph("run-1", "TB")).toBe("/api/v1/runs/run-1/graph?direction=TB");
|
||||
expect(queryKeys.runs.stageLog("run 1", "build step@2", "stderr", 12, 34)).toBe(
|
||||
"/api/v1/runs/run%201/stages/build%20step%402/logs/stderr?offset=12&limit=34",
|
||||
);
|
||||
expect(queryKeys.runs.stageEvents("run 1", "build step", 7, 25)).toBe(
|
||||
"/api/v1/runs/run%201/stages/build%20step/events?since_seq=7&limit=25",
|
||||
);
|
||||
test("uses semantic tuples as stable SWR keys and keeps SSE URLs explicit", () => {
|
||||
expect(queryKeys.auth.me()).toEqual(["auth", "me"]);
|
||||
expect(queryKeys.runs.files("run 1")).toEqual(["runs", "files", "run 1"]);
|
||||
expect(queryKeys.runs.graph("run-1", "TB")).toEqual(["runs", "graph", "run-1", "TB"]);
|
||||
expect(queryKeys.runs.stageLog("run 1", "build step@2", 12, 34)).toEqual([
|
||||
"runs",
|
||||
"stage-log",
|
||||
"run 1",
|
||||
"build step@2",
|
||||
12,
|
||||
34,
|
||||
]);
|
||||
expect(queryKeys.runs.stageEvents("run 1", "build step")).toEqual([
|
||||
"runs",
|
||||
"stage-events",
|
||||
"run 1",
|
||||
"build step",
|
||||
]);
|
||||
expect(queryKeys.system.attachUrl()).toBe("/api/v1/attach");
|
||||
expect(queryKeys.runs.attachUrl("run 1")).toBe("/api/v1/runs/run%201/attach");
|
||||
});
|
||||
|
||||
test("event-mapped keys match query hook resources", () => {
|
||||
|
|
|
|||
|
|
@ -1,84 +1,61 @@
|
|||
export type RunGraphDirection = "LR" | "TB" | "BT" | "RL";
|
||||
export type QueryKey = readonly unknown[];
|
||||
|
||||
function pathSegment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function withQuery(path: string, params: Record<string, string | number | null | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value != null) search.set(key, String(value));
|
||||
}
|
||||
const query = search.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
export const queryKeys = {
|
||||
auth: {
|
||||
config: () => "/api/v1/auth/config",
|
||||
me: () => "/api/v1/auth/me",
|
||||
config: () => ["auth", "config"] as const,
|
||||
me: () => ["auth", "me"] as const,
|
||||
loginDevToken: () => ["auth", "login-dev-token"] as const,
|
||||
},
|
||||
demo: {
|
||||
toggle: () => "/api/v1/demo/toggle",
|
||||
toggle: () => ["demo", "toggle"] as const,
|
||||
},
|
||||
system: {
|
||||
info: () => "/api/v1/system/info",
|
||||
attach: () => "/api/v1/attach",
|
||||
info: () => ["system", "info"] as const,
|
||||
attachUrl: () => "/api/v1/attach",
|
||||
},
|
||||
boards: {
|
||||
runs: (includeArchived = false) =>
|
||||
withQuery("/api/v1/boards/runs", {
|
||||
include_archived: includeArchived ? "true" : null,
|
||||
}),
|
||||
runs: (includeArchived = false) => ["boards", "runs", includeArchived] as const,
|
||||
},
|
||||
runs: {
|
||||
detail: (id: string) => `/api/v1/runs/${pathSegment(id)}`,
|
||||
state: (id: string) => `/api/v1/runs/${pathSegment(id)}/state`,
|
||||
files: (id: string) => `/api/v1/runs/${pathSegment(id)}/files`,
|
||||
stages: (id: string) => `/api/v1/runs/${pathSegment(id)}/stages`,
|
||||
graph: (id: string, direction?: "LR" | "TB") =>
|
||||
withQuery(`/api/v1/runs/${pathSegment(id)}/graph`, { direction }),
|
||||
graphSource: (id: string) => `/api/v1/runs/${pathSegment(id)}/graph/source`,
|
||||
settings: (id: string) => `/api/v1/runs/${pathSegment(id)}/settings`,
|
||||
logs: (id: string) => `/api/v1/runs/${pathSegment(id)}/logs`,
|
||||
billing: (id: string) => `/api/v1/runs/${pathSegment(id)}/billing`,
|
||||
detail: (id: string) => ["runs", "detail", id] as const,
|
||||
state: (id: string) => ["runs", "state", id] as const,
|
||||
files: (id: string) => ["runs", "files", id] as const,
|
||||
stages: (id: string) => ["runs", "stages", id] as const,
|
||||
graph: (id: string, direction?: RunGraphDirection) =>
|
||||
["runs", "graph", id, direction ?? null] as const,
|
||||
graphSource: (id: string) => ["runs", "graph-source", id] as const,
|
||||
settings: (id: string) => ["runs", "settings", id] as const,
|
||||
logs: (id: string) => ["runs", "logs", id] as const,
|
||||
artifacts: (id: string) => ["runs", "artifacts", id] as const,
|
||||
billing: (id: string) => ["runs", "billing", id] as const,
|
||||
questions: (id: string, limit = 1, offset = 0) =>
|
||||
withQuery(`/api/v1/runs/${pathSegment(id)}/questions`, {
|
||||
"page[limit]": limit,
|
||||
"page[offset]": offset,
|
||||
}),
|
||||
events: (id: string, limit = 1000) =>
|
||||
withQuery(`/api/v1/runs/${pathSegment(id)}/events`, { limit }),
|
||||
stageEvents: (id: string, stageId: string, sinceSeq?: number, limit?: number) =>
|
||||
withQuery(`/api/v1/runs/${pathSegment(id)}/stages/${pathSegment(stageId)}/events`, {
|
||||
since_seq: sinceSeq,
|
||||
limit,
|
||||
}),
|
||||
stageLog: (
|
||||
id: string,
|
||||
stageId: string,
|
||||
stream: "stdout" | "stderr",
|
||||
offset = 0,
|
||||
limit = 65_536,
|
||||
) =>
|
||||
withQuery(
|
||||
`/api/v1/runs/${pathSegment(id)}/stages/${pathSegment(stageId)}/logs/${stream}`,
|
||||
{ offset, limit },
|
||||
),
|
||||
preview: (id: string) => `/api/v1/runs/${pathSegment(id)}/preview`,
|
||||
cancel: (id: string) => `/api/v1/runs/${pathSegment(id)}/cancel`,
|
||||
archive: (id: string) => `/api/v1/runs/${pathSegment(id)}/archive`,
|
||||
unarchive: (id: string) => `/api/v1/runs/${pathSegment(id)}/unarchive`,
|
||||
attach: (id: string) => `/api/v1/runs/${pathSegment(id)}/attach`,
|
||||
["runs", "questions", id, limit, offset] as const,
|
||||
events: (id: string, limit = 1000) => ["runs", "events", id, limit] as const,
|
||||
stageEvents: (id: string, stageId: string) =>
|
||||
["runs", "stage-events", id, stageId] as const,
|
||||
stageLog: (id: string, stageId: string, offset = 0, limit = 65_536) =>
|
||||
["runs", "stage-log", id, stageId, offset, limit] as const,
|
||||
preview: (id: string) => ["runs", "preview", id] as const,
|
||||
cancel: (id: string) => ["runs", "cancel", id] as const,
|
||||
archive: (id: string) => ["runs", "archive", id] as const,
|
||||
unarchive: (id: string) => ["runs", "unarchive", id] as const,
|
||||
attachUrl: (id: string) => `/api/v1/runs/${pathSegment(id)}/attach`,
|
||||
},
|
||||
workflows: {
|
||||
list: () => "/api/v1/workflows",
|
||||
detail: (name: string) => `/api/v1/workflows/${pathSegment(name)}`,
|
||||
runs: (name: string) => `/api/v1/workflows/${pathSegment(name)}/runs`,
|
||||
list: () => ["workflows", "list"] as const,
|
||||
detail: (name: string) => ["workflows", "detail", name] as const,
|
||||
runs: (name: string) => ["workflows", "runs", name] as const,
|
||||
},
|
||||
insights: {
|
||||
queries: () => "/api/v1/insights/queries",
|
||||
history: () => "/api/v1/insights/history",
|
||||
queries: () => ["insights", "queries"] as const,
|
||||
history: () => ["insights", "history"] as const,
|
||||
},
|
||||
settings: {
|
||||
server: () => "/api/v1/settings",
|
||||
server: () => ["settings", "server"] as const,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import type { AxiosAdapter } from "axios";
|
||||
|
||||
import {
|
||||
archiveRun,
|
||||
|
|
@ -10,27 +11,38 @@ import {
|
|||
mapError,
|
||||
unarchiveRun,
|
||||
} from "./run-actions";
|
||||
import { generatedAxios } from "./api-client";
|
||||
|
||||
type StubResponseInit = {
|
||||
status: number;
|
||||
body?: string;
|
||||
body?: unknown;
|
||||
statusText?: string;
|
||||
};
|
||||
|
||||
function stubFetchOnce(init: StubResponseInit) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(init.body ?? "", {
|
||||
status: init.status,
|
||||
statusText: init.statusText ?? "",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)) as typeof fetch;
|
||||
const originalAdapter = generatedAxios.defaults.adapter;
|
||||
|
||||
return () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
};
|
||||
function stubGeneratedAxiosOnce(init: StubResponseInit) {
|
||||
generatedAxios.defaults.adapter = (async (config) => {
|
||||
if (init.status >= 400) {
|
||||
throw {
|
||||
isAxiosError: true,
|
||||
message: init.statusText ?? `HTTP ${init.status}`,
|
||||
response: {
|
||||
status: init.status,
|
||||
statusText: init.statusText ?? "",
|
||||
data: init.body ?? null,
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: init.body,
|
||||
status: init.status,
|
||||
statusText: init.statusText ?? "",
|
||||
headers: {},
|
||||
config,
|
||||
};
|
||||
}) as AxiosAdapter;
|
||||
}
|
||||
|
||||
async function expectLifecycleError(
|
||||
|
|
@ -45,22 +57,19 @@ async function expectLifecycleError(
|
|||
}
|
||||
|
||||
describe("run lifecycle actions", () => {
|
||||
let restoreFetch: (() => void) | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = undefined;
|
||||
generatedAxios.defaults.adapter = originalAdapter;
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
test("cancelRun parses a 200 response", async () => {
|
||||
restoreFetch = stubFetchOnce({
|
||||
stubGeneratedAxiosOnce({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
id: "run-1",
|
||||
status: { kind: "failed", reason: "cancelled" },
|
||||
created_at: "2026-04-20T12:00:00Z",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await cancelRun("run-1");
|
||||
|
|
@ -71,16 +80,16 @@ describe("run lifecycle actions", () => {
|
|||
});
|
||||
|
||||
test("archiveRun parses a 200 response", async () => {
|
||||
restoreFetch = stubFetchOnce({
|
||||
stubGeneratedAxiosOnce({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
id: "run-1",
|
||||
status: {
|
||||
kind: "archived",
|
||||
prior: { kind: "succeeded", reason: "completed" },
|
||||
},
|
||||
created_at: "2026-04-20T12:00:00Z",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await archiveRun("run-1");
|
||||
|
|
@ -88,13 +97,13 @@ describe("run lifecycle actions", () => {
|
|||
});
|
||||
|
||||
test("unarchiveRun parses a 200 response", async () => {
|
||||
restoreFetch = stubFetchOnce({
|
||||
stubGeneratedAxiosOnce({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
id: "run-1",
|
||||
status: { kind: "succeeded", reason: "completed" },
|
||||
created_at: "2026-04-20T12:00:00Z",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await unarchiveRun("run-1");
|
||||
|
|
@ -102,11 +111,11 @@ describe("run lifecycle actions", () => {
|
|||
});
|
||||
|
||||
test("404 and 409 preserve the parsed error envelope", async () => {
|
||||
restoreFetch = stubFetchOnce({
|
||||
stubGeneratedAxiosOnce({
|
||||
status: 404,
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const notFound = await expectLifecycleError(cancelRun("missing-run"));
|
||||
expect(notFound).toEqual({
|
||||
|
|
@ -114,11 +123,11 @@ describe("run lifecycle actions", () => {
|
|||
errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
|
||||
});
|
||||
|
||||
restoreFetch = stubFetchOnce({
|
||||
stubGeneratedAxiosOnce({
|
||||
status: 409,
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const conflict = await expectLifecycleError(archiveRun("run-1"));
|
||||
expect(conflict).toEqual({
|
||||
|
|
@ -128,7 +137,7 @@ describe("run lifecycle actions", () => {
|
|||
});
|
||||
|
||||
test("non-JSON error bodies fall back to an empty error list", async () => {
|
||||
restoreFetch = stubFetchOnce({
|
||||
stubGeneratedAxiosOnce({
|
||||
status: 409,
|
||||
body: "<html>conflict</html>",
|
||||
statusText: "Conflict",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import type { ErrorResponseEntry, RunStatusResponse } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { apiRequest } from "./api-client";
|
||||
import { queryKeys } from "./query-keys";
|
||||
import {
|
||||
ApiError,
|
||||
apiData,
|
||||
apiResponse,
|
||||
requestSignalOptions,
|
||||
runsApi,
|
||||
} from "./api-client";
|
||||
import type { RunStatus } from "../data/runs";
|
||||
|
||||
export type LifecycleAction = "cancel" | "archive" | "unarchive";
|
||||
|
|
@ -38,6 +43,15 @@ export async function unarchiveRun(id: string, request?: Request): Promise<RunSt
|
|||
return runLifecycleAction(id, "unarchive", request);
|
||||
}
|
||||
|
||||
export async function deleteRun(id: string, request?: Request): Promise<void> {
|
||||
try {
|
||||
await apiResponse(() => runsApi.deleteRun(id, undefined, requestSignalOptions(request)));
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return;
|
||||
throw lifecycleActionErrorFromError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function canCancel(status: string | null | undefined): boolean {
|
||||
return !!status && CANCELABLE_STATUSES.has(status as RunStatus);
|
||||
}
|
||||
|
|
@ -50,10 +64,25 @@ export function canUnarchive(status: string | null | undefined): boolean {
|
|||
return status === "archived";
|
||||
}
|
||||
|
||||
export function canDelete(status: string | null | undefined): boolean {
|
||||
return status === "archived";
|
||||
}
|
||||
|
||||
export function isTerminalCancelledRun(run: RunStatusResponse): boolean {
|
||||
return run.status.kind === "failed" && run.status.reason === "cancelled";
|
||||
}
|
||||
|
||||
export function deleteErrorMessage(error: unknown): string {
|
||||
if (isLifecycleActionError(error)) {
|
||||
if (error.status === 409) {
|
||||
return "Active runs can't be deleted.";
|
||||
}
|
||||
const detail = error.errors[0]?.detail?.trim();
|
||||
if (detail) return detail;
|
||||
}
|
||||
return "Couldn't delete the run right now. Try again.";
|
||||
}
|
||||
|
||||
export function mapError(error: unknown, action: LifecycleAction): string {
|
||||
if (isLifecycleActionError(error)) {
|
||||
if (error.status === 404) {
|
||||
|
|
@ -91,43 +120,33 @@ async function runLifecycleAction(
|
|||
action: LifecycleAction,
|
||||
request?: Request,
|
||||
): Promise<RunStatusResponse> {
|
||||
const response = await apiRequest(queryKeys.runs[action](id), {
|
||||
init: {
|
||||
method: "POST",
|
||||
},
|
||||
request,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseLifecycleActionError(response);
|
||||
try {
|
||||
switch (action) {
|
||||
case "cancel":
|
||||
return await apiData(() => runsApi.cancelRun(id, requestSignalOptions(request)));
|
||||
case "archive":
|
||||
return await apiData(() => runsApi.archiveRun(id, requestSignalOptions(request)));
|
||||
case "unarchive":
|
||||
return await apiData(() => runsApi.unarchiveRun(id, requestSignalOptions(request)));
|
||||
}
|
||||
} catch (error) {
|
||||
throw lifecycleActionErrorFromError(error);
|
||||
}
|
||||
|
||||
return response.json() as Promise<RunStatusResponse>;
|
||||
}
|
||||
|
||||
async function parseLifecycleActionError(response: Response): Promise<LifecycleActionError> {
|
||||
let bodyText = "";
|
||||
try {
|
||||
bodyText = await response.text();
|
||||
} catch {
|
||||
// Ignore body read failures and fall back to the status only.
|
||||
}
|
||||
function lifecycleActionErrorFromError(error: unknown): LifecycleActionError {
|
||||
if (!(error instanceof ApiError)) throw error;
|
||||
return {
|
||||
status: error.status,
|
||||
errors: parseLifecycleErrors(error.body),
|
||||
};
|
||||
}
|
||||
|
||||
if (!bodyText) {
|
||||
return { status: response.status, errors: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = JSON.parse(bodyText) as { errors?: unknown };
|
||||
if (!Array.isArray(body.errors)) {
|
||||
return { status: response.status, errors: [] };
|
||||
}
|
||||
|
||||
const errors = body.errors.filter(isErrorResponseEntry);
|
||||
return { status: response.status, errors };
|
||||
} catch {
|
||||
return { status: response.status, errors: [] };
|
||||
}
|
||||
function parseLifecycleErrors(body: unknown): ErrorResponseEntry[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
const errors = (body as { errors?: unknown }).errors;
|
||||
if (!Array.isArray(errors)) return [];
|
||||
return errors.filter(isErrorResponseEntry);
|
||||
}
|
||||
|
||||
export function isLifecycleActionError(value: unknown): value is LifecycleActionError {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
type BroadcastChannelLike,
|
||||
} from "./cross-tab-sse";
|
||||
import { queryKeys } from "./query-keys";
|
||||
import type { EventSourceLike } from "./sse";
|
||||
import type { EventSourceLike, SseKey } from "./sse";
|
||||
|
||||
type MessageHandler = ((event: { data: string }) => void) | null;
|
||||
|
||||
|
|
@ -68,13 +68,20 @@ describe("queryKeysForRunEvent", () => {
|
|||
queryKeys.runs.stageEvents("run-1", "agent@1"),
|
||||
]);
|
||||
});
|
||||
|
||||
test("stage-scoped interrupt injection invalidates run events and stage events", () => {
|
||||
expect(queryKeysForRunEvent("run-1", "agent.interrupt.injected", "nap@1")).toEqual([
|
||||
queryKeys.runs.events("run-1", 1000),
|
||||
queryKeys.runs.stageEvents("run-1", "nap@1"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeToRunEvents", () => {
|
||||
test("coordinated mode uses the global attach stream and filters by run_id", async () => {
|
||||
const source = new FakeEventSource();
|
||||
const created: string[] = [];
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createCoordinator((url) => {
|
||||
created.push(url);
|
||||
return source;
|
||||
|
|
@ -107,7 +114,7 @@ describe("subscribeToRunEvents", () => {
|
|||
|
||||
test("coordinated terminal events invalidate without closing the global stream", async () => {
|
||||
const source = new FakeEventSource();
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createCoordinator(() => source);
|
||||
const cleanup = subscribeToRunEvents(
|
||||
"run-terminal",
|
||||
|
|
@ -124,8 +131,8 @@ describe("subscribeToRunEvents", () => {
|
|||
|
||||
source.emit({ event: "run.failed", run_id: "run-terminal" });
|
||||
expect(source.closed).toBe(false);
|
||||
expect(keys).toContain(queryKeys.runs.files("run-terminal"));
|
||||
expect(keys).toContain(queryKeys.runs.billing("run-terminal"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.files("run-terminal"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.billing("run-terminal"));
|
||||
|
||||
keys.length = 0;
|
||||
source.emit({ event: "run.archived", run_id: "run-terminal" });
|
||||
|
|
@ -139,9 +146,9 @@ describe("subscribeToRunEvents", () => {
|
|||
test("fallback refcounts run-scoped sources and keeps mutators active until final unsubscribe", () => {
|
||||
const source = new FakeEventSource();
|
||||
const created: string[] = [];
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createFallbackCoordinator();
|
||||
const mutate = (key: string) => {
|
||||
const mutate = (key: SseKey) => {
|
||||
keys.push(key);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
|
@ -170,9 +177,9 @@ describe("subscribeToRunEvents", () => {
|
|||
test("fallback runs payload callbacks for later subscribers on a shared source", () => {
|
||||
const source = new FakeEventSource();
|
||||
const seen: string[] = [];
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createFallbackCoordinator();
|
||||
const mutate = (key: string) => {
|
||||
const mutate = (key: SseKey) => {
|
||||
keys.push(key);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
|
@ -204,7 +211,7 @@ describe("subscribeToRunEvents", () => {
|
|||
|
||||
test("fallback terminal events close the source after invalidating keys", () => {
|
||||
const source = new FakeEventSource();
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createFallbackCoordinator();
|
||||
const cleanup = subscribeToRunEvents(
|
||||
"run-terminal",
|
||||
|
|
@ -219,8 +226,8 @@ describe("subscribeToRunEvents", () => {
|
|||
source.emit({ event: "run.failed" });
|
||||
|
||||
expect(source.closed).toBe(true);
|
||||
expect(keys).toContain(queryKeys.runs.files("run-terminal"));
|
||||
expect(keys).toContain(queryKeys.runs.billing("run-terminal"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.files("run-terminal"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.billing("run-terminal"));
|
||||
|
||||
cleanup();
|
||||
coordinator.close();
|
||||
|
|
@ -228,7 +235,7 @@ describe("subscribeToRunEvents", () => {
|
|||
|
||||
test("envelope with suffixed stage_id invalidates stageEvents(runId, stageId)", async () => {
|
||||
const source = new FakeEventSource();
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createCoordinator(() => source);
|
||||
const cleanup = subscribeToRunEvents(
|
||||
"run-stage",
|
||||
|
|
@ -248,12 +255,12 @@ describe("subscribeToRunEvents", () => {
|
|||
node_id: "verify",
|
||||
});
|
||||
|
||||
expect(keys).toContain(queryKeys.runs.stageEvents("run-stage", "verify@2"));
|
||||
expect(keys).toContain(queryKeys.runs.stages("run-stage"));
|
||||
expect(keys).toContain(queryKeys.runs.events("run-stage", 1000));
|
||||
expect(keys).toContain(queryKeys.runs.graph("run-stage", "LR"));
|
||||
expect(keys).toContain(queryKeys.runs.detail("run-stage"));
|
||||
expect(keys).not.toContain(queryKeys.runs.stageEvents("run-stage", "verify"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify@2"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.stages("run-stage"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.events("run-stage", 1000));
|
||||
expect(keys).toContainEqual(queryKeys.runs.graph("run-stage", "LR"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.detail("run-stage"));
|
||||
expect(keys).not.toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify"));
|
||||
|
||||
cleanup();
|
||||
coordinator.close();
|
||||
|
|
@ -261,7 +268,7 @@ describe("subscribeToRunEvents", () => {
|
|||
|
||||
test("falls back to node_id when an event has no stage_id", async () => {
|
||||
const source = new FakeEventSource();
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createCoordinator(() => source);
|
||||
const cleanup = subscribeToRunEvents(
|
||||
"run-stage-node",
|
||||
|
|
@ -276,8 +283,8 @@ describe("subscribeToRunEvents", () => {
|
|||
await waitFor(() => source.onmessage !== null);
|
||||
source.emit({ event: "stage.started", run_id: "run-stage-node", node_id: "verify" });
|
||||
|
||||
expect(keys).toContain(queryKeys.runs.stageEvents("run-stage-node", "verify"));
|
||||
expect(keys).toContain(queryKeys.runs.stages("run-stage-node"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage-node", "verify"));
|
||||
expect(keys).toContainEqual(queryKeys.runs.stages("run-stage-node"));
|
||||
|
||||
cleanup();
|
||||
coordinator.close();
|
||||
|
|
@ -287,7 +294,7 @@ describe("subscribeToRunEvents", () => {
|
|||
const firstSource = new FakeEventSource();
|
||||
const secondSource = new FakeEventSource();
|
||||
const sources = [firstSource, secondSource];
|
||||
const keys: string[] = [];
|
||||
const keys: SseKey[] = [];
|
||||
const coordinator = createFallbackCoordinator();
|
||||
|
||||
const firstCleanup = subscribeToRunEvents(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
type EventPayload,
|
||||
type EventSourceLike,
|
||||
type MutateFn,
|
||||
type SseKey,
|
||||
type SharedEventSubscription,
|
||||
} from "./sse";
|
||||
|
||||
|
|
@ -63,9 +64,12 @@ const STAGE_EVENTS = new Set([
|
|||
// to run-scoped invalidations (stages list, graph, detail).
|
||||
export const STAGE_ACTIVITY_EVENT_TYPES = [
|
||||
"stage.prompt",
|
||||
"prompt.completed",
|
||||
"agent.message",
|
||||
"agent.tool.started",
|
||||
"agent.tool.completed",
|
||||
"agent.steering.injected",
|
||||
"agent.interrupt.injected",
|
||||
"command.started",
|
||||
"command.completed",
|
||||
] as const;
|
||||
|
|
@ -81,6 +85,7 @@ const STEERING_EVENTS = new Set([
|
|||
"run.interrupt",
|
||||
"run.steer",
|
||||
"agent.steering.injected",
|
||||
"agent.interrupt.injected",
|
||||
"agent.session.activated",
|
||||
"agent.session.deactivated",
|
||||
"agent.steer.buffered",
|
||||
|
|
@ -91,7 +96,7 @@ export function queryKeysForRunEvent(
|
|||
runId: string,
|
||||
event: string,
|
||||
stageId?: string,
|
||||
): string[] {
|
||||
): SseKey[] {
|
||||
if (event === "checkpoint.completed") {
|
||||
return [queryKeys.runs.files(runId)];
|
||||
}
|
||||
|
|
@ -119,7 +124,7 @@ export function queryKeysForRunEvent(
|
|||
}
|
||||
|
||||
if (STAGE_EVENTS.has(event)) {
|
||||
const keys = [
|
||||
const keys: SseKey[] = [
|
||||
queryKeys.runs.stages(runId),
|
||||
queryKeys.runs.billing(runId),
|
||||
queryKeys.runs.events(runId, 1000),
|
||||
|
|
@ -133,18 +138,18 @@ export function queryKeysForRunEvent(
|
|||
return keys;
|
||||
}
|
||||
|
||||
if (STAGE_ACTIVITY_EVENTS.has(event)) {
|
||||
return stageId ? [queryKeys.runs.stageEvents(runId, stageId)] : [];
|
||||
}
|
||||
|
||||
if (STEERING_EVENTS.has(event)) {
|
||||
const keys = [queryKeys.runs.events(runId, 1000)];
|
||||
const keys: SseKey[] = [queryKeys.runs.events(runId, 1000)];
|
||||
if (stageId) {
|
||||
keys.push(queryKeys.runs.stageEvents(runId, stageId));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
if (STAGE_ACTIVITY_EVENTS.has(event)) {
|
||||
return stageId ? [queryKeys.runs.stageEvents(runId, stageId)] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +174,7 @@ export function subscribeToRunEvents(
|
|||
subscribeToSharedEventSource<RunEventPayload>({
|
||||
subscriptions,
|
||||
subscriptionKey: runId,
|
||||
url: queryKeys.runs.attach(runId),
|
||||
url: queryKeys.runs.attachUrl(runId),
|
||||
mutate,
|
||||
eventSourceFactory,
|
||||
debounceMs,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { MutatorCallback } from "swr";
|
||||
import type { Key, MutatorCallback } from "swr";
|
||||
|
||||
export type MutateFn = (key: string) => ReturnType<MutatorCallback>;
|
||||
export type SseKey = Key;
|
||||
export type MutateFn = (key: SseKey) => ReturnType<MutatorCallback>;
|
||||
|
||||
export interface EventPayload {
|
||||
event?: string;
|
||||
|
|
@ -13,7 +14,7 @@ export interface EventSourceLike {
|
|||
}
|
||||
|
||||
export interface EventInvalidation {
|
||||
keys: string[];
|
||||
keys: SseKey[];
|
||||
close?: boolean;
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
|
@ -25,10 +26,14 @@ export interface SharedEventSubscription {
|
|||
refcount: number;
|
||||
mutators: Map<MutateFn, number>;
|
||||
resolvers: Map<symbol, EventResolver>;
|
||||
pendingKeys: Set<string>;
|
||||
pendingKeys: Map<string, SseKey>;
|
||||
debounceTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
export function sseKeyDedupeId(key: SseKey): string {
|
||||
return stringifyKeyValue(key);
|
||||
}
|
||||
|
||||
export function createBrowserEventSource(url: string): EventSourceLike {
|
||||
return new EventSource(url);
|
||||
}
|
||||
|
|
@ -58,7 +63,7 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
|
|||
refcount: 0,
|
||||
mutators: new Map(),
|
||||
resolvers: new Map(),
|
||||
pendingKeys: new Set(),
|
||||
pendingKeys: new Map(),
|
||||
debounceTimer: null,
|
||||
};
|
||||
subscriptions.set(subscriptionKey, subscription);
|
||||
|
|
@ -74,17 +79,19 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
|
|||
return;
|
||||
}
|
||||
|
||||
const keys = new Set<string>();
|
||||
const keys = new Map<string, SseKey>();
|
||||
let close = false;
|
||||
let immediate = false;
|
||||
for (const resolver of current.resolvers.values()) {
|
||||
const invalidation = resolver(payload);
|
||||
for (const key of invalidation.keys) keys.add(key);
|
||||
for (const key of invalidation.keys) {
|
||||
keys.set(sseKeyDedupeId(key), key);
|
||||
}
|
||||
close ||= Boolean(invalidation.close);
|
||||
immediate ||= Boolean(invalidation.immediate);
|
||||
}
|
||||
|
||||
queueInvalidations(current, [...keys], { debounceMs, immediate });
|
||||
queueInvalidations(current, [...keys.values()], { debounceMs, immediate });
|
||||
|
||||
if (close) {
|
||||
closeSharedEventSource(subscriptions, subscriptionKey, { flushPending: true });
|
||||
|
|
@ -122,7 +129,7 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
|
|||
|
||||
function queueInvalidations(
|
||||
subscription: SharedEventSubscription,
|
||||
keys: string[],
|
||||
keys: SseKey[],
|
||||
{
|
||||
debounceMs,
|
||||
immediate,
|
||||
|
|
@ -133,7 +140,7 @@ function queueInvalidations(
|
|||
) {
|
||||
if (keys.length === 0) return;
|
||||
for (const key of keys) {
|
||||
subscription.pendingKeys.add(key);
|
||||
subscription.pendingKeys.set(sseKeyDedupeId(key), key);
|
||||
}
|
||||
|
||||
if (immediate || debounceMs <= 0) {
|
||||
|
|
@ -152,7 +159,7 @@ function queueInvalidations(
|
|||
|
||||
function flushInvalidations(subscription: SharedEventSubscription) {
|
||||
if (subscription.pendingKeys.size === 0) return;
|
||||
const keys = [...subscription.pendingKeys];
|
||||
const keys = [...subscription.pendingKeys.values()];
|
||||
subscription.pendingKeys.clear();
|
||||
|
||||
for (const mutator of subscription.mutators.keys()) {
|
||||
|
|
@ -179,3 +186,17 @@ function closeSharedEventSource(
|
|||
subscription.source.close();
|
||||
subscriptions.delete(subscriptionKey);
|
||||
}
|
||||
|
||||
function stringifyKeyValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stringifyKeyValue(item)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stringifyKeyValue(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { PaginatedRunStageList, StageState } from "@qltysh/fabro-api-client";
|
||||
import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fabro-api-client";
|
||||
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar";
|
||||
|
|
@ -8,6 +8,7 @@ function makeStage(nodeId: string, visit: number, status: StageState): Stage {
|
|||
return {
|
||||
id: `${nodeId}@${visit}`,
|
||||
name: nodeId,
|
||||
handler: "agent",
|
||||
nodeId,
|
||||
visit,
|
||||
status,
|
||||
|
|
@ -23,6 +24,7 @@ describe("mapRunStagesToSidebarStages", () => {
|
|||
{
|
||||
id: "apply-changes@1",
|
||||
name: "Apply Changes",
|
||||
handler: "command",
|
||||
status: "succeeded",
|
||||
duration_secs: 12.5,
|
||||
node_id: "apply",
|
||||
|
|
@ -31,6 +33,7 @@ describe("mapRunStagesToSidebarStages", () => {
|
|||
{
|
||||
id: "apply-changes@2",
|
||||
name: "Apply Changes",
|
||||
handler: "agent",
|
||||
status: "running",
|
||||
node_id: "apply",
|
||||
visit: 2,
|
||||
|
|
@ -43,11 +46,13 @@ describe("mapRunStagesToSidebarStages", () => {
|
|||
expect(result).toHaveLength(2);
|
||||
|
||||
expect(result[0].id).toBe("apply-changes@1");
|
||||
expect(result[0].handler).toBe("command");
|
||||
expect(result[0].nodeId).toBe("apply");
|
||||
expect(result[0].visit).toBe(1);
|
||||
expect(formatStageLabel(result[0])).toBe("Apply Changes");
|
||||
|
||||
expect(result[1].id).toBe("apply-changes@2");
|
||||
expect(result[1].handler).toBe("agent");
|
||||
expect(result[1].nodeId).toBe("apply");
|
||||
expect(result[1].visit).toBe(2);
|
||||
expect(formatStageLabel(result[1])).toBe("Apply Changes (2)");
|
||||
|
|
@ -59,6 +64,7 @@ describe("mapRunStagesToSidebarStages", () => {
|
|||
{
|
||||
id: "start@1",
|
||||
name: "start",
|
||||
handler: "start",
|
||||
status: "succeeded",
|
||||
node_id: "start",
|
||||
visit: 1,
|
||||
|
|
@ -66,6 +72,7 @@ describe("mapRunStagesToSidebarStages", () => {
|
|||
{
|
||||
id: "verify@1",
|
||||
name: "verify",
|
||||
handler: "human",
|
||||
status: "succeeded",
|
||||
node_id: "verify",
|
||||
visit: 1,
|
||||
|
|
@ -73,6 +80,7 @@ describe("mapRunStagesToSidebarStages", () => {
|
|||
{
|
||||
id: "exit@1",
|
||||
name: "exit",
|
||||
handler: "exit",
|
||||
status: "succeeded",
|
||||
node_id: "exit",
|
||||
visit: 1,
|
||||
|
|
@ -91,6 +99,7 @@ describe("mapRunStagesToSidebarStages", () => {
|
|||
{
|
||||
id: "verify@1",
|
||||
name: "verify",
|
||||
handler: "wait",
|
||||
status: "running",
|
||||
node_id: "verify",
|
||||
visit: 1,
|
||||
|
|
@ -101,6 +110,24 @@ describe("mapRunStagesToSidebarStages", () => {
|
|||
|
||||
expect(mapRunStagesToSidebarStages(stages)[0].duration).toBe("--");
|
||||
});
|
||||
|
||||
test("preserves the authoritative handler for renderer dispatch", () => {
|
||||
const stages: PaginatedRunStageList = {
|
||||
data: [
|
||||
{
|
||||
id: "approval@1",
|
||||
name: "approval",
|
||||
handler: "human" satisfies StageHandler,
|
||||
status: "pending",
|
||||
node_id: "approval",
|
||||
visit: 1,
|
||||
},
|
||||
],
|
||||
meta: { has_more: false },
|
||||
};
|
||||
|
||||
expect(mapRunStagesToSidebarStages(stages)[0].handler).toBe("human");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateGraphNodeStatus", () => {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function mapRunStagesToSidebarStages(
|
|||
.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
handler: stage.handler,
|
||||
nodeId: stage.node_id,
|
||||
visit: stage.visit,
|
||||
status: stage.status,
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import type { PaginationMeta, WorkflowSettings } from "@qltysh/fabro-api-client";
|
||||
|
||||
export type WorkflowSettingsSnapshot = WorkflowSettings;
|
||||
|
||||
export interface WorkflowScheduleSummary {
|
||||
expression: string;
|
||||
next_run?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowLastRunSummary {
|
||||
ran_at?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowListItem {
|
||||
name: string;
|
||||
slug: string;
|
||||
filename: string;
|
||||
last_run?: WorkflowLastRunSummary | null;
|
||||
schedule?: WorkflowScheduleSummary | null;
|
||||
}
|
||||
|
||||
export interface PaginatedWorkflowListResponse {
|
||||
data: WorkflowListItem[];
|
||||
pagination?: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface WorkflowDetailResponse {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
filename: string;
|
||||
settings: WorkflowSettingsSnapshot;
|
||||
graph: string;
|
||||
}
|
||||
|
|
@ -16,14 +16,17 @@ import * as RunDetail from "./routes/run-detail";
|
|||
import * as RunOverview from "./routes/run-overview";
|
||||
import * as RunStages from "./routes/run-stages";
|
||||
import * as RunSettings from "./routes/run-settings";
|
||||
import * as RunGraph from "./routes/run-graph";
|
||||
import * as RunSource from "./routes/run-source";
|
||||
import * as RunLogs from "./routes/run-logs";
|
||||
import * as RunEvents from "./routes/run-events";
|
||||
import * as RunArtifacts from "./routes/run-artifacts";
|
||||
import * as RunFiles from "./routes/run-files";
|
||||
import * as RunBilling from "./routes/run-billing";
|
||||
import * as Insights from "./routes/insights";
|
||||
import * as InsightsEditor from "./routes/insights-editor";
|
||||
import * as InsightsNew from "./routes/insights-new";
|
||||
import * as Settings from "./routes/settings";
|
||||
import * as Profile from "./routes/profile";
|
||||
import AppShellModule from "./layouts/app-shell";
|
||||
|
||||
type RouteModule = {
|
||||
|
|
@ -92,8 +95,10 @@ export const routes: RouteObject[] = [
|
|||
route("stages", RunStages),
|
||||
route("stages/:stageId", RunStages),
|
||||
route("settings", RunSettings),
|
||||
route("graph", RunGraph),
|
||||
route("source", RunSource),
|
||||
route("logs", RunLogs),
|
||||
route("events", RunEvents),
|
||||
route("artifacts", RunArtifacts),
|
||||
route("files", RunFiles),
|
||||
route("billing", RunBilling),
|
||||
],
|
||||
|
|
@ -105,6 +110,7 @@ export const routes: RouteObject[] = [
|
|||
],
|
||||
}),
|
||||
route("settings", Settings),
|
||||
route("profile", Profile),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
73
apps/fabro-web/app/routes/profile.tsx
Normal file
73
apps/fabro-web/app/routes/profile.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { AuthSessionUser } from "@qltysh/fabro-api-client";
|
||||
import { useAuthMe } from "../lib/queries";
|
||||
import {
|
||||
Mono,
|
||||
Muted,
|
||||
Panel,
|
||||
PanelSkeleton,
|
||||
Row,
|
||||
UrlValue,
|
||||
} from "../components/settings-panel";
|
||||
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Profile — Fabro" }];
|
||||
}
|
||||
|
||||
export default function Profile() {
|
||||
const { data: auth } = useAuthMe();
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PanelSkeleton />
|
||||
<PanelSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = auth;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BasicsPanel user={user} />
|
||||
<IdentityPanel user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BasicsPanel({ user }: { user: AuthSessionUser }) {
|
||||
return (
|
||||
<Panel title="Basics">
|
||||
<Row title="Name">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
alt=""
|
||||
src={user.avatarUrl}
|
||||
className="size-10 rounded-full outline -outline-offset-1 outline-line-strong"
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Username">
|
||||
<Mono>{user.login}</Mono>
|
||||
</Row>
|
||||
<Row title="Email">{user.email}</Row>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityPanel({ user }: { user: AuthSessionUser }) {
|
||||
return (
|
||||
<Panel title="Identity">
|
||||
<Row title="IdP issuer">
|
||||
{user.idpIssuer ? <Mono>{user.idpIssuer}</Mono> : <Muted>—</Muted>}
|
||||
</Row>
|
||||
<Row title="IdP subject">
|
||||
{user.idpSubject ? <Mono>{user.idpSubject}</Mono> : <Muted>—</Muted>}
|
||||
</Row>
|
||||
<Row title="Profile URL">
|
||||
<UrlValue url={user.userUrl} />
|
||||
</Row>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
220
apps/fabro-web/app/routes/run-artifacts.tsx
Normal file
220
apps/fabro-web/app/routes/run-artifacts.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { ArrowDownTrayIcon, PaperClipIcon } from "@heroicons/react/24/outline";
|
||||
import type { RunArtifactEntry } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/state";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { stageArtifactDownloadUrl } from "../lib/api-client";
|
||||
import { useRunArtifacts, useRunStages } from "../lib/queries";
|
||||
import { formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
export default function RunArtifacts() {
|
||||
const { id } = useParams();
|
||||
const stagesQuery = useRunStages(id);
|
||||
const artifactsQuery = useRunArtifacts(id);
|
||||
const stages = useMemo(
|
||||
() => mapRunStagesToSidebarStages(stagesQuery.data),
|
||||
[stagesQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<StageSidebar stages={stages} runId={id!} activeLink="artifacts" />
|
||||
<div className="min-w-0 flex-1">{renderBody(id!, artifactsQuery, stages)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBody(
|
||||
runId: string,
|
||||
artifactsQuery: ReturnType<typeof useRunArtifacts>,
|
||||
stages: ReturnType<typeof mapRunStagesToSidebarStages>,
|
||||
) {
|
||||
if (artifactsQuery.error) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="Couldn't load artifacts"
|
||||
description={errorMessage(artifactsQuery.error)}
|
||||
onRetry={() => void artifactsQuery.mutate()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (artifactsQuery.data === undefined) {
|
||||
return <LoadingState label="Loading artifacts…" />;
|
||||
}
|
||||
const entries = artifactsQuery.data?.data ?? [];
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={PaperClipIcon}
|
||||
title="No artifacts captured"
|
||||
description="No stage in this run produced any artifacts."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ArtifactList runId={runId} entries={entries} stages={stages} />;
|
||||
}
|
||||
|
||||
interface StageGroup {
|
||||
key: string;
|
||||
stageId: string;
|
||||
retry: number;
|
||||
label: string;
|
||||
entries: RunArtifactEntry[];
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
function groupArtifacts(
|
||||
entries: readonly RunArtifactEntry[],
|
||||
stages: ReturnType<typeof mapRunStagesToSidebarStages>,
|
||||
): StageGroup[] {
|
||||
const stageLabels = new Map<string, string>();
|
||||
for (const stage of stages) {
|
||||
stageLabels.set(stage.id, formatStageLabel(stage));
|
||||
}
|
||||
|
||||
const groups = new Map<string, StageGroup>();
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.stage_id}#${entry.retry}`;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.entries.push(entry);
|
||||
existing.totalBytes += entry.size;
|
||||
} else {
|
||||
groups.set(key, {
|
||||
key,
|
||||
stageId: entry.stage_id,
|
||||
retry: entry.retry,
|
||||
label: stageLabels.get(entry.stage_id) ?? entry.node_slug,
|
||||
entries: [entry],
|
||||
totalBytes: entry.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
group.entries.sort((a, b) => a.relative_path.localeCompare(b.relative_path));
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => {
|
||||
const labelCmp = a.label.localeCompare(b.label);
|
||||
return labelCmp !== 0 ? labelCmp : a.retry - b.retry;
|
||||
});
|
||||
}
|
||||
|
||||
function ArtifactList({
|
||||
runId,
|
||||
entries,
|
||||
stages,
|
||||
}: {
|
||||
runId: string;
|
||||
entries: readonly RunArtifactEntry[];
|
||||
stages: ReturnType<typeof mapRunStagesToSidebarStages>;
|
||||
}) {
|
||||
const groups = useMemo(() => groupArtifacts(entries, stages), [entries, stages]);
|
||||
const totalBytes = useMemo(
|
||||
() => entries.reduce((sum, entry) => sum + entry.size, 0),
|
||||
[entries],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-medium text-fg">
|
||||
{entries.length} {entries.length === 1 ? "artifact" : "artifacts"}
|
||||
</h2>
|
||||
<span className="text-xs tabular-nums text-fg-muted">
|
||||
{formatBytes(totalBytes)} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{groups.map((group) => (
|
||||
<StageGroupCard key={group.key} runId={runId} group={group} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageGroupCard({ runId, group }: { runId: string; group: StageGroup }) {
|
||||
return (
|
||||
<section className="overflow-hidden rounded-md border border-line bg-panel-alt">
|
||||
<header className="flex items-baseline justify-between border-b border-line px-4 py-2.5">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-medium text-fg">{group.label}</h3>
|
||||
{group.retry > 0 && (
|
||||
<span className="rounded bg-overlay px-1.5 py-0.5 text-[11px] font-medium text-fg-3">
|
||||
retry {group.retry}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-fg-muted">
|
||||
{group.entries.length} {group.entries.length === 1 ? "file" : "files"}
|
||||
{" · "}
|
||||
{formatBytes(group.totalBytes)}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="divide-y divide-line">
|
||||
{group.entries.map((entry) => (
|
||||
<ArtifactRow
|
||||
key={`${group.key}#${entry.relative_path}`}
|
||||
runId={runId}
|
||||
entry={entry}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) {
|
||||
const [href, setHref] = useState<string>("#");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void stageArtifactDownloadUrl(
|
||||
runId,
|
||||
entry.stage_id,
|
||||
entry.relative_path,
|
||||
entry.retry,
|
||||
).then((url) => {
|
||||
if (active) setHref(url);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [entry.relative_path, entry.retry, entry.stage_id, runId]);
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-4 px-4 py-2">
|
||||
<span
|
||||
className="flex-1 truncate font-mono text-xs text-fg-2"
|
||||
title={entry.relative_path}
|
||||
>
|
||||
{entry.relative_path}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{formatBytes(entry.size)}
|
||||
</span>
|
||||
<a
|
||||
href={href}
|
||||
download={basename(entry.relative_path)}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
<ArrowDownTrayIcon className="size-3.5" aria-hidden="true" />
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
const idx = path.lastIndexOf("/");
|
||||
return idx >= 0 ? path.slice(idx + 1) : path;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string | undefined {
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ describe("RunBilling", () => {
|
|||
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
});
|
||||
|
||||
test("renders non-LLM-only stage rows without the by-model section", () => {
|
||||
test("hides completed non-billable stages but still renders the totals row", () => {
|
||||
const renderer = renderBilling(
|
||||
billing({
|
||||
stages: [
|
||||
|
|
@ -93,8 +93,8 @@ describe("RunBilling", () => {
|
|||
);
|
||||
|
||||
const text = textFromNode(renderer.toJSON());
|
||||
expect(text).toContain("start");
|
||||
expect(text).toContain("command");
|
||||
expect(text).not.toContain("start");
|
||||
expect(text).not.toContain("command");
|
||||
expect(text).toMatch(/—\s*\/\s*—/);
|
||||
expect(text).toContain("1m 1s");
|
||||
expect(text).not.toContain("By model");
|
||||
|
|
@ -150,7 +150,7 @@ describe("RunBilling", () => {
|
|||
);
|
||||
|
||||
const text = textFromNode(renderer.toJSON());
|
||||
expect(text).toContain("start");
|
||||
expect(text).not.toContain("start");
|
||||
expect(text).toContain("agent");
|
||||
expect(text).toContain("By model");
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,15 @@ function isInFlight(stage: RunBillingStage): boolean {
|
|||
return stage.state != null && IN_FLIGHT_STAGE_STATES.has(stage.state);
|
||||
}
|
||||
|
||||
function isVisibleRow(row: MappedStageRow): boolean {
|
||||
if (row.inFlight) return true;
|
||||
return (
|
||||
(row.inputTokens ?? 0) > 0 ||
|
||||
(row.outputTokens ?? 0) > 0 ||
|
||||
(row.totalUsdMicros ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
interface MappedStageRow {
|
||||
stage: string;
|
||||
model: string | null;
|
||||
|
|
@ -29,6 +38,7 @@ interface MappedStageRow {
|
|||
outputTokens: number | null;
|
||||
runtimeSecs: number;
|
||||
totalUsdMicros: number | null | undefined;
|
||||
inFlight: boolean;
|
||||
}
|
||||
|
||||
function liveRuntimeSecs(stage: RunBillingStage, now: number): number {
|
||||
|
|
@ -41,6 +51,8 @@ function liveRuntimeSecs(stage: RunBillingStage, now: number): number {
|
|||
return stage.runtime_secs;
|
||||
}
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
function mapStageRow(stage: RunBillingStage, runtimeSecs: number): MappedStageRow {
|
||||
const hasModel = stage.model != null;
|
||||
return {
|
||||
|
|
@ -52,6 +64,7 @@ function mapStageRow(stage: RunBillingStage, runtimeSecs: number): MappedStageRo
|
|||
: null,
|
||||
runtimeSecs,
|
||||
totalUsdMicros: stage.billing.total_usd_micros,
|
||||
inFlight: isInFlight(stage),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +134,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="overflow-hidden rounded-md border border-line">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
|
|
@ -134,7 +147,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
{rows.filter(isVisibleRow).map((row) => (
|
||||
<tr key={row.stage} className="border-b border-line last:border-b-0">
|
||||
<td className="px-4 py-3 text-fg-2">{row.stage}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-fg-3">
|
||||
|
|
|
|||
|
|
@ -15,15 +15,6 @@ let currentRunSummary: any = null;
|
|||
let currentQuestions: any[] = [];
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
|
||||
function noopMutation() {
|
||||
return {
|
||||
data: undefined,
|
||||
isMutating: false,
|
||||
trigger: mock(() => Promise.resolve()),
|
||||
reset: mock(() => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
mock.module("../lib/queries", () => ({
|
||||
useRun: () => ({
|
||||
data: currentRunSummary,
|
||||
|
|
@ -41,16 +32,6 @@ mock.module("../lib/queries", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
mock.module("../lib/mutations", () => ({
|
||||
useArchiveRun: () => noopMutation(),
|
||||
useCancelRun: () => noopMutation(),
|
||||
useInterruptRun: () => noopMutation(),
|
||||
usePreviewRun: () => noopMutation(),
|
||||
useSteerRun: () => noopMutation(),
|
||||
useSubmitInterviewAnswer: () => noopMutation(),
|
||||
useUnarchiveRun: () => noopMutation(),
|
||||
}));
|
||||
|
||||
mock.module("../lib/run-events", () => ({
|
||||
useRunEvents: () => undefined,
|
||||
}));
|
||||
|
|
@ -60,19 +41,27 @@ mock.module("../hooks/use-run-toasts", () => ({
|
|||
}));
|
||||
|
||||
const {
|
||||
actionMenuSeparatorVisibility,
|
||||
default: RunDetail,
|
||||
focusSteerAfterMenuClose,
|
||||
handleLifecycleToastResult,
|
||||
lifecycleActionVisibility,
|
||||
} = await import("./run-detail");
|
||||
mock.restore();
|
||||
type LifecycleToastState = import("./run-detail").LifecycleToastState;
|
||||
type RunDetailActionResult = import("./run-detail").RunDetailActionResult;
|
||||
|
||||
const h = createElement;
|
||||
|
||||
function makeRunSummary(status = "succeeded") {
|
||||
function makeRunSummary(
|
||||
status = "succeeded",
|
||||
diffSummary: any = null,
|
||||
pullRequest: any = null,
|
||||
title = "Run 1",
|
||||
) {
|
||||
return {
|
||||
run_id: "run_1",
|
||||
title: "Run 1",
|
||||
title,
|
||||
repository: { name: "fabro" },
|
||||
status: { kind: status },
|
||||
workflow_slug: "default",
|
||||
|
|
@ -80,6 +69,8 @@ function makeRunSummary(status = "succeeded") {
|
|||
duration_ms: null,
|
||||
elapsed_secs: null,
|
||||
source_directory: null,
|
||||
diff_summary: diffSummary,
|
||||
pull_request: pullRequest,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -105,12 +96,18 @@ async function renderRunDetail({
|
|||
initialEntry,
|
||||
status = "succeeded",
|
||||
questions = [],
|
||||
diffSummary = null,
|
||||
pullRequest = null,
|
||||
title,
|
||||
}: {
|
||||
initialEntry: string;
|
||||
status?: string;
|
||||
questions?: any[];
|
||||
diffSummary?: any;
|
||||
pullRequest?: any;
|
||||
title?: string;
|
||||
}) {
|
||||
currentRunSummary = makeRunSummary(status);
|
||||
currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title);
|
||||
currentQuestions = questions;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
|
@ -154,6 +151,12 @@ function hasClasses(value: unknown, classes: string[]) {
|
|||
return classes.every((className) => tokens.includes(className));
|
||||
}
|
||||
|
||||
function tabCountBadges(renderer: TestRenderer.ReactTestRenderer) {
|
||||
return renderer.root.findAll(
|
||||
(node) => node.type === "span" && hasClasses(node.props.className, ["tabular-nums"]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("lifecycleActionVisibility", () => {
|
||||
test("shows cancel for active cancellable states and hides it elsewhere", () => {
|
||||
expect(lifecycleActionVisibility("submitted").showPrimaryCancel).toBe(true);
|
||||
|
|
@ -178,6 +181,32 @@ describe("lifecycleActionVisibility", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("actionMenuSeparatorVisibility", () => {
|
||||
test("does not render adjacent dividers when destructive actions follow ops directly", () => {
|
||||
expect(
|
||||
actionMenuSeparatorVisibility({
|
||||
hasLifecycle: false,
|
||||
hasDestructive: true,
|
||||
}),
|
||||
).toEqual({
|
||||
afterOperations: true,
|
||||
beforeDestructive: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders both dividers when lifecycle actions sit between ops and destructive actions", () => {
|
||||
expect(
|
||||
actionMenuSeparatorVisibility({
|
||||
hasLifecycle: true,
|
||||
hasDestructive: true,
|
||||
}),
|
||||
).toEqual({
|
||||
afterOperations: true,
|
||||
beforeDestructive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleLifecycleToastResult", () => {
|
||||
type PushedToast = { message: string; action?: { label: string; onClick: () => void } };
|
||||
|
||||
|
|
@ -330,6 +359,63 @@ describe("RunDetail full-height child routes", () => {
|
|||
expect(outletWrappers).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("shows the Files Changed tab badge from run summary diff stats", async () => {
|
||||
const renderer = await renderRunDetail({
|
||||
initialEntry: "/runs/run_1/files",
|
||||
diffSummary: {
|
||||
files_changed: 7,
|
||||
additions: 30,
|
||||
deletions: 11,
|
||||
},
|
||||
});
|
||||
|
||||
const badges = tabCountBadges(renderer);
|
||||
expect(badges.map((badge) => badge.children.join(""))).toContain("7");
|
||||
});
|
||||
|
||||
test("defers steer bar focus until after the Actions menu item click settles", async () => {
|
||||
const focusCalls: string[] = [];
|
||||
|
||||
focusSteerAfterMenuClose(() => focusCalls.push("focus"));
|
||||
|
||||
expect(focusCalls).toEqual([]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(focusCalls).toEqual(["focus"]);
|
||||
});
|
||||
|
||||
test("hides the Files Changed tab badge when diff stats are absent", async () => {
|
||||
const renderer = await renderRunDetail({
|
||||
initialEntry: "/runs/run_1/files",
|
||||
});
|
||||
|
||||
expect(tabCountBadges(renderer)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("shows a linked pull request chip in the run header", async () => {
|
||||
const renderer = await renderRunDetail({
|
||||
initialEntry: "/runs/run_1",
|
||||
pullRequest: {
|
||||
html_url: "https://github.com/fabro-sh/fabro/pull/123",
|
||||
number: 123,
|
||||
owner: "fabro-sh",
|
||||
repo: "fabro",
|
||||
base_branch: "main",
|
||||
head_branch: "fabro/run/demo",
|
||||
title: "Add run PR chip",
|
||||
},
|
||||
});
|
||||
|
||||
const links = renderer.root.findAll(
|
||||
(node) =>
|
||||
node.type === "a" &&
|
||||
node.props.href === "https://github.com/fabro-sh/fabro/pull/123",
|
||||
);
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].props.target).toBe("_blank");
|
||||
expect(links[0].children.filter((child) => typeof child !== "object").join("")).toBe("#123");
|
||||
});
|
||||
|
||||
test("keeps blocked full-height children clear of the interview dock without an h-72 sibling", async () => {
|
||||
const renderer = await renderRunDetail({
|
||||
initialEntry: "/runs/run_1/files",
|
||||
|
|
@ -356,6 +442,32 @@ describe("RunDetail full-height child routes", () => {
|
|||
expect(clearanceOwners.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders inline <code> in the run title heading for Markdown-formatted titles", async () => {
|
||||
const renderer = await renderRunDetail({
|
||||
initialEntry: "/runs/run_1",
|
||||
title: "Move from `[server.integrations.github]` to `[run.integrations.github]`",
|
||||
});
|
||||
|
||||
const headings = renderer.root.findAll(
|
||||
(node) =>
|
||||
node.type === "h2" &&
|
||||
hasClasses(node.props.className, ["text-xl", "font-semibold", "text-fg"]),
|
||||
);
|
||||
expect(headings).toHaveLength(1);
|
||||
|
||||
const codes = headings[0]!.findAllByType("code");
|
||||
expect(codes).toHaveLength(2);
|
||||
expect(
|
||||
codes
|
||||
.map((code) =>
|
||||
code.children.filter((child) => typeof child === "string").join(""),
|
||||
),
|
||||
).toEqual([
|
||||
"[server.integrations.github]",
|
||||
"[run.integrations.github]",
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves document-flow layout for child routes without fullHeight", async () => {
|
||||
const renderer = await renderRunDetail({
|
||||
initialEntry: "/runs/run_1",
|
||||
|
|
@ -369,7 +481,12 @@ describe("RunDetail full-height child routes", () => {
|
|||
expect(fullHeightRoot).toHaveLength(0);
|
||||
|
||||
const outletWrappers = renderer.root.findAll(
|
||||
(node) => node.type === "div" && node.props.className === "mt-6",
|
||||
(node) =>
|
||||
node.type === "div" &&
|
||||
hasClasses(node.props.className, [
|
||||
"mt-6",
|
||||
"pb-[var(--fabro-interview-dock-clearance)]",
|
||||
]),
|
||||
);
|
||||
expect(outletWrappers).toHaveLength(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,15 +13,16 @@ import {
|
|||
RectangleStackIcon,
|
||||
SignalIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Link, Outlet, useLocation, useMatches } from "react-router";
|
||||
import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
|
||||
import { InlineMarkdown } from "../components/inline-markdown";
|
||||
import { InterviewDock } from "../components/interview-dock";
|
||||
import { PullRequestChip } from "../components/pull-request-chip";
|
||||
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
|
||||
import { SteerComposer } from "../components/steer-composer";
|
||||
import { ErrorState } from "../components/state";
|
||||
import { useToast } from "../components/toast";
|
||||
import { SECONDARY_BUTTON_CLASS } from "../components/ui";
|
||||
import { ConfirmDialog, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
|
||||
import {
|
||||
isRunStatus,
|
||||
mapRunSummaryToRunItem,
|
||||
|
|
@ -29,6 +30,7 @@ import {
|
|||
type RunSummary,
|
||||
} from "../data/runs";
|
||||
import { useDemoMode } from "../lib/demo-mode";
|
||||
import { useSWRConfig } from "swr";
|
||||
import {
|
||||
useArchiveRun,
|
||||
useCancelRun,
|
||||
|
|
@ -38,14 +40,18 @@ import {
|
|||
type LifecycleMutationResult,
|
||||
type PreviewMutationResult,
|
||||
} from "../lib/mutations";
|
||||
import { formatRelativeTime } from "../lib/format";
|
||||
import { formatAbsoluteTs, formatRelativeTime } from "../lib/format";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useRunEvents } from "../lib/run-events";
|
||||
import { useRunToasts } from "../hooks/use-run-toasts";
|
||||
import { useRun, useRunQuestions } from "../lib/queries";
|
||||
import {
|
||||
canArchive,
|
||||
canCancel,
|
||||
canDelete,
|
||||
canUnarchive,
|
||||
deleteErrorMessage,
|
||||
deleteRun,
|
||||
isTerminalCancelledRun,
|
||||
mapError,
|
||||
type LifecycleAction,
|
||||
|
|
@ -56,12 +62,28 @@ const allTabs = [
|
|||
{ name: "Overview", path: "", count: null, demoOnly: false },
|
||||
{ name: "Stages", path: "/stages", count: null, demoOnly: false },
|
||||
{ name: "Files Changed", path: "/files", count: null, demoOnly: false },
|
||||
{ name: "Graph", path: "/graph", count: null, demoOnly: false },
|
||||
{ name: "Billing", path: "/billing", count: null, demoOnly: false },
|
||||
];
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export function focusSteerAfterMenuClose(focus: () => void) {
|
||||
globalThis.setTimeout(focus, 0);
|
||||
}
|
||||
|
||||
export function actionMenuSeparatorVisibility({
|
||||
hasLifecycle,
|
||||
hasDestructive,
|
||||
}: {
|
||||
hasLifecycle: boolean;
|
||||
hasDestructive: boolean;
|
||||
}) {
|
||||
return {
|
||||
afterOperations: hasLifecycle || hasDestructive,
|
||||
beforeDestructive: hasLifecycle && hasDestructive,
|
||||
};
|
||||
}
|
||||
|
||||
const ACTIONS_TRIGGER_CLASS =
|
||||
`${SECONDARY_BUTTON_CLASS} disabled:cursor-not-allowed disabled:opacity-60`;
|
||||
|
||||
|
|
@ -109,6 +131,7 @@ export function lifecycleActionVisibility(status: string | null | undefined) {
|
|||
showPrimaryCancel: canCancel(status),
|
||||
showArchive: canArchive(status),
|
||||
showUnarchive: canUnarchive(status),
|
||||
showDelete: canDelete(status),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -149,11 +172,19 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
const archiveMutation = useArchiveRun(params.id);
|
||||
const unarchiveMutation = useUnarchiveRun(params.id);
|
||||
const interruptMutation = useInterruptRun(params.id);
|
||||
const navigate = useNavigate();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deletePending, setDeletePending] = useState(false);
|
||||
const { push, dismiss } = useToast();
|
||||
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
|
||||
const filesCount = runQuery.data?.diff_summary?.files_changed ?? null;
|
||||
const tabs = allTabs
|
||||
.map((tab) =>
|
||||
tab.name === "Files Changed" ? { ...tab, count: filesCount } : tab,
|
||||
)
|
||||
.filter((t) => !t.demoOnly || demoMode);
|
||||
const lifecycleToastStateRef = useRef<LifecycleToastState>(INITIAL_LIFECYCLE_TOAST_STATE);
|
||||
const steerBarRef = useRef<SteerBarHandle | null>(null);
|
||||
const [steerOpen, setSteerOpen] = useState(false);
|
||||
const now = useTickingNow(30_000);
|
||||
const fullHeight = matches.some(
|
||||
(m) => (m.handle as { fullHeight?: boolean } | undefined)?.fullHeight,
|
||||
|
|
@ -215,11 +246,26 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
const cancelPending = cancelMutation.isMutating;
|
||||
const archivePending = archiveMutation.isMutating;
|
||||
const unarchivePending = unarchiveMutation.isMutating;
|
||||
const handleConfirmDelete = async () => {
|
||||
setDeletePending(true);
|
||||
try {
|
||||
await deleteRun(params.id);
|
||||
void mutate(queryKeys.boards.runs());
|
||||
void mutate(queryKeys.boards.runs(true));
|
||||
push({ message: "Run deleted." });
|
||||
navigate("/runs");
|
||||
} catch (error) {
|
||||
push({ message: deleteErrorMessage(error), tone: "error" });
|
||||
} finally {
|
||||
setDeletePending(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
const hasPendingQuestions = isBlocked && pendingQuestions.length > 0;
|
||||
const dockClearance = hasPendingQuestions ? "18rem" : "5rem";
|
||||
const rootStyle = fullHeight
|
||||
? ({ "--fabro-interview-dock-clearance": dockClearance } as CSSProperties)
|
||||
: undefined;
|
||||
const rootStyle = {
|
||||
"--fabro-interview-dock-clearance": dockClearance,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -233,14 +279,13 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
)}
|
||||
>
|
||||
<Link to="/runs" className="text-fg-3 hover:text-fg">Runs</Link>
|
||||
{demoMode && (
|
||||
<>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
<Link to={`/workflows/${run.workflow}`} className="text-fg-3 hover:text-fg">
|
||||
{run.workflow}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<ChevronRightIcon className="size-3" />
|
||||
<Link
|
||||
to={`/runs?workflow=${encodeURIComponent(run.workflow)}`}
|
||||
className="text-fg-3 hover:text-fg"
|
||||
>
|
||||
{run.workflow}
|
||||
</Link>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
<span>{run.title}</span>
|
||||
</nav>
|
||||
|
|
@ -252,7 +297,9 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl font-semibold text-fg">{run.title}</h2>
|
||||
<h2 className="text-xl font-semibold text-fg">
|
||||
<InlineMarkdown content={run.title} />
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${run.statusDot}`} />
|
||||
|
|
@ -273,13 +320,19 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
</span>
|
||||
)}
|
||||
{run.lastEventAt && (
|
||||
<span
|
||||
className="flex items-center gap-1.5 font-mono text-xs text-fg-muted"
|
||||
title={`Last event ${new Date(run.lastEventAt).toLocaleString()}`}
|
||||
>
|
||||
<SignalIcon className="size-3.5" aria-hidden="true" />
|
||||
{formatRelativeTime(run.lastEventAt, now)}
|
||||
</span>
|
||||
<Tooltip label={`Last event ${formatAbsoluteTs(run.lastEventAt)}`}>
|
||||
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
|
||||
<SignalIcon className="size-3.5" aria-hidden="true" />
|
||||
{formatRelativeTime(run.lastEventAt, now)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{run.number != null && run.pullRequestUrl && (
|
||||
<PullRequestChip
|
||||
number={run.number}
|
||||
url={run.pullRequestUrl}
|
||||
iconClassName="size-3.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -287,13 +340,13 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
{demoMode && <ConnectMenu />}
|
||||
|
||||
<ActionsMenu
|
||||
canSteer={statusKind === "running"}
|
||||
onSteer={() => setSteerOpen(true)}
|
||||
canSendInterrupt={statusKind === "running"}
|
||||
interruptPending={interruptMutation.isMutating}
|
||||
onSendInterrupt={() => void interruptMutation.trigger()}
|
||||
canFocusSteer={statusKind === "running" && !hasPendingQuestions}
|
||||
onFocusSteer={() => steerBarRef.current?.focus()}
|
||||
onFocusSteer={() => {
|
||||
focusSteerAfterMenuClose(() => steerBarRef.current?.focus());
|
||||
}}
|
||||
canPreview={!!run.sandboxId}
|
||||
previewPending={previewPending}
|
||||
onPreview={() => void previewMutation.trigger({
|
||||
|
|
@ -306,12 +359,31 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
canUnarchive={visibility.showUnarchive}
|
||||
unarchivePending={unarchivePending}
|
||||
onUnarchive={() => void unarchiveMutation.trigger()}
|
||||
canDelete={visibility.showDelete}
|
||||
deletePending={deletePending}
|
||||
onDelete={() => setDeleteDialogOpen(true)}
|
||||
canCancel={visibility.showPrimaryCancel}
|
||||
cancelPending={cancelPending}
|
||||
onCancel={() => void cancelMutation.trigger()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
title="Delete this run?"
|
||||
description={
|
||||
<>
|
||||
This permanently removes <span className="font-mono text-fg-2">{run.title}</span> and its
|
||||
durable state. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Delete run"
|
||||
pendingLabel="Deleting…"
|
||||
pending={deletePending}
|
||||
onConfirm={() => void handleConfirmDelete()}
|
||||
onCancel={() => setDeleteDialogOpen(false)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"relative before:pointer-events-none before:absolute before:bottom-0 before:left-1/2 before:h-px before:w-screen before:-translate-x-1/2 before:bg-line",
|
||||
|
|
@ -348,16 +420,16 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
<div className={fullHeight ? "mt-6 flex min-h-0 flex-1 flex-col" : "mt-6"}>
|
||||
<div
|
||||
className={
|
||||
fullHeight
|
||||
? "mt-6 flex min-h-0 flex-1 flex-col"
|
||||
: "mt-6 pb-[var(--fabro-interview-dock-clearance)]"
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
<SteerComposer
|
||||
runId={params.id}
|
||||
open={steerOpen}
|
||||
onClose={() => setSteerOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-page">
|
||||
{hasPendingQuestions ? (
|
||||
<InterviewDock runId={params.id} questions={pendingQuestions} />
|
||||
|
|
@ -444,8 +516,6 @@ function ConnectMenu() {
|
|||
}
|
||||
|
||||
interface ActionsMenuProps {
|
||||
canSteer: boolean;
|
||||
onSteer: () => void;
|
||||
canSendInterrupt: boolean;
|
||||
interruptPending: boolean;
|
||||
onSendInterrupt: () => void;
|
||||
|
|
@ -460,6 +530,9 @@ interface ActionsMenuProps {
|
|||
canUnarchive: boolean;
|
||||
unarchivePending: boolean;
|
||||
onUnarchive: () => void;
|
||||
canDelete: boolean;
|
||||
deletePending: boolean;
|
||||
onDelete: () => void;
|
||||
canCancel: boolean;
|
||||
cancelPending: boolean;
|
||||
onCancel: () => void;
|
||||
|
|
@ -467,22 +540,23 @@ interface ActionsMenuProps {
|
|||
|
||||
function ActionsMenu(props: ActionsMenuProps) {
|
||||
const {
|
||||
canSteer, onSteer,
|
||||
canSendInterrupt, interruptPending, onSendInterrupt,
|
||||
canFocusSteer, onFocusSteer,
|
||||
canPreview, previewPending, onPreview,
|
||||
canArchive, archivePending, onArchive,
|
||||
canUnarchive, unarchivePending, onUnarchive,
|
||||
canDelete, deletePending, onDelete,
|
||||
canCancel, cancelPending, onCancel,
|
||||
} = props;
|
||||
|
||||
const hasOps =
|
||||
canPreview || canSteer || canSendInterrupt || canFocusSteer;
|
||||
canPreview || canSendInterrupt || canFocusSteer;
|
||||
const hasLifecycle = canArchive || canUnarchive;
|
||||
const hasDestructive = canCancel;
|
||||
const hasDestructive = canCancel || canDelete;
|
||||
const hasAny = hasOps || hasLifecycle || hasDestructive;
|
||||
const anyPending =
|
||||
previewPending || archivePending || unarchivePending || cancelPending || interruptPending;
|
||||
previewPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending;
|
||||
const separators = actionMenuSeparatorVisibility({ hasLifecycle, hasDestructive });
|
||||
|
||||
if (!hasAny) return null;
|
||||
|
||||
|
|
@ -510,13 +584,6 @@ function ActionsMenu(props: ActionsMenuProps) {
|
|||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canSteer && (
|
||||
<MenuItem>
|
||||
<button type="button" onClick={onSteer} className={MENU_ITEM_CLASS}>
|
||||
Steer
|
||||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -537,7 +604,7 @@ function ActionsMenu(props: ActionsMenuProps) {
|
|||
Send steering…
|
||||
</button>
|
||||
</MenuItem>
|
||||
{(hasLifecycle || hasDestructive) && (
|
||||
{separators.afterOperations && (
|
||||
<div className="my-1 h-px bg-line" role="separator" />
|
||||
)}
|
||||
{canArchive && (
|
||||
|
|
@ -564,7 +631,7 @@ function ActionsMenu(props: ActionsMenuProps) {
|
|||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
{(hasOps || hasLifecycle) && hasDestructive && (
|
||||
{separators.beforeDestructive && (
|
||||
<div className="my-1 h-px bg-line" role="separator" />
|
||||
)}
|
||||
{canCancel && (
|
||||
|
|
@ -579,6 +646,18 @@ function ActionsMenu(props: ActionsMenuProps) {
|
|||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deletePending}
|
||||
className={MENU_ITEM_DANGER_CLASS}
|
||||
>
|
||||
{deletePending ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
);
|
||||
|
|
|
|||
195
apps/fabro-web/app/routes/run-events.tsx
Normal file
195
apps/fabro-web/app/routes/run-events.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
import {
|
||||
DebugEventDetailsPanel,
|
||||
DebugEventRow,
|
||||
EventSearchInput,
|
||||
MultiSelectFilter,
|
||||
debugCategory,
|
||||
debugCategoryLabel,
|
||||
} from "../components/event-debug";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/state";
|
||||
import { useRun, useRunEventsList, useRunStages } from "../lib/queries";
|
||||
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
|
||||
|
||||
export const handle = { wide: true, fullHeight: true };
|
||||
|
||||
export default function RunEvents() {
|
||||
const { id } = useParams();
|
||||
const runQuery = useRun(id);
|
||||
const stagesQuery = useRunStages(id);
|
||||
const eventsQuery = useRunEventsList(id);
|
||||
const stages = useMemo(
|
||||
() => mapRunStagesToSidebarStages(stagesQuery.data),
|
||||
[stagesQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="-mr-4 -mt-6 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
|
||||
<div className="shrink-0 pb-6 pr-3 pt-6">
|
||||
<StageSidebar stages={stages} runId={id!} activeLink="events" />
|
||||
</div>
|
||||
|
||||
<div className="relative w-px shrink-0">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 -bottom-6 bg-line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EventsView
|
||||
events={eventsQuery.data}
|
||||
error={eventsQuery.error}
|
||||
onRetry={() => void eventsQuery.mutate()}
|
||||
runStart={runQuery.data?.start_time ?? runQuery.data?.created_at}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsView({
|
||||
events,
|
||||
error,
|
||||
onRetry,
|
||||
runStart,
|
||||
}: {
|
||||
events: EventEnvelope[] | undefined;
|
||||
error: unknown;
|
||||
onRetry: () => void;
|
||||
runStart: string | undefined;
|
||||
}) {
|
||||
const [openSeq, setOpenSeq] = useState<number | null>(null);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const all = events ?? [];
|
||||
|
||||
const availableCategories = useMemo<string[]>(() => {
|
||||
const set = new Set<string>();
|
||||
for (const event of all) {
|
||||
if (event.event) set.add(debugCategory(event.event));
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [all]);
|
||||
|
||||
const filtered = useMemo<EventEnvelope[]>(() => {
|
||||
const useCategoryFilter = selectedCategories.length > 0;
|
||||
const cats = new Set(selectedCategories);
|
||||
const needle = search.toLowerCase();
|
||||
return all.filter((event) => {
|
||||
const name = event.event ?? "";
|
||||
if (useCategoryFilter && !cats.has(debugCategory(name))) return false;
|
||||
if (needle) {
|
||||
const blob = `${name} ${JSON.stringify(event.properties ?? {})}`.toLowerCase();
|
||||
if (!blob.includes(needle)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [all, selectedCategories, search]);
|
||||
|
||||
const openEvent = useMemo<EventEnvelope | null>(
|
||||
() => (openSeq != null ? all.find((e) => e.seq === openSeq) ?? null : null),
|
||||
[all, openSeq],
|
||||
);
|
||||
|
||||
const allCategoriesSelected =
|
||||
selectedCategories.length === 0 ||
|
||||
selectedCategories.length === availableCategories.length;
|
||||
const isFiltering = !allCategoriesSelected || search.length > 0;
|
||||
|
||||
function clearFilters() {
|
||||
setSelectedCategories([]);
|
||||
setSearch("");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 pt-6">
|
||||
<ErrorState
|
||||
title="Couldn't load events"
|
||||
description={errorMessage(error)}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (events === undefined) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 pt-6">
|
||||
<LoadingState label="Loading events…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col pt-3">
|
||||
<div className="shrink-0 border-b border-line">
|
||||
<div className="pl-3 pr-4 sm:pr-6 lg:pr-8">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pb-3">
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<MultiSelectFilter<string>
|
||||
selected={selectedCategories}
|
||||
options={availableCategories}
|
||||
labelOf={debugCategoryLabel}
|
||||
onChange={setSelectedCategories}
|
||||
emptyMeansAll
|
||||
/>
|
||||
<EventSearchInput value={search} onChange={setSearch} />
|
||||
{isFiltering && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="rounded px-2 py-1 text-xs text-fg-muted transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{all.length > 0 && (
|
||||
<span className="text-xs tabular-nums text-fg-muted">
|
||||
{isFiltering
|
||||
? `${filtered.length.toLocaleString()} of ${all.length.toLocaleString()} events`
|
||||
: `${all.length.toLocaleString()} events`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-2 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
|
||||
{all.length === 0 ? (
|
||||
<div className="px-2 py-12">
|
||||
<EmptyState
|
||||
title="No events yet"
|
||||
description="Events will appear here as the run executes."
|
||||
/>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-2 py-6 text-sm text-fg-muted">
|
||||
No events match these filters.
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((event) => (
|
||||
<DebugEventRow
|
||||
key={`event-${event.seq}`}
|
||||
event={event}
|
||||
runStart={runStart}
|
||||
selected={openSeq === event.seq}
|
||||
onSelect={() => setOpenSeq(event.seq)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DebugEventDetailsPanel event={openEvent} onClose={() => setOpenSeq(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string | undefined {
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
}
|
||||
|
|
@ -2,8 +2,6 @@ import {
|
|||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import {
|
||||
|
|
@ -80,25 +78,16 @@ export function FileTreeSidebar({
|
|||
selectedPath,
|
||||
onSelect,
|
||||
}: FileTreeSidebarProps) {
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const normalizedFilter = filterText.trim().toLowerCase();
|
||||
const filteredFiles = useMemo(
|
||||
() =>
|
||||
normalizedFilter
|
||||
? files.filter((file) => filePath(file).toLowerCase().includes(normalizedFilter))
|
||||
: files,
|
||||
[files, normalizedFilter],
|
||||
);
|
||||
const paths = useMemo(() => filteredFiles.map(filePath), [filteredFiles]);
|
||||
const paths = useMemo(() => files.map(filePath), [files]);
|
||||
const changedPaths = useMemo(() => new Set(paths), [paths]);
|
||||
|
||||
const gitStatus = useMemo<GitStatusEntry[]>(
|
||||
() =>
|
||||
filteredFiles.map((file) => ({
|
||||
files.map((file) => ({
|
||||
path: filePath(file),
|
||||
status: gitStatusFor(file),
|
||||
})),
|
||||
[filteredFiles],
|
||||
[files],
|
||||
);
|
||||
|
||||
const onSelectRef = useRef(onSelect);
|
||||
|
|
@ -161,11 +150,13 @@ export function FileTreeSidebar({
|
|||
);
|
||||
}, [changedPaths, model, selectedPath, selection]);
|
||||
|
||||
const handleSearchChange = (event: ChangeEvent<HTMLInputElement>) =>
|
||||
setFilterText(event.target.value);
|
||||
|
||||
const themeStyles = useMemo(
|
||||
() => themeToTreeStyles(pierreDark) as TreeThemeStyle,
|
||||
const themeStyles = useMemo<TreeThemeStyle>(
|
||||
() => ({
|
||||
...(themeToTreeStyles(pierreDark) as TreeThemeStyle),
|
||||
backgroundColor: "transparent",
|
||||
"--trees-bg-override": "transparent",
|
||||
"--trees-padding-inline-override": "0px",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
|
|
@ -173,21 +164,13 @@ export function FileTreeSidebar({
|
|||
<aside
|
||||
aria-label="Changed files"
|
||||
style={themeStyles}
|
||||
className="flex min-h-0 w-72 shrink-0 flex-col gap-2 self-stretch"
|
||||
className="-ml-0.5 flex min-h-0 w-72 shrink-0 flex-col self-stretch"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
value={filterText}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Filter changed files…"
|
||||
aria-label="Filter changed files"
|
||||
className="w-full rounded-md border border-line bg-panel px-2 py-1.5 text-sm text-fg placeholder:text-fg-muted focus:outline-2 focus:outline-focus focus:outline-offset-2"
|
||||
/>
|
||||
{paths.length > 0 ? (
|
||||
<FileTree model={model} className="min-h-0 flex-1 overflow-hidden" />
|
||||
) : (
|
||||
<div role="status" className="min-h-0 flex-1 px-3 py-2 text-sm text-fg-muted">
|
||||
No matching files
|
||||
No changed files
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -93,13 +93,14 @@ describe("pickPlaceholder priority", () => {
|
|||
});
|
||||
|
||||
describe("bannerCopyForReason", () => {
|
||||
test("each known reason gets distinct copy", () => {
|
||||
const a = bannerCopyForReason("sandbox_gone");
|
||||
test("sandbox_gone is suppressed", () => {
|
||||
expect(bannerCopyForReason("sandbox_gone")).toBeNull();
|
||||
});
|
||||
|
||||
test("provider_unsupported and sandbox_unreachable get distinct copy", () => {
|
||||
const b = bannerCopyForReason("provider_unsupported");
|
||||
const c = bannerCopyForReason("sandbox_unreachable");
|
||||
expect(a).not.toBe(b);
|
||||
expect(b).not.toBe(c);
|
||||
expect(a).toContain("cleaned up");
|
||||
expect(b).toContain("provider");
|
||||
expect(c).toContain("refresh");
|
||||
});
|
||||
|
|
@ -146,9 +147,13 @@ describe("placeholder rendering", () => {
|
|||
).toContain("submodule");
|
||||
});
|
||||
|
||||
test("DegradedBanner renders nothing for sandbox_gone", () => {
|
||||
expect(renderedText(<DegradedBanner reason="sandbox_gone" />)).toBe("");
|
||||
});
|
||||
|
||||
test("DegradedBanner picks copy based on reason", () => {
|
||||
expect(renderedText(<DegradedBanner reason="sandbox_gone" />)).toContain(
|
||||
"cleaned up",
|
||||
);
|
||||
expect(
|
||||
renderedText(<DegradedBanner reason="provider_unsupported" />),
|
||||
).toContain("provider");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,19 +103,21 @@ export function DegradedBanner({
|
|||
}: {
|
||||
reason?: RunFilesMetaDegradedReasonEnum;
|
||||
}) {
|
||||
const copy = bannerCopyForReason(reason);
|
||||
if (copy === null) return null;
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-950/20 px-4 py-3 text-sm text-amber-100">
|
||||
{bannerCopyForReason(reason)}
|
||||
{copy}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function bannerCopyForReason(
|
||||
reason: RunFilesMetaDegradedReasonEnum | undefined | string,
|
||||
): string {
|
||||
): string | null {
|
||||
switch (reason) {
|
||||
case "sandbox_gone":
|
||||
return "Showing final patch only. This run's sandbox has been cleaned up, so individual file contents are no longer available.";
|
||||
return null;
|
||||
case "provider_unsupported":
|
||||
return "Live diff isn't supported for this sandbox provider. Showing the patch captured at the last checkpoint.";
|
||||
case "sandbox_unreachable":
|
||||
|
|
|
|||
|
|
@ -1,307 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import type { BundledLanguage } from "@pierre/diffs";
|
||||
import { graphTheme } from "../lib/graph-theme";
|
||||
import { useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries";
|
||||
import { LoadingState } from "../components/state";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import {
|
||||
GRAPH_DEFAULT_ZOOM_INDEX,
|
||||
GRAPH_ZOOM_STEPS,
|
||||
GraphToolbar,
|
||||
} from "../components/graph-toolbar";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import { registerDotLanguage } from "../data/register-dot-language";
|
||||
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
function buildDot(direction: Direction) {
|
||||
return `digraph sync {
|
||||
graph [label="Sync"]
|
||||
rankdir=${direction}
|
||||
bgcolor="transparent"
|
||||
pad=0.5
|
||||
|
||||
node [
|
||||
fontname="ui-monospace, monospace"
|
||||
fontsize=11
|
||||
fontcolor="${graphTheme.nodeText}"
|
||||
color="${graphTheme.edgeColor}"
|
||||
fillcolor="${graphTheme.nodeFill}"
|
||||
style=filled
|
||||
penwidth=1.2
|
||||
]
|
||||
edge [
|
||||
fontname="ui-monospace, monospace"
|
||||
fontsize=9
|
||||
fontcolor="${graphTheme.fontcolor}"
|
||||
color="${graphTheme.edgeColor}"
|
||||
arrowsize=0.7
|
||||
penwidth=1.2
|
||||
]
|
||||
|
||||
start [shape=Mdiamond, label="Start", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
|
||||
exit [shape=Msquare, label="Exit", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
|
||||
|
||||
detect [label="Detect\\nDrift"]
|
||||
propose [label="Propose\\nChanges"]
|
||||
review [shape=hexagon, label="Review\\nChanges", fillcolor="${graphTheme.gateFill}", color="${graphTheme.gateBorder}", fontcolor="${graphTheme.gateText}"]
|
||||
apply [label="Apply\\nChanges"]
|
||||
|
||||
start -> detect
|
||||
detect -> exit [label="No drift", style=dashed]
|
||||
detect -> propose [label="Drift found"]
|
||||
propose -> review
|
||||
review -> apply [label="Accept"]
|
||||
review -> propose [label="Revise", style=dashed]
|
||||
apply -> exit
|
||||
}`;
|
||||
}
|
||||
|
||||
function stripGraphTitle(svg: SVGSVGElement) {
|
||||
const title = svg.querySelector(".graph > title");
|
||||
if (!title) return;
|
||||
let sibling = title.nextElementSibling;
|
||||
while (sibling && sibling.tagName === "text") {
|
||||
const next = sibling.nextElementSibling;
|
||||
sibling.remove();
|
||||
sibling = next;
|
||||
}
|
||||
title.remove();
|
||||
}
|
||||
|
||||
type View = "graph" | "source";
|
||||
|
||||
export default function RunGraph() {
|
||||
const { id } = useParams();
|
||||
const [direction, setDirection] = useState<Direction>("LR");
|
||||
const [view, setView] = useState<View>("graph");
|
||||
const stagesQuery = useRunStages(id);
|
||||
const graphQuery = useRunGraph(id, direction);
|
||||
const sourceQuery = useRunGraphSource(id, view === "source");
|
||||
const stages = useMemo(
|
||||
() => mapRunStagesToSidebarStages(stagesQuery.data),
|
||||
[stagesQuery.data],
|
||||
);
|
||||
const graphSvg = graphQuery.data;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [dotReady, setDotReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
registerDotLanguage().then(() => {
|
||||
if (!cancelled) setDotReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const zoom = GRAPH_ZOOM_STEPS[zoomIndex];
|
||||
|
||||
useEffect(() => {
|
||||
if (graphSvg === undefined && !graphQuery.error) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function render() {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
if (graphQuery.error) {
|
||||
setError("Failed to load graph");
|
||||
return;
|
||||
}
|
||||
|
||||
let svg: SVGSVGElement;
|
||||
|
||||
if (graphSvg) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(graphSvg, "image/svg+xml");
|
||||
const parsed = doc.documentElement;
|
||||
if (!(parsed instanceof SVGSVGElement)) {
|
||||
setError("Invalid SVG from server");
|
||||
return;
|
||||
}
|
||||
svg = parsed;
|
||||
} else {
|
||||
// Fall back to hardcoded demo graph rendered client-side.
|
||||
const { instance } = await import("@viz-js/viz");
|
||||
const viz = await instance();
|
||||
if (cancelled) return;
|
||||
svg = viz.renderSVGElement(buildDot(direction));
|
||||
}
|
||||
|
||||
stripGraphTitle(svg);
|
||||
|
||||
svgRef.current = svg;
|
||||
if (innerRef.current) {
|
||||
innerRef.current.replaceChildren(svg);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to render diagram");
|
||||
}
|
||||
}
|
||||
|
||||
setPan({ x: 0, y: 0 });
|
||||
render();
|
||||
return () => { cancelled = true; };
|
||||
}, [direction, graphQuery.error, graphSvg]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, startPanX: pan.x, startPanY: pan.y };
|
||||
}, [pan]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
const drag = dragState.current;
|
||||
if (!drag) return;
|
||||
setPan({
|
||||
x: drag.startPanX + e.clientX - drag.startX,
|
||||
y: drag.startPanY + e.clientY - drag.startY,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragState.current = null;
|
||||
}, []);
|
||||
|
||||
const fitToWindow = useCallback(() => {
|
||||
const svg = svgRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!svg || !container) return;
|
||||
|
||||
const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width;
|
||||
const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height;
|
||||
const padPx = 48;
|
||||
const containerW = container.clientWidth - padPx;
|
||||
const containerH = container.clientHeight - padPx;
|
||||
|
||||
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
|
||||
let best = 0;
|
||||
for (let i = GRAPH_ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (GRAPH_ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
}
|
||||
setZoomIndex(best);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <p className="text-sm text-coral">{error}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<StageSidebar stages={stages} runId={id!} activeLink="graph" />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<ViewToggle view={view} setView={setView} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="graph-svg relative rounded-md border border-line bg-panel-alt"
|
||||
hidden={view !== "graph"}
|
||||
>
|
||||
<GraphToolbar
|
||||
direction={direction}
|
||||
setDirection={setDirection}
|
||||
fitToWindow={fitToWindow}
|
||||
zoomIndex={zoomIndex}
|
||||
setZoomIndex={setZoomIndex}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-hidden p-6"
|
||||
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className="flex items-center justify-center"
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">Loading diagram...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "source" && (
|
||||
<SourcePanel
|
||||
source={sourceQuery.data}
|
||||
loading={sourceQuery.data === undefined && !sourceQuery.error}
|
||||
dotReady={dotReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewToggle({ view, setView }: { view: View; setView: (v: View) => void }) {
|
||||
const btn =
|
||||
"rounded px-3 py-1.5 text-xs font-medium transition-colors";
|
||||
return (
|
||||
<div role="group" aria-label="Graph view" className="inline-flex rounded-md border border-line bg-panel/80 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("graph")}
|
||||
aria-pressed={view === "graph"}
|
||||
className={`${btn} ${view === "graph" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
>
|
||||
Graph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("source")}
|
||||
aria-pressed={view === "source"}
|
||||
className={`${btn} ${view === "source" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourcePanel({
|
||||
source,
|
||||
loading,
|
||||
dotReady,
|
||||
}: {
|
||||
source: string | null | undefined;
|
||||
loading: boolean;
|
||||
dotReady: boolean;
|
||||
}) {
|
||||
if (loading || !dotReady) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-panel-alt p-4">
|
||||
<LoadingState label="Loading graph source…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!source) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-panel-alt p-4">
|
||||
<p className="text-sm text-fg-muted">No graph source available for this run.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CollapsibleFile
|
||||
file={{ name: "workflow.fabro", contents: source, lang: "dot" as BundledLanguage }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -163,6 +163,9 @@ function SandboxPanel({ snapshot }: { snapshot: Snapshot }) {
|
|||
<Row title="Preserve" help="Keep the sandbox after the run completes.">
|
||||
<Toggle on={getBool(sandbox, "preserve") ?? false} />
|
||||
</Row>
|
||||
<Row title="Stop on terminal" help="Stop the sandbox when the run reaches a terminal state.">
|
||||
<Toggle on={getBool(sandbox, "stop_on_terminal") ?? false} />
|
||||
</Row>
|
||||
<Row title="Env" help="Environment variables injected into the sandbox.">
|
||||
<Count n={objectKeyCount(sandbox, "env")} singular="var" plural="vars" />
|
||||
</Row>
|
||||
|
|
|
|||
57
apps/fabro-web/app/routes/run-source.tsx
Normal file
57
apps/fabro-web/app/routes/run-source.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import type { BundledLanguage } from "@pierre/diffs";
|
||||
import { useRunGraphSource, useRunStages } from "../lib/queries";
|
||||
import { LoadingState } from "../components/state";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import { registerDotLanguage } from "../data/register-dot-language";
|
||||
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
export default function RunSource() {
|
||||
const { id } = useParams();
|
||||
const stagesQuery = useRunStages(id);
|
||||
const sourceQuery = useRunGraphSource(id, true);
|
||||
const stages = useMemo(
|
||||
() => mapRunStagesToSidebarStages(stagesQuery.data),
|
||||
[stagesQuery.data],
|
||||
);
|
||||
const [dotReady, setDotReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
registerDotLanguage().then(() => {
|
||||
if (!cancelled) setDotReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const source = sourceQuery.data;
|
||||
const loading = source === undefined && !sourceQuery.error;
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<StageSidebar stages={stages} runId={id!} activeLink="source" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{loading || !dotReady ? (
|
||||
<div className="rounded-md border border-line bg-panel-alt p-4">
|
||||
<LoadingState label="Loading graph source…" />
|
||||
</div>
|
||||
) : !source ? (
|
||||
<div className="rounded-md border border-line bg-panel-alt p-4">
|
||||
<p className="text-sm text-fg-muted">No graph source available for this run.</p>
|
||||
</div>
|
||||
) : (
|
||||
<CollapsibleFile
|
||||
file={{ name: "workflow.fabro", contents: source, lang: "dot" as BundledLanguage }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { eventsToActivity } from "./run-stages";
|
||||
import {
|
||||
eventsTabLabel,
|
||||
eventsToActivity,
|
||||
extractStageModel,
|
||||
groupConsecutiveTools,
|
||||
stageRendererBucket,
|
||||
} from "./run-stages";
|
||||
|
||||
function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
|
||||
return {
|
||||
|
|
@ -79,8 +85,8 @@ describe("eventsToActivity", () => {
|
|||
event: "command.completed",
|
||||
node_id: "fmt",
|
||||
properties: {
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
output: "blob://sha256/abc",
|
||||
output_bytes: 42,
|
||||
exit_code: 0,
|
||||
duration_ms: 12,
|
||||
termination: "exited",
|
||||
|
|
@ -94,6 +100,7 @@ describe("eventsToActivity", () => {
|
|||
kind: "command",
|
||||
script: "cargo fmt",
|
||||
running: false,
|
||||
outputBytes: 42,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -110,8 +117,7 @@ describe("eventsToActivity", () => {
|
|||
stage_id: "verify@2",
|
||||
node_id: "verify",
|
||||
properties: {
|
||||
stdout: "hi",
|
||||
stderr: "",
|
||||
output: "hi",
|
||||
exit_code: 0,
|
||||
duration_ms: 5,
|
||||
termination: "exited",
|
||||
|
|
@ -163,6 +169,231 @@ describe("eventsToActivity", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("renders injected steering as a transcript turn for the matching stage", () => {
|
||||
const events: EventEnvelope[] = [
|
||||
envelope(1, {
|
||||
event: "run.steer",
|
||||
properties: { text: "say hello" },
|
||||
}),
|
||||
envelope(2, {
|
||||
event: "agent.steering.injected",
|
||||
stage_id: "nap@1",
|
||||
node_id: "nap",
|
||||
properties: { text: "say hello", visit: 1 },
|
||||
}),
|
||||
envelope(3, {
|
||||
event: "agent.steering.injected",
|
||||
stage_id: "other@1",
|
||||
node_id: "other",
|
||||
properties: { text: "wrong stage", visit: 1 },
|
||||
}),
|
||||
];
|
||||
|
||||
expect(eventsToActivity(events, "nap@1")).toEqual([
|
||||
{
|
||||
kind: "steer",
|
||||
ts: "2026-04-09T12:00:00Z",
|
||||
content: "say hello",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders injected interrupt as a transcript turn for the matching stage", () => {
|
||||
const events: EventEnvelope[] = [
|
||||
envelope(1, {
|
||||
event: "run.interrupt",
|
||||
properties: {},
|
||||
}),
|
||||
envelope(2, {
|
||||
event: "agent.interrupt.injected",
|
||||
stage_id: "nap@1",
|
||||
node_id: "nap",
|
||||
properties: { visit: 1 },
|
||||
}),
|
||||
envelope(3, {
|
||||
event: "agent.interrupt.injected",
|
||||
stage_id: "other@1",
|
||||
node_id: "other",
|
||||
properties: { visit: 1 },
|
||||
}),
|
||||
];
|
||||
|
||||
expect(eventsToActivity(events, "nap@1")).toEqual([
|
||||
{
|
||||
kind: "interrupt",
|
||||
ts: "2026-04-09T12:00:00Z",
|
||||
content: "Agent interrupted",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders prompt.completed as an assistant turn for prompt-shape stages", () => {
|
||||
const events: EventEnvelope[] = [
|
||||
envelope(1, {
|
||||
event: "stage.prompt",
|
||||
stage_id: "summarize@1",
|
||||
node_id: "summarize",
|
||||
properties: { text: "summarize the diff" },
|
||||
}),
|
||||
envelope(2, {
|
||||
event: "prompt.completed",
|
||||
stage_id: "summarize@1",
|
||||
node_id: "summarize",
|
||||
properties: {
|
||||
response: "Refactored auth module",
|
||||
model: "claude-sonnet-4-6",
|
||||
provider: "anthropic",
|
||||
billing: { input_tokens: 120, output_tokens: 30 },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
expect(eventsToActivity(events, "summarize@1")).toEqual([
|
||||
{
|
||||
kind: "system",
|
||||
ts: "2026-04-09T12:00:00Z",
|
||||
content: "summarize the diff",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-09T12:00:00Z",
|
||||
content: "Refactored auth module",
|
||||
inputTokens: 120,
|
||||
outputTokens: 30,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not duplicate the assistant turn when prompt.completed follows agent.message", () => {
|
||||
const events: EventEnvelope[] = [
|
||||
envelope(1, {
|
||||
event: "stage.prompt",
|
||||
stage_id: "simplify@1",
|
||||
node_id: "simplify",
|
||||
properties: { text: "simplify" },
|
||||
}),
|
||||
envelope(2, {
|
||||
event: "agent.message",
|
||||
stage_id: "simplify@1",
|
||||
node_id: "simplify",
|
||||
properties: {
|
||||
text: "Done.",
|
||||
billing: { input_tokens: 10, output_tokens: 5 },
|
||||
},
|
||||
}),
|
||||
envelope(3, {
|
||||
event: "prompt.completed",
|
||||
stage_id: "simplify@1",
|
||||
node_id: "simplify",
|
||||
properties: {
|
||||
response: "Done.",
|
||||
model: "claude-sonnet-4-6",
|
||||
provider: "anthropic",
|
||||
billing: { input_tokens: 10, output_tokens: 5 },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const turns = eventsToActivity(events, "simplify@1");
|
||||
expect(turns).toEqual([
|
||||
{
|
||||
kind: "system",
|
||||
ts: "2026-04-09T12:00:00Z",
|
||||
content: "simplify",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-09T12:00:00Z",
|
||||
content: "Done.",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders prompt.completed even with no preceding stage.prompt", () => {
|
||||
const events: EventEnvelope[] = [
|
||||
envelope(1, {
|
||||
event: "prompt.completed",
|
||||
stage_id: "summarize@1",
|
||||
node_id: "summarize",
|
||||
properties: {
|
||||
response: "All clear.",
|
||||
model: "claude-sonnet-4-6",
|
||||
provider: "anthropic",
|
||||
billing: { input_tokens: 0, output_tokens: 4 },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
expect(eventsToActivity(events, "summarize@1")).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-09T12:00:00Z",
|
||||
content: "All clear.",
|
||||
inputTokens: 0,
|
||||
outputTokens: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("extractStageModel pulls model from agent.session.activated, ignoring other stages", () => {
|
||||
const events: EventEnvelope[] = [
|
||||
envelope(1, {
|
||||
event: "agent.session.activated",
|
||||
stage_id: "simplify@1",
|
||||
node_id: "simplify",
|
||||
properties: { provider: "anthropic", model: "claude-sonnet-4-5" },
|
||||
}),
|
||||
envelope(2, {
|
||||
event: "agent.session.activated",
|
||||
stage_id: "verify@1",
|
||||
node_id: "verify",
|
||||
properties: { provider: "openai", model: "gpt-5" },
|
||||
}),
|
||||
];
|
||||
|
||||
expect(extractStageModel(events, "simplify@1")).toBe("claude-sonnet-4-5");
|
||||
expect(extractStageModel(events, "verify@1")).toBe("gpt-5");
|
||||
expect(extractStageModel(events, "fmt@1")).toBe(null);
|
||||
});
|
||||
|
||||
test("extractStageModel uses latest stage event with a model", () => {
|
||||
const events: EventEnvelope[] = [
|
||||
envelope(1, {
|
||||
event: "stage.prompt",
|
||||
stage_id: "agent@1",
|
||||
node_id: "agent",
|
||||
properties: { model: "claude-opus-4-5" },
|
||||
}),
|
||||
envelope(2, {
|
||||
event: "agent.cli.started",
|
||||
stage_id: "agent@1",
|
||||
node_id: "agent",
|
||||
properties: {
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
command: "claude",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
expect(extractStageModel(events, "agent@1")).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
test("extractStageModel ignores model from unrelated event types", () => {
|
||||
const events: EventEnvelope[] = [
|
||||
envelope(1, {
|
||||
event: "agent.message",
|
||||
stage_id: "agent@1",
|
||||
node_id: "agent",
|
||||
properties: { text: "hi", model: "should-be-ignored" },
|
||||
}),
|
||||
];
|
||||
|
||||
expect(extractStageModel(events, "agent@1")).toBe(null);
|
||||
});
|
||||
|
||||
test("ignores unknown event types and events for other stages", () => {
|
||||
const events: EventEnvelope[] = [
|
||||
envelope(1, {
|
||||
|
|
@ -194,3 +425,178 @@ describe("eventsToActivity", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupConsecutiveTools", () => {
|
||||
type Filtered = Parameters<typeof groupConsecutiveTools>[0];
|
||||
|
||||
function tool(opts: {
|
||||
ts: string;
|
||||
toolName: string;
|
||||
durationMs?: number;
|
||||
isError?: boolean;
|
||||
input?: string;
|
||||
result?: string;
|
||||
}) {
|
||||
return {
|
||||
kind: "tool" as const,
|
||||
ts: opts.ts,
|
||||
toolName: opts.toolName,
|
||||
input: opts.input ?? "",
|
||||
result: opts.result ?? "",
|
||||
isError: opts.isError ?? false,
|
||||
durationMs: opts.durationMs ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(turn: ReturnType<typeof tool> | { kind: "system"; ts: string; content: string } | { kind: "assistant"; ts: string; content: string; inputTokens: number; outputTokens: number }, index: number): Filtered[number] {
|
||||
return { turn, index };
|
||||
}
|
||||
|
||||
test("empty input returns empty output", () => {
|
||||
expect(groupConsecutiveTools([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("single tool turn becomes a single, not a group", () => {
|
||||
const t = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 100 });
|
||||
expect(groupConsecutiveTools([entry(t, 0)])).toEqual([
|
||||
{ kind: "single", turn: t, turnIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("two consecutive same-tool successes form a group of 2", () => {
|
||||
const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 1000 });
|
||||
const b = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell", durationMs: 2000 });
|
||||
const result = groupConsecutiveTools([entry(a, 0), entry(b, 1)]);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
kind: "group",
|
||||
toolName: "shell",
|
||||
ts: "2026-04-09T12:00:00Z",
|
||||
durationMs: 3000,
|
||||
children: [
|
||||
{ turn: a, turnIndex: 0 },
|
||||
{ turn: b, turnIndex: 1 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("five consecutive same-tool successes form one group; durations summed; ts is first", () => {
|
||||
const turns = [0, 1, 2, 3, 4].map((i) =>
|
||||
tool({
|
||||
ts: `2026-04-09T12:00:0${i}Z`,
|
||||
toolName: "shell",
|
||||
durationMs: (i + 1) * 1000,
|
||||
}),
|
||||
);
|
||||
const filtered = turns.map((t, i) => entry(t, i));
|
||||
const result = groupConsecutiveTools(filtered);
|
||||
expect(result).toHaveLength(1);
|
||||
const item = result[0];
|
||||
expect(item.kind).toBe("group");
|
||||
if (item.kind === "group") {
|
||||
expect(item.ts).toBe("2026-04-09T12:00:00Z");
|
||||
expect(item.durationMs).toBe(15000);
|
||||
expect(item.children.map((c) => c.turnIndex)).toEqual([0, 1, 2, 3, 4]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a different tool between same-tool calls breaks the group boundary", () => {
|
||||
const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 1 });
|
||||
const b = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell", durationMs: 1 });
|
||||
const c = tool({ ts: "2026-04-09T12:00:02Z", toolName: "read_file", durationMs: 1 });
|
||||
const d = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell", durationMs: 1 });
|
||||
const e = tool({ ts: "2026-04-09T12:00:04Z", toolName: "shell", durationMs: 1 });
|
||||
const result = groupConsecutiveTools([
|
||||
entry(a, 0),
|
||||
entry(b, 1),
|
||||
entry(c, 2),
|
||||
entry(d, 3),
|
||||
entry(e, 4),
|
||||
]);
|
||||
expect(result.map((r) => r.kind)).toEqual(["group", "single", "group"]);
|
||||
if (result[0].kind === "group") {
|
||||
expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]);
|
||||
}
|
||||
if (result[1].kind === "single") {
|
||||
expect(result[1].turnIndex).toBe(2);
|
||||
}
|
||||
if (result[2].kind === "group") {
|
||||
expect(result[2].children.map((c) => c.turnIndex)).toEqual([3, 4]);
|
||||
}
|
||||
});
|
||||
|
||||
test("an errored tool call is never grouped and breaks the run", () => {
|
||||
const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell" });
|
||||
const errored = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell", isError: true });
|
||||
const c = tool({ ts: "2026-04-09T12:00:02Z", toolName: "shell" });
|
||||
const d = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell" });
|
||||
const result = groupConsecutiveTools([
|
||||
entry(a, 0),
|
||||
entry(errored, 1),
|
||||
entry(c, 2),
|
||||
entry(d, 3),
|
||||
]);
|
||||
expect(result.map((r) => r.kind)).toEqual(["single", "single", "group"]);
|
||||
if (result[1].kind === "single") {
|
||||
expect(result[1].turn).toBe(errored);
|
||||
}
|
||||
if (result[2].kind === "group") {
|
||||
expect(result[2].children.map((c) => c.turnIndex)).toEqual([2, 3]);
|
||||
}
|
||||
});
|
||||
|
||||
test("non-tool turns flush the buffer correctly", () => {
|
||||
const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell" });
|
||||
const b = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell" });
|
||||
const msg = {
|
||||
kind: "assistant" as const,
|
||||
ts: "2026-04-09T12:00:02Z",
|
||||
content: "thinking",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
const c = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell" });
|
||||
const result = groupConsecutiveTools([
|
||||
entry(a, 0),
|
||||
entry(b, 1),
|
||||
entry(msg, 2),
|
||||
entry(c, 3),
|
||||
]);
|
||||
expect(result.map((r) => r.kind)).toEqual(["group", "single", "single"]);
|
||||
if (result[0].kind === "group") {
|
||||
expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]);
|
||||
}
|
||||
if (result[2].kind === "single") {
|
||||
expect(result[2].turnIndex).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("stageRendererBucket", () => {
|
||||
test("maps agent and prompt handlers to the Thread renderer", () => {
|
||||
expect(stageRendererBucket("agent")).toBe("agent");
|
||||
expect(stageRendererBucket("prompt")).toBe("agent");
|
||||
expect(eventsTabLabel("transcript", stageRendererBucket("agent"))).toBe("Thread");
|
||||
});
|
||||
|
||||
test("maps command handlers to the Logs renderer", () => {
|
||||
expect(stageRendererBucket("command")).toBe("command");
|
||||
expect(eventsTabLabel("transcript", stageRendererBucket("command"))).toBe("Logs");
|
||||
});
|
||||
|
||||
test("maps non-transcript handlers to Debug", () => {
|
||||
for (const handler of [
|
||||
"start",
|
||||
"exit",
|
||||
"human",
|
||||
"conditional",
|
||||
"parallel",
|
||||
"parallel.fan_in",
|
||||
"stack.manager_loop",
|
||||
"wait",
|
||||
] as const) {
|
||||
expect(stageRendererBucket(handler)).toBe("other");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { ArchiveBoxIcon, ChevronDownIcon, CommandLineIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
|
|
@ -25,7 +25,8 @@ import { ciConfig, columnStatusDisplay, columnStatuses, deriveCiStatus, mapRunLi
|
|||
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
|
||||
import { formatRelativeTime } from "../lib/format";
|
||||
import { EmptyState } from "../components/state";
|
||||
import { SteerComposer } from "../components/steer-composer";
|
||||
import { InlineMarkdown } from "../components/inline-markdown";
|
||||
import { PullRequestChip } from "../components/pull-request-chip";
|
||||
import { useToast } from "../components/toast";
|
||||
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
|
||||
import { useAuthConfig, useBoardsRuns, useSystemInfo } from "../lib/queries";
|
||||
|
|
@ -40,21 +41,20 @@ export function meta({}: any) {
|
|||
}
|
||||
|
||||
interface ColumnStyle {
|
||||
iconType: "branch" | "pr";
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
const columnStyles: Record<ColumnStatus, ColumnStyle> = {
|
||||
queued: { iconType: "branch", actions: [] },
|
||||
initializing: { iconType: "branch", actions: [] },
|
||||
running: { iconType: "branch", actions: ["Watch", "Steer"] },
|
||||
blocked: { iconType: "branch", actions: ["Answer Question"] },
|
||||
succeeded: { iconType: "pr", actions: [] },
|
||||
failed: { iconType: "branch", actions: [] },
|
||||
archived: { iconType: "branch", actions: [] },
|
||||
queued: { actions: [] },
|
||||
initializing: { actions: [] },
|
||||
running: { actions: [] },
|
||||
blocked: { actions: ["Answer Question"] },
|
||||
succeeded: { actions: [] },
|
||||
failed: { actions: [] },
|
||||
archived: { actions: [] },
|
||||
};
|
||||
|
||||
const defaultColumnStyle: ColumnStyle = { iconType: "branch", actions: [] };
|
||||
const defaultColumnStyle: ColumnStyle = { actions: [] };
|
||||
const defaultColumnColors = { dot: "bg-fg-muted", text: "text-fg-muted" };
|
||||
|
||||
interface BoardRunsResponse {
|
||||
|
|
@ -68,7 +68,6 @@ type Column = {
|
|||
name: string;
|
||||
dot: string;
|
||||
text: string;
|
||||
iconType: "branch" | "pr";
|
||||
actions: string[];
|
||||
items: RunItem[];
|
||||
};
|
||||
|
|
@ -131,37 +130,6 @@ function listLifecycleStatusLabel(run: Pick<RunWithStatus, "statusLabel" | "life
|
|||
}
|
||||
|
||||
|
||||
function GitBranchIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.5 2.5 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitPullRequestIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
branch: GitBranchIcon,
|
||||
pr: GitPullRequestIcon,
|
||||
};
|
||||
|
||||
function CheckStatusIcon({ status }: { status: CheckStatus }) {
|
||||
switch (status) {
|
||||
case "success":
|
||||
|
|
@ -298,60 +266,45 @@ export const handle = {
|
|||
|
||||
function PrCard({
|
||||
pr,
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
actions,
|
||||
}: {
|
||||
pr: RunItem;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
iconColor: string;
|
||||
actions?: string[];
|
||||
}) {
|
||||
const lifecycleLabel = boardLifecycleStatusLabel(pr);
|
||||
const [steerOpen, setSteerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SteerComposer
|
||||
runId={pr.id}
|
||||
open={steerOpen}
|
||||
onClose={() => setSteerOpen(false)}
|
||||
/>
|
||||
<Link to={`/runs/${pr.id}`} className="group block rounded-md border border-line bg-panel p-4 transition-all duration-200 hover:border-line-strong hover:shadow-lg hover:shadow-black/20">
|
||||
<div className="group rounded-md border border-line bg-panel p-4 transition-all duration-200 hover:border-line-strong hover:shadow-lg hover:shadow-black/20">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Icon className={`size-3.5 shrink-0 ${iconColor}`} />
|
||||
<span className="font-mono text-xs font-medium text-teal-500">
|
||||
<Link to={`/runs/${pr.id}`} className="font-mono text-xs font-medium text-teal-500">
|
||||
{pr.repo}
|
||||
</span>
|
||||
{pr.number != null && (
|
||||
<span className="font-mono text-xs text-fg-muted">
|
||||
#{pr.number}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{lifecycleLabel != null && (
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[11px] uppercase tracking-wide text-fg-muted">
|
||||
{lifecycleLabel}
|
||||
</span>
|
||||
)}
|
||||
{pr.number != null && (
|
||||
<PullRequestChip
|
||||
number={pr.number}
|
||||
url={pr.pullRequestUrl}
|
||||
className={`ml-auto inline-flex items-center gap-1 font-mono text-xs ${iconColor}`}
|
||||
iconClassName="size-3.5 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-snug text-fg-2">{pr.title}</p>
|
||||
<Link to={`/runs/${pr.id}`} className="block">
|
||||
<p className="text-sm leading-snug text-fg-2">{pr.title}</p>
|
||||
</Link>
|
||||
|
||||
{(pr.additions != null || pr.resources != null || pr.elapsed != null) && (
|
||||
{(pr.resources != null || pr.comments != null || pr.elapsed != null) && (
|
||||
<div className="mt-3 flex items-center gap-3 font-mono text-xs">
|
||||
{pr.resources != null && (
|
||||
<span className="text-fg-3">{pr.resources}</span>
|
||||
)}
|
||||
{pr.additions != null && pr.deletions != null && (
|
||||
<>
|
||||
<span className="tabular-nums text-mint">
|
||||
+{pr.additions.toLocaleString()}
|
||||
</span>
|
||||
<span className="tabular-nums text-coral">
|
||||
-{pr.deletions.toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{pr.comments != null && (
|
||||
<span className="inline-flex items-center gap-1 text-fg-muted">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
|
||||
|
|
@ -379,13 +332,6 @@ function PrCard({
|
|||
key={label}
|
||||
type="button"
|
||||
disabled={pr.actionDisabled}
|
||||
onClick={(e) => {
|
||||
if (label === "Steer") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSteerOpen(true);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:text-fg-muted disabled:border-line ${
|
||||
label === "Merge"
|
||||
? "border-mint/20 text-mint hover:border-mint/50 hover:text-fg"
|
||||
|
|
@ -396,16 +342,6 @@ function PrCard({
|
|||
: "border-line-strong text-fg-3 hover:border-teal-500/40 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{label === "Watch" && (
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
|
||||
<path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.831.88 9.577.43 8.899a1.62 1.62 0 0 1 0-1.798c.45-.678 1.367-1.932 2.637-3.023C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.824.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z" />
|
||||
</svg>
|
||||
)}
|
||||
{label === "Steer" && (
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
|
||||
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7.25-4.5a.75.75 0 0 0-1.5 0v.582a2.75 2.75 0 0 0-2.168 2.168H4.5a.75.75 0 0 0 0 1.5h.582a2.75 2.75 0 0 0 2.168 2.168v.582a.75.75 0 0 0 1.5 0v-.582a2.75 2.75 0 0 0 2.168-2.168h.582a.75.75 0 0 0 0-1.5h-.582A2.75 2.75 0 0 0 8.75 4.082ZM8 6.75a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z" />
|
||||
</svg>
|
||||
)}
|
||||
{label === "Answer Question" && (
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
|
||||
<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
|
||||
|
|
@ -426,19 +362,32 @@ function PrCard({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</>
|
||||
|
||||
{((pr.additions != null && pr.additions !== 0) ||
|
||||
(pr.deletions != null && pr.deletions !== 0)) && (
|
||||
<div className="mt-3 flex items-center gap-3 font-mono text-xs">
|
||||
{pr.additions != null && (
|
||||
<span className="tabular-nums text-mint">
|
||||
+{pr.additions.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{pr.deletions != null && (
|
||||
<span className="tabular-nums text-coral">
|
||||
-{pr.deletions.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortablePrCard({
|
||||
pr,
|
||||
icon,
|
||||
iconColor,
|
||||
actions,
|
||||
}: {
|
||||
pr: RunItem;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
iconColor: string;
|
||||
actions?: string[];
|
||||
}) {
|
||||
|
|
@ -466,7 +415,7 @@ function SortablePrCard({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<PrCard pr={pr} icon={icon} iconColor={iconColor} actions={actions} />
|
||||
<PrCard pr={pr} iconColor={iconColor} actions={actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -550,7 +499,6 @@ function ColumnActionsMenu({ column }: { column: Column }) {
|
|||
}
|
||||
|
||||
function BoardColumn({ column }: { column: Column }) {
|
||||
const Icon = iconMap[column.iconType];
|
||||
const actions = column.actions;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
|
|
@ -571,7 +519,6 @@ function BoardColumn({ column }: { column: Column }) {
|
|||
<SortablePrCard
|
||||
key={pr.id}
|
||||
pr={pr}
|
||||
icon={Icon}
|
||||
iconColor={column.text}
|
||||
actions={actions}
|
||||
/>
|
||||
|
|
@ -595,6 +542,23 @@ const createdFilterOptions: { value: CreatedFilter; label: string }[] = [
|
|||
{ value: "30d", label: "Last 30 days" },
|
||||
];
|
||||
|
||||
function parseCreatedFilter(raw: string | null): CreatedFilter {
|
||||
switch (raw) {
|
||||
case "today":
|
||||
case "1h":
|
||||
case "1d":
|
||||
case "7d":
|
||||
case "30d":
|
||||
return raw;
|
||||
default:
|
||||
return "all";
|
||||
}
|
||||
}
|
||||
|
||||
function parseView(raw: string | null): ViewMode {
|
||||
return raw === "list" ? "list" : "columns";
|
||||
}
|
||||
|
||||
function createdCutoffMsFor(filter: CreatedFilter): number | null {
|
||||
const now = Date.now();
|
||||
switch (filter) {
|
||||
|
|
@ -621,7 +585,8 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
const statusDisplay = columnStatusDisplay[run.status];
|
||||
|
||||
return (
|
||||
<Link to={`/runs/${run.id}`} className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
|
||||
<div className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
|
||||
<Link to={`/runs/${run.id}`} className="contents">
|
||||
<span className="flex items-center gap-2 pr-2">
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${statusDisplay.dot}`} aria-hidden="true" />
|
||||
<span className={`font-mono text-xs ${statusDisplay.text}`}>{run.statusLabel}</span>
|
||||
|
|
@ -634,7 +599,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
<span className="truncate font-mono text-xs font-medium text-teal-500 pr-2">{run.repo}</span>
|
||||
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate text-sm text-fg-2">{run.title}</span>
|
||||
<InlineMarkdown content={run.title} className="truncate text-sm text-fg-2" />
|
||||
{lifecycleLabel != null && (
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[11px] uppercase tracking-wide text-fg-muted">
|
||||
{lifecycleLabel}
|
||||
|
|
@ -663,17 +628,16 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
{run.additions != null && <span className="text-mint">+{run.additions.toLocaleString()}</span>}
|
||||
{run.deletions != null && <span className="text-coral">-{run.deletions.toLocaleString()}</span>}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-fg-muted">
|
||||
{run.number != null && (
|
||||
<>
|
||||
<GitPullRequestIcon className="size-3" />
|
||||
#{run.number}
|
||||
<PullRequestChip number={run.number} url={run.pullRequestUrl}>
|
||||
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
|
||||
</>
|
||||
</PullRequestChip>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -796,7 +760,39 @@ function RunsLandingEmpty({
|
|||
}
|
||||
|
||||
export default function Runs() {
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = searchParams.get("search") ?? "";
|
||||
const repoFilter = searchParams.get("repo") ?? "all";
|
||||
const workflowFilter = searchParams.get("workflow") ?? "all";
|
||||
const createdFilter = parseCreatedFilter(searchParams.get("created"));
|
||||
const includeArchived = searchParams.get("archived") === "1";
|
||||
const view = parseView(searchParams.get("view"));
|
||||
|
||||
const updateParam = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (value == null || value === "") {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, value);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setQuery = (value: string) => updateParam("search", value || null);
|
||||
const setRepoFilter = (value: string) => updateParam("repo", value === "all" ? null : value);
|
||||
const setWorkflowFilter = (value: string) => updateParam("workflow", value === "all" ? null : value);
|
||||
const setCreatedFilter = (value: CreatedFilter) => updateParam("created", value === "all" ? null : value);
|
||||
const setIncludeArchived = (value: boolean) => updateParam("archived", value ? "1" : null);
|
||||
const setView = (value: ViewMode) => updateParam("view", value === "columns" ? null : value);
|
||||
|
||||
const boardRuns = useBoardsRuns(includeArchived);
|
||||
const authConfig = useAuthConfig();
|
||||
const systemInfo = useSystemInfo();
|
||||
|
|
@ -823,11 +819,6 @@ export default function Runs() {
|
|||
initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.workflow))),
|
||||
),
|
||||
].sort();
|
||||
const [query, setQuery] = useState("");
|
||||
const [repoFilter, setRepoFilter] = useState("all");
|
||||
const [workflowFilter, setWorkflowFilter] = useState("all");
|
||||
const [createdFilter, setCreatedFilter] = useState<CreatedFilter>("all");
|
||||
const [view, setView] = useState<ViewMode>("columns");
|
||||
const [columns, setColumns] = useState(initialColumns);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
useBoardEvents();
|
||||
|
|
@ -943,7 +934,7 @@ export default function Runs() {
|
|||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIncludeArchived((v) => !v)}
|
||||
onClick={() => setIncludeArchived(!includeArchived)}
|
||||
aria-pressed={includeArchived}
|
||||
title={includeArchived ? "Hide archived runs" : "Show archived runs"}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-3 py-2 text-xs font-medium transition-colors ${includeArchived ? "text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import type {
|
||||
WorkflowDetailResponse as ApiWorkflowDetail,
|
||||
WorkflowSettings as WorkflowSettingsSnapshot,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router";
|
||||
import { useWorkflow } from "../lib/queries";
|
||||
import type {
|
||||
WorkflowSettingsSnapshot,
|
||||
WorkflowDetailResponse as ApiWorkflowDetail,
|
||||
} from "../lib/workflow-api";
|
||||
|
||||
export interface WorkflowEntry {
|
||||
name: string;
|
||||
|
|
@ -66,6 +66,7 @@ function sampleSettings({
|
|||
sandbox: {
|
||||
provider: "daytona",
|
||||
preserve: false,
|
||||
stop_on_terminal: true,
|
||||
devcontainer: true,
|
||||
env: {},
|
||||
local: { worktree_mode: "dirty" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useState } from "react";
|
||||
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { InlineMarkdown } from "../components/inline-markdown";
|
||||
import { PullRequestChip } from "../components/pull-request-chip";
|
||||
import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs";
|
||||
import type { ColumnStatus, RunWithStatus } from "../data/runs";
|
||||
import { useWorkflowRuns } from "../lib/queries";
|
||||
|
|
@ -21,18 +23,11 @@ function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithSt
|
|||
.filter((run): run is RunWithStatus => run != null);
|
||||
}
|
||||
|
||||
function GitPullRequestIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className={className} aria-hidden="true">
|
||||
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: RunWithStatus }) {
|
||||
const colors = columnStatusDisplay[run.status];
|
||||
return (
|
||||
<Link to={`/runs/${run.id}`} className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
|
||||
<div className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
|
||||
<Link to={`/runs/${run.id}`} className="contents">
|
||||
<span className="flex items-center gap-2 pr-2">
|
||||
<span className={`size-2 shrink-0 rounded-full ${colors.dot}`} />
|
||||
<span className={`text-xs font-medium ${colors.text}`}>{run.statusLabel}</span>
|
||||
|
|
@ -43,7 +38,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate text-sm text-fg-2">{run.title}</span>
|
||||
<InlineMarkdown content={run.title} className="truncate text-sm text-fg-2" />
|
||||
{run.comments != null && run.comments > 0 && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-fg-muted">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
|
||||
|
|
@ -58,17 +53,16 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
{run.additions != null && <span className="text-mint">+{run.additions.toLocaleString()}</span>}
|
||||
{run.deletions != null && <span className="text-coral">-{run.deletions.toLocaleString()}</span>}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-fg-muted">
|
||||
{run.number != null && (
|
||||
<>
|
||||
<GitPullRequestIcon className="size-3" />
|
||||
#{run.number}
|
||||
<PullRequestChip number={run.number} url={run.pullRequestUrl}>
|
||||
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
|
||||
</>
|
||||
</PullRequestChip>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import {
|
|||
ShieldCheckIcon,
|
||||
WrenchIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { PaginatedWorkflowListResponse } from "@qltysh/fabro-api-client";
|
||||
import { Link } from "react-router";
|
||||
import { useWorkflows } from "../lib/queries";
|
||||
import { timeAgo, timeUntil } from "../lib/time";
|
||||
import type { PaginatedWorkflowListResponse } from "../lib/workflow-api";
|
||||
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Workflows — Fabro" }];
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"@qltysh/fabro-api-client": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@viz-js/viz": "^3.24.0",
|
||||
"axios": "^1.7.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -17,6 +17,7 @@
|
|||
"@qltysh/fabro-api-client": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@viz-js/viz": "^3.24.0",
|
||||
"axios": "^1.7.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
|
|
|||
34
docs/plans/2026-05-08-remove-retro-stage-plan.md
Normal file
34
docs/plans/2026-05-08-remove-retro-stage-plan.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Remove Retro Stage Entirely
|
||||
|
||||
## Summary
|
||||
|
||||
Delete the retro feature as a product/runtime concept, with no compatibility shims. The workflow pipeline becomes `initialize -> execute -> finalize -> pull_request`; there will be no retro agent, retro events, retro projection state, retro config, `--no-retro` flag, API fields, UI feature flag, PR retro section, or active docs/marketing references.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Runtime pipeline: delete `lib/crates/fabro-workflow/src/pipeline/retro.rs`, remove `RetroOptions`/`Retroed`, and change `finalize` to consume `Executed` directly after `execute`.
|
||||
- Types and storage: delete `fabro-types::retro`, remove `RunProjection.retro`, `retro_prompt`, and `retro_response`, remove retro event variants/props from `fabro-types` and `fabro-workflow::event`, and remove retro projection handling from `fabro-store`/`fabro-dump`.
|
||||
- Historical events: remove `retro.started`, `retro.completed`, and `retro.failed` from the `is_known_event_name` strict-deserialization whitelist so old stored retro events fall through to `EventBody::Unknown` instead of failing deserialization after the typed variants are deleted.
|
||||
- Dependencies: remove the `fabro-retro` crate directory and all `fabro-retro` dependencies from `fabro-workflow`, `fabro-cli`, and `fabro-server`; let `Cargo.lock` be regenerated by Cargo.
|
||||
- Config: remove `[run.execution].retros` from `fabro-config` defaults, run layer types, resolver output, obsolete-key hints, builders, and tests; remove the matching dense settings field from `fabro-types`.
|
||||
- CLI/API: remove `--no-retro`, `ManifestArgs.no_retro`, `SystemFeatures.retros`, and `RunExecutionSettings.retros`; update `docs/public/api-reference/fabro-api.yaml`, rebuild `fabro-api`, and regenerate `lib/packages/fabro-api-client`.
|
||||
- CLI event rendering: remove raw event-name handling for `retro.started`, `retro.completed`, and `retro.failed` from `lib/crates/fabro-cli/src/commands/run/events.rs` so retired retro events render only through the generic unknown-event path.
|
||||
- PR content: remove `format_retro_section`, stop reading retro state for PR bodies, and update PR body tests so only plan details and Fabro Details remain.
|
||||
- UI/docs/site/agent guidance: remove current active retro references from `apps/fabro-web`, `README.md`, `AGENTS.md`, `docs/public`, `docs/public/docs.json`, `docs/internal/events*.md`, product docs, marketing content, `evals/`, `test/retro-e2e.fabro`, and CLI test fixtures; delete `docs/public/execution/retros.mdx` and unused retro screenshots.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Run codegen: `cargo build -p fabro-api` and `cd lib/packages/fabro-api-client && bun run generate`.
|
||||
- Extend and rename the existing table-driven `retired_sandbox_snapshot_events_deserialize_as_unknown` regression test so it also proves `retro.started`, `retro.completed`, and `retro.failed` deserialize as `EventBody::Unknown` with properties preserved.
|
||||
- Update `lib/crates/fabro-api/tests/run_projection_round_trip.rs` and related JSON/API parity tests so `RunProjection` no longer requires or serializes `retro`, `retro_prompt`, or `retro_response`.
|
||||
- Update CLI command, workflow, scenario, and fixture tests that currently pass `--no-retro` or assert `no_retro`/`retros`/retro projection fields; delete the dedicated retro-enabled runner test rather than rewriting it around removed behavior.
|
||||
- Rust verification: `cargo nextest run --workspace`, `cargo +nightly-2026-04-14 fmt --check --all`, and `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`.
|
||||
- Frontend/docs verification: run `cd apps/fabro-web && bun run typecheck && bun test` and `cd apps/marketing && bun run build`, in parallel when possible; run `cd apps/fabro-web && bun run build` only if build-script test coverage is skipped or frontend asset output changed.
|
||||
- Snapshot flow: after test failures from removed CLI flags/help output, run `cargo insta pending-snapshots`, inspect expected changes, then accept only the intended snapshots.
|
||||
- Final grep check over tracked active files for `retro`, `retros`, `Retro`, and `no_retro`, excluding archival plans/brainstorms/changelogs unless explicitly scrubbed.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- No deprecation path: old config keys, CLI flags, API fields, generated client fields, and typed retro events are removed rather than translated.
|
||||
- Existing stored retro events are not migrated; they must deserialize as `EventBody::Unknown` with original properties preserved, and no runtime projection or UI will surface them.
|
||||
- Historical planning docs and changelog entries can remain as archive; active product docs, API docs, internal event references, and marketing copy should be scrubbed.
|
||||
59
docs/plans/2026-05-09-plural-pull-request-data-model-plan.md
Normal file
59
docs/plans/2026-05-09-plural-pull-request-data-model-plan.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Plural Pull Request Data Model And API
|
||||
|
||||
## Summary
|
||||
|
||||
- Make runtime pull request records plural now: a run has `pull_requests: []`, not `pull_request: null`.
|
||||
- Absorb the breaking API change now and remove singular runtime PR API fields/routes.
|
||||
- Keep end-user surfaces simple: CLI and web continue showing/acting on the first PR only, with no multi-PR UI or chooser.
|
||||
- Do not change run configuration settings like `[run.pull_request]`; that still controls whether a workflow auto-opens a PR.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Update OpenAPI runtime schemas so `RunProjection`, `RunSummary`, and board `RunListItem` expose `pull_requests: PullRequestRecord[]` or `RunPullRequest[]`; remove their singular `pull_request` fields.
|
||||
- Replace singular PR routes with plural routes:
|
||||
- `POST /api/v1/runs/{id}/pull_requests` creates and links a new GitHub PR.
|
||||
- `GET /api/v1/runs/{id}/pull_requests` lists stored PR records.
|
||||
- `POST /api/v1/runs/{id}/pull_requests/link` links an existing PR record without touching GitHub.
|
||||
- `POST /api/v1/runs/{id}/pull_requests/unlink` removes the association without closing the PR.
|
||||
- `GET/POST /api/v1/runs/{id}/pull_requests/{owner}/{repo}/{number}` style targeted detail/merge/close routes operate on a specific stored PR.
|
||||
- Add schemas for `PullRequestKey`, `LinkRunPullRequestRequest`, and `UnlinkRunPullRequestRequest`. Link/create requests may include `primary: boolean`; the first PR is primary by default, and primary means "move to index 0."
|
||||
- Replace projection events with action-oriented events:
|
||||
- `pull_request.linked` with `{ pull_request, source, primary }`
|
||||
- `pull_request.unlinked` with `{ pull_request, reason? }`
|
||||
- `pull_request.create_failed` with `{ error }`
|
||||
- Remove `pull_request.created` / `pull_request.failed` from canonical event handling unless implementation needs temporary test fixture cleanup during the refactor.
|
||||
|
||||
## Implementation Changes
|
||||
|
||||
- In `fabro-types` and the store reducer, replace `RunProjection.pull_request: Option<PullRequestRecord>` and `RunSummary.pull_request` with `pull_requests: Vec<PullRequestRecord>`.
|
||||
- Add helper behavior around PR keys: upsert linked records by `(owner, repo, number)`, move primary records to index `0`, and remove matching records on unlink.
|
||||
- Update workflow PR creation to emit `pull_request.linked` with `source: "created"` after GitHub succeeds, and `pull_request.create_failed` on failure.
|
||||
- Update server PR handlers to append link/unlink events rather than mutating state directly; remove the old "any PR exists" conflict because multiple PRs are now valid.
|
||||
- Update CLI `fabro pr create/view/merge/close` to keep singular UX:
|
||||
- `create` calls the plural create route and prints the returned URL.
|
||||
- `view/merge/close` list PRs, select `pull_requests[0]`, then call the targeted plural route.
|
||||
- If the list is empty, preserve the current "No pull request found" style error.
|
||||
- Update web data mapping in `apps/fabro-web/app/data/runs.ts` to read the first item from `pull_requests`; do not add any new UI controls or multiple-PR display.
|
||||
- Regenerate Rust and TypeScript API clients after editing `docs/public/api-reference/fabro-api.yaml`.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Add/update store reducer tests for link, idempotent upsert, primary reordering, unlink, and summary projection.
|
||||
- Add/update event conversion/name/pretty-output tests for `pull_request.linked`, `pull_request.unlinked`, and `pull_request.create_failed`.
|
||||
- Update server tests for plural route paths, list/link/unlink, targeted detail/merge/close, and create persisting into `pull_requests`.
|
||||
- Update CLI PR command tests to mock plural endpoints while keeping user-facing output singular.
|
||||
- Update web data mapper tests to prove only `pull_requests[0]` is surfaced in current cards.
|
||||
- Run:
|
||||
- `cargo build -p fabro-api`
|
||||
- `cd lib/packages/fabro-api-client && bun run generate`
|
||||
- `cargo nextest run -p fabro-store -p fabro-workflow -p fabro-server -p fabro-cli`
|
||||
- `cd apps/fabro-web && bun test && bun run typecheck`
|
||||
- `cargo +nightly-2026-04-14 fmt --check --all`
|
||||
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Breaking runtime API changes are acceptable; no singular runtime PR fields/routes are kept for compatibility.
|
||||
- No production data migration is needed. Existing local/dev event logs using old PR events may be treated as stale.
|
||||
- `pull_requests[0]` is the primary PR for current CLI/web behavior.
|
||||
- Link/unlink only changes Fabro's association to a PR; merge/close are the only operations that mutate GitHub state.
|
||||
|
|
@ -131,11 +131,11 @@ For the **CLI backend**, Fabro takes a different approach: it runs `git diff --n
|
|||
|
||||
## Artifact offloading
|
||||
|
||||
When a stage produces a large context value -- an LLM response or any context update -- Fabro automatically offloads it into a global content-addressed blob store instead of leaving the full value inline in durable context. Command stdout and stderr are streamed to stage log files while the command runs, then finalized into durable blob refs after completion.
|
||||
When a stage produces a large context value -- an LLM response or any context update -- Fabro automatically offloads it into a global content-addressed blob store instead of leaving the full value inline in durable context. Command output is streamed to a stage log file while the command runs, then finalized into a durable blob ref after completion.
|
||||
|
||||
### How offloading works
|
||||
|
||||
After each node completes, Fabro checks every context update. If the serialized JSON of a value exceeds **100KB**, it is stored once by SHA-256 hash and replaced with a durable blob ref. Command stdout and stderr are always stored this way after completion, even when they are small or empty:
|
||||
After each node completes, Fabro checks every context update. If the serialized JSON of a value exceeds **100KB**, it is stored once by SHA-256 hash and replaced with a durable blob ref. Command output is always stored this way after completion, even when it is small or empty:
|
||||
|
||||
```
|
||||
response.plan --> blob://sha256/2cf24dba5fb0...
|
||||
|
|
@ -238,5 +238,6 @@ Outputs and artifacts appear in several observability surfaces:
|
|||
|---|---|
|
||||
| `StageCompleted` event | `files_touched` list for the stage |
|
||||
| `WorkflowRunCompleted` event | `artifact_count` -- total number of offloaded artifacts across the run |
|
||||
| Web UI | Run stage output, stage artifacts, and downloadable artifact files |
|
||||
| [Preambles](/execution/context#preamble-construction) | File list and artifact pointer references for completed stages |
|
||||
| Stage logs | `status.json` in each stage's run directory contains the full outcome including `files_touched` |
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ tags:
|
|||
description: First-run browser install workflow
|
||||
- name: Integrations
|
||||
description: External provider callbacks and integration endpoints
|
||||
- name: Auth
|
||||
description: Browser authentication and demo-mode controls
|
||||
- name: Runs
|
||||
description: Run management operations
|
||||
- name: Human-in-the-Loop
|
||||
|
|
@ -621,6 +623,108 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────
|
||||
|
||||
/api/v1/auth/config:
|
||||
get:
|
||||
operationId: getAuthConfig
|
||||
tags: [Auth]
|
||||
summary: Retrieve auth configuration
|
||||
description: Returns the browser login methods enabled for this server.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: Enabled authentication methods
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthConfigResponse"
|
||||
|
||||
/api/v1/auth/me:
|
||||
get:
|
||||
operationId: getAuthMe
|
||||
tags: [Auth]
|
||||
summary: Retrieve current browser user
|
||||
description: Returns the authenticated browser session user and demo-mode state.
|
||||
responses:
|
||||
"200":
|
||||
description: Current authenticated browser user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthMeResponse"
|
||||
"401":
|
||||
description: Not authenticated
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/demo/toggle:
|
||||
post:
|
||||
operationId: toggleDemo
|
||||
tags: [Auth]
|
||||
summary: Toggle browser demo mode
|
||||
description: Enables or disables demo-mode routing for the current browser session.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DemoToggleRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Demo-mode state updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DemoToggleResponse"
|
||||
"401":
|
||||
description: Not authenticated
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/auth/login/dev-token:
|
||||
post:
|
||||
operationId: loginDevToken
|
||||
tags: [Auth]
|
||||
summary: Login with development token
|
||||
description: Creates a browser session from an enabled development token.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DevTokenLoginRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Browser session created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DevTokenLoginResponse"
|
||||
"401":
|
||||
description: Invalid or disabled development token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"409":
|
||||
description: Session secret is not configured
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Runs ──────────────────────────────────────────────────────────────
|
||||
|
||||
/api/v1/runs:
|
||||
|
|
@ -818,11 +922,17 @@ paths:
|
|||
operationId: deleteRun
|
||||
tags: [Runs]
|
||||
summary: Delete Run
|
||||
description: Deletes durable store state for a run. This does not remove any local run directory. Active runs require `force=true`.
|
||||
description: Deletes durable store state, local run scratch data, and the run-owned sandbox unless sandbox preservation is enabled. Active runs require `force=true`.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
- $ref: "#/components/parameters/ForceRunDelete"
|
||||
responses:
|
||||
"200":
|
||||
description: Run deleted and sandbox preservation details returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteRunResponse"
|
||||
"204":
|
||||
description: Run deleted or already absent
|
||||
"404":
|
||||
|
|
@ -1343,6 +1453,17 @@ paths:
|
|||
description: Renders the workflow graph as an SVG image using Graphviz.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
- name: direction
|
||||
in: query
|
||||
required: false
|
||||
description: Optional Graphviz rank direction override for the rendered graph.
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- LR
|
||||
- TB
|
||||
- BT
|
||||
- RL
|
||||
responses:
|
||||
"200":
|
||||
description: SVG image of the workflow graph
|
||||
|
|
@ -1877,16 +1998,15 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/stages/{stageId}/logs/{stream}:
|
||||
/api/v1/runs/{id}/stages/{stageId}/logs/output:
|
||||
get:
|
||||
operationId: getRunStageCommandLog
|
||||
tags: [Run Internals]
|
||||
summary: Tail Command Log
|
||||
description: Returns a byte-offset slice of a command stage stdout or stderr log. Bytes are base64-encoded and are not snapped to UTF-8 boundaries.
|
||||
description: Returns a byte-offset slice of a command stage output log. Bytes are base64-encoded and are not snapped to UTF-8 boundaries.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
- $ref: "#/components/parameters/StageId"
|
||||
- $ref: "#/components/parameters/CommandLogStream"
|
||||
- $ref: "#/components/parameters/CommandLogOffset"
|
||||
- $ref: "#/components/parameters/CommandLogLimit"
|
||||
responses:
|
||||
|
|
@ -1897,7 +2017,7 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/CommandLogResponse"
|
||||
"400":
|
||||
description: Invalid stage, stream, offset, or limit.
|
||||
description: Invalid stage, offset, or limit.
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
|
|
@ -2502,6 +2622,114 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Workflows ────────────────────────────────────────────────────────
|
||||
|
||||
/api/v1/workflows:
|
||||
get:
|
||||
operationId: listWorkflows
|
||||
tags: [Workflows]
|
||||
summary: List workflows
|
||||
description: Returns workflow definitions available to the browser workflow pages. Real-mode servers may return 501 until workflow cataloging is implemented.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PageLimit"
|
||||
- $ref: "#/components/parameters/PageOffset"
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated workflow summaries
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PaginatedWorkflowListResponse"
|
||||
"501":
|
||||
description: Workflow cataloging is not implemented in real mode
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/workflows/{name}:
|
||||
get:
|
||||
operationId: retrieveWorkflow
|
||||
tags: [Workflows]
|
||||
summary: Retrieve workflow
|
||||
description: Returns a single workflow definition and its dense settings snapshot.
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
description: Workflow slug or name.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Workflow details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WorkflowDetailResponse"
|
||||
"404":
|
||||
description: Workflow not found
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"501":
|
||||
description: Workflow cataloging is not implemented in real mode
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/workflows/{name}/runs:
|
||||
get:
|
||||
operationId: listWorkflowRuns
|
||||
tags: [Workflows]
|
||||
summary: List workflow runs
|
||||
description: Returns durable runs associated with one workflow.
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
description: Workflow slug or name.
|
||||
schema:
|
||||
type: string
|
||||
- $ref: "#/components/parameters/PageLimit"
|
||||
- $ref: "#/components/parameters/PageOffset"
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated durable runs for the workflow
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PaginatedRunList"
|
||||
"404":
|
||||
description: Workflow not found
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"501":
|
||||
description: Workflow cataloging is not implemented in real mode
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Insights ──────────────────────────────────────────────────────────
|
||||
|
||||
/api/v1/insights/queries:
|
||||
|
|
@ -3054,15 +3282,6 @@ components:
|
|||
type: string
|
||||
example: code@2
|
||||
|
||||
CommandLogStream:
|
||||
name: stream
|
||||
in: path
|
||||
required: true
|
||||
description: Command output stream to read.
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommandOutputStream"
|
||||
example: stdout
|
||||
|
||||
CommandLogOffset:
|
||||
name: offset
|
||||
in: query
|
||||
|
|
@ -3247,6 +3466,95 @@ components:
|
|||
format: uuid
|
||||
|
||||
schemas:
|
||||
AuthConfigResponse:
|
||||
description: Browser login methods enabled by server auth settings.
|
||||
type: object
|
||||
required:
|
||||
- methods
|
||||
properties:
|
||||
methods:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: ["dev-token", "github"]
|
||||
|
||||
AuthMeResponse:
|
||||
description: Current authenticated browser user and session state.
|
||||
type: object
|
||||
required:
|
||||
- user
|
||||
- provider
|
||||
- demoMode
|
||||
properties:
|
||||
user:
|
||||
$ref: "#/components/schemas/AuthSessionUser"
|
||||
provider:
|
||||
type: string
|
||||
example: dev-token
|
||||
demoMode:
|
||||
type: boolean
|
||||
|
||||
AuthSessionUser:
|
||||
description: Browser session user profile.
|
||||
type: object
|
||||
required:
|
||||
- login
|
||||
- name
|
||||
- email
|
||||
- avatarUrl
|
||||
- userUrl
|
||||
properties:
|
||||
login:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
idpIssuer:
|
||||
type: string
|
||||
idpSubject:
|
||||
type: string
|
||||
avatarUrl:
|
||||
type: string
|
||||
userUrl:
|
||||
type: string
|
||||
|
||||
DemoToggleRequest:
|
||||
description: Desired browser demo-mode state.
|
||||
type: object
|
||||
required:
|
||||
- enabled
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
|
||||
DemoToggleResponse:
|
||||
description: Updated browser demo-mode state.
|
||||
type: object
|
||||
required:
|
||||
- enabled
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
|
||||
DevTokenLoginRequest:
|
||||
description: Browser login payload for development-token auth.
|
||||
type: object
|
||||
required:
|
||||
- token
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
|
||||
DevTokenLoginResponse:
|
||||
description: Browser development-token login result.
|
||||
type: object
|
||||
required:
|
||||
- ok
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
|
||||
InstallSessionResponse:
|
||||
description: Current browser-install session snapshot with secrets redacted.
|
||||
type: object
|
||||
|
|
@ -3623,6 +3931,86 @@ components:
|
|||
meta:
|
||||
$ref: "#/components/schemas/PaginationMeta"
|
||||
|
||||
WorkflowScheduleSummary:
|
||||
description: Workflow schedule summary shown in workflow lists.
|
||||
type: object
|
||||
required:
|
||||
- expression
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
next_run:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
|
||||
WorkflowLastRunSummary:
|
||||
description: Most recent run timestamp for a workflow.
|
||||
type: object
|
||||
properties:
|
||||
ran_at:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
|
||||
WorkflowListItem:
|
||||
description: Workflow summary shown in workflow list pages.
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- slug
|
||||
- filename
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
filename:
|
||||
type: string
|
||||
last_run:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/WorkflowLastRunSummary"
|
||||
- type: "null"
|
||||
schedule:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/WorkflowScheduleSummary"
|
||||
- type: "null"
|
||||
|
||||
PaginatedWorkflowListResponse:
|
||||
description: Paginated list of workflows.
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WorkflowListItem"
|
||||
pagination:
|
||||
$ref: "#/components/schemas/PaginationMeta"
|
||||
|
||||
WorkflowDetailResponse:
|
||||
description: Workflow definition and dense settings snapshot.
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- slug
|
||||
- description
|
||||
- filename
|
||||
- settings
|
||||
- graph
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
filename:
|
||||
type: string
|
||||
settings:
|
||||
$ref: "#/components/schemas/WorkflowSettings"
|
||||
graph:
|
||||
type: string
|
||||
|
||||
PaginatedModelList:
|
||||
description: Paginated list of models.
|
||||
type: object
|
||||
|
|
@ -4721,6 +5109,27 @@ components:
|
|||
`server.web.url` is non-empty; absent otherwise.
|
||||
example: "http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z"
|
||||
|
||||
DeleteRunResponse:
|
||||
description: Returned when a run is deleted but its sandbox is intentionally preserved.
|
||||
type: object
|
||||
required: [deleted, sandbox_preserved, sandbox]
|
||||
properties:
|
||||
deleted:
|
||||
type: boolean
|
||||
sandbox_preserved:
|
||||
type: boolean
|
||||
sandbox:
|
||||
$ref: "#/components/schemas/DeleteRunSandbox"
|
||||
|
||||
DeleteRunSandbox:
|
||||
type: object
|
||||
required: [provider, identifier]
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
identifier:
|
||||
type: string
|
||||
|
||||
ApiQuestionOption:
|
||||
description: A selectable option for a multiple-choice or multi-select question.
|
||||
type: object
|
||||
|
|
@ -4793,25 +5202,90 @@ components:
|
|||
|
||||
SubmitAnswerRequest:
|
||||
description: >
|
||||
Request body for submitting an answer to a pending question.
|
||||
At least one of `value`, `selected_option_key`, or `selected_option_keys` must be provided.
|
||||
Request body for submitting an answer to a pending question. The
|
||||
`kind` discriminator determines which answer shape is submitted.
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SubmitAnswerYesRequest"
|
||||
- $ref: "#/components/schemas/SubmitAnswerNoRequest"
|
||||
- $ref: "#/components/schemas/SubmitAnswerSelectedRequest"
|
||||
- $ref: "#/components/schemas/SubmitAnswerMultiSelectedRequest"
|
||||
- $ref: "#/components/schemas/SubmitAnswerTextRequest"
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
"yes": "#/components/schemas/SubmitAnswerYesRequest"
|
||||
"no": "#/components/schemas/SubmitAnswerNoRequest"
|
||||
selected: "#/components/schemas/SubmitAnswerSelectedRequest"
|
||||
multi_selected: "#/components/schemas/SubmitAnswerMultiSelectedRequest"
|
||||
text: "#/components/schemas/SubmitAnswerTextRequest"
|
||||
|
||||
SubmitAnswerYesRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
value:
|
||||
kind:
|
||||
type: string
|
||||
description: Freeform answer text.
|
||||
example: "Yes, proceed with the changes."
|
||||
selected_option_key:
|
||||
enum: ["yes"]
|
||||
description: Affirmative answer for yes/no and confirmation questions.
|
||||
|
||||
SubmitAnswerNoRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
description: Key of the selected option (for single-select multiple-choice questions).
|
||||
enum: ["no"]
|
||||
description: Negative answer for yes/no questions.
|
||||
|
||||
SubmitAnswerSelectedRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- option_key
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [selected]
|
||||
description: Single selected option answer.
|
||||
option_key:
|
||||
type: string
|
||||
description: Key of the selected option.
|
||||
example: option_a
|
||||
selected_option_keys:
|
||||
|
||||
SubmitAnswerMultiSelectedRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- option_keys
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [multi_selected]
|
||||
description: Multiple selected option answer.
|
||||
option_keys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Keys of selected options (for multi-select questions).
|
||||
description: Keys of selected options.
|
||||
example: ["option_a", "option_b"]
|
||||
|
||||
SubmitAnswerTextRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- text
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [text]
|
||||
description: Freeform text answer.
|
||||
text:
|
||||
type: string
|
||||
description: Freeform answer text.
|
||||
example: "Yes, proceed with the changes."
|
||||
|
||||
ErrorResponseEntry:
|
||||
description: A single error entry in an error response.
|
||||
type: object
|
||||
|
|
@ -5181,13 +5655,6 @@ components:
|
|||
description: Blob identifier.
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
CommandOutputStream:
|
||||
description: Command output stream name.
|
||||
type: string
|
||||
enum:
|
||||
- stdout
|
||||
- stderr
|
||||
|
||||
CommandTermination:
|
||||
description: Terminal state for a command execution.
|
||||
type: string
|
||||
|
|
@ -5200,7 +5667,6 @@ components:
|
|||
description: Byte-offset command log slice.
|
||||
type: object
|
||||
required:
|
||||
- stream
|
||||
- offset
|
||||
- next_offset
|
||||
- total_bytes
|
||||
|
|
@ -5209,8 +5675,6 @@ components:
|
|||
- cas_ref
|
||||
- live_streaming
|
||||
properties:
|
||||
stream:
|
||||
$ref: "#/components/schemas/CommandOutputStream"
|
||||
offset:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
|
@ -5224,7 +5688,7 @@ components:
|
|||
total_bytes:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Total bytes currently available for the stream.
|
||||
description: Total bytes currently available for the output log.
|
||||
example: 8192
|
||||
bytes_base64:
|
||||
type: string
|
||||
|
|
@ -5232,7 +5696,7 @@ components:
|
|||
example: aGVsbG8K
|
||||
eof:
|
||||
type: boolean
|
||||
description: Whether the stream is finalized.
|
||||
description: Whether the output log is finalized.
|
||||
example: false
|
||||
cas_ref:
|
||||
oneOf:
|
||||
|
|
@ -5438,18 +5902,11 @@ components:
|
|||
items:
|
||||
type: object
|
||||
description: Per-branch result objects produced by a parallel stage.
|
||||
stdout:
|
||||
output:
|
||||
type: ["string", "null"]
|
||||
stderr:
|
||||
type: ["string", "null"]
|
||||
stdout_bytes:
|
||||
output_bytes:
|
||||
type: ["integer", "null"]
|
||||
minimum: 0
|
||||
stderr_bytes:
|
||||
type: ["integer", "null"]
|
||||
minimum: 0
|
||||
streams_separated:
|
||||
type: ["boolean", "null"]
|
||||
live_streaming:
|
||||
type: ["boolean", "null"]
|
||||
termination:
|
||||
|
|
@ -5643,6 +6100,10 @@ components:
|
|||
additionalProperties: true
|
||||
final_patch:
|
||||
type: ["string", "null"]
|
||||
diff_summary:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/DiffSummary"
|
||||
- type: "null"
|
||||
pull_request:
|
||||
type: ["object", "null"]
|
||||
additionalProperties: true
|
||||
|
|
@ -5718,6 +6179,14 @@ components:
|
|||
format: int64
|
||||
superseded_by:
|
||||
type: ["string", "null"]
|
||||
diff_summary:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/DiffSummary"
|
||||
- type: "null"
|
||||
pull_request:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/PullRequestRecord"
|
||||
- type: "null"
|
||||
|
||||
ForkRequest:
|
||||
description: Request body for creating a new run from a source run checkpoint.
|
||||
|
|
@ -5980,6 +6449,11 @@ components:
|
|||
type: integer
|
||||
description: Pull request number.
|
||||
example: 889
|
||||
html_url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Stored GitHub pull request URL.
|
||||
example: https://github.com/fabro-sh/fabro/pull/889
|
||||
additions:
|
||||
type: integer
|
||||
description: Lines added.
|
||||
|
|
@ -6461,12 +6935,29 @@ components:
|
|||
- skipped
|
||||
- cancelled
|
||||
|
||||
StageHandler:
|
||||
description: Canonical workflow stage handler kind.
|
||||
type: string
|
||||
enum:
|
||||
- start
|
||||
- exit
|
||||
- agent
|
||||
- prompt
|
||||
- command
|
||||
- human
|
||||
- conditional
|
||||
- parallel
|
||||
- parallel.fan_in
|
||||
- stack.manager_loop
|
||||
- wait
|
||||
|
||||
RunStage:
|
||||
description: A single stage in a run's workflow graph.
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- handler
|
||||
- status
|
||||
- node_id
|
||||
- visit
|
||||
|
|
@ -6479,6 +6970,8 @@ components:
|
|||
type: string
|
||||
description: Human-readable stage name.
|
||||
example: Propose Changes
|
||||
handler:
|
||||
$ref: "#/components/schemas/StageHandler"
|
||||
status:
|
||||
$ref: "#/components/schemas/StageState"
|
||||
duration_secs:
|
||||
|
|
@ -6606,6 +7099,27 @@ components:
|
|||
description: Total lines deleted.
|
||||
example: 234
|
||||
|
||||
DiffSummary:
|
||||
description: Cheap aggregate file and line counts for a run diff.
|
||||
type: object
|
||||
required:
|
||||
- files_changed
|
||||
- additions
|
||||
- deletions
|
||||
properties:
|
||||
files_changed:
|
||||
type: integer
|
||||
description: Total number of changed files, including binary files.
|
||||
example: 42
|
||||
additions:
|
||||
type: integer
|
||||
description: Total lines added across text files.
|
||||
example: 567
|
||||
deletions:
|
||||
type: integer
|
||||
description: Total lines deleted across text files.
|
||||
example: 234
|
||||
|
||||
RunFilesMeta:
|
||||
description: |
|
||||
Metadata for a `PaginatedRunFileList` response.
|
||||
|
|
@ -7585,12 +8099,14 @@ components:
|
|||
|
||||
RunSandboxSettings:
|
||||
type: object
|
||||
required: [provider, preserve, devcontainer, env, local, docker, daytona]
|
||||
required: [provider, preserve, stop_on_terminal, devcontainer, env, local, docker, daytona]
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
preserve:
|
||||
type: boolean
|
||||
stop_on_terminal:
|
||||
type: boolean
|
||||
devcontainer:
|
||||
type: boolean
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: "Refreshable GitHub workflow tokens"
|
||||
title: "Run events, raw logs, and refreshable GitHub tokens"
|
||||
date: "2026-05-06"
|
||||
---
|
||||
|
||||
|
|
@ -11,6 +11,21 @@ To migrate:
|
|||
2. In GitHub App mode, request scoped run tokens with `[run.integrations.github.permissions]`.
|
||||
</Warning>
|
||||
|
||||
## Separate run events and raw logs
|
||||
|
||||
`fabro events` now focuses on structured workflow events, while `fabro logs` prints the raw worker tracing output for a run. Use events when you want the run timeline, assistant messages, tool activity, or follow-mode output; use logs when you need low-level worker diagnostics.
|
||||
|
||||
```bash
|
||||
fabro events <run> --pretty --follow
|
||||
fabro logs <run> --tail 200
|
||||
```
|
||||
|
||||
The split makes the CLI match the product model more closely: events are the durable run history, and logs are the debugging stream.
|
||||
|
||||
## Better foreground server logs
|
||||
|
||||
`fabro server start --foreground` now writes server logs to stdout by default, and interactive terminals get a compact colored one-line format with date-bearing timestamps. Piped stdout and file logs keep the plain tracing format, so scripts and saved logs do not need to change.
|
||||
|
||||
## Refreshable GitHub tokens during workflows
|
||||
|
||||
Long-running workflows that used GitHub App installation tokens could outlive the first token minted for the run. Fabro now keeps GitHub credentials as a refreshable token source and resolves `GITHUB_TOKEN` lazily before command and API agent stages, so GitHub operations can continue after token rollover.
|
||||
|
|
@ -19,6 +34,14 @@ Tokens refresh when they are close to expiry. If a refresh fails while the cache
|
|||
|
||||
## More
|
||||
|
||||
<Accordion title="CLI">
|
||||
- Added `fabro events` for structured run event history with `--follow`, `--pretty`, `--since`, and `--tail`
|
||||
- `fabro logs` now prints raw worker tracing output with `--tail`
|
||||
- Foreground logs now show the run ID on run-scoped log lines
|
||||
- Failed API commands now preserve server response details instead of collapsing to a status-only error
|
||||
- CLI help output now wraps angle-bracket placeholders in backticks
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workflows">
|
||||
- Command stages and API agent stages resolve `GITHUB_TOKEN` from a refreshable source before use
|
||||
- GitHub App installation tokens refresh when they are within 15 minutes of expiry
|
||||
|
|
@ -26,10 +49,15 @@ Tokens refresh when they are close to expiry. If a refresh fails while the cache
|
|||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Interactive foreground server logs now use a compact colored TTY format
|
||||
- Worker processes preserve the terminal color environment passed by the foreground server
|
||||
- `--watch-web` now ignores non-source watcher events and responds faster during local development
|
||||
- Agent file reads now parallelize `read_many_files` work
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Fixed long-running GitHub App workflows losing GitHub access after the initial installation token expired
|
||||
- Fixed static configuration accepting short-lived `ghs_*` installation tokens
|
||||
- Fixed foreground server logs defaulting away from stdout
|
||||
- Fixed repeated `cargo dev` rebuilds during generated CLI reference refreshes
|
||||
</Accordion>
|
||||
|
|
|
|||
66
docs/public/changelog/2026-05-07.mdx
Normal file
66
docs/public/changelog/2026-05-07.mdx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
title: "Run debugger, sticky steering, and run filters"
|
||||
date: "2026-05-07"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**Command output streams are now merged.** Command stages expose one ordered output log instead of separate stdout and stderr streams.
|
||||
|
||||
To migrate:
|
||||
1. Replace `/api/v1/runs/{id}/stages/{stageId}/logs/{stream}` with `/api/v1/runs/{id}/stages/{stageId}/logs/output`.
|
||||
2. Replace command outcome fields such as `stdout`, `stderr`, `stdout_bytes`, and `stderr_bytes` with `output` and `output_bytes`.
|
||||
</Warning>
|
||||
|
||||
## Stage threads and debug events
|
||||
|
||||
The run stages view is now a stage debugger with a `Thread` view for readable agent activity and a `Debug` view for raw stage events. You can search and filter events, inspect tool calls in a side panel, see token and duration metrics, and open formatted JSON or Markdown details without leaving the stage.
|
||||
|
||||
Command stages now get a `Logs` tab with the real command output, so command-heavy workflows no longer require jumping to raw run logs to see what happened. Consecutive calls to the same tool are grouped together, which keeps long agent stages scannable while preserving the underlying details.
|
||||
|
||||
## Steering stays attached to the run
|
||||
|
||||
The web app now keeps steering and interview controls in a bottom-attached run dock. You can move between stages, logs, source, and billing while keeping the current human action available at the bottom of the screen.
|
||||
|
||||
Steering actions are also available from the run menu, including interrupt steering. That makes it easier to redirect a running agent from whichever run view you are already using.
|
||||
|
||||
## More useful run lists
|
||||
|
||||
The runs page now supports URL-backed filters for search, repository, workflow, created time, archived runs, and list-versus-column view. Run rows split repository, workflow, and created time into separate columns, and archived runs can be shown explicitly instead of disappearing from the board.
|
||||
|
||||
Run cards now surface pull request diff counts when available, and each terminal column can archive all archivable runs in that column from its actions menu.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="API">
|
||||
- Board run listing now accepts `include_archived=true` and can return an `archived` column
|
||||
- Run projections and summaries now expose `last_event_at` for activity-aware run views
|
||||
- Run projections and summaries now expose `diff_summary` with changed-file, addition, and deletion counts
|
||||
- Command stage log responses now return a single ordered output log instead of separate stdout and stderr fields
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="CLI">
|
||||
- Raw run logs are now kept free of ANSI escape sequences
|
||||
- Colored run span fields are parsed correctly when reading server-backed run logs
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workflows">
|
||||
- Command stages now persist one merged output stream for ordered stdout/stderr display
|
||||
- Workflow breadcrumbs stay visible on run detail pages
|
||||
- The Graph tab has moved to the run sidebar as `Graph Source`
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Run Logs now support level filtering, text search, copied output, and live refresh while a run is active
|
||||
- Run stage rows show model, duration, token counts, and absolute timestamp tooltips
|
||||
- Run Billing hides stages with no billing usage while keeping active stages visible
|
||||
- Run headers now show workflow metadata and recent activity
|
||||
- The run detail action buttons are collapsed into a menu
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Fixed active SSE streams blocking server shutdown
|
||||
- Fixed the synthetic start node being included in billing rollups
|
||||
- Fixed the Billing tab losing its anchored layout on run detail pages
|
||||
- Fixed full-height run stage layouts collapsing in the browser
|
||||
- Fixed the degraded sandbox banner appearing on the run files page after the sandbox is gone
|
||||
</Accordion>
|
||||
64
docs/public/changelog/2026-05-08.mdx
Normal file
64
docs/public/changelog/2026-05-08.mdx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
title: "Run Events, artifacts, and typed interviews"
|
||||
date: "2026-05-08"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**Interview answer submissions now use typed request bodies.** `POST /api/v1/runs/{id}/questions/{qid}/answer` requires a `kind` discriminator instead of the previous loose answer fields.
|
||||
|
||||
To migrate:
|
||||
1. Send `{ "kind": "yes" }` or `{ "kind": "no" }` for yes/no answers.
|
||||
2. Send `{ "kind": "selected", "option_key": "..." }` for single-choice answers.
|
||||
3. Send `{ "kind": "multi_selected", "option_keys": ["..."] }` or `{ "kind": "text", "text": "..." }` for multi-choice and freeform answers.
|
||||
</Warning>
|
||||
|
||||
## Run Events and Artifacts pages
|
||||
|
||||
Run detail sidebars now include dedicated `Run Events` and `Artifacts` pages. `Run Events` shows the full event stream with category filters, search, and an event detail panel, while `Artifacts` groups downloadable files by stage and retry.
|
||||
|
||||
This gives you a direct path to the two things users usually need after a run: the complete audit trail and the files produced by stages.
|
||||
|
||||
## Typed interview answers
|
||||
|
||||
Human-in-the-loop answers now have explicit API shapes for yes/no, single-select, multi-select, and text responses. The web UI and CLI attach flow use those shapes consistently, which removes ambiguity around which field should be present for each question type.
|
||||
|
||||
`fabro attach` also handles interview edge cases better. Invalid input re-prompts instead of dropping the interaction, and attach no longer blocks when a question was already answered from another client.
|
||||
|
||||
## API client coverage for the web app
|
||||
|
||||
The web app now uses the generated TypeScript API client for the auth, workflow, run, artifact, settings, and human-in-the-loop calls that can be generated from OpenAPI. The API reference now documents the browser auth and workflow routes the app relies on, which keeps the UI and public contract aligned as routes evolve.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="API">
|
||||
- `RunStage` now includes a canonical `handler` field so clients can choose agent, command, or debug renderers without guessing from names
|
||||
- Run summaries now include stored pull request records with `html_url`
|
||||
- OpenAPI now documents browser auth config, current user, demo toggle, and development-token login endpoints
|
||||
- OpenAPI now documents workflow list, workflow detail, and workflow runs endpoints
|
||||
- `GET /api/v1/runs/{id}/graph` now documents the optional `direction` query parameter
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="CLI">
|
||||
- `fabro attach` now re-prompts after invalid interview input
|
||||
- `fabro attach` now unblocks when an interview question is answered from another client
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workflows">
|
||||
- Added bundled `interview` and `sleeper` workflows for exercising human answers and interrupt handling
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Added a Profile page to the user menu
|
||||
- Run cards now show a pull request icon and number only when a stored pull request exists
|
||||
- Run cards now show repository names without repeating the owner prefix
|
||||
- Archived runs now have a Delete action in the web app
|
||||
- Unknown stage handler types now open in the Debug renderer by default
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fixes">
|
||||
- Fixed prompt-only stages missing completed responses in the Thread view
|
||||
- Fixed stage interrupt events missing from the Thread view
|
||||
- Fixed OpenAI reasoning metadata being lost across stateless round trips
|
||||
- Fixed duplicate action dividers in run menus
|
||||
- Fixed the steer bar not receiving focus from the actions menu
|
||||
</Accordion>
|
||||
|
|
@ -250,6 +250,8 @@
|
|||
"group": "May 2026",
|
||||
"icon": "clock-rotate-left",
|
||||
"pages": [
|
||||
"changelog/2026-05-08",
|
||||
"changelog/2026-05-07",
|
||||
"changelog/2026-05-06",
|
||||
"changelog/2026-05-05",
|
||||
"changelog/2026-05-04",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ The metadata branch (`fabro/meta/{run_id}`) is an orphan branch that stores stru
|
|||
After each node, the metadata branch is updated with:
|
||||
|
||||
- **`run.json`** — Refreshed projection snapshot with the new current checkpoint
|
||||
- **`stages/{rank:03}-{node_id}@{visit}/...`** — Execution-order-prefixed per-stage trace files (prompts, responses, status, diffs, stdout/stderr, and tool metadata)
|
||||
- **`stages/{rank:03}-{node_id}@{visit}/...`** — Execution-order-prefixed per-stage trace files (prompts, responses, status, diffs, command output, and tool metadata)
|
||||
|
||||
## What's in a checkpoint
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ The context starts empty at the beginning of a run. As each stage completes, its
|
|||
```
|
||||
Start → Plan → Implement → Test → Exit
|
||||
│ │ │
|
||||
│ │ └─ sets command.output, command.stderr
|
||||
│ │ └─ sets command.output
|
||||
│ └─ sets response.implement, last_response
|
||||
└─ sets response.plan, last_response
|
||||
```
|
||||
|
|
@ -44,8 +44,7 @@ Agents can also emit arbitrary context updates by including a JSON object with a
|
|||
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `command.output` | The command's stdout. Durable context stores this as a `blob://sha256/...` ref after the command completes; downstream prompts resolve it back to text. |
|
||||
| `command.stderr` | The command's stderr. Durable context stores this as a `blob://sha256/...` ref after the command completes; downstream prompts resolve it back to text. |
|
||||
| `command.output` | The command's ordered output stream. Durable context stores this as a `blob://sha256/...` ref after the command completes; downstream prompts resolve it back to text. |
|
||||
|
||||
### Human gates
|
||||
|
||||
|
|
@ -54,6 +53,9 @@ Agents can also emit arbitrary context updates by including a JSON object with a
|
|||
| `human.gate.selected` | The accelerator key (e.g. `"A"`) or `"freeform"` |
|
||||
| `human.gate.label` | The full label of the selected edge |
|
||||
| `human.gate.text` | The user's freeform text (if applicable) |
|
||||
| `human.gate.<node>.question` | The question text for a specific human gate node |
|
||||
| `human.gate.<node>.answer` | The answer text for a specific human gate node |
|
||||
| `human.gate.<node>.label` | The selected label for a specific human gate node, when applicable |
|
||||
|
||||
### Parallel merge (fan-in)
|
||||
|
||||
|
|
@ -172,11 +174,10 @@ Goal: Add a /health endpoint to the API server
|
|||
- Files: docs/plan.md
|
||||
- **test**: success
|
||||
- Script: `cargo test 2>&1 || true`
|
||||
- Stdout:
|
||||
- Output:
|
||||
```
|
||||
running 42 tests ... 41 passed, 1 failed
|
||||
```
|
||||
- Stderr: (empty)
|
||||
|
||||
## Context
|
||||
- tests_passed: false
|
||||
|
|
@ -188,7 +189,7 @@ The preamble includes:
|
|||
- The workflow goal
|
||||
- A summary of completed stages (format depends on fidelity level)
|
||||
- Handler-specific details:
|
||||
- **Command nodes** — the script that ran, stdout, and stderr
|
||||
- **Command nodes** — the script that ran and the ordered output
|
||||
- **Agent/prompt nodes** — the model used, token counts, and files touched
|
||||
- Non-internal context values that weren't already rendered inline
|
||||
|
||||
|
|
@ -196,7 +197,7 @@ Internal keys (prefixed with `internal.`, `current`, `graph.`, `thread.`, `respo
|
|||
|
||||
## Artifact offloading
|
||||
|
||||
When a stage produces a large output (over 100KB of serialized JSON), Fabro stores the serialized bytes in a global content-addressed blob store and replaces the context value with a durable blob ref. Command stdout and stderr are always finalized into blob refs after command completion, even when they are small or empty:
|
||||
When a stage produces a large output (over 100KB of serialized JSON), Fabro stores the serialized bytes in a global content-addressed blob store and replaces the context value with a durable blob ref. Command output is always finalized into a blob ref after command completion, even when it is small or empty:
|
||||
|
||||
```
|
||||
response.plan → blob://sha256/2cf24dba5fb0...
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ When running workflows through the API server, subscribe to the [run events endp
|
|||
|
||||
### Web UI
|
||||
|
||||
The web frontend consumes the SSE stream automatically and shows stage progress, tool calls, and human interaction as they happen.
|
||||
The web frontend consumes the SSE stream automatically and shows stage progress, tool calls, command output, and human interaction as they happen. Use the stage `Thread` and `Debug` views for per-stage activity, or the run-level `Run Events` page when you need the full event stream with search and category filters.
|
||||
|
||||
<Frame caption="The Stages tab shows the full agent conversation including tool calls and responses.">
|
||||
<img src="/images/web/run-stages.png" alt="Fabro web UI run stages showing agent conversation with tool calls" />
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ When the engine reaches a human gate node (`shape=hexagon`), the [human handler]
|
|||
1. Each edge becomes an option, with the accelerator key parsed from the label (e.g. `[A] Approve` → key `A`, label `[A] Approve`)
|
||||
2. Edges with `freeform=true` are excluded from the option list and enable free-text fallback
|
||||
3. The question text comes from the node's `label` attribute
|
||||
4. The question type defaults to `multiple_choice` when fixed options exist and `freeform` when only a freeform edge exists. Set `question_type="yes_no"`, `question_type="confirmation"`, `question_type="multi_select"`, or another supported value to override it.
|
||||
|
||||
The handler then passes the question to the interviewer, waits for an answer, and maps it back to an edge for [transition](/workflows/transitions#human-gate-transitions).
|
||||
|
||||
|
|
|
|||
|
|
@ -228,6 +228,13 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be
|
|||
|---|---|---|
|
||||
| `duration` | Duration | How long to pause (required). E.g. `"30s"`, `"2m"` |
|
||||
|
||||
### Human nodes
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|---|---|---|
|
||||
| `question_type` | String | Optional interview question type override: `yes_no`, `confirmation`, `multiple_choice`, `multi_select`, or `freeform`. Defaults to `freeform` when the gate only has a freeform edge; otherwise defaults to `multiple_choice`. |
|
||||
| `human.default_choice` | String | Target node to use when the question times out. |
|
||||
|
||||
### Manager loop (sub-workflow) nodes
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ Metadata branch snapshots and `fabro dump` exports now use the same core layout:
|
|||
|
||||
- `run.json` for the current projection snapshot, including the current checkpoint
|
||||
- `graph.fabro` for workflow source
|
||||
- `stages/{rank:03}-{node_id}@{visit}/...` for execution-order-prefixed per-stage prompt, response, status, diff, stdout, and stderr files
|
||||
- `stages/{rank:03}-{node_id}@{visit}/...` for execution-order-prefixed per-stage prompt, response, status, diff, and command output files
|
||||
|
||||
`fabro dump` adds export-only history surfaces on top of that shared layout:
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,19 @@ If the user types something that doesn't match any fixed option, their input rou
|
|||
|
||||
When a gate has only a freeform edge and no fixed choices, the user goes directly to a text input prompt — skipping the multiple-choice menu entirely. This is useful for conversational loops like REPL workflows where the user provides open-ended instructions each iteration.
|
||||
|
||||
### Question types
|
||||
|
||||
Human gates infer their interview type from their edges: fixed choices become `multiple_choice`, and a gate with only a freeform edge becomes `freeform`. To force another interviewer presentation, set `question_type` on the node:
|
||||
|
||||
```dot
|
||||
confirm [shape=hexagon, label="Continue?", question_type="confirmation"]
|
||||
|
||||
confirm -> next [label="[Y] Yes"]
|
||||
confirm -> exit [label="[N] No"]
|
||||
```
|
||||
|
||||
Supported values are `yes_no`, `confirmation`, `multiple_choice`, `multi_select`, and `freeform`.
|
||||
|
||||
### Default choice on timeout
|
||||
|
||||
If a human gate has a timeout configured, you can specify a default choice using the `human.default_choice` attribute:
|
||||
|
|
@ -97,5 +110,8 @@ When a user makes a selection, the human gate sets several context values for do
|
|||
| `human.gate.selected` | The accelerator key (e.g. `"A"`) or `"freeform"` |
|
||||
| `human.gate.label` | The full label of the selected edge |
|
||||
| `human.gate.text` | The user's freeform text (if applicable) |
|
||||
| `human.gate.<node>.question` | The question text for a specific human gate node |
|
||||
| `human.gate.<node>.answer` | The answer text for a specific human gate node |
|
||||
| `human.gate.<node>.label` | The selected label for a specific human gate node, when applicable |
|
||||
|
||||
These can be read in edge conditions or referenced by downstream agents.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
# Generated TypeScript API Client Migration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make `apps/fabro-web` use the generated `@qltysh/fabro-api-client` Axios client for every API-style call that can reasonably be generated.
|
||||
|
||||
**Architecture:** Expand the OpenAPI spec for frontend-consumed API routes that are missing from the generated client, regenerate Rust and TypeScript clients, then add a web-side adapter around the generated Axios APIs. Migrate web queries and mutations from hard-coded request URLs to generated client methods while preserving existing SWR caching behavior, API error behavior, install flow behavior, and SSE streaming behavior.
|
||||
|
||||
**Tech Stack:** OpenAPI, Rust `fabro-api` progenitor generation, OpenAPI Generator `typescript-axios`, React 19, SWR, Axios, Bun, TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The repository already generates a TypeScript Axios client in `lib/packages/fabro-api-client`, but `apps/fabro-web` mostly imports generated model types while still sending requests through `fetch` helpers and hard-coded paths. This plan migrates the web app transport layer to generated API classes and expands OpenAPI for the frontend API routes that currently cannot be generated.
|
||||
|
||||
The migration is a transport refactor. It should not intentionally change server wire shapes, UI behavior, auth semantics, demo behavior, pagination limits, or SSE connection behavior.
|
||||
|
||||
## Important Interfaces And Contracts
|
||||
|
||||
- OpenAPI remains the source of truth at `docs/public/api-reference/fabro-api.yaml`.
|
||||
- Generated TypeScript client output remains under `lib/packages/fabro-api-client/src/**`; do not hand-edit generated files.
|
||||
- The web app should import generated API classes and types from `@qltysh/fabro-api-client`.
|
||||
- Browser SSE remains custom `EventSource` code. The generated Axios client is not the transport for `/api/v1/attach` or `/api/v1/runs/{id}/attach`.
|
||||
- Browser navigation and form endpoints remain browser routes:
|
||||
- `GET /auth/login/github`
|
||||
- `POST /auth/logout`
|
||||
|
||||
## Task 1: Add Missing Frontend API Routes To OpenAPI
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/public/api-reference/fabro-api.yaml`
|
||||
- Modify if needed: `lib/crates/fabro-server/src/web_auth.rs`
|
||||
- Modify if needed: `lib/crates/fabro-server/src/server/handler/mod.rs`
|
||||
- Test: `lib/crates/fabro-server/src/server/tests.rs` or existing OpenAPI conformance tests
|
||||
|
||||
- [x] Add `GET /api/v1/auth/config` with operation id `getAuthConfig`, tag `Auth`, and response schema matching the current UI expectation:
|
||||
- response object has `methods: string[]`
|
||||
- current values include auth method names such as `dev-token` and `github`
|
||||
|
||||
- [x] Add `GET /api/v1/auth/me` with operation id `getAuthMe`, tag `Auth`, and response schema matching the current `AuthMeResponse` in `web_auth.rs`:
|
||||
- `user.login`
|
||||
- `user.name`
|
||||
- `user.email`
|
||||
- optional `user.idpIssuer`
|
||||
- optional `user.idpSubject`
|
||||
- `user.avatarUrl`
|
||||
- `user.userUrl`
|
||||
- `provider`
|
||||
- `demoMode`
|
||||
|
||||
- [x] Add `POST /api/v1/demo/toggle` with operation id `toggleDemo`, tag `Auth`, request body `{ enabled: boolean }`, and response body `{ enabled: boolean }`.
|
||||
|
||||
- [x] Add `POST /auth/login/dev-token` with operation id `loginDevToken`, tag `Auth`, request body `{ token: string }`, and response body `{ ok: boolean }`.
|
||||
|
||||
- [x] Add workflow routes consumed by the demo-only workflow pages:
|
||||
- `GET /api/v1/workflows` with operation id `listWorkflows`
|
||||
- `GET /api/v1/workflows/{name}` with operation id `retrieveWorkflow`
|
||||
- `GET /api/v1/workflows/{name}/runs` with operation id `listWorkflowRuns`
|
||||
|
||||
- [x] Define workflow schemas from the current hand-written TypeScript shapes in `apps/fabro-web/app/lib/workflow-api.ts`:
|
||||
- `WorkflowScheduleSummary`
|
||||
- `WorkflowLastRunSummary`
|
||||
- `WorkflowListItem`
|
||||
- `PaginatedWorkflowListResponse`
|
||||
- `WorkflowDetailResponse`
|
||||
|
||||
- [x] Preserve current server behavior for workflow routes:
|
||||
- real routes may continue returning `501 Not Implemented`
|
||||
- demo routes return demo workflow data
|
||||
- OpenAPI should document normal success responses plus standard API error responses
|
||||
|
||||
- [x] Fix existing spec drift for `GET /api/v1/runs/{id}/graph`:
|
||||
- add optional query parameter `direction`
|
||||
- allowed values: `LR`, `TB`, `BT`, `RL`
|
||||
- generated `RunsApi.retrieveRunGraph` should accept the direction argument after regeneration
|
||||
|
||||
- [x] Confirm no OpenAPI additions are needed for recently added run deletion and artifacts UI calls.
|
||||
- `RunsApi.deleteRun` is already generated from `DELETE /api/v1/runs/{id}`.
|
||||
- `RunInternalsApi.listRunArtifacts` is already generated from `GET /api/v1/runs/{id}/artifacts`.
|
||||
- `RunInternalsApi.getStageArtifact` is already generated from `GET /api/v1/runs/{id}/stages/{stageId}/artifacts/download`.
|
||||
|
||||
## Task 2: Regenerate API Clients
|
||||
|
||||
**Files:**
|
||||
- Modify generated: `lib/crates/fabro-api/src/**`
|
||||
- Modify generated: `lib/packages/fabro-api-client/src/**`
|
||||
- Test: `lib/packages/fabro-api-client`
|
||||
|
||||
- [x] Run `cargo build -p fabro-api`.
|
||||
- Expected: Rust OpenAPI client/types regenerate and compile.
|
||||
|
||||
- [x] Run `cd lib/packages/fabro-api-client && bun run generate`.
|
||||
- Expected: TypeScript Axios client regenerates with Auth and Workflow APIs plus updated `retrieveRunGraph` signature.
|
||||
|
||||
- [x] Run `cd lib/packages/fabro-api-client && bun run typecheck`.
|
||||
- Expected: generated client typechecks.
|
||||
|
||||
- [x] Inspect generated `lib/packages/fabro-api-client/src/api.ts`.
|
||||
- Expected: exports include generated APIs for auth/workflow routes, either as new tag APIs or existing tag APIs depending on the chosen OpenAPI tags.
|
||||
- Expected: existing exports still expose `RunsApi.deleteRun`, `RunInternalsApi.listRunArtifacts`, and `RunInternalsApi.getStageArtifact`.
|
||||
|
||||
## Task 3: Add A Web-Side Generated Client Adapter
|
||||
|
||||
**Files:**
|
||||
- Modify or split: `apps/fabro-web/app/lib/api-client.ts`
|
||||
- Possibly create: `apps/fabro-web/app/lib/generated-api.ts`
|
||||
- Modify: `apps/fabro-web/package.json`
|
||||
- Test: `apps/fabro-web/app/lib/api-client.test.ts`
|
||||
|
||||
- [x] Add `axios` as an explicit dependency of `apps/fabro-web`.
|
||||
- Rationale: `@qltysh/fabro-api-client` already depends on Axios, but the web app should explicitly own the runtime transport it configures.
|
||||
|
||||
- [x] Create a single generated API adapter module that:
|
||||
- creates one Axios instance with same-origin behavior and credentials included
|
||||
- creates `Configuration({ basePath: "" })`
|
||||
- exports instantiated generated API classes used by the web app
|
||||
- exposes helpers for converting Axios responses to response data
|
||||
|
||||
- [x] Preserve the existing `ApiError` contract used by UI components:
|
||||
- `status`
|
||||
- `requestId`
|
||||
- `body`
|
||||
- message behavior compatible with current callers
|
||||
|
||||
- [x] Normalize generated Axios failures into `ApiError`.
|
||||
- Extract request id from response headers first, then response body.
|
||||
- Preserve existing body parsing for JSON API error responses.
|
||||
- Preserve current 401 redirect to `/login` for normal authenticated app API calls.
|
||||
|
||||
- [x] Add a no-redirect mode for install and login calls.
|
||||
- Install requests and dev-token login failures must reject with an error the install/login UI can display, not redirect to `/login`.
|
||||
|
||||
- [x] Keep text/blob response support for generated calls that return non-JSON data:
|
||||
- graph SVG
|
||||
- graph DOT source
|
||||
- run logs
|
||||
- stage artifact downloads
|
||||
- stage command logs where generated response is typed as JSON
|
||||
|
||||
- [x] Add an artifact-download helper that uses generated client metadata instead of hand-built route strings.
|
||||
- Preferred shape: use the generated `RunInternalsApi` operation or generated request builder for `getStageArtifact`.
|
||||
- Preserve browser download behavior in `run-artifacts.tsx`; if using an `<a href>`, build that href through the generated request builder rather than duplicating the endpoint path.
|
||||
|
||||
## Task 4: Migrate Install, Auth, Mutations, And Run Actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/fabro-web/app/install-api.ts`
|
||||
- Modify: `apps/fabro-web/app/lib/mutations.ts`
|
||||
- Modify: `apps/fabro-web/app/lib/run-actions.ts`
|
||||
- Test: `apps/fabro-web/app/install-api.test.ts`
|
||||
- Test: `apps/fabro-web/app/lib/run-actions.test.ts`
|
||||
- Test: mutation-related component tests as needed
|
||||
|
||||
- [x] Replace `install-api.ts` custom `fetch` calls with generated `InstallApi` methods.
|
||||
- Pass the one-time install token through `Authorization: Bearer <token>` request options.
|
||||
- Keep the existing exported function names so `install-app.tsx` does not need broad rewiring.
|
||||
- Keep existing install error messages from API error details where available.
|
||||
|
||||
- [x] Replace `useLoginDevToken` custom `fetch` with generated `loginDevToken`.
|
||||
- Preserve `credentials: include` behavior through the Axios instance.
|
||||
- Preserve UI behavior: failed login shows "Invalid dev token."
|
||||
|
||||
- [x] Replace generated-capable mutations in `mutations.ts`:
|
||||
- preview uses `HumanInTheLoopApi.generatePreviewUrl`
|
||||
- submit interview answer uses `HumanInTheLoopApi.submitRunAnswer`
|
||||
- interrupt uses `HumanInTheLoopApi.interruptRun`
|
||||
- steer uses `HumanInTheLoopApi.steerRun`
|
||||
- demo toggle uses generated `toggleDemo`
|
||||
|
||||
- [x] Replace lifecycle actions in `run-actions.ts`:
|
||||
- cancel uses `RunsApi.cancelRun`
|
||||
- archive uses `RunsApi.archiveRun`
|
||||
- unarchive uses `RunsApi.unarchiveRun`
|
||||
- delete uses `RunsApi.deleteRun`
|
||||
|
||||
- [x] Preserve lifecycle error mapping.
|
||||
- `mapError` should still receive `LifecycleActionError` with `status` and `errors`.
|
||||
- Existing 404/409 UI messages should remain unchanged.
|
||||
|
||||
- [x] Preserve archived-run delete behavior.
|
||||
- `deleteRun` should still treat `204` and `404` as success.
|
||||
- `deleteErrorMessage` should still map `409` to "Active runs can't be deleted."
|
||||
- `run-detail.tsx` should still invalidate both normal and archived board keys and navigate back to `/runs` after successful deletion.
|
||||
|
||||
## Task 5: Migrate Queries And Cache Keys
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/fabro-web/app/lib/queries.ts`
|
||||
- Modify: `apps/fabro-web/app/lib/query-keys.ts`
|
||||
- Modify: `apps/fabro-web/app/lib/board-events.ts`
|
||||
- Modify: `apps/fabro-web/app/lib/run-events.ts`
|
||||
- Modify: `apps/fabro-web/app/lib/cross-tab-sse.ts`
|
||||
- Modify: `apps/fabro-web/app/routes/run-artifacts.tsx`
|
||||
- Test: `apps/fabro-web/app/lib/query-keys.test.ts`
|
||||
- Test: `apps/fabro-web/app/lib/board-events.test.tsx`
|
||||
- Test: `apps/fabro-web/app/lib/run-events.test.tsx`
|
||||
- Test: `apps/fabro-web/app/lib/cross-tab-sse.test.ts`
|
||||
|
||||
- [x] Change SWR cache keys from request URLs to semantic tuple keys.
|
||||
- Example shape: `["runs", "detail", id]`, `["runs", "files", id]`, `["board-runs", includeArchived]`.
|
||||
- Keep one source of truth in `query-keys.ts`.
|
||||
|
||||
- [x] Keep dedicated URL builders only for SSE streams.
|
||||
- `queryKeys.system.attachUrl()` or equivalent for `/api/v1/attach`
|
||||
- `queryKeys.runs.attachUrl(id)` or equivalent for `/api/v1/runs/{id}/attach`
|
||||
- Do not keep hand-built URL helpers for artifact downloads; route construction should come from generated client metadata.
|
||||
|
||||
- [x] Migrate `queries.ts` hooks to generated APIs:
|
||||
- auth config and current user
|
||||
- system info
|
||||
- board runs with pagination
|
||||
- run detail, state, files, stages, graph, graph source, logs, artifacts, settings, billing, questions
|
||||
- stage events with cursor pagination
|
||||
- stage command log
|
||||
- stage artifact download support for `run-artifacts.tsx`
|
||||
- workflows, workflow detail, workflow runs
|
||||
- insights queries and history
|
||||
- server settings
|
||||
|
||||
- [x] Preserve current pagination safety caps:
|
||||
- normal page-based fetches keep max page and max item caps
|
||||
- stage events keep cursor pagination from `since_seq=1`, page size `1000`, and non-advancing/empty-page guards
|
||||
|
||||
- [x] Update board/run SSE invalidation to emit semantic cache keys.
|
||||
- Existing invalidation behavior should remain the same.
|
||||
- Terminal run events should still refresh detail, files, artifacts, billing, stages, and graph keys.
|
||||
- Checkpoint/artifact-producing events should refresh the artifacts key when the current event taxonomy supports that distinction; otherwise terminal refresh coverage is acceptable for this migration.
|
||||
- Stage activity events should still refresh only the selected stage event key.
|
||||
|
||||
- [x] Update tests that assert URL cache keys to assert semantic cache keys or explicit SSE URLs.
|
||||
|
||||
## Task 6: Remove Dead Custom Transport And Verify
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/fabro-web/app/lib/api-client.ts`
|
||||
- Modify tests that mock `globalThis.fetch`
|
||||
- Test: `apps/fabro-web`
|
||||
|
||||
- [x] Remove or reduce custom `fetch` helpers that are no longer used:
|
||||
- `apiRequest`
|
||||
- `apiFetcher`
|
||||
- `apiNullableFetcher`
|
||||
- `apiTextFetcher`
|
||||
- `apiNullableTextFetcher`
|
||||
- `apiJsonMutation`
|
||||
- Keep only compatibility helpers still needed by the new generated adapter.
|
||||
|
||||
- [x] Update tests that mock `globalThis.fetch`.
|
||||
- Generated-client tests should mock the adapter or Axios instance, not browser fetch.
|
||||
- SSE tests can continue mocking `EventSourceLike`.
|
||||
|
||||
- [x] Run `cd apps/fabro-web && bun run typecheck`.
|
||||
- Expected: web app typechecks against generated API client method signatures.
|
||||
|
||||
- [x] Run `cd apps/fabro-web && bun test`.
|
||||
- Expected: all web tests pass after cache-key and transport test updates.
|
||||
|
||||
- [x] Run `cargo nextest run -p fabro-server`.
|
||||
- Expected: OpenAPI/router behavior remains valid for server routes.
|
||||
|
||||
- [x] Final inventory check:
|
||||
- `rg -n "\\bfetch\\(|/api/v1/|apiFetcher|apiJsonMutation|apiRequest" apps/fabro-web/app -g '*.ts' -g '*.tsx'`
|
||||
- Expected: remaining hits are tests, SSE URL builders, browser navigation/form routes, comments, or documented exceptions.
|
||||
|
||||
## Explicit Exceptions
|
||||
|
||||
- Keep `EventSource` for SSE:
|
||||
- `/api/v1/attach`
|
||||
- `/api/v1/runs/{id}/attach`
|
||||
- Keep browser navigation/form routes:
|
||||
- `/auth/login/github`
|
||||
- `/auth/logout`
|
||||
- Do not attempt to replace static asset or browser route links with generated API calls.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- All frontend API-style JSON/text requests that have generated operations use `@qltysh/fabro-api-client` API classes.
|
||||
- Run artifacts list/download and archived-run deletion use generated client operations or generated request builders.
|
||||
- Frontend-consumed API routes missing from the client are added to OpenAPI and generated.
|
||||
- No generated TypeScript files are hand-edited.
|
||||
- Existing UI behavior is preserved for auth, install, demo toggle, lifecycle actions, run detail views, workflows, insights, and settings.
|
||||
- SSE remains browser-native and continues to coordinate cross-tab subscriptions.
|
||||
- TypeScript, generated-client, and server tests listed above pass.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Expanding OpenAPI is desired for frontend API-style routes, including auth config/me, demo toggle, dev-token login, and workflow pages.
|
||||
- Workflow routes can be generated even though real mode currently returns `501`; generated success types should model the demo/expected response shape.
|
||||
- This work does not implement real workflow routes or insights routes; it only types and consumes their existing API contracts.
|
||||
- This work does not change public wire shapes except for documenting routes already served by the server and adding the missing graph `direction` query parameter to the spec.
|
||||
239
docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md
Normal file
239
docs/superpowers/plans/2026-05-08-run-owned-sandbox-lifecycle.md
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
# Run-Owned Sandbox Lifecycle Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Make Docker and Daytona sandboxes owned by a Fabro run: create/start for execution, stop on terminal by default, reactivate on access, and delete only when the run is deleted.
|
||||
|
||||
**Architecture:** Split sandbox lifecycle into explicit provider operations instead of using terminal cleanup as deletion. Persisted `SandboxRecord` remains the durable handle while the run exists for post-terminal access, resume, and deletion. `preserve = true` becomes an external handoff mode that skips provider deletion when the run is deleted and emits/returns the provider identifier before Fabro metadata is removed.
|
||||
|
||||
**Tech Stack:** Rust workspace, `fabro-sandbox`, `fabro-workflow`, `fabro-server`, `fabro-config`, OpenAPI-generated API types and TypeScript client.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Implement this default lifecycle:
|
||||
|
||||
```text
|
||||
run submitted
|
||||
no sandbox yet
|
||||
|
||||
run starts
|
||||
create sandbox
|
||||
start sandbox
|
||||
execute workflow
|
||||
|
||||
run resumes
|
||||
reconnect to persisted sandbox
|
||||
start sandbox
|
||||
continue from checkpoint
|
||||
|
||||
run terminal: succeeded / failed / cancelled
|
||||
stop sandbox by default
|
||||
keep sandbox record and provider resource
|
||||
|
||||
run accessed after terminal
|
||||
start or restore sandbox as needed
|
||||
|
||||
run deleted
|
||||
delete sandbox unless preserve = true
|
||||
delete run metadata, artifacts, and scratch state
|
||||
```
|
||||
|
||||
Missing sandboxes are errors. Fabro does not recreate a missing sandbox from a checkpoint, including on resume.
|
||||
|
||||
Once run deletion has begun, missing provider resources are treated as already deleted so deletion retries are idempotent.
|
||||
|
||||
## Public Configuration
|
||||
|
||||
Add `stop_on_terminal` beside the existing `preserve` setting:
|
||||
|
||||
```toml
|
||||
[run.sandbox]
|
||||
preserve = false
|
||||
stop_on_terminal = true
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
| `preserve` | `stop_on_terminal` | Terminal behavior | Run delete behavior |
|
||||
| --- | --- | --- | --- |
|
||||
| `false` | `true` | stop sandbox | delete sandbox |
|
||||
| `false` | `false` | leave sandbox running | delete sandbox |
|
||||
| `true` | `true` | stop sandbox | skip sandbox delete |
|
||||
| `true` | `false` | leave sandbox running | skip sandbox delete |
|
||||
|
||||
Avoid naming this `auto_stop` because Daytona already uses `auto_stop_interval` for provider-side inactivity behavior.
|
||||
|
||||
## Key Implementation Changes
|
||||
|
||||
- Add `run.sandbox.stop_on_terminal: bool`, default `true`, to config layers, resolved settings, serialized run settings, OpenAPI schema, and generated TypeScript client surfaces where run settings are exposed.
|
||||
- Extend the sandbox trait with explicit lifecycle methods:
|
||||
- `start()`
|
||||
- `stop()`
|
||||
- `delete()`
|
||||
- Keep init-failure cleanup as provider deletion so partially-created sandboxes do not leak before a durable run/sandbox record exists.
|
||||
- Audit every `sandbox.cleanup()` call and replace it with the correct lifecycle operation:
|
||||
- init failure before durable sandbox record persistence: `delete()`
|
||||
- terminal finalization and post-initialize early execution exits: `stop()` when `stop_on_terminal = true`
|
||||
- run deletion with `preserve = false`: `delete()`
|
||||
- Change workflow finalization from "cleanup/delete sandbox unless preserved" to:
|
||||
- emit terminal event
|
||||
- run the terminal lifecycle hook/notice path
|
||||
- if `stop_on_terminal = true`, call `sandbox.stop()`
|
||||
- never delete the sandbox from finalization
|
||||
- Change resume to:
|
||||
- load the persisted `SandboxRecord`
|
||||
- reconnect to the provider resource
|
||||
- call `sandbox.start()`
|
||||
- health-check the existing sandbox
|
||||
- rebuild runtime services around the attached sandbox
|
||||
- continue from checkpoint using the existing sandbox
|
||||
- do not call provider `initialize()`, create a new provider resource, clone the repo, or rerun provider workspace creation
|
||||
- return a conflict/precondition error if the provider resource is missing
|
||||
- Change run deletion to:
|
||||
- mark the run as `Removing` or otherwise persist a delete-started marker before provider deletion
|
||||
- load the persisted `SandboxRecord`
|
||||
- reconnect to the provider resource
|
||||
- call `sandbox.delete()` unless `preserve = true`
|
||||
- delete run metadata, artifacts, and scratch state
|
||||
- before delete has started, return `409 Conflict` on missing provider resource unless `force=true`
|
||||
- after delete has started, treat a missing provider resource as already deleted and continue removing Fabro metadata/artifacts/scratch
|
||||
- with `force=true`, surface/log the missing-sandbox condition, then remove Fabro store state, artifacts, and scratch idempotently
|
||||
- Change sandbox-backed access paths to activate as needed:
|
||||
- reconnect from `SandboxRecord`
|
||||
- call `start()` before sandbox file routes, run-files diff materialization, preview, SSH, and other sandbox operations
|
||||
- return an error if the provider resource is missing
|
||||
- Add explicit sandbox stop/delete lifecycle events, or rename the existing cleanup event vocabulary so logs and hooks distinguish stop from delete. Do not reuse "cleanup" wording for stop without documenting it as an umbrella compatibility term.
|
||||
- Define lifecycle trait behavior across all implementors:
|
||||
- `LocalSandbox.start/stop/delete` are no-ops
|
||||
- `WorktreeSandbox` and other wrappers/decorators forward lifecycle calls to their inner sandbox
|
||||
- delegation macros forward `start`, `stop`, and `delete`
|
||||
- test doubles record lifecycle calls so tests can assert stop-vs-delete behavior
|
||||
|
||||
## Resume Attach Mode
|
||||
|
||||
Resume must use an explicit attach-existing path. This path reuses the persisted sandbox and skips all provider creation behavior.
|
||||
|
||||
Attach-existing should:
|
||||
|
||||
- reconnect from the existing `SandboxRecord`
|
||||
- call `start()`
|
||||
- run provider health checks required for exec/file operations
|
||||
- rebuild the in-memory workflow runtime services, event sink, artifact sink, LLM client source, handlers, hooks, and run control plumbing
|
||||
- reuse existing Git/run branch state from the checkpoint projection
|
||||
|
||||
Attach-existing must not:
|
||||
|
||||
- call provider `initialize()`
|
||||
- create a new Docker container or Daytona sandbox
|
||||
- clone the repository
|
||||
- create an empty provider workspace
|
||||
- overwrite persisted sandbox identity
|
||||
|
||||
Setup-command/devcontainer behavior on resume should remain checkpoint-aware: do not rerun provider creation, clone, or devcontainer snapshot creation. Only rerun explicit resume setup commands already defined by the sandbox/provider when needed to reattach to the existing run branch.
|
||||
|
||||
## Delete And Preserve API Semantics
|
||||
|
||||
Normal delete keeps the current `204 No Content` response when no handoff information is needed.
|
||||
|
||||
For `preserve = true`, run deletion is an operator handoff. Add a response body and OpenAPI shape for this case:
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted": true,
|
||||
"sandbox_preserved": true,
|
||||
"sandbox": {
|
||||
"provider": "docker",
|
||||
"identifier": "container-id-or-daytona-name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clients that only care about success can continue treating any 2xx as success. The handler may return `200 OK` for preserve handoff responses and `204 No Content` for ordinary deletes.
|
||||
|
||||
Delete retries:
|
||||
|
||||
- before delete starts, missing sandbox is a conflict unless `force=true`
|
||||
- after delete starts, missing sandbox is treated as already deleted
|
||||
- metadata/artifact/scratch deletion must be idempotent so a retry after partial deletion can finish cleanly
|
||||
|
||||
## Provider Behavior
|
||||
|
||||
Docker:
|
||||
|
||||
- `initialize`: create and start the container as today.
|
||||
- `stop`: run `docker stop`; tolerate already-stopped containers.
|
||||
- `start`: run `docker start`; tolerate already-running containers; perform a lightweight health check before exec/file operations.
|
||||
- `delete`: verify managed labels and the run ownership label before removing anything, stop if needed, then remove the container.
|
||||
- Reconnect/delete/start/stop flows must receive or recover the Fabro `RunId` so `sh.fabro.run_id` can be checked against the run being operated on.
|
||||
|
||||
Daytona:
|
||||
|
||||
- `initialize`: create a non-ephemeral sandbox, apply Fabro/run labels, and explicitly disable provider auto-delete.
|
||||
- `stop`: call Daytona stop.
|
||||
- `start`: call Daytona start; if the sandbox is archived, rely on Daytona start/restore semantics.
|
||||
- `delete`: call Daytona delete.
|
||||
- Validate Daytona ownership using persisted sandbox metadata and Fabro/run labels before destructive operations when the provider exposes labels through the SDK/API.
|
||||
- Keep the existing CLI-agent behavior that disables Daytona auto-stop during active long-running CLI work, but restore the configured Daytona auto-stop interval after CLI work finishes. If restoration fails, emit a warning notice.
|
||||
- Defer Daytona auto-archive support to a separate change.
|
||||
|
||||
## Important Edge Cases
|
||||
|
||||
- `preserve = true` skips sandbox deletion on run delete and treats the provider resource as externally handed off. Before deleting Fabro metadata, return the provider identifier in the delete response so the operator is not left without a handle.
|
||||
- Missing provider resources are errors for access, resume, and normal delete before delete starts. After delete starts, missing provider resources are treated as already deleted so retrying a partially-failed delete does not get stuck.
|
||||
- Terminal failure, cancellation, and success all use the same stop-on-terminal policy.
|
||||
- Post-initialize execution errors and scopeguard exits stop the sandbox when `stop_on_terminal = true`; they do not delete it.
|
||||
- A sandbox initialization failure before durable sandbox record persistence must still delete any partially-created provider resource.
|
||||
- Existing `SandboxRecord` shape should remain unchanged unless implementation needs additional ownership metadata to validate provider resources safely.
|
||||
|
||||
## Test Plan
|
||||
|
||||
Config and schema tests:
|
||||
|
||||
- Default config resolves `stop_on_terminal = true`.
|
||||
- Explicit `stop_on_terminal = false` resolves and serializes.
|
||||
- Existing `preserve` behavior remains compatible except deletion now happens on run delete rather than terminal finalization.
|
||||
|
||||
Workflow/finalize tests:
|
||||
|
||||
- Terminal success with default settings calls `stop`, not `delete`.
|
||||
- Terminal failure calls `stop`, not `delete`.
|
||||
- Cancellation calls `stop`, not `delete`.
|
||||
- `stop_on_terminal = false` calls neither `stop` nor `delete` at terminal.
|
||||
- Post-initialize execution error stops but does not delete.
|
||||
- Sandbox initialization failure still deletes a partially-created sandbox.
|
||||
- Resume starts an existing stopped sandbox and does not create a new one.
|
||||
- Resume with a missing provider resource fails without recreation.
|
||||
|
||||
Server/delete tests:
|
||||
|
||||
- Deleting a run deletes the sandbox when `preserve = false`.
|
||||
- Deleting a run skips sandbox deletion when `preserve = true`.
|
||||
- Normal delete returns `409 Conflict` when the sandbox is missing.
|
||||
- Delete retry after successful provider deletion but failed metadata/artifact/scratch deletion treats the missing sandbox as already deleted and completes metadata cleanup.
|
||||
- Forced delete with a missing sandbox removes Fabro metadata only after explicit missing-sandbox handling.
|
||||
- Run delete validates ownership before deleting provider resources.
|
||||
- `preserve = true` delete returns a handoff response with provider and identifier before removing Fabro metadata.
|
||||
|
||||
Access tests:
|
||||
|
||||
- Sandbox file routes, run-files diff materialization, preview, and SSH routes reconnect and start a stopped sandbox before use.
|
||||
- Missing sandbox on access returns a conflict/error and does not recreate.
|
||||
|
||||
Provider tests:
|
||||
|
||||
- Docker stop/start/delete tolerate already-stopped or already-running states where appropriate and still verify managed labels before destructive operations.
|
||||
- Daytona lifecycle methods call the correct SDK operations and create non-ephemeral, non-auto-delete sandboxes with Fabro/run ownership labels.
|
||||
- Daytona CLI-agent auto-stop disablement restores the configured interval after CLI work.
|
||||
- Local lifecycle methods are no-ops.
|
||||
- Worktree/decorator lifecycle methods forward to the inner sandbox.
|
||||
- Test doubles record `start`, `stop`, and `delete` calls.
|
||||
- Live Daytona coverage stays ignored or e2e-gated.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- "Active on access" means Fabro starts/restores the sandbox for all sandbox-backed access paths, even if a provider could read files while stopped.
|
||||
- "Missing sandbox = error" means no recreate-from-checkpoint behavior in this feature.
|
||||
- Daytona auto-delete is disabled for run-owned sandboxes because deletion belongs to Fabro run deletion.
|
||||
- Daytona auto-archive is intentionally deferred.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Fixed Project Workflow Directory Plan
|
||||
|
||||
**Summary**
|
||||
Remove the `[project].directory` feature entirely so project workflows are always discovered, created, listed, and resolved from `<repo_root>/.fabro/workflows/*`. Because this is greenfield, configs that still contain `[project] directory = ...` should fail schema validation instead of being ignored or migrated. User-level workflows under `~/.fabro/workflows` remain unchanged as an additional fallback/list section.
|
||||
|
||||
**Key Changes**
|
||||
- Remove `directory` from the project config schema and resolved settings type:
|
||||
- Delete `ProjectLayer::directory` and `ProjectNamespace::directory`.
|
||||
- Remove `[project] directory = "."` from built-in defaults.
|
||||
- Update project resolver/tests so `[project]` only carries `name`, `description`, and `metadata`.
|
||||
- Make project Fabro root fixed:
|
||||
- Change `resolve_fabro_root` to validate `.fabro/project.toml`, then return `config_path.parent()` directly.
|
||||
- Update callers to handle `Result<PathBuf>` and use `<discovered .fabro>/workflows`.
|
||||
- Delete now-unused path normalization code for joining `project.directory`.
|
||||
- Update public API shape:
|
||||
- Remove `directory` from `ProjectNamespace` in `docs/public/api-reference/fabro-api.yaml`.
|
||||
- Update `workflow_settings_round_trip` expectations.
|
||||
- Regenerate `lib/packages/fabro-api-client` so TypeScript `ProjectNamespace` no longer has `directory`.
|
||||
- Update docs and hints:
|
||||
- Remove references to `project.directory` and the old `fabro.root -> project.directory` rename hint.
|
||||
- Add/keep explicit docs that project workflows live at `<repo_root>/.fabro/workflows/<name>/`.
|
||||
|
||||
**Test Plan**
|
||||
- Config tests:
|
||||
- Empty settings still resolve project metadata defaults.
|
||||
- `[project] directory = "..."` is rejected as an unknown field.
|
||||
- `resolve_fabro_root` always returns the `.fabro` directory containing `project.toml`.
|
||||
- CLI integration tests:
|
||||
- Replace custom-root workflow create/list tests with fixed-root assertions.
|
||||
- Confirm `fabro workflow create <name>` writes `.fabro/workflows/<name>/workflow.{fabro,toml}`.
|
||||
- Confirm named workflow resolution reads `.fabro/workflows/<name>/workflow.toml`.
|
||||
- API/client checks:
|
||||
- `cargo build -p fabro-api`
|
||||
- `cd lib/packages/fabro-api-client && bun run generate`
|
||||
- `cargo nextest run -p fabro-config -p fabro-api -p fabro-cli`
|
||||
- `cd apps/fabro-web && bun run typecheck`
|
||||
|
||||
**Assumptions**
|
||||
- No backwards compatibility or migration period: old configs with `[project].directory` should fail.
|
||||
- This change only fixes the project workflow directory. User workflows remain available unless a separate decision removes them later.
|
||||
- GitHub retrieval itself is out of scope here; this refactor makes the repo path deterministic for that future work.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Inline Markdown Run Titles
|
||||
|
||||
## Summary
|
||||
|
||||
Render run titles with a small allowlist of inline Markdown decoration in the web UI: backticks become `<code>`, `**bold**` becomes `<strong>`, and `_italic_` / `*italic*` becomes `<em>`. Do not render Markdown block structures, raw HTML, images, or clickable links in titles. Keep the API/server data model unchanged.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Add `apps/fabro-web/app/components/inline-markdown.tsx` exporting `InlineMarkdown({ content, className })`.
|
||||
- Use `marked`'s `Lexer.lexInline(...)` to tokenize inline Markdown, then map tokens to React elements manually. Do not use `dangerouslySetInnerHTML`.
|
||||
- Supported rendered elements:
|
||||
- `codespan` -> `<code>` with compact existing-theme styling.
|
||||
- `strong` -> `<strong>`.
|
||||
- `em` -> `<em>`.
|
||||
- plain/escaped text -> React text.
|
||||
- Unsupported inline syntax renders as safe text:
|
||||
- links render their label only, no `<a>`.
|
||||
- images render alt text only, no `<img>`.
|
||||
- raw HTML displays literally as text.
|
||||
- hard breaks/newlines render as normal title whitespace.
|
||||
- Replace visible run-title render sites with `InlineMarkdown`:
|
||||
- run detail breadcrumb, heading, and delete confirmation in `apps/fabro-web/app/routes/run-detail.tsx`.
|
||||
- run list row title in `apps/fabro-web/app/routes/runs.tsx`.
|
||||
- workflow run list title in `apps/fabro-web/app/routes/workflow-runs.tsx`.
|
||||
- Leave non-visual/plain-text title uses unchanged:
|
||||
- document metadata title.
|
||||
- filtering/search.
|
||||
- API/client/server fields.
|
||||
|
||||
## Tests
|
||||
|
||||
- Add `apps/fabro-web/app/components/inline-markdown.test.tsx`.
|
||||
- Cover these cases:
|
||||
- ``Move from `[server.integrations.github]` to `[run.integrations.github]` `` renders two `<code>` elements without visible backticks.
|
||||
- `**bold**` and `_italic_` render `<strong>` and `<em>`.
|
||||
- `# heading`, lists, and blockquotes remain text and do not create block nodes.
|
||||
- `[label](javascript:alert(1))`, ``, and raw HTML do not create `a`, `img`, or HTML nodes.
|
||||
- Add a small `run-detail.test.ts` assertion that a Markdown-formatted run title produces inline `<code>` in the visible detail heading.
|
||||
- Verify with:
|
||||
- `cd apps/fabro-web && bun test`
|
||||
- `cd apps/fabro-web && bun run typecheck`
|
||||
|
||||
## Assumptions
|
||||
|
||||
- This is frontend-only; no OpenAPI, Rust server, or stored title migration is needed.
|
||||
- Links should not be clickable inside run titles because titles already appear inside links and navigation surfaces.
|
||||
- Only `code`, `strong`, and `em` are intentionally styled for v1; other Markdown stays textual.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Explicit Event-Sourced Run Titles
|
||||
|
||||
## Summary
|
||||
|
||||
Add an explicit run `title` that is always a non-blank string in API responses. On creation, clients may provide `title`; otherwise the server infers it from the run goal exactly once for new runs. Later title changes are persisted through a new event, not by mutating stored summaries or recomputing from the goal.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Add optional `title` to `RunManifest`; validate provided titles by trimming, rejecting blank/whitespace-only values, and rejecting values over 100 chars.
|
||||
- Add shared title helpers:
|
||||
- explicit title normalization: trim, require non-blank, max 100, reject invalid input.
|
||||
- inferred title: current first-line goal cleanup, truncate to 100, fallback to `"Untitled run"` if inference is blank.
|
||||
- Store resolved title on new `run.created` events. Keep legacy event replay compatible by inferring title from goal only when replaying old `run.created` events that lack a title.
|
||||
- Add `run.title.updated` with `{ "title": "..." }`; update `RunProjection.title`, and make `RunSummary.title` read from projection title instead of deriving from `goal`.
|
||||
- Add `PATCH /api/v1/runs/{id}` with body `{ "title": "..." }`; return updated `RunSummary`.
|
||||
- Allow title PATCH for all run states, including archived runs, as a metadata-only exception to archived read-only behavior.
|
||||
- If PATCH normalizes to the existing title, return the current summary without appending a no-op event.
|
||||
- No web edit UI in this slice. Do update frontend/SSE invalidation so `run.title.updated` refreshes run detail and board data when another client changes a title.
|
||||
|
||||
## Public Interfaces
|
||||
|
||||
- OpenAPI:
|
||||
- `RunManifest.title?: string | null`
|
||||
- new `UpdateRunRequest` with required `title: string`
|
||||
- `PATCH /api/v1/runs/{id}` returns `RunSummary`
|
||||
- document `RunSummary.title` and `RunListItem.title` as non-blank strings
|
||||
- Generated clients:
|
||||
- rebuild Rust API types and TypeScript API client after OpenAPI changes.
|
||||
- CLI:
|
||||
- no new `fabro run --title` flag in this plan; CLI-created runs continue relying on server-side inference unless a caller builds a manifest with `title`.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit tests for title normalization: trims, rejects blank, rejects over 100, preserves valid title.
|
||||
- Unit tests for inference: strips markdown heading and `Plan:`, truncates to 100, returns `"Untitled run"` for blank/empty goals.
|
||||
- Store/projection tests:
|
||||
- new `run.created` with title populates projection and summary title.
|
||||
- old `run.created` without title still replays and yields inferred/fallback title.
|
||||
- `run.title.updated` changes projection and summary.
|
||||
- Server API tests:
|
||||
- create run with explicit title returns that title.
|
||||
- create run without title returns inferred title.
|
||||
- blank/whitespace create title returns 400.
|
||||
- PATCH updates active, terminal, and archived runs.
|
||||
- PATCH rejects blank and over-100 titles with 400.
|
||||
- same-title PATCH is idempotent and does not append an update event.
|
||||
- Frontend tests:
|
||||
- board and run-detail invalidation include `run.title.updated`.
|
||||
- existing rendering continues to display server `title`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Maximum explicit title length is 100 characters.
|
||||
- Explicit titles are rejected when invalid; only inferred titles are truncated.
|
||||
- `"Untitled run"` is the canonical server fallback when inference cannot produce a non-blank title.
|
||||
- Existing legacy runs may still have their title inferred during replay because old event streams did not record a resolved title.
|
||||
|
|
@ -235,6 +235,7 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool {
|
|||
executor: Arc::new(move |args, ctx| {
|
||||
Box::pin(async move {
|
||||
let command = required_str(&args, "command")?;
|
||||
let command = format!("exec 2>&1\n{command}");
|
||||
let timeout_ms = args
|
||||
.get("timeout_ms")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
|
|
@ -249,7 +250,7 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool {
|
|||
let result = ctx
|
||||
.env
|
||||
.exec_command(
|
||||
command,
|
||||
&command,
|
||||
timeout_ms,
|
||||
None,
|
||||
tool_env.as_ref(),
|
||||
|
|
@ -266,12 +267,11 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool {
|
|||
}
|
||||
let _ = write!(
|
||||
output,
|
||||
"Exit code: {}\nstdout:\n{}\nstderr:\n{}",
|
||||
"Exit code: {}\noutput:\n{}",
|
||||
result
|
||||
.exit_code
|
||||
.map_or_else(|| "none".to_string(), |code| code.to_string()),
|
||||
result.stdout,
|
||||
result.stderr
|
||||
result.stdout
|
||||
);
|
||||
Ok(output)
|
||||
})
|
||||
|
|
@ -916,8 +916,8 @@ mod tests {
|
|||
let tool = make_shell_tool();
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
exec_result: ExecResult {
|
||||
stdout: String::new(),
|
||||
stderr: "error".into(),
|
||||
stdout: "error".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: Some(1),
|
||||
termination: CommandTermination::Exited,
|
||||
duration_ms: 10,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,23 @@ fn patch_codegen_request_body_media_types(value: &mut serde_json::Value) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Progenitor currently panics when an operation advertises a typed success
|
||||
/// response alongside a no-content success response. Keep the source spec
|
||||
/// accurate for docs and other generators, but preserve the existing Rust
|
||||
/// client shape for `DELETE /runs/{id}`: success with no body.
|
||||
fn patch_codegen_delete_run_responses(value: &mut serde_json::Value) {
|
||||
let Some(responses) = value
|
||||
.get_mut("paths")
|
||||
.and_then(|paths| paths.get_mut("/api/v1/runs/{id}"))
|
||||
.and_then(|path| path.get_mut("delete"))
|
||||
.and_then(|operation| operation.get_mut("responses"))
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
responses.remove("200");
|
||||
}
|
||||
|
||||
fn spec_path_from_manifest_dir(manifest_dir: &Path) -> PathBuf {
|
||||
manifest_dir
|
||||
.ancestors()
|
||||
|
|
@ -161,6 +178,7 @@ fn main() {
|
|||
spec_value["openapi"] = serde_json::Value::String("3.0.3".to_string());
|
||||
patch_nullable(&mut spec_value);
|
||||
patch_codegen_request_body_media_types(&mut spec_value);
|
||||
patch_codegen_delete_run_responses(&mut spec_value);
|
||||
|
||||
let spec: openapiv3::OpenAPI =
|
||||
serde_json::from_value(spec_value).expect("failed to deserialize OpenAPI spec");
|
||||
|
|
@ -179,6 +197,7 @@ fn main() {
|
|||
&[],
|
||||
),
|
||||
("RunSummary", "fabro_types::RunSummary", &[]),
|
||||
("DiffSummary", "fabro_types::DiffSummary", &[]),
|
||||
(
|
||||
"RepositoryReference",
|
||||
"fabro_types::RepositoryReference",
|
||||
|
|
@ -335,12 +354,8 @@ fn main() {
|
|||
("QuestionType", "fabro_types::QuestionType", &[]),
|
||||
("StageCompletion", "fabro_types::StageCompletion", &[]),
|
||||
("StageOutcome", "fabro_types::StageOutcome", &[]),
|
||||
("StageHandler", "fabro_types::StageHandler", &[]),
|
||||
("StageState", "fabro_types::StageState", &[]),
|
||||
(
|
||||
"CommandOutputStream",
|
||||
"fabro_types::CommandOutputStream",
|
||||
&[],
|
||||
),
|
||||
("CommandTermination", "fabro_types::CommandTermination", &[]),
|
||||
("StageProjection", "fabro_types::StageProjection", &[]),
|
||||
("SecretMetadata", "fabro_types::SecretMetadata", &[]),
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ pub mod types {
|
|||
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
|
||||
};
|
||||
pub use fabro_types::{
|
||||
AuthMethod, BilledTokenCounts, CommandOutputStream, CommandTermination, DiffStats,
|
||||
DirtyStatus, EventEnvelope, GitContext, IdpIdentity, InterviewOption,
|
||||
InterviewQuestionRecord, PendingInterviewRecord, PreRunPushOutcome, Principal,
|
||||
QuestionType, RepositoryReference, RunClientProvenance, RunEvent, RunProjection,
|
||||
RunProvenance, RunServerProvenance, RunSummary, SecretMetadata, SecretType, ServerSettings,
|
||||
StageCompletion, StageOutcome, StageProjection, StageState, SystemActorKind, UserPrincipal,
|
||||
AuthMethod, BilledTokenCounts, CommandTermination, DiffStats, DiffSummary, DirtyStatus,
|
||||
EventEnvelope, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord,
|
||||
PendingInterviewRecord, PreRunPushOutcome, Principal, QuestionType, RepositoryReference,
|
||||
RunClientProvenance, RunEvent, RunProjection, RunProvenance, RunServerProvenance,
|
||||
RunSummary, SecretMetadata, SecretType, ServerSettings, StageCompletion, StageHandler,
|
||||
StageOutcome, StageProjection, StageState, SystemActorKind, UserPrincipal,
|
||||
WorkflowSettings,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::CommandOutputStream as ApiCommandOutputStream;
|
||||
use fabro_types::CommandOutputStream;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn command_output_stream_reuses_canonical_type() {
|
||||
assert_same_type::<ApiCommandOutputStream, CommandOutputStream>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_stream_serializes_as_stream_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(CommandOutputStream::Stdout).unwrap(),
|
||||
json!("stdout")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(CommandOutputStream::Stderr).unwrap(),
|
||||
json!("stderr")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_stream_deserializes_representative_values() {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ApiCommandOutputStream>(json!("stdout")).unwrap(),
|
||||
CommandOutputStream::Stdout
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ApiCommandOutputStream>(json!("stderr")).unwrap(),
|
||||
CommandOutputStream::Stderr
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
37
lib/crates/fabro-api/tests/diff_summary_round_trip.rs
Normal file
37
lib/crates/fabro-api/tests/diff_summary_round_trip.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::DiffSummary as ApiDiffSummary;
|
||||
use fabro_types::DiffSummary;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn diff_summary_reuses_canonical_type() {
|
||||
assert_same_type::<ApiDiffSummary, DiffSummary>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_summary_serializes_with_required_integer_fields() {
|
||||
let summary = DiffSummary {
|
||||
files_changed: 3,
|
||||
additions: 12,
|
||||
deletions: 4,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(summary).unwrap(),
|
||||
json!({
|
||||
"files_changed": 3,
|
||||
"additions": 12,
|
||||
"deletions": 4,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -78,6 +78,26 @@ fn run_event_round_trips_run_steer() {
|
|||
assert_run_event_round_trip(value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_event_round_trips_agent_interrupt_injected() {
|
||||
let value = json!({
|
||||
"id": "evt_interrupt_injected",
|
||||
"ts": "2026-04-29T12:00:00Z",
|
||||
"run_id": fixtures::RUN_1,
|
||||
"event": "agent.interrupt.injected",
|
||||
"node_id": "code",
|
||||
"node_label": "code",
|
||||
"stage_id": "code@2",
|
||||
"session_id": "ses_1",
|
||||
"actor": { "kind": "system", "system_kind": "engine" },
|
||||
"properties": {
|
||||
"visit": 2
|
||||
}
|
||||
});
|
||||
|
||||
assert_run_event_round_trip(value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_event_round_trips_stage_started() {
|
||||
let value = json!({
|
||||
|
|
|
|||
|
|
@ -65,8 +65,7 @@ fn run_projection_round_trips_populated_projection() {
|
|||
"script_invocation": null,
|
||||
"script_timing": null,
|
||||
"parallel_results": null,
|
||||
"stdout": "done",
|
||||
"stderr": null
|
||||
"output": "done"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use fabro_api::types::{
|
|||
RepositoryReference as ApiRepositoryReference, RunSummary as ApiRunSummary,
|
||||
};
|
||||
use fabro_types::status::{RunStatus, SuccessReason, TerminalStatus};
|
||||
use fabro_types::{RepositoryReference, RunId, RunSummary};
|
||||
use fabro_types::{DiffSummary, PullRequestRecord, RepositoryReference, RunId, RunSummary};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
|
|
@ -41,6 +41,20 @@ fn run_summary_json_matches_openapi_shape() {
|
|||
Some(42_000),
|
||||
Some(123),
|
||||
Some(superseded_by),
|
||||
Some(DiffSummary {
|
||||
files_changed: 3,
|
||||
additions: 12,
|
||||
deletions: 4,
|
||||
}),
|
||||
Some(PullRequestRecord {
|
||||
html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
|
||||
number: 123,
|
||||
owner: "fabro-sh".to_string(),
|
||||
repo: "fabro".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
head_branch: "fabro/run/demo".to_string(),
|
||||
title: "Add run PR chip".to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -74,7 +88,21 @@ fn run_summary_json_matches_openapi_shape() {
|
|||
"duration_ms": 42000,
|
||||
"elapsed_secs": 42.0,
|
||||
"total_usd_micros": 123,
|
||||
"superseded_by": superseded_by.to_string()
|
||||
"superseded_by": superseded_by.to_string(),
|
||||
"diff_summary": {
|
||||
"files_changed": 3,
|
||||
"additions": 12,
|
||||
"deletions": 4
|
||||
},
|
||||
"pull_request": {
|
||||
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
|
||||
"number": 123,
|
||||
"owner": "fabro-sh",
|
||||
"repo": "fabro",
|
||||
"base_branch": "main",
|
||||
"head_branch": "fabro/run/demo",
|
||||
"title": "Add run PR chip"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -117,6 +145,8 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
|
|||
assert_eq!(summary.elapsed_secs, None);
|
||||
assert_eq!(summary.total_usd_micros, None);
|
||||
assert_eq!(summary.superseded_by, None);
|
||||
assert_eq!(summary.diff_summary, None);
|
||||
assert_eq!(summary.pull_request, None);
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
|
|
|
|||
45
lib/crates/fabro-api/tests/stage_handler_round_trip.rs
Normal file
45
lib/crates/fabro-api/tests/stage_handler_round_trip.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::StageHandler as ApiStageHandler;
|
||||
use fabro_types::StageHandler;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn stage_handler_reuses_canonical_type() {
|
||||
assert_same_type::<ApiStageHandler, StageHandler>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_handler_serializes_openapi_wire_values() {
|
||||
let cases = [
|
||||
(StageHandler::Start, "start"),
|
||||
(StageHandler::Exit, "exit"),
|
||||
(StageHandler::Agent, "agent"),
|
||||
(StageHandler::Prompt, "prompt"),
|
||||
(StageHandler::Command, "command"),
|
||||
(StageHandler::Human, "human"),
|
||||
(StageHandler::Conditional, "conditional"),
|
||||
(StageHandler::Parallel, "parallel"),
|
||||
(StageHandler::ParallelFanIn, "parallel.fan_in"),
|
||||
(StageHandler::StackManagerLoop, "stack.manager_loop"),
|
||||
(StageHandler::Wait, "wait"),
|
||||
];
|
||||
|
||||
for (handler, wire) in cases {
|
||||
assert_eq!(serde_json::to_value(handler).unwrap(), json!(wire));
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ApiStageHandler>(json!(wire)).unwrap(),
|
||||
handler
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -26,8 +26,7 @@ fn stage_projection_round_trips_representative_json() {
|
|||
"script_invocation": { "command": "cargo test" },
|
||||
"script_timing": { "duration_ms": 42 },
|
||||
"parallel_results": [{ "branch": 0, "status": "succeeded" }],
|
||||
"stdout": "ok",
|
||||
"stderr": "",
|
||||
"output": "ok",
|
||||
"termination": "exited",
|
||||
"started_at": "2026-04-29T12:34:00Z",
|
||||
"duration_ms": 56000,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
use fabro_api::types::SubmitAnswerRequest;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn typed_answer_variants_round_trip_through_json() {
|
||||
let cases = [
|
||||
json!({ "kind": "yes" }),
|
||||
json!({ "kind": "no" }),
|
||||
json!({ "kind": "selected", "option_key": "approve" }),
|
||||
json!({ "kind": "multi_selected", "option_keys": ["approve", "notify"] }),
|
||||
json!({ "kind": "text", "text": "Looks good to me." }),
|
||||
];
|
||||
|
||||
for payload in cases {
|
||||
let request: SubmitAnswerRequest = serde_json::from_value(payload.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), payload);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_answer_fields_are_not_part_of_the_wire_contract() {
|
||||
let result = serde_json::from_value::<SubmitAnswerRequest>(json!({ "value": "yes" }));
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
|
@ -95,6 +95,7 @@ object_store.workspace = true
|
|||
bytes.workspace = true
|
||||
tokio-util.workspace = true
|
||||
libc = "0.2"
|
||||
nix = { version = "0.30", features = ["fs"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = { version = "0.9", optional = true }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
)]
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
#[cfg(unix)]
|
||||
use std::os::fd::AsFd;
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
#[cfg(test)]
|
||||
|
|
@ -17,17 +19,17 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::Result;
|
||||
use fabro_api::types;
|
||||
use fabro_interview::{AnswerValue, ConsoleInterviewer, Question};
|
||||
use fabro_interview::{Answer, AnswerValue, Question};
|
||||
use fabro_store::EventEnvelope;
|
||||
use fabro_types::settings::run::ApprovalMode;
|
||||
use fabro_types::{EventBody, InterviewOption, RunId};
|
||||
use fabro_types::{EventBody, InterviewOption, QuestionType, RunId};
|
||||
use fabro_util::json::normalize_json_value;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::outcome::StageOutcome;
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
use tokio::signal::ctrl_c;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::{Duration as TokioDuration, sleep};
|
||||
|
||||
use super::run_progress;
|
||||
use crate::server_client;
|
||||
|
|
@ -36,6 +38,101 @@ const INTERVIEW_UNANSWERED_MESSAGE: &str =
|
|||
"Interview ended without an answer. The run is still waiting for input; reattach to answer it.";
|
||||
const JSON_INTERVIEW_MESSAGE: &str = "This run is waiting for human input, but --json is non-interactive. Reattach without --json to answer it.";
|
||||
const ATTACH_PREMATURE_EOF_MESSAGE: &str = "Attach stream ended before terminal run event.";
|
||||
const PROMPT_READ_POLL_INTERVAL: TokioDuration = TokioDuration::from_millis(50);
|
||||
|
||||
enum PromptRead {
|
||||
Line(String),
|
||||
Eof,
|
||||
Error,
|
||||
}
|
||||
|
||||
enum ParsedPromptAnswer {
|
||||
Answer(Answer),
|
||||
Invalid(String),
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
use nix::errno::Errno;
|
||||
#[cfg(unix)]
|
||||
use nix::fcntl::{FcntlArg, OFlag, fcntl};
|
||||
#[cfg(unix)]
|
||||
use nix::unistd;
|
||||
#[cfg(unix)]
|
||||
enum LineRead {
|
||||
Pending,
|
||||
Complete(String),
|
||||
Eof,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct NonblockingStdin {
|
||||
stdin: std::io::Stdin,
|
||||
original_flags: OFlag,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl NonblockingStdin {
|
||||
fn new() -> Option<Self> {
|
||||
let stdin = std::io::stdin();
|
||||
let original_flags =
|
||||
OFlag::from_bits_truncate(fcntl(stdin.as_fd(), FcntlArg::F_GETFL).ok()?);
|
||||
fcntl(
|
||||
stdin.as_fd(),
|
||||
FcntlArg::F_SETFL(original_flags | OFlag::O_NONBLOCK),
|
||||
)
|
||||
.ok()?;
|
||||
Some(Self {
|
||||
stdin,
|
||||
original_flags,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_line(&self, buffer: &mut Vec<u8>) -> LineRead {
|
||||
let mut chunk = [0_u8; 256];
|
||||
loop {
|
||||
if let Some(newline) = buffer.iter().position(|byte| *byte == b'\n') {
|
||||
let line = buffer.drain(..=newline).collect::<Vec<_>>();
|
||||
return LineRead::Complete(
|
||||
String::from_utf8_lossy(&line)
|
||||
.trim_end_matches(['\r', '\n'])
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
match unistd::read(self.stdin.as_fd(), &mut chunk) {
|
||||
Ok(0) => {
|
||||
return if buffer.is_empty() {
|
||||
LineRead::Eof
|
||||
} else {
|
||||
let line = std::mem::take(buffer);
|
||||
LineRead::Complete(String::from_utf8_lossy(&line).to_string())
|
||||
};
|
||||
}
|
||||
Ok(read) => {
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if let Some(newline) = buffer.iter().position(|byte| *byte == b'\n') {
|
||||
let line = buffer.drain(..=newline).collect::<Vec<_>>();
|
||||
return LineRead::Complete(
|
||||
String::from_utf8_lossy(&line)
|
||||
.trim_end_matches(['\r', '\n'])
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(Errno::EAGAIN) => return LineRead::Pending,
|
||||
Err(_) => return LineRead::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for NonblockingStdin {
|
||||
fn drop(&mut self) {
|
||||
let _ = fcntl(self.stdin.as_fd(), FcntlArg::F_SETFL(self.original_flags));
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach to a running (or finished) workflow run, rendering progress live.
|
||||
///
|
||||
|
|
@ -106,7 +203,7 @@ pub(crate) async fn attach_run_with_client(
|
|||
}
|
||||
|
||||
let stream = client.attach_run_events(run_id, Some(next_seq)).await?;
|
||||
attach_live_run_with_client(
|
||||
Box::pin(attach_live_run_with_client(
|
||||
client,
|
||||
run_id,
|
||||
replay_events,
|
||||
|
|
@ -119,7 +216,7 @@ pub(crate) async fn attach_run_with_client(
|
|||
json_output,
|
||||
},
|
||||
printer,
|
||||
)
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
@ -168,15 +265,17 @@ async fn attach_live_run_with_client(
|
|||
emit_progress_line(&mut progress_ui, &line, opts.json_output)?;
|
||||
}
|
||||
|
||||
if let Some(exit_code) = handle_pending_server_interview(
|
||||
if let Some(exit_code) = Box::pin(handle_pending_server_interview(
|
||||
client,
|
||||
run_id,
|
||||
&mut stream,
|
||||
opts.auto_approve,
|
||||
&mut progress_ui,
|
||||
styles,
|
||||
opts.json_output,
|
||||
opts.kill_on_detach,
|
||||
printer,
|
||||
)
|
||||
))
|
||||
.await?
|
||||
{
|
||||
return Ok(exit_code);
|
||||
|
|
@ -206,15 +305,17 @@ async fn attach_live_run_with_client(
|
|||
}
|
||||
|
||||
if event_starts_interview(&event) {
|
||||
if let Some(exit_code) = handle_pending_server_interview(
|
||||
if let Some(exit_code) = Box::pin(handle_pending_server_interview(
|
||||
client,
|
||||
run_id,
|
||||
&mut stream,
|
||||
opts.auto_approve,
|
||||
&mut progress_ui,
|
||||
styles,
|
||||
opts.json_output,
|
||||
opts.kill_on_detach,
|
||||
printer,
|
||||
)
|
||||
))
|
||||
.await?
|
||||
{
|
||||
return Ok(exit_code);
|
||||
|
|
@ -226,10 +327,12 @@ async fn attach_live_run_with_client(
|
|||
async fn handle_pending_server_interview(
|
||||
client: &server_client::Client,
|
||||
run_id: &RunId,
|
||||
stream: &mut server_client::RunEventStream,
|
||||
auto_approve: bool,
|
||||
progress_ui: &mut run_progress::ProgressUI,
|
||||
styles: &'static Styles,
|
||||
json_output: bool,
|
||||
kill_on_detach: bool,
|
||||
printer: Printer,
|
||||
) -> Result<Option<ExitCode>> {
|
||||
let Some(question) = client.list_run_questions(run_id).await?.into_iter().next() else {
|
||||
|
|
@ -245,10 +348,42 @@ async fn handle_pending_server_interview(
|
|||
}
|
||||
|
||||
hide_progress(progress_ui, json_output);
|
||||
let interviewer = ConsoleInterviewer::new(styles, fabro_types::Principal::Anonymous);
|
||||
let submission =
|
||||
fabro_interview::Interviewer::ask(&interviewer, api_question_to_question(&question)).await;
|
||||
let answer = submission.answer;
|
||||
let ask = ask_attach_question(api_question_to_question(&question), styles);
|
||||
tokio::pin!(ask);
|
||||
let ctrl_c_signal = ctrl_c();
|
||||
tokio::pin!(ctrl_c_signal);
|
||||
|
||||
let answer = loop {
|
||||
let next_event = tokio::select! {
|
||||
answer = &mut ask => {
|
||||
break answer;
|
||||
}
|
||||
_ = &mut ctrl_c_signal => {
|
||||
handle_detach_signal(client, run_id, kill_on_detach, printer).await;
|
||||
show_progress(progress_ui, json_output);
|
||||
return Ok(Some(ExitCode::from(1)));
|
||||
}
|
||||
result = stream.next_event() => result?,
|
||||
};
|
||||
|
||||
let Some(event) = next_event else {
|
||||
show_progress(progress_ui, json_output);
|
||||
return Err(anyhow::anyhow!(ATTACH_PREMATURE_EOF_MESSAGE));
|
||||
};
|
||||
|
||||
let line = event_payload_line(&event)?;
|
||||
emit_progress_line(progress_ui, &line, json_output)?;
|
||||
|
||||
if let Some(exit_code) = event_exit_code(&event) {
|
||||
show_progress(progress_ui, json_output);
|
||||
return Ok(Some(exit_code));
|
||||
}
|
||||
|
||||
if event_resolves_interview(&event, &question.id) {
|
||||
show_progress(progress_ui, json_output);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
show_progress(progress_ui, json_output);
|
||||
|
||||
if answer_requires_reattach(&answer) {
|
||||
|
|
@ -307,18 +442,230 @@ fn api_question_to_question(question: &types::ApiQuestion) -> Question {
|
|||
converted
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::print_stderr,
|
||||
reason = "Interactive questions and options belong on stderr, not captured stdout."
|
||||
)]
|
||||
async fn ask_attach_question(question: Question, styles: &'static Styles) -> Answer {
|
||||
let mut input_buffer = Vec::new();
|
||||
if let Some(ref context_text) = question.context_display {
|
||||
let rendered = styles.render_markdown(context_text);
|
||||
eprint!("{rendered}");
|
||||
}
|
||||
eprintln!("{} {}", styles.bold_cyan.apply_to("?"), question.text);
|
||||
|
||||
match question.question_type {
|
||||
QuestionType::MultipleChoice | QuestionType::MultiSelect => {
|
||||
for (i, opt) in question.options.iter().enumerate() {
|
||||
eprintln!(
|
||||
" {}{}{} {} - {}",
|
||||
styles.dim.apply_to("["),
|
||||
styles.bold.apply_to(i + 1),
|
||||
styles.dim.apply_to("]"),
|
||||
opt.key,
|
||||
opt.label,
|
||||
);
|
||||
}
|
||||
if question.allow_freeform {
|
||||
eprintln!(" Or type a free-text response");
|
||||
}
|
||||
loop {
|
||||
match parse_choice_response(
|
||||
&question,
|
||||
read_attach_line("Select: ", &mut input_buffer).await,
|
||||
) {
|
||||
ParsedPromptAnswer::Answer(answer) => return answer,
|
||||
ParsedPromptAnswer::Invalid(message) => eprintln!("{message}"),
|
||||
ParsedPromptAnswer::Interrupted => return Answer::interrupted(),
|
||||
}
|
||||
}
|
||||
}
|
||||
QuestionType::YesNo | QuestionType::Confirmation => loop {
|
||||
match parse_confirm_response(read_attach_line("[Y/N]: ", &mut input_buffer).await) {
|
||||
ParsedPromptAnswer::Answer(answer) => return answer,
|
||||
ParsedPromptAnswer::Invalid(message) => eprintln!("{message}"),
|
||||
ParsedPromptAnswer::Interrupted => return Answer::interrupted(),
|
||||
}
|
||||
},
|
||||
QuestionType::Freeform => loop {
|
||||
match parse_freeform_response(read_attach_line("> ", &mut input_buffer).await) {
|
||||
ParsedPromptAnswer::Answer(answer) => return answer,
|
||||
ParsedPromptAnswer::Invalid(message) => eprintln!("{message}"),
|
||||
ParsedPromptAnswer::Interrupted => return Answer::interrupted(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::print_stderr,
|
||||
reason = "Prompts go to stderr so piped stdout stays machine-readable."
|
||||
)]
|
||||
async fn read_attach_line(prompt: &str, buffer: &mut Vec<u8>) -> PromptRead {
|
||||
eprint!("{prompt}");
|
||||
let _ = std::io::stderr().flush();
|
||||
read_attach_line_after_prompt(buffer).await
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn read_attach_line_after_prompt(buffer: &mut Vec<u8>) -> PromptRead {
|
||||
let Some(stdin) = NonblockingStdin::new() else {
|
||||
return PromptRead::Error;
|
||||
};
|
||||
loop {
|
||||
match stdin.read_line(buffer) {
|
||||
LineRead::Pending => sleep(PROMPT_READ_POLL_INTERVAL).await,
|
||||
LineRead::Complete(line) => return PromptRead::Line(line),
|
||||
LineRead::Eof => return PromptRead::Eof,
|
||||
LineRead::Error => return PromptRead::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn read_attach_line_after_prompt(_buffer: &mut Vec<u8>) -> PromptRead {
|
||||
use tokio::io::{self, AsyncBufReadExt, BufReader};
|
||||
|
||||
let stdin = io::stdin();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => PromptRead::Eof,
|
||||
Ok(_) => PromptRead::Line(line.trim_end().to_string()),
|
||||
Err(_) => PromptRead::Error,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_choice_response(question: &Question, prompt_read: PromptRead) -> ParsedPromptAnswer {
|
||||
let PromptRead::Line(response) = prompt_read else {
|
||||
return ParsedPromptAnswer::Interrupted;
|
||||
};
|
||||
if response.trim().is_empty() {
|
||||
return ParsedPromptAnswer::Invalid(invalid_choice_message(question));
|
||||
}
|
||||
if question.question_type == QuestionType::MultiSelect {
|
||||
let selected = response
|
||||
.split([',', ' '])
|
||||
.filter(|part| !part.trim().is_empty())
|
||||
.map(str::trim)
|
||||
.map(|part| {
|
||||
question
|
||||
.options
|
||||
.iter()
|
||||
.find(|option| option.key.eq_ignore_ascii_case(part))
|
||||
.map(|option| option.key.clone())
|
||||
.or_else(|| {
|
||||
part.parse::<usize>().ok().and_then(|idx| {
|
||||
idx.checked_sub(1)
|
||||
.and_then(|zero_idx| question.options.get(zero_idx))
|
||||
.map(|option| option.key.clone())
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>();
|
||||
if let Some(selected) = selected.filter(|keys| !keys.is_empty()) {
|
||||
return ParsedPromptAnswer::Answer(Answer::multi_selected(selected));
|
||||
}
|
||||
return ParsedPromptAnswer::Invalid(invalid_choice_message(question));
|
||||
}
|
||||
if let Some(answer) = find_matching_option(&response, &question.options) {
|
||||
return ParsedPromptAnswer::Answer(answer);
|
||||
}
|
||||
if question.allow_freeform {
|
||||
return ParsedPromptAnswer::Answer(Answer::text(response));
|
||||
}
|
||||
ParsedPromptAnswer::Invalid(invalid_choice_message(question))
|
||||
}
|
||||
|
||||
fn parse_confirm_response(prompt_read: PromptRead) -> ParsedPromptAnswer {
|
||||
let PromptRead::Line(response) = prompt_read else {
|
||||
return ParsedPromptAnswer::Interrupted;
|
||||
};
|
||||
match response.trim().to_lowercase().as_str() {
|
||||
"y" | "yes" => ParsedPromptAnswer::Answer(Answer::yes()),
|
||||
"n" | "no" => ParsedPromptAnswer::Answer(Answer::no()),
|
||||
_ => ParsedPromptAnswer::Invalid("Please enter y or n.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_freeform_response(prompt_read: PromptRead) -> ParsedPromptAnswer {
|
||||
let PromptRead::Line(response) = prompt_read else {
|
||||
return ParsedPromptAnswer::Interrupted;
|
||||
};
|
||||
if response.trim().is_empty() {
|
||||
ParsedPromptAnswer::Invalid("Please enter a response.".to_string())
|
||||
} else {
|
||||
ParsedPromptAnswer::Answer(Answer::text(response))
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_choice_message(question: &Question) -> String {
|
||||
let keys = question
|
||||
.options
|
||||
.iter()
|
||||
.map(|option| option.key.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if keys.is_empty() {
|
||||
return "Please enter one of the listed options.".to_string();
|
||||
}
|
||||
format!("Please enter one of: {keys}.")
|
||||
}
|
||||
|
||||
fn find_matching_option(response: &str, options: &[InterviewOption]) -> Option<Answer> {
|
||||
let trimmed = response.trim();
|
||||
for opt in options {
|
||||
if opt.key.eq_ignore_ascii_case(trimmed) {
|
||||
return Some(Answer {
|
||||
value: AnswerValue::Selected(opt.key.clone()),
|
||||
selected_option: Some(opt.clone()),
|
||||
text: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Ok(idx) = trimmed.parse::<usize>() {
|
||||
if idx >= 1 && idx <= options.len() {
|
||||
let opt = &options[idx - 1];
|
||||
return Some(Answer {
|
||||
value: AnswerValue::Selected(opt.key.clone()),
|
||||
selected_option: Some(opt.clone()),
|
||||
text: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn submit_server_interview_answer(
|
||||
client: &server_client::Client,
|
||||
run_id: &RunId,
|
||||
qid: &str,
|
||||
answer: &fabro_interview::Answer,
|
||||
) -> Result<bool> {
|
||||
let (value, selected_option_key, selected_option_keys) = match &answer.value {
|
||||
AnswerValue::Text(text) => (Some(text.clone()), None, Vec::new()),
|
||||
AnswerValue::Selected(key) => (None, Some(key.clone()), Vec::new()),
|
||||
AnswerValue::MultiSelected(keys) => (None, None, keys.clone()),
|
||||
AnswerValue::Yes => (Some("yes".to_string()), None, Vec::new()),
|
||||
AnswerValue::No => (Some("no".to_string()), None, Vec::new()),
|
||||
let body = match &answer.value {
|
||||
AnswerValue::Text(text) => types::SubmitAnswerTextRequest {
|
||||
kind: types::SubmitAnswerTextRequestKind::Text,
|
||||
text: text.clone(),
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::Selected(key) => types::SubmitAnswerSelectedRequest {
|
||||
kind: types::SubmitAnswerSelectedRequestKind::Selected,
|
||||
option_key: key.clone(),
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::MultiSelected(keys) => types::SubmitAnswerMultiSelectedRequest {
|
||||
kind: types::SubmitAnswerMultiSelectedRequestKind::MultiSelected,
|
||||
option_keys: keys.clone(),
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::Yes => types::SubmitAnswerYesRequest {
|
||||
kind: types::SubmitAnswerYesRequestKind::Yes,
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::No => types::SubmitAnswerNoRequest {
|
||||
kind: types::SubmitAnswerNoRequestKind::No,
|
||||
}
|
||||
.into(),
|
||||
AnswerValue::Cancelled
|
||||
| AnswerValue::Interrupted
|
||||
| AnswerValue::Skipped
|
||||
|
|
@ -326,15 +673,7 @@ async fn submit_server_interview_answer(
|
|||
return Ok(false);
|
||||
}
|
||||
};
|
||||
client
|
||||
.submit_run_answer(
|
||||
run_id,
|
||||
qid,
|
||||
value,
|
||||
selected_option_key,
|
||||
selected_option_keys,
|
||||
)
|
||||
.await?;
|
||||
client.submit_run_answer(run_id, qid, body).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
|
@ -471,6 +810,15 @@ fn event_starts_interview(event: &EventEnvelope) -> bool {
|
|||
matches!(event.event.body, EventBody::InterviewStarted(_))
|
||||
}
|
||||
|
||||
fn event_resolves_interview(event: &EventEnvelope, question_id: &str) -> bool {
|
||||
match &event.event.body {
|
||||
EventBody::InterviewCompleted(props) => props.question_id == question_id,
|
||||
EventBody::InterviewInterrupted(props) => props.question_id == question_id,
|
||||
EventBody::InterviewTimeout(props) => props.question_id == question_id,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(
|
||||
|
|
@ -592,6 +940,79 @@ mod tests {
|
|||
assert!(!answer_requires_reattach(&answered));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_confirm_response_is_user_correctable() {
|
||||
let response = parse_confirm_response(PromptRead::Line("dasf".to_string()));
|
||||
|
||||
assert!(matches!(response, ParsedPromptAnswer::Invalid(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eof_confirm_response_is_interrupted() {
|
||||
let response = parse_confirm_response(PromptRead::Eof);
|
||||
|
||||
assert!(matches!(response, ParsedPromptAnswer::Interrupted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_multiple_choice_without_freeform_is_user_correctable() {
|
||||
let mut question = Question::new("Pick one.", QuestionType::MultipleChoice);
|
||||
question.options = vec![InterviewOption {
|
||||
key: "A".to_string(),
|
||||
label: "Approve".to_string(),
|
||||
}];
|
||||
|
||||
let response = parse_choice_response(&question, PromptRead::Line("bogus".to_string()));
|
||||
|
||||
assert!(matches!(response, ParsedPromptAnswer::Invalid(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_multiple_choice_with_freeform_remains_text() {
|
||||
let mut question = Question::new("Pick one.", QuestionType::MultipleChoice);
|
||||
question.options = vec![InterviewOption {
|
||||
key: "A".to_string(),
|
||||
label: "Approve".to_string(),
|
||||
}];
|
||||
question.allow_freeform = true;
|
||||
|
||||
let response = parse_choice_response(&question, PromptRead::Line("custom".to_string()));
|
||||
|
||||
assert!(matches!(
|
||||
response,
|
||||
ParsedPromptAnswer::Answer(Answer {
|
||||
value: AnswerValue::Text(text),
|
||||
..
|
||||
}) if text == "custom"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_multi_select_token_is_user_correctable() {
|
||||
let mut question = Question::new("Pick many.", QuestionType::MultiSelect);
|
||||
question.options = vec![
|
||||
InterviewOption {
|
||||
key: "A".to_string(),
|
||||
label: "Approve".to_string(),
|
||||
},
|
||||
InterviewOption {
|
||||
key: "N".to_string(),
|
||||
label: "Notify".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let response = parse_choice_response(&question, PromptRead::Line("A bogus".to_string()));
|
||||
|
||||
assert!(matches!(response, ParsedPromptAnswer::Invalid(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_freeform_response_is_user_correctable() {
|
||||
let response = parse_freeform_response(PromptRead::Line(" ".to_string()));
|
||||
|
||||
assert!(matches!(response, ParsedPromptAnswer::Invalid(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_pending_interview_requires_manual_input_when_auto_approve_is_disabled() {
|
||||
assert!(json_pending_interview_requires_manual_input(true, false));
|
||||
|
|
|
|||
|
|
@ -773,6 +773,7 @@ mod tests {
|
|||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
diff_summary: None,
|
||||
billing: None,
|
||||
})),
|
||||
Some(WorkerTitlePhase::Succeeded)
|
||||
|
|
@ -785,6 +786,7 @@ mod tests {
|
|||
reason: FailureReason::Cancelled,
|
||||
git_commit_sha: None,
|
||||
final_patch: None,
|
||||
diff_summary: None,
|
||||
})),
|
||||
Some(WorkerTitlePhase::Cancelled)
|
||||
);
|
||||
|
|
@ -796,6 +798,7 @@ mod tests {
|
|||
reason: FailureReason::Terminated,
|
||||
git_commit_sha: None,
|
||||
final_patch: None,
|
||||
diff_summary: None,
|
||||
})),
|
||||
Some(WorkerTitlePhase::Failed)
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue