Claude commited on
Add artifact-immutability lock at the grid boundary
Browse filesNodes get a SHA-256 checksum + byte-size + LOC snapshot locked in as
they render in the React Flow grid. Before a test run can use that
data it re-verifies against the lock and refuses to proceed on a
mismatch; the lock stays checked-out (with a visible timeout) until
the run returns and re-arms it. Jessica's reference photo is a fixed
UI asset, not a pipeline node, so it never enters the checkout cycle.
- src/app/testbench/Testbench.tsx +127 -7
- src/lib/integrity.ts +64 -0
src/app/testbench/Testbench.tsx
CHANGED
|
@@ -2,11 +2,12 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useRef, useState } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
-
import { ArrowLeft, Cpu, ExternalLink, FolderGit2, MessageSquareText, Play, TerminalSquare } from "lucide-react";
|
| 6 |
import PipelineGraph from "@/components/PipelineGraph";
|
| 7 |
import ExpertChat from "@/components/ExpertChat";
|
| 8 |
import { berylPipeline } from "@/lib/beryl-pipeline";
|
| 9 |
import { buildSimulatedRunScript, TEST_RUN_BUDGET_MS, type SandboxLogLine } from "@/lib/testbench-log";
|
|
|
|
| 10 |
|
| 11 |
type Target = {
|
| 12 |
kind: "hf-space" | "github";
|
|
@@ -17,7 +18,7 @@ type Target = {
|
|
| 17 |
ok: boolean;
|
| 18 |
};
|
| 19 |
|
| 20 |
-
type RunState = "idle" | "running" | "done";
|
| 21 |
|
| 22 |
export default function Testbench() {
|
| 23 |
const [targets, setTargets] = useState<Target[] | null>(null);
|
|
@@ -33,6 +34,14 @@ export default function Testbench() {
|
|
| 33 |
const [elapsedMs, setElapsedMs] = useState(0);
|
| 34 |
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
| 35 |
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
useEffect(() => {
|
| 38 |
fetch("/api/testbench")
|
|
@@ -43,9 +52,15 @@ export default function Testbench() {
|
|
| 43 |
})
|
| 44 |
.catch((err) => setTargetsError(err instanceof Error ? err.message : "Failed to load test targets"));
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return () => {
|
| 47 |
timers.current.forEach(clearTimeout);
|
| 48 |
if (tickRef.current) clearInterval(tickRef.current);
|
|
|
|
| 49 |
};
|
| 50 |
}, []);
|
| 51 |
|
|
@@ -61,19 +76,45 @@ export default function Testbench() {
|
|
| 61 |
);
|
| 62 |
}
|
| 63 |
|
| 64 |
-
function runTest() {
|
| 65 |
if (runState === "running") return;
|
| 66 |
timers.current.forEach(clearTimeout);
|
| 67 |
if (tickRef.current) clearInterval(tickRef.current);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
const target = targets?.[activeTargetIdx];
|
| 70 |
const label = target ? `${target.owner}/${target.name}` : "reference target";
|
| 71 |
const script = buildSimulatedRunScript(berylPipeline, label);
|
| 72 |
|
| 73 |
-
setLog([
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
setElapsedMs(0);
|
| 75 |
setRunState("running");
|
| 76 |
-
|
| 77 |
|
| 78 |
const start = Date.now();
|
| 79 |
tickRef.current = setInterval(() => setElapsedMs(Date.now() - start), 200);
|
|
@@ -85,15 +126,48 @@ export default function Testbench() {
|
|
| 85 |
timers.current.push(timer);
|
| 86 |
});
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
const finishAt = script[script.length - 1]?.atMs ?? 0;
|
| 89 |
-
const finishTimer = setTimeout(() => {
|
| 90 |
setRunState("done");
|
| 91 |
if (tickRef.current) clearInterval(tickRef.current);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}, finishAt + 300);
|
| 93 |
timers.current.push(finishTimer);
|
| 94 |
}
|
| 95 |
|
| 96 |
const budgetPct = Math.min(100, (elapsedMs / TEST_RUN_BUDGET_MS) * 100);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
return (
|
| 99 |
<div className="flex-1 flex flex-col h-[100dvh]">
|
|
@@ -216,10 +290,50 @@ export default function Testbench() {
|
|
| 216 |
|
| 217 |
{tab === "console" && (
|
| 218 |
<div className="p-4 flex flex-col gap-3 h-full">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
<div className="flex items-center gap-2">
|
| 220 |
<button
|
| 221 |
onClick={runTest}
|
| 222 |
-
disabled={runState === "running" || !targets}
|
| 223 |
className="deep-dive-btn rounded-full px-3 py-1.5 text-[11px] flex items-center gap-1.5 disabled:opacity-50"
|
| 224 |
>
|
| 225 |
<Play size={11} /> {runState === "running" ? "Running..." : "Run Test"}
|
|
@@ -286,6 +400,12 @@ export default function Testbench() {
|
|
| 286 |
<span className="text-[9.5px] text-[var(--muted)] text-center leading-relaxed">
|
| 287 |
Jessica — the source identity driving Vision + Avatar in this test run.
|
| 288 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
</aside>
|
| 290 |
</div>
|
| 291 |
</div>
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useRef, useState } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
+
import { ArrowLeft, Cpu, ExternalLink, FolderGit2, Lock, MessageSquareText, Play, ShieldAlert, TerminalSquare, Unlock } from "lucide-react";
|
| 6 |
import PipelineGraph from "@/components/PipelineGraph";
|
| 7 |
import ExpertChat from "@/components/ExpertChat";
|
| 8 |
import { berylPipeline } from "@/lib/beryl-pipeline";
|
| 9 |
import { buildSimulatedRunScript, TEST_RUN_BUDGET_MS, type SandboxLogLine } from "@/lib/testbench-log";
|
| 10 |
+
import { snapshotArtifact, DEFAULT_LOCK_TIMEOUT_MS, type ArtifactSnapshot, type LockStatus } from "@/lib/integrity";
|
| 11 |
|
| 12 |
type Target = {
|
| 13 |
kind: "hf-space" | "github";
|
|
|
|
| 18 |
ok: boolean;
|
| 19 |
};
|
| 20 |
|
| 21 |
+
type RunState = "idle" | "running" | "done" | "blocked";
|
| 22 |
|
| 23 |
export default function Testbench() {
|
| 24 |
const [targets, setTargets] = useState<Target[] | null>(null);
|
|
|
|
| 34 |
const [elapsedMs, setElapsedMs] = useState(0);
|
| 35 |
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
| 36 |
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
| 37 |
+
const lockTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 38 |
+
|
| 39 |
+
// Artifact-immutability lock at the React Flow (grid) boundary: a checksum
|
| 40 |
+
// is locked in as soon as nodes render "at the front." Before they leave
|
| 41 |
+
// for a test run, the lock is re-verified; it stays checked-out until the
|
| 42 |
+
// run returns or DEFAULT_LOCK_TIMEOUT_MS elapses without returning.
|
| 43 |
+
const [lockSnapshot, setLockSnapshot] = useState<ArtifactSnapshot | null>(null);
|
| 44 |
+
const [lockStatus, setLockStatus] = useState<LockStatus>("locked");
|
| 45 |
|
| 46 |
useEffect(() => {
|
| 47 |
fetch("/api/testbench")
|
|
|
|
| 52 |
})
|
| 53 |
.catch((err) => setTargetsError(err instanceof Error ? err.message : "Failed to load test targets"));
|
| 54 |
|
| 55 |
+
snapshotArtifact(berylPipeline.nodes).then((snap) => {
|
| 56 |
+
setLockSnapshot(snap);
|
| 57 |
+
setLockStatus("locked");
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
return () => {
|
| 61 |
timers.current.forEach(clearTimeout);
|
| 62 |
if (tickRef.current) clearInterval(tickRef.current);
|
| 63 |
+
if (lockTimeoutRef.current) clearTimeout(lockTimeoutRef.current);
|
| 64 |
};
|
| 65 |
}, []);
|
| 66 |
|
|
|
|
| 76 |
);
|
| 77 |
}
|
| 78 |
|
| 79 |
+
async function runTest() {
|
| 80 |
if (runState === "running") return;
|
| 81 |
timers.current.forEach(clearTimeout);
|
| 82 |
if (tickRef.current) clearInterval(tickRef.current);
|
| 83 |
+
if (lockTimeoutRef.current) clearTimeout(lockTimeoutRef.current);
|
| 84 |
+
|
| 85 |
+
setTab("console");
|
| 86 |
+
|
| 87 |
+
// Re-verify the lock right at the boundary where nodes leave the grid.
|
| 88 |
+
const fresh = await snapshotArtifact(berylPipeline.nodes);
|
| 89 |
+
if (!lockSnapshot || fresh.checksum !== lockSnapshot.checksum) {
|
| 90 |
+
setLockStatus("violated");
|
| 91 |
+
setRunState("blocked");
|
| 92 |
+
setLog([
|
| 93 |
+
{
|
| 94 |
+
atMs: 0,
|
| 95 |
+
level: "warn",
|
| 96 |
+
text: `BLOCKED — artifact checksum mismatch at the grid boundary. Locked ${
|
| 97 |
+
lockSnapshot?.checksum.slice(0, 12) ?? "n/a"
|
| 98 |
+
}… vs current ${fresh.checksum.slice(0, 12)}…. Nodes were modified after the lock; run refused.`,
|
| 99 |
+
},
|
| 100 |
+
]);
|
| 101 |
+
return;
|
| 102 |
+
}
|
| 103 |
|
| 104 |
const target = targets?.[activeTargetIdx];
|
| 105 |
const label = target ? `${target.owner}/${target.name}` : "reference target";
|
| 106 |
const script = buildSimulatedRunScript(berylPipeline, label);
|
| 107 |
|
| 108 |
+
setLog([
|
| 109 |
+
{
|
| 110 |
+
atMs: 0,
|
| 111 |
+
level: "ok",
|
| 112 |
+
text: `Artifact lock verified — sha256:${fresh.checksum.slice(0, 16)}… · ${fresh.byteSize}B · ${fresh.loc} LOC · ${fresh.nodeCount} nodes. Checked out from the front for this run.`,
|
| 113 |
+
},
|
| 114 |
+
]);
|
| 115 |
setElapsedMs(0);
|
| 116 |
setRunState("running");
|
| 117 |
+
setLockStatus("checked-out");
|
| 118 |
|
| 119 |
const start = Date.now();
|
| 120 |
tickRef.current = setInterval(() => setElapsedMs(Date.now() - start), 200);
|
|
|
|
| 126 |
timers.current.push(timer);
|
| 127 |
});
|
| 128 |
|
| 129 |
+
lockTimeoutRef.current = setTimeout(() => {
|
| 130 |
+
setLockStatus((s) => (s === "checked-out" ? "expired" : s));
|
| 131 |
+
setLog((prev) => [
|
| 132 |
+
...prev,
|
| 133 |
+
{
|
| 134 |
+
atMs: DEFAULT_LOCK_TIMEOUT_MS,
|
| 135 |
+
level: "warn",
|
| 136 |
+
text: `Artifact lock expired after ${DEFAULT_LOCK_TIMEOUT_MS / 1000}s away from the front without returning.`,
|
| 137 |
+
},
|
| 138 |
+
]);
|
| 139 |
+
}, DEFAULT_LOCK_TIMEOUT_MS);
|
| 140 |
+
|
| 141 |
const finishAt = script[script.length - 1]?.atMs ?? 0;
|
| 142 |
+
const finishTimer = setTimeout(async () => {
|
| 143 |
setRunState("done");
|
| 144 |
if (tickRef.current) clearInterval(tickRef.current);
|
| 145 |
+
if (lockTimeoutRef.current) clearTimeout(lockTimeoutRef.current);
|
| 146 |
+
const returned = await snapshotArtifact(berylPipeline.nodes);
|
| 147 |
+
setLockSnapshot(returned);
|
| 148 |
+
setLockStatus("locked");
|
| 149 |
+
setLog((prev) => [
|
| 150 |
+
...prev,
|
| 151 |
+
{
|
| 152 |
+
atMs: finishAt + 200,
|
| 153 |
+
level: "ok",
|
| 154 |
+
text: `Nodes returned to the front — lock re-armed at sha256:${returned.checksum.slice(0, 16)}….`,
|
| 155 |
+
},
|
| 156 |
+
]);
|
| 157 |
}, finishAt + 300);
|
| 158 |
timers.current.push(finishTimer);
|
| 159 |
}
|
| 160 |
|
| 161 |
const budgetPct = Math.min(100, (elapsedMs / TEST_RUN_BUDGET_MS) * 100);
|
| 162 |
+
const lockRemainingMs = Math.max(0, DEFAULT_LOCK_TIMEOUT_MS - elapsedMs);
|
| 163 |
+
|
| 164 |
+
const LOCK_STATUS_LABEL: Record<LockStatus, string> = {
|
| 165 |
+
locked: "LOCKED",
|
| 166 |
+
"checked-out": "CHECKED OUT",
|
| 167 |
+
returned: "RETURNED",
|
| 168 |
+
expired: "EXPIRED",
|
| 169 |
+
violated: "VIOLATED",
|
| 170 |
+
};
|
| 171 |
|
| 172 |
return (
|
| 173 |
<div className="flex-1 flex flex-col h-[100dvh]">
|
|
|
|
| 290 |
|
| 291 |
{tab === "console" && (
|
| 292 |
<div className="p-4 flex flex-col gap-3 h-full">
|
| 293 |
+
<div
|
| 294 |
+
className={`rounded-lg border px-2.5 py-2 flex flex-col gap-1 ${
|
| 295 |
+
lockStatus === "expired" || lockStatus === "violated"
|
| 296 |
+
? "border-red-400/40 bg-red-500/5"
|
| 297 |
+
: lockStatus === "checked-out"
|
| 298 |
+
? "border-amber-400/40 bg-amber-500/5"
|
| 299 |
+
: "border-emerald-400/30 bg-emerald-500/5"
|
| 300 |
+
}`}
|
| 301 |
+
>
|
| 302 |
+
<div className="flex items-center gap-1.5 text-[10px] font-semibold">
|
| 303 |
+
{lockStatus === "locked" && <Lock size={11} className="text-emerald-300" />}
|
| 304 |
+
{lockStatus === "checked-out" && <Unlock size={11} className="text-amber-300" />}
|
| 305 |
+
{(lockStatus === "expired" || lockStatus === "violated") && (
|
| 306 |
+
<ShieldAlert size={11} className="text-red-300" />
|
| 307 |
+
)}
|
| 308 |
+
<span
|
| 309 |
+
className={
|
| 310 |
+
lockStatus === "expired" || lockStatus === "violated"
|
| 311 |
+
? "text-red-300"
|
| 312 |
+
: lockStatus === "checked-out"
|
| 313 |
+
? "text-amber-300"
|
| 314 |
+
: "text-emerald-300"
|
| 315 |
+
}
|
| 316 |
+
>
|
| 317 |
+
ARTIFACT {LOCK_STATUS_LABEL[lockStatus]}
|
| 318 |
+
</span>
|
| 319 |
+
{lockStatus === "checked-out" && (
|
| 320 |
+
<span className="ml-auto text-[9px] text-[var(--muted)]">
|
| 321 |
+
{(lockRemainingMs / 1000).toFixed(0)}s until timeout
|
| 322 |
+
</span>
|
| 323 |
+
)}
|
| 324 |
+
</div>
|
| 325 |
+
{lockSnapshot && (
|
| 326 |
+
<span className="text-[9.5px] text-[var(--muted)] font-mono">
|
| 327 |
+
sha256:{lockSnapshot.checksum.slice(0, 16)}… · {lockSnapshot.byteSize}B · {lockSnapshot.loc} LOC ·{" "}
|
| 328 |
+
{lockSnapshot.nodeCount} nodes
|
| 329 |
+
</span>
|
| 330 |
+
)}
|
| 331 |
+
</div>
|
| 332 |
+
|
| 333 |
<div className="flex items-center gap-2">
|
| 334 |
<button
|
| 335 |
onClick={runTest}
|
| 336 |
+
disabled={runState === "running" || !targets || !lockSnapshot}
|
| 337 |
className="deep-dive-btn rounded-full px-3 py-1.5 text-[11px] flex items-center gap-1.5 disabled:opacity-50"
|
| 338 |
>
|
| 339 |
<Play size={11} /> {runState === "running" ? "Running..." : "Run Test"}
|
|
|
|
| 400 |
<span className="text-[9.5px] text-[var(--muted)] text-center leading-relaxed">
|
| 401 |
Jessica — the source identity driving Vision + Avatar in this test run.
|
| 402 |
</span>
|
| 403 |
+
<span className="flex items-center gap-1 text-[9px] font-semibold text-emerald-300 border border-emerald-400/30 rounded-full px-2 py-0.5">
|
| 404 |
+
<Lock size={9} /> ANCHORED — NEVER CHECKED OUT
|
| 405 |
+
</span>
|
| 406 |
+
<span className="text-[9px] text-[var(--muted)] text-center leading-relaxed">
|
| 407 |
+
Only pipeline nodes cross the grid boundary and get locked/verified. Jessica stays fixed at the front through every run.
|
| 408 |
+
</span>
|
| 409 |
</aside>
|
| 410 |
</div>
|
| 411 |
</div>
|
src/lib/integrity.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PipelineNode } from "./types";
|
| 2 |
+
|
| 3 |
+
// Artifact-immutability gate for the boundary between the React Flow grid
|
| 4 |
+
// (nodes "at the front") and the Test Bench execution console. A snapshot is
|
| 5 |
+
// locked the moment nodes render into the grid; before any run is allowed to
|
| 6 |
+
// use that data, a fresh snapshot is recomputed and compared. A mismatch
|
| 7 |
+
// blocks the run rather than silently proceeding on stale/altered data.
|
| 8 |
+
|
| 9 |
+
export type ArtifactSnapshot = {
|
| 10 |
+
checksum: string;
|
| 11 |
+
byteSize: number;
|
| 12 |
+
loc: number;
|
| 13 |
+
nodeCount: number;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export type LockStatus = "locked" | "checked-out" | "returned" | "expired" | "violated";
|
| 17 |
+
|
| 18 |
+
// Placeholder until the real duration is specified — how long a checked-out
|
| 19 |
+
// artifact may stay away from the front before its lock is treated as expired.
|
| 20 |
+
export const DEFAULT_LOCK_TIMEOUT_MS = 180_000;
|
| 21 |
+
|
| 22 |
+
function canonicalize(nodes: PipelineNode[]): string {
|
| 23 |
+
const stable = nodes
|
| 24 |
+
.map((n) => ({
|
| 25 |
+
id: n.id,
|
| 26 |
+
label: n.label,
|
| 27 |
+
category: n.category,
|
| 28 |
+
summary: n.summary,
|
| 29 |
+
handoffOut: n.handoffOut ?? null,
|
| 30 |
+
expertNote: n.expertNote,
|
| 31 |
+
confidence: n.confidence ?? null,
|
| 32 |
+
implementations: n.implementations,
|
| 33 |
+
}))
|
| 34 |
+
.sort((a, b) => a.id.localeCompare(b.id));
|
| 35 |
+
return JSON.stringify(stable);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// No literal source files exist per node, so LOC is a defensible proxy: the
|
| 39 |
+
// count of non-empty lines across each node's authored text fields.
|
| 40 |
+
function countLoc(nodes: PipelineNode[]): number {
|
| 41 |
+
return nodes.reduce((sum, n) => {
|
| 42 |
+
const text = [n.summary, n.expertNote, n.handoffOut ?? ""].join("\n");
|
| 43 |
+
return sum + text.split("\n").filter((line) => line.trim().length > 0).length;
|
| 44 |
+
}, 0);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async function sha256Hex(text: string): Promise<string> {
|
| 48 |
+
const bytes = new TextEncoder().encode(text);
|
| 49 |
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
| 50 |
+
return Array.from(new Uint8Array(digest))
|
| 51 |
+
.map((b) => b.toString(16).padStart(2, "0"))
|
| 52 |
+
.join("");
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export async function snapshotArtifact(nodes: PipelineNode[]): Promise<ArtifactSnapshot> {
|
| 56 |
+
const canon = canonicalize(nodes);
|
| 57 |
+
const checksum = await sha256Hex(canon);
|
| 58 |
+
return {
|
| 59 |
+
checksum,
|
| 60 |
+
byteSize: new TextEncoder().encode(canon).length,
|
| 61 |
+
loc: countLoc(nodes),
|
| 62 |
+
nodeCount: nodes.length,
|
| 63 |
+
};
|
| 64 |
+
}
|