claude-dev-env 8.28.0 → 8.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/.agents/skills/codex-cleanse/SKILL.md +36 -0
- package/.agents/skills/codex-cleanse/config/constants.mjs +11 -0
- package/.agents/skills/codex-cleanse/scripts/cleanse.mjs +458 -0
- package/.agents/skills/codex-cleanse/scripts/cleanse.test.mjs +708 -0
- package/bin/codex-skill-load-block.mjs +50 -2
- package/bin/codex-skill-load-block.test.mjs +120 -1
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install.mjs +28 -27
- package/bin/install.question-presentation.test.mjs +264 -0
- package/bin/install.test.mjs +13 -7
- package/bin/seed-codex-pstack-models.mjs +13 -2
- package/bin/seed-codex-pstack-models.test.mjs +45 -1
- package/package.json +1 -1
- package/rules/question-presentation.md +12 -0
- package/rules/verify-before-asking.md +3 -3
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { assertSelectedHome, cleanseSessions, parseOptions } from './cleanse.mjs';
|
|
10
|
+
import * as cleanse from './cleanse.mjs';
|
|
11
|
+
|
|
12
|
+
const now = Date.parse('2026-09-30T12:00:00.000Z');
|
|
13
|
+
const cutoff = Math.floor((now - 7 * 86_400_000) / 1_000);
|
|
14
|
+
const threadId = number => `00000000-0000-4000-8000-${String(number).padStart(12, '0')}`;
|
|
15
|
+
|
|
16
|
+
test('CLI starts through a directory link and reports a missing executable', () => {
|
|
17
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-entry-'));
|
|
18
|
+
try {
|
|
19
|
+
const linkedDirectory = join(home, 'scripts');
|
|
20
|
+
symlinkSync(dirname(fileURLToPath(import.meta.url)), linkedDirectory, process.platform === 'win32' ? 'junction' : 'dir');
|
|
21
|
+
const execution = spawnSync(process.execPath, [join(linkedDirectory, 'cleanse.mjs'),
|
|
22
|
+
'--codex-home', home, '--codex-path', join(home, 'missing')], { encoding: 'utf8', timeout: 10_000 });
|
|
23
|
+
assert.equal(execution.status, 1, execution.stderr);
|
|
24
|
+
const report = JSON.parse(execution.stdout);
|
|
25
|
+
assert.equal(report.archived, 0);
|
|
26
|
+
assert.equal(report.scanned, 0);
|
|
27
|
+
assert.match(report.failed[0].error, /executable not found/);
|
|
28
|
+
} finally {
|
|
29
|
+
rmSync(home, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function makeThread(number, updatedAt, overrides = {}) {
|
|
34
|
+
return {
|
|
35
|
+
id: threadId(number),
|
|
36
|
+
updatedAt,
|
|
37
|
+
status: { type: 'idle' },
|
|
38
|
+
parentThreadId: null,
|
|
39
|
+
ephemeral: false,
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeOptions(overrides = {}) {
|
|
45
|
+
return {
|
|
46
|
+
home: join(tmpdir(), 'account-a'),
|
|
47
|
+
inactiveDays: 7,
|
|
48
|
+
apply: false,
|
|
49
|
+
excludedThreadIds: new Set(),
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeRequest(allThreads, archiveFailures = new Set(), missingReadback = new Set()) {
|
|
55
|
+
const archivedIds = new Set();
|
|
56
|
+
const allCalls = [];
|
|
57
|
+
const request = async (method, params) => {
|
|
58
|
+
allCalls.push({ method, params });
|
|
59
|
+
if (method === 'thread/list') {
|
|
60
|
+
const selectedThreads = allThreads.filter(eachThread =>
|
|
61
|
+
params.archived === archivedIds.has(eachThread.id)
|
|
62
|
+
&& (!params.ancestorThreadId || eachThread.parentThreadId === params.ancestorThreadId)
|
|
63
|
+
&& (!params.archived || !missingReadback.has(eachThread.id)));
|
|
64
|
+
const offset = Number(params.cursor || 0);
|
|
65
|
+
return { data: selectedThreads.slice(offset, offset + 2), nextCursor: offset + 2 < selectedThreads.length ? String(offset + 2) : null };
|
|
66
|
+
}
|
|
67
|
+
if (method === 'thread/read') {
|
|
68
|
+
return { thread: allThreads.find(eachThread => eachThread.id === params.threadId) };
|
|
69
|
+
}
|
|
70
|
+
if (method === 'thread/archive') {
|
|
71
|
+
if (archiveFailures.has(params.threadId)) throw new Error('archive rejected');
|
|
72
|
+
archivedIds.add(params.threadId);
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Unexpected method: ${method}`);
|
|
76
|
+
};
|
|
77
|
+
return { request, allCalls, archivedIds };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeRollout(home, number) {
|
|
81
|
+
const directory = join(home, 'sessions', '2026', '07', '01');
|
|
82
|
+
mkdirSync(directory, { recursive: true });
|
|
83
|
+
const rolloutPath = join(directory, `rollout-2026-07-01T00-00-00-${threadId(number)}.jsonl`);
|
|
84
|
+
writeFileSync(rolloutPath, 'session metadata');
|
|
85
|
+
utimesSync(rolloutPath, new Date(0), new Date(0));
|
|
86
|
+
return rolloutPath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function makeHiddenRequest(home, allThreads, visibleIds, archiveDirectory = join(home, 'archived_sessions')) {
|
|
90
|
+
const archivedIds = new Set();
|
|
91
|
+
const allCalls = [];
|
|
92
|
+
const request = async (method, params) => {
|
|
93
|
+
allCalls.push({ method, params });
|
|
94
|
+
if (method === 'thread/read') {
|
|
95
|
+
return { thread: allThreads.find(eachThread => eachThread.id === params.threadId) };
|
|
96
|
+
}
|
|
97
|
+
if (method === 'thread/archive') {
|
|
98
|
+
const thread = allThreads.find(eachThread => eachThread.id === params.threadId);
|
|
99
|
+
mkdirSync(archiveDirectory, { recursive: true });
|
|
100
|
+
const archivedPath = join(archiveDirectory, basename(thread.path));
|
|
101
|
+
renameSync(thread.path, archivedPath);
|
|
102
|
+
thread.path = archivedPath;
|
|
103
|
+
archivedIds.add(thread.id);
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
const matchingThreads = allThreads.filter(eachThread =>
|
|
107
|
+
archivedIds.has(eachThread.id) === params.archived
|
|
108
|
+
&& (params.ancestorThreadId
|
|
109
|
+
? eachThread.parentThreadId === params.ancestorThreadId
|
|
110
|
+
: visibleIds.has(eachThread.id)));
|
|
111
|
+
return { data: matchingThreads, nextCursor: null };
|
|
112
|
+
};
|
|
113
|
+
return { request, allCalls, archivedIds };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test('preview counts every page and source without archiving', async () => {
|
|
117
|
+
const allThreads = [
|
|
118
|
+
makeThread(1, cutoff),
|
|
119
|
+
makeThread(2, cutoff - 1),
|
|
120
|
+
makeThread(3, cutoff + 1),
|
|
121
|
+
];
|
|
122
|
+
const fixture = makeRequest(allThreads);
|
|
123
|
+
const report = await cleanseSessions(fixture.request, makeOptions(), now);
|
|
124
|
+
assert.deepEqual([report.scanned, report.eligible, report.skipped, report.archived], [3, 2, 1, 0]);
|
|
125
|
+
assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/list').length, 2);
|
|
126
|
+
assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('preview protects running sessions, excluded IDs, and parents with recent children', async () => {
|
|
130
|
+
const allThreads = [
|
|
131
|
+
makeThread(1, cutoff - 1, { status: { type: 'active' } }),
|
|
132
|
+
makeThread(2, cutoff - 1),
|
|
133
|
+
makeThread(3, cutoff - 1),
|
|
134
|
+
makeThread(4, cutoff + 1, { parentThreadId: threadId(3) }),
|
|
135
|
+
makeThread(5, cutoff - 1, { updatedAt: 0 }),
|
|
136
|
+
];
|
|
137
|
+
const fixture = makeRequest(allThreads);
|
|
138
|
+
const options = makeOptions({ excludedThreadIds: new Set([threadId(2)]) });
|
|
139
|
+
const report = await cleanseSessions(fixture.request, options, now);
|
|
140
|
+
assert.equal(report.eligible, 0);
|
|
141
|
+
assert.equal(report.skipped, 5);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
for (const eachProtection of ['recent', 'excluded']) {
|
|
145
|
+
test(`preview protects ancestors of a ${eachProtection} child with a source parent link`, async () => {
|
|
146
|
+
const allThreads = [makeThread(1, cutoff - 1),
|
|
147
|
+
makeThread(2, cutoff - 1, { source: { subAgent: { thread_spawn: { parent_thread_id: threadId(1) } } } }),
|
|
148
|
+
makeThread(3, eachProtection === 'recent' ? cutoff + 1 : cutoff - 1,
|
|
149
|
+
{ source: { subAgent: { thread_spawn: { parent_thread_id: threadId(2) } } } })];
|
|
150
|
+
const fixture = makeRequest(allThreads);
|
|
151
|
+
const excludedThreadIds = new Set(eachProtection === 'excluded' ? [threadId(3)] : []);
|
|
152
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ excludedThreadIds }), now);
|
|
153
|
+
assert.equal(report.eligible, 0);
|
|
154
|
+
assert.equal(report.skippedReasons.descendant, 2);
|
|
155
|
+
assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test('apply orders a stale child with a source parent link before its parent', async () => {
|
|
160
|
+
const fixture = makeRequest([makeThread(1, cutoff - 1), makeThread(2, cutoff - 1,
|
|
161
|
+
{ source: { subAgent: { thread_spawn: { parent_thread_id: threadId(1) } } } })]);
|
|
162
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
|
|
163
|
+
assert.equal(report.archived, 2);
|
|
164
|
+
assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
|
|
165
|
+
.map(eachCall => eachCall.params.threadId), [threadId(2), threadId(1)]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('preview prefers the top-level parent over a conflicting source parent link', async () => {
|
|
169
|
+
const fixture = makeRequest([makeThread(1, cutoff - 1), makeThread(2, cutoff - 1),
|
|
170
|
+
makeThread(3, cutoff + 1, { parentThreadId: threadId(1),
|
|
171
|
+
source: { subAgent: { thread_spawn: { parent_thread_id: threadId(2) } } } })]);
|
|
172
|
+
const report = await cleanseSessions(fixture.request, makeOptions(), now);
|
|
173
|
+
assert.equal(report.eligible, 1);
|
|
174
|
+
assert.equal(report.skippedReasons.descendant, 1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('apply archives a stale leaf and confirms archived listing', async () => {
|
|
178
|
+
const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
179
|
+
try {
|
|
180
|
+
const rolloutPath = join(directory, 'rollout.jsonl');
|
|
181
|
+
writeFileSync(rolloutPath, 'session metadata');
|
|
182
|
+
utimesSync(rolloutPath, new Date(0), new Date(0));
|
|
183
|
+
const fixture = makeRequest([makeThread(1, cutoff - 1, { path: rolloutPath })]);
|
|
184
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
|
|
185
|
+
assert.equal(report.archived, 1);
|
|
186
|
+
assert.deepEqual(report.failed, []);
|
|
187
|
+
assert.deepEqual([...fixture.archivedIds], [threadId(1)]);
|
|
188
|
+
} finally {
|
|
189
|
+
rmSync(directory, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('apply archives a stale database-backed thread with no rollout path', async () => {
|
|
194
|
+
const fixture = makeRequest([makeThread(1, cutoff - 1, { path: null })]);
|
|
195
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
|
|
196
|
+
assert.equal(report.archived, 1);
|
|
197
|
+
assert.deepEqual(report.failed, []);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('apply reads back earlier archives and stops after a rejected archive', async () => {
|
|
201
|
+
const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
202
|
+
try {
|
|
203
|
+
const rolloutPath = join(directory, 'rollout.jsonl');
|
|
204
|
+
writeFileSync(rolloutPath, 'session metadata');
|
|
205
|
+
utimesSync(rolloutPath, new Date(0), new Date(0));
|
|
206
|
+
const allThreads = [1, 2, 3].map(number => makeThread(number, cutoff - 1, { path: rolloutPath }));
|
|
207
|
+
const fixture = makeRequest(allThreads, new Set([threadId(2)]), new Set([threadId(1)]));
|
|
208
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
|
|
209
|
+
assert.equal(report.archived, 0);
|
|
210
|
+
assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [threadId(2), threadId(1), threadId(2)]);
|
|
211
|
+
assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
|
|
212
|
+
.map(eachCall => eachCall.params.threadId), [threadId(1), threadId(2)]);
|
|
213
|
+
assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/read'
|
|
214
|
+
&& eachCall.params.threadId === threadId(1)).length, 2);
|
|
215
|
+
} finally {
|
|
216
|
+
rmSync(directory, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('apply skips a rollout file changed after the cutoff', async () => {
|
|
221
|
+
const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
222
|
+
try {
|
|
223
|
+
const rolloutPath = join(directory, 'rollout.jsonl');
|
|
224
|
+
writeFileSync(rolloutPath, 'new activity');
|
|
225
|
+
const fixture = makeRequest([makeThread(1, cutoff - 1, { path: rolloutPath })]);
|
|
226
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
|
|
227
|
+
assert.equal(report.archived, 0);
|
|
228
|
+
assert.equal(report.skippedReasons.recentRolloutWrite, 1);
|
|
229
|
+
assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
|
|
230
|
+
} finally {
|
|
231
|
+
rmSync(directory, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('apply protects a parent when the daemon finds a recent descendant absent from parent metadata', async () => {
|
|
236
|
+
const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
237
|
+
try {
|
|
238
|
+
const rolloutPath = join(directory, 'rollout.jsonl');
|
|
239
|
+
writeFileSync(rolloutPath, 'session metadata');
|
|
240
|
+
utimesSync(rolloutPath, new Date(0), new Date(0));
|
|
241
|
+
const parent = makeThread(1, cutoff - 1, { path: rolloutPath });
|
|
242
|
+
const child = makeThread(2, cutoff + 1);
|
|
243
|
+
const fixture = makeRequest([parent, child]);
|
|
244
|
+
const request = async (method, params) => {
|
|
245
|
+
if (method === 'thread/list' && params.ancestorThreadId === parent.id) {
|
|
246
|
+
return { data: [child], nextCursor: null };
|
|
247
|
+
}
|
|
248
|
+
return fixture.request(method, params);
|
|
249
|
+
};
|
|
250
|
+
const report = await cleanseSessions(request, makeOptions({ apply: true }), now);
|
|
251
|
+
assert.equal(report.archived, 0);
|
|
252
|
+
assert.equal(report.skippedReasons.descendant, 1);
|
|
253
|
+
assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
|
|
254
|
+
} finally {
|
|
255
|
+
rmSync(directory, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('selected Codex home follows the flag before the environment and rejects a mismatched server', () => {
|
|
260
|
+
const homeFromEnvironment = join(tmpdir(), 'account-a');
|
|
261
|
+
const homeFromFlag = join(tmpdir(), 'account-b');
|
|
262
|
+
const options = parseOptions(['--codex-home', homeFromFlag], { CODEX_HOME: homeFromEnvironment });
|
|
263
|
+
assert.equal(options.home, homeFromFlag);
|
|
264
|
+
assert.throws(() => assertSelectedHome(homeFromEnvironment, options.home), /differs from selected home/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('selected home accepts a directory alias and rejects missing or different homes', () => {
|
|
268
|
+
const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-home-'));
|
|
269
|
+
try {
|
|
270
|
+
const home = join(directory, 'account');
|
|
271
|
+
const alias = join(directory, 'alias');
|
|
272
|
+
const otherHome = join(directory, 'other');
|
|
273
|
+
mkdirSync(home);
|
|
274
|
+
mkdirSync(otherHome);
|
|
275
|
+
symlinkSync(home, alias, process.platform === 'win32' ? 'junction' : 'dir');
|
|
276
|
+
assert.doesNotThrow(() => assertSelectedHome(home, alias));
|
|
277
|
+
assert.doesNotThrow(() => assertSelectedHome(alias, home));
|
|
278
|
+
if (process.platform === 'win32') {
|
|
279
|
+
assert.doesNotThrow(() => assertSelectedHome(home.toUpperCase(), alias));
|
|
280
|
+
}
|
|
281
|
+
assert.throws(() => assertSelectedHome(otherHome, alias), /differs from selected home/);
|
|
282
|
+
assert.throws(() => assertSelectedHome(undefined, home));
|
|
283
|
+
assert.throws(() => assertSelectedHome(join(directory, 'missing'), home));
|
|
284
|
+
} finally {
|
|
285
|
+
rmSync(directory, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('transport loss preserves acknowledged archives as unverified and stops later writes', async () => {
|
|
290
|
+
const fixture = makeRequest([1, 2, 3].map(number => makeThread(number, cutoff - 1)));
|
|
291
|
+
const allAttempts = [];
|
|
292
|
+
let isHealthy = true;
|
|
293
|
+
const request = async (method, params) => {
|
|
294
|
+
allAttempts.push({ method, params });
|
|
295
|
+
if (method === 'thread/archive' && params.threadId === threadId(2)) isHealthy = false;
|
|
296
|
+
if (!isHealthy) throw new Error('daemon exited');
|
|
297
|
+
return fixture.request(method, params);
|
|
298
|
+
};
|
|
299
|
+
const report = await cleanseSessions(request, makeOptions({ apply: true }), now, () => isHealthy);
|
|
300
|
+
assert.deepEqual([report.scanned, report.eligible, report.archived], [3, 2, 0]);
|
|
301
|
+
assert.ok(report.failed.some(eachFailure => eachFailure.id === threadId(2) && /daemon exited/.test(eachFailure.error)));
|
|
302
|
+
assert.ok(report.failed.some(eachFailure => eachFailure.id === threadId(1) && /daemon exited/.test(eachFailure.error)));
|
|
303
|
+
assert.deepEqual(allAttempts.filter(eachCall => eachCall.method === 'thread/archive')
|
|
304
|
+
.map(eachCall => eachCall.params.threadId), [threadId(1), threadId(2)]);
|
|
305
|
+
assert.ok(allAttempts.some(eachCall => eachCall.method === 'thread/list' && eachCall.params.archived));
|
|
306
|
+
assert.deepEqual(allAttempts.filter(eachCall => eachCall.method === 'thread/read')
|
|
307
|
+
.map(eachCall => eachCall.params.threadId), [threadId(1), threadId(2), threadId(1), threadId(2)]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('archived listing failure retains its error and verifies available per-thread readback', async () => {
|
|
311
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-readback-'));
|
|
312
|
+
try {
|
|
313
|
+
const orphan = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
|
|
314
|
+
const fixture = makeHiddenRequest(home, [orphan], new Set());
|
|
315
|
+
const request = async (method, params) => {
|
|
316
|
+
if (method === 'thread/list' && params.archived) throw new Error('archived listing failed');
|
|
317
|
+
return fixture.request(method, params);
|
|
318
|
+
};
|
|
319
|
+
const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
|
|
320
|
+
assert.equal(report.archived, 1);
|
|
321
|
+
assert.deepEqual(report.failed, [{ id: null, error: 'archived listing failed' }]);
|
|
322
|
+
} finally {
|
|
323
|
+
rmSync(home, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
function assertCliTransportFailure(failureMethod) {
|
|
328
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-cli-'));
|
|
329
|
+
try {
|
|
330
|
+
const scriptUrl = new URL('./cleanse.mjs', import.meta.url).href;
|
|
331
|
+
const program = `
|
|
332
|
+
import childProcess from 'node:child_process';
|
|
333
|
+
import { EventEmitter } from 'node:events';
|
|
334
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
335
|
+
import { PassThrough, Writable } from 'node:stream';
|
|
336
|
+
import { fileURLToPath } from 'node:url';
|
|
337
|
+
const allThreads = ${JSON.stringify([1, 2, 3].map(number => makeThread(number, 1)))};
|
|
338
|
+
childProcess.spawn = () => {
|
|
339
|
+
const child = new EventEmitter();
|
|
340
|
+
child.stdout = new PassThrough();
|
|
341
|
+
child.stderr = new PassThrough();
|
|
342
|
+
child.exitCode = null;
|
|
343
|
+
child.stdin = new Writable({ write(chunk, encoding, done) {
|
|
344
|
+
const message = JSON.parse(chunk.toString());
|
|
345
|
+
if (!message.id) return done();
|
|
346
|
+
if (message.method === ${JSON.stringify(failureMethod)} && (message.method !== 'thread/archive' || message.params.threadId === allThreads[1].id)) {
|
|
347
|
+
child.exitCode = 7;
|
|
348
|
+
child.emit('exit', 7);
|
|
349
|
+
return done();
|
|
350
|
+
}
|
|
351
|
+
let reply = {};
|
|
352
|
+
if (message.method === 'initialize') reply = { codexHome: ${JSON.stringify(failureMethod === 'wrong-home' ? tmpdir() : home)} };
|
|
353
|
+
if (message.method === 'thread/archive') process.stderr.write('archive attempted\\n');
|
|
354
|
+
if (message.method === 'thread/list') reply = { data: message.params.ancestorThreadId ? [] : allThreads };
|
|
355
|
+
if (message.method === 'thread/read') reply = { thread: allThreads.find(eachThread => eachThread.id === message.params.threadId) };
|
|
356
|
+
child.stdout.write(JSON.stringify({ id: message.id, result: reply }) + '\\n');
|
|
357
|
+
done();
|
|
358
|
+
}});
|
|
359
|
+
child.stdin.on('finish', () => process.stderr.write('daemon closed\\n'));
|
|
360
|
+
return child;
|
|
361
|
+
};
|
|
362
|
+
syncBuiltinESMExports();
|
|
363
|
+
process.argv = [process.execPath, fileURLToPath(${JSON.stringify(scriptUrl)}), '--apply', '--codex-home', ${JSON.stringify(home)}, '--codex-path', ${failureMethod === 'missing-binary' ? JSON.stringify(join(home, 'missing')) : 'process.execPath'}];
|
|
364
|
+
await import(${JSON.stringify(scriptUrl)});
|
|
365
|
+
`;
|
|
366
|
+
const execution = spawnSync(process.execPath, ['--input-type=module', '--eval', program], { encoding: 'utf8', timeout: 10_000 });
|
|
367
|
+
assert.equal(execution.status, 1, execution.stderr);
|
|
368
|
+
const allLines = execution.stdout.trim().split('\n');
|
|
369
|
+
assert.equal(allLines.length, 1);
|
|
370
|
+
const report = JSON.parse(allLines[0]);
|
|
371
|
+
const isArchiveFailure = failureMethod === 'thread/archive';
|
|
372
|
+
assert.deepEqual([report.scanned, report.eligible, report.archived], isArchiveFailure ? [3, 2, 0] : [0, 0, 0]);
|
|
373
|
+
assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), isArchiveFailure ? [threadId(2), null, threadId(1), threadId(2)] : [null]);
|
|
374
|
+
const expectedError = failureMethod === 'wrong-home' ? /differs from selected home/ : failureMethod === 'missing-binary' ? /executable not found/ : /exited with code 7/;
|
|
375
|
+
assert.ok(report.failed.every(eachFailure => expectedError.test(eachFailure.error)));
|
|
376
|
+
if (!isArchiveFailure) assert.doesNotMatch(execution.stderr, /archive attempted/);
|
|
377
|
+
if (failureMethod !== 'missing-binary') assert.match(execution.stderr, /daemon closed/);
|
|
378
|
+
} finally {
|
|
379
|
+
rmSync(home, { recursive: true, force: true });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const eachFailureMethod of ['thread/archive', 'thread/list', 'initialize', 'wrong-home', 'missing-binary']) {
|
|
384
|
+
test(`CLI emits one JSON report and exits unsuccessfully after ${eachFailureMethod} transport loss`, () => {
|
|
385
|
+
assertCliTransportFailure(eachFailureMethod);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
test('native PATH discovery keeps explicit overrides and rejects missing explicit executables', () => {
|
|
390
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-path-'));
|
|
391
|
+
try {
|
|
392
|
+
const executableName = process.platform === 'win32' ? 'codex.exe' : 'codex';
|
|
393
|
+
const binaryPath = join(home, executableName);
|
|
394
|
+
writeFileSync(binaryPath, 'fixture');
|
|
395
|
+
chmodSync(binaryPath, 0o755);
|
|
396
|
+
const environment = { [process.platform === 'win32' ? 'Path' : 'PATH']: home };
|
|
397
|
+
assert.equal(cleanse.findCodexPath(makeOptions({ home }), environment), binaryPath);
|
|
398
|
+
const standalonePath = join(home, 'packages', 'standalone', 'current', 'bin', executableName);
|
|
399
|
+
mkdirSync(dirname(standalonePath), { recursive: true });
|
|
400
|
+
writeFileSync(standalonePath, 'fixture');
|
|
401
|
+
chmodSync(standalonePath, 0o755);
|
|
402
|
+
assert.equal(cleanse.findCodexPath(makeOptions({ home }), environment), standalonePath);
|
|
403
|
+
rmSync(standalonePath);
|
|
404
|
+
assert.equal(cleanse.findCodexPath(makeOptions({ home, codexPath: process.execPath }), environment), process.execPath);
|
|
405
|
+
assert.throws(() => cleanse.findCodexPath(makeOptions({ home, codexPath: join(home, 'missing') }), environment), /executable not found/);
|
|
406
|
+
if (process.platform !== 'win32') {
|
|
407
|
+
chmodSync(binaryPath, 0o644);
|
|
408
|
+
assert.throws(() => cleanse.findCodexPath(makeOptions({ home }), environment), /executable not found/);
|
|
409
|
+
}
|
|
410
|
+
rmSync(binaryPath);
|
|
411
|
+
writeFileSync(join(home, 'codex.cmd'), 'fixture');
|
|
412
|
+
assert.throws(() => cleanse.findCodexPath(makeOptions({ home }), environment), /executable not found/);
|
|
413
|
+
if (process.platform === 'win32') {
|
|
414
|
+
const vendorPath = join(home, 'node_modules', '@openai', 'codex', 'node_modules', '@openai',
|
|
415
|
+
'codex-win32-x64', 'vendor', 'x86_64-pc-windows-msvc', 'bin', executableName);
|
|
416
|
+
mkdirSync(dirname(vendorPath), { recursive: true });
|
|
417
|
+
writeFileSync(vendorPath, 'fixture');
|
|
418
|
+
assert.equal(cleanse.findCodexPath(makeOptions({ home }), environment), vendorPath);
|
|
419
|
+
}
|
|
420
|
+
} finally {
|
|
421
|
+
rmSync(home, { recursive: true, force: true });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('initial listing failure returns a report without archive requests', async () => {
|
|
426
|
+
const allMethods = [];
|
|
427
|
+
const request = async method => {
|
|
428
|
+
allMethods.push(method);
|
|
429
|
+
throw new Error('initial listing failed');
|
|
430
|
+
};
|
|
431
|
+
const report = await cleanseSessions(request, makeOptions({ apply: true }), now);
|
|
432
|
+
assert.deepEqual([report.scanned, report.eligible, report.archived], [0, 0, 0]);
|
|
433
|
+
assert.deepEqual(report.failed, [{ id: null, error: 'initial listing failed' }]);
|
|
434
|
+
assert.deepEqual(allMethods, ['thread/list']);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('lost archive reply still verifies the moved rollout and stops later writes', async () => {
|
|
438
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-lost-reply-'));
|
|
439
|
+
try {
|
|
440
|
+
const allThreads = [1, 2].map(number => makeThread(number, cutoff - 1, { path: makeRollout(home, number) }));
|
|
441
|
+
const fixture = makeHiddenRequest(home, allThreads, new Set());
|
|
442
|
+
const request = async (method, params) => {
|
|
443
|
+
const reply = await fixture.request(method, params);
|
|
444
|
+
if (method === 'thread/archive') throw new Error('archive reply lost');
|
|
445
|
+
return reply;
|
|
446
|
+
};
|
|
447
|
+
const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
|
|
448
|
+
assert.equal(report.archived, 1);
|
|
449
|
+
assert.deepEqual(report.failed, [{ id: threadId(1), error: 'archive reply lost' }]);
|
|
450
|
+
assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
|
|
451
|
+
.map(eachCall => eachCall.params.threadId), [threadId(1)]);
|
|
452
|
+
} finally {
|
|
453
|
+
rmSync(home, { recursive: true, force: true });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('hidden discovery transport failure retains the listed count and returns a failure report', async () => {
|
|
458
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-discovery-'));
|
|
459
|
+
try {
|
|
460
|
+
makeRollout(home, 1);
|
|
461
|
+
const allMethods = [];
|
|
462
|
+
const request = async method => {
|
|
463
|
+
allMethods.push(method);
|
|
464
|
+
if (method === 'thread/read') throw new Error('discovery transport lost');
|
|
465
|
+
return { data: [makeThread(2, cutoff - 1)] };
|
|
466
|
+
};
|
|
467
|
+
const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now, () => false);
|
|
468
|
+
assert.equal(report.scanned, 1);
|
|
469
|
+
assert.equal(report.archived, 0);
|
|
470
|
+
assert.deepEqual(report.failed, [{ id: threadId(1), error: 'discovery transport lost' }]);
|
|
471
|
+
assert.deepEqual(allMethods, ['thread/list', 'thread/read']);
|
|
472
|
+
} finally {
|
|
473
|
+
rmSync(home, { recursive: true, force: true });
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('inactivity days reject cutoffs outside the supported date range', () => {
|
|
478
|
+
for (const eachInactiveDays of ['1e308', '1e9']) {
|
|
479
|
+
assert.throws(() => parseOptions(['--inactive-days', eachInactiveDays]), /valid cutoff date/);
|
|
480
|
+
}
|
|
481
|
+
assert.equal(parseOptions(['--inactive-days', '7']).inactiveDays, 7);
|
|
482
|
+
assert.equal(parseOptions(['--inactive-days', '0']).inactiveDays, 0);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('every value-taking option rejects a following flag or end of arguments', () => {
|
|
486
|
+
const allValueTakingFlags = ['--inactive-days', '--codex-home', '--codex-path', '--exclude-thread-id'];
|
|
487
|
+
for (const eachFlag of allValueTakingFlags) {
|
|
488
|
+
assert.throws(() => parseOptions([eachFlag, '--apply']), new RegExp(`Missing value for ${eachFlag}`));
|
|
489
|
+
assert.throws(() => parseOptions([eachFlag]), new RegExp(`Missing value for ${eachFlag}`));
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test('uppercase CLI exclusion is stored as a lowercase UUID', () => {
|
|
494
|
+
const uppercaseId = 'ABCDEFAB-CDEF-4ABC-8DEF-ABCDEFABCDEF';
|
|
495
|
+
const options = parseOptions(['--exclude-thread-id', uppercaseId]);
|
|
496
|
+
assert.deepEqual([...options.excludedThreadIds], [uppercaseId.toLowerCase()]);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('lowercase exclusion protects a native thread with an uppercase UUID', async () => {
|
|
500
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
501
|
+
try {
|
|
502
|
+
const uppercaseId = 'ABCDEFAB-CDEF-4ABC-8DEF-ABCDEFABCDEF';
|
|
503
|
+
const options = parseOptions(['--codex-home', home, '--exclude-thread-id', uppercaseId.toLowerCase(), '--apply']);
|
|
504
|
+
const fixture = makeRequest([makeThread(1, cutoff - 1, { id: uppercaseId })]);
|
|
505
|
+
const report = await cleanseSessions(fixture.request, options, now);
|
|
506
|
+
assert.equal(report.skippedReasons.excluded, 1);
|
|
507
|
+
assert.equal(report.archived, 0);
|
|
508
|
+
assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
|
|
509
|
+
} finally {
|
|
510
|
+
rmSync(home, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('apply finds an old hidden child and archives it before its visible parent', async () => {
|
|
515
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
516
|
+
try {
|
|
517
|
+
const parent = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
|
|
518
|
+
const child = makeThread(2, cutoff - 1, { path: makeRollout(home, 2), parentThreadId: parent.id });
|
|
519
|
+
const fixture = makeHiddenRequest(home, [parent, child], new Set([parent.id]));
|
|
520
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ home, apply: true }), now);
|
|
521
|
+
assert.equal(report.scanned, 2);
|
|
522
|
+
assert.equal(report.archived, 2);
|
|
523
|
+
assert.deepEqual(report.failed, []);
|
|
524
|
+
assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
|
|
525
|
+
.map(eachCall => eachCall.params.threadId), [child.id, parent.id]);
|
|
526
|
+
} finally {
|
|
527
|
+
rmSync(home, { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('apply archives an old hidden orphan from the selected home only', async () => {
|
|
532
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
533
|
+
const otherHome = mkdtempSync(join(tmpdir(), 'codex-cleanse-other-'));
|
|
534
|
+
try {
|
|
535
|
+
const orphan = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
|
|
536
|
+
writeFileSync(join(home, 'sessions', 'notes.jsonl'), 'ignored');
|
|
537
|
+
makeRollout(otherHome, 2);
|
|
538
|
+
const fixture = makeHiddenRequest(home, [orphan], new Set());
|
|
539
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ home, apply: true }), now);
|
|
540
|
+
assert.equal(report.scanned, 1);
|
|
541
|
+
assert.equal(report.archived, 1);
|
|
542
|
+
assert.deepEqual(report.failed, []);
|
|
543
|
+
} finally {
|
|
544
|
+
rmSync(home, { recursive: true, force: true });
|
|
545
|
+
rmSync(otherHome, { recursive: true, force: true });
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('a recent hidden child keeps its old parent unarchived', async () => {
|
|
550
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
551
|
+
try {
|
|
552
|
+
const parent = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
|
|
553
|
+
const child = makeThread(2, cutoff + 1, { path: makeRollout(home, 2), parentThreadId: parent.id });
|
|
554
|
+
const fixture = makeHiddenRequest(home, [parent, child], new Set([parent.id]));
|
|
555
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ home, apply: true }), now);
|
|
556
|
+
assert.equal(report.archived, 0);
|
|
557
|
+
assert.equal(report.skippedReasons.descendant, 1);
|
|
558
|
+
assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
|
|
559
|
+
} finally {
|
|
560
|
+
rmSync(home, { recursive: true, force: true });
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('hidden discovery reports native ID and path mismatches', async () => {
|
|
565
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
566
|
+
try {
|
|
567
|
+
const rolloutPath = makeRollout(home, 1);
|
|
568
|
+
const wrongPath = makeRollout(home, 2);
|
|
569
|
+
for (const thread of [makeThread(2, cutoff - 1, { path: rolloutPath }),
|
|
570
|
+
makeThread(1, cutoff - 1, { path: wrongPath })]) {
|
|
571
|
+
const request = async (method, params) => method === 'thread/read'
|
|
572
|
+
? { thread }
|
|
573
|
+
: { data: [], nextCursor: null };
|
|
574
|
+
const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
|
|
575
|
+
assert.equal(report.archived, 0);
|
|
576
|
+
assert.ok(report.failed.some(eachFailure => eachFailure.id === threadId(1)));
|
|
577
|
+
}
|
|
578
|
+
} finally {
|
|
579
|
+
rmSync(home, { recursive: true, force: true });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test('hidden discovery reports a native read failure', async () => {
|
|
584
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
585
|
+
try {
|
|
586
|
+
makeRollout(home, 1);
|
|
587
|
+
const request = async (method, params) => {
|
|
588
|
+
if (method === 'thread/read') throw new Error('native read failed');
|
|
589
|
+
return { data: [], nextCursor: null };
|
|
590
|
+
};
|
|
591
|
+
const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
|
|
592
|
+
assert.equal(report.archived, 0);
|
|
593
|
+
assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [threadId(1)]);
|
|
594
|
+
} finally {
|
|
595
|
+
rmSync(home, { recursive: true, force: true });
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('a hidden child read failure stops its listed parent before archive', async () => {
|
|
600
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
601
|
+
try {
|
|
602
|
+
const parent = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
|
|
603
|
+
makeRollout(home, 2);
|
|
604
|
+
const allCalls = [];
|
|
605
|
+
const request = async (method, params) => {
|
|
606
|
+
allCalls.push({ method, params });
|
|
607
|
+
if (method === 'thread/list') {
|
|
608
|
+
return { data: params.ancestorThreadId ? [] : [parent], nextCursor: null };
|
|
609
|
+
}
|
|
610
|
+
if (method === 'thread/read' && params.threadId === threadId(2)) {
|
|
611
|
+
throw new Error('Hidden child metadata unreadable');
|
|
612
|
+
}
|
|
613
|
+
if (method === 'thread/read') return { thread: parent };
|
|
614
|
+
return {};
|
|
615
|
+
};
|
|
616
|
+
const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
|
|
617
|
+
assert.equal(report.archived, 0);
|
|
618
|
+
assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [threadId(2)]);
|
|
619
|
+
assert.equal(allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
|
|
620
|
+
} finally {
|
|
621
|
+
rmSync(home, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test('a hidden child archive failure stops its listed parent with an incomplete ancestor listing', async () => {
|
|
626
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
627
|
+
try {
|
|
628
|
+
const parent = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
|
|
629
|
+
const child = makeThread(2, cutoff - 1, { path: makeRollout(home, 2), parentThreadId: parent.id });
|
|
630
|
+
const fixture = makeHiddenRequest(home, [parent, child], new Set([parent.id]));
|
|
631
|
+
const request = async (method, params) => {
|
|
632
|
+
if (method === 'thread/list' && params.ancestorThreadId) {
|
|
633
|
+
return { data: [], nextCursor: null };
|
|
634
|
+
}
|
|
635
|
+
if (method === 'thread/archive' && params.threadId === child.id) {
|
|
636
|
+
fixture.allCalls.push({ method, params });
|
|
637
|
+
throw new Error('child archive rejected');
|
|
638
|
+
}
|
|
639
|
+
return fixture.request(method, params);
|
|
640
|
+
};
|
|
641
|
+
const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
|
|
642
|
+
assert.equal(report.archived, 0);
|
|
643
|
+
assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [child.id, child.id]);
|
|
644
|
+
assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
|
|
645
|
+
.map(eachCall => eachCall.params.threadId), [child.id]);
|
|
646
|
+
} finally {
|
|
647
|
+
rmSync(home, { recursive: true, force: true });
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test('empty rollout stubs skip native read and archive', async () => {
|
|
652
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
653
|
+
try {
|
|
654
|
+
const rolloutPath = makeRollout(home, 1);
|
|
655
|
+
writeFileSync(rolloutPath, '');
|
|
656
|
+
const allMethods = [];
|
|
657
|
+
const request = async method => {
|
|
658
|
+
allMethods.push(method);
|
|
659
|
+
if (method !== 'thread/list') throw new Error('Empty rollout reached native session API');
|
|
660
|
+
return { data: [], nextCursor: null };
|
|
661
|
+
};
|
|
662
|
+
const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
|
|
663
|
+
assert.equal(report.scanned, 1);
|
|
664
|
+
assert.equal(report.skippedReasons.emptyRollout, 1);
|
|
665
|
+
assert.equal(report.archived, 0);
|
|
666
|
+
assert.deepEqual(report.failed, []);
|
|
667
|
+
assert.deepEqual(allMethods, ['thread/list']);
|
|
668
|
+
} finally {
|
|
669
|
+
rmSync(home, { recursive: true, force: true });
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test('hidden archive readback rejects a sibling directory with the archived name prefix', async () => {
|
|
674
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
675
|
+
try {
|
|
676
|
+
const orphan = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
|
|
677
|
+
const siblingDirectory = join(home, 'archived_sessions-copy');
|
|
678
|
+
const fixture = makeHiddenRequest(home, [orphan], new Set(), siblingDirectory);
|
|
679
|
+
const report = await cleanseSessions(fixture.request, makeOptions({ home, apply: true }), now);
|
|
680
|
+
assert.equal(report.archived, 0);
|
|
681
|
+
assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [orphan.id]);
|
|
682
|
+
} finally {
|
|
683
|
+
rmSync(home, { recursive: true, force: true });
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test('hidden discovery and readback accept paths that resolve to the same rollout file', async () => {
|
|
688
|
+
const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
|
|
689
|
+
try {
|
|
690
|
+
const orphan = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
|
|
691
|
+
orphan.path = process.platform === 'win32'
|
|
692
|
+
? `\\\\?\\${orphan.path}`
|
|
693
|
+
: join(dirname(orphan.path), '..', '01', basename(orphan.path));
|
|
694
|
+
const fixture = makeHiddenRequest(home, [orphan], new Set());
|
|
695
|
+
const request = async (method, params) => {
|
|
696
|
+
const reply = await fixture.request(method, params);
|
|
697
|
+
if (method === 'thread/archive' && process.platform === 'win32') {
|
|
698
|
+
orphan.path = `\\\\?\\${orphan.path}`;
|
|
699
|
+
}
|
|
700
|
+
return reply;
|
|
701
|
+
};
|
|
702
|
+
const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
|
|
703
|
+
assert.equal(report.archived, 1);
|
|
704
|
+
assert.deepEqual(report.failed, []);
|
|
705
|
+
} finally {
|
|
706
|
+
rmSync(home, { recursive: true, force: true });
|
|
707
|
+
}
|
|
708
|
+
});
|