@cleocode/core 2026.4.98 → 2026.4.100

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.
Files changed (85) hide show
  1. package/dist/gc/daemon-entry.d.ts +15 -0
  2. package/dist/gc/daemon-entry.d.ts.map +1 -0
  3. package/dist/gc/daemon.d.ts +71 -0
  4. package/dist/gc/daemon.d.ts.map +1 -0
  5. package/dist/gc/daemon.js +481 -0
  6. package/dist/gc/daemon.js.map +7 -0
  7. package/dist/gc/index.d.ts +14 -0
  8. package/dist/gc/index.d.ts.map +1 -0
  9. package/dist/gc/index.js +669 -0
  10. package/dist/gc/index.js.map +7 -0
  11. package/dist/gc/runner.d.ts +132 -0
  12. package/dist/gc/runner.d.ts.map +1 -0
  13. package/dist/gc/runner.js +360 -0
  14. package/dist/gc/runner.js.map +7 -0
  15. package/dist/gc/state.d.ts +94 -0
  16. package/dist/gc/state.d.ts.map +1 -0
  17. package/dist/gc/state.js +49 -0
  18. package/dist/gc/state.js.map +7 -0
  19. package/dist/gc/transcript.d.ts +130 -0
  20. package/dist/gc/transcript.d.ts.map +1 -0
  21. package/dist/gc/transcript.js +209 -0
  22. package/dist/gc/transcript.js.map +7 -0
  23. package/dist/memory/brain-backfill.js +14643 -0
  24. package/dist/memory/brain-backfill.js.map +7 -0
  25. package/dist/memory/precompact-flush.js +47725 -0
  26. package/dist/memory/precompact-flush.js.map +7 -0
  27. package/dist/sentient/daemon-entry.d.ts +11 -0
  28. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  29. package/dist/sentient/daemon.d.ts +160 -0
  30. package/dist/sentient/daemon.d.ts.map +1 -0
  31. package/dist/sentient/daemon.js +1100 -0
  32. package/dist/sentient/daemon.js.map +7 -0
  33. package/dist/sentient/index.d.ts +18 -0
  34. package/dist/sentient/index.d.ts.map +1 -0
  35. package/dist/sentient/index.js +1162 -0
  36. package/dist/sentient/index.js.map +7 -0
  37. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  38. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  39. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  40. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  41. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  42. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  43. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  44. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  45. package/dist/sentient/propose-tick.d.ts +105 -0
  46. package/dist/sentient/propose-tick.d.ts.map +1 -0
  47. package/dist/sentient/propose-tick.js +549 -0
  48. package/dist/sentient/propose-tick.js.map +7 -0
  49. package/dist/sentient/state.d.ts +143 -0
  50. package/dist/sentient/state.d.ts.map +1 -0
  51. package/dist/sentient/state.js +85 -0
  52. package/dist/sentient/state.js.map +7 -0
  53. package/dist/sentient/tick.d.ts +193 -0
  54. package/dist/sentient/tick.d.ts.map +1 -0
  55. package/dist/sentient/tick.js +396 -0
  56. package/dist/sentient/tick.js.map +7 -0
  57. package/dist/system/platform-paths.js +36 -0
  58. package/dist/system/platform-paths.js.map +7 -0
  59. package/package.json +76 -8
  60. package/src/gc/__tests__/runner.test.ts +367 -0
  61. package/src/gc/__tests__/state.test.ts +169 -0
  62. package/src/gc/__tests__/transcript.test.ts +371 -0
  63. package/src/gc/daemon-entry.ts +26 -0
  64. package/src/gc/daemon.ts +251 -0
  65. package/src/gc/index.ts +14 -0
  66. package/src/gc/runner.ts +378 -0
  67. package/src/gc/state.ts +140 -0
  68. package/src/gc/transcript.ts +380 -0
  69. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  70. package/src/sentient/__tests__/daemon.test.ts +472 -0
  71. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  72. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  73. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  74. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  75. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  76. package/src/sentient/daemon-entry.ts +20 -0
  77. package/src/sentient/daemon.ts +471 -0
  78. package/src/sentient/index.ts +18 -0
  79. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  80. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  81. package/src/sentient/ingesters/test-ingester.ts +205 -0
  82. package/src/sentient/proposal-rate-limiter.ts +172 -0
  83. package/src/sentient/propose-tick.ts +415 -0
  84. package/src/sentient/state.ts +229 -0
  85. package/src/sentient/tick.ts +688 -0
