claude-code-session-manager 0.75.3 → 0.77.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-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
- package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/lib/activeSessions.cjs +116 -6
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +269 -96
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +247 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +25 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +64 -5
- package/src/main/ipcSchemas.cjs +69 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +69 -2
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +514 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +520 -21
- package/src/main/lib/jobWorktree.cjs +2 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +370 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +106 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +103 -15
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +40 -7
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +54 -4
- package/src/main/lib/reaperHelpers.cjs +64 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +301 -55
- package/src/main/lib/schedulerConfig.cjs +99 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +162 -3
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1701 -130
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +187 -3
- package/src/preload/index.cjs +9 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
- package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
- package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* landedSinceRun.test.cjs — the widened, path-scoped commit evidence helper
|
|
3
|
+
* behind reverifyNeedsReview's looksDone annotation (PRD 1102).
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/landedSinceRun.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test } from 'vitest';
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { execFileSync } = require('node:child_process');
|
|
16
|
+
const { landedSinceRun } = require('../landedSinceRun.cjs');
|
|
17
|
+
|
|
18
|
+
function mkRepo() {
|
|
19
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-since-run-'));
|
|
20
|
+
execFileSync('git', ['-C', dir, 'init', '-q']);
|
|
21
|
+
execFileSync('git', ['-C', dir, 'config', 'user.email', 'test@test.com']);
|
|
22
|
+
execFileSync('git', ['-C', dir, 'config', 'user.name', 'Test']);
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function commitFile(dir, relPath, content, message) {
|
|
27
|
+
const abs = path.join(dir, relPath);
|
|
28
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
29
|
+
fs.writeFileSync(abs, content);
|
|
30
|
+
execFileSync('git', ['-C', dir, 'add', relPath]);
|
|
31
|
+
execFileSync('git', ['-C', dir, 'commit', '-q', '-m', message]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('finds a commit landed after sinceIso that touches a declared path', async () => {
|
|
35
|
+
const dir = mkRepo();
|
|
36
|
+
const since = new Date().toISOString();
|
|
37
|
+
await new Promise((r) => setTimeout(r, 1100)); // git --since has 1s resolution
|
|
38
|
+
commitFile(dir, 'src/foo.js', 'hello', 'touch foo');
|
|
39
|
+
const shas = await landedSinceRun(dir, since, ['src/foo.js']);
|
|
40
|
+
assert.strictEqual(shas.length, 1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('ignores a commit that does not touch any declared path', async () => {
|
|
44
|
+
const dir = mkRepo();
|
|
45
|
+
const since = new Date().toISOString();
|
|
46
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
47
|
+
commitFile(dir, 'src/unrelated.js', 'hello', 'touch unrelated');
|
|
48
|
+
const shas = await landedSinceRun(dir, since, ['src/foo.js']);
|
|
49
|
+
assert.deepStrictEqual(shas, []);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('empty paths never fabricates evidence — resolves []', async () => {
|
|
53
|
+
const dir = mkRepo();
|
|
54
|
+
commitFile(dir, 'src/foo.js', 'hello', 'touch foo');
|
|
55
|
+
const shas = await landedSinceRun(dir, new Date(0).toISOString(), []);
|
|
56
|
+
assert.deepStrictEqual(shas, []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('no cwd resolves [] without throwing', async () => {
|
|
60
|
+
const shas = await landedSinceRun(null, new Date().toISOString(), ['src/foo.js']);
|
|
61
|
+
assert.deepStrictEqual(shas, []);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('git-unavailable (non-repo cwd) resolves [] without throwing', async () => {
|
|
65
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-since-run-norepo-'));
|
|
66
|
+
const shas = await landedSinceRun(dir, new Date().toISOString(), ['src/foo.js']);
|
|
67
|
+
assert.deepStrictEqual(shas, []);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('has a bounded default timeout', () => {
|
|
71
|
+
const { LANDED_SINCE_RUN_TIMEOUT_MS } = require('../landedSinceRun.cjs');
|
|
72
|
+
assert.ok(LANDED_SINCE_RUN_TIMEOUT_MS > 0 && LANDED_SINCE_RUN_TIMEOUT_MS <= 60_000);
|
|
73
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launchFailure.test.cjs — the non-run classifier + launch circuit breaker
|
|
3
|
+
* behind GitHub issue #11 (macOS, 2026-09-02): an outdated Claude CLI sent
|
|
4
|
+
* `thinking.type.enabled`, the API answered HTTP 400 on the first request,
|
|
5
|
+
* and 12 of 41 runs in one project were recorded as `failed` with
|
|
6
|
+
* `error: null` and then investigated by a probe that died the same way.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/launchFailure.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect } from 'vitest';
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const lf = require('../launchFailure.cjs');
|
|
18
|
+
|
|
19
|
+
// Verbatim shape from the issue-#11 transcript (2c3cd24f-…ccf0eb290fff.jsonl):
|
|
20
|
+
// a single assistant entry that IS the raw API error, then the result event.
|
|
21
|
+
const THINKING_400_RESULT = {
|
|
22
|
+
type: 'result', subtype: 'error', is_error: true, api_error_status: 400, num_turns: 1,
|
|
23
|
+
duration_ms: 23811, total_cost_usd: 0,
|
|
24
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
25
|
+
result: 'API Error: 400 {"detail":{"error":"{\\"message\\":\\"\\\\\\"thinking.type.enabled\\\\\\" is not supported for this model. Use \\\\\\"thinking.type.adaptive\\\\\\" and \\\\\\"output_config.effort\\\\\\" to control thinking behavior.\\"}"}}',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const REAL_WORK_FAILURE = {
|
|
29
|
+
type: 'result', subtype: 'error', is_error: true, num_turns: 19,
|
|
30
|
+
usage: { input_tokens: 4000, output_tokens: 2200 },
|
|
31
|
+
result: 'Error: expected 2 but got 3\n at test/foo.test.ts:12:5', terminal_reason: 'error_max_turns',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function tmpLog(lines) {
|
|
35
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-launch-'));
|
|
36
|
+
const p = path.join(dir, 'run.log');
|
|
37
|
+
fs.writeFileSync(p, lines.map((l) => (typeof l === 'string' ? l : JSON.stringify(l))).join('\n') + '\n');
|
|
38
|
+
return p;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('parseResultEvent: flattens the last result event with turns/tokens/status', () => {
|
|
42
|
+
const r = lf.parseResultEvent([
|
|
43
|
+
'[scheduler] starting foo',
|
|
44
|
+
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'API Error: 400 …' }] } }),
|
|
45
|
+
JSON.stringify(THINKING_400_RESULT),
|
|
46
|
+
].join('\n'));
|
|
47
|
+
expect(r).toMatchObject({ subtype: 'error', isError: true, numTurns: 1, outputTokens: 0, apiErrorStatus: 400 });
|
|
48
|
+
expect(r.resultText).toMatch(/thinking\.type\.enabled/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('parseResultEvent: null when no result event exists (process died first)', () => {
|
|
52
|
+
expect(lf.parseResultEvent('[scheduler] starting\n{"type":"system","subtype":"init"}\n')).toBeNull();
|
|
53
|
+
expect(lf.parseResultEvent('')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('classifyLaunchFailure: the issue-#11 thinking 400 → model_config_rejected with the API message', () => {
|
|
57
|
+
const c = lf.classifyLaunchFailure(lf.parseResultEvent(JSON.stringify(THINKING_400_RESULT)));
|
|
58
|
+
expect(c).not.toBeNull();
|
|
59
|
+
expect(c.kind).toBe('model_config_rejected');
|
|
60
|
+
expect(c.httpStatus).toBe(400);
|
|
61
|
+
expect(c.message).toMatch(/thinking\.type\.enabled.*not supported for this model/);
|
|
62
|
+
expect(c.message).not.toMatch(/^API Error/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('classifyLaunchFailure: a run that took real turns is NEVER a launch failure, even with API Error text', () => {
|
|
66
|
+
expect(lf.classifyLaunchFailure(lf.parseResultEvent(JSON.stringify(REAL_WORK_FAILURE)))).toBeNull();
|
|
67
|
+
const lateApiError = { ...THINKING_400_RESULT, num_turns: 7, usage: { output_tokens: 900 } };
|
|
68
|
+
expect(lf.classifyLaunchFailure(lf.parseResultEvent(JSON.stringify(lateApiError)))).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('classifyLaunchFailure: zero-turn failure WITHOUT the API Error marker is not classified (someone else owns it)', () => {
|
|
72
|
+
const r = lf.parseResultEvent(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, num_turns: 1, usage: { output_tokens: 0 }, result: 'Invalid prompt' }));
|
|
73
|
+
expect(lf.classifyLaunchFailure(r)).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('classifyLaunchFailure: 429 is left to the rate-limit path', () => {
|
|
77
|
+
const r = lf.parseResultEvent(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, api_error_status: 429, num_turns: 1, usage: { output_tokens: 0 }, result: 'API Error: 429 {"error":{"message":"rate limited"}}' }));
|
|
78
|
+
expect(lf.classifyLaunchFailure(r)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('classifyLaunchFailure: status → kind mapping', () => {
|
|
82
|
+
const mk = (status, text) => lf.classifyLaunchFailure(lf.parseResultEvent(JSON.stringify({
|
|
83
|
+
type: 'result', subtype: 'error', is_error: true, num_turns: 1, usage: { output_tokens: 0 }, result: `API Error: ${status} ${text}`,
|
|
84
|
+
})));
|
|
85
|
+
expect(mk(401, '{"error":{"message":"invalid x-api-key"}}').kind).toBe('auth_failed');
|
|
86
|
+
expect(mk(403, '{"error":{"message":"forbidden"}}').kind).toBe('auth_failed');
|
|
87
|
+
expect(mk(404, '{"error":{"message":"model: claude-x not found"}}').kind).toBe('model_not_found');
|
|
88
|
+
expect(mk(400, '{"error":{"message":"max_tokens must be > 0"}}').kind).toBe('bad_request');
|
|
89
|
+
expect(mk(529, '{"error":{"type":"overloaded_error","message":"Overloaded"}}').kind).toBe('api_overloaded');
|
|
90
|
+
expect(mk(500, '{"error":{"message":"internal"}}').kind).toBe('api_overloaded');
|
|
91
|
+
expect(mk(418, 'teapot').kind).toBe('api_error');
|
|
92
|
+
expect(mk(401, '{"error":{"message":"invalid x-api-key"}}').message).toBe('invalid x-api-key');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('readResultEvent + classify on a real log tail (scheduler log lines interleaved)', () => {
|
|
96
|
+
const p = tmpLog([
|
|
97
|
+
'[scheduler] starting 04-fix-cdp at 2026-09-02T17:54:14.770Z',
|
|
98
|
+
{ type: 'system', subtype: 'init', session_id: 'x' },
|
|
99
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'PRD body' }] } },
|
|
100
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'API Error: 400 …' }] } },
|
|
101
|
+
THINKING_400_RESULT,
|
|
102
|
+
'[scheduler] exit code=1 (raw code=1 signal=null) duration=25s',
|
|
103
|
+
]);
|
|
104
|
+
try {
|
|
105
|
+
const r = lf.readResultEvent(p);
|
|
106
|
+
expect(lf.classifyLaunchFailure(r)?.kind).toBe('model_config_rejected');
|
|
107
|
+
expect(lf.resultShowsRealTurn(r)).toBe(false);
|
|
108
|
+
} finally {
|
|
109
|
+
fs.rmSync(path.dirname(p), { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('resultShowsRealTurn: true once a turn or any output token happened', () => {
|
|
114
|
+
expect(lf.resultShowsRealTurn({ numTurns: 1, outputTokens: 0 })).toBe(false);
|
|
115
|
+
expect(lf.resultShowsRealTurn({ numTurns: 2, outputTokens: 0 })).toBe(true);
|
|
116
|
+
expect(lf.resultShowsRealTurn({ numTurns: 1, outputTokens: 12 })).toBe(true);
|
|
117
|
+
expect(lf.resultShowsRealTurn(null)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ─── circuit breaker ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const T0 = Date.parse('2026-09-02T18:00:00Z');
|
|
123
|
+
|
|
124
|
+
test('armLaunchBlock: first failure opens with the kind backoff, a mitigation env, and an operator hint', () => {
|
|
125
|
+
const b = lf.armLaunchBlock(null, { kind: 'model_config_rejected', httpStatus: 400, message: 'm', now: T0, claudeVersion: '1.0.90', slug: 'a', runId: 'r1' });
|
|
126
|
+
expect(b.attempts).toBe(1);
|
|
127
|
+
expect(b.exhausted).toBe(false);
|
|
128
|
+
expect(Date.parse(b.until) - T0).toBe(lf.backoffMsFor('model_config_rejected', 1));
|
|
129
|
+
expect(b.mitigationEnv).toEqual({ MAX_THINKING_TOKENS: '0' });
|
|
130
|
+
expect(b.hint).toMatch(/claude update/);
|
|
131
|
+
expect(b.claudeVersion).toBe('1.0.90');
|
|
132
|
+
expect(b.probing).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('armLaunchBlock: repeated same-kind failures escalate, cap at 60 min, then exhaust (until=null)', () => {
|
|
136
|
+
let b = null;
|
|
137
|
+
const seen = [];
|
|
138
|
+
for (let i = 0; i < lf.LAUNCH_BLOCK_MAX_ATTEMPTS; i++) {
|
|
139
|
+
b = lf.armLaunchBlock(b, { kind: 'api_error', message: 'x', now: T0 + i * 1000 });
|
|
140
|
+
seen.push(b.until ? Date.parse(b.until) - (T0 + i * 1000) : null);
|
|
141
|
+
}
|
|
142
|
+
expect(b.attempts).toBe(lf.LAUNCH_BLOCK_MAX_ATTEMPTS);
|
|
143
|
+
expect(b.exhausted).toBe(true);
|
|
144
|
+
expect(b.until).toBeNull();
|
|
145
|
+
const finite = seen.filter((x) => x !== null);
|
|
146
|
+
for (let i = 1; i < finite.length; i++) expect(finite[i]).toBeGreaterThanOrEqual(finite[i - 1]);
|
|
147
|
+
expect(Math.max(...finite)).toBeLessThanOrEqual(60 * 60_000);
|
|
148
|
+
expect(b.since).toBe(new Date(T0).toISOString()); // first-failure time survives re-arming
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('armLaunchBlock: a different kind restarts the attempt count', () => {
|
|
152
|
+
const a = lf.armLaunchBlock(null, { kind: 'api_overloaded', message: 'x', now: T0 });
|
|
153
|
+
const b = lf.armLaunchBlock(a, { kind: 'api_overloaded', message: 'x', now: T0 + 1 });
|
|
154
|
+
const c = lf.armLaunchBlock(b, { kind: 'auth_failed', message: 'y', now: T0 + 2 });
|
|
155
|
+
expect(b.attempts).toBe(2);
|
|
156
|
+
expect(c.attempts).toBe(1);
|
|
157
|
+
expect(c.mitigationEnv).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('evaluateLaunchGate: open → blocked during backoff → probe after → blocked while a probe is in flight', () => {
|
|
161
|
+
expect(lf.evaluateLaunchGate(null, { now: T0 }).state).toBe('open');
|
|
162
|
+
const b = lf.armLaunchBlock(null, { kind: 'model_config_rejected', message: 'm', now: T0, claudeVersion: '1.0.90' });
|
|
163
|
+
expect(lf.evaluateLaunchGate(b, { now: T0 + 1000, claudeVersion: '1.0.90' }).state).toBe('blocked');
|
|
164
|
+
expect(lf.evaluateLaunchGate(b, { now: T0 + 1000 }).reason).toMatch(/re-probe in \d+ min/);
|
|
165
|
+
const afterBackoff = Date.parse(b.until) + 1;
|
|
166
|
+
expect(lf.evaluateLaunchGate(b, { now: afterBackoff, claudeVersion: '1.0.90' }).state).toBe('probe');
|
|
167
|
+
const probing = { ...b, probing: { slug: 'p', at: new Date(afterBackoff).toISOString() } };
|
|
168
|
+
const g = lf.evaluateLaunchGate(probing, { now: afterBackoff + 60_000, claudeVersion: '1.0.90' });
|
|
169
|
+
expect(g.state).toBe('blocked');
|
|
170
|
+
expect(g.reason).toMatch(/probe p in flight/);
|
|
171
|
+
// a probe that never reported back goes stale and the gate re-opens to a new probe
|
|
172
|
+
expect(lf.evaluateLaunchGate(probing, { now: afterBackoff + lf.LAUNCH_PROBE_STALE_MS + 1, claudeVersion: '1.0.90' }).state).toBe('probe');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('evaluateLaunchGate: a CLI version change short-circuits the backoff (the real fix for issue #11)', () => {
|
|
176
|
+
const b = lf.armLaunchBlock(null, { kind: 'model_config_rejected', message: 'm', now: T0, claudeVersion: '1.0.90' });
|
|
177
|
+
const g = lf.evaluateLaunchGate(b, { now: T0 + 1000, claudeVersion: '1.0.128' });
|
|
178
|
+
expect(g.state).toBe('open');
|
|
179
|
+
expect(g.reason).toMatch(/cli-version-changed/);
|
|
180
|
+
// unknown current version (probe failed) → no shortcut, backoff still holds
|
|
181
|
+
expect(lf.evaluateLaunchGate(b, { now: T0 + 1000, claudeVersion: null }).state).toBe('blocked');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('evaluateLaunchGate: an exhausted block stays blocked until version change', () => {
|
|
185
|
+
let b = null;
|
|
186
|
+
for (let i = 0; i < lf.LAUNCH_BLOCK_MAX_ATTEMPTS; i++) b = lf.armLaunchBlock(b, { kind: 'auth_failed', message: 'x', now: T0, claudeVersion: 'v1' });
|
|
187
|
+
expect(lf.evaluateLaunchGate(b, { now: T0 + 365 * 24 * 3600_000, claudeVersion: 'v1' }).state).toBe('blocked');
|
|
188
|
+
expect(lf.evaluateLaunchGate(b, { now: T0 + 1, claudeVersion: 'v2' }).state).toBe('open');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('launchBlockKeyFor: persona is the key, default when absent', () => {
|
|
192
|
+
expect(lf.launchBlockKeyFor({ agentType: 'dev-lead' })).toBe('dev-lead');
|
|
193
|
+
expect(lf.launchBlockKeyFor({})).toBe('default');
|
|
194
|
+
expect(lf.launchBlockKeyFor(null)).toBe('default');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('deriveTerminalReason: closed-set taxonomy', () => {
|
|
198
|
+
expect(lf.deriveTerminalReason({ effectiveStatus: 'completed', exitCode: 0 })).toBe('completed');
|
|
199
|
+
expect(lf.deriveTerminalReason({ effectiveStatus: 'failed', exitCode: 1 })).toBe('impl_failed:exit_1');
|
|
200
|
+
expect(lf.deriveTerminalReason({ effectiveStatus: 'failed', exitCode: 143 })).toBe('signal_kill');
|
|
201
|
+
expect(lf.deriveTerminalReason({ effectiveStatus: 'needs_review', exitCode: 143, sigtermOverride: { status: 'needs_review' } })).toBe('signal_kill_with_commit');
|
|
202
|
+
expect(lf.deriveTerminalReason({ effectiveStatus: 'needs_review', exitCode: 0, verifyResult: { verdict: 'silent_no_op' } })).toBe('verifier:silent_no_op');
|
|
203
|
+
expect(lf.deriveTerminalReason({ effectiveStatus: 'needs_review', exitCode: 0, worktreeIntegrationFailure: 'conflict' })).toBe('worktree_integration_failed');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('writeOutcomeSidecar: writes <slug>.outcome.json atomically and never throws on a bad dir', () => {
|
|
207
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-outcome-'));
|
|
208
|
+
try {
|
|
209
|
+
const p = lf.writeOutcomeSidecar(dir, '12-foo', { numTurns: 1, outputTokens: 0, terminalReason: 'launch_failure:model_config_rejected' });
|
|
210
|
+
expect(p).toBe(path.join(dir, '12-foo.outcome.json'));
|
|
211
|
+
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
212
|
+
expect(parsed).toMatchObject({ slug: '12-foo', numTurns: 1, outputTokens: 0, terminalReason: 'launch_failure:model_config_rejected' });
|
|
213
|
+
expect(parsed.writtenAt).toMatch(/^\d{4}-/);
|
|
214
|
+
expect(fs.existsSync(`${p}.tmp`)).toBe(false);
|
|
215
|
+
} finally {
|
|
216
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
expect(lf.writeOutcomeSidecar('/nonexistent/dir/for/sure', 'x', {})).toBeNull();
|
|
219
|
+
expect(lf.writeOutcomeSidecar(null, 'x', {})).toBeNull();
|
|
220
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Run: timeout 120 npx vitest run src/main/lib/__tests__/loadGate.test.cjs
|
|
3
|
+
//
|
|
4
|
+
// PRD 1085 — CPU-load launch gate. The static per-project cap
|
|
5
|
+
// (schedulerBatchProjectCap.test.cjs) cannot see the feedback loop observed
|
|
6
|
+
// 2026-09-01 on starry-night-ships: 4 Godot test batteries under Xvfb,
|
|
7
|
+
// loadavg 12.95 / 14 cores = 0.93, every battery stretching past its
|
|
8
|
+
// executor's timeout and spawning fix-chain reruns that launch more
|
|
9
|
+
// batteries. These tests pin the pure decision, the audit rate-limit and the
|
|
10
|
+
// escalation, with loadavg/cores/clock all injected.
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const { isLoadGated, createLoadGate, AUDIT_INTERVAL_MS } = require('../loadGate.cjs');
|
|
13
|
+
const { loadGateThreshold, LOAD_GATE_PER_CORE } = require('../schedulerConfig.cjs');
|
|
14
|
+
|
|
15
|
+
const ORIGINAL_ENV = process.env.SM_LOAD_GATE_PER_CORE;
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (ORIGINAL_ENV === undefined) delete process.env.SM_LOAD_GATE_PER_CORE;
|
|
18
|
+
else process.env.SM_LOAD_GATE_PER_CORE = ORIGINAL_ENV;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// ─── isLoadGated ────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
test('the live incident shape gates: 12.95 / 14 cores = 0.93 > 0.85', () => {
|
|
24
|
+
assert.equal(isLoadGated(12.95, 14, 0.85), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('below threshold does not gate', () => {
|
|
28
|
+
assert.equal(isLoadGated(5, 14, 0.85), false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('exactly AT threshold does not gate (strictly greater)', () => {
|
|
32
|
+
assert.equal(isLoadGated(0.85 * 14, 14, 0.85), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('zero cores never gates (unknown topology must not wedge the queue)', () => {
|
|
36
|
+
assert.equal(isLoadGated(100, 0, 0.85), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('loadavg [0,0,0] (Windows / unsupported) never gates', () => {
|
|
40
|
+
assert.equal(isLoadGated(0, 14, 0.85), false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a disabled threshold (0) never gates regardless of load', () => {
|
|
44
|
+
assert.equal(isLoadGated(1000, 1, 0), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── loadGateThreshold (env) ────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
test('default threshold is the documented 0.85', () => {
|
|
50
|
+
delete process.env.SM_LOAD_GATE_PER_CORE;
|
|
51
|
+
assert.equal(LOAD_GATE_PER_CORE, 0.85);
|
|
52
|
+
assert.equal(loadGateThreshold(), 0.85);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('SM_LOAD_GATE_PER_CORE is honored and clamped to [0.25, 4]; 0 disables; garbage falls back', () => {
|
|
56
|
+
process.env.SM_LOAD_GATE_PER_CORE = '1.5';
|
|
57
|
+
assert.equal(loadGateThreshold(), 1.5);
|
|
58
|
+
process.env.SM_LOAD_GATE_PER_CORE = '0.01';
|
|
59
|
+
assert.equal(loadGateThreshold(), 0.25);
|
|
60
|
+
process.env.SM_LOAD_GATE_PER_CORE = '99';
|
|
61
|
+
assert.equal(loadGateThreshold(), 4);
|
|
62
|
+
process.env.SM_LOAD_GATE_PER_CORE = '0';
|
|
63
|
+
assert.equal(loadGateThreshold(), 0);
|
|
64
|
+
process.env.SM_LOAD_GATE_PER_CORE = 'banana';
|
|
65
|
+
assert.equal(loadGateThreshold(), 0.85);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ─── createLoadGate: decision, 1-minute-only, bypass ───────────────────────
|
|
69
|
+
|
|
70
|
+
function gateWith({ l1 = 12.95, l5 = 0, l15 = 0, cores = 14, threshold = 0.85, clock }) {
|
|
71
|
+
let t = clock ?? 0;
|
|
72
|
+
const g = createLoadGate({
|
|
73
|
+
loadavg: () => [l1, l5, l15],
|
|
74
|
+
cores: () => cores,
|
|
75
|
+
now: () => t,
|
|
76
|
+
threshold,
|
|
77
|
+
});
|
|
78
|
+
return { g, tick: (ms) => { t += ms; } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test('evaluate() gates on the incident shape and reports ratio/threshold', () => {
|
|
82
|
+
const { g } = gateWith({});
|
|
83
|
+
const r = g.evaluate();
|
|
84
|
+
assert.equal(r.gated, true);
|
|
85
|
+
assert.equal(r.bypassed, false);
|
|
86
|
+
assert.equal(r.ratio, 0.925);
|
|
87
|
+
assert.equal(r.threshold, 0.85);
|
|
88
|
+
assert.equal(r.loadavg1, 12.95);
|
|
89
|
+
assert.equal(r.cores, 14);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('only the 1-minute average is consulted — a saturated 5/15-minute history with a quiet last minute launches', () => {
|
|
93
|
+
const { g } = gateWith({ l1: 2, l5: 13, l15: 13 });
|
|
94
|
+
assert.equal(g.evaluate().gated, false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an explicit Run now bypasses the gate but records that it did', () => {
|
|
98
|
+
const { g } = gateWith({});
|
|
99
|
+
const r = g.evaluate({ bypass: true });
|
|
100
|
+
assert.equal(r.gated, false);
|
|
101
|
+
assert.equal(r.bypassed, true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('bypass on an UNgated tick is not reported as a bypass', () => {
|
|
105
|
+
const { g } = gateWith({ l1: 1 });
|
|
106
|
+
const r = g.evaluate({ bypass: true });
|
|
107
|
+
assert.deepEqual([r.gated, r.bypassed], [false, false]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ─── audit rate limit + escalation (fake clock) ─────────────────────────────
|
|
111
|
+
|
|
112
|
+
test('audits once, then not again until AUDIT_INTERVAL_MS has elapsed', () => {
|
|
113
|
+
const { g, tick } = gateWith({});
|
|
114
|
+
assert.equal(g.evaluate().shouldAudit, true, 'first gated tick audits');
|
|
115
|
+
tick(60_000);
|
|
116
|
+
assert.equal(g.evaluate().shouldAudit, false, '1 min later: silent');
|
|
117
|
+
tick(AUDIT_INTERVAL_MS - 60_000 - 1);
|
|
118
|
+
assert.equal(g.evaluate().shouldAudit, false, 'just under the interval: silent');
|
|
119
|
+
tick(1);
|
|
120
|
+
assert.equal(g.evaluate().shouldAudit, true, 'at the interval: audits again');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('an ungated tick never audits', () => {
|
|
124
|
+
const { g } = gateWith({ l1: 1 });
|
|
125
|
+
assert.equal(g.evaluate().shouldAudit, false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('escalates once the gated stretch exceeds the escalation window, and the stretch resets when load drops', () => {
|
|
129
|
+
let l1 = 12.95;
|
|
130
|
+
let t = 0;
|
|
131
|
+
const g = createLoadGate({
|
|
132
|
+
loadavg: () => [l1, 0, 0],
|
|
133
|
+
cores: () => 14,
|
|
134
|
+
now: () => t,
|
|
135
|
+
threshold: 0.85,
|
|
136
|
+
escalateAfterMs: 45 * 60_000,
|
|
137
|
+
});
|
|
138
|
+
assert.equal(g.evaluate().escalate, false);
|
|
139
|
+
t += 44 * 60_000;
|
|
140
|
+
assert.equal(g.evaluate().escalate, false, 'under 45m: no escalation');
|
|
141
|
+
t += 60_000;
|
|
142
|
+
const r = g.evaluate();
|
|
143
|
+
assert.equal(r.escalate, true, 'at 45m: escalates');
|
|
144
|
+
assert.equal(r.gatedSinceMs, 45 * 60_000);
|
|
145
|
+
l1 = 1; t += 60_000;
|
|
146
|
+
assert.equal(g.evaluate().gatedSinceMs, 0, 'load dropped: stretch resets');
|
|
147
|
+
l1 = 12.95; t += 60_000;
|
|
148
|
+
assert.equal(g.evaluate().gatedSinceMs, 0, 'a fresh stretch starts from zero');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('snapshot() reflects the last evaluation and is null before any', () => {
|
|
152
|
+
const { g } = gateWith({});
|
|
153
|
+
assert.equal(g.snapshot(), null);
|
|
154
|
+
g.evaluate();
|
|
155
|
+
const s = g.snapshot();
|
|
156
|
+
assert.equal(s.gated, true);
|
|
157
|
+
assert.equal(typeof s.at, 'string');
|
|
158
|
+
assert.equal('shouldAudit' in s, false, 'per-tick flags are not part of the persisted snapshot');
|
|
159
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcpToolCatalog.test.cjs — parity + shape gate for PRD (mcpToolCatalog):
|
|
3
|
+
* asserts the catalog and the live scheduler-mcp-server.cjs TOOLS array can
|
|
4
|
+
* never drift apart, that every catalog entry is a well-formed
|
|
5
|
+
* documentation record, and that each tool's exampleArgs is actually
|
|
6
|
+
* runnable against that tool's own inputSchema.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/mcpToolCatalog.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect } from 'vitest';
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { MCP_TOOL_CATALOG, MCP_RECIPES, CatalogEntrySchema, composeDescription } = require('../mcpToolCatalog.cjs');
|
|
15
|
+
// scheduler-mcp-server.cjs guards its stdio-server main() behind
|
|
16
|
+
// `require.main === module`, so requiring it here (require.main is the test
|
|
17
|
+
// runner, not this file) just loads TOOLS without opening a stdio transport.
|
|
18
|
+
const { TOOLS } = require(path.join(__dirname, '../../../../scripts/scheduler-mcp-server.cjs'));
|
|
19
|
+
|
|
20
|
+
const LIVE_MCP_TOOL_NAMES = TOOLS.map((t) => t.name);
|
|
21
|
+
|
|
22
|
+
test('every live MCP tool name has a catalog entry', () => {
|
|
23
|
+
const catalogNames = new Set(MCP_TOOL_CATALOG.map((e) => e.name));
|
|
24
|
+
const missing = LIVE_MCP_TOOL_NAMES.filter((n) => !catalogNames.has(n));
|
|
25
|
+
expect(missing).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('every catalog entry corresponds to a live MCP tool', () => {
|
|
29
|
+
const liveNames = new Set(LIVE_MCP_TOOL_NAMES);
|
|
30
|
+
const extra = MCP_TOOL_CATALOG.filter((e) => !liveNames.has(e.name)).map((e) => e.name);
|
|
31
|
+
expect(extra).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test.each(TOOLS)('$name description is composed from the catalog, not a separate literal', (tool) => {
|
|
35
|
+
const entry = MCP_TOOL_CATALOG.find((e) => e.name === tool.name);
|
|
36
|
+
expect(entry).toBeTruthy();
|
|
37
|
+
expect(tool.description).toBe(composeDescription(entry));
|
|
38
|
+
expect(tool.description.length).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test.each(MCP_TOOL_CATALOG)('$name has all required catalog fields', (entry) => {
|
|
42
|
+
expect(() => CatalogEntrySchema.parse(entry)).not.toThrow();
|
|
43
|
+
expect(entry.name).toBeTruthy();
|
|
44
|
+
expect(entry.group).toBeTruthy();
|
|
45
|
+
expect(entry.purpose).toBeTruthy();
|
|
46
|
+
expect(entry.whenToUse).toBeTruthy();
|
|
47
|
+
expect(entry.whenNotToUse).toBeTruthy();
|
|
48
|
+
expect(entry.exampleArgs).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a catalog entry missing a required field fails schema validation', () => {
|
|
52
|
+
const broken = {
|
|
53
|
+
name: 'some_tool',
|
|
54
|
+
group: 'scheduler',
|
|
55
|
+
purpose: '',
|
|
56
|
+
whenToUse: 'x',
|
|
57
|
+
whenNotToUse: 'y',
|
|
58
|
+
exampleArgs: {},
|
|
59
|
+
notes: null,
|
|
60
|
+
};
|
|
61
|
+
expect(() => CatalogEntrySchema.parse(broken)).toThrow();
|
|
62
|
+
|
|
63
|
+
const missingField = {
|
|
64
|
+
name: 'some_tool',
|
|
65
|
+
group: 'scheduler',
|
|
66
|
+
purpose: 'x',
|
|
67
|
+
whenToUse: 'y',
|
|
68
|
+
// whenNotToUse omitted entirely
|
|
69
|
+
exampleArgs: {},
|
|
70
|
+
notes: null,
|
|
71
|
+
};
|
|
72
|
+
expect(() => CatalogEntrySchema.parse(missingField)).toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test.each(MCP_TOOL_CATALOG)('$name composeDescription joins purpose/whenToUse/whenNotToUse/notes deterministically', (entry) => {
|
|
76
|
+
const expected = [entry.purpose, entry.whenToUse, entry.whenNotToUse, entry.notes].filter(Boolean).join(' ');
|
|
77
|
+
expect(composeDescription(entry)).toBe(expected);
|
|
78
|
+
expect(composeDescription(entry).length).toBeGreaterThan(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('MCP_RECIPES covers queue-work, unstick-needs-review, and hand-off-to-another-project', () => {
|
|
82
|
+
const ids = MCP_RECIPES.map((r) => r.id);
|
|
83
|
+
expect(ids).toContain('queue-work-via-develop');
|
|
84
|
+
expect(ids).toContain('unstick-needs-review-job');
|
|
85
|
+
expect(ids).toContain('hand-finding-to-another-project');
|
|
86
|
+
expect(ids).toContain('generate-project-home');
|
|
87
|
+
for (const recipe of MCP_RECIPES) {
|
|
88
|
+
expect(recipe.title).toBeTruthy();
|
|
89
|
+
expect(Array.isArray(recipe.steps)).toBe(true);
|
|
90
|
+
expect(recipe.steps.length).toBeGreaterThan(0);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test.each(MCP_TOOL_CATALOG)('$name exampleArgs satisfies its own inputSchema required fields', (entry) => {
|
|
95
|
+
const tool = TOOLS.find((t) => t.name === entry.name);
|
|
96
|
+
expect(tool).toBeTruthy();
|
|
97
|
+
const required = tool.inputSchema?.required ?? [];
|
|
98
|
+
for (const key of required) {
|
|
99
|
+
expect(Object.prototype.hasOwnProperty.call(entry.exampleArgs, key)).toBe(true);
|
|
100
|
+
expect(entry.exampleArgs[key]).not.toBeUndefined();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
@@ -27,6 +27,12 @@ test('the owner of a namespace may write it', () => {
|
|
|
27
27
|
assert.equal(checkOpsWrite(`${P}/prompt-sessions/active-index.json`, 'epics').ok, true);
|
|
28
28
|
assert.equal(checkOpsWrite(`${P}/scheduler/state/queue.json`, 'scheduler').ok, true);
|
|
29
29
|
assert.equal(checkOpsWrite(`${P}/project-brief/brief.json`, 'project-home').ok, true);
|
|
30
|
+
// project-pages: declared owner is 'project-home', same as project-brief —
|
|
31
|
+
// this governs only the app's own /admin/project-home/render write path
|
|
32
|
+
// (config.cjs), NOT a project-home-builder Epic's own Write-tool authoring
|
|
33
|
+
// of summary.json/picks.json, which never goes through config.cjs at all
|
|
34
|
+
// and so is unaffected by this table either way (see project-pages/README.md).
|
|
35
|
+
assert.equal(checkOpsWrite(`${P}/project-pages/output/manifest.json`, 'project-home').ok, true);
|
|
30
36
|
});
|
|
31
37
|
|
|
32
38
|
test('a non-owner is refused, and the error names the owner', () => {
|
|
@@ -36,6 +42,7 @@ test('a non-owner is refused, and the error names the owner', () => {
|
|
|
36
42
|
// The exact cross-write this law exists to prevent: another surface
|
|
37
43
|
// rewriting the Epic store out from under Epics.
|
|
38
44
|
assert.equal(checkOpsWrite(`${P}/prompt-sessions/psess-1.json`, 'scheduler').ok, false);
|
|
45
|
+
assert.equal(checkOpsWrite(`${P}/project-pages/output/manifest.json`, 'scheduler').ok, false);
|
|
39
46
|
});
|
|
40
47
|
|
|
41
48
|
test('an undeclared writer is refused (fail-closed)', () => {
|