whydid 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +232 -0
- package/dashboard/dist/android-chrome-192x192.png +0 -0
- package/dashboard/dist/android-chrome-512x512.png +0 -0
- package/dashboard/dist/apple-touch-icon.png +0 -0
- package/dashboard/dist/assets/index-DImKqodm.js +69 -0
- package/dashboard/dist/assets/index-mHXgqqkj.css +1 -0
- package/dashboard/dist/favicon-16x16.png +0 -0
- package/dashboard/dist/favicon-32x32.png +0 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/index.html +24 -0
- package/dashboard/dist/logo-mark.svg +8 -0
- package/dashboard/dist/site.webmanifest +11 -0
- package/dist/adapters/claude-code.js +168 -0
- package/dist/adapters/hook-events.js +86 -0
- package/dist/adapters/sync-claude-code.js +85 -0
- package/dist/cli/ai-budget-warning.js +19 -0
- package/dist/cli/commands/agent-event.js +80 -0
- package/dist/cli/commands/checkpoint.js +36 -0
- package/dist/cli/commands/context.js +27 -0
- package/dist/cli/commands/daemon.js +139 -0
- package/dist/cli/commands/export.js +36 -0
- package/dist/cli/commands/init.js +63 -0
- package/dist/cli/commands/key.js +65 -0
- package/dist/cli/commands/license.js +57 -0
- package/dist/cli/commands/milestone.js +64 -0
- package/dist/cli/commands/prompt.js +75 -0
- package/dist/cli/commands/recap.js +48 -0
- package/dist/cli/commands/restore-fork.js +101 -0
- package/dist/cli/commands/status.js +26 -0
- package/dist/cli/commands/test.js +33 -0
- package/dist/cli/commands/timeline.js +23 -0
- package/dist/cli/commands/why.js +71 -0
- package/dist/cli/context.js +48 -0
- package/dist/cli/index.js +77 -0
- package/dist/cli/license-gate.js +26 -0
- package/dist/cli/preflight.js +28 -0
- package/dist/core/agent-context.js +46 -0
- package/dist/core/agent-hooks.js +68 -0
- package/dist/core/ai-budget.js +27 -0
- package/dist/core/chat-retrieval.js +36 -0
- package/dist/core/context-tree.js +135 -0
- package/dist/core/diff.js +30 -0
- package/dist/core/engine.js +321 -0
- package/dist/core/export.js +98 -0
- package/dist/core/file-history.js +31 -0
- package/dist/core/manifest.js +102 -0
- package/dist/core/recap.js +49 -0
- package/dist/core/test-runner.js +23 -0
- package/dist/daemon/listen.js +26 -0
- package/dist/daemon/pidfile.js +36 -0
- package/dist/daemon/server.js +554 -0
- package/dist/db/database.js +631 -0
- package/dist/db/schema.js +186 -0
- package/dist/git/git.js +22 -0
- package/dist/licensing/config.js +28 -0
- package/dist/licensing/license-client.js +45 -0
- package/dist/licensing/license-manager.js +89 -0
- package/dist/licensing/license-payload.js +12 -0
- package/dist/licensing/license-store.js +96 -0
- package/dist/licensing/machine-id.js +78 -0
- package/dist/licensing/secret-store.js +102 -0
- package/dist/licensing/verify-license.js +20 -0
- package/dist/llm/agent-prompt.js +36 -0
- package/dist/llm/annotate-checkpoint.js +67 -0
- package/dist/llm/annotate.js +11 -0
- package/dist/llm/anthropic-provider.js +97 -0
- package/dist/llm/chat.js +35 -0
- package/dist/llm/community-pricing.js +75 -0
- package/dist/llm/factory.js +17 -0
- package/dist/llm/http-error.js +25 -0
- package/dist/llm/noop-provider.js +19 -0
- package/dist/llm/openai-provider.js +85 -0
- package/dist/llm/pricing.js +55 -0
- package/dist/llm/prompt.js +103 -0
- package/dist/llm/provider.js +2 -0
- package/dist/llm/schema.js +49 -0
- package/dist/llm/social-post.js +49 -0
- package/dist/objects/store.js +31 -0
- package/dist/shared/config.js +45 -0
- package/dist/shared/redaction.js +18 -0
- package/dist/shared/types.js +2 -0
- package/dist/watcher/ignore-rules.js +92 -0
- package/dist/watcher/watcher.js +249 -0
- package/package.json +55 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createTwoFilesPatch, diffLines } from "diff";
|
|
2
|
+
export function lineDiffStats(before, after) {
|
|
3
|
+
const parts = diffLines(before, after);
|
|
4
|
+
let linesAdded = 0;
|
|
5
|
+
let linesRemoved = 0;
|
|
6
|
+
for (const part of parts) {
|
|
7
|
+
const lineCount = part.value.endsWith("\n") ? part.value.split("\n").length - 1 : part.value.split("\n").length;
|
|
8
|
+
if (part.added)
|
|
9
|
+
linesAdded += lineCount;
|
|
10
|
+
if (part.removed)
|
|
11
|
+
linesRemoved += lineCount;
|
|
12
|
+
}
|
|
13
|
+
return { linesAdded, linesRemoved };
|
|
14
|
+
}
|
|
15
|
+
export function unifiedDiff(filePath, before, after) {
|
|
16
|
+
return createTwoFilesPatch(filePath, filePath, before, after, "before", "after");
|
|
17
|
+
}
|
|
18
|
+
const isProbablyBinary = (buf) => buf.subarray(0, 8000).includes(0);
|
|
19
|
+
export function computeFileDiff(store, change) {
|
|
20
|
+
const beforeBuf = change.beforeHash ? store.read(change.beforeHash) : Buffer.alloc(0);
|
|
21
|
+
const afterBuf = change.afterHash ? store.read(change.afterHash) : Buffer.alloc(0);
|
|
22
|
+
if (isProbablyBinary(beforeBuf) || isProbablyBinary(afterBuf)) {
|
|
23
|
+
return { unified: "Binary file differs.", linesAdded: 0, linesRemoved: 0, binary: true };
|
|
24
|
+
}
|
|
25
|
+
const before = beforeBuf.toString("utf-8");
|
|
26
|
+
const after = afterBuf.toString("utf-8");
|
|
27
|
+
const stats = lineDiffStats(before, after);
|
|
28
|
+
return { unified: unifiedDiff(change.filePath, before, after), ...stats, binary: false };
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=diff.js.map
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { buildManifest, diffManifests } from "./manifest.js";
|
|
5
|
+
import { computeFileDiff } from "./diff.js";
|
|
6
|
+
import { detectGit } from "../git/git.js";
|
|
7
|
+
import { hashContent } from "../objects/store.js";
|
|
8
|
+
import { isAutoGeneratedDocFile } from "./context-tree.js";
|
|
9
|
+
// A fixed-size digest of "every tracked file's path and content hash",
|
|
10
|
+
// stored per state as a cheap way to tell whether two states represent an
|
|
11
|
+
// identical working tree. This was previously storing the raw, un-hashed
|
|
12
|
+
// "path:hash\npath:hash\n..." string directly — at thousands of tracked
|
|
13
|
+
// files that's hundreds of KB of text duplicated into every single state
|
|
14
|
+
// row, unconditionally, and was confirmed (via dbstat on a synthetic
|
|
15
|
+
// 4,000-file, 300-checkpoint project) to be the actual dominant cost in
|
|
16
|
+
// db.sqlite — over 10x bigger than state_manifest itself, not just a
|
|
17
|
+
// secondary factor. Nothing in the codebase reads working_tree_hash for
|
|
18
|
+
// comparison today, so this was pure write-only dead weight; hashing it
|
|
19
|
+
// down to 64 hex chars keeps the field genuinely useful for that purpose
|
|
20
|
+
// going forward at negligible storage cost.
|
|
21
|
+
function manifestFingerprint(manifest) {
|
|
22
|
+
const raw = manifest.map((m) => `${m.filePath}:${m.contentHash}`).join("\n");
|
|
23
|
+
return hashContent(Buffer.from(raw, "utf-8"));
|
|
24
|
+
}
|
|
25
|
+
function storeManifestBlobs(ctx, manifest, changes) {
|
|
26
|
+
const changedPaths = new Set(changes.filter((c) => c.changeType !== "deleted").map((c) => c.filePath));
|
|
27
|
+
for (const entry of manifest) {
|
|
28
|
+
if (!changedPaths.has(entry.filePath))
|
|
29
|
+
continue;
|
|
30
|
+
if (ctx.store.has(entry.contentHash))
|
|
31
|
+
continue;
|
|
32
|
+
const content = readFileSync(join(ctx.projectRoot, entry.filePath));
|
|
33
|
+
ctx.store.write(content);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function withLineStats(ctx, changes) {
|
|
37
|
+
return changes.map((change) => {
|
|
38
|
+
const diff = computeFileDiff(ctx.store, change);
|
|
39
|
+
return { ...change, linesAdded: diff.linesAdded, linesRemoved: diff.linesRemoved };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// Every Nth regular checkpoint gets a full manifest snapshot ("keyframe");
|
|
43
|
+
// the rest rely on getStateManifest's delta-chain reconstruction. Bounds
|
|
44
|
+
// reconstruction to at most this many ancestor hops (the initial state is
|
|
45
|
+
// always a keyframe, so the walk always terminates) while cutting
|
|
46
|
+
// state_manifest's write volume by roughly this factor — confirmed via a
|
|
47
|
+
// synthetic 4,000-file, 300-checkpoint run: state_manifest was the
|
|
48
|
+
// dominant driver of db.sqlite growing ~1.1MB/checkpoint. Deliberately
|
|
49
|
+
// small enough that a restore/fork's reconstruction replay (bounded by
|
|
50
|
+
// this same interval) stays fast even on a large project.
|
|
51
|
+
const MANIFEST_KEYFRAME_INTERVAL = 20;
|
|
52
|
+
// Safety snapshots (before restore/fork) and restore/fork states themselves
|
|
53
|
+
// always keep a full manifest (see snapshotCurrentDisk, restoreState,
|
|
54
|
+
// forkState) — they're comparatively rare, and keeping them fully
|
|
55
|
+
// keyframed means anything forked or restored from is immediately fast to
|
|
56
|
+
// reconstruct from. Only this regular, high-frequency checkpoint path is
|
|
57
|
+
// sparsified.
|
|
58
|
+
function shouldStoreFullManifest(ctx, parentStateId) {
|
|
59
|
+
let cur = parentStateId;
|
|
60
|
+
for (let i = 0; i < MANIFEST_KEYFRAME_INTERVAL; i++) {
|
|
61
|
+
if (!cur)
|
|
62
|
+
return false;
|
|
63
|
+
if (ctx.db.hasFullManifest(cur))
|
|
64
|
+
return false;
|
|
65
|
+
cur = ctx.db.getState(cur)?.parentStateId ?? undefined;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
function requireSingleProjectId(ctx) {
|
|
70
|
+
const row = ctx.db.raw.prepare(`SELECT id FROM projects ORDER BY created_at ASC LIMIT 1`).get();
|
|
71
|
+
if (!row)
|
|
72
|
+
throw new Error("No project registered in this .whydid database.");
|
|
73
|
+
return row.id;
|
|
74
|
+
}
|
|
75
|
+
export async function initProject(ctx, projectName) {
|
|
76
|
+
const git = await detectGit(ctx.projectRoot);
|
|
77
|
+
const projectId = randomUUID();
|
|
78
|
+
ctx.db.insertProject({ id: projectId, name: projectName, rootPath: ctx.projectRoot, gitRoot: git.root });
|
|
79
|
+
const manifest = await buildManifest(ctx.projectRoot, ctx.ignoreRules);
|
|
80
|
+
const changes = diffManifests([], manifest);
|
|
81
|
+
storeManifestBlobs(ctx, manifest, changes);
|
|
82
|
+
const changesWithStats = withLineStats(ctx, changes);
|
|
83
|
+
const stateId = randomUUID();
|
|
84
|
+
ctx.db.insertState({
|
|
85
|
+
id: stateId, projectId, parentStateId: null, sessionId: null, turnId: null,
|
|
86
|
+
stateType: "initial", label: "Initial state", summary: null, reason: null,
|
|
87
|
+
decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
|
|
88
|
+
gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(manifest),
|
|
89
|
+
});
|
|
90
|
+
ctx.db.insertFileChanges(stateId, changesWithStats);
|
|
91
|
+
ctx.db.insertStateManifest(stateId, manifest);
|
|
92
|
+
ctx.db.setHeadState(projectId, stateId);
|
|
93
|
+
ctx.db.insertEvent({ id: randomUUID(), projectId, type: "project_initialized", payloadJson: "{}" });
|
|
94
|
+
return { projectId, stateId };
|
|
95
|
+
}
|
|
96
|
+
export async function createCheckpoint(ctx, opts = {}) {
|
|
97
|
+
const projectId = opts.projectId ?? requireSingleProjectId(ctx);
|
|
98
|
+
const head = ctx.db.getHeadState(projectId);
|
|
99
|
+
if (!head)
|
|
100
|
+
throw new Error("No head state found — run initProject first.");
|
|
101
|
+
const previousManifest = ctx.db.getStateManifest(head.id);
|
|
102
|
+
const currentManifest = await buildManifest(ctx.projectRoot, ctx.ignoreRules);
|
|
103
|
+
const changes = diffManifests(previousManifest, currentManifest);
|
|
104
|
+
if (changes.length === 0) {
|
|
105
|
+
return { stateId: head.id, changed: false, fileChanges: [] };
|
|
106
|
+
}
|
|
107
|
+
// A checkpoint whose *only* changes are whydid's own auto-generated docs
|
|
108
|
+
// (CONTEXT.md, WHYDID_SUMMARY.md) is a checkpoint about nothing — those
|
|
109
|
+
// files only ever change as a side effect of a real checkpoint's own
|
|
110
|
+
// annotation, so treating that write as itself checkpoint-worthy is what
|
|
111
|
+
// causes a self-sustaining loop: checkpoint -> regenerate docs -> that
|
|
112
|
+
// regeneration looks like a new change -> checkpoint again, forever. A
|
|
113
|
+
// checkpoint that changes real files *and* incidentally touches these is
|
|
114
|
+
// unaffected — only an all-docs diff is skipped.
|
|
115
|
+
if (changes.every((c) => isAutoGeneratedDocFile(c.filePath))) {
|
|
116
|
+
return { stateId: head.id, changed: false, fileChanges: [] };
|
|
117
|
+
}
|
|
118
|
+
storeManifestBlobs(ctx, currentManifest, changes);
|
|
119
|
+
const changesWithStats = withLineStats(ctx, changes);
|
|
120
|
+
const git = await detectGit(ctx.projectRoot);
|
|
121
|
+
const stateId = randomUUID();
|
|
122
|
+
ctx.db.insertState({
|
|
123
|
+
id: stateId, projectId, parentStateId: head.id, sessionId: opts.sessionId ?? null, turnId: opts.turnId ?? null,
|
|
124
|
+
stateType: opts.stateType ?? "checkpoint", label: opts.label ?? null, summary: null,
|
|
125
|
+
reason: null, decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
|
|
126
|
+
gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(currentManifest),
|
|
127
|
+
});
|
|
128
|
+
ctx.db.insertFileChanges(stateId, changesWithStats);
|
|
129
|
+
if (shouldStoreFullManifest(ctx, head.id)) {
|
|
130
|
+
ctx.db.insertStateManifest(stateId, currentManifest);
|
|
131
|
+
}
|
|
132
|
+
ctx.db.setHeadState(projectId, stateId);
|
|
133
|
+
ctx.db.insertEvent({ id: randomUUID(), projectId, type: "checkpoint_created", payloadJson: JSON.stringify({ stateId }) });
|
|
134
|
+
return { stateId, changed: true, fileChanges: changesWithStats };
|
|
135
|
+
}
|
|
136
|
+
function readdirSyncSafe(dir) {
|
|
137
|
+
return existsSync(dir) ? readdirSync(dir, { withFileTypes: true }) : [];
|
|
138
|
+
}
|
|
139
|
+
function collectFiles(dir, root, ignoreRules, out) {
|
|
140
|
+
for (const entry of readdirSyncSafe(dir)) {
|
|
141
|
+
const abs = join(dir, entry.name);
|
|
142
|
+
const rel = relative(root, abs).split("\\").join("/");
|
|
143
|
+
if (ignoreRules.isIgnored(entry.isDirectory() ? `${rel}/` : rel))
|
|
144
|
+
continue;
|
|
145
|
+
if (entry.isDirectory())
|
|
146
|
+
collectFiles(abs, root, ignoreRules, out);
|
|
147
|
+
else if (entry.isFile())
|
|
148
|
+
out.push(abs);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function applyManifestToDisk(ctx, targetManifest) {
|
|
152
|
+
const targetPaths = new Set(targetManifest.map((e) => e.filePath));
|
|
153
|
+
const currentAbsFiles = [];
|
|
154
|
+
collectFiles(ctx.projectRoot, ctx.projectRoot, ctx.ignoreRules, currentAbsFiles);
|
|
155
|
+
for (const abs of currentAbsFiles) {
|
|
156
|
+
const rel = relative(ctx.projectRoot, abs).split("\\").join("/");
|
|
157
|
+
if (!targetPaths.has(rel)) {
|
|
158
|
+
unlinkSync(abs);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const entry of targetManifest) {
|
|
162
|
+
const abs = join(ctx.projectRoot, entry.filePath);
|
|
163
|
+
const content = ctx.store.read(entry.contentHash);
|
|
164
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
165
|
+
writeFileSync(abs, content);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function snapshotCurrentDisk(ctx, projectId, parentStateId, stateType, label) {
|
|
169
|
+
const previousManifest = ctx.db.getStateManifest(parentStateId);
|
|
170
|
+
const currentManifest = await buildManifest(ctx.projectRoot, ctx.ignoreRules);
|
|
171
|
+
const changes = diffManifests(previousManifest, currentManifest);
|
|
172
|
+
storeManifestBlobs(ctx, currentManifest, changes);
|
|
173
|
+
const changesWithStats = withLineStats(ctx, changes);
|
|
174
|
+
const stateId = randomUUID();
|
|
175
|
+
const git = await detectGit(ctx.projectRoot);
|
|
176
|
+
ctx.db.insertState({
|
|
177
|
+
id: stateId, projectId, parentStateId, sessionId: null, turnId: null,
|
|
178
|
+
stateType, label, summary: null, reason: null, decisionNotes: null, riskLevel: null,
|
|
179
|
+
createdAt: new Date().toISOString(), gitCommitSha: git.commitSha,
|
|
180
|
+
workingTreeHash: manifestFingerprint(currentManifest),
|
|
181
|
+
});
|
|
182
|
+
ctx.db.insertFileChanges(stateId, changesWithStats);
|
|
183
|
+
ctx.db.insertStateManifest(stateId, currentManifest);
|
|
184
|
+
return stateId;
|
|
185
|
+
}
|
|
186
|
+
export async function restoreState(ctx, projectId, targetStateId) {
|
|
187
|
+
const head = ctx.db.getHeadState(projectId);
|
|
188
|
+
if (!head)
|
|
189
|
+
throw new Error("No head state found.");
|
|
190
|
+
const target = ctx.db.getState(targetStateId);
|
|
191
|
+
if (!target)
|
|
192
|
+
throw new Error(`Unknown state: ${targetStateId}`);
|
|
193
|
+
// Preserve whatever is currently on disk as a real state first.
|
|
194
|
+
const safetyStateId = await snapshotCurrentDisk(ctx, projectId, head.id, "checkpoint", "Before restore");
|
|
195
|
+
// Apply target manifest to the working tree.
|
|
196
|
+
const targetManifest = ctx.db.getStateManifest(targetStateId);
|
|
197
|
+
applyManifestToDisk(ctx, targetManifest);
|
|
198
|
+
// Record the restore as a new state, parented on the actual restore
|
|
199
|
+
// target — not the safety snapshot — so the state graph shows the real
|
|
200
|
+
// branch: the target now has two futures, the original one (preserved via
|
|
201
|
+
// the safety snapshot, itself parented on the pre-restore head) and this
|
|
202
|
+
// new restored one. This is what makes the timeline an actual graph
|
|
203
|
+
// instead of a straight line no matter how many times you restore.
|
|
204
|
+
const restoredStateId = randomUUID();
|
|
205
|
+
const git = await detectGit(ctx.projectRoot);
|
|
206
|
+
const changesFromSafety = withLineStats(ctx, diffManifests(ctx.db.getStateManifest(safetyStateId), targetManifest));
|
|
207
|
+
ctx.db.insertState({
|
|
208
|
+
id: restoredStateId, projectId, parentStateId: targetStateId, sessionId: null, turnId: null,
|
|
209
|
+
stateType: "restore", label: `Restored to ${target.label ?? target.id}`, summary: null,
|
|
210
|
+
reason: null, decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
|
|
211
|
+
gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(targetManifest),
|
|
212
|
+
});
|
|
213
|
+
ctx.db.insertFileChanges(restoredStateId, changesFromSafety);
|
|
214
|
+
ctx.db.insertStateManifest(restoredStateId, targetManifest);
|
|
215
|
+
ctx.db.setHeadState(projectId, restoredStateId);
|
|
216
|
+
ctx.db.insertEvent({
|
|
217
|
+
id: randomUUID(), projectId, type: "state_restored",
|
|
218
|
+
payloadJson: JSON.stringify({ targetStateId, safetyStateId, restoredStateId }),
|
|
219
|
+
});
|
|
220
|
+
return { safetyStateId, restoredStateId };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Restores only specific files from a target state, leaving everything
|
|
224
|
+
* else on disk exactly as it is. For a file the target state didn't have
|
|
225
|
+
* (it was added after that point), "restoring" it means deleting it now —
|
|
226
|
+
* matching what a full restore would do to that same file.
|
|
227
|
+
*
|
|
228
|
+
* Unlike a full restore, the resulting state is parented on the safety
|
|
229
|
+
* snapshot, not the target — the disk state here is overwhelmingly a
|
|
230
|
+
* continuation of current work with a few files patched in from history,
|
|
231
|
+
* not a jump back to the target's own timeline. A full restore's "the
|
|
232
|
+
* target now has two futures" framing doesn't apply when only one file
|
|
233
|
+
* out of dozens came from that target.
|
|
234
|
+
*/
|
|
235
|
+
export async function restoreFiles(ctx, projectId, targetStateId, filePaths) {
|
|
236
|
+
const head = ctx.db.getHeadState(projectId);
|
|
237
|
+
if (!head)
|
|
238
|
+
throw new Error("No head state found.");
|
|
239
|
+
const target = ctx.db.getState(targetStateId);
|
|
240
|
+
if (!target)
|
|
241
|
+
throw new Error(`Unknown state: ${targetStateId}`);
|
|
242
|
+
if (filePaths.length === 0)
|
|
243
|
+
throw new Error("No files specified to restore.");
|
|
244
|
+
const safetyStateId = await snapshotCurrentDisk(ctx, projectId, head.id, "checkpoint", "Before selective restore");
|
|
245
|
+
const targetManifest = ctx.db.getStateManifest(targetStateId);
|
|
246
|
+
const targetByPath = new Map(targetManifest.map((e) => [e.filePath, e]));
|
|
247
|
+
const filesRestored = [];
|
|
248
|
+
for (const filePath of filePaths) {
|
|
249
|
+
const abs = join(ctx.projectRoot, filePath);
|
|
250
|
+
const entry = targetByPath.get(filePath);
|
|
251
|
+
if (entry) {
|
|
252
|
+
const content = ctx.store.read(entry.contentHash);
|
|
253
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
254
|
+
writeFileSync(abs, content);
|
|
255
|
+
filesRestored.push(filePath);
|
|
256
|
+
}
|
|
257
|
+
else if (existsSync(abs)) {
|
|
258
|
+
unlinkSync(abs);
|
|
259
|
+
filesRestored.push(filePath);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const safetyManifest = ctx.db.getStateManifest(safetyStateId);
|
|
263
|
+
const restoredSet = new Set(filesRestored);
|
|
264
|
+
const newManifest = safetyManifest.filter((e) => !restoredSet.has(e.filePath));
|
|
265
|
+
for (const filePath of filesRestored) {
|
|
266
|
+
const entry = targetByPath.get(filePath);
|
|
267
|
+
if (entry)
|
|
268
|
+
newManifest.push(entry);
|
|
269
|
+
}
|
|
270
|
+
const restoredStateId = randomUUID();
|
|
271
|
+
const git = await detectGit(ctx.projectRoot);
|
|
272
|
+
const changesFromSafety = withLineStats(ctx, diffManifests(safetyManifest, newManifest));
|
|
273
|
+
ctx.db.insertState({
|
|
274
|
+
id: restoredStateId, projectId, parentStateId: safetyStateId, sessionId: null, turnId: null,
|
|
275
|
+
stateType: "restore",
|
|
276
|
+
label: `Restored ${filesRestored.length} file${filesRestored.length === 1 ? "" : "s"} from ${target.label ?? target.id.slice(0, 8)}`,
|
|
277
|
+
summary: null, reason: null, decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
|
|
278
|
+
gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(newManifest),
|
|
279
|
+
});
|
|
280
|
+
ctx.db.insertFileChanges(restoredStateId, changesFromSafety);
|
|
281
|
+
ctx.db.insertStateManifest(restoredStateId, newManifest);
|
|
282
|
+
ctx.db.setHeadState(projectId, restoredStateId);
|
|
283
|
+
ctx.db.insertEvent({
|
|
284
|
+
id: randomUUID(), projectId, type: "state_restored",
|
|
285
|
+
payloadJson: JSON.stringify({ targetStateId, safetyStateId, restoredStateId, filePaths: filesRestored, selective: true }),
|
|
286
|
+
});
|
|
287
|
+
return { safetyStateId, restoredStateId, filesRestored };
|
|
288
|
+
}
|
|
289
|
+
export async function forkState(ctx, projectId, sourceStateId, forkName) {
|
|
290
|
+
const head = ctx.db.getHeadState(projectId);
|
|
291
|
+
if (!head)
|
|
292
|
+
throw new Error("No head state found.");
|
|
293
|
+
const source = ctx.db.getState(sourceStateId);
|
|
294
|
+
if (!source)
|
|
295
|
+
throw new Error(`Unknown state: ${sourceStateId}`);
|
|
296
|
+
const safetyStateId = await snapshotCurrentDisk(ctx, projectId, head.id, "checkpoint", "Before fork");
|
|
297
|
+
const sourceManifest = ctx.db.getStateManifest(sourceStateId);
|
|
298
|
+
applyManifestToDisk(ctx, sourceManifest);
|
|
299
|
+
// Same reasoning as restoreState: parent the fork on its actual source
|
|
300
|
+
// state so it becomes a real sibling branch of the source's other
|
|
301
|
+
// children, not a dead-end hanging off the safety snapshot.
|
|
302
|
+
const forkStateId = randomUUID();
|
|
303
|
+
const git = await detectGit(ctx.projectRoot);
|
|
304
|
+
const changesFromSafety = withLineStats(ctx, diffManifests(ctx.db.getStateManifest(safetyStateId), sourceManifest));
|
|
305
|
+
ctx.db.insertState({
|
|
306
|
+
id: forkStateId, projectId, parentStateId: sourceStateId, sessionId: null, turnId: null,
|
|
307
|
+
stateType: "fork", label: `Fork: ${forkName}`, summary: null, reason: null,
|
|
308
|
+
decisionNotes: null, riskLevel: null, createdAt: new Date().toISOString(),
|
|
309
|
+
gitCommitSha: git.commitSha, workingTreeHash: manifestFingerprint(sourceManifest),
|
|
310
|
+
});
|
|
311
|
+
ctx.db.insertFileChanges(forkStateId, changesFromSafety);
|
|
312
|
+
ctx.db.insertStateManifest(forkStateId, sourceManifest);
|
|
313
|
+
ctx.db.setHeadState(projectId, forkStateId);
|
|
314
|
+
ctx.db.insertFork({ id: randomUUID(), projectId, sourceStateId, name: forkName });
|
|
315
|
+
ctx.db.insertEvent({
|
|
316
|
+
id: randomUUID(), projectId, type: "state_forked",
|
|
317
|
+
payloadJson: JSON.stringify({ sourceStateId, forkStateId, forkName }),
|
|
318
|
+
});
|
|
319
|
+
return { safetyStateId, forkStateId };
|
|
320
|
+
}
|
|
321
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export function buildExportData(db, project) {
|
|
2
|
+
const states = db.listStates(project.id).map((s) => {
|
|
3
|
+
const annotation = db.getAnnotation(s.id);
|
|
4
|
+
const changes = db.getFileChanges(s.id);
|
|
5
|
+
return {
|
|
6
|
+
id: s.id,
|
|
7
|
+
label: s.label,
|
|
8
|
+
stateType: s.stateType,
|
|
9
|
+
createdAt: s.createdAt,
|
|
10
|
+
riskLevel: annotation?.riskLevel ?? s.riskLevel,
|
|
11
|
+
summary: annotation?.status === "generated" ? annotation.summary : s.summary,
|
|
12
|
+
reason: annotation?.status === "generated" ? annotation.reason : s.reason,
|
|
13
|
+
decisions: annotation?.status === "generated" ? annotation.decisions : [],
|
|
14
|
+
annotationStatus: annotation?.status ?? null,
|
|
15
|
+
fileChanges: changes.map((c) => ({
|
|
16
|
+
filePath: c.filePath, changeType: c.changeType, linesAdded: c.linesAdded, linesRemoved: c.linesRemoved,
|
|
17
|
+
})),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
return { project: { name: project.name, exportedAt: new Date().toISOString() }, states };
|
|
21
|
+
}
|
|
22
|
+
export function exportAsJson(data) {
|
|
23
|
+
return JSON.stringify(data, null, 2);
|
|
24
|
+
}
|
|
25
|
+
export function exportAsMarkdown(data) {
|
|
26
|
+
const lines = [`# ${data.project.name} — whydid export`, "", `Exported: ${data.project.exportedAt}`, ""];
|
|
27
|
+
for (const s of data.states) {
|
|
28
|
+
lines.push(`## ${s.label ?? s.stateType} (#${s.id.slice(0, 8)})`, "");
|
|
29
|
+
lines.push(`- Type: ${s.stateType}`, `- Created: ${s.createdAt}`);
|
|
30
|
+
if (s.riskLevel)
|
|
31
|
+
lines.push(`- Risk: ${s.riskLevel}`);
|
|
32
|
+
lines.push("");
|
|
33
|
+
if (s.reason)
|
|
34
|
+
lines.push(`**Why:** ${s.reason}`, "");
|
|
35
|
+
if (s.decisions.length > 0) {
|
|
36
|
+
lines.push("**Decisions:**");
|
|
37
|
+
for (const d of s.decisions)
|
|
38
|
+
lines.push(`- ${d}`);
|
|
39
|
+
lines.push("");
|
|
40
|
+
}
|
|
41
|
+
lines.push(`**Files changed (${s.fileChanges.length}):**`);
|
|
42
|
+
for (const c of s.fileChanges)
|
|
43
|
+
lines.push(`- \`${c.filePath}\` (${c.changeType}, +${c.linesAdded}/-${c.linesRemoved})`);
|
|
44
|
+
lines.push("");
|
|
45
|
+
}
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
function escapeHtml(text) {
|
|
49
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
50
|
+
}
|
|
51
|
+
export function exportAsHtml(data) {
|
|
52
|
+
const stateBlocks = data.states
|
|
53
|
+
.map((s) => {
|
|
54
|
+
const decisions = s.decisions.map((d) => `<li>${escapeHtml(d)}</li>`).join("");
|
|
55
|
+
const files = s.fileChanges
|
|
56
|
+
.map((c) => `<li><code>${escapeHtml(c.filePath)}</code> <span class="change-type">${c.changeType}</span> <span class="stats">+${c.linesAdded}/-${c.linesRemoved}</span></li>`)
|
|
57
|
+
.join("");
|
|
58
|
+
return `
|
|
59
|
+
<section class="state">
|
|
60
|
+
<h2>${escapeHtml(s.label ?? s.stateType)} <span class="state-id">#${s.id.slice(0, 8)}</span></h2>
|
|
61
|
+
<p class="meta">${escapeHtml(s.stateType)} · ${escapeHtml(s.createdAt)}${s.riskLevel ? ` · <span class="risk risk-${s.riskLevel}">${s.riskLevel} risk</span>` : ""}</p>
|
|
62
|
+
${s.reason ? `<p class="why"><strong>Why:</strong> ${escapeHtml(s.reason)}</p>` : ""}
|
|
63
|
+
${decisions ? `<p><strong>Decisions:</strong></p><ul>${decisions}</ul>` : ""}
|
|
64
|
+
<p><strong>Files changed (${s.fileChanges.length}):</strong></p>
|
|
65
|
+
<ul class="files">${files}</ul>
|
|
66
|
+
</section>`;
|
|
67
|
+
})
|
|
68
|
+
.join("\n");
|
|
69
|
+
return `<!doctype html>
|
|
70
|
+
<html lang="en">
|
|
71
|
+
<head>
|
|
72
|
+
<meta charset="UTF-8" />
|
|
73
|
+
<title>${escapeHtml(data.project.name)} — whydid export</title>
|
|
74
|
+
<style>
|
|
75
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 760px; margin: 0 auto; padding: 2rem 1.5rem; color: #1a1a1a; background: #fff; }
|
|
76
|
+
h1 { font-size: 1.6rem; }
|
|
77
|
+
h2 { font-size: 1.15rem; margin-bottom: 0.25rem; }
|
|
78
|
+
.state-id { color: #888; font-weight: normal; font-size: 0.85em; }
|
|
79
|
+
.meta { color: #666; font-size: 0.9em; margin-top: 0; }
|
|
80
|
+
.why { background: #f6f6f6; padding: 0.75rem 1rem; border-radius: 6px; }
|
|
81
|
+
.risk { padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.8em; }
|
|
82
|
+
.risk-low { background: #dcfce7; color: #166534; }
|
|
83
|
+
.risk-medium { background: #fef3c7; color: #92400e; }
|
|
84
|
+
.risk-high { background: #fee2e2; color: #991b1b; }
|
|
85
|
+
ul.files { font-size: 0.9em; }
|
|
86
|
+
.change-type { color: #888; text-transform: uppercase; font-size: 0.75em; }
|
|
87
|
+
.stats { color: #888; font-size: 0.85em; }
|
|
88
|
+
section.state { border-top: 1px solid #e5e5e5; padding: 1.5rem 0; }
|
|
89
|
+
</style>
|
|
90
|
+
</head>
|
|
91
|
+
<body>
|
|
92
|
+
<h1>${escapeHtml(data.project.name)}</h1>
|
|
93
|
+
<p class="meta">whydid export — ${escapeHtml(data.project.exportedAt)}</p>
|
|
94
|
+
${stateBlocks}
|
|
95
|
+
</body>
|
|
96
|
+
</html>`;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=export.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every state that touched a given file, oldest first, across the whole
|
|
3
|
+
* project history (not just one branch) — so a later change that lands on
|
|
4
|
+
* top of an earlier one in the same file is visible, even if the user asks
|
|
5
|
+
* about it long after both happened.
|
|
6
|
+
*/
|
|
7
|
+
export function getFileHistory(db, projectId, filePath) {
|
|
8
|
+
const states = db.listStates(projectId).slice().reverse(); // oldest first
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const state of states) {
|
|
11
|
+
const change = db.getFileChanges(state.id).find((c) => c.filePath === filePath);
|
|
12
|
+
if (!change)
|
|
13
|
+
continue;
|
|
14
|
+
const annotation = db.getAnnotation(state.id);
|
|
15
|
+
const turn = state.turnId ? db.getTurn(state.turnId) : undefined;
|
|
16
|
+
entries.push({
|
|
17
|
+
stateId: state.id,
|
|
18
|
+
stateLabel: state.label,
|
|
19
|
+
stateType: state.stateType,
|
|
20
|
+
createdAt: state.createdAt,
|
|
21
|
+
changeType: change.changeType,
|
|
22
|
+
turnPrompt: turn?.prompt ?? null,
|
|
23
|
+
annotationSummary: annotation?.status === "generated" ? annotation.summary : null,
|
|
24
|
+
annotationReason: annotation?.status === "generated" ? annotation.reason : null,
|
|
25
|
+
annotationStatus: annotation?.status ?? null,
|
|
26
|
+
buildsOnPriorChange: entries.length > 0 && change.changeType !== "added",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return entries;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=file-history.js.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { hashContent } from "../objects/store.js";
|
|
4
|
+
const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024;
|
|
5
|
+
function walk(dir, root, ignoreRules, out) {
|
|
6
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
7
|
+
const abs = join(dir, entry.name);
|
|
8
|
+
const rel = relative(root, abs).split("\\").join("/");
|
|
9
|
+
if (ignoreRules.isIgnored(entry.isDirectory() ? `${rel}/` : rel))
|
|
10
|
+
continue;
|
|
11
|
+
if (entry.isDirectory()) {
|
|
12
|
+
walk(abs, root, ignoreRules, out);
|
|
13
|
+
}
|
|
14
|
+
else if (entry.isFile()) {
|
|
15
|
+
out.push(abs);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Guard against the classic "racy stat cache" bug (the same one git works
|
|
20
|
+
// around): if a file's mtime is very close to "now", a filesystem with
|
|
21
|
+
// coarse (e.g. 1s) mtime resolution could report the same mtime for two
|
|
22
|
+
// different writes made in quick succession. Refuse to cache — and refuse
|
|
23
|
+
// to trust a cached entry for — any file whose mtime falls within this
|
|
24
|
+
// window of the current scan, so a same-tick rewrite always gets re-hashed
|
|
25
|
+
// instead of silently reusing a stale hash.
|
|
26
|
+
const RACY_WINDOW_MS = 1100;
|
|
27
|
+
// Every checkpoint used to re-read and re-hash every tracked file's full
|
|
28
|
+
// content, even when nothing about the file changed. On a project with
|
|
29
|
+
// tens of thousands of tracked files that made every checkpoint cost
|
|
30
|
+
// seconds regardless of how small the actual edit was. Content hashes are
|
|
31
|
+
// immutable for a given (path, mtime, size) triple in real editing
|
|
32
|
+
// workflows, so this cache lets a file skip its content read+hash
|
|
33
|
+
// entirely when neither its mtime nor its size moved since the last scan
|
|
34
|
+
// of this project root — the returned manifest is byte-identical either
|
|
35
|
+
// way, only the cost of producing it changes. The cache is in-memory only
|
|
36
|
+
// (keyed by project root) and rebuilds itself on first scan after a
|
|
37
|
+
// process restart, so there is no persistence/correctness surface to get
|
|
38
|
+
// wrong.
|
|
39
|
+
const statCache = new Map();
|
|
40
|
+
export async function buildManifest(projectRoot, ignoreRules, maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE) {
|
|
41
|
+
const absFiles = [];
|
|
42
|
+
walk(projectRoot, projectRoot, ignoreRules, absFiles);
|
|
43
|
+
let cache = statCache.get(projectRoot);
|
|
44
|
+
if (!cache) {
|
|
45
|
+
cache = new Map();
|
|
46
|
+
statCache.set(projectRoot, cache);
|
|
47
|
+
}
|
|
48
|
+
const scanStartMs = Date.now();
|
|
49
|
+
const entries = [];
|
|
50
|
+
const seenPaths = new Set();
|
|
51
|
+
for (const abs of absFiles) {
|
|
52
|
+
const stat = statSync(abs);
|
|
53
|
+
if (stat.size > maxFileSizeBytes)
|
|
54
|
+
continue;
|
|
55
|
+
const relPath = relative(projectRoot, abs).split("\\").join("/");
|
|
56
|
+
seenPaths.add(relPath);
|
|
57
|
+
const isRacy = scanStartMs - stat.mtimeMs < RACY_WINDOW_MS;
|
|
58
|
+
const cached = cache.get(relPath);
|
|
59
|
+
let contentHash;
|
|
60
|
+
if (!isRacy && cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
61
|
+
contentHash = cached.contentHash;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const content = readFileSync(abs);
|
|
65
|
+
contentHash = hashContent(content);
|
|
66
|
+
if (!isRacy)
|
|
67
|
+
cache.set(relPath, { mtimeMs: stat.mtimeMs, size: stat.size, contentHash });
|
|
68
|
+
else
|
|
69
|
+
cache.delete(relPath);
|
|
70
|
+
}
|
|
71
|
+
entries.push({ filePath: relPath, contentHash, sizeBytes: stat.size });
|
|
72
|
+
}
|
|
73
|
+
// Drop cache entries for files that no longer exist so the cache doesn't
|
|
74
|
+
// grow unbounded across a project's lifetime (renames, deletes, etc.).
|
|
75
|
+
for (const cachedPath of cache.keys()) {
|
|
76
|
+
if (!seenPaths.has(cachedPath))
|
|
77
|
+
cache.delete(cachedPath);
|
|
78
|
+
}
|
|
79
|
+
entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
export function diffManifests(before, after) {
|
|
83
|
+
const beforeMap = new Map(before.map((e) => [e.filePath, e]));
|
|
84
|
+
const afterMap = new Map(after.map((e) => [e.filePath, e]));
|
|
85
|
+
const changes = [];
|
|
86
|
+
for (const [filePath, afterEntry] of afterMap) {
|
|
87
|
+
const beforeEntry = beforeMap.get(filePath);
|
|
88
|
+
if (!beforeEntry) {
|
|
89
|
+
changes.push({ filePath, changeType: "added", beforeHash: null, afterHash: afterEntry.contentHash, linesAdded: 0, linesRemoved: 0 });
|
|
90
|
+
}
|
|
91
|
+
else if (beforeEntry.contentHash !== afterEntry.contentHash) {
|
|
92
|
+
changes.push({ filePath, changeType: "modified", beforeHash: beforeEntry.contentHash, afterHash: afterEntry.contentHash, linesAdded: 0, linesRemoved: 0 });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const [filePath, beforeEntry] of beforeMap) {
|
|
96
|
+
if (!afterMap.has(filePath)) {
|
|
97
|
+
changes.push({ filePath, changeType: "deleted", beforeHash: beforeEntry.contentHash, afterHash: null, linesAdded: 0, linesRemoved: 0 });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return changes;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const SKIP_SEGMENTS = new Set(["src", "lib", "app", "apps", "packages", "test", "tests", "__tests__"]);
|
|
2
|
+
function deriveArea(filePath) {
|
|
3
|
+
const parts = filePath.split("/");
|
|
4
|
+
const directories = parts.slice(0, -1).filter((p) => !SKIP_SEGMENTS.has(p.toLowerCase()));
|
|
5
|
+
if (directories.length === 0)
|
|
6
|
+
return null;
|
|
7
|
+
const raw = directories[0];
|
|
8
|
+
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
9
|
+
}
|
|
10
|
+
export function computeRecap(db, projectId, sinceIso) {
|
|
11
|
+
const states = db.listStates(projectId).filter((s) => s.createdAt >= sinceIso && s.stateType !== "initial");
|
|
12
|
+
const fileChurn = new Map();
|
|
13
|
+
const areaCounts = new Map();
|
|
14
|
+
const riskyAreas = new Set();
|
|
15
|
+
let unexplainedChanges = 0;
|
|
16
|
+
for (const state of states) {
|
|
17
|
+
const changes = db.getFileChanges(state.id);
|
|
18
|
+
for (const change of changes) {
|
|
19
|
+
fileChurn.set(change.filePath, (fileChurn.get(change.filePath) ?? 0) + 1);
|
|
20
|
+
const area = deriveArea(change.filePath);
|
|
21
|
+
if (area)
|
|
22
|
+
areaCounts.set(area, (areaCounts.get(area) ?? 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
const annotation = db.getAnnotation(state.id);
|
|
25
|
+
if (!annotation || annotation.status !== "generated") {
|
|
26
|
+
unexplainedChanges += 1;
|
|
27
|
+
}
|
|
28
|
+
else if (annotation.riskLevel === "medium" || annotation.riskLevel === "high") {
|
|
29
|
+
riskyAreas.add(annotation.summary || state.label || `State #${state.id.slice(0, 8)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const highChurnFiles = [...fileChurn.entries()]
|
|
33
|
+
.sort((a, b) => b[1] - a[1])
|
|
34
|
+
.slice(0, 5)
|
|
35
|
+
.map(([filePath, changes]) => ({ filePath, changes }));
|
|
36
|
+
const mainAreas = [...areaCounts.entries()]
|
|
37
|
+
.sort((a, b) => b[1] - a[1])
|
|
38
|
+
.slice(0, 5)
|
|
39
|
+
.map(([area]) => area);
|
|
40
|
+
return {
|
|
41
|
+
totalChanges: states.length,
|
|
42
|
+
mainAreas,
|
|
43
|
+
highChurnFiles,
|
|
44
|
+
riskyAreas: [...riskyAreas].slice(0, 5),
|
|
45
|
+
unexplainedChanges,
|
|
46
|
+
latestTestRun: db.getLatestTestRunForProject(projectId) ?? null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=recap.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { exec } from "node:child_process";
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
6
|
+
export async function runTestCommand(ctx, stateId, command, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
7
|
+
const logsDir = join(ctx.whydidDir, "logs");
|
|
8
|
+
mkdirSync(logsDir, { recursive: true });
|
|
9
|
+
const outputPath = join(logsDir, `test-${stateId}.log`);
|
|
10
|
+
const start = Date.now();
|
|
11
|
+
const { exitCode, output } = await new Promise((resolve) => {
|
|
12
|
+
exec(command, { cwd: ctx.projectRoot, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
13
|
+
resolve({ exitCode: error ? (typeof error.code === "number" ? error.code : 1) : 0, output: `${stdout}\n${stderr}` });
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
const durationMs = Date.now() - start;
|
|
17
|
+
writeFileSync(outputPath, output);
|
|
18
|
+
const status = exitCode === 0 ? "passed" : "failed";
|
|
19
|
+
const id = randomUUID();
|
|
20
|
+
ctx.db.insertTestRun({ id, stateId, command, status, exitCode, durationMs, outputPath });
|
|
21
|
+
return { id, status, exitCode, durationMs, outputPath };
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=test-runner.js.map
|