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) && ( +
+ + {notice ? notice.text : `Replaying ${session.id}`} + + {custom && ( + + )} +
+ )} + + ); +} diff --git a/tsconfig.json b/tsconfig.json index f13bc90..96fdb1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "skipLibCheck": true, "strict": true, "noEmit": true, + "allowImportingTsExtensions": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler",