@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.
- package/dist/gc/daemon-entry.d.ts +15 -0
- package/dist/gc/daemon-entry.d.ts.map +1 -0
- package/dist/gc/daemon.d.ts +71 -0
- package/dist/gc/daemon.d.ts.map +1 -0
- package/dist/gc/daemon.js +481 -0
- package/dist/gc/daemon.js.map +7 -0
- package/dist/gc/index.d.ts +14 -0
- package/dist/gc/index.d.ts.map +1 -0
- package/dist/gc/index.js +669 -0
- package/dist/gc/index.js.map +7 -0
- package/dist/gc/runner.d.ts +132 -0
- package/dist/gc/runner.d.ts.map +1 -0
- package/dist/gc/runner.js +360 -0
- package/dist/gc/runner.js.map +7 -0
- package/dist/gc/state.d.ts +94 -0
- package/dist/gc/state.d.ts.map +1 -0
- package/dist/gc/state.js +49 -0
- package/dist/gc/state.js.map +7 -0
- package/dist/gc/transcript.d.ts +130 -0
- package/dist/gc/transcript.d.ts.map +1 -0
- package/dist/gc/transcript.js +209 -0
- package/dist/gc/transcript.js.map +7 -0
- package/dist/memory/brain-backfill.js +14643 -0
- package/dist/memory/brain-backfill.js.map +7 -0
- package/dist/memory/precompact-flush.js +47725 -0
- package/dist/memory/precompact-flush.js.map +7 -0
- package/dist/sentient/daemon-entry.d.ts +11 -0
- package/dist/sentient/daemon-entry.d.ts.map +1 -0
- package/dist/sentient/daemon.d.ts +160 -0
- package/dist/sentient/daemon.d.ts.map +1 -0
- package/dist/sentient/daemon.js +1100 -0
- package/dist/sentient/daemon.js.map +7 -0
- package/dist/sentient/index.d.ts +18 -0
- package/dist/sentient/index.d.ts.map +1 -0
- package/dist/sentient/index.js +1162 -0
- package/dist/sentient/index.js.map +7 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
- package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
- package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
- package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
- package/dist/sentient/propose-tick.d.ts +105 -0
- package/dist/sentient/propose-tick.d.ts.map +1 -0
- package/dist/sentient/propose-tick.js +549 -0
- package/dist/sentient/propose-tick.js.map +7 -0
- package/dist/sentient/state.d.ts +143 -0
- package/dist/sentient/state.d.ts.map +1 -0
- package/dist/sentient/state.js +85 -0
- package/dist/sentient/state.js.map +7 -0
- package/dist/sentient/tick.d.ts +193 -0
- package/dist/sentient/tick.d.ts.map +1 -0
- package/dist/sentient/tick.js +396 -0
- package/dist/sentient/tick.js.map +7 -0
- package/dist/system/platform-paths.js +36 -0
- package/dist/system/platform-paths.js.map +7 -0
- package/package.json +76 -8
- package/src/gc/__tests__/runner.test.ts +367 -0
- package/src/gc/__tests__/state.test.ts +169 -0
- package/src/gc/__tests__/transcript.test.ts +371 -0
- package/src/gc/daemon-entry.ts +26 -0
- package/src/gc/daemon.ts +251 -0
- package/src/gc/index.ts +14 -0
- package/src/gc/runner.ts +378 -0
- package/src/gc/state.ts +140 -0
- package/src/gc/transcript.ts +380 -0
- package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
- package/src/sentient/__tests__/daemon.test.ts +472 -0
- package/src/sentient/__tests__/dream-tick.test.ts +200 -0
- package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
- package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
- package/src/sentient/__tests__/propose-tick.test.ts +296 -0
- package/src/sentient/__tests__/test-ingester.test.ts +104 -0
- package/src/sentient/daemon-entry.ts +20 -0
- package/src/sentient/daemon.ts +471 -0
- package/src/sentient/index.ts +18 -0
- package/src/sentient/ingesters/brain-ingester.ts +122 -0
- package/src/sentient/ingesters/nexus-ingester.ts +171 -0
- package/src/sentient/ingesters/test-ingester.ts +205 -0
- package/src/sentient/proposal-rate-limiter.ts +172 -0
- package/src/sentient/propose-tick.ts +415 -0
- package/src/sentient/state.ts +229 -0
- package/src/sentient/tick.ts +688 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript Lifecycle Tests (T735)
|
|
3
|
+
*
|
|
4
|
+
* Covers memory-architecture-spec.md §12.1 acceptance criteria:
|
|
5
|
+
* - TL-F5: prune --dry-run makes zero filesystem mutations
|
|
6
|
+
* - TL-F6: Budget cap triggers early prune when total size > threshold
|
|
7
|
+
* - TL-F7: API key absent falls back to 30d-only deletion
|
|
8
|
+
*
|
|
9
|
+
* Additional unit tests:
|
|
10
|
+
* - classifyTranscriptTier: correct hot/warm/cold at all boundary values
|
|
11
|
+
* - scanTranscripts: correct hot/warm counts for a known directory layout
|
|
12
|
+
* - pruneTranscripts: correct deletion with confirm=true
|
|
13
|
+
* - parseDurationMs: correct parsing of all supported formats
|
|
14
|
+
*
|
|
15
|
+
* Uses real temp directories (mkdtemp). No mocked filesystem.
|
|
16
|
+
*
|
|
17
|
+
* @task T735
|
|
18
|
+
* @epic T726
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
classifyTranscriptTier,
|
|
28
|
+
parseDurationMs,
|
|
29
|
+
pruneTranscripts,
|
|
30
|
+
scanTranscripts,
|
|
31
|
+
} from '../transcript.js';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a fake session JSONL under `projectsDir/<slug>/`.
|
|
39
|
+
* Sets the mtime to `now - ageMs` so tier classification works correctly.
|
|
40
|
+
*/
|
|
41
|
+
async function createSession(
|
|
42
|
+
projectsDir: string,
|
|
43
|
+
slug: string,
|
|
44
|
+
sessionId: string,
|
|
45
|
+
ageMs: number,
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
const slugDir = join(projectsDir, slug);
|
|
48
|
+
await mkdir(slugDir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
const jsonlPath = join(slugDir, `${sessionId}.jsonl`);
|
|
51
|
+
await writeFile(jsonlPath, `{"type":"user","text":"session ${sessionId}"}\n`, 'utf-8');
|
|
52
|
+
|
|
53
|
+
// Back-date the mtime to simulate age
|
|
54
|
+
const pastDate = new Date(Date.now() - ageMs);
|
|
55
|
+
await utimes(jsonlPath, pastDate, pastDate);
|
|
56
|
+
|
|
57
|
+
return jsonlPath;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// classifyTranscriptTier
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
describe('classifyTranscriptTier', () => {
|
|
65
|
+
it('returns hot for age < 24h', () => {
|
|
66
|
+
expect(classifyTranscriptTier(0)).toBe('hot');
|
|
67
|
+
expect(classifyTranscriptTier(1 * 60 * 60 * 1000)).toBe('hot'); // 1h
|
|
68
|
+
expect(classifyTranscriptTier(23 * 60 * 60 * 1000)).toBe('hot'); // 23h
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns warm for age 24h–7d', () => {
|
|
72
|
+
const ONE_DAY = 24 * 60 * 60 * 1000;
|
|
73
|
+
const SEVEN_DAYS = 7 * ONE_DAY;
|
|
74
|
+
expect(classifyTranscriptTier(ONE_DAY)).toBe('warm');
|
|
75
|
+
expect(classifyTranscriptTier(ONE_DAY + 1000)).toBe('warm'); // just past 24h
|
|
76
|
+
expect(classifyTranscriptTier(3 * ONE_DAY)).toBe('warm'); // 3 days
|
|
77
|
+
expect(classifyTranscriptTier(SEVEN_DAYS - 1000)).toBe('warm'); // just under 7d
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns cold for age >= 7d', () => {
|
|
81
|
+
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
|
|
82
|
+
expect(classifyTranscriptTier(SEVEN_DAYS)).toBe('cold');
|
|
83
|
+
expect(classifyTranscriptTier(SEVEN_DAYS + 1000)).toBe('cold');
|
|
84
|
+
expect(classifyTranscriptTier(30 * 24 * 60 * 60 * 1000)).toBe('cold'); // 30d
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// parseDurationMs
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
describe('parseDurationMs', () => {
|
|
93
|
+
it('parses days correctly', () => {
|
|
94
|
+
expect(parseDurationMs('7d')).toBe(7 * 24 * 60 * 60 * 1000);
|
|
95
|
+
expect(parseDurationMs('1d')).toBe(24 * 60 * 60 * 1000);
|
|
96
|
+
expect(parseDurationMs('30d')).toBe(30 * 24 * 60 * 60 * 1000);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('parses hours correctly', () => {
|
|
100
|
+
expect(parseDurationMs('24h')).toBe(24 * 60 * 60 * 1000);
|
|
101
|
+
expect(parseDurationMs('1h')).toBe(60 * 60 * 1000);
|
|
102
|
+
expect(parseDurationMs('168h')).toBe(168 * 60 * 60 * 1000);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('parses minutes correctly', () => {
|
|
106
|
+
expect(parseDurationMs('30m')).toBe(30 * 60 * 1000);
|
|
107
|
+
expect(parseDurationMs('1m')).toBe(60 * 1000);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('parses seconds correctly', () => {
|
|
111
|
+
expect(parseDurationMs('60s')).toBe(60 * 1000);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('throws on invalid format', () => {
|
|
115
|
+
expect(() => parseDurationMs('invalid')).toThrow();
|
|
116
|
+
expect(() => parseDurationMs('7')).toThrow();
|
|
117
|
+
expect(() => parseDurationMs('')).toThrow();
|
|
118
|
+
expect(() => parseDurationMs('7w')).toThrow(); // weeks not supported
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// scanTranscripts — correct hot/warm counts
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe('scanTranscripts', () => {
|
|
127
|
+
let tmpDir: string;
|
|
128
|
+
let projectsDir: string;
|
|
129
|
+
|
|
130
|
+
beforeEach(async () => {
|
|
131
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-scan-'));
|
|
132
|
+
projectsDir = join(tmpDir, 'projects');
|
|
133
|
+
await mkdir(projectsDir, { recursive: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(async () => {
|
|
137
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns empty result when projects dir does not exist', async () => {
|
|
141
|
+
const result = await scanTranscripts(join(tmpDir, 'nonexistent'));
|
|
142
|
+
expect(result.totalSessions).toBe(0);
|
|
143
|
+
expect(result.hot).toHaveLength(0);
|
|
144
|
+
expect(result.warm).toHaveLength(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('correctly identifies hot sessions (< 24h)', async () => {
|
|
148
|
+
// 1 hour old → hot
|
|
149
|
+
await createSession(projectsDir, 'proj', 'hot-session', 1 * 60 * 60 * 1000);
|
|
150
|
+
|
|
151
|
+
const result = await scanTranscripts(projectsDir);
|
|
152
|
+
|
|
153
|
+
expect(result.hot).toHaveLength(1);
|
|
154
|
+
expect(result.warm).toHaveLength(0);
|
|
155
|
+
expect(result.hot[0]?.sessionId).toBe('hot-session');
|
|
156
|
+
expect(result.hot[0]?.tier).toBe('hot');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('correctly identifies warm sessions (1–7d)', async () => {
|
|
160
|
+
// 3 days old → warm
|
|
161
|
+
await createSession(projectsDir, 'proj', 'warm-session', 3 * 24 * 60 * 60 * 1000);
|
|
162
|
+
|
|
163
|
+
const result = await scanTranscripts(projectsDir);
|
|
164
|
+
|
|
165
|
+
expect(result.warm).toHaveLength(1);
|
|
166
|
+
expect(result.hot).toHaveLength(0);
|
|
167
|
+
expect(result.warm[0]?.sessionId).toBe('warm-session');
|
|
168
|
+
expect(result.warm[0]?.tier).toBe('warm');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('cold sessions (>7d, already deleted) do not appear in scan', async () => {
|
|
172
|
+
// Create a file but COLD sessions should have already been deleted from disk
|
|
173
|
+
// We verify cold sessions don't appear in warm/hot lists
|
|
174
|
+
const result = await scanTranscripts(projectsDir);
|
|
175
|
+
// No sessions created yet → all empty
|
|
176
|
+
expect(result.totalSessions).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('counts sessions across multiple projects', async () => {
|
|
180
|
+
await createSession(projectsDir, 'proj-a', 'session-1', 1 * 60 * 60 * 1000); // hot
|
|
181
|
+
await createSession(projectsDir, 'proj-a', 'session-2', 2 * 24 * 60 * 60 * 1000); // warm
|
|
182
|
+
await createSession(projectsDir, 'proj-b', 'session-3', 5 * 24 * 60 * 60 * 1000); // warm
|
|
183
|
+
|
|
184
|
+
const result = await scanTranscripts(projectsDir);
|
|
185
|
+
|
|
186
|
+
expect(result.totalSessions).toBe(3);
|
|
187
|
+
expect(result.hot).toHaveLength(1);
|
|
188
|
+
expect(result.warm).toHaveLength(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('totalBytes is the sum of all session file sizes', async () => {
|
|
192
|
+
await createSession(projectsDir, 'proj', 'size-session', 1 * 60 * 60 * 1000);
|
|
193
|
+
|
|
194
|
+
const result = await scanTranscripts(projectsDir);
|
|
195
|
+
expect(result.totalBytes).toBeGreaterThan(0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// TL-F5: prune --dry-run makes zero filesystem mutations
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('pruneTranscripts dry-run (TL-F5)', () => {
|
|
204
|
+
let tmpDir: string;
|
|
205
|
+
let projectsDir: string;
|
|
206
|
+
|
|
207
|
+
beforeEach(async () => {
|
|
208
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-prune-dr-'));
|
|
209
|
+
projectsDir = join(tmpDir, 'projects');
|
|
210
|
+
await mkdir(projectsDir, { recursive: true });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
afterEach(async () => {
|
|
214
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('dry-run (confirm=false) makes zero filesystem mutations', async () => {
|
|
218
|
+
// Use 35d to be beyond the 30d API-key-absent circuit breaker minimum
|
|
219
|
+
const OLD_SESSION_AGE = 35 * 24 * 60 * 60 * 1000; // 35 days
|
|
220
|
+
const jsonlPath = await createSession(projectsDir, 'proj', 'old-session', OLD_SESSION_AGE);
|
|
221
|
+
|
|
222
|
+
const result = await pruneTranscripts({
|
|
223
|
+
olderThanMs: 7 * 24 * 60 * 60 * 1000, // 7d threshold
|
|
224
|
+
confirm: false, // dry-run
|
|
225
|
+
projectsDir,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// File must still exist after dry-run
|
|
229
|
+
const { access } = await import('node:fs/promises');
|
|
230
|
+
await expect(access(jsonlPath)).resolves.toBeUndefined();
|
|
231
|
+
|
|
232
|
+
expect(result.dryRun).toBe(true);
|
|
233
|
+
expect(result.pruned).toBeGreaterThan(0);
|
|
234
|
+
expect(result.deletedPaths).toContain(jsonlPath);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('dry-run reports correct byte count', async () => {
|
|
238
|
+
const content = 'x'.repeat(1024); // 1 KB
|
|
239
|
+
const slugDir = join(projectsDir, 'proj');
|
|
240
|
+
await mkdir(slugDir, { recursive: true });
|
|
241
|
+
const jsonlPath = join(slugDir, 'big-session.jsonl');
|
|
242
|
+
await writeFile(jsonlPath, content, 'utf-8');
|
|
243
|
+
// Use 35d to exceed the 30d API-key-absent circuit breaker minimum
|
|
244
|
+
const past = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
|
|
245
|
+
await utimes(jsonlPath, past, past);
|
|
246
|
+
|
|
247
|
+
const result = await pruneTranscripts({
|
|
248
|
+
olderThanMs: 7 * 24 * 60 * 60 * 1000,
|
|
249
|
+
confirm: false,
|
|
250
|
+
projectsDir,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(result.bytesFreed).toBeGreaterThanOrEqual(1024);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// pruneTranscripts with confirm=true (actual deletion)
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
describe('pruneTranscripts confirm=true', () => {
|
|
262
|
+
let tmpDir: string;
|
|
263
|
+
let projectsDir: string;
|
|
264
|
+
|
|
265
|
+
beforeEach(async () => {
|
|
266
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-prune-'));
|
|
267
|
+
projectsDir = join(tmpDir, 'projects');
|
|
268
|
+
await mkdir(projectsDir, { recursive: true });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
afterEach(async () => {
|
|
272
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('deletes sessions older than the threshold', async () => {
|
|
276
|
+
// Use 35d to be beyond the 30d API-key-absent circuit breaker minimum
|
|
277
|
+
const OLD_AGE = 35 * 24 * 60 * 60 * 1000; // 35 days
|
|
278
|
+
const jsonlPath = await createSession(projectsDir, 'proj', 'old-del', OLD_AGE);
|
|
279
|
+
|
|
280
|
+
const result = await pruneTranscripts({
|
|
281
|
+
olderThanMs: 7 * 24 * 60 * 60 * 1000,
|
|
282
|
+
confirm: true,
|
|
283
|
+
projectsDir,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// File must be gone
|
|
287
|
+
const { access } = await import('node:fs/promises');
|
|
288
|
+
await expect(access(jsonlPath)).rejects.toThrow();
|
|
289
|
+
|
|
290
|
+
expect(result.dryRun).toBe(false);
|
|
291
|
+
expect(result.pruned).toBeGreaterThan(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('does NOT delete sessions younger than the threshold', async () => {
|
|
295
|
+
const RECENT_AGE = 1 * 24 * 60 * 60 * 1000; // 1 day
|
|
296
|
+
const jsonlPath = await createSession(projectsDir, 'proj', 'recent-keep', RECENT_AGE);
|
|
297
|
+
|
|
298
|
+
await pruneTranscripts({
|
|
299
|
+
olderThanMs: 7 * 24 * 60 * 60 * 1000,
|
|
300
|
+
confirm: true,
|
|
301
|
+
projectsDir,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Recent file must still exist
|
|
305
|
+
const { access } = await import('node:fs/promises');
|
|
306
|
+
await expect(access(jsonlPath)).resolves.toBeUndefined();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// TL-F7: ANTHROPIC_API_KEY absent → 30d-only deletion (circuit breaker)
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
describe('pruneTranscripts API key circuit breaker (TL-F7)', () => {
|
|
315
|
+
let tmpDir: string;
|
|
316
|
+
let projectsDir: string;
|
|
317
|
+
let savedApiKey: string | undefined;
|
|
318
|
+
|
|
319
|
+
beforeEach(async () => {
|
|
320
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-apikey-'));
|
|
321
|
+
projectsDir = join(tmpDir, 'projects');
|
|
322
|
+
await mkdir(projectsDir, { recursive: true });
|
|
323
|
+
// Save and clear API key
|
|
324
|
+
savedApiKey = process.env['ANTHROPIC_API_KEY'];
|
|
325
|
+
delete process.env['ANTHROPIC_API_KEY'];
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
afterEach(async () => {
|
|
329
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
330
|
+
// Restore API key
|
|
331
|
+
if (savedApiKey !== undefined) {
|
|
332
|
+
process.env['ANTHROPIC_API_KEY'] = savedApiKey;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('skips deletion of sessions < 30d when API key is absent', async () => {
|
|
337
|
+
// 10-day-old session; caller requests 7d threshold
|
|
338
|
+
const TEN_DAYS = 10 * 24 * 60 * 60 * 1000;
|
|
339
|
+
const jsonlPath = await createSession(projectsDir, 'proj', 'apikey-test', TEN_DAYS);
|
|
340
|
+
|
|
341
|
+
const result = await pruneTranscripts({
|
|
342
|
+
olderThanMs: 7 * 24 * 60 * 60 * 1000, // 7d requested
|
|
343
|
+
confirm: true,
|
|
344
|
+
projectsDir,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Circuit breaker: no API key → 30d minimum. 10-day session is NOT deleted.
|
|
348
|
+
const { access } = await import('node:fs/promises');
|
|
349
|
+
await expect(access(jsonlPath)).resolves.toBeUndefined();
|
|
350
|
+
|
|
351
|
+
expect(result.pruned).toBe(0);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('allows deletion of sessions > 30d even without API key', async () => {
|
|
355
|
+
// 35-day-old session — beyond the 30d circuit-breaker minimum
|
|
356
|
+
const THIRTY_FIVE_DAYS = 35 * 24 * 60 * 60 * 1000;
|
|
357
|
+
const jsonlPath = await createSession(projectsDir, 'proj', 'old-apikey', THIRTY_FIVE_DAYS);
|
|
358
|
+
|
|
359
|
+
const result = await pruneTranscripts({
|
|
360
|
+
olderThanMs: 7 * 24 * 60 * 60 * 1000,
|
|
361
|
+
confirm: true,
|
|
362
|
+
projectsDir,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// 35-day session IS deleted (beyond 30d fallback minimum)
|
|
366
|
+
const { access } = await import('node:fs/promises');
|
|
367
|
+
await expect(access(jsonlPath)).rejects.toThrow();
|
|
368
|
+
|
|
369
|
+
expect(result.pruned).toBeGreaterThan(0);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GC Daemon Entry Point — Standalone script executed by `spawnGCDaemon()`.
|
|
3
|
+
*
|
|
4
|
+
* This script is spawned as a detached background process by `cleo daemon start`.
|
|
5
|
+
* It must NOT import from the main CLI shim (no citty, no commander). It only
|
|
6
|
+
* imports from the gc/ module subtree.
|
|
7
|
+
*
|
|
8
|
+
* The cleoDir is passed as argv[2] by `spawnGCDaemon()`.
|
|
9
|
+
*
|
|
10
|
+
* @see gc/daemon.ts for the spawn logic
|
|
11
|
+
* @task T731
|
|
12
|
+
* @epic T726
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { bootstrapDaemon } from './daemon.js';
|
|
18
|
+
|
|
19
|
+
const cleoDir = process.argv[2] ?? join(homedir(), '.cleo');
|
|
20
|
+
|
|
21
|
+
bootstrapDaemon(cleoDir).catch((err: unknown) => {
|
|
22
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
23
|
+
// stderr is redirected to gc.err by the parent spawn call
|
|
24
|
+
process.stderr.write(`[CLEO GC] Fatal daemon error: ${message}\n`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
package/src/gc/daemon.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GC Daemon — Sidecar background process for autonomous transcript cleanup.
|
|
3
|
+
*
|
|
4
|
+
* Architecture (Pattern B from T751 §2.2):
|
|
5
|
+
* - Spawned via `cleo daemon start` as a detached Node.js process
|
|
6
|
+
* - All three required flags: `detached: true`, file stdio, `child.unref()`
|
|
7
|
+
* - Persists across CLI invocations
|
|
8
|
+
* - Crash recovery via `.cleo/gc-state.json` startup-check
|
|
9
|
+
* - node-cron v4 for scheduling (zero runtime deps, cross-platform)
|
|
10
|
+
*
|
|
11
|
+
* Startup algorithm (systemd `Persistent=true` semantics in pure Node.js):
|
|
12
|
+
* 1. Read gc-state.json
|
|
13
|
+
* 2. If pendingPrune non-empty → resume deletion (crash recovery)
|
|
14
|
+
* 3. If lastRunAt null OR elapsed > 24h → run GC immediately (missed-run recovery)
|
|
15
|
+
* 4. Schedule future runs via node-cron (daily at 03:00 UTC)
|
|
16
|
+
* 5. Write daemonPid to state
|
|
17
|
+
*
|
|
18
|
+
* @see ADR-047 — Autonomous GC and Disk Safety
|
|
19
|
+
* @see T751 §2.2 for sidecar daemon pattern rationale
|
|
20
|
+
* @task T731
|
|
21
|
+
* @epic T726
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import { createWriteStream } from 'node:fs';
|
|
26
|
+
import { mkdir } from 'node:fs/promises';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import cron from 'node-cron';
|
|
30
|
+
import { runGC } from './runner.js';
|
|
31
|
+
import { patchGCState, readGCState } from './state.js';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Constants
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** Cron expression: daily at 03:00 UTC. */
|
|
38
|
+
const GC_CRON_EXPR = '0 3 * * *';
|
|
39
|
+
|
|
40
|
+
/** Interval for missed-run recovery check (24 hours in ms). */
|
|
41
|
+
const GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Daemon Bootstrap (runs when this module is executed as a standalone script)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bootstrap the GC daemon process.
|
|
49
|
+
*
|
|
50
|
+
* Performs crash recovery, missed-run recovery, and schedules future GC runs.
|
|
51
|
+
* This function runs in the long-lived daemon process.
|
|
52
|
+
*
|
|
53
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
54
|
+
*/
|
|
55
|
+
export async function bootstrapDaemon(cleoDir: string): Promise<void> {
|
|
56
|
+
const statePath = join(cleoDir, 'gc-state.json');
|
|
57
|
+
|
|
58
|
+
// Register daemon PID in state file
|
|
59
|
+
await patchGCState(statePath, {
|
|
60
|
+
daemonPid: process.pid,
|
|
61
|
+
daemonStartedAt: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const state = await readGCState(statePath);
|
|
65
|
+
|
|
66
|
+
// Step 1: Crash recovery — resume pending prune from prior run
|
|
67
|
+
if (state.pendingPrune && state.pendingPrune.length > 0) {
|
|
68
|
+
try {
|
|
69
|
+
await runGC({ cleoDir, resumeFrom: state.pendingPrune });
|
|
70
|
+
} catch {
|
|
71
|
+
// Crash recovery failure is non-fatal; continue with scheduled runs
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Step 2: Missed-run recovery — if last run was > 24h ago, run immediately
|
|
76
|
+
const lastRunTs = state.lastRunAt ? new Date(state.lastRunAt).getTime() : 0;
|
|
77
|
+
const elapsed = Date.now() - lastRunTs;
|
|
78
|
+
if (elapsed > GC_INTERVAL_MS) {
|
|
79
|
+
try {
|
|
80
|
+
await runGC({ cleoDir });
|
|
81
|
+
} catch {
|
|
82
|
+
// Immediate GC failure is non-fatal; cron will retry next cycle
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Step 3: Schedule future runs via node-cron
|
|
87
|
+
// noOverlap: true prevents double-runs if a previous run exceeds 24h
|
|
88
|
+
cron.schedule(
|
|
89
|
+
GC_CRON_EXPR,
|
|
90
|
+
async () => {
|
|
91
|
+
try {
|
|
92
|
+
await runGC({ cleoDir });
|
|
93
|
+
} catch {
|
|
94
|
+
// Log failures via stderr (already redirected to gc.log by spawn)
|
|
95
|
+
const state2 = await readGCState(statePath);
|
|
96
|
+
await patchGCState(statePath, {
|
|
97
|
+
consecutiveFailures: state2.consecutiveFailures + 1,
|
|
98
|
+
lastRunResult: 'failed',
|
|
99
|
+
escalationNeeded: state2.consecutiveFailures + 1 >= 3,
|
|
100
|
+
escalationReason:
|
|
101
|
+
state2.consecutiveFailures + 1 >= 3
|
|
102
|
+
? `GC daemon: ${state2.consecutiveFailures + 1} consecutive failures. Check logs.`
|
|
103
|
+
: state2.escalationReason,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
timezone: 'UTC',
|
|
109
|
+
noOverlap: true,
|
|
110
|
+
name: 'cleo-gc',
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Spawn Helpers (called by `cleo daemon start` in the parent CLI process)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Spawn the GC daemon as a detached background process.
|
|
121
|
+
*
|
|
122
|
+
* All three requirements from T751 §2.2 are met:
|
|
123
|
+
* 1. `detached: true` — process group leader (survives parent exit)
|
|
124
|
+
* 2. File stdio — stdout/stderr redirected to gc.log (not inherited)
|
|
125
|
+
* 3. `child.unref()` — parent CLI exits immediately
|
|
126
|
+
*
|
|
127
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
128
|
+
* @returns PID of the spawned daemon process
|
|
129
|
+
*/
|
|
130
|
+
export async function spawnGCDaemon(cleoDir: string): Promise<number> {
|
|
131
|
+
const logsDir = join(cleoDir, 'logs');
|
|
132
|
+
await mkdir(logsDir, { recursive: true });
|
|
133
|
+
|
|
134
|
+
const logPath = join(logsDir, 'gc.log');
|
|
135
|
+
const errPath = join(logsDir, 'gc.err');
|
|
136
|
+
|
|
137
|
+
// File-based stdio: required for detached process to not inherit the TTY
|
|
138
|
+
const outStream = createWriteStream(logPath, { flags: 'a' });
|
|
139
|
+
const errStream = createWriteStream(errPath, { flags: 'a' });
|
|
140
|
+
|
|
141
|
+
// The daemon entry-point script (compiled alongside this module)
|
|
142
|
+
const daemonEntry = join(fileURLToPath(import.meta.url), '..', 'daemon-entry.js');
|
|
143
|
+
|
|
144
|
+
const child = spawn(process.execPath, [daemonEntry, cleoDir], {
|
|
145
|
+
detached: true,
|
|
146
|
+
stdio: ['ignore', outStream, errStream],
|
|
147
|
+
env: { ...process.env, CLEO_GC_DAEMON: '1' },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// unref() allows the parent CLI process to exit while the daemon continues
|
|
151
|
+
child.unref();
|
|
152
|
+
|
|
153
|
+
const pid = child.pid ?? 0;
|
|
154
|
+
|
|
155
|
+
// Persist PID so `cleo daemon stop` can find and signal the process
|
|
156
|
+
await patchGCState(join(cleoDir, 'gc-state.json'), {
|
|
157
|
+
daemonPid: pid,
|
|
158
|
+
daemonStartedAt: new Date().toISOString(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return pid;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Stop the GC daemon by sending SIGTERM to its PID.
|
|
166
|
+
*
|
|
167
|
+
* Uses `process.kill(pid, 0)` as a no-throw liveness probe before signalling.
|
|
168
|
+
*
|
|
169
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
170
|
+
* @returns `{ stopped: boolean; pid: number | null; reason: string }`
|
|
171
|
+
*/
|
|
172
|
+
export async function stopGCDaemon(
|
|
173
|
+
cleoDir: string,
|
|
174
|
+
): Promise<{ stopped: boolean; pid: number | null; reason: string }> {
|
|
175
|
+
const statePath = join(cleoDir, 'gc-state.json');
|
|
176
|
+
const state = await readGCState(statePath);
|
|
177
|
+
const pid = state.daemonPid;
|
|
178
|
+
|
|
179
|
+
if (!pid) {
|
|
180
|
+
return { stopped: false, pid: null, reason: 'Daemon PID not found in gc-state.json' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Liveness probe: process.kill(pid, 0) throws if PID is not running
|
|
184
|
+
try {
|
|
185
|
+
process.kill(pid, 0);
|
|
186
|
+
} catch {
|
|
187
|
+
// Process is not running — clear stale PID from state
|
|
188
|
+
await patchGCState(statePath, { daemonPid: null });
|
|
189
|
+
return {
|
|
190
|
+
stopped: false,
|
|
191
|
+
pid,
|
|
192
|
+
reason: `Daemon PID ${pid} is not running (stale state cleared)`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Send SIGTERM — daemon should clean up and exit gracefully
|
|
197
|
+
try {
|
|
198
|
+
process.kill(pid, 'SIGTERM');
|
|
199
|
+
// Clear PID from state after successful signal
|
|
200
|
+
await patchGCState(statePath, { daemonPid: null });
|
|
201
|
+
return { stopped: true, pid, reason: `SIGTERM sent to PID ${pid}` };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
204
|
+
return { stopped: false, pid, reason: `Failed to send SIGTERM to PID ${pid}: ${msg}` };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check whether the GC daemon is currently running.
|
|
210
|
+
*
|
|
211
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
212
|
+
* @returns `{ running: boolean; pid: number | null; startedAt: string | null }`
|
|
213
|
+
*/
|
|
214
|
+
export async function getGCDaemonStatus(cleoDir: string): Promise<{
|
|
215
|
+
running: boolean;
|
|
216
|
+
pid: number | null;
|
|
217
|
+
startedAt: string | null;
|
|
218
|
+
lastRunAt: string | null;
|
|
219
|
+
lastDiskUsedPct: number | null;
|
|
220
|
+
escalationNeeded: boolean;
|
|
221
|
+
}> {
|
|
222
|
+
const state = await readGCState(join(cleoDir, 'gc-state.json'));
|
|
223
|
+
const pid = state.daemonPid;
|
|
224
|
+
|
|
225
|
+
let running = false;
|
|
226
|
+
if (pid) {
|
|
227
|
+
try {
|
|
228
|
+
process.kill(pid, 0);
|
|
229
|
+
running = true;
|
|
230
|
+
} catch {
|
|
231
|
+
running = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
running,
|
|
237
|
+
pid: running ? pid : null,
|
|
238
|
+
startedAt: state.daemonStartedAt,
|
|
239
|
+
lastRunAt: state.lastRunAt,
|
|
240
|
+
lastDiskUsedPct: state.lastDiskUsedPct,
|
|
241
|
+
escalationNeeded: state.escalationNeeded,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Standalone daemon entry point
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
// When this module is executed directly (via `node daemon.js <cleoDir>`),
|
|
250
|
+
// bootstrap the daemon. The daemon-entry.js shim calls bootstrapDaemon().
|
|
251
|
+
// See src/gc/daemon-entry.ts for the entry script.
|
package/src/gc/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cleocode/core/gc — Autonomous GC daemon public API.
|
|
3
|
+
*
|
|
4
|
+
* Provides transcript cleanup, disk-pressure monitoring, GC state
|
|
5
|
+
* management, and daemon lifecycle (spawn/stop/status).
|
|
6
|
+
*
|
|
7
|
+
* @see ADR-047 — Autonomous GC and Disk Safety
|
|
8
|
+
* @package @cleocode/core
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export * from './daemon.js';
|
|
12
|
+
export * from './runner.js';
|
|
13
|
+
export * from './state.js';
|
|
14
|
+
export * from './transcript.js';
|