Skip to content

Commit 0576524

Browse files
committed
feat(miner-governor): freeze/snapshot mechanism for historical replay targets (#3010)
Adds exportReplaySnapshot: given a repo and commit T, produces a detached git-worktree checkout at T (never mutates the caller's own checkout/branch) plus a context bundle (commit history up to and including T by ancestry, tags reachable from T, and the README as it existed at T, matched case-insensitively via git ls-tree rather than a guessed filename list). Fail-fast validation: every exported commit's and tag's date is checked against T's own commit date, since ancestry-walking excludes unreachable commits by construction but does not guarantee dates are monotonic (a tag can be created long after the commit it points to; committer dates aren't strictly increasing along the DAG in general). Persistence is commit-keyed (UNIQUE on repo_full_name, commit_sha) in the local store -- re-exporting the same (repo, T) pair returns the cached row rather than re-running git, which is both how "byte- reproducible" holds trivially and avoids redundant work. Reuse: this issue's own text points at "existing discovery/analyze git-history utilities" as the starting point. Grepped both packages before writing this and found no such utility (opportunity-fanout.js reads GitHub API issue updated_at, not git commit/tag history at all). The genuinely reusable piece -- worktree-allocator.ts's injected-exec convention and its removeWorktree -- is reused directly via @jsonbored/gittensory-engine rather than inventing a third "inject the git subprocess" abstraction. Note on this issue's history: previously closed by #3782, an unrelated PR (Rust protobuf stub classification) that cited this issue number to satisfy the linked-issue gate without implementing the actual feature. Reopened and implemented for real here. Closes #3010.
1 parent 7d260f1 commit 0576524

4 files changed

Lines changed: 710 additions & 1 deletion

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { WorktreeExecFn, WorktreeRemoveResult } from "@jsonbored/gittensory-engine";
2+
3+
export const REPLAY_SNAPSHOT_SUBDIR: ".gittensory-replay-snapshots";
4+
5+
export type ReplaySnapshotCommit = { sha: string; date: string; subject: string };
6+
export type ReplaySnapshotTag = { name: string; date: string; targetSha: string };
7+
export type ReplaySnapshotReadme = { filename: string; content: string };
8+
9+
export type ReplaySnapshot = {
10+
repoFullName: string;
11+
commitSha: string;
12+
worktreePath: string;
13+
targetDate: string;
14+
commits: ReplaySnapshotCommit[];
15+
tags: ReplaySnapshotTag[];
16+
readme: ReplaySnapshotReadme | null;
17+
exportedAt: string;
18+
};
19+
20+
export function resolveReplaySnapshotDbPath(env?: NodeJS.ProcessEnv): string;
21+
22+
export function planReplaySnapshotPath(input: { repoPath: string; commitSha: string }): string;
23+
24+
export function validateSnapshotFreshness(input: { targetDate: string; commits: ReplaySnapshotCommit[]; tags: ReplaySnapshotTag[] }): void;
25+
26+
export type ReplaySnapshotStore = {
27+
dbPath: string;
28+
getSnapshot(repoFullName: string, commitSha: string): ReplaySnapshot | null;
29+
saveSnapshot(snapshot: Omit<ReplaySnapshot, "exportedAt">): ReplaySnapshot;
30+
close(): void;
31+
};
32+
33+
export function openReplaySnapshotStore(dbPath?: string): ReplaySnapshotStore;
34+
export function closeDefaultReplaySnapshotStore(): void;
35+
36+
export function exportReplaySnapshot(
37+
input: { repoPath: string; repoFullName: string; commitSha: string },
38+
deps: { exec: WorktreeExecFn; store?: ReplaySnapshotStore },
39+
): Promise<ReplaySnapshot>;
40+
41+
export function removeReplaySnapshotWorktree(exec: WorktreeExecFn, repoPath: string, worktreePath: string): Promise<WorktreeRemoveResult>;
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { join } from "node:path";
2+
import { removeWorktree } from "@jsonbored/gittensory-engine";
3+
import { openLocalStoreDb, resolveLocalStoreDbPath, normalizeLocalStoreDbPath } from "./local-store.js";
4+
5+
// Freeze/snapshot mechanism for historical replay targets (#3010). Given a repo and a commit SHA T, exports:
6+
// (a) the full working tree checked out AT T via a DETACHED git worktree -- the same isolation primitive
7+
// worktree-allocator.ts (#4269) uses for attempt isolation, just detached rather than on a new branch,
8+
// since a replay target is read-only, never a place to commit -- so it never mutates the caller's own
9+
// checkout/branch.
10+
// (b) a context bundle: commit history up to and including T (by ANCESTRY, via `git log T` -- walking the DAG
11+
// is the tamper-resistant way to bound "up to T", since a commit's committer date is user-controlled and
12+
// can't be trusted alone), tags reachable from T (`git tag --merged T`), and the README as it existed at
13+
// T (`git ls-tree` + `git show T:<name>`, matched case-insensitively rather than a guessed filename list).
14+
//
15+
// REUSE NOTE: this issue's own text frames "the discover and analyze phases... already read git history" as
16+
// the reuse starting point. Grepped both packages (git log/git tag/commits/tags/releases) before writing this
17+
// and found no such utility anywhere -- opportunity-fanout.js reads GitHub API issue `updated_at`, not git
18+
// commit/tag history at all. The one genuinely reusable piece is worktree-allocator.ts's injected-exec
19+
// convention (WorktreeExecFn) and its removeWorktree -- both reused directly below (import from
20+
// @jsonbored/gittensory-engine), rather than inventing a THIRD "inject the git subprocess" abstraction
21+
// alongside cli-subprocess-driver.ts's and worktree-allocator.ts's own.
22+
//
23+
// FAIL-FAST VALIDATION: ancestry-walking (git log T) already excludes anything NOT reachable from T by
24+
// construction, but a tag can point at a commit that IS an ancestor of T while the TAG's own creation/tagger
25+
// date is LATER (e.g. a tag added long after the commit it points to), and commit committer-dates are not
26+
// strictly monotonic along the DAG in general (rebases, clock skew). So checking every exported commit's date
27+
// and every exported tag's date against T's own commit date is a genuine, not merely defensive, check.
28+
//
29+
// PERSISTENCE: the context bundle is cached in the local store, UNIQUE-keyed on (repo_full_name, commit_sha) --
30+
// re-exporting the same (repo, T) pair returns the identical cached row rather than re-running git, which is
31+
// both how "byte-reproducible" holds trivially and avoids redundant work on repeat replay runs. The working-
32+
// tree export itself is git-content-addressed already (the same commit SHA always checks out identical files).
33+
34+
const defaultDbFileName = "replay-snapshot.sqlite3";
35+
let defaultDb = null;
36+
37+
export function resolveReplaySnapshotDbPath(env = process.env) {
38+
return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_REPLAY_SNAPSHOT_DB", env);
39+
}
40+
41+
function normalizeDbPath(dbPath) {
42+
return normalizeLocalStoreDbPath(dbPath, resolveReplaySnapshotDbPath(), "invalid_replay_snapshot_db_path");
43+
}
44+
45+
const FIELD_SEP = "\x1f";
46+
const README_NAME_PATTERN = /^readme(\.\w+)?$/i;
47+
48+
function normalizeRepoFullName(repoFullName) {
49+
if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
50+
const [owner, repo, extra] = repoFullName.trim().split("/");
51+
if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name");
52+
return `${owner}/${repo}`;
53+
}
54+
55+
function normalizeCommitSha(commitSha) {
56+
if (typeof commitSha !== "string" || !commitSha.trim()) throw new Error("invalid_commit_sha");
57+
return commitSha.trim();
58+
}
59+
60+
/** Worktree exports live under this dir inside the repo, mirroring worktree-allocator.ts's WORKTREE_SUBDIR. */
61+
export const REPLAY_SNAPSHOT_SUBDIR = ".gittensory-replay-snapshots";
62+
63+
/** PURE: the deterministic on-disk location for a (repo, commit) replay export -- same pair -> same path. */
64+
export function planReplaySnapshotPath(input) {
65+
const commitSha = normalizeCommitSha(input.commitSha);
66+
return join(input.repoPath, REPLAY_SNAPSHOT_SUBDIR, commitSha);
67+
}
68+
69+
function assertExecResult(result, description) {
70+
if (result.code !== 0) {
71+
const detail = (result.stderr ?? "").trim() || `exit_${result.code}`;
72+
throw new Error(`${description}: ${detail}`);
73+
}
74+
return result.stdout ?? "";
75+
}
76+
77+
/** Detached checkout at commitSha via `git worktree add --detach` -- never creates a branch, never touches the
78+
* caller's own checkout. Idempotent in effect: `git worktree add` itself fails if the path already has a
79+
* worktree, which callers avoid by checking the store cache first (see exportReplaySnapshot). */
80+
async function addDetachedWorktree(exec, repoPath, worktreePath, commitSha) {
81+
const result = await exec("git", ["worktree", "add", "--detach", worktreePath, commitSha], { cwd: repoPath });
82+
assertExecResult(result, "git_worktree_add_failed");
83+
}
84+
85+
async function readTargetCommitDate(exec, repoPath, commitSha) {
86+
const result = await exec("git", ["log", "-1", "--format=%cI", commitSha], { cwd: repoPath });
87+
const stdout = assertExecResult(result, "git_log_target_failed").trim();
88+
if (!stdout) throw new Error(`git_log_target_failed: no commit found for ${commitSha}`);
89+
return stdout;
90+
}
91+
92+
async function readCommitHistory(exec, repoPath, commitSha) {
93+
const result = await exec("git", ["log", commitSha, `--format=%H${FIELD_SEP}%cI${FIELD_SEP}%s`], { cwd: repoPath });
94+
const stdout = assertExecResult(result, "git_log_history_failed");
95+
return stdout
96+
.split("\n")
97+
.filter((line) => line.length > 0)
98+
.map((line) => {
99+
const [sha, date, subject] = line.split(FIELD_SEP);
100+
return { sha, date, subject: subject ?? "" };
101+
});
102+
}
103+
104+
async function readReachableTags(exec, repoPath, commitSha) {
105+
const result = await exec(
106+
"git",
107+
["tag", "--merged", commitSha, `--format=%(refname:short)${FIELD_SEP}%(creatordate:iso-strict)${FIELD_SEP}%(objectname)`],
108+
{ cwd: repoPath },
109+
);
110+
const stdout = assertExecResult(result, "git_tag_merged_failed");
111+
return stdout
112+
.split("\n")
113+
.filter((line) => line.length > 0)
114+
.map((line) => {
115+
const [name, date, targetSha] = line.split(FIELD_SEP);
116+
return { name, date, targetSha };
117+
});
118+
}
119+
120+
/** Finds the repo-root README (any casing/extension) at commitSha and returns its content, or null if none
121+
* exists at that commit. Uses `git ls-tree` to find the real filename rather than guessing a fixed spelling
122+
* list. */
123+
async function readReadmeAtCommit(exec, repoPath, commitSha) {
124+
const listing = await exec("git", ["ls-tree", "--name-only", commitSha], { cwd: repoPath });
125+
const stdout = assertExecResult(listing, "git_ls_tree_failed");
126+
const filename = stdout
127+
.split("\n")
128+
.map((line) => line.trim())
129+
.find((line) => README_NAME_PATTERN.test(line));
130+
if (!filename) return null;
131+
132+
const shown = await exec("git", ["show", `${commitSha}:${filename}`], { cwd: repoPath });
133+
const content = assertExecResult(shown, "git_show_readme_failed");
134+
return { filename, content };
135+
}
136+
137+
/** PURE: fails fast (throws) if any exported commit or tag carries a date LATER than the target commit's own
138+
* date. Returns nothing on success. */
139+
export function validateSnapshotFreshness(input) {
140+
const targetMs = Date.parse(input.targetDate);
141+
const violations = [];
142+
for (const commit of input.commits) {
143+
if (Date.parse(commit.date) > targetMs) violations.push(`commit ${commit.sha} dated ${commit.date} is after target ${input.targetDate}`);
144+
}
145+
for (const tag of input.tags) {
146+
if (Date.parse(tag.date) > targetMs) violations.push(`tag ${tag.name} dated ${tag.date} is after target ${input.targetDate}`);
147+
}
148+
if (violations.length > 0) throw new Error(`replay_snapshot_freshness_violation: ${violations.join("; ")}`);
149+
}
150+
151+
export function openReplaySnapshotStore(dbPath = resolveReplaySnapshotDbPath()) {
152+
const resolvedPath = normalizeDbPath(dbPath);
153+
const db = openLocalStoreDb(resolvedPath);
154+
db.exec(`
155+
CREATE TABLE IF NOT EXISTS replay_snapshots (
156+
id INTEGER PRIMARY KEY AUTOINCREMENT,
157+
repo_full_name TEXT NOT NULL,
158+
commit_sha TEXT NOT NULL,
159+
worktree_path TEXT NOT NULL,
160+
target_date TEXT NOT NULL,
161+
commits_json TEXT NOT NULL,
162+
tags_json TEXT NOT NULL,
163+
readme_filename TEXT,
164+
readme_content TEXT,
165+
exported_at TEXT NOT NULL,
166+
UNIQUE (repo_full_name, commit_sha)
167+
)
168+
`);
169+
const getStatement = db.prepare("SELECT * FROM replay_snapshots WHERE repo_full_name = ? AND commit_sha = ?");
170+
const insertStatement = db.prepare(`
171+
INSERT INTO replay_snapshots
172+
(repo_full_name, commit_sha, worktree_path, target_date, commits_json, tags_json, readme_filename, readme_content, exported_at)
173+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
174+
`);
175+
176+
function rowToSnapshot(row) {
177+
return {
178+
repoFullName: row.repo_full_name,
179+
commitSha: row.commit_sha,
180+
worktreePath: row.worktree_path,
181+
targetDate: row.target_date,
182+
commits: JSON.parse(row.commits_json),
183+
tags: JSON.parse(row.tags_json),
184+
readme: row.readme_filename ? { filename: row.readme_filename, content: row.readme_content } : null,
185+
exportedAt: row.exported_at,
186+
};
187+
}
188+
189+
return {
190+
dbPath: resolvedPath,
191+
getSnapshot(repoFullName, commitSha) {
192+
const row = getStatement.get(normalizeRepoFullName(repoFullName), normalizeCommitSha(commitSha));
193+
return row ? rowToSnapshot(row) : null;
194+
},
195+
saveSnapshot(snapshot) {
196+
const repoFullName = normalizeRepoFullName(snapshot.repoFullName);
197+
const commitSha = normalizeCommitSha(snapshot.commitSha);
198+
insertStatement.run(
199+
repoFullName,
200+
commitSha,
201+
snapshot.worktreePath,
202+
snapshot.targetDate,
203+
JSON.stringify(snapshot.commits),
204+
JSON.stringify(snapshot.tags),
205+
snapshot.readme?.filename ?? null,
206+
snapshot.readme?.content ?? null,
207+
new Date().toISOString(),
208+
);
209+
return this.getSnapshot(repoFullName, commitSha);
210+
},
211+
close() {
212+
db.close();
213+
},
214+
};
215+
}
216+
217+
function getDefaultReplaySnapshotStore() {
218+
defaultDb ??= openReplaySnapshotStore();
219+
return defaultDb;
220+
}
221+
222+
export function closeDefaultReplaySnapshotStore() {
223+
if (!defaultDb) return;
224+
defaultDb.close();
225+
defaultDb = null;
226+
}
227+
228+
/**
229+
* Export a frozen, reproducible replay snapshot for (repoFullName, commitSha): a detached working-tree checkout
230+
* at that commit plus a context bundle (commit history, reachable tags, README-at-commit). Returns the CACHED
231+
* snapshot without touching git again if one already exists for this exact (repo, commit) pair.
232+
*
233+
* @param {{ repoPath: string, repoFullName: string, commitSha: string }} input
234+
* @param {{ exec: import("./worktree-allocator.js").WorktreeExecFn, store?: ReturnType<typeof openReplaySnapshotStore> }} deps
235+
*/
236+
export async function exportReplaySnapshot(input, deps) {
237+
if (!input || typeof input !== "object") throw new Error("invalid_replay_snapshot_input");
238+
const repoFullName = normalizeRepoFullName(input.repoFullName);
239+
const commitSha = normalizeCommitSha(input.commitSha);
240+
if (typeof input.repoPath !== "string" || !input.repoPath.trim()) throw new Error("invalid_repo_path");
241+
const repoPath = input.repoPath.trim();
242+
243+
if (!deps || typeof deps !== "object" || typeof deps.exec !== "function") throw new Error("invalid_exec");
244+
const { exec } = deps;
245+
const store = deps.store ?? getDefaultReplaySnapshotStore();
246+
247+
const cached = store.getSnapshot(repoFullName, commitSha);
248+
if (cached) return cached;
249+
250+
const worktreePath = planReplaySnapshotPath({ repoPath, commitSha });
251+
await addDetachedWorktree(exec, repoPath, worktreePath, commitSha);
252+
253+
const targetDate = await readTargetCommitDate(exec, repoPath, commitSha);
254+
const commits = await readCommitHistory(exec, repoPath, commitSha);
255+
const tags = await readReachableTags(exec, repoPath, commitSha);
256+
const readme = await readReadmeAtCommit(exec, repoPath, commitSha);
257+
258+
validateSnapshotFreshness({ targetDate, commits, tags });
259+
260+
return store.saveSnapshot({ repoFullName, commitSha, worktreePath, targetDate, commits, tags, readme });
261+
}
262+
263+
/** Tear down a replay snapshot's working-tree export (the cached context-bundle row is left in place -- it is
264+
* cheap, commit-keyed, and re-usable even after the on-disk tree is removed; only re-adding the worktree would
265+
* require the tree again, which is out of this function's scope). */
266+
export async function removeReplaySnapshotWorktree(exec, repoPath, worktreePath) {
267+
return removeWorktree({ exec, repoPath, worktreePath });
268+
}

packages/gittensory-miner/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"expected-engine.version"
3333
],
3434
"scripts": {
35-
"build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js"
35+
"build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js"
3636
},
3737
"dependencies": {
3838
"@jsonbored/gittensory-engine": "*"

0 commit comments

Comments
 (0)