claude-code-session-manager 0.65.0 → 0.67.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-CiqimwWl.js +3 -0
- package/dist/assets/{History-DB-9-zwc.js → History-DbzjII2Z.js} +2 -2
- package/dist/assets/{Hooks-DnxqMRrR.js → Hooks-C0PTplI_.js} +3 -3
- package/dist/assets/{HostBilko-Cw6JocV7.js → HostBilko-vGc1FYLD.js} +1 -1
- package/dist/assets/{Library-BYSB0dmY.js → Library-4tU4rg2h.js} +1 -1
- package/dist/assets/{ListDetail-BfKqnL0r.js → ListDetail-BGIvkCwZ.js} +1 -1
- package/dist/assets/{MarkdownEditor-Cs-l5JOv.js → MarkdownEditor-ChFdpCam.js} +1 -1
- package/dist/assets/{McpServers-bBqj3hGg.js → McpServers-DsRWWpU3.js} +2 -2
- package/dist/assets/{Memory-DzPGXT5J.js → Memory-DmxoxxxY.js} +6 -6
- package/dist/assets/{Panel-BX6UZ-W0.js → Panel-KC-rv3jS.js} +1 -1
- package/dist/assets/{Permissions-Db7S4T3Z.js → Permissions-DrDjCRFl.js} +3 -3
- package/dist/assets/{Plugins-CIuey9R3.js → Plugins-KexSQwk0.js} +2 -2
- package/dist/assets/{ProvenanceBadge-FPWNWc_-.js → ProvenanceBadge-Rg-jL94y.js} +1 -1
- package/dist/assets/SaveBar-BbEo8U2e.js +1 -0
- package/dist/assets/Scheduler-C4Ti9TCc.js +14 -0
- package/dist/assets/ScopeSwitcher-Di9BmYze.js +1 -0
- package/dist/assets/Settings-CxyhPuYQ.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CprLcOCe.js → SkillReferenceGraph-0q26RHrn.js} +1 -1
- package/dist/assets/Skills-BXkVyCo5.js +3 -0
- package/dist/assets/SystemPrompt-Bwe3NTbi.js +1 -0
- package/dist/assets/{TagLibrary-q66s4N3i.js → TagLibrary-CnwEf6EI.js} +1 -1
- package/dist/assets/{TiptapBody-I22aArc2.js → TiptapBody-faCtLj9L.js} +1 -1
- package/dist/assets/{Toggle-D9sapSAv.js → Toggle-Bw7G_-RR.js} +1 -1
- package/dist/assets/{index-BRwaw_1W.js → index-B6dJ2CsU.js} +666 -665
- package/dist/assets/{index-LlWpj2VJ.css → index-CEnMgeQU.css} +1 -1
- package/dist/assets/{settingsSchema-CTc4qelV.js → settingsSchema-B5C9hZoS.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +73 -15
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +10 -0
- package/src/main/__tests__/agentLibrary.test.cjs +40 -0
- package/src/main/__tests__/develop-skill-failure-modes.test.cjs +70 -0
- package/src/main/__tests__/flatPrdTickSweep.test.cjs +110 -0
- package/src/main/__tests__/health-per-project-stall.test.cjs +82 -0
- package/src/main/__tests__/prdAdminRouteParity.test.cjs +68 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +311 -0
- package/src/main/__tests__/prdCreate.test.cjs +7 -2
- package/src/main/__tests__/prdMigration.test.cjs +17 -0
- package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +91 -0
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +109 -0
- package/src/main/__tests__/scheduleJobSchema.test.cjs +127 -0
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +65 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +152 -0
- package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +59 -0
- package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +203 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +196 -0
- package/src/main/__tests__/scheduler-stall-per-project.test.cjs +108 -0
- package/src/main/agentLibrary.cjs +40 -2
- package/src/main/health.cjs +97 -2
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +60 -0
- package/src/main/lib/localAdminHttp.cjs +10 -3
- package/src/main/lib/prdAdminRoutes.cjs +175 -0
- package/src/main/lib/prdCreate.cjs +37 -2
- package/src/main/lib/prdFrontmatter.cjs +179 -1
- package/src/main/lib/prdMigration.cjs +82 -5
- package/src/main/lib/queueStore.cjs +41 -7
- package/src/main/lib/scheduleJobSchema.cjs +114 -0
- package/src/main/lib/scheduleJobTransitions.cjs +164 -0
- package/src/main/lib/schedulerConfig.cjs +7 -0
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +769 -138
- package/src/preload/api.d.ts +54 -2
- package/src/preload/index.cjs +12 -0
- package/dist/assets/AgentLibrary-DYriNDGf.js +0 -1
- package/dist/assets/Scheduler-X5y252Qw.js +0 -14
- package/dist/assets/ScopeSwitcher-DU7M_q5-.js +0 -1
- package/dist/assets/Settings-DEptXcEY.js +0 -3
- package/dist/assets/Skills-CGN56X1i.js +0 -3
- package/dist/assets/SystemPrompt-DIEK7FpJ.js +0 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-stall-per-project.test.cjs — unit tests for computeStallSummary's
|
|
3
|
+
* per-project `stalled` verdict and findStaleQuarantinedJobs' age-based
|
|
4
|
+
* escalation.
|
|
5
|
+
*
|
|
6
|
+
* Covers the exact burrow-vs-others shape observed live: one project (burrow)
|
|
7
|
+
* holds only quarantined rows (0 running, 0 pending) while another project
|
|
8
|
+
* has running + pending work — the machine-wide `stalled` boolean reads
|
|
9
|
+
* false (masking burrow), but burrow's own byProject entry must read true.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 node --test src/main/__tests__/scheduler-stall-per-project.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { test } = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const { computeStallSummary, findStaleQuarantinedJobs, QUARANTINE_ESCALATE_MS } = require('../scheduler.cjs');
|
|
19
|
+
|
|
20
|
+
test('computeStallSummary: burrow fully stalled (quarantined only) while another project is busy — machine-wide false, burrow true', () => {
|
|
21
|
+
const state = {
|
|
22
|
+
paused: null,
|
|
23
|
+
jobs: [
|
|
24
|
+
{ slug: '821-a', cwd: '/home/bilko/Projects/burrow', status: 'quarantined' },
|
|
25
|
+
{ slug: '822-b', cwd: '/home/bilko/Projects/burrow', status: 'quarantined' },
|
|
26
|
+
{ slug: '823-c', cwd: '/home/bilko/Projects/burrow', status: 'quarantined' },
|
|
27
|
+
{ slug: '824-d', cwd: '/home/bilko/Projects/burrow', status: 'quarantined' },
|
|
28
|
+
{ slug: '900-e', cwd: '/home/bilko/Projects/other', status: 'running' },
|
|
29
|
+
{ slug: '901-f', cwd: '/home/bilko/Projects/other', status: 'pending' },
|
|
30
|
+
],
|
|
31
|
+
invalidJobs: [],
|
|
32
|
+
};
|
|
33
|
+
const summary = computeStallSummary(state);
|
|
34
|
+
assert.strictEqual(summary.stalled, false, 'machine-wide must NOT read stalled — other project is busy');
|
|
35
|
+
assert.strictEqual(summary.byProject['/home/bilko/Projects/burrow'].stalled, true, 'burrow must read stalled on its own');
|
|
36
|
+
assert.strictEqual(summary.byProject['/home/bilko/Projects/other'].stalled, false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('computeStallSummary: both projects busy — neither stalled', () => {
|
|
40
|
+
const state = {
|
|
41
|
+
paused: null,
|
|
42
|
+
jobs: [
|
|
43
|
+
{ slug: 'a', cwd: '/p1', status: 'running' },
|
|
44
|
+
{ slug: 'b', cwd: '/p2', status: 'pending' },
|
|
45
|
+
],
|
|
46
|
+
invalidJobs: [],
|
|
47
|
+
};
|
|
48
|
+
const summary = computeStallSummary(state);
|
|
49
|
+
assert.strictEqual(summary.stalled, false);
|
|
50
|
+
assert.strictEqual(summary.byProject['/p1'].stalled, false);
|
|
51
|
+
assert.strictEqual(summary.byProject['/p2'].stalled, false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('computeStallSummary: both projects stalled — machine-wide AND both per-project read true', () => {
|
|
55
|
+
const state = {
|
|
56
|
+
paused: null,
|
|
57
|
+
jobs: [
|
|
58
|
+
{ slug: 'a', cwd: '/p1', status: 'failed' },
|
|
59
|
+
{ slug: 'b', cwd: '/p2', status: 'needs_review' },
|
|
60
|
+
],
|
|
61
|
+
invalidJobs: [],
|
|
62
|
+
};
|
|
63
|
+
const summary = computeStallSummary(state);
|
|
64
|
+
assert.strictEqual(summary.stalled, true);
|
|
65
|
+
assert.strictEqual(summary.byProject['/p1'].stalled, true);
|
|
66
|
+
assert.strictEqual(summary.byProject['/p2'].stalled, true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('computeStallSummary: paused scheduler never reads stalled, machine-wide or per-project', () => {
|
|
70
|
+
const state = {
|
|
71
|
+
paused: { reason: 'rate_limit' },
|
|
72
|
+
jobs: [{ slug: 'a', cwd: '/p1', status: 'quarantined' }],
|
|
73
|
+
invalidJobs: [],
|
|
74
|
+
};
|
|
75
|
+
const summary = computeStallSummary(state);
|
|
76
|
+
assert.strictEqual(summary.stalled, false);
|
|
77
|
+
assert.strictEqual(summary.byProject['/p1'].stalled, false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('findStaleQuarantinedJobs: fires past threshold, not before', () => {
|
|
81
|
+
const now = Date.parse('2026-08-08T12:00:00.000Z');
|
|
82
|
+
const jobs = [
|
|
83
|
+
{
|
|
84
|
+
slug: 'fresh',
|
|
85
|
+
cwd: '/p1',
|
|
86
|
+
status: 'quarantined',
|
|
87
|
+
statusHistory: [{ to: 'quarantined', at: new Date(now - 1 * 60 * 60_000).toISOString() }],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
slug: 'stale',
|
|
91
|
+
cwd: '/p1',
|
|
92
|
+
status: 'quarantined',
|
|
93
|
+
statusHistory: [{ to: 'quarantined', at: new Date(now - 25 * 60 * 60_000).toISOString() }],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
slug: 'exactly-at-threshold',
|
|
97
|
+
cwd: '/p1',
|
|
98
|
+
status: 'quarantined',
|
|
99
|
+
statusHistory: [{ to: 'quarantined', at: new Date(now - QUARANTINE_ESCALATE_MS).toISOString() }],
|
|
100
|
+
},
|
|
101
|
+
{ slug: 'no-history', cwd: '/p1', status: 'quarantined' },
|
|
102
|
+
{ slug: 'not-quarantined', cwd: '/p1', status: 'pending' },
|
|
103
|
+
];
|
|
104
|
+
const stale = findStaleQuarantinedJobs(jobs, now, QUARANTINE_ESCALATE_MS);
|
|
105
|
+
const slugs = stale.map((s) => s.slug).sort();
|
|
106
|
+
assert.deepStrictEqual(slugs, ['exactly-at-threshold', 'stale']);
|
|
107
|
+
assert.strictEqual(stale.find((s) => s.slug === 'stale').cwd, '/p1');
|
|
108
|
+
});
|
|
@@ -34,6 +34,25 @@ function parseTools(raw) {
|
|
|
34
34
|
/** Filename-safe persona name: lowercase, hyphenated, matches the `.md` files on disk. */
|
|
35
35
|
const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
36
36
|
|
|
37
|
+
/** Sentinel `projects:` entry meaning "this agent's Action button shows in every project". */
|
|
38
|
+
const ALL_PROJECTS = '*';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Frontmatter values are single-line (see lib/prdFrontmatter.cjs) but an
|
|
42
|
+
* Action's opening instruction is genuinely multi-line prose (the builder
|
|
43
|
+
* action is `/builder\n\n<instruction>`). Encode newlines as a literal `\n`
|
|
44
|
+
* two-char sequence on the way out, decode on the way in — one place, so the
|
|
45
|
+
* round-trip is structural rather than something each caller remembers.
|
|
46
|
+
*/
|
|
47
|
+
function encodeLine(s) {
|
|
48
|
+
return String(s).replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeLine(s) {
|
|
52
|
+
if (!s) return '';
|
|
53
|
+
return String(s).replace(/\\([\\n])/g, (_m, c) => (c === 'n' ? '\n' : '\\'));
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
/**
|
|
38
57
|
* Serializes a persona's frontmatter + body back to `.md` file text.
|
|
39
58
|
* `tags` is a Claude-Code-agnostic extension of the frontmatter (Claude Code
|
|
@@ -41,14 +60,25 @@ const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
|
41
60
|
* this persona is associated with, so Agent Library and Tag Library can each
|
|
42
61
|
* assign/remove the relationship from either side. Stored the same
|
|
43
62
|
* comma-list way as `tools` for one parser to cover both.
|
|
63
|
+
*
|
|
64
|
+
* `projects` / `action` / `actionLabel` are the same kind of extension, for
|
|
65
|
+
* the Sessions workspace's per-project **Action buttons**: `projects` is the
|
|
66
|
+
* list of project cwds (or the `*` sentinel = every project) whose Sessions
|
|
67
|
+
* toolbar shows a one-click button for this agent, `action` is the opening
|
|
68
|
+
* instruction that button sends into the new session, and `actionLabel`
|
|
69
|
+
* overrides the button's caption (defaults to the persona name). Claude Code
|
|
70
|
+
* ignores all three; only this app reads them.
|
|
44
71
|
*/
|
|
45
|
-
function serializePersona({ name, description, tools, model, color, tags, title, body }) {
|
|
72
|
+
function serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body }) {
|
|
46
73
|
const lines = ['---', `name: ${name}`];
|
|
47
74
|
if (description) lines.push(`description: ${description}`);
|
|
48
75
|
if (tools && tools.length) lines.push(`tools: ${tools.join(', ')}`);
|
|
49
76
|
if (model && model !== 'inherit') lines.push(`model: ${model}`);
|
|
50
77
|
if (color) lines.push(`color: ${color}`);
|
|
51
78
|
if (tags && tags.length) lines.push(`tags: ${tags.join(', ')}`);
|
|
79
|
+
if (projects && projects.length) lines.push(`projects: ${projects.join(', ')}`);
|
|
80
|
+
if (action) lines.push(`action: ${encodeLine(action)}`);
|
|
81
|
+
if (actionLabel) lines.push(`actionLabel: ${encodeLine(actionLabel)}`);
|
|
52
82
|
if (title) lines.push(`title: ${title}`);
|
|
53
83
|
lines.push('---', '');
|
|
54
84
|
return lines.join('\n') + (body || '').trim() + '\n';
|
|
@@ -67,6 +97,9 @@ async function savePersona({
|
|
|
67
97
|
model,
|
|
68
98
|
color,
|
|
69
99
|
tags,
|
|
100
|
+
projects,
|
|
101
|
+
action,
|
|
102
|
+
actionLabel,
|
|
70
103
|
title,
|
|
71
104
|
body,
|
|
72
105
|
globalDir = path.join(os.homedir(), '.claude', 'agents'),
|
|
@@ -77,7 +110,7 @@ async function savePersona({
|
|
|
77
110
|
throw new Error('agent name must be lowercase, hyphenated (e.g. "my-agent")');
|
|
78
111
|
}
|
|
79
112
|
const target = validatePath(path.join(globalDir, `${name}.md`));
|
|
80
|
-
const text = serializePersona({ name, description, tools, model, color, tags, title, body });
|
|
113
|
+
const text = serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body });
|
|
81
114
|
await writeTextAtomic(target, text);
|
|
82
115
|
if (originalName && originalName !== name) {
|
|
83
116
|
const oldReal = validatePath(path.join(globalDir, `${originalName}.md`));
|
|
@@ -203,6 +236,9 @@ async function listPersonas({
|
|
|
203
236
|
model: fm.model || null,
|
|
204
237
|
color: fm.color || null,
|
|
205
238
|
tags: parseTools(fm.tags),
|
|
239
|
+
projects: parseTools(fm.projects),
|
|
240
|
+
action: decodeLine(fm.action) || null,
|
|
241
|
+
actionLabel: decodeLine(fm.actionLabel) || null,
|
|
206
242
|
title: fm.title || null,
|
|
207
243
|
path: real,
|
|
208
244
|
body: body.trim(),
|
|
@@ -220,5 +256,7 @@ module.exports = {
|
|
|
220
256
|
savePersona,
|
|
221
257
|
deletePersona,
|
|
222
258
|
removeOverride,
|
|
259
|
+
serializePersona,
|
|
223
260
|
PERSONA_NAME_RE,
|
|
261
|
+
ALL_PROJECTS,
|
|
224
262
|
};
|
package/src/main/health.cjs
CHANGED
|
@@ -14,6 +14,7 @@ const { checkPersonaImports } = require('./lib/personaImportHealth.cjs');
|
|
|
14
14
|
const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
|
|
15
15
|
const { migratePrds } = require('./lib/prdMigration.cjs');
|
|
16
16
|
const queueStore = require('./lib/queueStore.cjs');
|
|
17
|
+
const { computeStallSummary } = require('./scheduler.cjs');
|
|
17
18
|
const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
|
|
18
19
|
|
|
19
20
|
const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
|
|
@@ -131,6 +132,55 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
|
|
|
131
132
|
};
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
// computeProjectProblemCounts(jobs) → { [cwd]: { failed, needs_review, quarantined } }
|
|
136
|
+
//
|
|
137
|
+
// health.cjs's machine-wide `failed` count answered "is the machine stuck",
|
|
138
|
+
// never "is any ONE project stuck" — a single project with 4 quarantined
|
|
139
|
+
// PRDs and nothing else running was invisible in a rollup dominated by other
|
|
140
|
+
// projects' healthy jobs. Breaks down every non-terminal-problem status
|
|
141
|
+
// (failed/needs_review/quarantined — deliberately NOT 'completed'/'running'/
|
|
142
|
+
// 'pending'/'investigating', which are not problems) by project cwd.
|
|
143
|
+
function computeProjectProblemCounts(jobs) {
|
|
144
|
+
const byProject = {};
|
|
145
|
+
for (const j of jobs || []) {
|
|
146
|
+
if (j.status !== 'failed' && j.status !== 'needs_review' && j.status !== 'quarantined') continue;
|
|
147
|
+
const cwd = j.cwd || '(unknown)';
|
|
148
|
+
byProject[cwd] = byProject[cwd] || { failed: 0, needs_review: 0, quarantined: 0 };
|
|
149
|
+
byProject[cwd][j.status] += 1;
|
|
150
|
+
}
|
|
151
|
+
return byProject;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// evaluatePerProjectStall(stallSummary, lastRunAtIso, now, thresholdMs) →
|
|
155
|
+
// { [cwd]: { stalled, pastThreshold?, ageMs?, caveat? } }
|
|
156
|
+
//
|
|
157
|
+
// computeStallSummary's per-project `stalled` flag (scheduler.cjs) is a
|
|
158
|
+
// point-in-time verdict with no duration attached — a project can flip
|
|
159
|
+
// stalled/unstalled within a single tick as work completes, so flagging RED
|
|
160
|
+
// the instant it's true would false-trip on ordinary queue churn. There is
|
|
161
|
+
// no per-project lastRunAt persisted (only a machine-wide one), so this
|
|
162
|
+
// reuses that machine-wide timestamp as the best available "has the
|
|
163
|
+
// scheduler ticked recently at all" signal, gated per-project by whether
|
|
164
|
+
// THAT project currently holds stalled work.
|
|
165
|
+
function evaluatePerProjectStall(stallSummary, lastRunAtIso, now, thresholdMs) {
|
|
166
|
+
const lastRunAt = lastRunAtIso ? Date.parse(lastRunAtIso) : null;
|
|
167
|
+
const results = {};
|
|
168
|
+
for (const cwd of Object.keys(stallSummary?.byProject || {})) {
|
|
169
|
+
const counts = stallSummary.byProject[cwd];
|
|
170
|
+
if (!counts.stalled) {
|
|
171
|
+
results[cwd] = { stalled: false };
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (lastRunAt == null || Number.isNaN(lastRunAt)) {
|
|
175
|
+
results[cwd] = { stalled: true, pastThreshold: false, caveat: 'no-lastRunAt' };
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const ageMs = now - lastRunAt;
|
|
179
|
+
results[cwd] = { stalled: true, pastThreshold: ageMs >= thresholdMs, ageMs };
|
|
180
|
+
}
|
|
181
|
+
return results;
|
|
182
|
+
}
|
|
183
|
+
|
|
134
184
|
// Pure evaluator over migratePrds()'s { moved, skipped, unresolved } result —
|
|
135
185
|
// kept separate from the fs-touching check() call site so it's directly
|
|
136
186
|
// unit-testable, matching evaluateTickLiveness's pattern.
|
|
@@ -222,18 +272,42 @@ async function check() {
|
|
|
222
272
|
const failedCount = Object.values(queueState.jobs || {}).filter(
|
|
223
273
|
(j) => j.status === 'failed'
|
|
224
274
|
).length;
|
|
275
|
+
const needsReviewCount = Object.values(queueState.jobs || {}).filter(
|
|
276
|
+
(j) => j.status === 'needs_review'
|
|
277
|
+
).length;
|
|
278
|
+
const quarantinedCount = Object.values(queueState.jobs || {}).filter(
|
|
279
|
+
(j) => j.status === 'quarantined'
|
|
280
|
+
).length;
|
|
225
281
|
const heartbeatPath = path.join(
|
|
226
282
|
os.homedir(),
|
|
227
283
|
'.claude/session-manager/scheduler-heartbeat.log'
|
|
228
284
|
);
|
|
229
285
|
const heartbeat = readFreshHeartbeat(heartbeatPath);
|
|
230
286
|
const liveness = evaluateTickLiveness(queueState, heartbeat, Date.now(), runningCount);
|
|
287
|
+
|
|
288
|
+
// Per-project rollup (PRD: monitoring must not collapse per-project
|
|
289
|
+
// reality into one machine-wide boolean — see computeStallSummary /
|
|
290
|
+
// computeProjectProblemCounts headers). A project holding ONLY
|
|
291
|
+
// failed/needs_review/quarantined rows (0 running, 0 pending) never
|
|
292
|
+
// trips evaluateTickLiveness above, since that check requires actual
|
|
293
|
+
// pending work — this is what let the burrow project go dark.
|
|
294
|
+
const stallSummary = computeStallSummary(queueState);
|
|
295
|
+
const now = Date.now();
|
|
296
|
+
const perProjectStall = evaluatePerProjectStall(stallSummary, queueState.lastRunAt, now, TICK_STALL_THRESHOLD_MS);
|
|
297
|
+
const projectsPastThreshold = Object.entries(perProjectStall)
|
|
298
|
+
.filter(([, v]) => v.pastThreshold)
|
|
299
|
+
.map(([cwd]) => cwd);
|
|
300
|
+
|
|
231
301
|
status.components.scheduler_queue = {
|
|
232
|
-
ok: !liveness.stalled,
|
|
302
|
+
ok: !liveness.stalled && projectsPastThreshold.length === 0,
|
|
233
303
|
path: queuePath,
|
|
234
304
|
jobs: Object.keys(queueState.jobs || {}).length,
|
|
235
305
|
running: runningCount,
|
|
236
306
|
failed: failedCount,
|
|
307
|
+
needsReview: needsReviewCount,
|
|
308
|
+
quarantined: quarantinedCount,
|
|
309
|
+
byProject: computeProjectProblemCounts(queueState.jobs),
|
|
310
|
+
perProjectStall,
|
|
237
311
|
tickLiveness: liveness.reason,
|
|
238
312
|
};
|
|
239
313
|
if (liveness.stalled) {
|
|
@@ -249,6 +323,18 @@ async function check() {
|
|
|
249
323
|
? `Tick hasn't advanced in a while but scheduler-heartbeat.log is missing/stale, so current billing utilization can't be checked — cannot rule out a legitimate when-available hold`
|
|
250
324
|
: 'No lastRunAt recorded yet — cannot assess tick liveness';
|
|
251
325
|
}
|
|
326
|
+
if (projectsPastThreshold.length > 0) {
|
|
327
|
+
status.components.scheduler_queue.stalledProjects = projectsPastThreshold;
|
|
328
|
+
for (const cwd of projectsPastThreshold) {
|
|
329
|
+
const ageMin = Math.round(perProjectStall[cwd].ageMs / 60_000);
|
|
330
|
+
const counts = status.components.scheduler_queue.byProject[cwd] || {};
|
|
331
|
+
status.issues.push(
|
|
332
|
+
`Project fully stalled: ${cwd} — 0 running, 0 pending, only problem jobs `
|
|
333
|
+
+ `(failed=${counts.failed ?? 0} needs_review=${counts.needs_review ?? 0} quarantined=${counts.quarantined ?? 0}), `
|
|
334
|
+
+ `no scheduler tick in ~${ageMin}m (threshold ${Math.round(TICK_STALL_THRESHOLD_MS / 60_000)}m)`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
252
338
|
} catch (e) {
|
|
253
339
|
if (e.code !== 'ENOENT') {
|
|
254
340
|
status.issues.push(`Scheduler queue unreadable: ${e.message}`);
|
|
@@ -422,4 +508,13 @@ if (require.main === module) {
|
|
|
422
508
|
})();
|
|
423
509
|
}
|
|
424
510
|
|
|
425
|
-
module.exports = {
|
|
511
|
+
module.exports = {
|
|
512
|
+
check,
|
|
513
|
+
evaluateTickLiveness,
|
|
514
|
+
readFreshHeartbeat,
|
|
515
|
+
evaluatePrdMigrationHealth,
|
|
516
|
+
computeProjectProblemCounts,
|
|
517
|
+
evaluatePerProjectStall,
|
|
518
|
+
TICK_STALL_THRESHOLD_MS,
|
|
519
|
+
HEARTBEAT_STALE_MS,
|
|
520
|
+
};
|
package/src/main/index.cjs
CHANGED
|
@@ -26,6 +26,7 @@ const voiceWizard = require('./voiceWizard.cjs');
|
|
|
26
26
|
const scheduler = require('./scheduler.cjs');
|
|
27
27
|
const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
|
|
28
28
|
const prdCreate = require('./lib/prdCreate.cjs');
|
|
29
|
+
const prdAdminRoutes = require('./lib/prdAdminRoutes.cjs');
|
|
29
30
|
const { appendAuditEvent } = require('./lib/auditLog.cjs');
|
|
30
31
|
const chatRunner = require('./chatRunner.cjs');
|
|
31
32
|
const promptSessionEvents = require('./promptSessionEvents.cjs');
|
|
@@ -37,6 +38,7 @@ const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
|
|
|
37
38
|
const adminHttp = createAdminHttp();
|
|
38
39
|
scheduler.registerAdminRoutes(adminHttp);
|
|
39
40
|
prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
|
|
41
|
+
prdAdminRoutes.registerAdminRoute(adminHttp, scheduler.remote);
|
|
40
42
|
chatRunner.registerAdminRoute(adminHttp);
|
|
41
43
|
const supervisor = require('./supervisor.cjs');
|
|
42
44
|
const watchers = require('./watchers.cjs');
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -327,6 +327,62 @@ const scheduleRetagPrd = z.object({
|
|
|
327
327
|
items: z.array(scheduleRetagItem).min(1).max(500),
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
+
// ──────────────────────────────────────────── Admin API — PRD lifecycle (PRD 1024)
|
|
331
|
+
//
|
|
332
|
+
// GET routes parse their input from URLSearchParams (all values arrive as
|
|
333
|
+
// strings), so numeric/array fields aren't accepted here — only slug/cwd/
|
|
334
|
+
// epicId/status, matching what a query string can actually carry.
|
|
335
|
+
const adminListPrdsQuery = z.object({
|
|
336
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
337
|
+
epicId: z.string().min(1).max(200).optional(),
|
|
338
|
+
status: z.string().min(1).max(40).optional(),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const adminGetPrdQuery = z.object({
|
|
342
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
343
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Partial frontmatter patch for update-prd — same recognized-key set as
|
|
347
|
+
// prdFrontmatter.cjs's parsePrdFile/serializePrdFile round-trip pair. Every
|
|
348
|
+
// field optional; the route refuses an empty patch+no-body request via the
|
|
349
|
+
// top-level .refine below.
|
|
350
|
+
const adminPrdFrontmatterPatch = z.object({
|
|
351
|
+
title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
352
|
+
cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
353
|
+
estimateMinutes: z.number().int().min(1).max(100000).optional(),
|
|
354
|
+
parallelGroup: z.number().int().min(0).max(999999).optional(),
|
|
355
|
+
sourcePromptId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
356
|
+
sourceTabId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
357
|
+
// 'discussion' deliberately excluded: matches prdFrontmatter.cjs's
|
|
358
|
+
// parsePrdFile, which never parses that value for a PRD's tag key (a
|
|
359
|
+
// discussion-tagged Epic never reaches PRD authoring).
|
|
360
|
+
tag: z.enum(['feature', 'bug', 'build']).optional(),
|
|
361
|
+
// Provenance stamp (PRD-authoring lockdown). Free-text rather than an enum
|
|
362
|
+
// — 'scheduler-api' (create-time) and 'legacy-adopted' (migration/manual
|
|
363
|
+
// adopt) are the two values this codebase writes today, but the field
|
|
364
|
+
// itself is descriptive, not a closed set.
|
|
365
|
+
createdVia: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
366
|
+
issuedAt: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const adminUpdatePrd = z.object({
|
|
370
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
371
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
372
|
+
frontmatter: adminPrdFrontmatterPatch.optional(),
|
|
373
|
+
body: z.string().refine(
|
|
374
|
+
(s) => Buffer.byteLength(s, 'utf8') <= PRD_WRITE_MAX_BYTES,
|
|
375
|
+
`body must be ≤ ${PRD_WRITE_MAX_BYTES} bytes`,
|
|
376
|
+
).optional(),
|
|
377
|
+
}).refine(
|
|
378
|
+
(it) => (it.frontmatter && Object.keys(it.frontmatter).length > 0) || it.body !== undefined,
|
|
379
|
+
'at least one of frontmatter or body is required',
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const adminCancelJob = z.object({
|
|
383
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
384
|
+
});
|
|
385
|
+
|
|
330
386
|
// ──────────────────────────────────────────── Projects
|
|
331
387
|
const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
|
|
332
388
|
|
|
@@ -812,6 +868,10 @@ module.exports = {
|
|
|
812
868
|
schedulerCreatePrd,
|
|
813
869
|
scheduleArchivePrd,
|
|
814
870
|
scheduleRetagPrd,
|
|
871
|
+
adminListPrdsQuery,
|
|
872
|
+
adminGetPrdQuery,
|
|
873
|
+
adminUpdatePrd,
|
|
874
|
+
adminCancelJob,
|
|
815
875
|
setConfigSchema,
|
|
816
876
|
setSessionSlotsSchema,
|
|
817
877
|
shellOpen,
|
|
@@ -151,17 +151,24 @@ function createAdminHttp() {
|
|
|
151
151
|
sendJson(res, 401, { ok: false, error: 'unauthorized' });
|
|
152
152
|
return;
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
// Route lookup is keyed by pathname only — req.url carries the raw query
|
|
155
|
+
// string too, which would otherwise never match a registered route (every
|
|
156
|
+
// route before PRD 1024 was query-free, so this was a latent gap, not a
|
|
157
|
+
// behavior change for them). `query` is handed to the route handler as a
|
|
158
|
+
// third argument (URLSearchParams) for routes that need it (e.g. GET
|
|
159
|
+
// /admin/scheduler/prds?cwd=&epicId=&status=).
|
|
160
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
161
|
+
if (req.method === 'GET' && urlObj.pathname === HEALTH_PATH) {
|
|
155
162
|
sendJson(res, 200, { ok: true });
|
|
156
163
|
return;
|
|
157
164
|
}
|
|
158
165
|
try {
|
|
159
|
-
const handler = routes.get(`${req.method} ${
|
|
166
|
+
const handler = routes.get(`${req.method} ${urlObj.pathname}`);
|
|
160
167
|
if (!handler) {
|
|
161
168
|
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
162
169
|
return;
|
|
163
170
|
}
|
|
164
|
-
await handler(req, res);
|
|
171
|
+
await handler(req, res, urlObj.searchParams);
|
|
165
172
|
} catch (e) {
|
|
166
173
|
sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
|
|
167
174
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdAdminRoutes.cjs — admin HTTP routes closing the PRD-lifecycle capability
|
|
3
|
+
* gap (PRD 1024, step 1 of locking the PRD entity behind the scheduler
|
|
4
|
+
* service boundary — see that PRD's Goal). Before this file, the admin API
|
|
5
|
+
* exposed only create-prd/jobs/reset-job/send-prompt: there was no way to
|
|
6
|
+
* READ, LIST, EDIT, ARCHIVE, or CANCEL a PRD/job over HTTP, which is exactly
|
|
7
|
+
* why direct filesystem access to session-manager-operations/scheduler/ was
|
|
8
|
+
* still sanctioned. This file plumbs those operations to HTTP; it adds no
|
|
9
|
+
* enforcement/denial of direct access — that's the dependent PRD's job (see
|
|
10
|
+
* this PRD's "Out of scope").
|
|
11
|
+
*
|
|
12
|
+
* Every route here reuses an existing verified implementation rather than
|
|
13
|
+
* reimplementing it:
|
|
14
|
+
* - list/get/update/cancel delegate to scheduler.cjs's `remote` object
|
|
15
|
+
* (job-status-aware; remote already owns the PRD-dir search + symlink
|
|
16
|
+
* defense patterns these need)
|
|
17
|
+
* - archive/retag delegate to queueOps.cjs's archiveMany/retagMany
|
|
18
|
+
* directly (job-status-agnostic bulk file ops; already used by the
|
|
19
|
+
* renderer's schedule:archive-prd/schedule:retag-prd IPC handlers)
|
|
20
|
+
*
|
|
21
|
+
* Registered against the same injected localAdminHttp.cjs transport as
|
|
22
|
+
* scheduler.cjs's registerAdminRoutes and prdCreate.cjs's registerAdminRoute
|
|
23
|
+
* (see index.cjs) — auth, loopback-only binding, and the bearer-token
|
|
24
|
+
* lifecycle are all the transport's job, not this file's.
|
|
25
|
+
*/
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const { schemas } = require('../ipcSchemas.cjs');
|
|
29
|
+
const { readBody, sendJson } = require('./localAdminHttp.cjs');
|
|
30
|
+
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
31
|
+
const queueOps = require('../queueOps.cjs');
|
|
32
|
+
|
|
33
|
+
function parseJsonBody(raw) {
|
|
34
|
+
try {
|
|
35
|
+
return { ok: true, value: raw ? JSON.parse(raw) : {} };
|
|
36
|
+
} catch {
|
|
37
|
+
return { ok: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* registerAdminRoute(adminHttp, remote) — `remote` is scheduler.cjs's
|
|
43
|
+
* `remote` object, injected (not required directly) so this file stays
|
|
44
|
+
* testable without booting Electron, matching prdCreate.cjs's and
|
|
45
|
+
* scheduler.cjs's own registerAdminRoute(s) pattern.
|
|
46
|
+
*/
|
|
47
|
+
function registerAdminRoute(adminHttp, remote) {
|
|
48
|
+
adminHttp.registerRoute('GET', '/admin/scheduler/prds', async (req, res, query) => {
|
|
49
|
+
let input;
|
|
50
|
+
try {
|
|
51
|
+
input = schemas.adminListPrdsQuery.parse(Object.fromEntries(query ?? []));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const prds = await remote.listPrds(input);
|
|
57
|
+
sendJson(res, 200, { ok: true, prds });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
adminHttp.registerRoute('GET', '/admin/scheduler/prd', async (req, res, query) => {
|
|
61
|
+
let input;
|
|
62
|
+
try {
|
|
63
|
+
input = schemas.adminGetPrdQuery.parse(Object.fromEntries(query ?? []));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const result = await remote.getPrdParsed(input.slug, input.cwd);
|
|
69
|
+
if (!result.ok) {
|
|
70
|
+
sendJson(res, 404, result);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
sendJson(res, 200, result);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/update-prd', async (req, res) => {
|
|
77
|
+
const raw = await readBody(req);
|
|
78
|
+
const parsedBody = parseJsonBody(raw);
|
|
79
|
+
if (!parsedBody.ok) {
|
|
80
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let input;
|
|
84
|
+
try {
|
|
85
|
+
input = schemas.adminUpdatePrd.parse(parsedBody.value);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const result = await remote.updatePrd(input);
|
|
91
|
+
if (!result.ok) {
|
|
92
|
+
const status = /not found/i.test(result.error ?? '') ? 404 : /status is/i.test(result.error ?? '') ? 409 : 400;
|
|
93
|
+
sendJson(res, status, result);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
appendAuditEvent('admin_prd_update', { route: '/admin/scheduler/update-prd', slug: input.slug, cwd: input.cwd ?? null });
|
|
97
|
+
sendJson(res, 200, result);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/archive-prd', async (req, res) => {
|
|
101
|
+
const raw = await readBody(req);
|
|
102
|
+
const parsedBody = parseJsonBody(raw);
|
|
103
|
+
if (!parsedBody.ok) {
|
|
104
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let input;
|
|
108
|
+
try {
|
|
109
|
+
input = schemas.scheduleArchivePrd.parse(parsedBody.value);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const result = await queueOps.archiveMany(input.slugs);
|
|
115
|
+
// Audit only the slugs that actually archived — archiveMany's `results`
|
|
116
|
+
// array carries the real per-slug outcome; logging every requested slug
|
|
117
|
+
// regardless of whether it succeeded would misrepresent a partial-failure
|
|
118
|
+
// batch as a clean one in audit-log.jsonl.
|
|
119
|
+
for (const r of result.results ?? []) {
|
|
120
|
+
if (!r.ok) continue;
|
|
121
|
+
appendAuditEvent('admin_prd_archive', { route: '/admin/scheduler/archive-prd', slug: r.slug, cwd: null });
|
|
122
|
+
}
|
|
123
|
+
sendJson(res, result.ok ? 200 : 500, result);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/cancel-job', async (req, res) => {
|
|
127
|
+
const raw = await readBody(req);
|
|
128
|
+
const parsedBody = parseJsonBody(raw);
|
|
129
|
+
if (!parsedBody.ok) {
|
|
130
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let input;
|
|
134
|
+
try {
|
|
135
|
+
input = schemas.adminCancelJob.parse(parsedBody.value);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const result = await remote.cancelJob(input.slug);
|
|
141
|
+
if (!result.ok) {
|
|
142
|
+
const status = /not found/i.test(result.error ?? '') ? 404 : 409;
|
|
143
|
+
sendJson(res, status, result);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
appendAuditEvent('admin_job_cancel', { route: '/admin/scheduler/cancel-job', slug: input.slug, cwd: result.cwd ?? null });
|
|
147
|
+
sendJson(res, 200, result);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/retag-prd', async (req, res) => {
|
|
151
|
+
const raw = await readBody(req);
|
|
152
|
+
const parsedBody = parseJsonBody(raw);
|
|
153
|
+
if (!parsedBody.ok) {
|
|
154
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
let input;
|
|
158
|
+
try {
|
|
159
|
+
input = schemas.scheduleRetagPrd.parse(parsedBody.value);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const result = await queueOps.retagMany(input.items);
|
|
165
|
+
// Audit only the items that actually retagged (see the archive-prd route
|
|
166
|
+
// above for why this must read result.results rather than the request).
|
|
167
|
+
for (const r of result.results ?? []) {
|
|
168
|
+
if (!r.ok) continue;
|
|
169
|
+
appendAuditEvent('admin_prd_retag', { route: '/admin/scheduler/retag-prd', slug: r.newSlug ?? r.slug, cwd: null });
|
|
170
|
+
}
|
|
171
|
+
sendJson(res, result.ok ? 200 : 500, result);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { registerAdminRoute };
|