claude-code-session-manager 0.39.3 → 0.39.4
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/{TiptapBody-B90xy18x.js → TiptapBody-BSRtwtPP.js} +1 -1
- package/dist/assets/{index-DVlD8N1X.css → index-DyLm7GM_.css} +1 -1
- package/dist/assets/{index-BUuhV6vT.js → index-XGsBHqYu.js} +553 -553
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +19 -13
- package/scripts/lib/watchdogHelpers.cjs +57 -21
- package/src/main/__tests__/health-prd-migration.test.cjs +37 -0
- package/src/main/__tests__/queueHistory.test.cjs +9 -4
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +12 -0
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +92 -0
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +1 -1
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/health.cjs +46 -10
- package/src/main/index.cjs +6 -3
- package/src/main/lib/__tests__/sessionSlots.test.cjs +55 -0
- package/src/main/lib/epicMint.cjs +144 -0
- package/src/main/lib/prdLocations.cjs +54 -1
- package/src/main/lib/prdMigration.cjs +47 -1
- package/src/main/lib/queueHistory.cjs +85 -37
- package/src/main/lib/queueStore.cjs +299 -0
- package/src/main/lib/sessionSlots.cjs +85 -0
- package/src/main/queueOps.cjs +20 -1
- package/src/main/scheduler.cjs +241 -49
- package/src/preload/api.d.ts +2 -0
- package/src/preload/index.cjs +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epicMint.cjs — auto-mint an Epic (PromptSession) for a PRD dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Domain rule (CLAUDE.md "Domain model"): every PRD belongs to an Epic; the
|
|
5
|
+
* hierarchy TAB → operations-root → EPIC → PRD is total. Dispatch paths that
|
|
6
|
+
* have no Epic in hand (feedback sweep, ad-hoc /develop, admin/MCP create)
|
|
7
|
+
* call ensureEpic() to create-or-join one instead of writing epicless PRDs.
|
|
8
|
+
*
|
|
9
|
+
* The Epic registry is the renderer's own store: the per-cwd
|
|
10
|
+
* `session-manager-operations/prompt-sessions/active-index.json`
|
|
11
|
+
* ({ sessions, events } — shape defined in state/promptSessions.ts). Writing
|
|
12
|
+
* the same file the renderer reads keeps a single source of truth: an
|
|
13
|
+
* auto-minted Epic shows up in the Epics nav like a hand-created one.
|
|
14
|
+
*
|
|
15
|
+
* Plain Node module (no Electron deps) so the external watchdog scripts can
|
|
16
|
+
* require it. Atomic writes: tmp + rename, same pattern as config.cjs.
|
|
17
|
+
*/
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const crypto = require('node:crypto');
|
|
23
|
+
const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
|
|
24
|
+
|
|
25
|
+
function activeIndexPath(cwd) {
|
|
26
|
+
return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readActiveIndex(cwd) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(fs.readFileSync(activeIndexPath(cwd), 'utf8'));
|
|
32
|
+
return {
|
|
33
|
+
sessions: parsed && typeof parsed.sessions === 'object' && parsed.sessions ? parsed.sessions : {},
|
|
34
|
+
events: parsed && typeof parsed.events === 'object' && parsed.events ? parsed.events : {},
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
return { sessions: {}, events: {} };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeActiveIndex(cwd, index) {
|
|
42
|
+
const file = activeIndexPath(cwd);
|
|
43
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
44
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
45
|
+
fs.writeFileSync(tmp, JSON.stringify(index, null, 2));
|
|
46
|
+
fs.renameSync(tmp, file);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function slugify(text) {
|
|
50
|
+
return String(text || 'epic')
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
53
|
+
.replace(/^-+|-+$/g, '')
|
|
54
|
+
.slice(0, 48) || 'epic';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ensureEpic(cwd, { goalText, tag?, reuseByGoal? }) → { epicId, prdDir, created }
|
|
59
|
+
*
|
|
60
|
+
* Mints a new Epic — or, with `reuseByGoal`, joins the existing ACTIVE Epic
|
|
61
|
+
* whose goalText matches (used by the recurring feedback sweep so successive
|
|
62
|
+
* sweeps chain into one Epic instead of minting one per tick).
|
|
63
|
+
*
|
|
64
|
+
* The Epic's id doubles as its directory name under scheduler/epics/, so the
|
|
65
|
+
* PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
|
|
66
|
+
*/
|
|
67
|
+
function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId } = {}) {
|
|
68
|
+
if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
|
|
69
|
+
const index = readActiveIndex(cwd);
|
|
70
|
+
|
|
71
|
+
// A dispatch that already knows its Epic (sourcePromptId frontmatter from
|
|
72
|
+
// an Epic-conversation dispatch) joins it rather than minting a sibling.
|
|
73
|
+
if (explicitEpicId && index.sessions[explicitEpicId]) {
|
|
74
|
+
const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
|
|
75
|
+
fs.mkdirSync(prdDir, { recursive: true });
|
|
76
|
+
return { epicId: explicitEpicId, prdDir, created: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (reuseByGoal) {
|
|
80
|
+
for (const s of Object.values(index.sessions)) {
|
|
81
|
+
if (s && s.status === 'active' && s.goalText === goalText) {
|
|
82
|
+
const prdDir = resolveEpicPrdWriteDir(cwd, s.id);
|
|
83
|
+
fs.mkdirSync(prdDir, { recursive: true });
|
|
84
|
+
return { epicId: s.id, prdDir, created: false };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
const session = {
|
|
92
|
+
id: epicId,
|
|
93
|
+
cwd,
|
|
94
|
+
goalText: String(goalText || ''),
|
|
95
|
+
// Independently minted, never shared with a SessionTab — same invariant
|
|
96
|
+
// as renderer-created PromptSessions (state/promptSessions.ts).
|
|
97
|
+
claudeSessionId: crypto.randomUUID(),
|
|
98
|
+
status: 'active',
|
|
99
|
+
createdAt: now,
|
|
100
|
+
completedAt: null,
|
|
101
|
+
...(tag ? { tag } : {}),
|
|
102
|
+
};
|
|
103
|
+
const firstEvent = {
|
|
104
|
+
id: crypto.randomUUID(),
|
|
105
|
+
promptSessionId: epicId,
|
|
106
|
+
kind: 'prompt',
|
|
107
|
+
causedByEventId: null,
|
|
108
|
+
at: now,
|
|
109
|
+
text: String(goalText || ''),
|
|
110
|
+
};
|
|
111
|
+
index.sessions[epicId] = session;
|
|
112
|
+
index.events[epicId] = [firstEvent];
|
|
113
|
+
writeActiveIndex(cwd, index);
|
|
114
|
+
|
|
115
|
+
const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
116
|
+
fs.mkdirSync(prdDir, { recursive: true });
|
|
117
|
+
return { epicId, prdDir, created: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* appendPrdCreatedEvent(cwd, epicId, prdSlug) — record a PRD dispatch on the
|
|
122
|
+
* Epic's event chain, FK-linked to the current tail (chain, not tree — the
|
|
123
|
+
* referential-integrity requirement from promptSessions.ts).
|
|
124
|
+
*/
|
|
125
|
+
function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
|
|
126
|
+
const index = readActiveIndex(cwd);
|
|
127
|
+
if (!index.sessions[epicId]) return false;
|
|
128
|
+
const chain = Array.isArray(index.events[epicId]) ? index.events[epicId] : [];
|
|
129
|
+
const tail = chain.length ? chain[chain.length - 1] : null;
|
|
130
|
+
chain.push({
|
|
131
|
+
id: crypto.randomUUID(),
|
|
132
|
+
promptSessionId: epicId,
|
|
133
|
+
kind: 'prd_created',
|
|
134
|
+
causedByEventId: tail ? tail.id : null,
|
|
135
|
+
at: new Date().toISOString(),
|
|
136
|
+
prdSlug,
|
|
137
|
+
...(text ? { text } : {}),
|
|
138
|
+
});
|
|
139
|
+
index.events[epicId] = chain;
|
|
140
|
+
writeActiveIndex(cwd, index);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { ensureEpic, appendPrdCreatedEvent, activeIndexPath };
|
|
@@ -20,6 +20,47 @@ const path = require('node:path');
|
|
|
20
20
|
const { activeProjectCwds, allProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
|
|
21
21
|
|
|
22
22
|
const PRD_SUBPATH = ['session-manager-operations', 'scheduler', 'prds'];
|
|
23
|
+
const EPICS_SUBPATH = ['session-manager-operations', 'scheduler', 'epics'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* resolveEpicsRoot(cwd) → `<cwd>/session-manager-operations/scheduler/epics`
|
|
27
|
+
* The per-project root under which every Epic keeps its own prds/ dir.
|
|
28
|
+
*/
|
|
29
|
+
function resolveEpicsRoot(cwd) {
|
|
30
|
+
if (!cwd || typeof cwd !== 'string') {
|
|
31
|
+
throw new Error('resolveEpicsRoot: cwd is required');
|
|
32
|
+
}
|
|
33
|
+
return path.join(cwd, ...EPICS_SUBPATH);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* resolveEpicPrdWriteDir(cwd, epicId) →
|
|
38
|
+
* `<cwd>/session-manager-operations/scheduler/epics/<epicId>/prds`
|
|
39
|
+
* The write destination for every NEW PRD. The flat legacy dir
|
|
40
|
+
* (resolvePrdWriteDir) remains read-only during the transition — new PRDs
|
|
41
|
+
* always belong to an Epic (auto-minted when the dispatch has none).
|
|
42
|
+
*/
|
|
43
|
+
function resolveEpicPrdWriteDir(cwd, epicId) {
|
|
44
|
+
if (!epicId || typeof epicId !== 'string' || epicId.includes('/') || epicId.includes('..')) {
|
|
45
|
+
throw new Error('resolveEpicPrdWriteDir: epicId must be a plain directory name');
|
|
46
|
+
}
|
|
47
|
+
return path.join(resolveEpicsRoot(cwd), epicId, 'prds');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Enumerate every existing `epics/<id>/prds` dir under one project cwd. */
|
|
51
|
+
function listEpicPrdDirs(cwd) {
|
|
52
|
+
let root;
|
|
53
|
+
try { root = resolveEpicsRoot(cwd); } catch { return []; }
|
|
54
|
+
let entries;
|
|
55
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
|
|
56
|
+
const dirs = [];
|
|
57
|
+
for (const ent of entries) {
|
|
58
|
+
if (!ent.isDirectory()) continue;
|
|
59
|
+
const prdDir = path.join(root, ent.name, 'prds');
|
|
60
|
+
if (fs.existsSync(prdDir)) dirs.push(prdDir);
|
|
61
|
+
}
|
|
62
|
+
return dirs;
|
|
63
|
+
}
|
|
23
64
|
|
|
24
65
|
/**
|
|
25
66
|
* resolvePrdWriteDir(cwd) → `<cwd>/session-manager-operations/scheduler/prds`
|
|
@@ -68,6 +109,9 @@ function resolvePrdsDirs(maxAgeMin, opts) {
|
|
|
68
109
|
let dir;
|
|
69
110
|
try { dir = resolvePrdWriteDir(cwd); } catch { continue; }
|
|
70
111
|
if (fs.existsSync(dir)) add(dir);
|
|
112
|
+
// Epic-scoped dirs (the write layout for all new PRDs) are first-class
|
|
113
|
+
// scan sources alongside the legacy flat dir.
|
|
114
|
+
for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
|
|
71
115
|
}
|
|
72
116
|
|
|
73
117
|
// Active projects are added unconditionally: a brand-new project has no
|
|
@@ -75,9 +119,18 @@ function resolvePrdsDirs(maxAgeMin, opts) {
|
|
|
75
119
|
// find it. Scans over a non-existent dir are a harmless ENOENT no-op.
|
|
76
120
|
for (const cwd of activeProjectCwds(maxAgeMin, opts)) {
|
|
77
121
|
try { add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
|
|
122
|
+
for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
|
|
78
123
|
}
|
|
79
124
|
|
|
80
125
|
return dirs;
|
|
81
126
|
}
|
|
82
127
|
|
|
83
|
-
module.exports = {
|
|
128
|
+
module.exports = {
|
|
129
|
+
resolvePrdWriteDir,
|
|
130
|
+
resolvePrdsDirs,
|
|
131
|
+
resolveEpicsRoot,
|
|
132
|
+
resolveEpicPrdWriteDir,
|
|
133
|
+
listEpicPrdDirs,
|
|
134
|
+
PRD_SUBPATH,
|
|
135
|
+
EPICS_SUBPATH,
|
|
136
|
+
};
|
|
@@ -82,4 +82,50 @@ async function migratePrds(legacyPrdsDir) {
|
|
|
82
82
|
return { moved, skipped, unresolved };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
/**
|
|
86
|
+
* consolidateFlatPrds(cwd) — one-time, idempotent retirement of the legacy
|
|
87
|
+
* FLAT per-project PRD dir (`scheduler/prds/`) now that new PRDs are always
|
|
88
|
+
* epic-scoped (`scheduler/epics/<id>/prds/`, CLAUDE.md domain model).
|
|
89
|
+
*
|
|
90
|
+
* Every `.md` in the flat dir moves to the sibling `prds-archived/` for later
|
|
91
|
+
* special processing (per 2026-07-31 decision: "new PRDs only; consolidate
|
|
92
|
+
* existing into prds-archived"). Queue rows pointing at moved files are
|
|
93
|
+
* reaped by the existing archived-twin retirement (11ad3d8). Dotfiles
|
|
94
|
+
* (.max-allocated-group, .reserved-*) stay — NN allocation still uses them.
|
|
95
|
+
* Name collisions in prds-archived/ get a `-legacy-<n>` suffix, never an
|
|
96
|
+
* overwrite. Idempotent: an emptied flat dir is a no-op readdir.
|
|
97
|
+
*
|
|
98
|
+
* Returns { moved, failed: [{ file, reason }] }.
|
|
99
|
+
*/
|
|
100
|
+
async function consolidateFlatPrds(cwd) {
|
|
101
|
+
const flatDir = resolvePrdWriteDir(cwd);
|
|
102
|
+
const archiveDir = path.join(path.dirname(flatDir), 'prds-archived');
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = await fsp.readdir(flatDir);
|
|
106
|
+
} catch {
|
|
107
|
+
return { moved: 0, failed: [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let moved = 0;
|
|
111
|
+
const failed = [];
|
|
112
|
+
for (const name of entries) {
|
|
113
|
+
if (!name.endsWith('.md') || name.startsWith('.')) continue;
|
|
114
|
+
const src = path.join(flatDir, name);
|
|
115
|
+
try {
|
|
116
|
+
await fsp.mkdir(archiveDir, { recursive: true });
|
|
117
|
+
let dst = path.join(archiveDir, name);
|
|
118
|
+
for (let n = 1; fs.existsSync(dst); n++) {
|
|
119
|
+
dst = path.join(archiveDir, `${name.slice(0, -3)}-legacy-${n}.md`);
|
|
120
|
+
}
|
|
121
|
+
await fsp.rename(src, dst);
|
|
122
|
+
moved++;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (e?.code === 'ENOENT') continue; // raced with a concurrent mover
|
|
125
|
+
failed.push({ file: name, reason: e?.message ?? 'move failed' });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { moved, failed };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { migratePrds, consolidateFlatPrds };
|
|
@@ -14,9 +14,32 @@ const os = require('os');
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const fsp = require('fs').promises;
|
|
16
16
|
const { HISTORY_RETENTION_MS } = require('./schedulerConfig.cjs');
|
|
17
|
+
const { projectHistoryPath, stateCwds } = require('./queueStore.cjs');
|
|
17
18
|
|
|
19
|
+
// Legacy global sidecar — READ-ONLY since 2026-07-31 (federated per-project
|
|
20
|
+
// history under <cwd>/session-manager-operations/scheduler/state/history.jsonl,
|
|
21
|
+
// resolved via queueStore.cjs). Old rows stay readable from here; new appends
|
|
22
|
+
// go to the owning project's shard (entries without a cwd still land here so
|
|
23
|
+
// nothing is ever dropped).
|
|
18
24
|
const HISTORY_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'history.jsonl');
|
|
19
25
|
|
|
26
|
+
/** Every history file currently in play: each project's shard + the legacy global. */
|
|
27
|
+
function historyPaths() {
|
|
28
|
+
const paths = [];
|
|
29
|
+
for (const cwd of stateCwds()) {
|
|
30
|
+
try { paths.push(projectHistoryPath(cwd)); } catch { /* unusable cwd */ }
|
|
31
|
+
}
|
|
32
|
+
paths.push(HISTORY_PATH);
|
|
33
|
+
return paths;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function historyPathFor(entry) {
|
|
37
|
+
if (entry && entry.cwd) {
|
|
38
|
+
try { return projectHistoryPath(entry.cwd); } catch { /* fall through */ }
|
|
39
|
+
}
|
|
40
|
+
return HISTORY_PATH;
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
function isFixPlanSlug(slug) {
|
|
21
44
|
return /^\d+-fix-/.test(slug);
|
|
22
45
|
}
|
|
@@ -76,32 +99,45 @@ async function appendHistory(entries) {
|
|
|
76
99
|
const list = Array.isArray(entries) ? entries.filter(Boolean) : [];
|
|
77
100
|
if (list.length === 0) return { appended: 0 };
|
|
78
101
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
// Federated: each entry appends to its own project's shard (fallback: the
|
|
103
|
+
// legacy global file for cwd-less rows). Dedupe stays per target file.
|
|
104
|
+
const byPath = new Map();
|
|
105
|
+
for (const e of list) {
|
|
106
|
+
const target = historyPathFor(e);
|
|
107
|
+
if (!byPath.has(target)) byPath.set(target, []);
|
|
108
|
+
byPath.get(target).push(e);
|
|
84
109
|
}
|
|
85
110
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
111
|
+
let appended = 0;
|
|
112
|
+
for (const [target, group] of byPath) {
|
|
113
|
+
let existingText = '';
|
|
114
|
+
try {
|
|
115
|
+
existingText = await fsp.readFile(target, 'utf8');
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (e.code !== 'ENOENT') throw e;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const existingKeys = new Set();
|
|
121
|
+
if (existingText) {
|
|
122
|
+
for (const line of existingText.split('\n')) {
|
|
123
|
+
if (!line.trim()) continue;
|
|
124
|
+
try {
|
|
125
|
+
existingKeys.add(jobKey(JSON.parse(line)));
|
|
126
|
+
} catch {
|
|
127
|
+
// corrupt/partial line — ignore for dedupe purposes
|
|
128
|
+
}
|
|
94
129
|
}
|
|
95
130
|
}
|
|
96
|
-
}
|
|
97
131
|
|
|
98
|
-
|
|
99
|
-
|
|
132
|
+
const fresh = group.filter((e) => !existingKeys.has(jobKey(e)));
|
|
133
|
+
if (fresh.length === 0) continue;
|
|
100
134
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
135
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
136
|
+
const text = fresh.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
137
|
+
await fsp.appendFile(target, text, 'utf8');
|
|
138
|
+
appended += fresh.length;
|
|
139
|
+
}
|
|
140
|
+
return { appended };
|
|
105
141
|
}
|
|
106
142
|
|
|
107
143
|
/**
|
|
@@ -112,10 +148,22 @@ async function appendHistory(entries) {
|
|
|
112
148
|
*/
|
|
113
149
|
async function readHistory({ limit } = {}) {
|
|
114
150
|
const cap = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 50));
|
|
151
|
+
// Federated: newest `cap` from EACH file, merged newest-first by finishedAt
|
|
152
|
+
// (fallback startedAt), then capped. Per-file tail reads keep this cheap.
|
|
153
|
+
const chunks = await Promise.all(historyPaths().map((p2) => readHistoryFile(p2, cap)));
|
|
154
|
+
const ts = (e) => Date.parse(e?.finishedAt ?? e?.startedAt ?? '') || 0;
|
|
155
|
+
// Each chunk is already newest-first; keep that order as the tiebreak so
|
|
156
|
+
// timestamp-less rows don't get shuffled by the cross-file sort.
|
|
157
|
+
const tagged = chunks.flatMap((chunk) => chunk.map((e, idx) => ({ e, ts: ts(e), idx })));
|
|
158
|
+
tagged.sort((a, b) => (b.ts - a.ts) || (a.idx - b.idx));
|
|
159
|
+
return tagged.slice(0, cap).map((t) => t.e);
|
|
160
|
+
}
|
|
115
161
|
|
|
162
|
+
/** Tail-read one history file: newest `cap` parsed rows, newest-first. */
|
|
163
|
+
async function readHistoryFile(target, cap) {
|
|
116
164
|
let fh;
|
|
117
165
|
try {
|
|
118
|
-
fh = await fsp.open(
|
|
166
|
+
fh = await fsp.open(target, 'r');
|
|
119
167
|
} catch (e) {
|
|
120
168
|
if (e.code === 'ENOENT') return [];
|
|
121
169
|
throw e;
|
|
@@ -162,7 +210,7 @@ async function readHistory({ limit } = {}) {
|
|
|
162
210
|
// mtime-gated cache, same convention as scheduler/prdParser.cjs's dirCache —
|
|
163
211
|
// repeated reconcile() calls between appends hit the cache instead of
|
|
164
212
|
// re-reading/re-parsing a JSONL file that only grows over time.
|
|
165
|
-
let
|
|
213
|
+
let historyCacheKey = null;
|
|
166
214
|
let historyCacheMap = null;
|
|
167
215
|
|
|
168
216
|
/**
|
|
@@ -176,22 +224,22 @@ let historyCacheMap = null;
|
|
|
176
224
|
* from "this is a genuinely brand-new PRD nobody has ever queued."
|
|
177
225
|
*/
|
|
178
226
|
async function historyTerminalBySlug() {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (mtime === historyCacheMtime && historyCacheMap) return historyCacheMap;
|
|
227
|
+
const paths = historyPaths();
|
|
228
|
+
const mtimes = await Promise.all(paths.map(async (p2) => {
|
|
229
|
+
try { return (await fsp.stat(p2)).mtimeMs; } catch { return -1; }
|
|
230
|
+
}));
|
|
231
|
+
const cacheKey = paths.map((p2, i) => `${p2}:${mtimes[i]}`).join('|');
|
|
232
|
+
if (cacheKey === historyCacheKey && historyCacheMap) return historyCacheMap;
|
|
186
233
|
|
|
187
234
|
const map = new Map();
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
235
|
+
for (const p2 of paths) {
|
|
236
|
+
let text = '';
|
|
237
|
+
try {
|
|
238
|
+
text = await fsp.readFile(p2, 'utf8');
|
|
239
|
+
} catch (e) {
|
|
240
|
+
if (e.code !== 'ENOENT') throw e;
|
|
241
|
+
}
|
|
242
|
+
if (!text) continue;
|
|
195
243
|
for (const line of text.split('\n')) {
|
|
196
244
|
if (!line.trim()) continue;
|
|
197
245
|
try {
|
|
@@ -202,7 +250,7 @@ async function historyTerminalBySlug() {
|
|
|
202
250
|
}
|
|
203
251
|
}
|
|
204
252
|
}
|
|
205
|
-
|
|
253
|
+
historyCacheKey = cacheKey;
|
|
206
254
|
historyCacheMap = map;
|
|
207
255
|
return map;
|
|
208
256
|
}
|