@bridge4dev/runner 0.27.0 → 0.29.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/dist/adapters/claude.js +136 -8
- package/dist/adapters/codex.js +96 -2
- package/dist/adapters/types.d.ts +60 -1
- package/dist/attachments.d.ts +27 -0
- package/dist/attachments.js +150 -8
- package/dist/checkpoints.d.ts +175 -0
- package/dist/checkpoints.js +816 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +17 -0
- package/dist/index.js +30 -0
- package/dist/journal.d.ts +34 -1
- package/dist/journal.js +51 -2
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +12 -0
- package/dist/policy.js +15 -0
- package/dist/protocol.d.ts +5 -5
- package/dist/supervisor.d.ts +86 -0
- package/dist/supervisor.js +555 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { checkpointsDir } from './paths.js';
|
|
7
|
+
import { isSecretPath } from './policy.js';
|
|
8
|
+
import { log } from './log.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
// Ticket #126 — restore points for a session's working tree.
|
|
11
|
+
//
|
|
12
|
+
// A checkpoint is an ordinary git tree, built from the worktree as it stands,
|
|
13
|
+
// and kept in an object store of the RUNNER's, never in the project's `.git`.
|
|
14
|
+
// Three properties come out of that choice, and all three are load-bearing:
|
|
15
|
+
//
|
|
16
|
+
// 1. `git status --porcelain` is byte-identical before and after. Nothing is
|
|
17
|
+
// staged, stashed, committed or checked out to take one — verified on live
|
|
18
|
+
// repositories in the test suite.
|
|
19
|
+
// 2. The blobs are outside `.git/objects`, so `git push --mirror` cannot carry
|
|
20
|
+
// them anywhere and `git log --all -p` inside the worktree is structurally
|
|
21
|
+
// blind to them. The agent's own reads (Bash `git`, `cat`, `grep`) are
|
|
22
|
+
// denied by `SECRET_PATH_PATTERNS`, which already covers the whole
|
|
23
|
+
// `devbridge-runner` tree.
|
|
24
|
+
// 3. Secrets never become objects in the first place. The index is built from
|
|
25
|
+
// an EXPLICIT file list with `update-index`, not from `add -A`, so a `.env`
|
|
26
|
+
// is not blobbed and then filtered out of the tree — it is never read.
|
|
27
|
+
// That distinction matters: an unreferenced blob is still a readable blob.
|
|
28
|
+
//
|
|
29
|
+
// The single source of truth for what counts as a secret is `isSecretPath` in
|
|
30
|
+
// `policy.ts`. There is deliberately no second list here (gotcha #110).
|
|
31
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
32
|
+
/** `--literal-pathspecs`: a path is a path, never a pattern (QA-105 MAJOR-1). */
|
|
33
|
+
const GIT_GLOBAL_ARGS = ['--literal-pathspecs', '-c', 'core.quotePath=false'];
|
|
34
|
+
/** argv has a ceiling; every path list is fed in batches of this size. */
|
|
35
|
+
const PATH_BATCH = 400;
|
|
36
|
+
/**
|
|
37
|
+
* Ceilings on one checkpoint.
|
|
38
|
+
*
|
|
39
|
+
* An unignored `dist/` would otherwise be blobbed on every single turn. When a
|
|
40
|
+
* ceiling is hit the checkpoint is SKIPPED and the caller says so out loud —
|
|
41
|
+
* a restore point that silently is not there is worse than none at all.
|
|
42
|
+
*/
|
|
43
|
+
const MAX_CHECKPOINT_FILES = 20_000;
|
|
44
|
+
const MAX_CHECKPOINT_BYTES = 512 * 1024 * 1024;
|
|
45
|
+
/** A single file bigger than this is left out of the checkpoint (with a notice). */
|
|
46
|
+
const MAX_CHECKPOINT_FILE_BYTES = 64 * 1024 * 1024;
|
|
47
|
+
/** Retention: nothing older than this survives, whatever the ordinal. */
|
|
48
|
+
export const CHECKPOINT_MAX_AGE_MS = 14 * 24 * 3_600_000;
|
|
49
|
+
/** Retention: the newest N per session. */
|
|
50
|
+
export const CHECKPOINT_MAX_PER_SESSION = 200;
|
|
51
|
+
/**
|
|
52
|
+
* How long the points of a session nobody mentions are kept anyway.
|
|
53
|
+
*
|
|
54
|
+
* The list of live sessions arrives capped, so absence from it is weak
|
|
55
|
+
* evidence. A day of silence is strong evidence — and until then the cost of
|
|
56
|
+
* being wrong is disk, while the cost of being wrong the other way is the only
|
|
57
|
+
* copy of somebody's uncommitted work.
|
|
58
|
+
*/
|
|
59
|
+
export const ORPHAN_GRACE_MS = 24 * 3_600_000;
|
|
60
|
+
/**
|
|
61
|
+
* Where this repository's checkpoints live.
|
|
62
|
+
*
|
|
63
|
+
* Keyed by the repo's common git dir, so a session worktree and the project
|
|
64
|
+
* folder it belongs to share one store — which is what makes a DIRECT session
|
|
65
|
+
* and a BRANCH session on the same project interchangeable here.
|
|
66
|
+
*/
|
|
67
|
+
async function storeFor(worktreePath) {
|
|
68
|
+
const commonDir = await gitIn(worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir');
|
|
69
|
+
const real = await realpath(commonDir);
|
|
70
|
+
const key = createHash('sha256').update(real).digest('hex').slice(0, 32);
|
|
71
|
+
return {
|
|
72
|
+
store: path.join(checkpointsDir(), `${key}.git`),
|
|
73
|
+
objectDir: path.join(real, 'objects'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async function realpath(p) {
|
|
77
|
+
try {
|
|
78
|
+
return await fs.promises.realpath(p);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function gitIn(cwd, ...args) {
|
|
85
|
+
const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, ...args], {
|
|
86
|
+
cwd,
|
|
87
|
+
timeout: GIT_TIMEOUT_MS,
|
|
88
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
89
|
+
// The project's own environment must not leak in: a GIT_INDEX_FILE or
|
|
90
|
+
// GIT_DIR inherited from a parent process would silently retarget every
|
|
91
|
+
// command below at the wrong repository.
|
|
92
|
+
env: cleanEnv(),
|
|
93
|
+
});
|
|
94
|
+
return stdout.replace(/\n$/, '');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Run git against the checkpoint store with the project as its working tree.
|
|
98
|
+
*
|
|
99
|
+
* `GIT_INDEX_FILE` is inside the runner's state directory and never inside the
|
|
100
|
+
* worktree: an index file left in the project would be blobbed by the NEXT
|
|
101
|
+
* checkpoint, which is both a leak and an infinite growth loop.
|
|
102
|
+
*/
|
|
103
|
+
async function gitStore(store, worktreePath, indexFile, ...args) {
|
|
104
|
+
const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, ...args], {
|
|
105
|
+
cwd: worktreePath,
|
|
106
|
+
timeout: GIT_TIMEOUT_MS,
|
|
107
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
108
|
+
env: {
|
|
109
|
+
...cleanEnv(),
|
|
110
|
+
GIT_DIR: store,
|
|
111
|
+
GIT_WORK_TREE: worktreePath,
|
|
112
|
+
GIT_INDEX_FILE: indexFile,
|
|
113
|
+
GIT_AUTHOR_NAME: 'DevBridge',
|
|
114
|
+
GIT_AUTHOR_EMAIL: 'runner@devbridge.local',
|
|
115
|
+
GIT_COMMITTER_NAME: 'DevBridge',
|
|
116
|
+
GIT_COMMITTER_EMAIL: 'runner@devbridge.local',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
return stdout.replace(/\n$/, '');
|
|
120
|
+
}
|
|
121
|
+
function cleanEnv() {
|
|
122
|
+
const env = { ...process.env };
|
|
123
|
+
delete env['GIT_DIR'];
|
|
124
|
+
delete env['GIT_WORK_TREE'];
|
|
125
|
+
delete env['GIT_INDEX_FILE'];
|
|
126
|
+
delete env['GIT_OBJECT_DIRECTORY'];
|
|
127
|
+
delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
|
|
128
|
+
return env;
|
|
129
|
+
}
|
|
130
|
+
async function ensureStore(worktreePath) {
|
|
131
|
+
const { store, objectDir } = await storeFor(worktreePath);
|
|
132
|
+
if (!fs.existsSync(store)) {
|
|
133
|
+
fs.mkdirSync(path.dirname(store), { recursive: true, mode: 0o700 });
|
|
134
|
+
await execFileAsync('git', ['init', '--quiet', '--bare', store], {
|
|
135
|
+
timeout: GIT_TIMEOUT_MS,
|
|
136
|
+
env: cleanEnv(),
|
|
137
|
+
});
|
|
138
|
+
fs.chmodSync(store, 0o700);
|
|
139
|
+
}
|
|
140
|
+
// Alternates let the store READ the project's objects (so `read-tree HEAD`
|
|
141
|
+
// resolves) while every object it WRITES stays on its own side. The direction
|
|
142
|
+
// is one-way: the project's git cannot see anything in here — asserted in the
|
|
143
|
+
// test suite, because that one-way-ness is the whole security argument.
|
|
144
|
+
const alternates = path.join(store, 'objects', 'info', 'alternates');
|
|
145
|
+
const wanted = `${objectDir}\n`;
|
|
146
|
+
if (!fs.existsSync(alternates) || fs.readFileSync(alternates, 'utf8') !== wanted) {
|
|
147
|
+
fs.mkdirSync(path.dirname(alternates), { recursive: true, mode: 0o700 });
|
|
148
|
+
fs.writeFileSync(alternates, wanted, { mode: 0o600 });
|
|
149
|
+
}
|
|
150
|
+
return store;
|
|
151
|
+
}
|
|
152
|
+
function indexFileFor(sessionId) {
|
|
153
|
+
return path.join(checkpointsDir(), 'index', `${sessionId}.idx`);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A private index file for ONE operation.
|
|
157
|
+
*
|
|
158
|
+
* Not one per session: a checkpoint taken while a preview is being built would
|
|
159
|
+
* otherwise share an index with it, and the loser of that race writes a tree
|
|
160
|
+
* describing a state that never existed. `git` gives no locking here — the
|
|
161
|
+
* file IS the state — so the isolation has to come from the name.
|
|
162
|
+
*/
|
|
163
|
+
let indexCounter = 0;
|
|
164
|
+
function tempIndexFile(sessionId, tag) {
|
|
165
|
+
indexCounter += 1;
|
|
166
|
+
return path.join(checkpointsDir(), 'index', `${sessionId}.${tag}.${process.pid}.${indexCounter}.idx`);
|
|
167
|
+
}
|
|
168
|
+
const REF_PREFIX = 'refs/devbridge/checkpoints/';
|
|
169
|
+
function refFor(sessionId, ordinal) {
|
|
170
|
+
return `${REF_PREFIX}${sessionId}/${ordinal}`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Run git against a store alone — no working tree involved.
|
|
174
|
+
*
|
|
175
|
+
* Ref bookkeeping and garbage collection need no checkout, and demanding one
|
|
176
|
+
* would make housekeeping impossible for exactly the repositories that need it
|
|
177
|
+
* most: the ones whose sessions are already gone.
|
|
178
|
+
*/
|
|
179
|
+
async function gitRefs(store, ...args) {
|
|
180
|
+
const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, '--git-dir', store, ...args], {
|
|
181
|
+
timeout: GIT_TIMEOUT_MS,
|
|
182
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
183
|
+
env: cleanEnv(),
|
|
184
|
+
});
|
|
185
|
+
return stdout.replace(/\n$/, '');
|
|
186
|
+
}
|
|
187
|
+
/** Checkpoint metadata read straight from a store, with no worktree. */
|
|
188
|
+
async function readRecord(store, sessionId, ordinal) {
|
|
189
|
+
try {
|
|
190
|
+
const commit = await gitRefs(store, 'rev-parse', `${refFor(sessionId, ordinal)}^{commit}`);
|
|
191
|
+
const meta = decodeMeta(await gitRefs(store, 'log', '-1', '--format=%B', commit));
|
|
192
|
+
return meta ? { ordinal, commit, ...meta } : null;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** NUL-separated git output → array, with the trailing empty field dropped. */
|
|
199
|
+
function splitZ(raw) {
|
|
200
|
+
return raw.split('\0').filter((entry) => entry.length > 0);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Everything in the worktree that differs from HEAD right now, minus secrets.
|
|
204
|
+
*
|
|
205
|
+
* `-uall` matters: without it git collapses an untracked directory into a
|
|
206
|
+
* single `dir/` entry, and a checkpoint built from that list would silently
|
|
207
|
+
* miss every file inside it.
|
|
208
|
+
*/
|
|
209
|
+
async function changedPaths(worktreePath) {
|
|
210
|
+
const raw = await gitIn(worktreePath, 'status', '--porcelain=v1', '-uall', '-z');
|
|
211
|
+
const paths = [];
|
|
212
|
+
const secrets = [];
|
|
213
|
+
// Porcelain v1 with -z: `XY <path>` records, and a rename adds a second
|
|
214
|
+
// NUL-separated field. Both sides of a rename are ordinary paths for us.
|
|
215
|
+
const fields = splitZ(raw);
|
|
216
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
217
|
+
const entry = fields[i];
|
|
218
|
+
if (!entry || entry.length < 4)
|
|
219
|
+
continue;
|
|
220
|
+
const status = entry.slice(0, 2);
|
|
221
|
+
const file = entry.slice(3);
|
|
222
|
+
if (status[0] === 'R' || status[0] === 'C') {
|
|
223
|
+
// `R new\0old` — the following field is the old path.
|
|
224
|
+
const old = fields[i + 1];
|
|
225
|
+
i += 1;
|
|
226
|
+
if (old)
|
|
227
|
+
push(old);
|
|
228
|
+
}
|
|
229
|
+
push(file);
|
|
230
|
+
}
|
|
231
|
+
function push(file) {
|
|
232
|
+
if (!file)
|
|
233
|
+
return;
|
|
234
|
+
const absolute = path.join(worktreePath, file);
|
|
235
|
+
if (isSecretPath(absolute)) {
|
|
236
|
+
secrets.push(file);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
paths.push(file);
|
|
240
|
+
}
|
|
241
|
+
return { paths: [...new Set(paths)], secrets: [...new Set(secrets)] };
|
|
242
|
+
}
|
|
243
|
+
async function runBatched(store, worktreePath, indexFile, args, paths) {
|
|
244
|
+
for (let i = 0; i < paths.length; i += PATH_BATCH) {
|
|
245
|
+
await gitStore(store, worktreePath, indexFile, ...args, '--', ...paths.slice(i, i + PATH_BATCH));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Build an index describing the worktree, under ONE set of rules.
|
|
250
|
+
*
|
|
251
|
+
* Both the checkpoint and the "where are we now" tree go through here, and
|
|
252
|
+
* that is the whole point. When the two were built by separate code with
|
|
253
|
+
* slightly different filters, a file excluded from one and present in the
|
|
254
|
+
* other appeared in the diff as "created after the checkpoint" — and a rewind
|
|
255
|
+
* offered to delete a file that had been there all along.
|
|
256
|
+
*
|
|
257
|
+
* The second rule is subtler and cost a live data-loss bug: a path we do not
|
|
258
|
+
* cover must be **removed from the index**, not merely left at its HEAD
|
|
259
|
+
* version. `read-tree --reset -u` writes every index entry to disk, so a
|
|
260
|
+
* tracked `.env` sitting at its HEAD version silently overwrote the user's
|
|
261
|
+
* local edits — while the preview, built from a diff in which both sides were
|
|
262
|
+
* identical, said nothing would happen to it. An entry that does not exist
|
|
263
|
+
* cannot be written.
|
|
264
|
+
*/
|
|
265
|
+
async function buildIndex(store, worktreePath, indexFile) {
|
|
266
|
+
fs.mkdirSync(path.dirname(indexFile), { recursive: true, mode: 0o700 });
|
|
267
|
+
fs.rmSync(indexFile, { force: true });
|
|
268
|
+
const headSha = await gitIn(worktreePath, 'rev-parse', 'HEAD');
|
|
269
|
+
await gitStore(store, worktreePath, indexFile, 'read-tree', headSha);
|
|
270
|
+
const { paths, secrets } = await changedPaths(worktreePath);
|
|
271
|
+
const excluded = new Set(secrets);
|
|
272
|
+
const included = [];
|
|
273
|
+
let byteCount = 0;
|
|
274
|
+
for (const file of paths) {
|
|
275
|
+
let size = 0;
|
|
276
|
+
try {
|
|
277
|
+
const stat = fs.lstatSync(path.join(worktreePath, file));
|
|
278
|
+
size = stat.isFile() ? stat.size : 0;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Deleted since `status` ran — `update-index --remove` handles it.
|
|
282
|
+
}
|
|
283
|
+
if (size > MAX_CHECKPOINT_FILE_BYTES) {
|
|
284
|
+
excluded.add(file);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
byteCount += size;
|
|
288
|
+
included.push(file);
|
|
289
|
+
}
|
|
290
|
+
// Everything HEAD tracks that we may not touch — not just the paths that
|
|
291
|
+
// happen to have changed. An unmodified tracked secret would otherwise stay
|
|
292
|
+
// in the index and be rewritten from HEAD on every restore.
|
|
293
|
+
for (const tracked of splitZ(await gitStore(store, worktreePath, indexFile, 'ls-files', '-z'))) {
|
|
294
|
+
if (isSecretPath(path.join(worktreePath, tracked)))
|
|
295
|
+
excluded.add(tracked);
|
|
296
|
+
}
|
|
297
|
+
await runBatched(store, worktreePath, indexFile, ['update-index', '--add', '--remove'], included);
|
|
298
|
+
const drop = [...excluded];
|
|
299
|
+
if (drop.length > 0) {
|
|
300
|
+
await runBatched(store, worktreePath, indexFile, ['update-index', '--force-remove'], drop);
|
|
301
|
+
}
|
|
302
|
+
// `read-tree <commit>` writes index entries with ZEROED stat data, and
|
|
303
|
+
// `read-tree --reset -u` rewrites every entry whose stat does not match the
|
|
304
|
+
// file on disk — which, with zeroed stat, is all of them. Without this
|
|
305
|
+
// refresh a rewind of one file re-wrote the whole repository: mtime churn on
|
|
306
|
+
// every tracked path, i.e. a full rebuild for every watcher on the machine.
|
|
307
|
+
//
|
|
308
|
+
// Exit status is deliberately ignored: `--refresh` reports "needs update"
|
|
309
|
+
// for genuinely modified paths by failing, and those are exactly the paths a
|
|
310
|
+
// rewind is supposed to touch.
|
|
311
|
+
try {
|
|
312
|
+
await gitStore(store, worktreePath, indexFile, 'update-index', '-q', '--refresh');
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// Some entries differ from the worktree — expected, and the point.
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
headSha,
|
|
319
|
+
included,
|
|
320
|
+
excluded: drop,
|
|
321
|
+
byteCount,
|
|
322
|
+
tooLarge: included.length > MAX_CHECKPOINT_FILES || byteCount > MAX_CHECKPOINT_BYTES,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
async function nextOrdinal(store, worktreePath, sessionId) {
|
|
326
|
+
const raw = await gitStore(store, worktreePath, indexFileFor(sessionId), 'for-each-ref', '--format=%(refname)', `refs/devbridge/checkpoints/${sessionId}/`);
|
|
327
|
+
let max = 0;
|
|
328
|
+
for (const line of raw.split('\n')) {
|
|
329
|
+
const n = Number.parseInt(line.slice(line.lastIndexOf('/') + 1), 10);
|
|
330
|
+
if (Number.isFinite(n) && n > max)
|
|
331
|
+
max = n;
|
|
332
|
+
}
|
|
333
|
+
return max + 1;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Metadata rides in the commit MESSAGE, not in a side file.
|
|
337
|
+
*
|
|
338
|
+
* One object, one fsync, one thing to garbage-collect — and a checkpoint whose
|
|
339
|
+
* ref survives can never be missing its own metadata.
|
|
340
|
+
*/
|
|
341
|
+
function encodeMeta(meta) {
|
|
342
|
+
return `devbridge-checkpoint\n\n${JSON.stringify(meta)}\n`;
|
|
343
|
+
}
|
|
344
|
+
function decodeMeta(message) {
|
|
345
|
+
const start = message.indexOf('{');
|
|
346
|
+
if (start < 0)
|
|
347
|
+
return null;
|
|
348
|
+
try {
|
|
349
|
+
const parsed = JSON.parse(message.slice(start));
|
|
350
|
+
const kind = parsed['kind'];
|
|
351
|
+
return {
|
|
352
|
+
kind: kind === 'SAFETY' || kind === 'MANUAL' ? kind : 'TURN',
|
|
353
|
+
headSha: typeof parsed['headSha'] === 'string' ? parsed['headSha'] : '',
|
|
354
|
+
stagedPaths: Array.isArray(parsed['stagedPaths'])
|
|
355
|
+
? parsed['stagedPaths'].filter((p) => typeof p === 'string')
|
|
356
|
+
: [],
|
|
357
|
+
createdAt: typeof parsed['createdAt'] === 'number' ? parsed['createdAt'] : 0,
|
|
358
|
+
fileCount: typeof parsed['fileCount'] === 'number' ? parsed['fileCount'] : 0,
|
|
359
|
+
byteCount: typeof parsed['byteCount'] === 'number' ? parsed['byteCount'] : 0,
|
|
360
|
+
...(typeof parsed['agentAnchor'] === 'string' ? { agentAnchor: parsed['agentAnchor'] } : {}),
|
|
361
|
+
...(typeof parsed['agentSession'] === 'string'
|
|
362
|
+
? { agentSession: parsed['agentSession'] }
|
|
363
|
+
: {}),
|
|
364
|
+
...(typeof parsed['messageSeq'] === 'number' ? { messageSeq: parsed['messageSeq'] } : {}),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Take a restore point for this worktree.
|
|
373
|
+
*
|
|
374
|
+
* Never throws for an ordinary failure: a checkpoint that could not be taken
|
|
375
|
+
* must not stop the message it was taken for from reaching the agent.
|
|
376
|
+
*/
|
|
377
|
+
export async function createCheckpoint(input) {
|
|
378
|
+
const { worktreePath, sessionId, kind } = input;
|
|
379
|
+
try {
|
|
380
|
+
const store = await ensureStore(worktreePath);
|
|
381
|
+
const indexFile = tempIndexFile(sessionId, 'create');
|
|
382
|
+
const built = await buildIndex(store, worktreePath, indexFile);
|
|
383
|
+
if (built.tooLarge) {
|
|
384
|
+
fs.rmSync(indexFile, { force: true });
|
|
385
|
+
return { created: false, reason: 'too-large' };
|
|
386
|
+
}
|
|
387
|
+
const { headSha, included, excluded: skippedFiles, byteCount } = built;
|
|
388
|
+
const tree = await gitStore(store, worktreePath, indexFile, 'write-tree');
|
|
389
|
+
const stagedPaths = splitZ(await gitIn(worktreePath, 'diff', '--cached', '--name-only', '-z')).filter((file) => !isSecretPath(path.join(worktreePath, file)));
|
|
390
|
+
const meta = {
|
|
391
|
+
kind,
|
|
392
|
+
headSha,
|
|
393
|
+
stagedPaths,
|
|
394
|
+
createdAt: Date.now(),
|
|
395
|
+
fileCount: included.length,
|
|
396
|
+
byteCount,
|
|
397
|
+
...(input.agentAnchor ? { agentAnchor: input.agentAnchor } : {}),
|
|
398
|
+
...(input.agentSession ? { agentSession: input.agentSession } : {}),
|
|
399
|
+
...(input.messageSeq === undefined ? {} : { messageSeq: input.messageSeq }),
|
|
400
|
+
};
|
|
401
|
+
const commit = await gitStore(store, worktreePath, indexFile, 'commit-tree', tree, '-m', encodeMeta(meta));
|
|
402
|
+
const ordinal = await nextOrdinal(store, worktreePath, sessionId);
|
|
403
|
+
await gitStore(store, worktreePath, indexFile, 'update-ref', refFor(sessionId, ordinal), commit);
|
|
404
|
+
fs.rmSync(indexFile, { force: true });
|
|
405
|
+
return {
|
|
406
|
+
created: true,
|
|
407
|
+
record: { ordinal, commit, ...meta },
|
|
408
|
+
...(input.messageSeq === undefined ? {} : { messageSeq: input.messageSeq }),
|
|
409
|
+
skippedFiles,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
const detail = String(error instanceof Error ? error.message : error).slice(0, 300);
|
|
414
|
+
if (/not a git repository|ambiguous argument 'HEAD'|unknown revision/i.test(detail)) {
|
|
415
|
+
return { created: false, reason: 'not-a-repo', detail };
|
|
416
|
+
}
|
|
417
|
+
log.warn('checkpoints: could not create a restore point', { sessionId, error: detail });
|
|
418
|
+
return { created: false, reason: 'failed', detail };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function readCheckpoint(store, worktreePath, sessionId, ordinal) {
|
|
422
|
+
try {
|
|
423
|
+
const commit = await gitStore(store, worktreePath, indexFileFor(sessionId), 'rev-parse', `${refFor(sessionId, ordinal)}^{commit}`);
|
|
424
|
+
const message = await gitStore(store, worktreePath, indexFileFor(sessionId), 'log', '-1', '--format=%B', commit);
|
|
425
|
+
const meta = decodeMeta(message);
|
|
426
|
+
if (!meta)
|
|
427
|
+
return null;
|
|
428
|
+
return { ordinal, commit, ...meta };
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
export async function listCheckpoints(worktreePath, sessionId) {
|
|
435
|
+
try {
|
|
436
|
+
const store = await ensureStore(worktreePath);
|
|
437
|
+
const raw = await gitStore(store, worktreePath, indexFileFor(sessionId), 'for-each-ref', '--format=%(refname)', `refs/devbridge/checkpoints/${sessionId}/`);
|
|
438
|
+
const out = [];
|
|
439
|
+
for (const line of raw.split('\n')) {
|
|
440
|
+
if (!line.trim())
|
|
441
|
+
continue;
|
|
442
|
+
const ordinal = Number.parseInt(line.slice(line.lastIndexOf('/') + 1), 10);
|
|
443
|
+
if (!Number.isFinite(ordinal))
|
|
444
|
+
continue;
|
|
445
|
+
const record = await readCheckpoint(store, worktreePath, sessionId, ordinal);
|
|
446
|
+
if (record)
|
|
447
|
+
out.push(record);
|
|
448
|
+
}
|
|
449
|
+
return out.sort((a, b) => a.ordinal - b.ordinal);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Where the worktree stands right now, under the SAME rules a checkpoint uses.
|
|
457
|
+
*
|
|
458
|
+
* Identical filters are not a tidiness point: a path covered by one side and
|
|
459
|
+
* not the other shows up in the diff as a creation or a deletion that never
|
|
460
|
+
* happened, and the rewind acts on it.
|
|
461
|
+
*/
|
|
462
|
+
async function currentTree(store, worktreePath, indexFile) {
|
|
463
|
+
const built = await buildIndex(store, worktreePath, indexFile);
|
|
464
|
+
return {
|
|
465
|
+
tree: await gitStore(store, worktreePath, indexFile, 'write-tree'),
|
|
466
|
+
headSha: built.headSha,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const MAX_PREVIEW_ENTRIES = 5_000;
|
|
470
|
+
/** What a rewind to this checkpoint would do, without doing any of it. */
|
|
471
|
+
export async function previewRewind(input) {
|
|
472
|
+
const { worktreePath, sessionId, ordinal } = input;
|
|
473
|
+
const store = await ensureStore(worktreePath);
|
|
474
|
+
const indexFile = tempIndexFile(sessionId, 'preview');
|
|
475
|
+
const record = await readCheckpoint(store, worktreePath, sessionId, ordinal);
|
|
476
|
+
const { tree, headSha } = await currentTree(store, worktreePath, indexFile);
|
|
477
|
+
if (!record) {
|
|
478
|
+
return {
|
|
479
|
+
restore: [],
|
|
480
|
+
delete: [],
|
|
481
|
+
recreate: [],
|
|
482
|
+
headSha,
|
|
483
|
+
checkpointHeadSha: '',
|
|
484
|
+
treeOid: tree,
|
|
485
|
+
blockedReason: 'checkpoint-lost',
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const raw = await gitStore(store, worktreePath, indexFile, 'diff-tree', '-r', '--no-renames', '--name-status', '-z', `${record.commit}^{tree}`, tree);
|
|
489
|
+
fs.rmSync(indexFile, { force: true });
|
|
490
|
+
const restore = [];
|
|
491
|
+
const remove = [];
|
|
492
|
+
const recreate = [];
|
|
493
|
+
const fields = splitZ(raw);
|
|
494
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
495
|
+
const status = fields[i] ?? '';
|
|
496
|
+
const file = fields[i + 1] ?? '';
|
|
497
|
+
// Direction is checkpoint → now: `A` means the file appeared after the
|
|
498
|
+
// checkpoint (a rewind deletes it), `D` means it was there and is gone
|
|
499
|
+
// (a rewind brings it back), everything else is a content change.
|
|
500
|
+
if (status.startsWith('A'))
|
|
501
|
+
remove.push(file);
|
|
502
|
+
else if (status.startsWith('D'))
|
|
503
|
+
recreate.push(file);
|
|
504
|
+
else
|
|
505
|
+
restore.push(file);
|
|
506
|
+
}
|
|
507
|
+
// A list too long to SHOW is a rewind we must not run. Truncating it and
|
|
508
|
+
// going ahead anyway would delete files nobody was shown — the one thing the
|
|
509
|
+
// confirmation exists to prevent — so the size of the change is itself a
|
|
510
|
+
// reason to refuse.
|
|
511
|
+
const total = restore.length + remove.length + recreate.length;
|
|
512
|
+
let blockedReason = total > MAX_PREVIEW_ENTRIES ? 'too-many-changes' : undefined;
|
|
513
|
+
const commitsSince = [];
|
|
514
|
+
if (!blockedReason && record.headSha && record.headSha !== headSha) {
|
|
515
|
+
blockedReason = 'head-moved';
|
|
516
|
+
try {
|
|
517
|
+
const listed = await gitIn(worktreePath, 'log', '--format=%h%x00%s', '--max-count=20', `${record.headSha}..${headSha}`);
|
|
518
|
+
for (const line of listed.split('\n')) {
|
|
519
|
+
if (!line.trim())
|
|
520
|
+
continue;
|
|
521
|
+
const [sha, subject] = line.split('\0');
|
|
522
|
+
commitsSince.push({ sha: sha ?? '', subject: (subject ?? '').slice(0, 200) });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// The old HEAD is unreachable (history was rewritten) — the refusal
|
|
527
|
+
// stands on its own; the list is a courtesy.
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (!blockedReason && (await mergeInProgress(worktreePath))) {
|
|
531
|
+
blockedReason = 'merge-in-progress';
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
restore: restore.slice(0, MAX_PREVIEW_ENTRIES),
|
|
535
|
+
delete: remove.slice(0, MAX_PREVIEW_ENTRIES),
|
|
536
|
+
recreate: recreate.slice(0, MAX_PREVIEW_ENTRIES),
|
|
537
|
+
headSha,
|
|
538
|
+
checkpointHeadSha: record.headSha,
|
|
539
|
+
treeOid: tree,
|
|
540
|
+
...(blockedReason ? { blockedReason } : {}),
|
|
541
|
+
...(commitsSince.length ? { commitsSince } : {}),
|
|
542
|
+
...(total > MAX_PREVIEW_ENTRIES ? { truncated: true, totalChanges: total } : {}),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
async function mergeInProgress(worktreePath) {
|
|
546
|
+
try {
|
|
547
|
+
const gitDir = await gitIn(worktreePath, 'rev-parse', '--path-format=absolute', '--git-dir');
|
|
548
|
+
return (fs.existsSync(path.join(gitDir, 'MERGE_HEAD')) ||
|
|
549
|
+
fs.existsSync(path.join(gitDir, 'REBASE_HEAD')) ||
|
|
550
|
+
fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD')));
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Put the working tree back to a checkpoint.
|
|
558
|
+
*
|
|
559
|
+
* The SAFETY point is taken FIRST and its ref is on disk before a single byte
|
|
560
|
+
* of the worktree changes — kill the process between the two steps and the way
|
|
561
|
+
* back still exists. `read-tree --reset -u` restores content but does NOT
|
|
562
|
+
* delete files created after the checkpoint (verified on live git), so the
|
|
563
|
+
* deletions are done explicitly from a list the caller echoed back. There is no
|
|
564
|
+
* `git clean` anywhere in this file, on purpose: it deletes by rule rather than
|
|
565
|
+
* by list, and a rule is exactly what nobody confirmed.
|
|
566
|
+
*/
|
|
567
|
+
export async function applyRewind(input) {
|
|
568
|
+
const { worktreePath, sessionId, ordinal, confirmDeletes, expectedTreeOid } = input;
|
|
569
|
+
const store = await ensureStore(worktreePath);
|
|
570
|
+
const record = await readCheckpoint(store, worktreePath, sessionId, ordinal);
|
|
571
|
+
if (!record)
|
|
572
|
+
throw new Error('This restore point is no longer available');
|
|
573
|
+
const preview = await previewRewind({ worktreePath, sessionId, ordinal });
|
|
574
|
+
if (preview.blockedReason) {
|
|
575
|
+
throw new Error(rewindBlockMessage(preview.blockedReason));
|
|
576
|
+
}
|
|
577
|
+
const MOVED = 'The working tree changed while you were looking at it — open the preview again';
|
|
578
|
+
// The whole state, not just the deletions (QA-120 B1). `read-tree --reset -u`
|
|
579
|
+
// writes the RESTORE list as well, and that list is recomputed HERE — so a
|
|
580
|
+
// file saved from an editor while the dialog sat open used to be overwritten
|
|
581
|
+
// from the checkpoint without appearing on any list the person read.
|
|
582
|
+
if (expectedTreeOid && expectedTreeOid !== preview.treeOid)
|
|
583
|
+
throw new Error(MOVED);
|
|
584
|
+
const expected = [...preview.delete].sort();
|
|
585
|
+
const echoed = [...confirmDeletes].sort();
|
|
586
|
+
if (expected.length !== echoed.length || expected.some((p, i) => p !== echoed[i])) {
|
|
587
|
+
throw new Error(MOVED);
|
|
588
|
+
}
|
|
589
|
+
const safetyResult = await createCheckpoint({ worktreePath, sessionId, kind: 'SAFETY' });
|
|
590
|
+
if (!safetyResult.created) {
|
|
591
|
+
throw new Error(safetyResult.reason === 'too-large'
|
|
592
|
+
? 'The working tree is too large to take a safety point — the rewind was not started'
|
|
593
|
+
: 'Could not take a safety point before the rewind — nothing was changed');
|
|
594
|
+
}
|
|
595
|
+
const indexFile = tempIndexFile(sessionId, 'rewind');
|
|
596
|
+
// Seed the index with the CURRENT state so `read-tree --reset -u` only
|
|
597
|
+
// touches files that actually differ. Against an empty index git rewrites
|
|
598
|
+
// every file in the repository, and an mtime bump on a whole tree is a full
|
|
599
|
+
// rebuild for every watcher on the machine.
|
|
600
|
+
await currentTree(store, worktreePath, indexFile);
|
|
601
|
+
await gitStore(store, worktreePath, indexFile, 'read-tree', '--reset', '-u', `${record.commit}^{tree}`);
|
|
602
|
+
fs.rmSync(indexFile, { force: true });
|
|
603
|
+
// `read-tree --reset -u` removes the files that are in the seeded index and
|
|
604
|
+
// not in the checkpoint — which is the same set the user just confirmed,
|
|
605
|
+
// because both come from the same tree diff. This pass is the belt to that
|
|
606
|
+
// brace: it names each path explicitly, re-checks it against the worktree
|
|
607
|
+
// root, and reports what is actually gone. Nothing here deletes by rule, and
|
|
608
|
+
// there is no `git clean` anywhere in this file.
|
|
609
|
+
await deletePaths(worktreePath, preview.delete);
|
|
610
|
+
const deleted = preview.delete.filter((rel) => !fs.existsSync(path.join(worktreePath, rel))).length;
|
|
611
|
+
await reconcileIndex(worktreePath, preview, record.stagedPaths);
|
|
612
|
+
return {
|
|
613
|
+
restored: preview.restore.length,
|
|
614
|
+
deleted,
|
|
615
|
+
recreated: preview.recreate.length,
|
|
616
|
+
safety: safetyResult.record,
|
|
617
|
+
rewoundToKind: record.kind,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
export function rewindBlockMessage(reason) {
|
|
621
|
+
switch (reason) {
|
|
622
|
+
case 'head-moved':
|
|
623
|
+
return 'A commit was made after this restore point — rewind the commit from the Changes panel first';
|
|
624
|
+
case 'merge-in-progress':
|
|
625
|
+
return 'This worktree is in the middle of a merge — finish or abort it first';
|
|
626
|
+
case 'checkpoint-lost':
|
|
627
|
+
return 'This restore point is no longer available';
|
|
628
|
+
case 'too-many-changes':
|
|
629
|
+
return `More than ${MAX_PREVIEW_ENTRIES} files differ from this restore point — too many to list, and a rewind must never delete a file nobody was shown`;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Delete exactly the paths the user confirmed.
|
|
634
|
+
*
|
|
635
|
+
* Every one is re-checked against the worktree root here as well as in the API:
|
|
636
|
+
* this function is the last thing standing between a list and `unlink`, and a
|
|
637
|
+
* symlink that leaves the tree must not be followed out of it.
|
|
638
|
+
*/
|
|
639
|
+
async function deletePaths(worktreePath, paths) {
|
|
640
|
+
const root = await realpath(worktreePath);
|
|
641
|
+
let deleted = 0;
|
|
642
|
+
for (const rel of paths) {
|
|
643
|
+
if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..'))
|
|
644
|
+
continue;
|
|
645
|
+
const target = path.resolve(worktreePath, rel);
|
|
646
|
+
const relative = path.relative(worktreePath, target);
|
|
647
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
|
|
648
|
+
continue;
|
|
649
|
+
if (relative.split(path.sep).includes('.git'))
|
|
650
|
+
continue;
|
|
651
|
+
// The PARENT is resolved, not the target: resolving the target would follow
|
|
652
|
+
// the very symlink we are deciding about.
|
|
653
|
+
let parentReal;
|
|
654
|
+
try {
|
|
655
|
+
parentReal = await fs.promises.realpath(path.dirname(target));
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const parentRel = path.relative(root, parentReal);
|
|
661
|
+
if (parentRel.startsWith('..') || path.isAbsolute(parentRel))
|
|
662
|
+
continue;
|
|
663
|
+
try {
|
|
664
|
+
const stat = await fs.promises.lstat(target);
|
|
665
|
+
// Directories are never in the list — git names files — and a directory
|
|
666
|
+
// here would mean the list is not what we think it is.
|
|
667
|
+
if (stat.isDirectory())
|
|
668
|
+
continue;
|
|
669
|
+
await fs.promises.unlink(target);
|
|
670
|
+
deleted += 1;
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
// Already gone: the outcome the caller asked for.
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return deleted;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Put the project's own index back the way it was at the checkpoint, for the
|
|
680
|
+
* paths this rewind touched — and only those.
|
|
681
|
+
*
|
|
682
|
+
* The rewind itself never touches the project index (it runs against an index
|
|
683
|
+
* of the runner's), so without this a file that was staged-as-added and is now
|
|
684
|
+
* deleted from disk would show up as `AD` forever. Paths outside the rewind are
|
|
685
|
+
* left strictly alone: the user's other staged work is none of our business.
|
|
686
|
+
*/
|
|
687
|
+
async function reconcileIndex(worktreePath, preview, stagedAtCheckpoint) {
|
|
688
|
+
const touched = [...new Set([...preview.restore, ...preview.delete, ...preview.recreate])];
|
|
689
|
+
if (touched.length === 0)
|
|
690
|
+
return;
|
|
691
|
+
const staged = new Set(stagedAtCheckpoint);
|
|
692
|
+
try {
|
|
693
|
+
for (let i = 0; i < touched.length; i += PATH_BATCH) {
|
|
694
|
+
await gitIn(worktreePath, 'reset', '--quiet', '--', ...touched.slice(i, i + PATH_BATCH));
|
|
695
|
+
}
|
|
696
|
+
const restage = touched.filter((file) => staged.has(file) && !preview.delete.includes(file));
|
|
697
|
+
for (let i = 0; i < restage.length; i += PATH_BATCH) {
|
|
698
|
+
await gitIn(worktreePath, 'add', '--', ...restage.slice(i, i + PATH_BATCH));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
// The files are already back; a staging area we could not reproduce is a
|
|
703
|
+
// cosmetic loss, not a reason to report the rewind as failed.
|
|
704
|
+
log.warn('checkpoints: could not restore the staging area', { error: String(error) });
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/** Drop every restore point of one session (session deleted or purged). */
|
|
708
|
+
export async function dropCheckpoints(worktreePath, sessionId) {
|
|
709
|
+
try {
|
|
710
|
+
const store = await ensureStore(worktreePath);
|
|
711
|
+
await deleteRefs(store, worktreePath, sessionId);
|
|
712
|
+
fs.rmSync(indexFileFor(sessionId), { force: true });
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
log.warn('checkpoints: could not drop restore points', { sessionId, error: String(error) });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async function deleteRefs(store, worktreePath, sessionId) {
|
|
719
|
+
const raw = await gitStore(store, worktreePath, indexFileFor(sessionId), 'for-each-ref', '--format=%(refname)', `refs/devbridge/checkpoints/${sessionId}/`);
|
|
720
|
+
for (const ref of raw.split('\n')) {
|
|
721
|
+
if (!ref.trim())
|
|
722
|
+
continue;
|
|
723
|
+
await gitStore(store, worktreePath, indexFileFor(sessionId), 'update-ref', '-d', ref);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Garbage collection.
|
|
728
|
+
*
|
|
729
|
+
* Two jobs, and the second one is the reason this exists: deleting a session
|
|
730
|
+
* while its dev server is switched off leaves refs — a full copy of a working
|
|
731
|
+
* tree — with no row anywhere to point at them. The reconciliation on `hello`
|
|
732
|
+
* is what eventually removes those, so this takes a list of the sessions that
|
|
733
|
+
* still exist rather than a list of the ones that do not.
|
|
734
|
+
*/
|
|
735
|
+
export async function pruneCheckpoints(input) {
|
|
736
|
+
const { liveSessionIds } = input;
|
|
737
|
+
const now = input.now ?? Date.now();
|
|
738
|
+
const droppedSessions = [];
|
|
739
|
+
let droppedRefs = 0;
|
|
740
|
+
const root = checkpointsDir();
|
|
741
|
+
if (!fs.existsSync(root))
|
|
742
|
+
return { droppedSessions, droppedRefs };
|
|
743
|
+
// Every store, not just the one belonging to some worktree we happen to hold
|
|
744
|
+
// a path for. A session deleted while its dev server was switched off leaves
|
|
745
|
+
// refs behind with no row anywhere pointing at them, and the repository it
|
|
746
|
+
// came from may have no live session left to find it through.
|
|
747
|
+
for (const entry of fs.readdirSync(root)) {
|
|
748
|
+
if (!entry.endsWith('.git'))
|
|
749
|
+
continue;
|
|
750
|
+
const store = path.join(root, entry);
|
|
751
|
+
try {
|
|
752
|
+
const raw = await gitRefs(store, 'for-each-ref', '--format=%(refname)', REF_PREFIX);
|
|
753
|
+
const bySession = new Map();
|
|
754
|
+
for (const ref of raw.split('\n')) {
|
|
755
|
+
if (!ref.trim())
|
|
756
|
+
continue;
|
|
757
|
+
const rest = ref.slice(REF_PREFIX.length);
|
|
758
|
+
const slash = rest.lastIndexOf('/');
|
|
759
|
+
if (slash <= 0)
|
|
760
|
+
continue;
|
|
761
|
+
const sessionId = rest.slice(0, slash);
|
|
762
|
+
const ordinal = Number.parseInt(rest.slice(slash + 1), 10);
|
|
763
|
+
if (!Number.isFinite(ordinal))
|
|
764
|
+
continue;
|
|
765
|
+
const list = bySession.get(sessionId) ?? [];
|
|
766
|
+
list.push(ordinal);
|
|
767
|
+
bySession.set(sessionId, list);
|
|
768
|
+
}
|
|
769
|
+
let droppedHere = 0;
|
|
770
|
+
for (const [sessionId, ordinals] of bySession) {
|
|
771
|
+
// "Not in the list" is NOT the same as "does not exist". The list the
|
|
772
|
+
// API sends on `hello` is capped — REVIEW sessions are trimmed to the
|
|
773
|
+
// ten most recent — so treating every absent id as an orphan would
|
|
774
|
+
// delete the restore points of a session that is merely quiet. Age is
|
|
775
|
+
// what makes the difference safe: a session deleted while its server
|
|
776
|
+
// was off stops being mentioned forever, so its points age out; a live
|
|
777
|
+
// one that fell off a truncated list is mentioned again within minutes.
|
|
778
|
+
const unlisted = !liveSessionIds.has(sessionId);
|
|
779
|
+
ordinals.sort((a, b) => b - a);
|
|
780
|
+
const keep = new Set(ordinals.slice(0, CHECKPOINT_MAX_PER_SESSION));
|
|
781
|
+
let survivors = 0;
|
|
782
|
+
for (const ordinal of ordinals) {
|
|
783
|
+
let drop = !keep.has(ordinal);
|
|
784
|
+
if (!drop) {
|
|
785
|
+
const record = await readRecord(store, sessionId, ordinal);
|
|
786
|
+
const age = record ? now - record.createdAt : Number.POSITIVE_INFINITY;
|
|
787
|
+
drop = !record || age > CHECKPOINT_MAX_AGE_MS || (unlisted && age > ORPHAN_GRACE_MS);
|
|
788
|
+
}
|
|
789
|
+
if (!drop) {
|
|
790
|
+
survivors += 1;
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
await gitRefs(store, 'update-ref', '-d', refFor(sessionId, ordinal));
|
|
794
|
+
droppedHere += 1;
|
|
795
|
+
}
|
|
796
|
+
if (survivors === 0) {
|
|
797
|
+
droppedSessions.push(sessionId);
|
|
798
|
+
fs.rmSync(indexFileFor(sessionId), { force: true });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
droppedRefs += droppedHere;
|
|
802
|
+
if (droppedHere > 0) {
|
|
803
|
+
// Dropping a ref only unlinks it; the trees and blobs it named stay on
|
|
804
|
+
// disk until they are collected. Skipping this would make "retention"
|
|
805
|
+
// mean nothing at all for the thing that actually takes the space.
|
|
806
|
+
await gitRefs(store, 'reflog', 'expire', '--expire=now', '--all');
|
|
807
|
+
await gitRefs(store, 'gc', '--prune=now', '--quiet');
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
log.warn('checkpoints: prune failed for a store', { store: entry, error: String(error) });
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return { droppedSessions, droppedRefs };
|
|
815
|
+
}
|
|
816
|
+
//# sourceMappingURL=checkpoints.js.map
|