mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-24 00:51:19 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
93f87ce62d
15 changed files with 1356 additions and 183 deletions
|
|
@ -17,9 +17,8 @@ digraph ImplementPlan {
|
|||
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
|
||||
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
|
||||
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
|
||||
verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
|
||||
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
|
||||
fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]
|
||||
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
|
||||
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
|
||||
|
||||
start -> toolchain
|
||||
toolchain -> preflight_compile [condition="outcome=succeeded"]
|
||||
|
|
@ -30,8 +29,7 @@ digraph ImplementPlan {
|
|||
preflight_lint -> fix_lints
|
||||
fix_lints -> preflight_lint
|
||||
implement -> simplify_opus -> simplify_gpt -> verify
|
||||
verify -> fmt [condition="outcome=succeeded"]
|
||||
verify -> exit [condition="outcome=succeeded"]
|
||||
verify -> fixup
|
||||
fixup -> verify
|
||||
fmt -> exit
|
||||
}
|
||||
|
|
|
|||
95
Cargo.lock
generated
95
Cargo.lock
generated
|
|
@ -1580,7 +1580,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-acp"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-tokio",
|
||||
|
|
@ -1599,7 +1599,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-agent"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -1629,6 +1629,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"shell-escape",
|
||||
"strum",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
@ -1639,7 +1640,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -1660,7 +1661,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -1684,11 +1685,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-config",
|
||||
|
|
@ -1704,7 +1705,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -1805,7 +1806,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1834,7 +1835,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -1862,7 +1863,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-core"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
|
|
@ -1877,7 +1878,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -1896,7 +1897,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-devcontainer"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-static",
|
||||
|
|
@ -1913,7 +1914,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1927,7 +1928,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1949,7 +1950,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -1963,7 +1964,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-hooks"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
|
|
@ -1987,7 +1988,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http",
|
||||
|
|
@ -1997,7 +1998,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2013,7 +2014,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dialoguer",
|
||||
|
|
@ -2028,7 +2029,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2062,7 +2063,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2073,7 +2074,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
|
|
@ -2091,7 +2092,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-config",
|
||||
|
|
@ -2107,7 +2108,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2134,7 +2135,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http",
|
||||
|
|
@ -2150,7 +2151,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2172,7 +2173,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2180,7 +2181,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -2189,7 +2190,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"ref-cast",
|
||||
|
|
@ -2205,7 +2206,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2248,7 +2249,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2333,7 +2334,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -2354,18 +2355,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -2392,7 +2393,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2418,7 +2419,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2432,7 +2433,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"assert_cmd",
|
||||
"axum",
|
||||
|
|
@ -2455,7 +2456,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2476,7 +2477,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2490,7 +2491,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -2511,7 +2512,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -2531,7 +2532,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-validate"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"fabro-acp",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -2544,7 +2545,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-types",
|
||||
|
|
@ -2556,7 +2557,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -7336,7 +7337,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -7355,7 +7356,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.241.0-nightly.1"
|
||||
version = "0.242.0-nightly.1"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
|||
330
apps/fabro-web/app/components/run-waterfall.tsx
Normal file
330
apps/fabro-web/app/components/run-waterfall.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { StageState, type RunStage } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { HoverCard, PopoverHeader, PopoverRow, PopoverRows } from "./ui";
|
||||
import { isVisibleStage } from "../data/runs";
|
||||
import { formatAbsoluteTs, formatDurationMs } from "../lib/format";
|
||||
import {
|
||||
formatStageLabel,
|
||||
stageStatusLabel,
|
||||
stageStatusTone,
|
||||
} from "../lib/stage-sidebar";
|
||||
import { deriveRunPhases, type RunPhase } from "../lib/run-phases";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
interface WaterfallProps {
|
||||
runId: string;
|
||||
events: EventEnvelope[];
|
||||
stages: RunStage[];
|
||||
createdAtIso: string;
|
||||
completedAtIso: string | null;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
key: string;
|
||||
kind: "phase" | "stage";
|
||||
label: string;
|
||||
startMs: number;
|
||||
endMs: number | null;
|
||||
durationMs: number | null;
|
||||
barClass: string;
|
||||
href: string | null;
|
||||
popover: ReactNode;
|
||||
}
|
||||
|
||||
const MIN_BAR_WIDTH_PCT = 0.4;
|
||||
|
||||
function useTickingNow(intervalMs: number): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
function stageBarClass(status: StageState): string {
|
||||
switch (status) {
|
||||
case StageState.RUNNING:
|
||||
case StageState.RETRYING:
|
||||
return "bg-teal-500 animate-pulse";
|
||||
case StageState.SUCCEEDED:
|
||||
return "bg-mint";
|
||||
case StageState.PARTIALLY_SUCCEEDED:
|
||||
return "bg-amber";
|
||||
case StageState.FAILED:
|
||||
return "bg-coral";
|
||||
case StageState.PENDING:
|
||||
return "bg-overlay-strong";
|
||||
case StageState.SKIPPED:
|
||||
case StageState.CANCELLED:
|
||||
return "bg-fg-muted/40";
|
||||
}
|
||||
}
|
||||
|
||||
function isStageInFlight(status: StageState): boolean {
|
||||
return status === StageState.RUNNING || status === StageState.RETRYING;
|
||||
}
|
||||
|
||||
function chooseTickIntervalMs(rangeMs: number): number {
|
||||
if (rangeMs < 30_000) return 5_000;
|
||||
if (rangeMs < 120_000) return 15_000;
|
||||
if (rangeMs < 600_000) return 60_000;
|
||||
if (rangeMs < 3_600_000) return 5 * 60_000;
|
||||
if (rangeMs < 6 * 3_600_000) return 30 * 60_000;
|
||||
return 60 * 60_000;
|
||||
}
|
||||
|
||||
function phasePopover(phase: RunPhase, durationMs: number | null, inFlight: boolean): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<PopoverHeader>{phase.label}</PopoverHeader>
|
||||
<PopoverRows>
|
||||
<PopoverRow label="Started">
|
||||
{formatAbsoluteTs(new Date(phase.startMs).toISOString())}
|
||||
</PopoverRow>
|
||||
<PopoverRow label={inFlight ? "Elapsed" : "Duration"}>
|
||||
<span className="font-mono">
|
||||
{durationMs != null ? formatDurationMs(durationMs) : "--"}
|
||||
</span>
|
||||
</PopoverRow>
|
||||
</PopoverRows>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function phaseRow(phase: RunPhase, nowMs: number): Row {
|
||||
const endMs = phase.endMs;
|
||||
const inFlight = endMs == null;
|
||||
const closedEnd = endMs ?? nowMs;
|
||||
const rawDuration = closedEnd - phase.startMs;
|
||||
const durationMs = rawDuration >= 0 ? rawDuration : null;
|
||||
return {
|
||||
key: `phase:${phase.kind}`,
|
||||
kind: "phase",
|
||||
label: phase.label,
|
||||
startMs: phase.startMs,
|
||||
endMs,
|
||||
durationMs,
|
||||
barClass: inFlight ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40",
|
||||
href: null,
|
||||
popover: phasePopover(phase, durationMs, inFlight),
|
||||
};
|
||||
}
|
||||
|
||||
function stagePopover(
|
||||
stage: RunStage,
|
||||
durationMs: number | null,
|
||||
inFlight: boolean,
|
||||
): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<PopoverHeader>{formatStageLabel(stage)}</PopoverHeader>
|
||||
<PopoverRows>
|
||||
<PopoverRow label="Status">
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium ${stageStatusTone(stage.status)}`}
|
||||
>
|
||||
{stageStatusLabel(stage.status)}
|
||||
</span>
|
||||
</PopoverRow>
|
||||
{stage.started_at && (
|
||||
<PopoverRow label="Started">{formatAbsoluteTs(stage.started_at)}</PopoverRow>
|
||||
)}
|
||||
<PopoverRow label={inFlight ? "Elapsed" : "Duration"}>
|
||||
<span className="font-mono">
|
||||
{durationMs != null ? formatDurationMs(durationMs) : "--"}
|
||||
</span>
|
||||
</PopoverRow>
|
||||
</PopoverRows>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null {
|
||||
if (!stage.started_at) return null;
|
||||
const startMs = Date.parse(stage.started_at);
|
||||
if (Number.isNaN(startMs)) return null;
|
||||
const inFlight = isStageInFlight(stage.status);
|
||||
const wallMs = stage.wall_time_ms ?? null;
|
||||
const endMs = inFlight ? null : wallMs != null ? startMs + wallMs : null;
|
||||
const durationMs = inFlight ? nowMs - startMs : wallMs;
|
||||
return {
|
||||
key: `stage:${stage.id}`,
|
||||
kind: "stage",
|
||||
label: formatStageLabel(stage),
|
||||
startMs,
|
||||
endMs,
|
||||
durationMs,
|
||||
barClass: stageBarClass(stage.status),
|
||||
href: `/runs/${runId}/stages/${encodeURIComponent(stage.id)}`,
|
||||
popover: stagePopover(stage, durationMs, inFlight),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRows({
|
||||
runId,
|
||||
events,
|
||||
stages,
|
||||
createdAtIso,
|
||||
nowMs,
|
||||
}: {
|
||||
runId: string;
|
||||
events: EventEnvelope[];
|
||||
stages: RunStage[];
|
||||
createdAtIso: string;
|
||||
nowMs: number;
|
||||
}): Row[] {
|
||||
const phases = deriveRunPhases(events, createdAtIso).map((p) => phaseRow(p, nowMs));
|
||||
const stageRows = stages
|
||||
.filter((s) => isVisibleStage(s.node_id))
|
||||
.map((s) => stageRow(runId, s, nowMs))
|
||||
.filter((r): r is Row => r !== null)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
return [...phases, ...stageRows];
|
||||
}
|
||||
|
||||
export function RunWaterfall({
|
||||
runId,
|
||||
events,
|
||||
stages,
|
||||
createdAtIso,
|
||||
completedAtIso,
|
||||
}: WaterfallProps) {
|
||||
const nowMs = useTickingNow(1000);
|
||||
const rows = useMemo(
|
||||
() => buildRows({ runId, events, stages, createdAtIso, nowMs }),
|
||||
[runId, events, stages, createdAtIso, nowMs],
|
||||
);
|
||||
|
||||
const createdMs = Date.parse(createdAtIso);
|
||||
const completedMs = completedAtIso ? Date.parse(completedAtIso) : null;
|
||||
const hasInFlight = rows.some((r) => r.endMs == null);
|
||||
const lastRowEnd = rows.reduce(
|
||||
(max, r) => Math.max(max, r.endMs ?? r.startMs + (r.durationMs ?? 0)),
|
||||
createdMs,
|
||||
);
|
||||
const timelineStartMs = createdMs;
|
||||
const timelineEndMs = Math.max(
|
||||
timelineStartMs + 1_000,
|
||||
completedMs ?? (hasInFlight ? nowMs : lastRowEnd),
|
||||
);
|
||||
const rangeMs = timelineEndMs - timelineStartMs;
|
||||
|
||||
const ticks = useMemo(() => {
|
||||
const interval = chooseTickIntervalMs(rangeMs);
|
||||
const out: { offsetMs: number; pct: number; label: string }[] = [];
|
||||
for (let t = 0; t <= rangeMs + 1; t += interval) {
|
||||
out.push({
|
||||
offsetMs: t,
|
||||
pct: (t / rangeMs) * 100,
|
||||
label: t === 0 ? "0s" : formatDurationMs(t),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [rangeMs]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-12 text-sm text-fg-muted">
|
||||
Waterfall will populate as the run progresses.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1 overflow-y-auto pt-2 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
|
||||
<div className="sticky top-0 z-10 bg-page">
|
||||
<div className="flex items-end gap-3 px-3 pb-1">
|
||||
<div className="w-48 shrink-0" />
|
||||
<div className="relative h-5 flex-1">
|
||||
{ticks.map((tick) => (
|
||||
<div
|
||||
key={tick.offsetMs}
|
||||
className="absolute top-0 h-full"
|
||||
style={{ left: `${tick.pct}%` }}
|
||||
>
|
||||
<div className="absolute top-0 h-2 w-px bg-line-strong" />
|
||||
<span className="absolute top-2 -translate-x-1/2 whitespace-nowrap font-mono text-[10px] text-fg-muted">
|
||||
{tick.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-16 shrink-0" />
|
||||
</div>
|
||||
<div className="border-b border-line" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{rows.map((row) => {
|
||||
const startPct =
|
||||
((row.startMs - timelineStartMs) / rangeMs) * 100;
|
||||
const closedEnd = row.endMs ?? timelineEndMs;
|
||||
const rawWidthPct = ((closedEnd - row.startMs) / rangeMs) * 100;
|
||||
const widthPct = Math.max(MIN_BAR_WIDTH_PCT, rawWidthPct);
|
||||
const durationLabel =
|
||||
row.durationMs != null ? formatDurationMs(row.durationMs) : "";
|
||||
return (
|
||||
<WaterfallRow
|
||||
key={row.key}
|
||||
row={row}
|
||||
startPct={startPct}
|
||||
widthPct={widthPct}
|
||||
durationLabel={durationLabel}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WaterfallRow({
|
||||
row,
|
||||
startPct,
|
||||
widthPct,
|
||||
durationLabel,
|
||||
}: {
|
||||
row: Row;
|
||||
startPct: number;
|
||||
widthPct: number;
|
||||
durationLabel: string;
|
||||
}) {
|
||||
const labelClass =
|
||||
row.kind === "phase"
|
||||
? "text-fg-muted"
|
||||
: "text-fg-2";
|
||||
const inner = (
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 hover:bg-overlay">
|
||||
<div className={`w-48 shrink-0 truncate font-mono text-xs ${labelClass}`}>
|
||||
{row.label}
|
||||
</div>
|
||||
<div className="relative h-3 flex-1">
|
||||
<div
|
||||
className={`absolute top-0 h-full rounded-sm ${row.barClass}`}
|
||||
style={{ left: `${startPct}%`, width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16 shrink-0 text-right font-mono text-[11px] tabular-nums text-fg-muted">
|
||||
{durationLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const trigger = row.href ? (
|
||||
<Link
|
||||
to={row.href}
|
||||
className="block focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
inner
|
||||
);
|
||||
return (
|
||||
<HoverCard content={row.popover} className="block">
|
||||
{trigger}
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -226,9 +226,11 @@ function hoverCardStyle(rect: DOMRect): CSSProperties {
|
|||
export function HoverCard({
|
||||
content,
|
||||
children,
|
||||
className = "inline-flex",
|
||||
}: {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { open, rect, triggerProps } = useHoverAnchor();
|
||||
const id = useId();
|
||||
|
|
@ -236,7 +238,7 @@ export function HoverCard({
|
|||
|
||||
return (
|
||||
<>
|
||||
<span {...triggerProps} aria-describedby={open ? id : undefined} className="inline-flex">
|
||||
<span {...triggerProps} aria-describedby={open ? id : undefined} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
{rect && portalTarget
|
||||
|
|
@ -255,3 +257,24 @@ export function HoverCard({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoverHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoverRows({ children }: { children: ReactNode }) {
|
||||
return <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">{children}</dl>;
|
||||
}
|
||||
|
||||
export function PopoverRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-fg-3">{label}</dt>
|
||||
<dd className="min-w-0 text-fg">{children}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,9 +86,19 @@ export function formatDurationMs(ms: number, fractionDigits = 1): string {
|
|||
const seconds = ms / 1000;
|
||||
return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(fractionDigits)}s`;
|
||||
}
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const seconds = Math.round((ms % 60_000) / 1000);
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
if (ms < 3_600_000) {
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const seconds = Math.round((ms % 60_000) / 1000);
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
if (ms < 86_400_000) {
|
||||
const hours = Math.floor(ms / 3_600_000);
|
||||
const minutes = Math.round((ms % 3_600_000) / 60_000);
|
||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
}
|
||||
const days = Math.floor(ms / 86_400_000);
|
||||
const hours = Math.round((ms % 86_400_000) / 3_600_000);
|
||||
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
||||
}
|
||||
|
||||
export function formatTokenCount(
|
||||
|
|
|
|||
129
apps/fabro-web/app/lib/run-phases.test.ts
Normal file
129
apps/fabro-web/app/lib/run-phases.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { deriveRunPhases } from "./run-phases";
|
||||
|
||||
const CREATED = "2026-05-23T12:00:00.000Z";
|
||||
const T_QUEUED = "2026-05-23T12:00:01.000Z";
|
||||
const T_STARTING = "2026-05-23T12:00:03.000Z";
|
||||
const T_RUNNING = "2026-05-23T12:00:10.000Z";
|
||||
|
||||
function makeEvent(name: string, ts: string, seq: number): EventEnvelope {
|
||||
return {
|
||||
id: `evt-${seq}`,
|
||||
seq,
|
||||
ts,
|
||||
run_id: "run-1",
|
||||
event: name,
|
||||
} as EventEnvelope;
|
||||
}
|
||||
|
||||
describe("deriveRunPhases", () => {
|
||||
test("returns empty for an unparseable created_at", () => {
|
||||
expect(deriveRunPhases([], "not-a-date")).toEqual([]);
|
||||
});
|
||||
|
||||
test("submitted phase is open-ended when no transitions have fired", () => {
|
||||
const phases = deriveRunPhases([], CREATED);
|
||||
expect(phases).toEqual([
|
||||
{
|
||||
kind: "submitted",
|
||||
label: "Submitted",
|
||||
startMs: Date.parse(CREATED),
|
||||
endMs: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("closes submitted at run.queued and opens an in-progress queued phase", () => {
|
||||
const phases = deriveRunPhases(
|
||||
[makeEvent("run.queued", T_QUEUED, 1)],
|
||||
CREATED,
|
||||
);
|
||||
expect(phases).toEqual([
|
||||
{
|
||||
kind: "submitted",
|
||||
label: "Submitted",
|
||||
startMs: Date.parse(CREATED),
|
||||
endMs: Date.parse(T_QUEUED),
|
||||
},
|
||||
{
|
||||
kind: "queued",
|
||||
label: "Queued",
|
||||
startMs: Date.parse(T_QUEUED),
|
||||
endMs: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits submitted, queued, and initializing through run.running", () => {
|
||||
const phases = deriveRunPhases(
|
||||
[
|
||||
makeEvent("run.queued", T_QUEUED, 1),
|
||||
makeEvent("run.starting", T_STARTING, 2),
|
||||
makeEvent("run.running", T_RUNNING, 3),
|
||||
],
|
||||
CREATED,
|
||||
);
|
||||
expect(phases).toEqual([
|
||||
{
|
||||
kind: "submitted",
|
||||
label: "Submitted",
|
||||
startMs: Date.parse(CREATED),
|
||||
endMs: Date.parse(T_QUEUED),
|
||||
},
|
||||
{
|
||||
kind: "queued",
|
||||
label: "Queued",
|
||||
startMs: Date.parse(T_QUEUED),
|
||||
endMs: Date.parse(T_STARTING),
|
||||
},
|
||||
{
|
||||
kind: "initializing",
|
||||
label: "Initializing",
|
||||
startMs: Date.parse(T_STARTING),
|
||||
endMs: Date.parse(T_RUNNING),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips the queued phase when there was no run.queued event", () => {
|
||||
const phases = deriveRunPhases(
|
||||
[
|
||||
makeEvent("run.starting", T_STARTING, 1),
|
||||
makeEvent("run.running", T_RUNNING, 2),
|
||||
],
|
||||
CREATED,
|
||||
);
|
||||
expect(phases.map((p) => p.kind)).toEqual(["submitted", "initializing"]);
|
||||
expect(phases[0]!.endMs).toBe(Date.parse(T_STARTING));
|
||||
expect(phases[1]!.startMs).toBe(Date.parse(T_STARTING));
|
||||
expect(phases[1]!.endMs).toBe(Date.parse(T_RUNNING));
|
||||
});
|
||||
|
||||
test("uses run.starting as fallback end for submitted when queued is missing", () => {
|
||||
const phases = deriveRunPhases(
|
||||
[makeEvent("run.starting", T_STARTING, 1)],
|
||||
CREATED,
|
||||
);
|
||||
expect(phases[0]!.endMs).toBe(Date.parse(T_STARTING));
|
||||
});
|
||||
|
||||
test("ignores unrelated events", () => {
|
||||
const phases = deriveRunPhases(
|
||||
[
|
||||
makeEvent("agent.message", T_QUEUED, 1),
|
||||
makeEvent("stage.started", T_STARTING, 2),
|
||||
],
|
||||
CREATED,
|
||||
);
|
||||
expect(phases).toEqual([
|
||||
{
|
||||
kind: "submitted",
|
||||
label: "Submitted",
|
||||
startMs: Date.parse(CREATED),
|
||||
endMs: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
70
apps/fabro-web/app/lib/run-phases.ts
Normal file
70
apps/fabro-web/app/lib/run-phases.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
export type RunPhaseKind = "submitted" | "queued" | "initializing";
|
||||
|
||||
export interface RunPhase {
|
||||
kind: RunPhaseKind;
|
||||
label: string;
|
||||
startMs: number;
|
||||
endMs: number | null;
|
||||
}
|
||||
|
||||
const PHASE_LABEL: Record<RunPhaseKind, string> = {
|
||||
submitted: "Submitted",
|
||||
queued: "Queued",
|
||||
initializing: "Initializing",
|
||||
};
|
||||
|
||||
export function phaseLabel(kind: RunPhaseKind): string {
|
||||
return PHASE_LABEL[kind];
|
||||
}
|
||||
|
||||
// Stages own the timeline once `run.running` fires, so we stop slicing there.
|
||||
export function deriveRunPhases(
|
||||
events: ReadonlyArray<EventEnvelope> | undefined,
|
||||
createdAtIso: string,
|
||||
): RunPhase[] {
|
||||
const createdMs = Date.parse(createdAtIso);
|
||||
if (Number.isNaN(createdMs)) return [];
|
||||
|
||||
const firstTs = (name: string): number | null => {
|
||||
if (!events) return null;
|
||||
const event = events.find((e) => e.event === name);
|
||||
if (!event) return null;
|
||||
const ms = Date.parse(event.ts);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
};
|
||||
|
||||
const queuedMs = firstTs("run.queued");
|
||||
const startingMs = firstTs("run.starting");
|
||||
const runningMs = firstTs("run.running");
|
||||
|
||||
const phases: RunPhase[] = [];
|
||||
|
||||
phases.push({
|
||||
kind: "submitted",
|
||||
label: PHASE_LABEL.submitted,
|
||||
startMs: createdMs,
|
||||
endMs: queuedMs ?? startingMs ?? runningMs,
|
||||
});
|
||||
|
||||
if (queuedMs != null) {
|
||||
phases.push({
|
||||
kind: "queued",
|
||||
label: PHASE_LABEL.queued,
|
||||
startMs: queuedMs,
|
||||
endMs: startingMs ?? runningMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (startingMs != null) {
|
||||
phases.push({
|
||||
kind: "initializing",
|
||||
label: PHASE_LABEL.initializing,
|
||||
startMs: startingMs,
|
||||
endMs: runningMs,
|
||||
});
|
||||
}
|
||||
|
||||
return phases;
|
||||
}
|
||||
|
|
@ -38,7 +38,15 @@ import { InterviewDock } from "../components/interview-dock";
|
|||
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
|
||||
import { ErrorState } from "../components/state";
|
||||
import { useToast } from "../components/toast";
|
||||
import { ConfirmDialog, HoverCard, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
|
||||
import {
|
||||
ConfirmDialog,
|
||||
HoverCard,
|
||||
PopoverHeader,
|
||||
PopoverRow,
|
||||
PopoverRows,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
Tooltip,
|
||||
} from "../components/ui";
|
||||
import {
|
||||
isRunStatus,
|
||||
mapRunToRunItem,
|
||||
|
|
@ -49,6 +57,7 @@ import type {
|
|||
PullRequestDetails,
|
||||
RepositoryRef,
|
||||
RunLifecycle,
|
||||
RunTiming,
|
||||
WorkflowRef,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
import { useAskFabroLayout } from "../lib/ask-fabro-layout";
|
||||
|
|
@ -63,7 +72,7 @@ import {
|
|||
type LifecycleMutationResult,
|
||||
type PreviewMutationResult,
|
||||
} from "../lib/mutations";
|
||||
import { formatAbsoluteTs, formatRelativeTime } from "../lib/format";
|
||||
import { formatAbsoluteTs, formatDurationMs, formatRelativeTime } from "../lib/format";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useRunEvents } from "../lib/run-events";
|
||||
import { useRunToasts } from "../hooks/use-run-toasts";
|
||||
|
|
@ -192,27 +201,6 @@ export function meta({ data }: any) {
|
|||
|
||||
// ---- Header hover-card popovers ----
|
||||
|
||||
function PopoverHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverRows({ children }: { children: ReactNode }) {
|
||||
return <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">{children}</dl>;
|
||||
}
|
||||
|
||||
function PopoverRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-fg-3">{label}</dt>
|
||||
<dd className="min-w-0 text-fg">{children}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function humanizeFailureReason(reason: string): string {
|
||||
const spaced = reason.replace(/_/g, " ");
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
|
|
@ -303,6 +291,36 @@ function WorkflowPopover({
|
|||
);
|
||||
}
|
||||
|
||||
function DurationPopover({
|
||||
timing,
|
||||
createdAt,
|
||||
completedAt,
|
||||
now,
|
||||
}: {
|
||||
timing: RunTiming;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
now: number;
|
||||
}) {
|
||||
const endMs = completedAt != null ? Date.parse(completedAt) : now;
|
||||
const sinceCreatedMs = Math.max(0, endMs - Date.parse(createdAt));
|
||||
return (
|
||||
<>
|
||||
<PopoverHeader>Duration</PopoverHeader>
|
||||
<dl className="space-y-2">
|
||||
<div>
|
||||
<dt className="text-fg-3">Wall-clock since created</dt>
|
||||
<dd className="mt-0.5 font-mono text-fg">{formatDurationMs(sinceCreatedMs)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-fg-3">Active (inference + tools)</dt>
|
||||
<dd className="mt-0.5 font-mono text-fg">{formatDurationMs(timing.active_time_ms)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function prStateBadge(details: PullRequestDetails): { label: string; className: string } {
|
||||
if (details.merged) return { label: "Merged", className: "bg-mint/15 text-mint" };
|
||||
if (details.draft) return { label: "Draft", className: "bg-overlay-strong text-fg-3" };
|
||||
|
|
@ -580,11 +598,22 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
) : (
|
||||
workflowChip
|
||||
)}
|
||||
{run.elapsed && (
|
||||
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
|
||||
<ClockIcon className="size-3.5" aria-hidden="true" />
|
||||
{run.elapsed}
|
||||
</span>
|
||||
{run.elapsed && summary.timing && (
|
||||
<HoverCard
|
||||
content={
|
||||
<DurationPopover
|
||||
timing={summary.timing}
|
||||
createdAt={summary.timestamps.created_at}
|
||||
completedAt={summary.timestamps.completed_at}
|
||||
now={now}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
|
||||
<ClockIcon className="size-3.5" aria-hidden="true" />
|
||||
{run.elapsed}
|
||||
</span>
|
||||
</HoverCard>
|
||||
)}
|
||||
{run.lastEventAt && (
|
||||
<Tooltip label={`Last event ${formatAbsoluteTs(run.lastEventAt)}`}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
import {
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
debugCategory,
|
||||
debugCategoryLabel,
|
||||
} from "../components/event-debug";
|
||||
import { RunWaterfall } from "../components/run-waterfall";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/state";
|
||||
import { useRun, useRunEventsList, useRunStages } from "../lib/queries";
|
||||
|
|
@ -17,11 +18,26 @@ import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
|
|||
|
||||
export const handle = { wide: true, fullHeight: true };
|
||||
|
||||
type ViewMode = "waterfall" | "events";
|
||||
|
||||
export default function RunEvents() {
|
||||
const { id } = useParams();
|
||||
const runQuery = useRun(id);
|
||||
const stagesQuery = useRunStages(id);
|
||||
const eventsQuery = useRunEventsList(id);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const view: ViewMode = searchParams.get("view") === "events" ? "events" : "waterfall";
|
||||
const setView = (next: ViewMode) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
if (next === "waterfall") params.delete("view");
|
||||
else params.set("view", "events");
|
||||
return params;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
const stages = useMemo(
|
||||
() => mapRunStagesToSidebarStages(stagesQuery.data),
|
||||
[stagesQuery.data],
|
||||
|
|
@ -40,15 +56,133 @@ export default function RunEvents() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<EventsView
|
||||
events={eventsQuery.data}
|
||||
error={eventsQuery.error}
|
||||
onRetry={() => void eventsQuery.mutate()}
|
||||
runStart={
|
||||
runQuery.data?.timestamps.started_at ??
|
||||
runQuery.data?.timestamps.created_at
|
||||
}
|
||||
/>
|
||||
{view === "waterfall" ? (
|
||||
<WaterfallPane
|
||||
runId={id!}
|
||||
events={eventsQuery.data}
|
||||
eventsError={eventsQuery.error}
|
||||
stagesData={stagesQuery.data}
|
||||
stagesError={stagesQuery.error}
|
||||
createdAt={runQuery.data?.timestamps.created_at}
|
||||
completedAt={runQuery.data?.timestamps.completed_at ?? null}
|
||||
onRetry={() => {
|
||||
void eventsQuery.mutate();
|
||||
void stagesQuery.mutate();
|
||||
}}
|
||||
view={view}
|
||||
onChangeView={setView}
|
||||
/>
|
||||
) : (
|
||||
<EventsView
|
||||
events={eventsQuery.data}
|
||||
error={eventsQuery.error}
|
||||
onRetry={() => void eventsQuery.mutate()}
|
||||
runStart={
|
||||
runQuery.data?.timestamps.started_at ??
|
||||
runQuery.data?.timestamps.created_at
|
||||
}
|
||||
view={view}
|
||||
onChangeView={setView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ViewMode;
|
||||
onChange: (v: ViewMode) => void;
|
||||
}) {
|
||||
const options: ReadonlyArray<{ value: ViewMode; label: string }> = [
|
||||
{ value: "waterfall", label: "Waterfall" },
|
||||
{ value: "events", label: "Events" },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="View"
|
||||
className="inline-flex rounded-md bg-panel p-0.5 outline-1 -outline-offset-1 outline-line-strong"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const active = value === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
aria-pressed={active}
|
||||
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500 ${
|
||||
active
|
||||
? "bg-overlay-strong text-fg"
|
||||
: "text-fg-muted hover:text-fg-2"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WaterfallPane({
|
||||
runId,
|
||||
events,
|
||||
eventsError,
|
||||
stagesData,
|
||||
stagesError,
|
||||
createdAt,
|
||||
completedAt,
|
||||
onRetry,
|
||||
view,
|
||||
onChangeView,
|
||||
}: {
|
||||
runId: string;
|
||||
events: EventEnvelope[] | undefined;
|
||||
eventsError: unknown;
|
||||
stagesData: ReturnType<typeof useRunStages>["data"];
|
||||
stagesError: unknown;
|
||||
createdAt: string | undefined;
|
||||
completedAt: string | null;
|
||||
onRetry: () => void;
|
||||
view: ViewMode;
|
||||
onChangeView: (v: ViewMode) => void;
|
||||
}) {
|
||||
const error = eventsError ?? stagesError;
|
||||
const ready =
|
||||
events !== undefined && stagesData !== undefined && createdAt !== undefined;
|
||||
|
||||
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="flex items-center gap-3 pb-3 pl-3 pr-4 sm:pr-6 lg:pr-8">
|
||||
<ViewToggle value={view} onChange={onChangeView} />
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="min-w-0 flex-1 pt-3">
|
||||
<ErrorState
|
||||
title="Couldn't load waterfall"
|
||||
description={errorMessage(error)}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
) : !ready ? (
|
||||
<div className="min-w-0 flex-1 pt-3">
|
||||
<LoadingState label="Loading waterfall…" />
|
||||
</div>
|
||||
) : (
|
||||
<RunWaterfall
|
||||
runId={runId}
|
||||
events={events!}
|
||||
stages={stagesData!.data ?? []}
|
||||
createdAtIso={createdAt!}
|
||||
completedAtIso={completedAt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -58,11 +192,15 @@ function EventsView({
|
|||
error,
|
||||
onRetry,
|
||||
runStart,
|
||||
view,
|
||||
onChangeView,
|
||||
}: {
|
||||
events: EventEnvelope[] | undefined;
|
||||
error: unknown;
|
||||
onRetry: () => void;
|
||||
runStart: string | undefined;
|
||||
view: ViewMode;
|
||||
onChangeView: (v: ViewMode) => void;
|
||||
}) {
|
||||
const [openSeq, setOpenSeq] = useState<number | null>(null);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
|
|
@ -134,6 +272,7 @@ function EventsView({
|
|||
<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">
|
||||
<ViewToggle value={view} onChange={onChangeView} />
|
||||
<MultiSelectFilter<string>
|
||||
selected={selectedCategories}
|
||||
options={availableCategories}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ function ProjectManagementPanel() {
|
|||
<Row title="Linear" help="Sync runs with Linear issues and projects.">
|
||||
<span className="text-sm text-fg-muted">Coming Soon</span>
|
||||
</Row>
|
||||
<Row title="Jira" help="Sync runs with Jira issues and projects.">
|
||||
<span className="text-sm text-fg-muted">Coming Soon</span>
|
||||
</Row>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -90,6 +93,18 @@ function SlackPanel({ settings }: { settings: ServerSettings }) {
|
|||
}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title="Microsoft Teams"
|
||||
help="Channel app for run notifications and approvals."
|
||||
>
|
||||
<span className="text-sm text-fg-muted">Coming Soon</span>
|
||||
</Row>
|
||||
<Row
|
||||
title="Discord"
|
||||
help="Server app for run notifications and approvals."
|
||||
>
|
||||
<span className="text-sm text-fg-muted">Coming Soon</span>
|
||||
</Row>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ fabro-http.workspace = true
|
|||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
futures.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,37 +11,53 @@ use crate::file_tracker::FileTracker;
|
|||
use crate::history::History;
|
||||
use crate::types::{AgentEvent, Message};
|
||||
|
||||
const APPROX_CHARS_PER_TOKEN: usize = 4;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub(crate) enum ContextEstimateMethod {
|
||||
ApiUsagePlusLocalDelta,
|
||||
LocalEstimate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct ContextEstimate {
|
||||
pub tokens: usize,
|
||||
pub method: ContextEstimateMethod,
|
||||
}
|
||||
|
||||
/// Check whether the context window usage exceeds the configured threshold.
|
||||
/// Emits a `Warning` event with kind `"context_window"` when over the
|
||||
/// threshold. Returns `true` if the threshold is exceeded.
|
||||
pub fn check_context_usage(
|
||||
/// threshold. Returns `Some(estimate)` if the threshold is exceeded so the
|
||||
/// caller can pass it to `compact_context` without recomputing.
|
||||
pub(crate) fn check_context_usage(
|
||||
system_prompt: &str,
|
||||
history: &History,
|
||||
provider_profile: &dyn AgentProfile,
|
||||
threshold_percent: usize,
|
||||
emitter: &Emitter,
|
||||
session_id: &str,
|
||||
) -> bool {
|
||||
let estimated_tokens = estimate_token_count(system_prompt, history);
|
||||
) -> Option<ContextEstimate> {
|
||||
let estimate = estimate_active_context_usage(system_prompt, history);
|
||||
let context_window = provider_profile.context_window_size();
|
||||
let threshold = context_window * threshold_percent / 100;
|
||||
|
||||
if estimated_tokens > threshold {
|
||||
if estimate.tokens > threshold {
|
||||
let usage_percent = estimate.tokens.saturating_mul(100) / context_window;
|
||||
let method: &'static str = estimate.method.into();
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::Warning {
|
||||
kind: "context_window".into(),
|
||||
message: format!(
|
||||
"Context window usage: {}%",
|
||||
estimated_tokens * 100 / context_window
|
||||
),
|
||||
message: format!("Context window usage: {usage_percent}%"),
|
||||
details: serde_json::json!({
|
||||
"estimated_tokens": estimated_tokens,
|
||||
"estimated_tokens": estimate.tokens,
|
||||
"context_window_size": context_window,
|
||||
"usage_percent": estimated_tokens * 100 / context_window,
|
||||
"usage_percent": usage_percent,
|
||||
"estimate_method": method,
|
||||
}),
|
||||
});
|
||||
true
|
||||
Some(estimate)
|
||||
} else {
|
||||
false
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,29 +67,29 @@ pub fn check_context_usage(
|
|||
clippy::too_many_arguments,
|
||||
reason = "Context compaction needs explicit history, model, tracking, and emission inputs."
|
||||
)]
|
||||
pub async fn compact_context(
|
||||
pub(crate) async fn compact_context(
|
||||
history: &mut History,
|
||||
llm_client: &Client,
|
||||
provider_profile: &dyn AgentProfile,
|
||||
system_prompt: &str,
|
||||
file_tracker: &FileTracker,
|
||||
preserve_count: usize,
|
||||
estimate: ContextEstimate,
|
||||
emitter: &Emitter,
|
||||
session_id: &str,
|
||||
) -> Result<(), Error> {
|
||||
let estimated_tokens = estimate_token_count(system_prompt, history);
|
||||
let context_window = provider_profile.context_window_size();
|
||||
let original_turn_count = history.turns().len();
|
||||
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::CompactionStarted {
|
||||
estimated_tokens,
|
||||
context_window_size: context_window,
|
||||
});
|
||||
|
||||
// Determine turns to summarize
|
||||
// Determine turns to summarize. If there are not enough turns to compact,
|
||||
// do not emit a started event without a matching completion.
|
||||
if original_turn_count <= preserve_count {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
emitter.emit(session_id.to_owned(), AgentEvent::CompactionStarted {
|
||||
estimated_tokens: estimate.tokens,
|
||||
context_window_size: provider_profile.context_window_size(),
|
||||
});
|
||||
|
||||
let turns_to_summarize = &history.turns()[..original_turn_count - preserve_count];
|
||||
let rendered = render_turns_for_summary(turns_to_summarize);
|
||||
|
||||
|
|
@ -139,7 +155,7 @@ function names, error messages, and exact values. Omit pleasantries and conversa
|
|||
"A different assistant began this task and produced the following summary. \
|
||||
Build on their progress — do not repeat completed steps.\n\n{summary_text}"
|
||||
);
|
||||
let summary_token_estimate = summary_content.len() / 4;
|
||||
let summary_token_estimate = estimate_chars_local_tokens(summary_content.len());
|
||||
|
||||
history.compact(preserve_count, summary_content);
|
||||
|
||||
|
|
@ -153,40 +169,76 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}"
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Estimate the total token count of the system prompt and conversation
|
||||
/// history. Uses a rough heuristic of ~4 characters per token.
|
||||
pub fn estimate_token_count(system_prompt: &str, history: &History) -> usize {
|
||||
let mut total_chars = system_prompt.len();
|
||||
|
||||
for turn in history.turns() {
|
||||
match turn {
|
||||
Message::User { content, .. } => total_chars += content.len(),
|
||||
Message::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
total_chars += content.len();
|
||||
if let Some(r) = turn.reasoning_text() {
|
||||
total_chars += r.len();
|
||||
}
|
||||
for tc in tool_calls {
|
||||
total_chars += tc.name.len();
|
||||
total_chars += tc.arguments.to_string().len();
|
||||
}
|
||||
}
|
||||
Message::ToolResults { results, .. } => {
|
||||
for r in results {
|
||||
total_chars += r.content.to_string().len();
|
||||
}
|
||||
}
|
||||
Message::System { content, .. } | Message::Steering { content, .. } => {
|
||||
total_chars += content.len();
|
||||
}
|
||||
}
|
||||
pub(crate) fn estimate_active_context_usage(
|
||||
system_prompt: &str,
|
||||
history: &History,
|
||||
) -> ContextEstimate {
|
||||
let turns = history.turns();
|
||||
if let Some((baseline_index, baseline_tokens)) = latest_assistant_usage_baseline(turns) {
|
||||
let local_delta = estimate_turns_local_tokens(&turns[baseline_index + 1..]);
|
||||
return ContextEstimate {
|
||||
tokens: baseline_tokens.saturating_add(local_delta),
|
||||
method: ContextEstimateMethod::ApiUsagePlusLocalDelta,
|
||||
};
|
||||
}
|
||||
|
||||
total_chars / 4 // rough estimate: ~4 chars per token
|
||||
ContextEstimate {
|
||||
tokens: estimate_chars_local_tokens(
|
||||
system_prompt
|
||||
.len()
|
||||
.saturating_add(estimate_turns_local_chars(turns)),
|
||||
),
|
||||
method: ContextEstimateMethod::LocalEstimate,
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_assistant_usage_baseline(turns: &[Message]) -> Option<(usize, usize)> {
|
||||
turns.iter().enumerate().rev().find_map(|(index, turn)| {
|
||||
if let Message::Assistant { usage, .. } = turn {
|
||||
let total_tokens = usage.total_tokens();
|
||||
if total_tokens > 0 {
|
||||
return Some((index, usize::try_from(total_tokens).unwrap_or(usize::MAX)));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn estimate_turns_local_tokens(turns: &[Message]) -> usize {
|
||||
estimate_chars_local_tokens(estimate_turns_local_chars(turns))
|
||||
}
|
||||
|
||||
fn estimate_turns_local_chars(turns: &[Message]) -> usize {
|
||||
turns.iter().fold(0usize, |total, turn| {
|
||||
total.saturating_add(estimate_turn_chars(turn))
|
||||
})
|
||||
}
|
||||
|
||||
fn estimate_chars_local_tokens(chars: usize) -> usize {
|
||||
chars / APPROX_CHARS_PER_TOKEN
|
||||
}
|
||||
|
||||
fn estimate_turn_chars(turn: &Message) -> usize {
|
||||
match turn {
|
||||
Message::User { content, .. }
|
||||
| Message::System { content, .. }
|
||||
| Message::Steering { content, .. } => content.len(),
|
||||
Message::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
let reasoning_chars = turn.reasoning_text().map_or(0, str::len);
|
||||
let tool_call_chars: usize = tool_calls
|
||||
.iter()
|
||||
.map(|tc| tc.name.len() + tc.arguments.to_string().len())
|
||||
.sum();
|
||||
content.len() + reasoning_chars + tool_call_chars
|
||||
}
|
||||
Message::ToolResults { results, .. } => {
|
||||
results.iter().map(|r| r.content.to_string().len()).sum()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render conversation turns into a human-readable summary format for the
|
||||
|
|
@ -313,14 +365,176 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_token_count_basic() {
|
||||
fn estimate_local_token_count_basic() {
|
||||
let mut history = History::default();
|
||||
history.push(Message::User {
|
||||
content: "Hello world".into(), // 11 chars
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
// system_prompt = "test" (4 chars) + 11 chars = 15 chars / 4 = 3 tokens
|
||||
assert_eq!(estimate_token_count("test", &history), 3);
|
||||
// system_prompt = "test" (4/4 = 1 token) + 11 chars / 4 = 2 tokens = 3 tokens
|
||||
let estimate = estimate_active_context_usage("test", &history);
|
||||
assert_eq!(estimate.tokens, 3);
|
||||
assert_eq!(estimate.method, ContextEstimateMethod::LocalEstimate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_context_estimate_without_assistant_usage_uses_local_estimate() {
|
||||
let mut history = History::default();
|
||||
history.push(Message::User {
|
||||
content: "Hello world".into(), // 11 chars => 2 tokens
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Message::Assistant {
|
||||
// 18 chars content + tool call name (9) + args (16) = 43 chars => 10 tokens
|
||||
content: "No usage available".into(),
|
||||
tool_calls: vec![ToolCall::new(
|
||||
"call_1",
|
||||
"read_file",
|
||||
serde_json::json!({"path": "foo.rs"}),
|
||||
)],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts::default()),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Message::ToolResults {
|
||||
// 4 chars => 1 token
|
||||
results: vec![ToolResult::success("call_1", serde_json::json!(1234))],
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
|
||||
let estimate = estimate_active_context_usage("test", &history);
|
||||
|
||||
assert_eq!(estimate.method, ContextEstimateMethod::LocalEstimate);
|
||||
// (system prompt 4 + turn chars 11 + 18 + 9 + 16 + 4) / 4 = 62/4 = 15
|
||||
assert_eq!(estimate.tokens, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_context_local_estimate_matches_whole_history_rounding() {
|
||||
let mut history = History::default();
|
||||
history.push(Message::User {
|
||||
content: "abc".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
|
||||
let estimate = estimate_active_context_usage("x", &history);
|
||||
|
||||
assert_eq!(estimate.method, ContextEstimateMethod::LocalEstimate);
|
||||
assert_eq!(estimate.tokens, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_context_estimate_uses_latest_assistant_usage_plus_later_turns() {
|
||||
let mut history = History::default();
|
||||
history.push(Message::User {
|
||||
content: "ignored before baseline".repeat(100),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Message::Assistant {
|
||||
content: "baseline response".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts {
|
||||
input_tokens: 50,
|
||||
..TokenCounts::default()
|
||||
}),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Message::ToolResults {
|
||||
// JSON number renders as 4 chars => 1 local token.
|
||||
results: vec![ToolResult::success("call_1", serde_json::json!(1234))],
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Message::User {
|
||||
// 16 chars => 4 local tokens.
|
||||
content: "u".repeat(16),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Message::Steering {
|
||||
// 8 chars => 2 local tokens.
|
||||
content: "s".repeat(8),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
|
||||
let estimate = estimate_active_context_usage("ignored system prompt", &history);
|
||||
|
||||
assert_eq!(estimate.tokens, 57);
|
||||
assert_eq!(
|
||||
estimate.method,
|
||||
ContextEstimateMethod::ApiUsagePlusLocalDelta
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_context_estimate_uses_total_tokens_including_cache_and_reasoning() {
|
||||
let mut history = History::default();
|
||||
history.push(Message::Assistant {
|
||||
content: "short".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
reasoning_tokens: 30,
|
||||
cache_read_tokens: 40,
|
||||
cache_write_tokens: 50,
|
||||
}),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
|
||||
let estimate = estimate_active_context_usage("", &history);
|
||||
|
||||
assert_eq!(estimate.tokens, 150);
|
||||
assert_eq!(
|
||||
estimate.method,
|
||||
ContextEstimateMethod::ApiUsagePlusLocalDelta
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_context_estimate_ignores_earlier_usage_when_later_usage_exists() {
|
||||
let mut history = History::default();
|
||||
history.push(Message::Assistant {
|
||||
content: "older response".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts {
|
||||
input_tokens: 1_000,
|
||||
..TokenCounts::default()
|
||||
}),
|
||||
response_id: "resp_old".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Message::User {
|
||||
content: "ignored before latest baseline".repeat(100),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Message::Assistant {
|
||||
content: "latest response".into(),
|
||||
tool_calls: vec![],
|
||||
provider_parts: vec![],
|
||||
usage: Box::new(TokenCounts {
|
||||
input_tokens: 20,
|
||||
..TokenCounts::default()
|
||||
}),
|
||||
response_id: "resp_new".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
history.push(Message::User {
|
||||
content: "u".repeat(8),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
|
||||
let estimate = estimate_active_context_usage("", &history);
|
||||
|
||||
assert_eq!(estimate.tokens, 22);
|
||||
assert_eq!(
|
||||
estimate.method,
|
||||
ContextEstimateMethod::ApiUsagePlusLocalDelta
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -329,8 +543,8 @@ mod tests {
|
|||
let emitter = Emitter::new();
|
||||
let profile = TestProfile::new();
|
||||
// Empty history, huge context window => well below threshold
|
||||
let over = check_context_usage("short", &history, &profile, 80, &emitter, "sess");
|
||||
assert!(!over);
|
||||
let result = check_context_usage("short", &history, &profile, 80, &emitter, "sess");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -345,11 +559,12 @@ mod tests {
|
|||
let mut rx = emitter.subscribe();
|
||||
// TestProfile has context_window=200_000 by default; use a small one
|
||||
let profile = TestProfile::with_context_window(ToolRegistry::new(), 100);
|
||||
let over = check_context_usage("prompt", &history, &profile, 80, &emitter, "sess");
|
||||
assert!(over);
|
||||
let result = check_context_usage("prompt", &history, &profile, 80, &emitter, "sess");
|
||||
assert!(result.is_some());
|
||||
|
||||
// Should have emitted a Warning
|
||||
let event = rx.try_recv().unwrap();
|
||||
assert!(matches!(event.event, AgentEvent::Warning { .. }));
|
||||
assert!(matches!(event.event, AgentEvent::Warning { details, .. }
|
||||
if details["estimate_method"] == "local_estimate"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use fabro_llm::types::{ContentPart, Message as LlmMessage, Role};
|
||||
use fabro_llm::types::{ContentPart, Message as LlmMessage, Role, TokenCounts};
|
||||
use fabro_types::SessionMessage;
|
||||
|
||||
use crate::types::Message;
|
||||
|
|
@ -32,11 +32,17 @@ impl History {
|
|||
self.turns.iter().map(Message::to_session_message).collect()
|
||||
}
|
||||
|
||||
/// Compact the history by replacing all but the trailing `preserve_count`
|
||||
/// turns with a summary `System` message. Preserved assistant turns have
|
||||
/// their `usage` reset to default so a later context-window estimate does
|
||||
/// not treat pre-compaction provider-reported usage as the new baseline;
|
||||
/// authoritative billing is recorded via emitted run events.
|
||||
pub fn compact(&mut self, preserve_count: usize, summary: String) {
|
||||
if self.turns.len() <= preserve_count {
|
||||
return;
|
||||
}
|
||||
let preserved = self.turns.split_off(self.turns.len() - preserve_count);
|
||||
let mut preserved = self.turns.split_off(self.turns.len() - preserve_count);
|
||||
Self::invalidate_preserved_usage(&mut preserved);
|
||||
let discarded = std::mem::take(&mut self.turns);
|
||||
let extracted_user_messages =
|
||||
extract_recent_user_messages(discarded, COMPACTION_USER_MESSAGE_TOKEN_BUDGET);
|
||||
|
|
@ -49,6 +55,14 @@ impl History {
|
|||
self.strip_opaque_provider_items();
|
||||
}
|
||||
|
||||
fn invalidate_preserved_usage(preserved: &mut [Message]) {
|
||||
for turn in preserved {
|
||||
if let Message::Assistant { usage, .. } = turn {
|
||||
**usage = TokenCounts::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove provider-specific opaque items that are no longer valid after
|
||||
/// compaction. OpenAI reasoning and message items are opaque round-trip
|
||||
/// data tied to specific API responses; after compaction replaces their
|
||||
|
|
@ -599,6 +613,60 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_preserves_assistant_data_but_resets_usage() {
|
||||
let mut history = History::default();
|
||||
history.push(Message::User {
|
||||
content: "old msg".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
let tool_call = ToolCall::new("call_1", "search", serde_json::json!({"query": "fabro"}));
|
||||
let thinking = ContentPart::Thinking(ThinkingData {
|
||||
text: "deep thought".into(),
|
||||
signature: Some("sig_xyz".into()),
|
||||
redacted: false,
|
||||
});
|
||||
history.push(Message::Assistant {
|
||||
content: "answer".into(),
|
||||
tool_calls: vec![tool_call.clone()],
|
||||
provider_parts: vec![thinking.clone()],
|
||||
usage: Box::new(TokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
reasoning_tokens: 30,
|
||||
cache_read_tokens: 40,
|
||||
cache_write_tokens: 50,
|
||||
}),
|
||||
response_id: "resp_1".into(),
|
||||
timestamp: SystemTime::now(),
|
||||
});
|
||||
|
||||
history.compact(1, "Summary".into());
|
||||
|
||||
let assistant_turn = history
|
||||
.turns()
|
||||
.iter()
|
||||
.find(|turn| matches!(turn, Message::Assistant { .. }))
|
||||
.expect("preserved assistant turn");
|
||||
if let Message::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
provider_parts,
|
||||
usage,
|
||||
response_id,
|
||||
..
|
||||
} = assistant_turn
|
||||
{
|
||||
assert_eq!(content, "answer");
|
||||
assert_eq!(tool_calls, &[tool_call]);
|
||||
assert_eq!(provider_parts, &[thinking]);
|
||||
assert_eq!(response_id, "resp_1");
|
||||
assert_eq!(**usage, TokenCounts::default());
|
||||
} else {
|
||||
panic!("expected Assistant turn");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_strips_reasoning_from_all_preserved_assistant_turns() {
|
||||
let mut history = History::default();
|
||||
|
|
|
|||
|
|
@ -1670,31 +1670,34 @@ impl Session {
|
|||
}
|
||||
|
||||
async fn compact_if_needed(&mut self) {
|
||||
let over_threshold = check_context_usage(
|
||||
let Some(estimate) = check_context_usage(
|
||||
&self.system_prompt,
|
||||
&self.history,
|
||||
self.provider_profile.as_ref(),
|
||||
self.config.compaction_threshold_percent,
|
||||
&self.event_emitter,
|
||||
&self.id,
|
||||
);
|
||||
if over_threshold && self.config.enable_context_compaction {
|
||||
if let Err(e) = compact_context(
|
||||
&mut self.history,
|
||||
&self.llm_client,
|
||||
self.provider_profile.as_ref(),
|
||||
&self.system_prompt,
|
||||
&self.file_tracker,
|
||||
self.config.compaction_preserve_turns,
|
||||
&self.event_emitter,
|
||||
&self.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.event_emitter.emit(self.id.clone(), AgentEvent::Error {
|
||||
error: Error::InvalidState(format!("Context compaction failed: {e}")),
|
||||
});
|
||||
}
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if !self.config.enable_context_compaction {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = compact_context(
|
||||
&mut self.history,
|
||||
&self.llm_client,
|
||||
self.provider_profile.as_ref(),
|
||||
&self.file_tracker,
|
||||
self.config.compaction_preserve_turns,
|
||||
estimate,
|
||||
&self.event_emitter,
|
||||
&self.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.event_emitter.emit(self.id.clone(), AgentEvent::Error {
|
||||
error: Error::InvalidState(format!("Context compaction failed: {e}")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1853,7 +1856,7 @@ mod tests {
|
|||
use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind};
|
||||
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
|
||||
use fabro_llm::types::{
|
||||
ContentPart, ReasoningEffort, Request, Response, Role, StreamEvent, ToolCall,
|
||||
ContentPart, ReasoningEffort, Request, Response, Role, StreamEvent, TokenCounts, ToolCall,
|
||||
ToolDefinition,
|
||||
};
|
||||
use futures::stream;
|
||||
|
|
@ -3653,13 +3656,25 @@ mod tests {
|
|||
assert!(found_auth_error_event, "expected auth error event");
|
||||
}
|
||||
|
||||
fn response_with_usage(mut response: Response, usage: TokenCounts) -> Response {
|
||||
response.usage = usage;
|
||||
response
|
||||
}
|
||||
|
||||
fn response_with_input_tokens(response: Response, input_tokens: i64) -> Response {
|
||||
response_with_usage(response, TokenCounts {
|
||||
input_tokens,
|
||||
..TokenCounts::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_triggered_when_over_threshold() {
|
||||
// Tiny context window to trigger compaction
|
||||
// Responses: [0] conversation response (stream), [1] summarization (complete),
|
||||
// [2] unused fallback
|
||||
let responses = vec![
|
||||
text_response("OK"),
|
||||
response_with_usage(text_response("OK"), TokenCounts::default()),
|
||||
text_response("Here is the summary of the conversation so far."),
|
||||
text_response("fallback"),
|
||||
];
|
||||
|
|
@ -3704,6 +3719,90 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_uses_assistant_usage_baseline_for_short_response() {
|
||||
let responses = vec![
|
||||
response_with_input_tokens(text_response("OK"), 90),
|
||||
text_response("Here is the summary of the conversation so far."),
|
||||
text_response("fallback"),
|
||||
];
|
||||
|
||||
let provider = Arc::new(MockLlmProvider::new(responses));
|
||||
let client = make_client(provider).await;
|
||||
let registry = ToolRegistry::new();
|
||||
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
|
||||
let env = Arc::new(MockSandbox::default());
|
||||
let config = SessionOptions {
|
||||
enable_context_compaction: true,
|
||||
compaction_preserve_turns: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = Session::new(client, profile, env, config, None);
|
||||
let mut rx = session.subscribe();
|
||||
|
||||
session.process_input("hi").await.unwrap();
|
||||
|
||||
let mut started = None;
|
||||
let mut found_completed = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event.event {
|
||||
AgentEvent::CompactionStarted {
|
||||
estimated_tokens,
|
||||
context_window_size,
|
||||
} => started = Some((estimated_tokens, context_window_size)),
|
||||
AgentEvent::CompactionCompleted { .. } => found_completed = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(started, Some((90, 100)));
|
||||
assert!(
|
||||
found_completed,
|
||||
"CompactionCompleted event should be emitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_noop_does_not_emit_started() {
|
||||
let large_input = "x".repeat(400);
|
||||
let responses = vec![text_response("OK")];
|
||||
|
||||
let provider = Arc::new(MockLlmProvider::new(responses));
|
||||
let client = make_client(provider).await;
|
||||
let registry = ToolRegistry::new();
|
||||
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
|
||||
let env = Arc::new(MockSandbox::default());
|
||||
let config = SessionOptions {
|
||||
enable_context_compaction: true,
|
||||
compaction_preserve_turns: 10,
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = Session::new(client, profile, env, config, None);
|
||||
let mut rx = session.subscribe();
|
||||
|
||||
session.process_input(&large_input).await.unwrap();
|
||||
|
||||
let mut found_warning = false;
|
||||
let mut found_compaction = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event.event {
|
||||
AgentEvent::Warning { kind, .. } if kind == "context_window" => {
|
||||
found_warning = true;
|
||||
}
|
||||
AgentEvent::CompactionStarted { .. } | AgentEvent::CompactionCompleted { .. } => {
|
||||
found_compaction = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(found_warning, "threshold should have been exceeded");
|
||||
assert!(
|
||||
!found_compaction,
|
||||
"no-op compaction should not emit started or completed events"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_not_triggered_when_disabled() {
|
||||
let large_input = "x".repeat(400);
|
||||
|
|
@ -3735,6 +3834,49 @@ mod tests {
|
|||
assert!(!found_compaction, "No compaction events when disabled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_disabled_blocks_api_usage_baseline_compaction() {
|
||||
let responses = vec![response_with_input_tokens(text_response("OK"), 90)];
|
||||
|
||||
let provider = Arc::new(MockLlmProvider::new(responses));
|
||||
let client = make_client(provider).await;
|
||||
let registry = ToolRegistry::new();
|
||||
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
|
||||
let env = Arc::new(MockSandbox::default());
|
||||
let config = SessionOptions {
|
||||
enable_context_compaction: false,
|
||||
compaction_preserve_turns: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = Session::new(client, profile, env, config, None);
|
||||
let mut rx = session.subscribe();
|
||||
|
||||
session.process_input("hi").await.unwrap();
|
||||
|
||||
let mut found_api_usage_warning = false;
|
||||
let mut found_compaction = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event.event {
|
||||
AgentEvent::Warning { details, .. }
|
||||
if details["estimated_tokens"] == 90
|
||||
&& details["estimate_method"] == "api_usage_plus_local_delta" =>
|
||||
{
|
||||
found_api_usage_warning = true;
|
||||
}
|
||||
AgentEvent::CompactionStarted { .. } | AgentEvent::CompactionCompleted { .. } => {
|
||||
found_compaction = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
found_api_usage_warning,
|
||||
"API usage baseline should still drive context warning"
|
||||
);
|
||||
assert!(!found_compaction, "compaction must remain disabled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_failure_is_non_fatal() {
|
||||
// Response [0] = conversation response (stream), [1] will be used for
|
||||
|
|
@ -3789,7 +3931,10 @@ mod tests {
|
|||
}
|
||||
|
||||
let large_input = "x".repeat(400);
|
||||
let responses = vec![text_response("OK")];
|
||||
let responses = vec![response_with_usage(
|
||||
text_response("OK"),
|
||||
TokenCounts::default(),
|
||||
)];
|
||||
|
||||
let provider = Arc::new(StreamOnlyProvider {
|
||||
responses,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue