From 3fecb28b46c95836345067f2d043aa22dc0dd3f5 Mon Sep 17 00:00:00 2001
From: arko33
Date: Wed, 21 Oct 2026 08:02:56 +0700
Subject: [PATCH] feat: record a real session and replay it in the playground
The recorder closes the loop the landing page was already claiming:
- src/collector/record.ts samples /api/health and snapshots every change burst
- shadow-git stages into a throwaway index and writes commit objects with
commit-tree, so HEAD, the branch and the user's index are never touched
- npm run record writes session.json
- /playground accepts that file and replays it: timeline, diff, 3D city, autopsy
Verified locally: a 14s recording against the deployed app produced 2 turns with
real diffs and a clean metric stream.
---
.gitignore | 2 +
README.md | 22 ++++
app/page.tsx | 8 ++
app/playground/page.tsx | 10 +-
package.json | 3 +-
scripts/record.ts | 41 +++++++
src/collector/record.ts | 167 ++++++++++++++++++++++++++++
src/collector/shadow-git.ts | 77 +++++++++++++
src/components/Dashboard.tsx | 36 +++++-
src/components/PlaygroundClient.tsx | 73 ++++++++++++
tsconfig.json | 1 +
11 files changed, 432 insertions(+), 8 deletions(-)
create mode 100644 scripts/record.ts
create mode 100644 src/collector/record.ts
create mode 100644 src/components/PlaygroundClient.tsx
diff --git a/.gitignore b/.gitignore
index e964959..87f22a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,5 +30,7 @@ yarn-error.log*
# recorded sessions (repo root only — src/data is source)
/data/
+/session.json
+/session-*.json
.vercel
diff --git a/README.md b/README.md
index 2917826..2dfb76f 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,27 @@ flag.
---
+## Record your own session
+
+The deployed dashboard replays a recorded session. Recording runs where your code
+runs, because it shells out to git and polls your process:
+
+```bash
+npm run dev # terminal 1
+npm run record -- --repo . \
+ --health http://localhost:3000/api/health \
+ --duration 60 # terminal 2
+```
+
+It writes `session.json`. Open `/playground`, click **load session**, and pick the
+file. The timeline, the diff, the 3D city and the autopsy are then built from your
+own repository instead of the demo fixture.
+
+How it stays factual: every change burst is staged into a throwaway git index and
+written as a commit object **without moving `HEAD` or touching your branch**, so
+the diff is what actually landed on disk. Metrics come from the app's
+`/api/health`, not from what the agent says it did.
+
## Architecture
```
@@ -204,6 +225,7 @@ it runs the suite with the official CLI on every pull request and holds the merg
| Live target API + BOLA demo | done, `bob` gets 403 after #2 |
| TestSprite suite (13 cases) | done, green on every PR |
| Shadow-git collector, metrics runner, autopsy engine | done |
+| Local recorder (`npm run record`) + load session in the playground | done |
| MCP read-only surface (`get_autopsy`) | planned |
## Credits
diff --git a/app/page.tsx b/app/page.tsx
index eb8dc2f..d655bf7 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -298,6 +298,14 @@ export default function Home() {
Seven turns, one unbounded cache, one heal. Scrub the timeline,
read the diff, rotate the city, and copy the fix prompt.
+
+ Or record your own:{" "}
+
+ npm run record
+ {" "}
+ writes a session.json{" "}
+ — drop it into the playground and the timeline is yours.
+
;
+ return ;
}
diff --git a/package.json b/package.json
index 0ea85eb..e29d5cc 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "record": "node --experimental-strip-types --no-warnings scripts/record.ts"
},
"dependencies": {
"@react-three/drei": "9.122.0",
diff --git a/scripts/record.ts b/scripts/record.ts
new file mode 100644
index 0000000..df8f21b
--- /dev/null
+++ b/scripts/record.ts
@@ -0,0 +1,41 @@
+// Record a session from a real repository + a running app, then write the JSON
+// the playground replays.
+//
+// npm run record -- --repo . --health http://localhost:3000/api/health --duration 60
+//
+// Drop the resulting session.json into /playground to see the timeline, the 3D
+// city and the autopsy for YOUR session instead of the demo fixture.
+
+import fs from "node:fs";
+import path from "node:path";
+import { recordSession } from "../src/collector/record.ts";
+
+function arg(name: string, fallback: string): string {
+ const flag = `--${name}`;
+ const index = process.argv.indexOf(flag);
+ if (index !== -1 && process.argv[index + 1]) return process.argv[index + 1];
+ const inline = process.argv.find((a) => a.startsWith(`${flag}=`));
+ if (inline) return inline.slice(flag.length + 1);
+ return fallback;
+}
+
+const repoPath = path.resolve(arg("repo", process.cwd()));
+const healthUrl = arg("health", "http://localhost:3000/api/health");
+const durationMs = Number(arg("duration", "60")) * 1000;
+const intervalMs = Number(arg("interval", "2")) * 1000;
+const out = path.resolve(arg("out", "session.json"));
+
+console.log(`recording ${repoPath}`);
+console.log(`probing ${healthUrl}`);
+console.log(`window ${durationMs / 1000}s, sampling every ${intervalMs / 1000}s\n`);
+
+const session = await recordSession({ repoPath, healthUrl, durationMs, intervalMs });
+
+fs.writeFileSync(out, JSON.stringify(session, null, 2));
+
+const leaks = session.anomalies.length;
+console.log(`turns ${session.turns.length}`);
+console.log(`samples ${session.metrics.length}`);
+console.log(`anomalies ${leaks}${leaks ? " — " + session.anomalies.map((a) => a.kind).join(", ") : ""}`);
+console.log(`written ${out}`);
+console.log("\nopen /playground and drop the file in to replay it.");
diff --git a/src/collector/record.ts b/src/collector/record.ts
new file mode 100644
index 0000000..5a4420e
--- /dev/null
+++ b/src/collector/record.ts
@@ -0,0 +1,167 @@
+// Recorder: turns a repository plus a running app into a Session the dashboard
+// can replay.
+//
+// It never touches HEAD, the branch, or the user's index: every change burst is
+// written as a throwaway commit in the object database (see shadowSnapshot), so
+// the timeline is built from what actually landed on disk. Metrics come from the
+// app's own /api/health probe, not from a guess.
+//
+// Runs where the code lives (a laptop, CI, a devcontainer). The deployed
+// dashboard only replays the JSON it produces.
+
+import fs from "node:fs";
+import path from "node:path";
+import type { Session, Turn, MetricSample, Snapshot, FileNode } from "../types.ts";
+import {
+ isGitRepo,
+ shadowTree,
+ commitShadowTree,
+ treeOf,
+ shadowSnapshot,
+ diffBetween,
+} from "./shadow-git.ts";
+import { runAutopsy } from "../autopsy/engine.ts";
+
+export interface RecordOptions {
+ repoPath: string;
+ /** Probe polled for cpu/mem/latency, e.g. http://localhost:3000/api/health */
+ healthUrl: string;
+ /** How long to record. Default 60s. */
+ durationMs?: number;
+ /** Sampling interval. Default 2s. */
+ intervalMs?: number;
+}
+
+interface HealthPayload {
+ ok?: boolean;
+ cpuPct?: number;
+ rssMb?: number;
+}
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/** Relative and alias imports of a file, used to draw the 3D city's arcs. */
+function importsFor(repoPath: string, file: string): string[] {
+ if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(file)) return [];
+ try {
+ const source = fs.readFileSync(path.join(repoPath, file), "utf8");
+ const found = new Set();
+ for (const match of source.matchAll(/from\s+["']([^"']+)["']/g)) {
+ const spec = match[1];
+ if (spec.startsWith(".") || spec.startsWith("@/")) found.add(spec);
+ }
+ return [...found];
+ } catch {
+ return [];
+ }
+}
+
+export async function recordSession(options: RecordOptions): Promise {
+ const { repoPath, healthUrl, durationMs = 60_000, intervalMs = 2_000 } = options;
+
+ if (!isGitRepo(repoPath)) {
+ throw new Error(`${repoPath} is not inside a git work tree`);
+ }
+
+ const startedAt = new Date().toISOString();
+ const turns: Turn[] = [];
+ const metrics: MetricSample[] = [];
+ const snapshots: Snapshot[] = [];
+
+ let parent = shadowSnapshot(repoPath, null, "baseline").hash;
+ let parentTree = treeOf(repoPath, parent);
+ let turnIndex = 0;
+ let errors = 0;
+
+ // Warm the app once so the first recorded sample is not a cold start.
+ try {
+ await fetch(healthUrl, { cache: "no-store" });
+ } catch {
+ // A cold-start miss is not a recorded error.
+ }
+
+ const deadline = Date.now() + durationMs;
+
+ while (Date.now() < deadline) {
+ const tick = Date.now();
+
+ let cpu = 0;
+ let mem = 0;
+ try {
+ const res = await fetch(healthUrl, { cache: "no-store" });
+ if (!res.ok) throw new Error(String(res.status));
+ const body = (await res.json()) as HealthPayload;
+ cpu = Math.round(body.cpuPct ?? 0);
+ mem = Math.round(body.rssMb ?? 0);
+ } catch {
+ errors += 1;
+ }
+ const latency = Date.now() - tick;
+
+ metrics.push({ turn: turnIndex, cpu, mem, errors, latency });
+
+ const tree = shadowTree(repoPath);
+ if (tree !== parentTree) {
+ const hash = commitShadowTree(repoPath, tree, parent, `turn ${turnIndex + 1}`).hash;
+ const diff = diffBetween(repoPath, parent, hash);
+ const message = `turn ${turnIndex + 1}: ${diff.length} file(s) changed`;
+
+ const files: FileNode[] = diff.map((hunk) => ({
+ path: hunk.file,
+ loc: hunk.lines.length,
+ owner: "ai" as const,
+ }));
+
+ const imports: Array<[string, string]> = [];
+ for (const file of files) {
+ for (const target of importsFor(repoPath, file.path)) {
+ imports.push([file.path, target]);
+ }
+ }
+
+ const turn: Turn = {
+ id: `t${turnIndex + 1}`,
+ index: turnIndex,
+ role: "assistant",
+ text: message,
+ timestamp: new Date().toISOString(),
+ changedFiles: files.map((f) => f.path),
+ diff,
+ snapshotId: `snap-${turnIndex + 1}`,
+ };
+
+ turns.push(turn);
+ snapshots.push({
+ id: `snap-${turnIndex + 1}`,
+ turnId: turn.id,
+ files,
+ imports,
+ });
+
+ parent = hash;
+ parentTree = tree;
+ turnIndex += 1;
+ }
+
+ const wait = intervalMs - (Date.now() - tick);
+ if (wait > 0) await sleep(wait);
+ }
+
+ const base = {
+ id: `sess-${Date.now()}`,
+ agent: "shadow-git",
+ repo: path.basename(repoPath),
+ startedAt,
+ turns,
+ metrics,
+ snapshots,
+ };
+
+ const { anomalies, autopsies } = runAutopsy({
+ ...base,
+ anomalies: [],
+ autopsies: [],
+ } as Session);
+
+ return { ...base, anomalies, autopsies };
+}
diff --git a/src/collector/shadow-git.ts b/src/collector/shadow-git.ts
index 4e1b383..7ab0f82 100644
--- a/src/collector/shadow-git.ts
+++ b/src/collector/shadow-git.ts
@@ -6,6 +6,8 @@
// checked out (a local recorder). It is not used by the deployed dashboard.
import { execFileSync } from "node:child_process";
+import os from "node:os";
+import path from "node:path";
import type { DiffHunk } from "@/src/types";
export interface ShadowCommit {
@@ -79,3 +81,78 @@ export function rollbackFile(repoPath: string, hash: string, file: string): void
export function rollbackAll(repoPath: string, hash: string): void {
git(repoPath, ["checkout", hash, "--", "."]);
}
+
+/** Files with uncommitted changes, including untracked ones. */
+export function workingTreeChanges(repoPath: string): string[] {
+ const out = git(repoPath, ["status", "--porcelain"]);
+ return out
+ .split("\n")
+ .map((l) => l.trim())
+ .filter(Boolean)
+ .map((l) => l.slice(2).trim())
+ .filter(Boolean);
+}
+
+/**
+ * Stage the whole working tree into a throwaway index and return its tree hash.
+ * Nothing about HEAD, the branch or the user's own index is touched.
+ */
+export function shadowTree(repoPath: string): string {
+ const run = ghostGit(repoPath);
+ run(["add", "-A"]);
+ return run(["write-tree"]).trim();
+}
+
+/** Write a commit for an already-staged tree, without moving any ref. */
+export function commitShadowTree(
+ repoPath: string,
+ tree: string,
+ parent: string | null,
+ message: string,
+): ShadowCommit {
+ const args = ["commit-tree", tree, "-m", message];
+ if (parent) args.push("-p", parent);
+ const hash = ghostGit(repoPath)(args).trim();
+ return { hash, message, at: new Date().toISOString() };
+}
+
+/** Tree hash of an existing commit. */
+export function treeOf(repoPath: string, commit: string): string {
+ return git(repoPath, ["rev-parse", `${commit}^{tree}`]).trim();
+}
+
+/**
+ * Stage + commit the working tree as a throwaway commit. Returns the commit.
+ * Kept for callers that do not need to compare trees first.
+ */
+export function shadowSnapshot(
+ repoPath: string,
+ parent: string | null,
+ message: string,
+): ShadowCommit {
+ return commitShadowTree(repoPath, shadowTree(repoPath), parent, message);
+}
+
+function ghostGit(repoPath: string) {
+ const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
+ cwd: repoPath,
+ encoding: "utf8",
+ }).trim();
+
+ const env = {
+ ...process.env,
+ GIT_INDEX_FILE: path.join(os.tmpdir(), `ghost-index-${path.basename(gitDir)}`),
+ GIT_AUTHOR_NAME: "Ghost Recorder",
+ GIT_AUTHOR_EMAIL: "ghost@localhost",
+ GIT_COMMITTER_NAME: "Ghost Recorder",
+ GIT_COMMITTER_EMAIL: "ghost@localhost",
+ };
+
+ return (args: string[]) =>
+ execFileSync("git", args, {
+ cwd: repoPath,
+ env,
+ encoding: "utf8",
+ maxBuffer: 16 * 1024 * 1024,
+ });
+}
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx
index 98edfad..c501867 100644
--- a/src/components/Dashboard.tsx
+++ b/src/components/Dashboard.tsx
@@ -16,6 +16,7 @@ import {
SkipForward,
Timer,
TriangleAlert,
+ Upload,
User,
} from "lucide-react";
import type { Session } from "@/src/types";
@@ -29,13 +30,27 @@ function clock(iso: string) {
return iso.slice(11, 16);
}
-export default function Dashboard({ session }: { session: Session }) {
+export default function Dashboard({
+ session,
+ onLoadSession,
+}: {
+ session: Session;
+ onLoadSession?: (file: File) => void;
+}) {
const [activeTurn, setActiveTurn] = useState(0);
const [playing, setPlaying] = useState(false);
const [pinned, setPinned] = useState(null);
+ // A freshly loaded session starts at its first turn, not at whatever index the
+ // previous session was scrubbed to.
+ useEffect(() => {
+ setActiveTurn(0);
+ setPinned(null);
+ setPlaying(false);
+ }, [session.id]);
+
const total = session.turns.length;
- const turn = session.turns[activeTurn];
+ const turn = session.turns[activeTurn] ?? session.turns[0];
const selectTurn = useCallback((index: number) => {
setActiveTurn(index);
@@ -164,6 +179,23 @@ export default function Dashboard({ session }: { session: Session }) {
>
)}
+
+ {onLoadSession && (
+
+ )}
diff --git a/src/components/PlaygroundClient.tsx b/src/components/PlaygroundClient.tsx
new file mode 100644
index 0000000..7dccd35
--- /dev/null
+++ b/src/components/PlaygroundClient.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import type { Session } from "@/src/types";
+import Dashboard from "./Dashboard";
+
+/** Shape check, not a full schema: enough to reject arbitrary JSON. */
+function isSession(value: unknown): value is Session {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Partial;
+ return (
+ typeof candidate.id === "string" &&
+ typeof candidate.repo === "string" &&
+ Array.isArray(candidate.turns) &&
+ Array.isArray(candidate.metrics) &&
+ Array.isArray(candidate.snapshots) &&
+ Array.isArray(candidate.anomalies) &&
+ Array.isArray(candidate.autopsies)
+ );
+}
+
+export default function PlaygroundClient({ initial }: { initial: Session }) {
+ const [session, setSession] = useState(initial);
+ const [notice, setNotice] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
+
+ const load = useCallback(async (file: File) => {
+ try {
+ const parsed = JSON.parse(await file.text());
+ if (!isSession(parsed)) {
+ setNotice({ kind: "err", text: "That file is not a Ghost session." });
+ return;
+ }
+ if (parsed.turns.length === 0) {
+ setNotice({ kind: "err", text: "That session has no turns. Record for longer." });
+ return;
+ }
+ setSession(parsed);
+ setNotice({ kind: "ok", text: `Loaded ${parsed.turns.length} turns from ${parsed.repo}` });
+ } catch {
+ setNotice({ kind: "err", text: "Could not parse that file as JSON." });
+ }
+ }, []);
+
+ const reset = useCallback(() => {
+ setSession(initial);
+ setNotice(null);
+ }, [initial]);
+
+ const custom = session.id !== initial.id;
+
+ return (
+ <>
+
+
+ {(notice || custom) && (
+