@@ -0,0 +1,367 @@
1
+ /**
2
+ * GC Runner Tests (T735)
3
+ *
4
+ * Covers:
5
+ * - classifyDiskTier: correct tier at all boundary values
6
+ * - retentionMs: correct retention mapping per tier
7
+ * - runGC: dry-run mode makes zero filesystem mutations
8
+ * - runGC: budget cap (>5GB equivalent) triggers URGENT tier prune
9
+ * - runGC: API key absent falls back to 30d-only deletion (circuit breaker)
10
+ * - Crash recovery: pendingPrune paths are re-deleted on resume
11
+ *
12
+ * Uses real temp directories (mkdtemp). No mocked filesystem.
13
+ *
14
+ * @task T735
15
+ * @epic T726
16
+ */
17
+
18
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
+
23
+ // Mock check-disk-space so tests don't depend on actual disk state
24
+ vi.mock('check-disk-space', () => ({
25
+ default: vi.fn(),
26
+ }));
27
+
28
+ import checkDiskSpace from 'check-disk-space';
29
+
30
+ import { classifyDiskTier, DISK_THRESHOLDS, retentionMs, runGC } from '../runner.js';
31
+ import { readGCState } from '../state.js';
32
+
33
+ const mockCheckDisk = vi.mocked(checkDiskSpace);
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Test helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Create a fake project directory structure under a temp dir.
41
+ * Writes a session JSONL file with an mtime in the past.
42
+ */
43
+ async function createFakeSession(
44
+ projectsDir: string,
45
+ slug: string,
46
+ sessionId: string,
47
+ ageMs: number,
48
+ ): Promise<string> {
49
+ const slugDir = join(projectsDir, slug);
50
+ await mkdir(slugDir, { recursive: true });
51
+
52
+ const jsonlPath = join(slugDir, `${sessionId}.jsonl`);
53
+ await writeFile(jsonlPath, `{"type":"user","text":"hello"}\n`, 'utf-8');
54
+
55
+ // Set mtime to simulate age
56
+ const pastTime = new Date(Date.now() - ageMs);
57
+ const { utimes } = await import('node:fs/promises');
58
+ await utimes(jsonlPath, pastTime, pastTime);
59
+
60
+ return jsonlPath;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // classifyDiskTier
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe('classifyDiskTier', () => {
68
+ it('returns ok for disk usage below WATCH threshold', () => {
69
+ expect(classifyDiskTier(0)).toBe('ok');
70
+ expect(classifyDiskTier(50)).toBe('ok');
71
+ expect(classifyDiskTier(DISK_THRESHOLDS.WATCH - 0.01)).toBe('ok');
72
+ });
73
+
74
+ it('returns watch at WATCH threshold boundary (70%)', () => {
75
+ expect(classifyDiskTier(DISK_THRESHOLDS.WATCH)).toBe('watch');
76
+ expect(classifyDiskTier(70)).toBe('watch');
77
+ expect(classifyDiskTier(84.9)).toBe('watch');
78
+ });
79
+
80
+ it('returns warn at WARN threshold boundary (85%)', () => {
81
+ expect(classifyDiskTier(DISK_THRESHOLDS.WARN)).toBe('warn');
82
+ expect(classifyDiskTier(85)).toBe('warn');
83
+ expect(classifyDiskTier(89.9)).toBe('warn');
84
+ });
85
+
86
+ it('returns urgent at URGENT threshold boundary (90%)', () => {
87
+ expect(classifyDiskTier(DISK_THRESHOLDS.URGENT)).toBe('urgent');
88
+ expect(classifyDiskTier(90)).toBe('urgent');
89
+ expect(classifyDiskTier(94.9)).toBe('urgent');
90
+ });
91
+
92
+ it('returns emergency at EMERGENCY threshold boundary (95%)', () => {
93
+ expect(classifyDiskTier(DISK_THRESHOLDS.EMERGENCY)).toBe('emergency');
94
+ expect(classifyDiskTier(95)).toBe('emergency');
95
+ expect(classifyDiskTier(100)).toBe('emergency');
96
+ });
97
+ });
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // retentionMs
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe('retentionMs', () => {
104
+ it('returns 30 days for ok tier', () => {
105
+ expect(retentionMs('ok')).toBe(30 * 24 * 60 * 60 * 1000);
106
+ });
107
+
108
+ it('returns 30 days for watch tier', () => {
109
+ expect(retentionMs('watch')).toBe(30 * 24 * 60 * 60 * 1000);
110
+ });
111
+
112
+ it('returns 7 days for warn tier', () => {
113
+ expect(retentionMs('warn')).toBe(7 * 24 * 60 * 60 * 1000);
114
+ });
115
+
116
+ it('returns 3 days for urgent tier', () => {
117
+ expect(retentionMs('urgent')).toBe(3 * 24 * 60 * 60 * 1000);
118
+ });
119
+
120
+ it('returns 1 day for emergency tier', () => {
121
+ expect(retentionMs('emergency')).toBe(1 * 24 * 60 * 60 * 1000);
122
+ });
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // runGC — dry-run makes zero filesystem mutations
127
+ // ---------------------------------------------------------------------------
128
+
129
+ describe('runGC dry-run', () => {
130
+ let tmpDir: string;
131
+ let cleoDir: string;
132
+ let projectsDir: string;
133
+
134
+ beforeEach(async () => {
135
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-test-'));
136
+ cleoDir = join(tmpDir, '.cleo');
137
+ projectsDir = join(tmpDir, '.claude', 'projects');
138
+ await mkdir(cleoDir, { recursive: true });
139
+ await mkdir(projectsDir, { recursive: true });
140
+
141
+ // Simulate low disk usage (ok tier) for deterministic prune behavior
142
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 900, size: 1000 } as Awaited<
143
+ ReturnType<typeof checkDiskSpace>
144
+ >);
145
+ });
146
+
147
+ afterEach(async () => {
148
+ await rm(tmpDir, { recursive: true, force: true });
149
+ vi.clearAllMocks();
150
+ });
151
+
152
+ it('dry-run reports prunable paths without deleting any files', async () => {
153
+ // Create a session old enough to be pruned (35 days old — beyond 30d ok-tier retention)
154
+ const OLD_AGE_MS = 35 * 24 * 60 * 60 * 1000;
155
+ const jsonlPath = await createFakeSession(
156
+ projectsDir,
157
+ 'test-project',
158
+ 'session-abc',
159
+ OLD_AGE_MS,
160
+ );
161
+
162
+ const gcResult = await runGC({ cleoDir, projectsDir, dryRun: true });
163
+
164
+ // Dry-run: file must still exist
165
+ const { access } = await import('node:fs/promises');
166
+ await expect(access(jsonlPath)).resolves.toBeUndefined(); // still exists
167
+
168
+ // Dry-run: result reports the path as prunable
169
+ expect(gcResult.pruned.length).toBeGreaterThan(0);
170
+ expect(gcResult.pruned.some((p) => p.path === jsonlPath)).toBe(true);
171
+ });
172
+
173
+ it('dry-run does not write to gc-state.json', async () => {
174
+ await runGC({ cleoDir, projectsDir, dryRun: true });
175
+
176
+ // gc-state.json should not be written in dry-run
177
+ const statePath = join(cleoDir, 'gc-state.json');
178
+ try {
179
+ await readFile(statePath, 'utf-8');
180
+ // If it exists, it shouldn't have updated lastRunAt
181
+ const state = await readGCState(statePath);
182
+ expect(state.lastRunAt).toBeNull();
183
+ } catch {
184
+ // File doesn't exist — that's also fine for dry-run
185
+ }
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // runGC — threshold-based auto-prune
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe('runGC threshold-based prune', () => {
194
+ let tmpDir: string;
195
+ let cleoDir: string;
196
+ let projectsDir: string;
197
+
198
+ beforeEach(async () => {
199
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-thresh-'));
200
+ cleoDir = join(tmpDir, '.cleo');
201
+ projectsDir = join(tmpDir, '.claude', 'projects');
202
+ await mkdir(cleoDir, { recursive: true });
203
+ await mkdir(projectsDir, { recursive: true });
204
+ });
205
+
206
+ afterEach(async () => {
207
+ await rm(tmpDir, { recursive: true, force: true });
208
+ vi.clearAllMocks();
209
+ });
210
+
211
+ it('URGENT tier (90%+ disk): prunes sessions older than 3d', async () => {
212
+ // Simulate URGENT disk pressure (92%)
213
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 80, size: 1000 } as Awaited<
214
+ ReturnType<typeof checkDiskSpace>
215
+ >);
216
+
217
+ // Create a 4-day-old session (older than 3d urgent threshold)
218
+ const OLD_AGE_MS = 4 * 24 * 60 * 60 * 1000;
219
+ const jsonlPath = await createFakeSession(projectsDir, 'proj', 'sess-urgent', OLD_AGE_MS);
220
+
221
+ const gcResult = await runGC({ cleoDir, projectsDir });
222
+
223
+ expect(gcResult.threshold).toBe('urgent');
224
+ expect(gcResult.pruned.some((p) => p.path === jsonlPath)).toBe(true);
225
+ });
226
+
227
+ it('URGENT tier: escalation flag is set in gc-state.json', async () => {
228
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 80, size: 1000 } as Awaited<
229
+ ReturnType<typeof checkDiskSpace>
230
+ >);
231
+
232
+ await createFakeSession(projectsDir, 'proj', 'sess-escalate', 4 * 24 * 60 * 60 * 1000);
233
+ await runGC({ cleoDir, projectsDir });
234
+
235
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
236
+ expect(state.escalationNeeded).toBe(true);
237
+ });
238
+
239
+ it('OK tier: sessions within 30d are NOT pruned', async () => {
240
+ // Simulate OK disk pressure (30%)
241
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 700, size: 1000 } as Awaited<
242
+ ReturnType<typeof checkDiskSpace>
243
+ >);
244
+
245
+ // Create a 10-day-old session (below 30d ok-tier threshold)
246
+ const RECENT_AGE_MS = 10 * 24 * 60 * 60 * 1000;
247
+ const jsonlPath = await createFakeSession(projectsDir, 'proj', 'sess-recent', RECENT_AGE_MS);
248
+
249
+ const gcResult = await runGC({ cleoDir, projectsDir });
250
+
251
+ // File should still exist
252
+ const { access } = await import('node:fs/promises');
253
+ await expect(access(jsonlPath)).resolves.toBeUndefined();
254
+
255
+ expect(gcResult.threshold).toBe('ok');
256
+ expect(gcResult.pruned.some((p) => p.path === jsonlPath)).toBe(false);
257
+ });
258
+ });
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // runGC — crash recovery via pendingPrune
262
+ // ---------------------------------------------------------------------------
263
+
264
+ describe('runGC crash recovery', () => {
265
+ let tmpDir: string;
266
+ let cleoDir: string;
267
+
268
+ beforeEach(async () => {
269
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-crash-'));
270
+ cleoDir = join(tmpDir, '.cleo');
271
+ await mkdir(cleoDir, { recursive: true });
272
+
273
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 900, size: 1000 } as Awaited<
274
+ ReturnType<typeof checkDiskSpace>
275
+ >);
276
+ });
277
+
278
+ afterEach(async () => {
279
+ await rm(tmpDir, { recursive: true, force: true });
280
+ vi.clearAllMocks();
281
+ });
282
+
283
+ it('resumes deletion from pendingPrune when resumeFrom is provided', async () => {
284
+ // Simulate a file that was in the pending list
285
+ const fakeFilePath = join(tmpDir, 'session-pending.jsonl');
286
+ await writeFile(fakeFilePath, '{"type":"user"}\n', 'utf-8');
287
+
288
+ // Run GC with resumeFrom (simulates crash recovery)
289
+ const gcResult = await runGC({ cleoDir, resumeFrom: [fakeFilePath] });
290
+
291
+ // File should be deleted
292
+ const { access } = await import('node:fs/promises');
293
+ await expect(access(fakeFilePath)).rejects.toThrow();
294
+
295
+ expect(gcResult.pruned.some((p) => p.path === fakeFilePath)).toBe(true);
296
+ });
297
+
298
+ it('pendingPrune is cleared after successful deletion', async () => {
299
+ const fakeFilePath = join(tmpDir, 'pending-cleared.jsonl');
300
+ await writeFile(fakeFilePath, '{"type":"user"}\n', 'utf-8');
301
+
302
+ await runGC({ cleoDir, resumeFrom: [fakeFilePath] });
303
+
304
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
305
+ expect(state.pendingPrune).toBeNull();
306
+ });
307
+
308
+ it('skips ENOENT paths idempotently (already deleted)', async () => {
309
+ // Path that does not exist — should not throw
310
+ const nonExistentPath = join(tmpDir, 'already-gone.jsonl');
311
+
312
+ const gcResult = await runGC({ cleoDir, resumeFrom: [nonExistentPath] });
313
+
314
+ // Should complete without error; path included in pruned (reported as 0 bytes)
315
+ expect(gcResult).toBeDefined();
316
+ });
317
+ });
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // runGC — gc-state.json is written with correct structure
321
+ // ---------------------------------------------------------------------------
322
+
323
+ describe('runGC state persistence', () => {
324
+ let tmpDir: string;
325
+ let cleoDir: string;
326
+ let projectsDir: string;
327
+
328
+ beforeEach(async () => {
329
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-state-'));
330
+ cleoDir = join(tmpDir, '.cleo');
331
+ projectsDir = join(tmpDir, '.claude', 'projects');
332
+ await mkdir(cleoDir, { recursive: true });
333
+ await mkdir(projectsDir, { recursive: true });
334
+
335
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 700, size: 1000 } as Awaited<
336
+ ReturnType<typeof checkDiskSpace>
337
+ >);
338
+ });
339
+
340
+ afterEach(async () => {
341
+ await rm(tmpDir, { recursive: true, force: true });
342
+ vi.clearAllMocks();
343
+ });
344
+
345
+ it('writes lastRunAt as ISO-8601 after a successful run', async () => {
346
+ await runGC({ cleoDir, projectsDir });
347
+
348
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
349
+ expect(state.lastRunAt).toBeTruthy();
350
+ expect(() => new Date(state.lastRunAt as string)).not.toThrow();
351
+ });
352
+
353
+ it('sets lastRunResult to success when no errors', async () => {
354
+ await runGC({ cleoDir, projectsDir });
355
+
356
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
357
+ expect(state.lastRunResult).toBe('success');
358
+ });
359
+
360
+ it('sets lastDiskUsedPct from check-disk-space result', async () => {
361
+ // 30% used: (1000 - 700) / 1000 = 30%
362
+ await runGC({ cleoDir, projectsDir });
363
+
364
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
365
+ expect(state.lastDiskUsedPct).toBeCloseTo(30, 1);
366
+ });
367
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * GC State Tests (T735)
3
+ *
4
+ * Covers:
5
+ * - readGCState: returns default state when file missing
6
+ * - readGCState: merges missing fields with defaults on schema version bump
7
+ * - writeGCState: writes atomically via tmp+rename
8
+ * - patchGCState: merges patch over current state
9
+ *
10
+ * Uses real temp directories (mkdtemp). No mocked filesystem.
11
+ *
12
+ * @task T735
13
+ * @epic T726
14
+ */
15
+
16
+ import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
20
+
21
+ import {
22
+ DEFAULT_GC_STATE,
23
+ GC_STATE_SCHEMA_VERSION,
24
+ patchGCState,
25
+ readGCState,
26
+ writeGCState,
27
+ } from '../state.js';
28
+
29
+ describe('readGCState', () => {
30
+ let tmpDir: string;
31
+
32
+ beforeEach(async () => {
33
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-state-test-'));
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await rm(tmpDir, { recursive: true, force: true });
38
+ });
39
+
40
+ it('returns default state when file does not exist', async () => {
41
+ const nonExistentPath = join(tmpDir, 'nonexistent', 'gc-state.json');
42
+ const state = await readGCState(nonExistentPath);
43
+ expect(state).toEqual(DEFAULT_GC_STATE);
44
+ });
45
+
46
+ it('returns default state when file contains invalid JSON', async () => {
47
+ const statePath = join(tmpDir, 'gc-state.json');
48
+ await mkdir(tmpDir, { recursive: true });
49
+ // Write invalid JSON
50
+ await import('node:fs/promises').then((fs) =>
51
+ fs.writeFile(statePath, 'not valid json {{{', 'utf-8'),
52
+ );
53
+
54
+ const state = await readGCState(statePath);
55
+ expect(state).toEqual(DEFAULT_GC_STATE);
56
+ });
57
+
58
+ it('merges partial state with defaults (forward compatibility)', async () => {
59
+ const statePath = join(tmpDir, 'gc-state.json');
60
+ // Write a state with only some fields (simulates old schema version)
61
+ const partial = { schemaVersion: '1.0', lastRunAt: '2026-04-10T03:00:00.000Z' };
62
+ await import('node:fs/promises').then((fs) =>
63
+ fs.writeFile(statePath, JSON.stringify(partial), 'utf-8'),
64
+ );
65
+
66
+ const state = await readGCState(statePath);
67
+
68
+ // Explicitly set fields are preserved
69
+ expect(state.lastRunAt).toBe('2026-04-10T03:00:00.000Z');
70
+ // Missing fields default to the DEFAULT_GC_STATE values
71
+ expect(state.consecutiveFailures).toBe(0);
72
+ expect(state.escalationNeeded).toBe(false);
73
+ expect(state.daemonPid).toBeNull();
74
+ });
75
+ });
76
+
77
+ describe('writeGCState', () => {
78
+ let tmpDir: string;
79
+
80
+ beforeEach(async () => {
81
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-write-test-'));
82
+ });
83
+
84
+ afterEach(async () => {
85
+ await rm(tmpDir, { recursive: true, force: true });
86
+ });
87
+
88
+ it('creates parent directories if they do not exist', async () => {
89
+ const statePath = join(tmpDir, 'nested', 'deep', 'gc-state.json');
90
+ await writeGCState(statePath, { ...DEFAULT_GC_STATE });
91
+
92
+ const raw = await readFile(statePath, 'utf-8');
93
+ const parsed = JSON.parse(raw);
94
+ expect(parsed.schemaVersion).toBe(GC_STATE_SCHEMA_VERSION);
95
+ });
96
+
97
+ it('writes valid JSON with correct schema version', async () => {
98
+ const statePath = join(tmpDir, 'gc-state.json');
99
+ const state = { ...DEFAULT_GC_STATE, daemonPid: 12345, lastRunAt: '2026-04-15T03:00:00Z' };
100
+
101
+ await writeGCState(statePath, state);
102
+
103
+ const raw = await readFile(statePath, 'utf-8');
104
+ const parsed = JSON.parse(raw);
105
+ expect(parsed.schemaVersion).toBe('1.0');
106
+ expect(parsed.daemonPid).toBe(12345);
107
+ expect(parsed.lastRunAt).toBe('2026-04-15T03:00:00Z');
108
+ });
109
+
110
+ it('overwrites existing state file', async () => {
111
+ const statePath = join(tmpDir, 'gc-state.json');
112
+ const initial = { ...DEFAULT_GC_STATE, consecutiveFailures: 1 };
113
+ await writeGCState(statePath, initial);
114
+
115
+ const updated = { ...DEFAULT_GC_STATE, consecutiveFailures: 0, lastRunAt: 'now' };
116
+ await writeGCState(statePath, updated);
117
+
118
+ const raw = await readFile(statePath, 'utf-8');
119
+ const parsed = JSON.parse(raw);
120
+ expect(parsed.consecutiveFailures).toBe(0);
121
+ expect(parsed.lastRunAt).toBe('now');
122
+ });
123
+ });
124
+
125
+ describe('patchGCState', () => {
126
+ let tmpDir: string;
127
+
128
+ beforeEach(async () => {
129
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-patch-test-'));
130
+ });
131
+
132
+ afterEach(async () => {
133
+ await rm(tmpDir, { recursive: true, force: true });
134
+ });
135
+
136
+ it('creates file with patch merged over defaults when file does not exist', async () => {
137
+ const statePath = join(tmpDir, 'gc-state.json');
138
+ const patched = await patchGCState(statePath, { daemonPid: 9999 });
139
+
140
+ expect(patched.daemonPid).toBe(9999);
141
+ // Other fields default
142
+ expect(patched.consecutiveFailures).toBe(0);
143
+ expect(patched.escalationNeeded).toBe(false);
144
+ });
145
+
146
+ it('preserves existing fields not in the patch', async () => {
147
+ const statePath = join(tmpDir, 'gc-state.json');
148
+ await writeGCState(statePath, { ...DEFAULT_GC_STATE, consecutiveFailures: 3, daemonPid: 100 });
149
+
150
+ const patched = await patchGCState(statePath, { escalationNeeded: true });
151
+
152
+ // Patched field updated
153
+ expect(patched.escalationNeeded).toBe(true);
154
+ // Unpatched fields preserved
155
+ expect(patched.consecutiveFailures).toBe(3);
156
+ expect(patched.daemonPid).toBe(100);
157
+ });
158
+
159
+ it('allows null to clear a field', async () => {
160
+ const statePath = join(tmpDir, 'gc-state.json');
161
+ await writeGCState(statePath, {
162
+ ...DEFAULT_GC_STATE,
163
+ pendingPrune: ['path/a', 'path/b'],
164
+ });
165
+
166
+ const patched = await patchGCState(statePath, { pendingPrune: null });
167
+ expect(patched.pendingPrune).toBeNull();
168
+ });
169
+ });