Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,7 @@ yarn-error.log*

# recorded sessions (repo root only — src/data is source)
/data/
/session.json
/session-*.json

.vercel
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</p>
<p className="mt-2 max-w-lg text-[11.5px] leading-relaxed text-dim">
Or record your own:{" "}
<code className="rounded border border-line bg-elev px-1.5 py-0.5 font-mono text-[11px] text-accent">
npm run record
</code>{" "}
writes a <code className="font-mono text-[11px]">session.json</code>{" "}
— drop it into the playground and the timeline is yours.
</p>
</div>
<Link
href="/playground"
Expand Down
10 changes: 5 additions & 5 deletions app/playground/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { Metadata } from "next";
import { demoSession } from "@/src/data/fixture";
import Dashboard from "@/src/components/Dashboard";
import PlaygroundClient from "@/src/components/PlaygroundClient";

export const metadata: Metadata = {
title: "Playground — GhostInTheCodebase",
description:
"Replay a recorded agent session: timeline, code diff, runtime metrics and the 3D code city.",
"Replay a recorded agent session: timeline, code diff, runtime metrics and the 3D code city. Drop in your own session.json to replay it instead.",
};

// Server component: loads the recorded demo session and hands it to the
// interactive client dashboard.
// Server component: ships the recorded demo session to the client, which can
// swap it for a session.json the user recorded locally.
export default function PlaygroundPage() {
return <Dashboard session={demoSession} />;
return <PlaygroundClient initial={demoSession} />;
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions scripts/record.ts
Original file line number Diff line number Diff line change
@@ -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.");
167 changes: 167 additions & 0 deletions src/collector/record.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<Session> {
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 };
}
77 changes: 77 additions & 0 deletions src/collector/shadow-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
});
}
Loading
Loading