claude-code-session-manager 0.62.1 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AgentLibrary-9KJ4fedH.js +1 -0
- package/dist/assets/History-Dp3-_bjQ.js +2 -0
- package/dist/assets/Hooks-DMQFgGDd.js +3 -0
- package/dist/assets/HostBilko-zNU1NtBh.js +1 -0
- package/dist/assets/Library-BrODBbeL.js +46 -0
- package/dist/assets/ListDetail-C5vbA1gC.js +1 -0
- package/dist/assets/MarkdownEditor-CpzAEiiG.js +1 -0
- package/dist/assets/McpServers-DWuw-flj.js +2 -0
- package/dist/assets/Memory-B4TC9SsJ.js +8 -0
- package/dist/assets/Panel-Bap7G-pI.js +1 -0
- package/dist/assets/Permissions-BIpNWItH.js +3 -0
- package/dist/assets/Plugins-DrwklmW4.js +2 -0
- package/dist/assets/ProvenanceBadge-CCp_EhjV.js +1 -0
- package/dist/assets/Scheduler-DfoTegGG.js +14 -0
- package/dist/assets/ScopeSwitcher-BlQvpyLE.js +1 -0
- package/dist/assets/Settings-BzvmZvGa.js +3 -0
- package/dist/assets/SkillReferenceGraph-CGtwmCjm.js +46 -0
- package/dist/assets/Skills-Cauvbtk7.js +3 -0
- package/dist/assets/SystemPrompt-BqzM76PV.js +1 -0
- package/dist/assets/TagLibrary-DVKPjeRu.js +1 -0
- package/dist/assets/{TiptapBody-BwN4pNC0.js → TiptapBody-CUWdBAYf.js} +1 -1
- package/dist/assets/Toggle-Bos67yDJ.js +1 -0
- package/dist/assets/index-BfPVknuV.js +3066 -0
- package/dist/assets/{index-CVCCMw5o.css → index-LlWpj2VJ.css} +1 -1
- package/dist/assets/listSkills-QORduPIk.js +1 -0
- package/dist/assets/settingsSchema-DKuyaEK5.js +3 -0
- package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
- package/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
- package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
- package/src/main/__tests__/runLogRetention.test.cjs +430 -0
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
- package/src/main/config.cjs +31 -7
- package/src/main/health.cjs +32 -0
- package/src/main/heapSnapshot.cjs +122 -0
- package/src/main/historyAggregator.cjs +176 -36
- package/src/main/index.cjs +29 -0
- package/src/main/ipcSchemas.cjs +9 -0
- package/src/main/lib/runLogRetention.cjs +379 -0
- package/src/main/transcripts.cjs +29 -5
- package/src/preload/api.d.ts +16 -2
- package/src/preload/index.cjs +11 -2
- package/dist/assets/index-ZjTDk5b7.js +0 -3197
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* runLogRetention.cjs — computes (and, only when explicitly opted in,
|
|
5
|
+
* applies) a retention policy over `scheduled-plans/runs/`.
|
|
6
|
+
*
|
|
7
|
+
* That directory is a machine-level Session-Manager runtime artifact store
|
|
8
|
+
* (execution logs of the scheduler's own `claude -p` runs), not anything
|
|
9
|
+
* governed by the per-project single-writer law in opsOwnership.cjs — see
|
|
10
|
+
* queueStore.cjs's header comment, which draws the same line for
|
|
11
|
+
* scheduler-machine.json.
|
|
12
|
+
*
|
|
13
|
+
* Directory shape: one subdirectory per tick (`RUNS_DIR/<iso-ts>/`, minted by
|
|
14
|
+
* scheduler.cjs's pickRunDir), which commonly holds MANY different PRD
|
|
15
|
+
* slugs' artifacts side by side — `<slug>.log`, `<slug>.meta.json`, and an
|
|
16
|
+
* optional `root-cause-<slug>.md` (rcaReport.cjs) per slug. Retention is
|
|
17
|
+
* therefore computed per (runId, slug) ENTRY, not per directory: a directory
|
|
18
|
+
* is only "fully removable" once every entry it holds is independently
|
|
19
|
+
* eligible AND every file physically present in it is claimed by one of
|
|
20
|
+
* those entries. Definition-of-done reports
|
|
21
|
+
* (`definition-of-done-<batchKey>.md`, definitionOfDone.cjs) are keyed by a
|
|
22
|
+
* hash of a job batch, not a single slug, so they are deliberately left
|
|
23
|
+
* unclaimed by any entry — their presence in a directory blocks that
|
|
24
|
+
* directory from being fully removed, but never blocks removal of a claimed
|
|
25
|
+
* slug's own files.
|
|
26
|
+
*
|
|
27
|
+
* SAFETY MODEL (the point of this module — see the PRD that added it):
|
|
28
|
+
* - Nothing is ever deleted unless the caller passes settings with
|
|
29
|
+
* `schedulerRunLogRetention.enabled === true` AND a non-empty policy.
|
|
30
|
+
* Everywhere else (computeReport, and applyRetention with no opt-in)
|
|
31
|
+
* this module only READS the filesystem.
|
|
32
|
+
* - An entry belonging to a job that is currently live — status `pending`
|
|
33
|
+
* (queued), `running`, `needs_review`, or `investigating` (the exact
|
|
34
|
+
* literals scheduler.cjs uses; see LIVE_STATUSES) — is never eligible,
|
|
35
|
+
* regardless of age or count policy.
|
|
36
|
+
* - The most recent entry for any given slug is never eligible, even past
|
|
37
|
+
* an age cap — every PRD keeps at least its latest evidence.
|
|
38
|
+
* - Age and count policies are COMBINABLE and combine conservatively
|
|
39
|
+
* (AND): when both are set, an entry must clear both floors to be
|
|
40
|
+
* eligible. A policy is a safety floor, not a removal trigger — setting
|
|
41
|
+
* one dimension should never make the other one weaker.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const fs = require('node:fs');
|
|
45
|
+
const os = require('node:os');
|
|
46
|
+
const path = require('node:path');
|
|
47
|
+
|
|
48
|
+
const DEFAULT_RUNS_DIR = path.join(
|
|
49
|
+
os.homedir(),
|
|
50
|
+
'.claude', 'session-manager', 'scheduled-plans', 'runs'
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Any status that is not yet a terminal outcome. Mirrors the status literals
|
|
54
|
+
// used throughout scheduler.cjs (see e.g. its DOD_SLUG_RE-adjacent status
|
|
55
|
+
// checks) — 'completed' and 'failed' are the only terminal ones.
|
|
56
|
+
const LIVE_STATUSES = new Set(['pending', 'running', 'needs_review', 'investigating']);
|
|
57
|
+
|
|
58
|
+
const META_SUFFIX = '.meta.json';
|
|
59
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
function isLiveJob(job) {
|
|
62
|
+
return !!job && LIVE_STATUSES.has(job.status);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the `${slug}|${runId}` protection set from a scheduler job list
|
|
67
|
+
* (queueStore.readMergedSync().jobs, or any array with the same shape). Jobs
|
|
68
|
+
* with no runId yet (never started) have no run directory to protect.
|
|
69
|
+
*
|
|
70
|
+
* A `needs_review` job can lose its `runId` (an old queue-schema gap —
|
|
71
|
+
* scheduler.cjs's own `resolveRunId` backfill exists for the same reason)
|
|
72
|
+
* while a run directory for its slug still exists on disk. When `runsDir` is
|
|
73
|
+
* given, every run directory containing `<slug>.log` is protected for such a
|
|
74
|
+
* job — not just scheduler.cjs's single newest match, since this module has
|
|
75
|
+
* no way to know which one the job actually corresponds to and protecting
|
|
76
|
+
* too many is always safe here, never protecting too few.
|
|
77
|
+
*/
|
|
78
|
+
function liveKeysFromJobs(jobs, opts) {
|
|
79
|
+
const runsDir = opts && opts.runsDir;
|
|
80
|
+
const keys = new Set();
|
|
81
|
+
for (const job of jobs || []) {
|
|
82
|
+
if (!isLiveJob(job) || !job.slug) continue;
|
|
83
|
+
if (job.runId) {
|
|
84
|
+
keys.add(`${job.slug}|${job.runId}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!runsDir) continue;
|
|
88
|
+
let dirs;
|
|
89
|
+
try {
|
|
90
|
+
dirs = fs.readdirSync(runsDir);
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
for (const d of dirs) {
|
|
95
|
+
try {
|
|
96
|
+
if (fs.existsSync(path.join(runsDir, d, `${job.slug}.log`))) keys.add(`${job.slug}|${d}`);
|
|
97
|
+
} catch {
|
|
98
|
+
// skip
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return keys;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Scan RUNS_DIR into one entry per (runId, slug) pair found via its
|
|
107
|
+
* `<slug>.meta.json` file. Read-only; never throws on a missing runsDir.
|
|
108
|
+
*/
|
|
109
|
+
function scanRunEntries(runsDir) {
|
|
110
|
+
let dirEntries;
|
|
111
|
+
try {
|
|
112
|
+
dirEntries = fs.readdirSync(runsDir, { withFileTypes: true });
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (e && e.code === 'ENOENT') return [];
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const entries = [];
|
|
119
|
+
for (const de of dirEntries) {
|
|
120
|
+
if (!de.isDirectory()) continue;
|
|
121
|
+
const runId = de.name;
|
|
122
|
+
const dir = path.join(runsDir, runId);
|
|
123
|
+
let files;
|
|
124
|
+
try {
|
|
125
|
+
files = fs.readdirSync(dir);
|
|
126
|
+
} catch {
|
|
127
|
+
continue; // vanished between readdir calls
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const metaFiles = files.filter((f) => f.endsWith(META_SUFFIX));
|
|
131
|
+
for (const metaFile of metaFiles) {
|
|
132
|
+
const slug = metaFile.slice(0, -META_SUFFIX.length);
|
|
133
|
+
const relatedNames = files.filter(
|
|
134
|
+
(f) => f === metaFile || f === `${slug}.log` || f === `root-cause-${slug}.md`
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
let sizeBytes = 0;
|
|
138
|
+
let mtimeMs = null;
|
|
139
|
+
const filePaths = [];
|
|
140
|
+
for (const name of relatedNames) {
|
|
141
|
+
const fp = path.join(dir, name);
|
|
142
|
+
filePaths.push(fp);
|
|
143
|
+
try {
|
|
144
|
+
const st = fs.statSync(fp);
|
|
145
|
+
sizeBytes += st.size;
|
|
146
|
+
if (mtimeMs === null || st.mtimeMs > mtimeMs) mtimeMs = st.mtimeMs;
|
|
147
|
+
} catch {
|
|
148
|
+
// file vanished mid-scan — skip its contribution
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let recordedAtMs = null;
|
|
153
|
+
try {
|
|
154
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, metaFile), 'utf8'));
|
|
155
|
+
const candidate = meta.finishedAt ?? meta.startedAt;
|
|
156
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate)) recordedAtMs = candidate;
|
|
157
|
+
} catch {
|
|
158
|
+
// corrupt/unreadable meta.json — fall back to file mtime below
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
entries.push({
|
|
162
|
+
runId,
|
|
163
|
+
slug,
|
|
164
|
+
dir,
|
|
165
|
+
files: filePaths,
|
|
166
|
+
sizeBytes,
|
|
167
|
+
timeMs: recordedAtMs ?? mtimeMs ?? 0,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return entries;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Evaluate eligibility for each entry against a policy. Returns entries
|
|
176
|
+
* augmented with { ageDays, rank, isMostRecent, isLive, eligible }.
|
|
177
|
+
*
|
|
178
|
+
* @param {Array} entries Output of scanRunEntries().
|
|
179
|
+
* @param {{maxAgeDays?: number, keepPerSlug?: number}} policy
|
|
180
|
+
* @param {{now?: number, liveKeys?: Set<string>}} opts
|
|
181
|
+
*/
|
|
182
|
+
function computeEligibility(entries, policy, opts) {
|
|
183
|
+
const { maxAgeDays, keepPerSlug } = policy || {};
|
|
184
|
+
const now = (opts && opts.now) ?? Date.now();
|
|
185
|
+
const liveKeys = (opts && opts.liveKeys) ?? new Set();
|
|
186
|
+
const hasPolicy = maxAgeDays != null || keepPerSlug != null;
|
|
187
|
+
|
|
188
|
+
const bySlug = new Map();
|
|
189
|
+
for (const e of entries) {
|
|
190
|
+
if (!bySlug.has(e.slug)) bySlug.set(e.slug, []);
|
|
191
|
+
bySlug.get(e.slug).push(e);
|
|
192
|
+
}
|
|
193
|
+
const rankOf = new Map();
|
|
194
|
+
for (const list of bySlug.values()) {
|
|
195
|
+
list.sort((a, b) => b.timeMs - a.timeMs);
|
|
196
|
+
list.forEach((e, i) => rankOf.set(e, i));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return entries.map((e) => {
|
|
200
|
+
const rank = rankOf.get(e) ?? 0;
|
|
201
|
+
const isMostRecent = rank === 0;
|
|
202
|
+
const isLive = liveKeys.has(`${e.slug}|${e.runId}`);
|
|
203
|
+
const ageDays = (now - e.timeMs) / DAY_MS;
|
|
204
|
+
const ageOk = maxAgeDays == null || ageDays > maxAgeDays;
|
|
205
|
+
const countOk = keepPerSlug == null || rank >= keepPerSlug;
|
|
206
|
+
const eligible = hasPolicy && !isLive && !isMostRecent && ageOk && countOk;
|
|
207
|
+
return { ...e, ageDays, rank, isMostRecent, isLive, eligible };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read-only report: current usage against runsDir, plus what the given
|
|
213
|
+
* policy WOULD remove. Never writes or deletes anything.
|
|
214
|
+
*/
|
|
215
|
+
function computeReport(runsDir, policy, opts) {
|
|
216
|
+
const now = (opts && opts.now) ?? Date.now();
|
|
217
|
+
const liveKeys = (opts && opts.liveKeys) ?? new Set();
|
|
218
|
+
const entries = scanRunEntries(runsDir);
|
|
219
|
+
const evaluated = computeEligibility(entries, policy || {}, { now, liveKeys });
|
|
220
|
+
|
|
221
|
+
const totalBytes = entries.reduce((sum, e) => sum + e.sizeBytes, 0);
|
|
222
|
+
const dirSet = new Set(entries.map((e) => e.dir));
|
|
223
|
+
const oldestRunAt = entries.length ? Math.min(...entries.map((e) => e.timeMs)) : null;
|
|
224
|
+
|
|
225
|
+
const eligible = evaluated.filter((e) => e.eligible);
|
|
226
|
+
const eligibleBytes = eligible.reduce((sum, e) => sum + e.sizeBytes, 0);
|
|
227
|
+
|
|
228
|
+
// A directory is fully removable only when every file physically present
|
|
229
|
+
// in it belongs to an eligible entry — an unclaimed file (a DoD report, or
|
|
230
|
+
// a slug entry that isn't eligible) keeps the directory itself alive even
|
|
231
|
+
// though the eligible entries' own files can still be unlinked.
|
|
232
|
+
const removableDirs = [];
|
|
233
|
+
for (const dir of dirSet) {
|
|
234
|
+
let actualFiles;
|
|
235
|
+
try {
|
|
236
|
+
actualFiles = fs.readdirSync(dir);
|
|
237
|
+
} catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (actualFiles.length === 0) continue;
|
|
241
|
+
const claimedEligible = new Set();
|
|
242
|
+
for (const e of evaluated) {
|
|
243
|
+
if (e.dir !== dir || !e.eligible) continue;
|
|
244
|
+
for (const fp of e.files) claimedEligible.add(path.basename(fp));
|
|
245
|
+
}
|
|
246
|
+
if (actualFiles.every((f) => claimedEligible.has(f))) removableDirs.push(dir);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
generatedAt: now,
|
|
251
|
+
runsDir,
|
|
252
|
+
policy: policy || null,
|
|
253
|
+
usage: {
|
|
254
|
+
totalBytes,
|
|
255
|
+
dirCount: dirSet.size,
|
|
256
|
+
runCount: entries.length,
|
|
257
|
+
oldestRunAt,
|
|
258
|
+
},
|
|
259
|
+
eligible,
|
|
260
|
+
eligibleSummary: { count: eligible.length, bytes: eligibleBytes },
|
|
261
|
+
removableDirs,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* True only when settings carry an explicit, well-formed opt-in:
|
|
267
|
+
* `schedulerRunLogRetention.enabled === true` plus a non-empty policy.
|
|
268
|
+
*/
|
|
269
|
+
function isRetentionEnabled(settings) {
|
|
270
|
+
const cfg = settings && settings.schedulerRunLogRetention;
|
|
271
|
+
if (!cfg || cfg.enabled !== true) return false;
|
|
272
|
+
const policy = cfg.policy;
|
|
273
|
+
return !!policy && (policy.maxAgeDays != null || policy.keepPerSlug != null);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the live-job protection set for applyRetention. Prefers whatever
|
|
278
|
+
* the caller supplied (opts.liveKeys, or opts.jobs to derive it from); if
|
|
279
|
+
* neither is given AND deletion is actually about to happen, falls back to
|
|
280
|
+
* reading the real scheduler queue itself via queueStore.cjs (plain Node, no
|
|
281
|
+
* Electron deps — safe to require here) rather than silently treating no
|
|
282
|
+
* jobs as live. This is deliberately defense-in-depth: a future caller that
|
|
283
|
+
* forgets to thread live-job info through must not thereby lose live-job
|
|
284
|
+
* protection.
|
|
285
|
+
*/
|
|
286
|
+
function resolveLiveKeysForApply(runsDir, opts, enabled) {
|
|
287
|
+
if (opts && opts.liveKeys) return opts.liveKeys;
|
|
288
|
+
if (opts && opts.jobs) return liveKeysFromJobs(opts.jobs, { runsDir });
|
|
289
|
+
if (!enabled) return new Set(); // dry-run path never deletes; no live read needed
|
|
290
|
+
try {
|
|
291
|
+
const queueStore = require('./queueStore.cjs');
|
|
292
|
+
const state = queueStore.readMergedSync();
|
|
293
|
+
return liveKeysFromJobs(state.jobs || [], { runsDir });
|
|
294
|
+
} catch {
|
|
295
|
+
return new Set();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Compute the report, and — ONLY when isRetentionEnabled(settings) — delete
|
|
301
|
+
* the eligible files and rmdir any directory left fully empty. With no
|
|
302
|
+
* opt-in (the default), this is exactly computeReport(): read-only,
|
|
303
|
+
* `deleted: false`, nothing removed.
|
|
304
|
+
*/
|
|
305
|
+
function applyRetention(runsDir, settings, opts) {
|
|
306
|
+
const cfg = (settings && settings.schedulerRunLogRetention) || null;
|
|
307
|
+
const policy = (cfg && cfg.policy) || {};
|
|
308
|
+
const enabled = isRetentionEnabled(settings);
|
|
309
|
+
const liveKeys = resolveLiveKeysForApply(runsDir, opts, enabled);
|
|
310
|
+
const report = computeReport(runsDir, policy, { now: opts && opts.now, liveKeys });
|
|
311
|
+
|
|
312
|
+
if (!enabled) {
|
|
313
|
+
return {
|
|
314
|
+
deleted: false,
|
|
315
|
+
reason: 'dry-run: schedulerRunLogRetention.enabled is not true (or has no policy)',
|
|
316
|
+
report,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let removedFiles = 0;
|
|
321
|
+
let freedBytes = 0;
|
|
322
|
+
const errors = [];
|
|
323
|
+
|
|
324
|
+
for (const entry of report.eligible) {
|
|
325
|
+
for (const fp of entry.files) {
|
|
326
|
+
try {
|
|
327
|
+
const st = fs.statSync(fp);
|
|
328
|
+
fs.unlinkSync(fp);
|
|
329
|
+
removedFiles += 1;
|
|
330
|
+
freedBytes += st.size;
|
|
331
|
+
} catch (e) {
|
|
332
|
+
if (e && e.code !== 'ENOENT') errors.push({ path: fp, error: e.message });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
for (const dir of report.removableDirs) {
|
|
338
|
+
try {
|
|
339
|
+
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if (e && e.code !== 'ENOENT') errors.push({ path: dir, error: e.message });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { deleted: true, removedFiles, freedBytes, errors, report };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* One-shot production entry point: reads the REAL scheduler queue (jobs +
|
|
350
|
+
* machine config, via queueStore.readMergedSync) and calls applyRetention
|
|
351
|
+
* with those jobs passed EXPLICITLY as opts.jobs — never relying on
|
|
352
|
+
* applyRetention's own internal queueStore fallback (resolveLiveKeysForApply
|
|
353
|
+
* above), which exists only as a defensive net for callers who forget to
|
|
354
|
+
* thread live-job info through, not as this module's primary path. Deletion
|
|
355
|
+
* still only happens when the read config carries an explicit
|
|
356
|
+
* schedulerRunLogRetention.enabled === true (see isRetentionEnabled).
|
|
357
|
+
*
|
|
358
|
+
* Intended to be called once per app boot (see index.cjs's deferred 30s
|
|
359
|
+
* startup timer, alongside finalizeClosedDays) — never on its own timer.
|
|
360
|
+
*/
|
|
361
|
+
function runBootSweep(opts) {
|
|
362
|
+
const runsDir = (opts && opts.runsDir) || DEFAULT_RUNS_DIR;
|
|
363
|
+
const queueStore = require('./queueStore.cjs');
|
|
364
|
+
const state = queueStore.readMergedSync();
|
|
365
|
+
return applyRetention(runsDir, state.config || {}, { jobs: state.jobs || [] });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
module.exports = {
|
|
369
|
+
DEFAULT_RUNS_DIR,
|
|
370
|
+
LIVE_STATUSES,
|
|
371
|
+
isLiveJob,
|
|
372
|
+
liveKeysFromJobs,
|
|
373
|
+
scanRunEntries,
|
|
374
|
+
computeEligibility,
|
|
375
|
+
computeReport,
|
|
376
|
+
isRetentionEnabled,
|
|
377
|
+
applyRetention,
|
|
378
|
+
runBootSweep,
|
|
379
|
+
};
|
package/src/main/transcripts.cjs
CHANGED
|
@@ -126,8 +126,23 @@ async function readDelta(sub) {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Cap on the number of events carried in a single `transcript:event:<tabId>`
|
|
130
|
+
// IPC message. One flush can classify a large delta (e.g. a rotated/replayed
|
|
131
|
+
// transcript, or the 8 MB MAX_DELTA_BYTES window) into far more than a
|
|
132
|
+
// screenful of events — sending them all as one unbounded array risks a
|
|
133
|
+
// single oversized IPC payload. Above this count, doFlush sends multiple
|
|
134
|
+
// ordered batches instead of one giant one; each still lands as one store
|
|
135
|
+
// commit on the renderer side.
|
|
136
|
+
const MAX_EVENTS_PER_BATCH = 200;
|
|
137
|
+
|
|
129
138
|
async function doFlush(sub, { emit = true } = {}) {
|
|
130
139
|
const lines = await readDelta(sub);
|
|
140
|
+
let batch = [];
|
|
141
|
+
const flushBatch = () => {
|
|
142
|
+
if (batch.length === 0) return;
|
|
143
|
+
if (emit) sendIfAlive(window, `transcript:event:${sub.tabId}`, batch);
|
|
144
|
+
batch = [];
|
|
145
|
+
};
|
|
131
146
|
for (const line of lines) {
|
|
132
147
|
// Index every line — including ones that fail to parse — so line numbers
|
|
133
148
|
// and byte offsets stay correct for paged reads regardless of content.
|
|
@@ -142,13 +157,13 @@ async function doFlush(sub, { emit = true } = {}) {
|
|
|
142
157
|
const ref = { filePath: sub.filePath, byteOffset: line.byteOffset, byteLength: line.byteLength };
|
|
143
158
|
const events = classifyLine(obj, ref);
|
|
144
159
|
for (const ev of events) {
|
|
145
|
-
|
|
160
|
+
batch.push(ev);
|
|
146
161
|
// Mirror to OTEL — no-op when disabled. We emit on the initial drain too
|
|
147
162
|
// so backfilled transcripts show up in the trace store. One span per
|
|
148
|
-
// emitted event, not per line. doFlush only ever processes
|
|
149
|
-
// line once (readDelta never re-returns already-consumed
|
|
150
|
-
// this can't double-record — paged re-reads (readPage) go
|
|
151
|
-
// separate code path below that never touches OTEL.
|
|
163
|
+
// emitted event, not per line or per batch. doFlush only ever processes
|
|
164
|
+
// a given line once (readDelta never re-returns already-consumed
|
|
165
|
+
// bytes), so this can't double-record — paged re-reads (readPage) go
|
|
166
|
+
// through a separate code path below that never touches OTEL.
|
|
152
167
|
otel.recordTranscriptEvent({
|
|
153
168
|
tabId: sub.tabId,
|
|
154
169
|
tabCwd: sub.cwd,
|
|
@@ -156,8 +171,10 @@ async function doFlush(sub, { emit = true } = {}) {
|
|
|
156
171
|
data: ev.data,
|
|
157
172
|
ts: Date.now(),
|
|
158
173
|
});
|
|
174
|
+
if (batch.length >= MAX_EVENTS_PER_BATCH) flushBatch();
|
|
159
175
|
}
|
|
160
176
|
}
|
|
177
|
+
flushBatch();
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
/**
|
|
@@ -525,4 +542,11 @@ module.exports = {
|
|
|
525
542
|
// particular) — asserting a memory ceiling requires inspecting what's
|
|
526
543
|
// actually held, not just what a read API returns.
|
|
527
544
|
__getSubForTest: (tabId) => subs.get(tabId),
|
|
545
|
+
// Test-only: run a live (emit:true) flush directly against a subscription,
|
|
546
|
+
// without going through the chokidar watcher — lets batching/IPC-shape
|
|
547
|
+
// tests stay deterministic instead of racing a filesystem watch event.
|
|
548
|
+
__doFlushForTest: (sub, opts) => doFlush(sub, opts),
|
|
549
|
+
// Batch size cap for a single transcript:event IPC message — exported so
|
|
550
|
+
// tests can size fixtures against it without a magic-number duplicate.
|
|
551
|
+
MAX_EVENTS_PER_BATCH,
|
|
528
552
|
};
|
package/src/preload/api.d.ts
CHANGED
|
@@ -43,6 +43,13 @@ export interface ReadTextResult {
|
|
|
43
43
|
text: string;
|
|
44
44
|
mtimeMs: number;
|
|
45
45
|
error: string | null;
|
|
46
|
+
/** True when maxBytes was set and the file is larger than the read prefix. */
|
|
47
|
+
truncated: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ReadTextOptions {
|
|
51
|
+
/** Read at most this many bytes from the start of the file instead of the whole file. */
|
|
52
|
+
maxBytes?: number;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
/** One resolved node in a CLAUDE.md-like file's `@path` import chain. */
|
|
@@ -1245,7 +1252,10 @@ export interface SessionManagerAPI {
|
|
|
1245
1252
|
* session with no transcript file yet (or one over the main-process size
|
|
1246
1253
|
* cap) maps to null. */
|
|
1247
1254
|
usageFor: (cwd: string, sessionIds: string[]) => Promise<Record<string, { inputTokens: number; outputTokens: number } | null>>;
|
|
1248
|
-
|
|
1255
|
+
/** Fires once per main-process flush with the ORDERED batch of events that
|
|
1256
|
+
* flush produced (never one call per event) — see transcripts.cjs's
|
|
1257
|
+
* doFlush / MAX_EVENTS_PER_BATCH. */
|
|
1258
|
+
onEvent: (tabId: string, handler: (events: TranscriptEvent[]) => void) => () => void;
|
|
1249
1259
|
};
|
|
1250
1260
|
sessions: {
|
|
1251
1261
|
load: () => Promise<LoadedSessions>;
|
|
@@ -1281,7 +1291,7 @@ export interface SessionManagerAPI {
|
|
|
1281
1291
|
};
|
|
1282
1292
|
config: {
|
|
1283
1293
|
readJson: (path: string) => Promise<ReadJsonResult>;
|
|
1284
|
-
readText: (path: string) => Promise<ReadTextResult>;
|
|
1294
|
+
readText: (path: string, opts?: ReadTextOptions) => Promise<ReadTextResult>;
|
|
1285
1295
|
/** `writer` declares the owning surface for the single-writer law — required
|
|
1286
1296
|
* when `path` is inside a project's session-manager-operations/ root. */
|
|
1287
1297
|
writeJson: (path: string, data: unknown, writer?: OpsWriter) => Promise<WriteResult>;
|
|
@@ -1326,6 +1336,10 @@ export interface SessionManagerAPI {
|
|
|
1326
1336
|
status: () => Promise<OtelStatus>;
|
|
1327
1337
|
configPath: () => Promise<string>;
|
|
1328
1338
|
};
|
|
1339
|
+
diagnostics: {
|
|
1340
|
+
/** Rejects unless the main process has SM_HEAP_SNAPSHOT=1 set. */
|
|
1341
|
+
takeHeapSnapshot: () => Promise<{ filePath: string; bytes: number | null; ms: number }>;
|
|
1342
|
+
};
|
|
1329
1343
|
files: {
|
|
1330
1344
|
list: (path: string, showHidden?: boolean) => Promise<FilesListResult>;
|
|
1331
1345
|
read: (path: string) => Promise<FilesReadResult>;
|
package/src/preload/index.cjs
CHANGED
|
@@ -68,9 +68,13 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
68
68
|
ipcRenderer.invoke('transcript:path', { cwd, sessionUuid }),
|
|
69
69
|
usageFor: (cwd, sessionIds) =>
|
|
70
70
|
ipcRenderer.invoke('transcript:usageFor', { cwd, sessionIds }),
|
|
71
|
+
// Main sends one batch (array) per flush — see transcripts.cjs's doFlush —
|
|
72
|
+
// so the handler is array-shaped, not per-event, letting subscribers
|
|
73
|
+
// (live.ts, chat.ts) commit their store once per batch instead of once
|
|
74
|
+
// per event.
|
|
71
75
|
onEvent: (tabId, handler) => {
|
|
72
76
|
const channel = `transcript:event:${tabId}`;
|
|
73
|
-
const listener = (_e,
|
|
77
|
+
const listener = (_e, events) => handler(events);
|
|
74
78
|
ipcRenderer.on(channel, listener);
|
|
75
79
|
return () => ipcRenderer.removeListener(channel, listener);
|
|
76
80
|
},
|
|
@@ -112,7 +116,7 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
112
116
|
},
|
|
113
117
|
config: {
|
|
114
118
|
readJson: (path) => ipcRenderer.invoke('config:read-json', { path }),
|
|
115
|
-
readText: (path) => ipcRenderer.invoke('config:read-text', { path }),
|
|
119
|
+
readText: (path, opts) => ipcRenderer.invoke('config:read-text', { path, maxBytes: opts?.maxBytes }),
|
|
116
120
|
writeJson: (path, data, writer) => ipcRenderer.invoke('config:write-json', { path, data, writer }),
|
|
117
121
|
writeText: (path, text, writer) => ipcRenderer.invoke('config:write-text', { path, text, writer }),
|
|
118
122
|
listDir: (path, opts) => ipcRenderer.invoke('config:list-dir', { path, opts }),
|
|
@@ -173,6 +177,11 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
173
177
|
status: () => ipcRenderer.invoke('otel:status'),
|
|
174
178
|
configPath: () => ipcRenderer.invoke('otel:config-path'),
|
|
175
179
|
},
|
|
180
|
+
// Diagnostic only — no handler is registered unless the main process was
|
|
181
|
+
// launched with SM_HEAP_SNAPSHOT=1, so this rejects by default.
|
|
182
|
+
diagnostics: {
|
|
183
|
+
takeHeapSnapshot: () => ipcRenderer.invoke('diagnostics:heap-snapshot'),
|
|
184
|
+
},
|
|
176
185
|
history: {
|
|
177
186
|
aggregate: (req) => ipcRenderer.invoke('history:aggregate', req),
|
|
178
187
|
scanProjects: () => ipcRenderer.invoke('history:scan-projects'),
|