@cleocode/core 2026.4.98 → 2026.4.99
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/index.d.ts +14 -0
- package/dist/gc/index.d.ts.map +1 -0
- package/dist/gc/runner.d.ts +132 -0
- package/dist/gc/runner.d.ts.map +1 -0
- package/dist/gc/state.d.ts +94 -0
- package/dist/gc/state.d.ts.map +1 -0
- package/dist/gc/transcript.d.ts +130 -0
- package/dist/gc/transcript.d.ts.map +1 -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/index.d.ts +18 -0
- package/dist/sentient/index.d.ts.map +1 -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/state.d.ts +143 -0
- package/dist/sentient/state.d.ts.map +1 -0
- package/dist/sentient/tick.d.ts +193 -0
- package/dist/sentient/tick.d.ts.map +1 -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,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
|
+
});
|
|
@@ -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
|
+
});
|