@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,15 @@
|
|
|
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
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=daemon-entry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"daemon-entry.d.ts","sourceRoot":"","sources":["../../src/gc/daemon-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG"}
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
* Bootstrap the GC daemon process.
|
|
25
|
+
*
|
|
26
|
+
* Performs crash recovery, missed-run recovery, and schedules future GC runs.
|
|
27
|
+
* This function runs in the long-lived daemon process.
|
|
28
|
+
*
|
|
29
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
30
|
+
*/
|
|
31
|
+
export declare function bootstrapDaemon(cleoDir: string): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Spawn the GC daemon as a detached background process.
|
|
34
|
+
*
|
|
35
|
+
* All three requirements from T751 §2.2 are met:
|
|
36
|
+
* 1. `detached: true` — process group leader (survives parent exit)
|
|
37
|
+
* 2. File stdio — stdout/stderr redirected to gc.log (not inherited)
|
|
38
|
+
* 3. `child.unref()` — parent CLI exits immediately
|
|
39
|
+
*
|
|
40
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
41
|
+
* @returns PID of the spawned daemon process
|
|
42
|
+
*/
|
|
43
|
+
export declare function spawnGCDaemon(cleoDir: string): Promise<number>;
|
|
44
|
+
/**
|
|
45
|
+
* Stop the GC daemon by sending SIGTERM to its PID.
|
|
46
|
+
*
|
|
47
|
+
* Uses `process.kill(pid, 0)` as a no-throw liveness probe before signalling.
|
|
48
|
+
*
|
|
49
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
50
|
+
* @returns `{ stopped: boolean; pid: number | null; reason: string }`
|
|
51
|
+
*/
|
|
52
|
+
export declare function stopGCDaemon(cleoDir: string): Promise<{
|
|
53
|
+
stopped: boolean;
|
|
54
|
+
pid: number | null;
|
|
55
|
+
reason: string;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* Check whether the GC daemon is currently running.
|
|
59
|
+
*
|
|
60
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
61
|
+
* @returns `{ running: boolean; pid: number | null; startedAt: string | null }`
|
|
62
|
+
*/
|
|
63
|
+
export declare function getGCDaemonStatus(cleoDir: string): Promise<{
|
|
64
|
+
running: boolean;
|
|
65
|
+
pid: number | null;
|
|
66
|
+
startedAt: string | null;
|
|
67
|
+
lastRunAt: string | null;
|
|
68
|
+
lastDiskUsedPct: number | null;
|
|
69
|
+
escalationNeeded: boolean;
|
|
70
|
+
}>;
|
|
71
|
+
//# sourceMappingURL=daemon.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../../src/gc/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAyBH;;;;;;;GAOG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0DpE;AAMD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAgCpE;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAgCnE;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;IAChE,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,gBAAgB,EAAE,OAAO,CAAC;CAC3B,CAAC,CAsBD"}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// packages/core/src/gc/daemon.ts
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { mkdir as mkdir2 } from "node:fs/promises";
|
|
5
|
+
import { join as join3 } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import cron from "node-cron";
|
|
8
|
+
|
|
9
|
+
// packages/core/src/gc/runner.ts
|
|
10
|
+
import { lstat, readdir, rm, stat } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join as join2 } from "node:path";
|
|
13
|
+
|
|
14
|
+
// node_modules/.pnpm/check-disk-space@3.4.0/node_modules/check-disk-space/dist/check-disk-space.mjs
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { access } from "node:fs/promises";
|
|
17
|
+
import { release } from "node:os";
|
|
18
|
+
import { normalize, sep } from "node:path";
|
|
19
|
+
import { platform } from "node:process";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
var InvalidPathError = class _InvalidPathError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "InvalidPathError";
|
|
25
|
+
Object.setPrototypeOf(this, _InvalidPathError.prototype);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var NoMatchError = class _NoMatchError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "NoMatchError";
|
|
32
|
+
Object.setPrototypeOf(this, _NoMatchError.prototype);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
async function isDirectoryExisting(directoryPath, dependencies) {
|
|
36
|
+
try {
|
|
37
|
+
await dependencies.fsAccess(directoryPath);
|
|
38
|
+
return Promise.resolve(true);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return Promise.resolve(false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function getFirstExistingParentPath(directoryPath, dependencies) {
|
|
44
|
+
let parentDirectoryPath = directoryPath;
|
|
45
|
+
let parentDirectoryFound = await isDirectoryExisting(parentDirectoryPath, dependencies);
|
|
46
|
+
while (!parentDirectoryFound) {
|
|
47
|
+
parentDirectoryPath = dependencies.pathNormalize(parentDirectoryPath + "/..");
|
|
48
|
+
parentDirectoryFound = await isDirectoryExisting(parentDirectoryPath, dependencies);
|
|
49
|
+
}
|
|
50
|
+
return parentDirectoryPath;
|
|
51
|
+
}
|
|
52
|
+
async function hasPowerShell3(dependencies) {
|
|
53
|
+
const major = parseInt(dependencies.release.split(".")[0], 10);
|
|
54
|
+
if (major <= 6) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
await dependencies.cpExecFile("where", ["powershell"], { windowsHide: true });
|
|
59
|
+
return true;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function checkDiskSpace(directoryPath, dependencies = {
|
|
65
|
+
platform,
|
|
66
|
+
release: release(),
|
|
67
|
+
fsAccess: access,
|
|
68
|
+
pathNormalize: normalize,
|
|
69
|
+
pathSep: sep,
|
|
70
|
+
cpExecFile: promisify(execFile)
|
|
71
|
+
}) {
|
|
72
|
+
function mapOutput(stdout, filter, mapping, coefficient) {
|
|
73
|
+
const parsed = stdout.split("\n").map((line) => line.trim()).filter((line) => line.length !== 0).slice(1).map((line) => line.split(/\s+(?=[\d/])/));
|
|
74
|
+
const filtered = parsed.filter(filter);
|
|
75
|
+
if (filtered.length === 0) {
|
|
76
|
+
throw new NoMatchError();
|
|
77
|
+
}
|
|
78
|
+
const diskData = filtered[0];
|
|
79
|
+
return {
|
|
80
|
+
diskPath: diskData[mapping.diskPath],
|
|
81
|
+
free: parseInt(diskData[mapping.free], 10) * coefficient,
|
|
82
|
+
size: parseInt(diskData[mapping.size], 10) * coefficient
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function check(cmd, filter, mapping, coefficient = 1) {
|
|
86
|
+
const [file, ...args] = cmd;
|
|
87
|
+
if (file === void 0) {
|
|
88
|
+
return Promise.reject(new Error("cmd must contain at least one item"));
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const { stdout } = await dependencies.cpExecFile(file, args, { windowsHide: true });
|
|
92
|
+
return mapOutput(stdout, filter, mapping, coefficient);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return Promise.reject(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function checkWin32(directoryPath2) {
|
|
98
|
+
if (directoryPath2.charAt(1) !== ":") {
|
|
99
|
+
return Promise.reject(new InvalidPathError(`The following path is invalid (should be X:\\...): ${directoryPath2}`));
|
|
100
|
+
}
|
|
101
|
+
const powershellCmd = [
|
|
102
|
+
"powershell",
|
|
103
|
+
"Get-CimInstance -ClassName Win32_LogicalDisk | Select-Object Caption, FreeSpace, Size"
|
|
104
|
+
];
|
|
105
|
+
const wmicCmd = [
|
|
106
|
+
"wmic",
|
|
107
|
+
"logicaldisk",
|
|
108
|
+
"get",
|
|
109
|
+
"size,freespace,caption"
|
|
110
|
+
];
|
|
111
|
+
const cmd = await hasPowerShell3(dependencies) ? powershellCmd : wmicCmd;
|
|
112
|
+
return check(cmd, (driveData) => {
|
|
113
|
+
const driveLetter = driveData[0];
|
|
114
|
+
return directoryPath2.toUpperCase().startsWith(driveLetter.toUpperCase());
|
|
115
|
+
}, {
|
|
116
|
+
diskPath: 0,
|
|
117
|
+
free: 1,
|
|
118
|
+
size: 2
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
async function checkUnix(directoryPath2) {
|
|
122
|
+
if (!dependencies.pathNormalize(directoryPath2).startsWith(dependencies.pathSep)) {
|
|
123
|
+
return Promise.reject(new InvalidPathError(`The following path is invalid (should start by ${dependencies.pathSep}): ${directoryPath2}`));
|
|
124
|
+
}
|
|
125
|
+
const pathToCheck = await getFirstExistingParentPath(directoryPath2, dependencies);
|
|
126
|
+
return check(
|
|
127
|
+
[
|
|
128
|
+
"df",
|
|
129
|
+
"-Pk",
|
|
130
|
+
"--",
|
|
131
|
+
pathToCheck
|
|
132
|
+
],
|
|
133
|
+
() => true,
|
|
134
|
+
// We should only get one line, so we did not need to filter
|
|
135
|
+
{
|
|
136
|
+
diskPath: 5,
|
|
137
|
+
free: 3,
|
|
138
|
+
size: 1
|
|
139
|
+
},
|
|
140
|
+
1024
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (dependencies.platform === "win32") {
|
|
144
|
+
return checkWin32(directoryPath);
|
|
145
|
+
}
|
|
146
|
+
return checkUnix(directoryPath);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// packages/core/src/gc/state.ts
|
|
150
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
151
|
+
import { dirname, join } from "node:path";
|
|
152
|
+
var GC_STATE_SCHEMA_VERSION = "1.0";
|
|
153
|
+
var DEFAULT_GC_STATE = {
|
|
154
|
+
schemaVersion: GC_STATE_SCHEMA_VERSION,
|
|
155
|
+
lastRunAt: null,
|
|
156
|
+
lastRunResult: null,
|
|
157
|
+
lastRunBytesFreed: 0,
|
|
158
|
+
pendingPrune: null,
|
|
159
|
+
consecutiveFailures: 0,
|
|
160
|
+
diskThresholdBreached: false,
|
|
161
|
+
lastDiskUsedPct: null,
|
|
162
|
+
escalationNeeded: false,
|
|
163
|
+
escalationReason: null,
|
|
164
|
+
daemonPid: null,
|
|
165
|
+
daemonStartedAt: null
|
|
166
|
+
};
|
|
167
|
+
async function readGCState(statePath) {
|
|
168
|
+
try {
|
|
169
|
+
const raw = await readFile(statePath, "utf-8");
|
|
170
|
+
const parsed = JSON.parse(raw);
|
|
171
|
+
return { ...DEFAULT_GC_STATE, ...parsed };
|
|
172
|
+
} catch {
|
|
173
|
+
return { ...DEFAULT_GC_STATE };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function writeGCState(statePath, state) {
|
|
177
|
+
const dir = dirname(statePath);
|
|
178
|
+
await mkdir(dir, { recursive: true });
|
|
179
|
+
const tmpPath = join(dir, `.gc-state-${process.pid}.tmp`);
|
|
180
|
+
const json = JSON.stringify(state, null, 2);
|
|
181
|
+
await writeFile(tmpPath, json, "utf-8");
|
|
182
|
+
await rename(tmpPath, statePath);
|
|
183
|
+
}
|
|
184
|
+
async function patchGCState(statePath, patch) {
|
|
185
|
+
const current = await readGCState(statePath);
|
|
186
|
+
const updated = { ...current, ...patch };
|
|
187
|
+
await writeGCState(statePath, updated);
|
|
188
|
+
return updated;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// packages/core/src/gc/runner.ts
|
|
192
|
+
var checkDiskSpace2 = checkDiskSpace;
|
|
193
|
+
var DISK_THRESHOLDS = {
|
|
194
|
+
WATCH: 70,
|
|
195
|
+
WARN: 85,
|
|
196
|
+
URGENT: 90,
|
|
197
|
+
EMERGENCY: 95
|
|
198
|
+
};
|
|
199
|
+
function classifyDiskTier(pct) {
|
|
200
|
+
if (pct >= DISK_THRESHOLDS.EMERGENCY) return "emergency";
|
|
201
|
+
if (pct >= DISK_THRESHOLDS.URGENT) return "urgent";
|
|
202
|
+
if (pct >= DISK_THRESHOLDS.WARN) return "warn";
|
|
203
|
+
if (pct >= DISK_THRESHOLDS.WATCH) return "watch";
|
|
204
|
+
return "ok";
|
|
205
|
+
}
|
|
206
|
+
function retentionMs(tier) {
|
|
207
|
+
switch (tier) {
|
|
208
|
+
case "emergency":
|
|
209
|
+
return 1 * 24 * 60 * 60 * 1e3;
|
|
210
|
+
// 1 day
|
|
211
|
+
case "urgent":
|
|
212
|
+
return 3 * 24 * 60 * 60 * 1e3;
|
|
213
|
+
// 3 days
|
|
214
|
+
case "warn":
|
|
215
|
+
return 7 * 24 * 60 * 60 * 1e3;
|
|
216
|
+
// 7 days
|
|
217
|
+
default:
|
|
218
|
+
return 30 * 24 * 60 * 60 * 1e3;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function getPathBytes(targetPath) {
|
|
222
|
+
try {
|
|
223
|
+
const info = await lstat(targetPath);
|
|
224
|
+
if (info.isFile()) return info.size;
|
|
225
|
+
if (!info.isDirectory()) return 0;
|
|
226
|
+
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
227
|
+
let total = 0;
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
total += await getPathBytes(join2(targetPath, entry.name));
|
|
230
|
+
}
|
|
231
|
+
return total;
|
|
232
|
+
} catch {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function idempotentRm(targetPath) {
|
|
237
|
+
try {
|
|
238
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const nodeErr = err;
|
|
241
|
+
if (nodeErr.code === "ENOENT") return;
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function gatherPruneCandidates(maxAgeMs, projectsDir) {
|
|
246
|
+
const resolvedProjectsDir = projectsDir ?? join2(homedir(), ".claude", "projects");
|
|
247
|
+
const candidates = [];
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
let projectSlugs;
|
|
250
|
+
try {
|
|
251
|
+
const entries = await readdir(resolvedProjectsDir, { withFileTypes: true });
|
|
252
|
+
projectSlugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
253
|
+
} catch {
|
|
254
|
+
return candidates;
|
|
255
|
+
}
|
|
256
|
+
for (const slug of projectSlugs) {
|
|
257
|
+
const slugDir = join2(resolvedProjectsDir, slug);
|
|
258
|
+
let slugEntries;
|
|
259
|
+
try {
|
|
260
|
+
slugEntries = await readdir(slugDir, { withFileTypes: true });
|
|
261
|
+
} catch {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
for (const entry of slugEntries) {
|
|
265
|
+
const entryPath = join2(slugDir, entry.name);
|
|
266
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
267
|
+
try {
|
|
268
|
+
const info = await stat(entryPath);
|
|
269
|
+
const ageMs = now - info.mtimeMs;
|
|
270
|
+
if (ageMs > maxAgeMs) {
|
|
271
|
+
candidates.push(entryPath);
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
} else if (entry.isDirectory()) {
|
|
276
|
+
try {
|
|
277
|
+
const info = await stat(entryPath);
|
|
278
|
+
const ageMs = now - info.mtimeMs;
|
|
279
|
+
if (ageMs > maxAgeMs) {
|
|
280
|
+
candidates.push(entryPath);
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return candidates;
|
|
288
|
+
}
|
|
289
|
+
async function runGC(opts = {}) {
|
|
290
|
+
const cleoDir = opts.cleoDir ?? join2(homedir(), ".cleo");
|
|
291
|
+
const statePath = join2(cleoDir, "gc-state.json");
|
|
292
|
+
const dryRun = opts.dryRun ?? false;
|
|
293
|
+
const projectsDir = opts.projectsDir;
|
|
294
|
+
const initialState = await readGCState(statePath);
|
|
295
|
+
const resumePaths = opts.resumeFrom ?? initialState.pendingPrune ?? [];
|
|
296
|
+
let diskUsedPct = 0;
|
|
297
|
+
try {
|
|
298
|
+
const { free, size } = await checkDiskSpace2(cleoDir);
|
|
299
|
+
diskUsedPct = size > 0 ? (size - free) / size * 100 : 0;
|
|
300
|
+
} catch {
|
|
301
|
+
diskUsedPct = 0;
|
|
302
|
+
}
|
|
303
|
+
const tier = classifyDiskTier(diskUsedPct);
|
|
304
|
+
const maxAgeMs = retentionMs(tier);
|
|
305
|
+
const candidatesFromScan = resumePaths.length > 0 ? resumePaths : await gatherPruneCandidates(maxAgeMs, projectsDir);
|
|
306
|
+
if (!dryRun && candidatesFromScan.length > 0) {
|
|
307
|
+
await patchGCState(statePath, { pendingPrune: candidatesFromScan });
|
|
308
|
+
}
|
|
309
|
+
const pruned = [];
|
|
310
|
+
let bytesFreed = 0;
|
|
311
|
+
const remaining = [...candidatesFromScan];
|
|
312
|
+
for (const candidatePath of candidatesFromScan) {
|
|
313
|
+
const bytes = await getPathBytes(candidatePath);
|
|
314
|
+
if (dryRun) {
|
|
315
|
+
pruned.push({ path: candidatePath, bytes });
|
|
316
|
+
bytesFreed += bytes;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
await idempotentRm(candidatePath);
|
|
321
|
+
pruned.push({ path: candidatePath, bytes });
|
|
322
|
+
bytesFreed += bytes;
|
|
323
|
+
const idx = remaining.indexOf(candidatePath);
|
|
324
|
+
if (idx !== -1) remaining.splice(idx, 1);
|
|
325
|
+
await patchGCState(statePath, {
|
|
326
|
+
pendingPrune: remaining.length > 0 ? remaining : null
|
|
327
|
+
});
|
|
328
|
+
} catch {
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const escalationSet = tier === "warn" || tier === "urgent" || tier === "emergency";
|
|
332
|
+
let escalationReason = null;
|
|
333
|
+
if (escalationSet) {
|
|
334
|
+
escalationReason = `Disk at ${diskUsedPct.toFixed(1)}% (${tier.toUpperCase()}): ${pruned.length} paths pruned, ${bytesFreed} bytes freed`;
|
|
335
|
+
}
|
|
336
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
337
|
+
if (!dryRun) {
|
|
338
|
+
await patchGCState(statePath, {
|
|
339
|
+
lastRunAt: completedAt,
|
|
340
|
+
lastRunResult: remaining.length === 0 ? "success" : "partial",
|
|
341
|
+
lastRunBytesFreed: bytesFreed,
|
|
342
|
+
pendingPrune: remaining.length > 0 ? remaining : null,
|
|
343
|
+
consecutiveFailures: remaining.length > 0 ? initialState.consecutiveFailures + 1 : 0,
|
|
344
|
+
diskThresholdBreached: diskUsedPct >= DISK_THRESHOLDS.WATCH,
|
|
345
|
+
lastDiskUsedPct: diskUsedPct,
|
|
346
|
+
escalationNeeded: escalationSet || initialState.escalationNeeded,
|
|
347
|
+
escalationReason: escalationReason ?? initialState.escalationReason
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
diskUsedPct,
|
|
352
|
+
threshold: tier,
|
|
353
|
+
pruned,
|
|
354
|
+
bytesFreed,
|
|
355
|
+
escalationSet,
|
|
356
|
+
escalationReason,
|
|
357
|
+
completedAt
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// packages/core/src/gc/daemon.ts
|
|
362
|
+
var GC_CRON_EXPR = "0 3 * * *";
|
|
363
|
+
var GC_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
364
|
+
async function bootstrapDaemon(cleoDir) {
|
|
365
|
+
const statePath = join3(cleoDir, "gc-state.json");
|
|
366
|
+
await patchGCState(statePath, {
|
|
367
|
+
daemonPid: process.pid,
|
|
368
|
+
daemonStartedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
369
|
+
});
|
|
370
|
+
const state = await readGCState(statePath);
|
|
371
|
+
if (state.pendingPrune && state.pendingPrune.length > 0) {
|
|
372
|
+
try {
|
|
373
|
+
await runGC({ cleoDir, resumeFrom: state.pendingPrune });
|
|
374
|
+
} catch {
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const lastRunTs = state.lastRunAt ? new Date(state.lastRunAt).getTime() : 0;
|
|
378
|
+
const elapsed = Date.now() - lastRunTs;
|
|
379
|
+
if (elapsed > GC_INTERVAL_MS) {
|
|
380
|
+
try {
|
|
381
|
+
await runGC({ cleoDir });
|
|
382
|
+
} catch {
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
cron.schedule(
|
|
386
|
+
GC_CRON_EXPR,
|
|
387
|
+
async () => {
|
|
388
|
+
try {
|
|
389
|
+
await runGC({ cleoDir });
|
|
390
|
+
} catch {
|
|
391
|
+
const state2 = await readGCState(statePath);
|
|
392
|
+
await patchGCState(statePath, {
|
|
393
|
+
consecutiveFailures: state2.consecutiveFailures + 1,
|
|
394
|
+
lastRunResult: "failed",
|
|
395
|
+
escalationNeeded: state2.consecutiveFailures + 1 >= 3,
|
|
396
|
+
escalationReason: state2.consecutiveFailures + 1 >= 3 ? `GC daemon: ${state2.consecutiveFailures + 1} consecutive failures. Check logs.` : state2.escalationReason
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
timezone: "UTC",
|
|
402
|
+
noOverlap: true,
|
|
403
|
+
name: "cleo-gc"
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
async function spawnGCDaemon(cleoDir) {
|
|
408
|
+
const logsDir = join3(cleoDir, "logs");
|
|
409
|
+
await mkdir2(logsDir, { recursive: true });
|
|
410
|
+
const logPath = join3(logsDir, "gc.log");
|
|
411
|
+
const errPath = join3(logsDir, "gc.err");
|
|
412
|
+
const outStream = createWriteStream(logPath, { flags: "a" });
|
|
413
|
+
const errStream = createWriteStream(errPath, { flags: "a" });
|
|
414
|
+
const daemonEntry = join3(fileURLToPath(import.meta.url), "..", "daemon-entry.js");
|
|
415
|
+
const child = spawn(process.execPath, [daemonEntry, cleoDir], {
|
|
416
|
+
detached: true,
|
|
417
|
+
stdio: ["ignore", outStream, errStream],
|
|
418
|
+
env: { ...process.env, CLEO_GC_DAEMON: "1" }
|
|
419
|
+
});
|
|
420
|
+
child.unref();
|
|
421
|
+
const pid = child.pid ?? 0;
|
|
422
|
+
await patchGCState(join3(cleoDir, "gc-state.json"), {
|
|
423
|
+
daemonPid: pid,
|
|
424
|
+
daemonStartedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
425
|
+
});
|
|
426
|
+
return pid;
|
|
427
|
+
}
|
|
428
|
+
async function stopGCDaemon(cleoDir) {
|
|
429
|
+
const statePath = join3(cleoDir, "gc-state.json");
|
|
430
|
+
const state = await readGCState(statePath);
|
|
431
|
+
const pid = state.daemonPid;
|
|
432
|
+
if (!pid) {
|
|
433
|
+
return { stopped: false, pid: null, reason: "Daemon PID not found in gc-state.json" };
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
process.kill(pid, 0);
|
|
437
|
+
} catch {
|
|
438
|
+
await patchGCState(statePath, { daemonPid: null });
|
|
439
|
+
return {
|
|
440
|
+
stopped: false,
|
|
441
|
+
pid,
|
|
442
|
+
reason: `Daemon PID ${pid} is not running (stale state cleared)`
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
process.kill(pid, "SIGTERM");
|
|
447
|
+
await patchGCState(statePath, { daemonPid: null });
|
|
448
|
+
return { stopped: true, pid, reason: `SIGTERM sent to PID ${pid}` };
|
|
449
|
+
} catch (err) {
|
|
450
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
451
|
+
return { stopped: false, pid, reason: `Failed to send SIGTERM to PID ${pid}: ${msg}` };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function getGCDaemonStatus(cleoDir) {
|
|
455
|
+
const state = await readGCState(join3(cleoDir, "gc-state.json"));
|
|
456
|
+
const pid = state.daemonPid;
|
|
457
|
+
let running = false;
|
|
458
|
+
if (pid) {
|
|
459
|
+
try {
|
|
460
|
+
process.kill(pid, 0);
|
|
461
|
+
running = true;
|
|
462
|
+
} catch {
|
|
463
|
+
running = false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
running,
|
|
468
|
+
pid: running ? pid : null,
|
|
469
|
+
startedAt: state.daemonStartedAt,
|
|
470
|
+
lastRunAt: state.lastRunAt,
|
|
471
|
+
lastDiskUsedPct: state.lastDiskUsedPct,
|
|
472
|
+
escalationNeeded: state.escalationNeeded
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
export {
|
|
476
|
+
bootstrapDaemon,
|
|
477
|
+
getGCDaemonStatus,
|
|
478
|
+
spawnGCDaemon,
|
|
479
|
+
stopGCDaemon
|
|
480
|
+
};
|
|
481
|
+
//# sourceMappingURL=daemon.js.map
|