Claude commited on
Commit
e2dea69
·
unverified ·
1 Parent(s): 04e34a2

Add artifact-immutability lock at the grid boundary

Browse files

Nodes 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 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
- setTab("console");
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
+ }