cc-viewer 1.9.11 → 1.9.13

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.
Files changed (94) hide show
  1. package/README.md +8 -0
  2. package/dist/assets/{App-D3swMyLc.js → App-BzOM1_y-.js} +2 -2
  3. package/dist/assets/{MdxEditorPanel-DeyoQsxw.js → MdxEditorPanel-Db1nmcJV.js} +1 -1
  4. package/dist/assets/{Mobile-DvI_Kby2.js → Mobile-rjfABa-U.js} +1 -1
  5. package/dist/assets/{ProxyStatsModal-CoI275by.js → ProxyStatsModal-Ch1BH0Kp.js} +1 -1
  6. package/dist/assets/{_baseUniq-BvPjRimO.js → _baseUniq-CsF04anE.js} +1 -1
  7. package/dist/assets/{arc-BzZyqc6p.js → arc-BOacYe6N.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-B1oszHzA.js → architectureDiagram-Q4EWVU46-BSUEfjC_.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-Ndp4o_QB.js → blockDiagram-DXYQGD6D-CC37UGtW.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-DlDE-ALt.js → c4Diagram-AHTNJAMY-IzgRD4uO.js} +1 -1
  11. package/dist/assets/{channel-BhXx9y6q.js → channel-C6PxyQUn.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-CM-2e6q_.js → chunk-4BX2VUAB-cdQt_dcU.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-GWLkQKRO.js → chunk-4TB4RGXK-BpTDA_Pb.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-B6LOFqRZ.js → chunk-55IACEB6-CcitNo0P.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-Ct3e09Ku.js → chunk-EDXVE4YY-BjKwZ9I7.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-DK-vXpZP.js → chunk-FMBD7UC4-vZDVX3tI.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-CWxZc_DU.js → chunk-OYMX7WX6-DGkYahGo.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-jp8ISHue.js → chunk-QZHKN3VN-C9065hSE.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-DtYet6WV.js → chunk-YZCP3GAM-D_pcTg6_.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-BUmU_G-i.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-BUmU_G-i.js +1 -0
  22. package/dist/assets/clone-CvoOne2K.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-lNC-slVX.js → cose-bilkent-S5V4N54A-BPPFMIGv.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-vm6U82qq.js → dagre-KV5264BT-fjzhKO5o.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-Dq3FXh-U.js → diagram-5BDNPKRD-5L6FzTry.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BxF85wcF.js → diagram-G4DWMVQ6-GOwcGpff.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-CFCTeMz0.js → diagram-MMDJMWI5-BDsFE25W.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-Bui5Azdg.js → diagram-TYMM5635-BqjpbSGH.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-DrpUO1Kb.js → erDiagram-SMLLAGMA-DWO3Dk1v.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-Bb3amxiK.js → flowDiagram-DWJPFMVM-Br4nNBVd.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-BRwCFM3C.js → ganttDiagram-T4ZO3ILL-9sN-VOhw.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DVXbBwhs.js → gitGraphDiagram-UUTBAWPF-BsPcTAKr.js} +1 -1
  33. package/dist/assets/{graph-Dh22wYWs.js → graph-o31GzaB6.js} +1 -1
  34. package/dist/assets/{index-CfFFgxxf.js → index-B3b0lRUB.js} +1 -1
  35. package/dist/assets/{index-BRW8dfBU.js → index-CAiC6cw-.js} +2 -2
  36. package/dist/assets/{index-BwZB7UwN.js → index-CXMr3tiK.js} +1 -1
  37. package/dist/assets/{index-UlO7Q5dX.js → index-CuvDJTY7.js} +1 -1
  38. package/dist/assets/{index-Cfm4N4ao.js → index-DAjx9lT3.js} +1 -1
  39. package/dist/assets/{index-xZXOwglE.js → index-DyV81jSF.js} +1 -1
  40. package/dist/assets/{index-CQ1Nswa3.js → index-PDvAAbti.js} +1 -1
  41. package/dist/assets/{index-e8wejtj_.js → index-tZqiJJGq.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-cQPbF7Q6.js → infoDiagram-42DDH7IO-VUMzEo4o.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-cp-ir90Y.js → ishikawaDiagram-UXIWVN3A-BuzrU3sx.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-aoMcyNLC.js → journeyDiagram-VCZTEJTY-BZtR5BAF.js} +1 -1
  45. package/dist/assets/{jszip.min-C7BBsGL5.js → jszip.min-CtodH5MV.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-Cugr0o0f.js → kanban-definition-6JOO6SKY-Cj_97xXb.js} +1 -1
  47. package/dist/assets/{layout-CL0eiocE.js → layout-Du0I-tlh.js} +1 -1
  48. package/dist/assets/{linear-D6EJvwNs.js → linear-BJtxCASI.js} +1 -1
  49. package/dist/assets/{mermaid.core-CNYcCP19.js → mermaid.core-QlorX0Yw.js} +2 -2
  50. package/dist/assets/{min-dFBWKmZ_.js → min-BVnQfunZ.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-4fbr7E2n.js → mindmap-definition-QFDTVHPH-Bh_KL4qU.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-UHWzU_JW.js → pieDiagram-DEJITSTG-Bvv0rC_q.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-e87bZkAb.js → quadrantDiagram-34T5L4WZ-Bw8cGCRU.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-l_97YNSi.js → requirementDiagram-MS252O5E-Cg9jQ71h.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-D3JFnltO.js → sankeyDiagram-XADWPNL6-BXJo6-CD.js} +1 -1
  56. package/dist/assets/seqResourceLoaders-CxUFE36z.css +41 -0
  57. package/dist/assets/seqResourceLoaders-Ds8-_QA3.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-5jDKcsRZ.js → sequenceDiagram-FGHM5R23-D7zpGxwq.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-D5_gBKn8.js → stateDiagram-FHFEXIEX-Der2CqR1.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP5uQ5EH.js → stateDiagram-v2-QKLJ7IA2-DetvG_Lc.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-7IVQRwK1.js → timeline-definition-GMOUNBTQ-0UenzEZ6.js} +1 -1
  62. package/dist/assets/{vendor-antd-CfuxfYfy.js → vendor-antd-I6I2F7Rv.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-C4Jn4TTc.js → vendor-codemirror-Zkm6UHQg.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-BreKt5Hd.js → vendor-mdxeditor-CC09MDyZ.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-CutTqXgh.js → vendor-qrcode-gZpmSeeh.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-Cc9ovCDf.js → vendor-virtuoso-BGwznQ5y.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-D6qzGquN.js → vennDiagram-DHZGUBPP-COzYyZ5h.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-DzAFqZmY.js → wardley-RL74JXVD-VZdOtN8t.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-CImP9kv8.js → wardleyDiagram-NUSXRM2D-CVkL75Qy.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-Co45rRaM.js → xychartDiagram-5P7HB3ND-6CHBAYyF.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/package.json +1 -1
  73. package/server/lib/chat-queue.js +14 -1
  74. package/server/lib/config-backup.js +4 -2
  75. package/server/lib/cron.js +181 -0
  76. package/server/lib/file-access-policy.js +45 -1
  77. package/server/lib/im-deny.js +12 -9
  78. package/server/lib/json-store.js +27 -1
  79. package/server/lib/project-prefs.js +4 -1
  80. package/server/lib/schedule-model.js +149 -0
  81. package/server/lib/scheduled-task-executor.js +97 -0
  82. package/server/lib/scheduled-task-runs-store.js +99 -0
  83. package/server/lib/scheduled-task-scheduler.js +254 -0
  84. package/server/lib/scheduled-task-store.js +511 -0
  85. package/server/lib/sdk-manager.js +283 -12
  86. package/server/pty-manager.js +13 -2
  87. package/server/routes/preferences.js +24 -1
  88. package/server/routes/scheduled-tasks.js +310 -0
  89. package/server/server.js +135 -0
  90. package/dist/assets/classDiagram-6PBFFD2Q-C0dwAyuU.js +0 -1
  91. package/dist/assets/classDiagram-v2-HSJHXN6E-C0dwAyuU.js +0 -1
  92. package/dist/assets/clone-fiofFlrV.js +0 -1
  93. package/dist/assets/seqResourceLoaders-8kfGbCYo.js +0 -2
  94. package/dist/assets/seqResourceLoaders-C2YGihCH.css +0 -41
@@ -0,0 +1,99 @@
1
+ // Scheduled-task run-history store — a single JSON file per cc-viewer data root.
2
+ //
3
+ // Run history is deliberately split from the task registry (which lives in
4
+ // preferences.json `scheduledTasks`): history is expendable, append-mostly, and grows
5
+ // unboundedly per task, while the registry carries secrets (prompts) and scheduling
6
+ // state that must survive strict writes. Keeping them apart lets the registry stay
7
+ // strictCorrupt while history self-heals, and keeps prefs rewrites small.
8
+ //
9
+ // File shape: { version: 1, byTask: { [taskId]: Run[] } } — oldest → newest, capped at
10
+ // MAX_RUNS per task. Each run carries `claimRunId` so the registry's claim/finalize can
11
+ // dedupe an interrupted marker against an already-recorded terminal run.
12
+ //
13
+ // Lock: its own `${file}.lock` via mutateJson — NEVER nested inside the prefs lock
14
+ // (lock order is registry-then-runs for claim/update/sweep, runs-then-registry for
15
+ // finalize; see scheduled-task-store.js). Tolerant reads/writes: a corrupt file is
16
+ // reseeded on the next append (history loss is acceptable; a stuck claim is not).
17
+ //
18
+ // Boundary: L1-lib. Imports only node builtins + json-store. Must NOT import L2.
19
+
20
+ import { join } from 'node:path';
21
+ import { randomBytes } from 'node:crypto';
22
+ import { mutateJson, readJsonSafe } from './json-store.js';
23
+
24
+ const FILE_NAME = 'scheduled-task-runs.json';
25
+ const VERSION = 1;
26
+ export const MAX_RUNS = 20;
27
+
28
+ export function runsFilePath(root) { return join(root, FILE_NAME); }
29
+
30
+ function _newRunId() { return `run_${randomBytes(8).toString('hex')}`; }
31
+
32
+ function _normalize(data) {
33
+ const reg = (data && typeof data === 'object' && !Array.isArray(data) && data.byTask && typeof data.byTask === 'object' && !Array.isArray(data.byTask))
34
+ ? data
35
+ : { version: VERSION, byTask: {} };
36
+ reg.version = VERSION;
37
+ return reg;
38
+ }
39
+
40
+ /**
41
+ * Read one task's run history (oldest → newest, last MAX_RUNS). Never throws; missing /
42
+ * corrupt / unknown task → [].
43
+ */
44
+ export function readScheduledTaskRuns(root, taskId) {
45
+ const data = readJsonSafe(runsFilePath(root), { version: VERSION, byTask: {} });
46
+ const reg = _normalize(data);
47
+ const runs = reg.byTask[taskId];
48
+ if (!Array.isArray(runs)) return [];
49
+ return runs.filter(r => r && typeof r === 'object');
50
+ }
51
+
52
+ /**
53
+ * Append a batch of runs in ONE locked write. entries = [{ taskId, run }].
54
+ * A run with an existing id OR an existing claimRunId (when provided) is skipped, so
55
+ * finalize/reclaim retries are idempotent. Never throws to the caller beyond a hard
56
+ * lock/IO error from mutateJson — history is best-effort by design.
57
+ */
58
+ export function appendRuns(root, entries) {
59
+ const list = Array.isArray(entries) ? entries.filter(e => e && typeof e.taskId === 'string' && e.run && typeof e.run === 'object') : [];
60
+ if (list.length === 0) return Promise.resolve();
61
+ return mutateJson(runsFilePath(root), (data) => {
62
+ const reg = _normalize(data);
63
+ for (const { taskId, run } of list) {
64
+ const arr = Array.isArray(reg.byTask[taskId]) ? reg.byTask[taskId] : (reg.byTask[taskId] = []);
65
+ // Idempotency: skip when the same run id OR the same claimRunId already exists.
66
+ const dup = (run.id && arr.some(r => r.id === run.id))
67
+ || (run.claimRunId && arr.some(r => r.claimRunId && r.claimRunId === run.claimRunId && r.status !== 'running'));
68
+ if (dup) continue;
69
+ arr.push({ id: run.id || _newRunId(), ...run });
70
+ if (arr.length > MAX_RUNS) arr.splice(0, arr.length - MAX_RUNS);
71
+ }
72
+ return reg;
73
+ }, { pretty: true, fallback: { version: VERSION, byTask: {} } });
74
+ }
75
+
76
+ /** Append a single run. Convenience wrapper over appendRuns. */
77
+ export function appendRun(root, taskId, run) {
78
+ return appendRuns(root, [{ taskId, run }]);
79
+ }
80
+
81
+ /** Remove a task's history (called when the task itself is deleted). */
82
+ export function deleteRunsForTask(root, taskId) {
83
+ return mutateJson(runsFilePath(root), (data) => {
84
+ const reg = _normalize(data);
85
+ if (Object.prototype.hasOwnProperty.call(reg.byTask, taskId)) delete reg.byTask[taskId];
86
+ return reg;
87
+ }, { pretty: true, fallback: { version: VERSION, byTask: {} } });
88
+ }
89
+
90
+ /**
91
+ * Look up whether a terminal run (completed/dispatched/error/failed) already exists for a
92
+ * claimRunId — used by reclaim to restore a once-task's terminal state instead of blindly
93
+ * appending a misleading 'interrupted' marker after a finalize crash.
94
+ */
95
+ export function findRunByClaimId(root, taskId, claimRunId) {
96
+ if (!claimRunId) return null;
97
+ const runs = readScheduledTaskRuns(root, taskId);
98
+ return runs.find(r => r.claimRunId === claimRunId && r.status && r.status !== 'running') || null;
99
+ }
@@ -0,0 +1,254 @@
1
+ // Scheduled-task scheduler — the timer that turns due tasks into runs.
2
+ //
3
+ // This is L1 pure logic: it never imports the SDK/PTY managers (L2) or the SSE
4
+ // plumbing (L3/server). server.js injects every capability via initScheduledTaskScheduler
5
+ // (the same shape as initChatQueue): getRoot / getMode / canExecute / execute / isReady /
6
+ // broadcast / report. That keeps the tick loop unit-testable with fakes.
7
+ //
8
+ // Multi-process model (review P0-1): several cc-viewer processes share one LOG_DIR
9
+ // (Electron tabs, IM workers, parallel instances). The scheduler only starts when the
10
+ // host process opts in (server.js gates on isCliMode && !CCV_IM_PLATFORM). On top of
11
+ // that, each tick filters due tasks through deps.canExecute(task) so a process only
12
+ // claims work it can actually run (an IM worker never has the user's resident session).
13
+ // The actual cross-process mutual exclusion is the store's claimTask file lock.
14
+ //
15
+ // Robustness (compliance P0): every async step is wrapped so a throwing store call
16
+ // (strictCorrupt / lock timeout) or executor surfaces through deps.report instead of
17
+ // becoming an unhandledRejection that would kill the whole ccv process under Node 22.
18
+
19
+ import { readScheduledTasks, claimTask, finalizeTask, sweepStaleClaims } from './scheduled-task-store.js';
20
+
21
+ const TICK_MS = 10_000;
22
+ const STARTUP_GRACE_MS = 20_000; // let SDK/PTY come up before the first tick
23
+ const READY_WINDOW_MS = 5 * 60_000; // beyond this, execute anyway (and record failures)
24
+ const SPAWN_PER_PROJECT = 1;
25
+ const SPAWN_GLOBAL = 2;
26
+
27
+ let _deps = null;
28
+ let _timer = null;
29
+ let _startedAt = 0;
30
+ let _ticking = false;
31
+ let _instanceId = '';
32
+ const _spawnInFlight = { total: 0, byProject: new Map() };
33
+
34
+ /**
35
+ * Wire the scheduler's capabilities. Called once from server.js at start.
36
+ * deps: {
37
+ * getRoot(): string,
38
+ * getMode(): { sdk:boolean },
39
+ * canExecute(task): boolean, // process-level capability filter (P0-1)
40
+ * execute(task, claim): Promise<result>,
41
+ * isReady(): boolean,
42
+ * broadcast(project, payload): void,
43
+ * report(tag, err): void,
44
+ * }
45
+ */
46
+ export function initScheduledTaskScheduler(deps) {
47
+ _deps = deps;
48
+ _instanceId = `boot_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
49
+ }
50
+
51
+ /** Idempotent start (server.js may re-run _doStart on stop/start cycles). */
52
+ export function startScheduledTaskScheduler() {
53
+ if (!_deps || _timer) return;
54
+ _startedAt = Date.now();
55
+ // Reclaim any claims whose owner died while we were down (or a previous generation
56
+ // of THIS pid left behind). Failures here must never block startup.
57
+ const root = _safe(() => _deps.getRoot());
58
+ if (root) {
59
+ // Skip the sweep entirely in legacy read-only mode: the sweep would create the
60
+ // `scheduledTasks` prefs key (tombstone) and hide the legacy set. Legacy claims are
61
+ // never swept (the legacy file is never written) — they are display-neutralized at
62
+ // the read boundary instead.
63
+ const reg = _safe(() => readScheduledTasks(root));
64
+ if (!reg || !reg.legacy) {
65
+ // Defer past the current synchronous startup burst: auth handoff (saveAuthConfig)
66
+ // and the credential migration run synchronously during _doStart, and the sync lock
67
+ // flavor THROWS on reentry if our async prefs write is mid-flight (json-store
68
+ // JSON_STORE_REENTRANT). setTimeout(0) keeps the sweep's lock off that path while
69
+ // still running before the first 10s tick.
70
+ setTimeout(() => {
71
+ sweepStaleClaims(root, Date.now(), { selfInstanceId: _instanceId })
72
+ .catch(err => _report('scheduler.sweep', err));
73
+ }, 0).unref?.();
74
+ }
75
+ }
76
+ _timer = setInterval(() => { _tick().catch(err => _report('scheduler.tick', err)); }, TICK_MS);
77
+ if (typeof _timer.unref === 'function') _timer.unref();
78
+ }
79
+
80
+ /** Stop ticking and refuse new claims. In-flight runs finish on their own. */
81
+ export function stopScheduledTaskScheduler() {
82
+ if (_timer) { clearInterval(_timer); _timer = null; }
83
+ }
84
+
85
+ /** Test seam: is the loop running? */
86
+ export function isSchedulerRunning() { return !!_timer; }
87
+
88
+ function _report(tag, err) {
89
+ try { if (_deps && typeof _deps.report === 'function') _deps.report(tag, err); }
90
+ catch { /* reporting must never throw */ }
91
+ }
92
+ function _safe(fn) { try { return fn(); } catch { return undefined; } }
93
+
94
+ // Fail-closed canExecute: a throwing/absent canExecute means "cannot execute" (claim
95
+ // nothing), and the throw is reported (diagnostic value, not a silent swallow).
96
+ function _canExecuteSafe(task) {
97
+ if (!_deps || typeof _deps.canExecute !== 'function') return false;
98
+ try { return _deps.canExecute(task) === true; }
99
+ catch (err) { _report('scheduler.can-execute', err); return false; }
100
+ }
101
+
102
+ async function _tick(now = Date.now()) {
103
+ if (!_deps || _ticking) return;
104
+ _ticking = true;
105
+ try {
106
+ const root = _safe(() => _deps.getRoot());
107
+ if (!root) return;
108
+ // Startup grace: don't fire during the first seconds after boot while SDK/PTY
109
+ // sessions are still coming up. After READY_WINDOW we execute regardless.
110
+ const uptime = now - _startedAt;
111
+ if (uptime < STARTUP_GRACE_MS) return;
112
+ if (uptime < READY_WINDOW_MS) {
113
+ const ready = _safe(() => _deps.isReady && _deps.isReady());
114
+ if (!ready) return;
115
+ }
116
+
117
+ const reg = readScheduledTasks(root);
118
+ if (reg.legacy) return; // read-only legacy fallback: writes can't resolve these ids
119
+ const due = reg.tasks.filter(t =>
120
+ t.enabled && t.nextRunAt != null && t.nextRunAt <= now &&
121
+ // Process-level capability: only claim work THIS process can execute (P0-1).
122
+ // Fail-CLOSED: a throwing canExecute must not let this process claim the task
123
+ // (review: fail-open would let a PTY/IM process claim work it can't run).
124
+ _canExecuteSafe(t),
125
+ );
126
+ for (const t of due) {
127
+ let claim = null;
128
+ try {
129
+ claim = await claimTask(root, t.id, now, { manual: false, instanceId: _instanceId });
130
+ } catch (err) {
131
+ _report('scheduler.claim', err); // strictCorrupt / lock timeout → skip, don't crash
132
+ continue;
133
+ }
134
+ if (!claim || !claim.ok) continue;
135
+ if (claim.action === 'missed') {
136
+ _broadcastRun(claim.task, { runId: null }, { status: 'missed', reason: 'stale' });
137
+ continue;
138
+ }
139
+ // Fire-and-forget, but the promise is guarded so a rejection never escapes.
140
+ _launch(root, claim.task, claim).catch(err => _report('scheduler.launch', err));
141
+ }
142
+ } finally {
143
+ _ticking = false;
144
+ }
145
+ }
146
+
147
+ async function _launch(root, task, claim) {
148
+ // Broadcast a running marker so an open modal shows a spinner (review P2-3).
149
+ _broadcastRun(task, claim, { status: 'running' });
150
+ let result;
151
+ if (task.mode === 'spawn' && _spawnAtCap(task.project)) {
152
+ result = { status: 'skipped', reason: 'busy' };
153
+ } else {
154
+ if (task.mode === 'spawn') _incSpawn(task.project);
155
+ try {
156
+ result = await _deps.execute(task, claim);
157
+ } catch (err) {
158
+ _report('scheduler.execute', err);
159
+ result = { status: task.mode === 'spawn' ? 'error' : 'failed', reason: 'internal', error: String(err?.message || err) };
160
+ } finally {
161
+ if (task.mode === 'spawn') _decSpawn(task.project);
162
+ }
163
+ }
164
+ try {
165
+ await finalizeTask(root, task.id, claim, result, Date.now());
166
+ } catch (err) {
167
+ _report('scheduler.finalize', err);
168
+ }
169
+ _broadcastRun(task, claim, result);
170
+ }
171
+
172
+ function _broadcastRun(task, claim, result) {
173
+ try {
174
+ if (!_deps || typeof _deps.broadcast !== 'function') return;
175
+ _deps.broadcast(task.project, {
176
+ type: 'task_run',
177
+ project: task.project,
178
+ taskId: task.id,
179
+ runId: claim?.runId ?? null,
180
+ mode: task.mode,
181
+ status: result.status,
182
+ reason: result.reason ?? null,
183
+ summary: String(task.prompt || '').replace(/\s+/g, ' ').trim().slice(0, 200),
184
+ sessionId: result.sessionId ?? null,
185
+ error: result.error ?? null,
186
+ catchUp: !!claim?.catchUp,
187
+ manual: !!claim?.manual,
188
+ at: Date.now(),
189
+ });
190
+ } catch (err) {
191
+ _report('scheduler.broadcast', err);
192
+ }
193
+ }
194
+
195
+ function _spawnAtCap(project) {
196
+ if (_spawnInFlight.total >= SPAWN_GLOBAL) return true;
197
+ return (_spawnInFlight.byProject.get(project) || 0) >= SPAWN_PER_PROJECT;
198
+ }
199
+ function _incSpawn(project) {
200
+ _spawnInFlight.total += 1;
201
+ _spawnInFlight.byProject.set(project, (_spawnInFlight.byProject.get(project) || 0) + 1);
202
+ }
203
+ function _decSpawn(project) {
204
+ _spawnInFlight.total = Math.max(0, _spawnInFlight.total - 1);
205
+ _spawnInFlight.byProject.set(project, Math.max(0, (_spawnInFlight.byProject.get(project) || 1) - 1));
206
+ }
207
+
208
+ /** Run a task immediately (Run now). Returns claimTask's claim or an error object. */
209
+ export async function runTaskNow(id, { manual = true } = {}) {
210
+ if (!_deps) return { ok: false, reason: 'scheduler_unavailable' };
211
+ if (!_timer) return { ok: false, reason: 'scheduler_unavailable' };
212
+ const root = _safe(() => _deps.getRoot());
213
+ if (!root) return { ok: false, reason: 'scheduler_unavailable' };
214
+ const now = Date.now();
215
+ let claim;
216
+ try {
217
+ claim = await claimTask(root, id, now, { manual, instanceId: _instanceId });
218
+ } catch (err) {
219
+ _report('scheduler.run-now.claim', err);
220
+ return { ok: false, reason: 'store_corrupt' };
221
+ }
222
+ if (!claim || !claim.ok) return claim || { ok: false, reason: 'not-found' };
223
+ if (claim.action === 'missed') {
224
+ _broadcastRun(claim.task, { runId: null }, { status: 'missed', reason: 'stale' });
225
+ return { ok: true, action: 'missed', runId: null };
226
+ }
227
+ _launch(root, claim.task, claim).catch(err => _report('scheduler.run-now.launch', err));
228
+ return { ok: true, action: 'claimed', runId: claim.runId };
229
+ }
230
+
231
+ /** Expose the spawn availability flag for the routes' GET list. */
232
+ export function spawnAvailable() {
233
+ try {
234
+ const mode = _deps && _deps.getMode ? _deps.getMode() : null;
235
+ return !!(mode && mode.sdk && _deps.getSpawnConfig && _deps.getSpawnConfig().available);
236
+ } catch { return false; }
237
+ }
238
+
239
+ /** Test seam: reset module state (timers, in-flight counters). */
240
+ export function __resetSchedulerForTests() {
241
+ stopScheduledTaskScheduler();
242
+ _deps = null;
243
+ _ticking = false;
244
+ _startedAt = 0;
245
+ _instanceId = '';
246
+ _spawnInFlight.total = 0;
247
+ _spawnInFlight.byProject.clear();
248
+ }
249
+
250
+ /** Test seam: drive one tick synchronously (bypasses the interval + startup grace). */
251
+ export async function __tickForTests(now = Date.now()) {
252
+ _startedAt = 0; // neutralize the startup grace for a deterministic test tick
253
+ await _tick(now);
254
+ }