claude-code-session-manager 0.89.0 → 0.90.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-tuj-j1kQ.js → AgentLibrary-CrmoSn9-.js} +1 -1
- package/dist/assets/DataModel-SPLQTR8E.js +1 -0
- package/dist/assets/{History-DylcasUk.js → History-DT6S3abz.js} +2 -2
- package/dist/assets/{Hooks--2xoag9a.js → Hooks-CC2iv5Oq.js} +3 -3
- package/dist/assets/{HostBilko-oaw8paAh.js → HostBilko-DqY7dA2a.js} +1 -1
- package/dist/assets/{Library-DzKA09Iy.js → Library-CUoOC77T.js} +1 -1
- package/dist/assets/{ListDetail-CEdcO80v.js → ListDetail-DgrG72f4.js} +1 -1
- package/dist/assets/MarkdownEditor-C8dB3ZWU.js +1 -0
- package/dist/assets/{McpServers-BB7ayW7W.js → McpServers-DupdjvFt.js} +2 -2
- package/dist/assets/{Memory-BOdYvrL4.js → Memory-C_4LhC2r.js} +6 -6
- package/dist/assets/{Panel-Yf3SxsRv.js → Panel-DWmE5Fu9.js} +1 -1
- package/dist/assets/{Permissions-BYCpHNy5.js → Permissions-hxiN2IWM.js} +3 -3
- package/dist/assets/{Plugins-B0lXiYqV.js → Plugins-fttEgZqy.js} +2 -2
- package/dist/assets/{ProvenanceBadge-T7x8LWcV.js → ProvenanceBadge-DROCzlbx.js} +1 -1
- package/dist/assets/{SaveBar-BdVb4DbR.js → SaveBar-BZs0IQHq.js} +1 -1
- package/dist/assets/Scheduler-Bi4oZ8gg.js +16 -0
- package/dist/assets/{ScopeSwitcher-WVhmNsL5.js → ScopeSwitcher-C0538XU8.js} +1 -1
- package/dist/assets/{Settings-Bt4dVsl6.js → Settings-CcW6x-wu.js} +2 -2
- package/dist/assets/{SkillReferenceGraph-R-0YykV8.js → SkillReferenceGraph-BtCcPAYO.js} +1 -1
- package/dist/assets/{Skills-DXV_LdJP.js → Skills-Daf_1sQR.js} +2 -2
- package/dist/assets/SystemPrompt-rYIla_wt.js +1 -0
- package/dist/assets/{TagLibrary-B9OuqU6M.js → TagLibrary-81Mr1RLU.js} +1 -1
- package/dist/assets/{TiptapBody-HKKmB0AE.js → TiptapBody-BxuLNg1e.js} +1 -1
- package/dist/assets/{Toggle-CBggPonH.js → Toggle-CN8EVEai.js} +1 -1
- package/dist/assets/{index-BcJqLDiY.js → index-A9TWmB7_.js} +345 -345
- package/dist/assets/{index-BHTX4OTc.css → index-Dh7BLxdA.css} +1 -1
- package/dist/assets/{settingsSchema-BYE9DVw_.js → settingsSchema-C9I9a0jv.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/README.md +1 -0
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
- package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
- package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
- package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
- package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +108 -45
- package/src/main/health.cjs +20 -3
- package/src/main/ipcSchemas.cjs +3 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
- package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
- package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
- package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
- package/src/main/lib/effectiveModelInfo.cjs +3 -3
- package/src/main/lib/epicDelegationStats.cjs +3 -2
- package/src/main/lib/epicSpawnCwd.cjs +36 -5
- package/src/main/lib/epicSpawnPlan.cjs +119 -0
- package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
- package/src/main/lib/epicTranscriptPath.cjs +184 -0
- package/src/main/lib/gitWorktree.cjs +1 -1
- package/src/main/lib/mcpToolCatalog.cjs +6 -1
- package/src/main/lib/prdCreate.cjs +27 -1
- package/src/main/lib/prdFrontmatter.cjs +16 -5
- package/src/main/lib/rcaReport.cjs +27 -3
- package/src/main/lib/schedulerPaths.cjs +27 -4
- package/src/main/lib/telemetryClient.cjs +11 -4
- package/src/main/lib/terminalRunOutcome.cjs +1 -0
- package/src/main/pty.cjs +1 -1
- package/src/main/runVerify.cjs +89 -1
- package/src/main/scheduler.cjs +17 -4
- package/src/main/templates/PRD_AUTHORING.md +11 -0
- package/src/main/transcripts.cjs +28 -7
- package/src/preload/api.d.ts +2 -0
- package/src/preload/index.cjs +1 -0
- package/dist/assets/DataModel-DOim5SXW.js +0 -1
- package/dist/assets/MarkdownEditor-BMyo0F7s.js +0 -1
- package/dist/assets/Scheduler-DlOCWioV.js +0 -14
- package/dist/assets/SystemPrompt-DiQRo_WF.js +0 -1
package/src/main/chatRunner.cjs
CHANGED
|
@@ -53,7 +53,14 @@ const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
|
|
|
53
53
|
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
54
54
|
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
55
55
|
const agentModelResolve = require('./lib/agentModelResolve.cjs');
|
|
56
|
-
const {
|
|
56
|
+
const { planEpicSpawn } = require('./lib/epicSpawnPlan.cjs');
|
|
57
|
+
const logs = require('./logs.cjs');
|
|
58
|
+
|
|
59
|
+
// The only two CLI errors that mean "wrong session flag" (verified against the
|
|
60
|
+
// real CLI). "in use" → the transcript exists, so resume; "no conversation" →
|
|
61
|
+
// nothing to resume, so create. Never widen this into a general retry.
|
|
62
|
+
const SESSION_IN_USE_RE = /Session ID .* is already in use/i;
|
|
63
|
+
const NO_CONVERSATION_RE = /No conversation found with session ID/i;
|
|
57
64
|
|
|
58
65
|
// ─── Stop-signal protocol ──────────────────────────────────────────────────
|
|
59
66
|
// Single source of truth for the sentinel and parser. The renderer (PRD 320)
|
|
@@ -540,21 +547,43 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
540
547
|
// must never be left unpinned (CLAUDE.md model-pinning rule).
|
|
541
548
|
const model = agentModelResolve.resolveEpicModel({ cwd, claudeSessionId: sessionId });
|
|
542
549
|
|
|
543
|
-
//
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
'
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
550
|
+
// Main is authoritative about --resume vs --session-id AND the spawn cwd, and it decides
|
|
551
|
+
// them TOGETHER (epicSpawnPlan.cjs): a transcript under an encoding the spawn cwd cannot see
|
|
552
|
+
// must never yield a claimed flag. Unreachable / closed Epics are refused before any spawn.
|
|
553
|
+
const plan = planEpicSpawn({ cwd, claudeSessionId: sessionId, fallbackResume: !!resume });
|
|
554
|
+
if (!plan.ok) {
|
|
555
|
+
emitTerminal('chat:run:error', { tabId, sessionId, code: plan.code, message: plan.message });
|
|
556
|
+
settle();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const useResume = plan.useResume;
|
|
560
|
+
if (useResume !== !!resume) {
|
|
561
|
+
try {
|
|
562
|
+
logs.writeLine({
|
|
563
|
+
scope: 'chatRunner',
|
|
564
|
+
level: 'warn',
|
|
565
|
+
message: `session flag mismatch: renderer resume=${!!resume}, main resume=${useResume}; transcript=${plan.transcriptPath ?? 'none'}`,
|
|
566
|
+
meta: { tabId, sessionId, rendererResume: !!resume, mainResume: useResume, transcriptPath: plan.transcriptPath ?? null },
|
|
567
|
+
});
|
|
568
|
+
} catch { /* logging must never fail a turn */ }
|
|
556
569
|
}
|
|
557
570
|
|
|
571
|
+
// Build argv as an array — no shell: true, no string interpolation. Also
|
|
572
|
+
// used for the flag-swap retry so both attempts share one construction path.
|
|
573
|
+
const buildArgs = (resumeFlag) => {
|
|
574
|
+
const a = [
|
|
575
|
+
'-p', fullPrompt,
|
|
576
|
+
'--model', model,
|
|
577
|
+
'--dangerously-skip-permissions',
|
|
578
|
+
'--output-format', 'stream-json',
|
|
579
|
+
'--verbose',
|
|
580
|
+
];
|
|
581
|
+
// --resume carries the session context; no --session-id needed
|
|
582
|
+
if (resumeFlag) a.push('--resume', sessionId);
|
|
583
|
+
else a.push('--session-id', sessionId);
|
|
584
|
+
return a;
|
|
585
|
+
};
|
|
586
|
+
|
|
558
587
|
if (!silent) broadcast('chat:run:started', { tabId, sessionId });
|
|
559
588
|
|
|
560
589
|
// sessionId IS the Epic's claudeSessionId for an Epic-backed tab (see
|
|
@@ -564,36 +593,14 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
564
593
|
// below keeps using it exactly as before — only this ACTUAL spawn cwd
|
|
565
594
|
// option does. See epicSpawnCwd.cjs's header comment (the ops-root
|
|
566
595
|
// hazard) and jobWorktree.cjs's own (execCwd vs job.cwd) for why.
|
|
567
|
-
const execCwd =
|
|
596
|
+
const execCwd = plan.execCwd;
|
|
568
597
|
|
|
569
|
-
// Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
|
|
570
|
-
// "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
|
|
571
|
-
// real-time NDJSON streaming; stderr piped for error-message capture.
|
|
572
|
-
let child;
|
|
573
|
-
try {
|
|
574
|
-
const target = claudeSpawnTarget('chat', sessionId, claudeBin);
|
|
575
|
-
child = spawn(target.command, args, {
|
|
576
|
-
...(target.argv0 ? { argv0: target.argv0 } : {}),
|
|
577
|
-
cwd: execCwd,
|
|
578
|
-
env: childEnv,
|
|
579
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
580
|
-
detached: true, // own process group so killTree can SIGTERM descendants
|
|
581
|
-
});
|
|
582
|
-
} catch (err) {
|
|
583
|
-
emitTerminal('chat:run:error', {
|
|
584
|
-
tabId,
|
|
585
|
-
sessionId,
|
|
586
|
-
message: `spawn failed: ${err?.message ?? String(err)}`,
|
|
587
|
-
});
|
|
588
|
-
settle();
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// One-shot kill helper — idempotent via the `killed` flag
|
|
593
598
|
let killed = false;
|
|
599
|
+
let child = null;
|
|
594
600
|
const doKill = (sig) => {
|
|
595
601
|
if (killed) return;
|
|
596
602
|
killed = true;
|
|
603
|
+
if (!child) return;
|
|
597
604
|
// Negative pid targets the process group (requires detached: true)
|
|
598
605
|
try { process.kill(-child.pid, sig); }
|
|
599
606
|
catch { try { child.kill(sig); } catch { /* already dead */ } }
|
|
@@ -647,10 +654,40 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
647
654
|
}, KILL_CEILING_MS);
|
|
648
655
|
if (killTimer.unref) killTimer.unref();
|
|
649
656
|
|
|
657
|
+
// One spawn of the CLI. Per-attempt state (buffers, child) lives in here;
|
|
658
|
+
// everything turn-level (timers, latch, lane) is in the enclosing scope.
|
|
659
|
+
const startAttempt = (resumeFlag, attemptNo) => {
|
|
660
|
+
// Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
|
|
661
|
+
// "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
|
|
662
|
+
// real-time NDJSON streaming; stderr piped for error-message capture.
|
|
663
|
+
let thisChild;
|
|
664
|
+
try {
|
|
665
|
+
const target = claudeSpawnTarget('chat', sessionId, claudeBin);
|
|
666
|
+
thisChild = spawn(target.command, buildArgs(resumeFlag), {
|
|
667
|
+
...(target.argv0 ? { argv0: target.argv0 } : {}),
|
|
668
|
+
cwd: execCwd,
|
|
669
|
+
env: childEnv,
|
|
670
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
671
|
+
detached: true, // own process group so killTree can SIGTERM descendants
|
|
672
|
+
});
|
|
673
|
+
} catch (err) {
|
|
674
|
+
clearTimeout(killTimer);
|
|
675
|
+
clearTimeout(warnTimer);
|
|
676
|
+
emitTerminal('chat:run:error', {
|
|
677
|
+
tabId,
|
|
678
|
+
sessionId,
|
|
679
|
+
message: `spawn failed: ${err?.message ?? String(err)}`,
|
|
680
|
+
});
|
|
681
|
+
settle();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
child = thisChild;
|
|
685
|
+
|
|
650
686
|
// ─── Stream parsing ────────────────────────────────────────────────────
|
|
651
687
|
// stdout is newline-delimited JSON (stream-json format). We buffer partial
|
|
652
688
|
// lines across TCP/pipe chunks. O(output-size) in memory per run.
|
|
653
689
|
|
|
690
|
+
// Per-attempt state — a retry starts from empty buffers.
|
|
654
691
|
let lineBuffer = '';
|
|
655
692
|
let finalAssistantText = '';
|
|
656
693
|
let stderrBuffer = '';
|
|
@@ -738,7 +775,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
738
775
|
}
|
|
739
776
|
};
|
|
740
777
|
|
|
741
|
-
|
|
778
|
+
thisChild.stdout.on('data', (chunk) => {
|
|
742
779
|
lineBuffer += chunk.toString('utf8');
|
|
743
780
|
let nl;
|
|
744
781
|
while ((nl = lineBuffer.indexOf('\n')) !== -1) {
|
|
@@ -747,11 +784,11 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
747
784
|
}
|
|
748
785
|
});
|
|
749
786
|
|
|
750
|
-
|
|
787
|
+
thisChild.stderr.on('data', (chunk) => {
|
|
751
788
|
stderrBuffer += chunk.toString('utf8');
|
|
752
789
|
});
|
|
753
790
|
|
|
754
|
-
|
|
791
|
+
thisChild.on('error', (err) => {
|
|
755
792
|
clearTimeout(killTimer);
|
|
756
793
|
clearTimeout(warnTimer);
|
|
757
794
|
emitTerminal('chat:run:error', {
|
|
@@ -768,12 +805,35 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
768
805
|
// line) reaches the 'data' handler above, which raced this fallback into
|
|
769
806
|
// firing first and dropping the real chat:run:complete behind the
|
|
770
807
|
// terminalSent latch.
|
|
771
|
-
|
|
772
|
-
clearTimeout(killTimer);
|
|
773
|
-
clearTimeout(warnTimer);
|
|
808
|
+
thisChild.on('close', (code, signal) => {
|
|
774
809
|
// Flush any partial line that didn't end with \n
|
|
775
810
|
if (lineBuffer.trim()) processLine(lineBuffer.trim());
|
|
776
811
|
|
|
812
|
+
// Flag-swap retry: the CLI rejected the session flag before doing any
|
|
813
|
+
// work. Retry exactly once with the other flag; the timers, lane and
|
|
814
|
+
// terminal latch stay at executeRun level, so this is still one turn.
|
|
815
|
+
if (!terminalSent && !killed && code !== 0 && attemptNo === 1
|
|
816
|
+
&& finalAssistantText === '' && recentToolUses.length === 0
|
|
817
|
+
&& !/rate.?limit/i.test(stderrBuffer)) {
|
|
818
|
+
const swapTo = resumeFlag
|
|
819
|
+
? (NO_CONVERSATION_RE.test(stderrBuffer) ? false : null)
|
|
820
|
+
: (SESSION_IN_USE_RE.test(stderrBuffer) ? true : null);
|
|
821
|
+
if (swapTo !== null) {
|
|
822
|
+
if (!silent) {
|
|
823
|
+
broadcast('chat:run:notice', {
|
|
824
|
+
tabId,
|
|
825
|
+
sessionId,
|
|
826
|
+
message: `Session flag mismatch (${resumeFlag ? '--resume' : '--session-id'} rejected); retrying once with ${swapTo ? '--resume' : '--session-id'}.`,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
startAttempt(swapTo, 2);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
clearTimeout(killTimer);
|
|
835
|
+
clearTimeout(warnTimer);
|
|
836
|
+
|
|
777
837
|
// Emit a fallback terminal event for any run that ended without one — a
|
|
778
838
|
// cancel/SIGTERM (killed=true), a crash before a result, or a silent
|
|
779
839
|
// exit. `emitTerminal` no-ops if the run already sent complete/needs-
|
|
@@ -799,6 +859,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
799
859
|
}
|
|
800
860
|
settle();
|
|
801
861
|
});
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
startAttempt(useResume, 1);
|
|
802
865
|
});
|
|
803
866
|
}
|
|
804
867
|
|
package/src/main/health.cjs
CHANGED
|
@@ -25,6 +25,7 @@ const { auditLogPath, readTail } = require('./lib/auditLog.cjs');
|
|
|
25
25
|
const { resolveBuildIdentity } = require('./lib/buildIdentity.cjs');
|
|
26
26
|
const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
|
|
27
27
|
const { allProjectCwds } = require('./lib/activeSessions.cjs');
|
|
28
|
+
const { scanEpicTranscripts } = require('./lib/epicTranscriptDiagnostic.cjs');
|
|
28
29
|
|
|
29
30
|
const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
|
|
30
31
|
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
@@ -716,7 +717,10 @@ function evaluateBlockingParkHealth(jobs, now, thresholdMs = REVERIFY_INTERVAL_M
|
|
|
716
717
|
};
|
|
717
718
|
}
|
|
718
719
|
|
|
719
|
-
|
|
720
|
+
// opts.skipTypecheck: skip the whole-repo `tsc` shell-out (~15s idle, unbounded under CPU load).
|
|
721
|
+
// For callers that assert on one other component; the typescript component is then
|
|
722
|
+
// reported { ok: true, skipped: true } so it can never read as a real pass.
|
|
723
|
+
async function check(opts = {}) {
|
|
720
724
|
const start = Date.now();
|
|
721
725
|
const status = {
|
|
722
726
|
ok: true,
|
|
@@ -736,8 +740,8 @@ async function check() {
|
|
|
736
740
|
}
|
|
737
741
|
|
|
738
742
|
// 1.5. Check TypeScript compilation (no errors).
|
|
739
|
-
const typesOk = runCheck('npm run typecheck 2>&1 | grep -q "error" && exit 1 || exit 0');
|
|
740
|
-
status.components.typescript = { ok: typesOk };
|
|
743
|
+
const typesOk = opts.skipTypecheck ? true : runCheck('npm run typecheck 2>&1 | grep -q "error" && exit 1 || exit 0');
|
|
744
|
+
status.components.typescript = opts.skipTypecheck ? { ok: true, skipped: true } : { ok: typesOk };
|
|
741
745
|
if (!typesOk) {
|
|
742
746
|
status.issues.push('TypeScript compilation has errors');
|
|
743
747
|
status.ok = false;
|
|
@@ -1146,6 +1150,19 @@ async function check() {
|
|
|
1146
1150
|
status.issues.push(`Epic index health check failed: ${e.message}`);
|
|
1147
1151
|
}
|
|
1148
1152
|
|
|
1153
|
+
// 6.66. Epic transcript location (NON-fatal warning): a mislocated/duplicated
|
|
1154
|
+
// transcript is read correctly by epicTranscriptPath, so it never reddens the
|
|
1155
|
+
// rollup (not in criticalComponents). See scripts/check-epic-transcripts.cjs.
|
|
1156
|
+
try {
|
|
1157
|
+
const findings = scanEpicTranscripts({ cwds: allProjectCwds() });
|
|
1158
|
+
status.components.epic_transcripts = { ok: true, findings: findings.length, duplicated: findings.filter((f) => f.classification === 'duplicated').length };
|
|
1159
|
+
if (findings.length > 0) {
|
|
1160
|
+
status.issues.push(`Warning: ${findings.length} Epic transcript(s) mislocated/duplicated — run: node scripts/check-epic-transcripts.cjs`);
|
|
1161
|
+
}
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
status.components.epic_transcripts = { ok: true, error: e.message };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1149
1166
|
// 6.7. Build freshness (informational): running heartbeat vs installed
|
|
1150
1167
|
// build-info vs repo HEAD.
|
|
1151
1168
|
try {
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -351,6 +351,9 @@ const schedulerCreatePrd = z.object({
|
|
|
351
351
|
// createPrd() requires it in that situation for an interactive caller and
|
|
352
352
|
// defaults to 'append' (logged as a default) for a non-interactive one.
|
|
353
353
|
disposition: PrdDispositionSchema.optional(),
|
|
354
|
+
// Artifact-only PRD declaration — cross-field rules enforced in prdCreate.cjs.
|
|
355
|
+
deliverable: z.literal('artifact').optional(),
|
|
356
|
+
artifactPaths: z.array(z.string().min(1).max(1024).regex(NO_NEWLINE_RE, 'must not contain newlines')).max(50).optional(),
|
|
354
357
|
// An EXISTING Epic's promptSessionId (PRD 748) — NOT a PromptTicket.id.
|
|
355
358
|
// ensureEpic() (epicMint.cjs) joins the Epic whose active-index.json
|
|
356
359
|
// sessions key literally equals this value; the renderer only ever sends
|
|
@@ -191,3 +191,37 @@ test('a real, existing directory IS returned — the check gates on disk state,
|
|
|
191
191
|
fs.rmSync(realDir, { recursive: true, force: true });
|
|
192
192
|
}
|
|
193
193
|
});
|
|
194
|
+
|
|
195
|
+
// ─── spawn-side: a vanished worktree is restored at the SAME path, never swapped for cwd ───
|
|
196
|
+
|
|
197
|
+
test('restore:true re-attaches the worktree and returns the recorded dir (transcript encoding unchanged)', () => {
|
|
198
|
+
const calls = [];
|
|
199
|
+
const result = resolveEpicSpawnCwd({
|
|
200
|
+
cwd: '/projects/foo',
|
|
201
|
+
claudeSessionId: 'sess-1',
|
|
202
|
+
deps: {
|
|
203
|
+
readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS),
|
|
204
|
+
statSync: statGone,
|
|
205
|
+
restore: true,
|
|
206
|
+
restoreWorktree: (a) => { calls.push(a); return true; },
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
expect(result).toBe('/tmp/session-manager-epic-worktrees/deadbeef/epic-1');
|
|
210
|
+
expect(calls).toEqual([{ dir: '/tmp/session-manager-epic-worktrees/deadbeef/epic-1', branch: 'sm-epic/epic-1', baseCwd: '/projects/foo' }]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('restore:true with a failed restore still returns the recorded dir — never the project cwd', () => {
|
|
214
|
+
const result = resolveEpicSpawnCwd({
|
|
215
|
+
cwd: '/projects/foo',
|
|
216
|
+
claudeSessionId: 'sess-1',
|
|
217
|
+
deps: { readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS), statSync: statGone, restore: true, restoreWorktree: () => false },
|
|
218
|
+
});
|
|
219
|
+
expect(result).toBe('/tmp/session-manager-epic-worktrees/deadbeef/epic-1');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('restoreEpicWorktree refuses dirs outside an epic-worktrees segment and non sm-epic branches', () => {
|
|
223
|
+
const { restoreEpicWorktree } = require('../epicSpawnCwd.cjs');
|
|
224
|
+
expect(restoreEpicWorktree({ dir: '/etc/evil', branch: 'sm-epic/x', baseCwd: '/projects/foo' })).toBe(false);
|
|
225
|
+
expect(restoreEpicWorktree({ dir: '/tmp/session-manager-epic-worktrees/a/b', branch: 'main; rm -rf', baseCwd: '/projects/foo' })).toBe(false);
|
|
226
|
+
expect(restoreEpicWorktree({ dir: 'relative/session-manager-epic-worktrees/a', branch: 'sm-epic/x', baseCwd: '/projects/foo' })).toBe(false);
|
|
227
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epicSpawnPlan.test.cjs — PRD 1319: spawn cwd + --resume flag are decided together; a session
|
|
3
|
+
* whose transcript is stranded under a dead worktree encoding is refused BEFORE any CLI spawn
|
|
4
|
+
* with an actionable message, later requests are refused cheaply, closed Epics are refused.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/epicSpawnPlan.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { EventEmitter } = require('node:events');
|
|
15
|
+
const { encodeCwd } = require('../encodeCwd.cjs');
|
|
16
|
+
const { planEpicSpawn, __resetForTests } = require('../epicSpawnPlan.cjs');
|
|
17
|
+
const { __resetCacheForTests } = require('../epicTranscriptPath.cjs');
|
|
18
|
+
|
|
19
|
+
const SID = '00ff31cb-56b6-421c-829c-012ef95b15e2';
|
|
20
|
+
const PROJECT = '/projects/starry-night-ships';
|
|
21
|
+
const DEAD = '/tmp/session-manager-epic-worktrees/abc123/blender-epic-442d711d';
|
|
22
|
+
|
|
23
|
+
let home;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
__resetForTests();
|
|
26
|
+
__resetCacheForTests();
|
|
27
|
+
home = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-home-'));
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
30
|
+
|
|
31
|
+
function transcriptUnder(dir) {
|
|
32
|
+
const d = path.join(home, '.claude', 'projects', encodeCwd(dir));
|
|
33
|
+
fs.mkdirSync(d, { recursive: true });
|
|
34
|
+
const f = path.join(d, `${SID}.jsonl`);
|
|
35
|
+
fs.writeFileSync(f, '{"type":"user"}\n');
|
|
36
|
+
return f;
|
|
37
|
+
}
|
|
38
|
+
const epic = (extra = {}) => ({ e1: { id: 'e1', claudeSessionId: SID, status: 'active', worktree: { dir: DEAD, branch: 'sm-epic/e1', baseCwd: PROJECT, status: 'active' }, ...extra } });
|
|
39
|
+
|
|
40
|
+
test('transcript under a dead worktree encoding + restore failure => refused, names the worktree, no raw CLI text', () => {
|
|
41
|
+
transcriptUnder(DEAD);
|
|
42
|
+
const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic() }), restoreWorktree: () => false } });
|
|
43
|
+
expect(plan.ok).toBe(false);
|
|
44
|
+
expect(plan.code).toBe('session_unreachable');
|
|
45
|
+
expect(plan.message).toContain(DEAD);
|
|
46
|
+
expect(plan.message).not.toMatch(/already in use/i);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('circuit breaker: 2nd..Nth request refused without re-running restore', () => {
|
|
50
|
+
transcriptUnder(DEAD);
|
|
51
|
+
let restores = 0;
|
|
52
|
+
const deps = { homeDir: home, readActiveIndex: () => ({ sessions: epic() }), restoreWorktree: () => { restores++; return false; } };
|
|
53
|
+
const first = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps });
|
|
54
|
+
for (let i = 0; i < 4; i++) expect(planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps })).toEqual(first);
|
|
55
|
+
expect(restores).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('worktree re-attached at the same path => ok, resume, spawn cwd is the worktree', () => {
|
|
59
|
+
transcriptUnder(DEAD);
|
|
60
|
+
const live = new Set();
|
|
61
|
+
const statSync = (p) => (live.has(p) ? { isDirectory: () => true } : fs.statSync(p));
|
|
62
|
+
const plan = planEpicSpawn({
|
|
63
|
+
cwd: PROJECT, claudeSessionId: SID,
|
|
64
|
+
deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic() }), statSync, restoreWorktree: ({ dir }) => { live.add(dir); return true; } },
|
|
65
|
+
});
|
|
66
|
+
expect(plan.ok).toBe(true);
|
|
67
|
+
expect(plan.useResume).toBe(true);
|
|
68
|
+
expect(plan.execCwd).toBe(DEAD);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('transcript under the project encoding, no worktree => ok, resume from project cwd', () => {
|
|
72
|
+
transcriptUnder(PROJECT);
|
|
73
|
+
const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic({ worktree: undefined }) }), statSync: (p) => (p === PROJECT ? { isDirectory: () => true } : fs.statSync(p)) } });
|
|
74
|
+
expect(plan).toMatchObject({ ok: true, useResume: true, execCwd: PROJECT });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('closed (completed) Epic is refused with its own message', () => {
|
|
78
|
+
const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic({ status: 'completed' }) }) } });
|
|
79
|
+
expect(plan).toMatchObject({ ok: false, code: 'epic_closed' });
|
|
80
|
+
expect(plan.message).toMatch(/closed/i);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('chatRunner: dead-encoding transcript => zero CLI spawns, exactly one clear error event', async () => {
|
|
84
|
+
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-proj-'));
|
|
85
|
+
const prevHome = process.env.HOME;
|
|
86
|
+
process.env.HOME = home;
|
|
87
|
+
try {
|
|
88
|
+
const idxDir = path.join(project, 'session-manager-operations', 'prompt-sessions');
|
|
89
|
+
fs.mkdirSync(idxDir, { recursive: true });
|
|
90
|
+
fs.writeFileSync(path.join(idxDir, 'active-index.json'), JSON.stringify({ sessions: epic() }));
|
|
91
|
+
transcriptUnder(DEAD);
|
|
92
|
+
const cp = require('node:child_process');
|
|
93
|
+
const realSpawn = cp.spawn;
|
|
94
|
+
let spawns = 0;
|
|
95
|
+
cp.spawn = () => { spawns++; const c = new EventEmitter(); c.stdout = new EventEmitter(); c.stderr = new EventEmitter(); c.pid = 1; c.kill = () => {}; return c; };
|
|
96
|
+
const chatRunner = require('../../chatRunner.cjs');
|
|
97
|
+
chatRunner.__resetQueueForTests();
|
|
98
|
+
const sent = [];
|
|
99
|
+
chatRunner.attachWindow({ isDestroyed: () => false, webContents: { isDestroyed: () => false, send: (channel, payload) => sent.push({ channel, payload }) } });
|
|
100
|
+
try {
|
|
101
|
+
for (let i = 0; i < 3; i++) chatRunner.run({ tabId: `t${i}`, sessionId: SID, prompt: 'hi', cwd: project, resume: true });
|
|
102
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
103
|
+
} finally { cp.spawn = realSpawn; }
|
|
104
|
+
const errs = sent.filter((e) => e.channel === 'chat:run:error');
|
|
105
|
+
expect(spawns).toBe(0);
|
|
106
|
+
expect(errs.length).toBe(3);
|
|
107
|
+
for (const e of errs) expect(e.payload.message).toContain(DEAD);
|
|
108
|
+
// Single-writer law: the rescued session's ops write (error log) lands under the PROJECT root.
|
|
109
|
+
const logsDir = path.join(project, 'session-manager-operations', 'logs');
|
|
110
|
+
expect(fs.existsSync(logsDir)).toBe(true);
|
|
111
|
+
expect(fs.readdirSync(logsDir).some((n) => n.startsWith('errors-'))).toBe(true);
|
|
112
|
+
expect(fs.existsSync(path.join(DEAD, 'session-manager-operations'))).toBe(false);
|
|
113
|
+
} finally {
|
|
114
|
+
process.env.HOME = prevHome;
|
|
115
|
+
fs.rmSync(project, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epicTranscriptPath.test.cjs — resolver for where an Epic session's JSONL
|
|
3
|
+
* transcript actually lives (worktree encoding vs project encoding).
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/epicTranscriptPath.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { resolveEpicTranscriptPath, __resetCacheForTests } = require('../epicTranscriptPath.cjs');
|
|
15
|
+
const { encodeCwd } = require('../encodeCwd.cjs');
|
|
16
|
+
const { readActiveIndex } = require('../epicMint.cjs');
|
|
17
|
+
|
|
18
|
+
const SID = 'sess-1';
|
|
19
|
+
let root, home, cwd, wtDir;
|
|
20
|
+
|
|
21
|
+
const projFile = (dir) => path.join(home, '.claude', 'projects', encodeCwd(dir), `${SID}.jsonl`);
|
|
22
|
+
function put(p, body = '{"a":1}\n', mtimeSec) {
|
|
23
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
24
|
+
fs.writeFileSync(p, body);
|
|
25
|
+
if (mtimeSec) fs.utimesSync(p, mtimeSec, mtimeSec);
|
|
26
|
+
}
|
|
27
|
+
const promptDir = () => path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
28
|
+
function writeIndex(sessions) {
|
|
29
|
+
fs.mkdirSync(promptDir(), { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(promptDir(), 'active-index.json'), JSON.stringify({ sessions, events: {} }));
|
|
31
|
+
}
|
|
32
|
+
const epic = (extra = {}) => ({ id: 'epic-1', claudeSessionId: SID, worktree: { dir: wtDir, branch: 'b', baseCwd: cwd, status: 'active' }, ...extra });
|
|
33
|
+
const resolve = (deps = {}) => resolveEpicTranscriptPath({ cwd, claudeSessionId: SID, deps: { homeDir: home, ...deps } });
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
__resetCacheForTests();
|
|
37
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-etp-'));
|
|
38
|
+
home = path.join(root, 'home');
|
|
39
|
+
cwd = path.join(root, 'project');
|
|
40
|
+
wtDir = path.join(root, 'wt', 'epic-1');
|
|
41
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
44
|
+
|
|
45
|
+
test('worktree transcript is found when the worktree dir exists', () => {
|
|
46
|
+
fs.mkdirSync(wtDir, { recursive: true });
|
|
47
|
+
writeIndex({ e: epic() });
|
|
48
|
+
put(projFile(wtDir));
|
|
49
|
+
const r = resolve();
|
|
50
|
+
expect(r.path).toBe(projFile(wtDir));
|
|
51
|
+
expect(r.existsAnywhere).toBe(true);
|
|
52
|
+
expect(r.candidates).toEqual([projFile(wtDir), projFile(cwd)]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('worktree swept from disk but transcript survives — still returned', () => {
|
|
56
|
+
writeIndex({ e: epic() });
|
|
57
|
+
put(projFile(wtDir));
|
|
58
|
+
const r = resolve();
|
|
59
|
+
expect(r.path).toBe(projFile(wtDir));
|
|
60
|
+
expect(r.candidates).toContain(projFile(wtDir));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('project-encoding-only transcript', () => {
|
|
64
|
+
fs.mkdirSync(wtDir, { recursive: true });
|
|
65
|
+
writeIndex({ e: epic() });
|
|
66
|
+
put(projFile(cwd));
|
|
67
|
+
const r = resolve();
|
|
68
|
+
expect(r.path).toBe(projFile(cwd));
|
|
69
|
+
expect(r.existingPaths).toEqual([projFile(cwd)]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('nothing anywhere: path is the spawn-cwd candidate, existsAnywhere false', () => {
|
|
73
|
+
fs.mkdirSync(wtDir, { recursive: true });
|
|
74
|
+
writeIndex({ e: epic() });
|
|
75
|
+
const r = resolve();
|
|
76
|
+
expect(r.path).toBe(projFile(wtDir));
|
|
77
|
+
expect(r.existsAnywhere).toBe(false);
|
|
78
|
+
expect(r.existingPaths).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('non-Epic tab returns exactly the project path with a single candidate', () => {
|
|
82
|
+
writeIndex({ e: epic({ claudeSessionId: 'other' }) });
|
|
83
|
+
put(projFile(cwd));
|
|
84
|
+
const r = resolve();
|
|
85
|
+
expect(r.candidates).toEqual([projFile(cwd)]);
|
|
86
|
+
expect(r.path).toBe(projFile(cwd));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('dir exists but holds only a tool-results sidecar — does not win', () => {
|
|
90
|
+
fs.mkdirSync(wtDir, { recursive: true });
|
|
91
|
+
writeIndex({ e: epic() });
|
|
92
|
+
fs.mkdirSync(path.join(path.dirname(projFile(wtDir)), SID, 'tool-results'), { recursive: true });
|
|
93
|
+
put(projFile(cwd));
|
|
94
|
+
const r = resolve();
|
|
95
|
+
expect(r.path).toBe(projFile(cwd));
|
|
96
|
+
expect(r.existingPaths).toEqual([projFile(cwd)]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a directory named <id>.jsonl is not a transcript', () => {
|
|
100
|
+
writeIndex({ e: epic() });
|
|
101
|
+
fs.mkdirSync(projFile(wtDir), { recursive: true });
|
|
102
|
+
expect(resolve().existsAnywhere).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('zero-byte file counts as existing but loses to a non-empty one', () => {
|
|
106
|
+
fs.mkdirSync(wtDir, { recursive: true });
|
|
107
|
+
writeIndex({ e: epic() });
|
|
108
|
+
put(projFile(wtDir), '');
|
|
109
|
+
let r = resolve();
|
|
110
|
+
expect(r.existsAnywhere).toBe(true);
|
|
111
|
+
expect(r.path).toBe(projFile(wtDir));
|
|
112
|
+
put(projFile(cwd), '{"a":1}\n', 1000);
|
|
113
|
+
r = resolve();
|
|
114
|
+
expect(r.path).toBe(projFile(cwd));
|
|
115
|
+
expect(r.existingPaths.length).toBe(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('duplicate under two encodings: newest mtime wins', () => {
|
|
119
|
+
fs.mkdirSync(wtDir, { recursive: true });
|
|
120
|
+
writeIndex({ e: epic() });
|
|
121
|
+
put(projFile(wtDir), 'x\n', 2000);
|
|
122
|
+
put(projFile(cwd), 'x\n', 3000);
|
|
123
|
+
let r = resolve();
|
|
124
|
+
expect(r.existingPaths.length).toBe(2);
|
|
125
|
+
expect(r.path).toBe(projFile(cwd));
|
|
126
|
+
put(projFile(wtDir), 'x\n', 4000);
|
|
127
|
+
r = resolve();
|
|
128
|
+
expect(r.path).toBe(projFile(wtDir));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('completed Epic (absent from index) resolved from its archive', () => {
|
|
132
|
+
writeIndex({});
|
|
133
|
+
fs.writeFileSync(path.join(promptDir(), 'done-epic-abcd1234.json'), JSON.stringify({ session: epic(), events: [], transcript: '', archivedAt: 'x' }));
|
|
134
|
+
put(projFile(wtDir));
|
|
135
|
+
const r = resolve();
|
|
136
|
+
expect(r.path).toBe(projFile(wtDir));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('malformed archive JSON and unreadable index never throw', () => {
|
|
140
|
+
writeIndex({});
|
|
141
|
+
fs.writeFileSync(path.join(promptDir(), 'bad.json'), '{not json');
|
|
142
|
+
expect(() => resolve()).not.toThrow();
|
|
143
|
+
const r = resolve({ readActiveIndex: () => { throw new Error('boom'); } });
|
|
144
|
+
expect(r.candidates).toEqual([projFile(cwd)]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('blank/missing/unsafe inputs return a safe result', () => {
|
|
148
|
+
for (const args of [{}, { cwd: '', claudeSessionId: SID }, { cwd, claudeSessionId: '' }, { cwd, claudeSessionId: '../x' }]) {
|
|
149
|
+
const r = resolveEpicTranscriptPath({ ...args, deps: { homeDir: home } });
|
|
150
|
+
expect(r).toEqual({ path: null, candidates: [], existsAnywhere: false, existingPaths: [] });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('index read once across N calls when unchanged; re-read after a touch', () => {
|
|
155
|
+
writeIndex({ e: epic() });
|
|
156
|
+
let reads = 0;
|
|
157
|
+
const deps = { readActiveIndex: (c) => { reads++; return readActiveIndex(c); } };
|
|
158
|
+
for (let i = 0; i < 20; i++) resolve(deps);
|
|
159
|
+
expect(reads).toBe(1);
|
|
160
|
+
writeIndex({ e: epic(), f: epic({ id: 'epic-2', claudeSessionId: 'zzz' }) });
|
|
161
|
+
resolve(deps);
|
|
162
|
+
expect(reads).toBe(2);
|
|
163
|
+
});
|
|
@@ -147,8 +147,12 @@ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.jso
|
|
|
147
147
|
expect(parsed.port).toBe(port);
|
|
148
148
|
expect(parsed.token).toBe(token);
|
|
149
149
|
// Production file must be untouched by the dev-mode instance.
|
|
150
|
+
// Content, not mtime: a live Session Manager on this machine legitimately rewrites the real
|
|
151
|
+
// admin-api.json at any moment (mtime drifted 12ms mid-test under load). What must hold is
|
|
152
|
+
// that THIS dev instance never wrote its port/token there.
|
|
150
153
|
if (prevMtime !== null) {
|
|
151
|
-
|
|
154
|
+
const prod = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
|
|
155
|
+
expect(prod.token).not.toBe(token);
|
|
152
156
|
} else {
|
|
153
157
|
expect(fs.existsSync(TOKEN_PATH)).toBe(false);
|
|
154
158
|
}
|
|
@@ -68,8 +68,47 @@ test('resolver and KIND_CONFIG roots follow SM_WORKTREE_ROOT together', () => {
|
|
|
68
68
|
expect(schedulerPaths.worktreeRoot('epic')).toBe(path.join(override, 'session-manager-epic-worktrees'));
|
|
69
69
|
expect(gitWorktree.KIND_CONFIG.job.root).toBe(schedulerPaths.worktreeRoot('job'));
|
|
70
70
|
expect(gitWorktree.KIND_CONFIG.epic.root).toBe(schedulerPaths.worktreeRoot('epic'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('unset SM_WORKTREE_ROOT resolves the persistent per-user dir (0700, created), never os.tmpdir()', () => {
|
|
74
|
+
const state = path.join(scratch, 'state');
|
|
75
|
+
const prevXdg = process.env.XDG_STATE_HOME;
|
|
76
|
+
process.env.XDG_STATE_HOME = state;
|
|
71
77
|
delete process.env.SM_WORKTREE_ROOT;
|
|
72
|
-
|
|
78
|
+
try {
|
|
79
|
+
// scratch lives under os.tmpdir(), so the state dir passes the (unchanged) guard as a tmp child.
|
|
80
|
+
const base = schedulerPaths.worktreeBase();
|
|
81
|
+
expect(base).toBe(path.join(state, 'session-manager'));
|
|
82
|
+
expect(base).not.toBe(os.tmpdir());
|
|
83
|
+
expect(fs.statSync(base).mode & 0o777).toBe(0o700);
|
|
84
|
+
expect(schedulerPaths.worktreeRoot('epic')).toBe(path.join(base, 'session-manager-epic-worktrees'));
|
|
85
|
+
} finally {
|
|
86
|
+
if (prevXdg === undefined) delete process.env.XDG_STATE_HOME; else process.env.XDG_STATE_HOME = prevXdg;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the vitest guard still throws for a live default (no SM_WORKTREE_ROOT, real ~/.local/state) and creates nothing', () => {
|
|
91
|
+
const prevXdg = process.env.XDG_STATE_HOME;
|
|
92
|
+
delete process.env.XDG_STATE_HOME;
|
|
93
|
+
delete process.env.SM_WORKTREE_ROOT;
|
|
94
|
+
const prevHome = process.env.HOME;
|
|
95
|
+
const fakeHome = path.join(scratch, 'nohome');
|
|
96
|
+
process.env.HOME = '/nonexistent-live-home-for-guard-test';
|
|
97
|
+
try {
|
|
98
|
+
expect(() => schedulerPaths.worktreeBase()).toThrow(/live root/);
|
|
99
|
+
expect(fs.existsSync(path.join(fakeHome, '.local'))).toBe(false);
|
|
100
|
+
} finally {
|
|
101
|
+
process.env.HOME = prevHome;
|
|
102
|
+
if (prevXdg !== undefined) process.env.XDG_STATE_HOME = prevXdg;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('SM_WORKTREE_ROOT set wins over the persistent default', () => {
|
|
107
|
+
process.env.XDG_STATE_HOME = path.join(scratch, 'ignored');
|
|
108
|
+
try {
|
|
109
|
+
expect(schedulerPaths.worktreeBase()).toBe(override);
|
|
110
|
+
expect(fs.existsSync(path.join(scratch, 'ignored'))).toBe(false);
|
|
111
|
+
} finally { delete process.env.XDG_STATE_HOME; }
|
|
73
112
|
});
|
|
74
113
|
|
|
75
114
|
test('create / sweep / isEphemeralCwd operate under the override, never the real root', async () => {
|