cc-viewer 1.9.12 → 1.9.14
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/README.md +8 -0
- package/dist/assets/{App-Xqmx9TR5.js → App-BzV1ieDH.js} +2 -2
- package/dist/assets/{MdxEditorPanel-BwTbSU48.js → MdxEditorPanel-DI3B4gsM.js} +1 -1
- package/dist/assets/{Mobile-BOXHO9-C.js → Mobile-qBkAnXy3.js} +1 -1
- package/dist/assets/{ProxyStatsModal-BzfIUfG1.js → ProxyStatsModal-B8K0AtAq.js} +1 -1
- package/dist/assets/{_baseUniq-BvPjRimO.js → _baseUniq-CsF04anE.js} +1 -1
- package/dist/assets/{arc-BzZyqc6p.js → arc-BOacYe6N.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-B1oszHzA.js → architectureDiagram-Q4EWVU46-BSUEfjC_.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Ndp4o_QB.js → blockDiagram-DXYQGD6D-CC37UGtW.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DlDE-ALt.js → c4Diagram-AHTNJAMY-IzgRD4uO.js} +1 -1
- package/dist/assets/{channel-BhXx9y6q.js → channel-C6PxyQUn.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-CM-2e6q_.js → chunk-4BX2VUAB-cdQt_dcU.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-GWLkQKRO.js → chunk-4TB4RGXK-BpTDA_Pb.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B6LOFqRZ.js → chunk-55IACEB6-CcitNo0P.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-Ct3e09Ku.js → chunk-EDXVE4YY-BjKwZ9I7.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DK-vXpZP.js → chunk-FMBD7UC4-vZDVX3tI.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CWxZc_DU.js → chunk-OYMX7WX6-DGkYahGo.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-jp8ISHue.js → chunk-QZHKN3VN-C9065hSE.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-DtYet6WV.js → chunk-YZCP3GAM-D_pcTg6_.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BUmU_G-i.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-BUmU_G-i.js +1 -0
- package/dist/assets/clone-CvoOne2K.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-lNC-slVX.js → cose-bilkent-S5V4N54A-BPPFMIGv.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-vm6U82qq.js → dagre-KV5264BT-fjzhKO5o.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-Dq3FXh-U.js → diagram-5BDNPKRD-5L6FzTry.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-BxF85wcF.js → diagram-G4DWMVQ6-GOwcGpff.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CFCTeMz0.js → diagram-MMDJMWI5-BDsFE25W.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Bui5Azdg.js → diagram-TYMM5635-BqjpbSGH.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-DrpUO1Kb.js → erDiagram-SMLLAGMA-DWO3Dk1v.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-Bb3amxiK.js → flowDiagram-DWJPFMVM-Br4nNBVd.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BRwCFM3C.js → ganttDiagram-T4ZO3ILL-9sN-VOhw.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DVXbBwhs.js → gitGraphDiagram-UUTBAWPF-BsPcTAKr.js} +1 -1
- package/dist/assets/{graph-Dh22wYWs.js → graph-o31GzaB6.js} +1 -1
- package/dist/assets/{index-CfFFgxxf.js → index-B3b0lRUB.js} +1 -1
- package/dist/assets/{index-CWZz2pyj.js → index-CU0gt6m1.js} +2 -2
- package/dist/assets/{index-BwZB7UwN.js → index-CXMr3tiK.js} +1 -1
- package/dist/assets/{index-UlO7Q5dX.js → index-CuvDJTY7.js} +1 -1
- package/dist/assets/{index-Cfm4N4ao.js → index-DAjx9lT3.js} +1 -1
- package/dist/assets/{index-xZXOwglE.js → index-DyV81jSF.js} +1 -1
- package/dist/assets/{index-CQ1Nswa3.js → index-PDvAAbti.js} +1 -1
- package/dist/assets/{index-e8wejtj_.js → index-tZqiJJGq.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-cQPbF7Q6.js → infoDiagram-42DDH7IO-VUMzEo4o.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-cp-ir90Y.js → ishikawaDiagram-UXIWVN3A-BuzrU3sx.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-aoMcyNLC.js → journeyDiagram-VCZTEJTY-BZtR5BAF.js} +1 -1
- package/dist/assets/{jszip.min-C7BBsGL5.js → jszip.min-CtodH5MV.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Cugr0o0f.js → kanban-definition-6JOO6SKY-Cj_97xXb.js} +1 -1
- package/dist/assets/{layout-CL0eiocE.js → layout-Du0I-tlh.js} +1 -1
- package/dist/assets/{linear-D6EJvwNs.js → linear-BJtxCASI.js} +1 -1
- package/dist/assets/{mermaid.core-CNYcCP19.js → mermaid.core-QlorX0Yw.js} +2 -2
- package/dist/assets/{min-dFBWKmZ_.js → min-BVnQfunZ.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-4fbr7E2n.js → mindmap-definition-QFDTVHPH-Bh_KL4qU.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-UHWzU_JW.js → pieDiagram-DEJITSTG-Bvv0rC_q.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-e87bZkAb.js → quadrantDiagram-34T5L4WZ-Bw8cGCRU.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-l_97YNSi.js → requirementDiagram-MS252O5E-Cg9jQ71h.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-D3JFnltO.js → sankeyDiagram-XADWPNL6-BXJo6-CD.js} +1 -1
- package/dist/assets/{seqResourceLoaders-ChbSSNu8.css → seqResourceLoaders-CxUFE36z.css} +1 -1
- package/dist/assets/seqResourceLoaders-nGCs11zu.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-5jDKcsRZ.js → sequenceDiagram-FGHM5R23-D7zpGxwq.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-D5_gBKn8.js → stateDiagram-FHFEXIEX-Der2CqR1.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP5uQ5EH.js → stateDiagram-v2-QKLJ7IA2-DetvG_Lc.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-7IVQRwK1.js → timeline-definition-GMOUNBTQ-0UenzEZ6.js} +1 -1
- package/dist/assets/{vendor-antd-CfuxfYfy.js → vendor-antd-I6I2F7Rv.js} +1 -1
- package/dist/assets/{vendor-codemirror-C4Jn4TTc.js → vendor-codemirror-Zkm6UHQg.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BreKt5Hd.js → vendor-mdxeditor-CC09MDyZ.js} +2 -2
- package/dist/assets/{vendor-qrcode-CutTqXgh.js → vendor-qrcode-gZpmSeeh.js} +1 -1
- package/dist/assets/{vendor-virtuoso-Cc9ovCDf.js → vendor-virtuoso-BGwznQ5y.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-D6qzGquN.js → vennDiagram-DHZGUBPP-COzYyZ5h.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DzAFqZmY.js → wardley-RL74JXVD-VZdOtN8t.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-CImP9kv8.js → wardleyDiagram-NUSXRM2D-CVkL75Qy.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Co45rRaM.js → xychartDiagram-5P7HB3ND-6CHBAYyF.js} +1 -1
- package/dist/index.html +4 -4
- package/node_modules/@ccv/core/src/context-rules.js +6 -2
- package/package.json +1 -1
- package/server/lib/chat-queue.js +14 -1
- package/server/lib/config-backup.js +4 -2
- package/server/lib/cron.js +181 -0
- package/server/lib/file-access-policy.js +45 -1
- package/server/lib/im-deny.js +12 -9
- package/server/lib/json-store.js +27 -1
- package/server/lib/model-system-prompts.js +4 -1
- package/server/lib/project-prefs.js +4 -1
- package/server/lib/schedule-model.js +149 -0
- package/server/lib/scheduled-task-executor.js +97 -0
- package/server/lib/scheduled-task-runs-store.js +99 -0
- package/server/lib/scheduled-task-scheduler.js +254 -0
- package/server/lib/scheduled-task-store.js +511 -0
- package/server/lib/sdk-manager.js +283 -12
- package/server/pty-manager.js +13 -2
- package/server/routes/preferences.js +24 -1
- package/server/routes/scheduled-tasks.js +310 -0
- package/server/server.js +135 -0
- package/server/system-prompt-templates/presets/kimi-k3.md +4 -0
- package/dist/assets/classDiagram-6PBFFD2Q-C0dwAyuU.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-C0dwAyuU.js +0 -1
- package/dist/assets/clone-fiofFlrV.js +0 -1
- package/dist/assets/seqResourceLoaders-t8OZr44t.js +0 -2
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
// Scheduled-task store — task definitions + scheduling state live in preferences.json
|
|
2
|
+
// (`scheduledTasks` key); run history lives in scheduled-task-runs.json.
|
|
3
|
+
//
|
|
4
|
+
// Why preferences.json: the task definition is user configuration, so it rides the same
|
|
5
|
+
// locked/atomic prefs file the rest of the user's config uses (one place to back up and
|
|
6
|
+
// maintain). Scheduling state (nextRunAt/claim/jitter) travels WITH the definition so the
|
|
7
|
+
// claim's cross-process mutual exclusion stays a single locked read-modify-write on
|
|
8
|
+
// `preferences.json.lock` — splitting state from definition would invite double-fires.
|
|
9
|
+
//
|
|
10
|
+
// Why runs are separate (scheduled-task-runs-store.js): history is expendable,
|
|
11
|
+
// append-mostly, and grows per task; keeping it out of prefs keeps every claim/finalize
|
|
12
|
+
// prefs rewrite small and lets history self-heal (tolerant) while the registry is strict.
|
|
13
|
+
//
|
|
14
|
+
// Corruption contract:
|
|
15
|
+
// - prefs corrupt OR `scheduledTasks` present-but-malformed → corrupt:true on read, and
|
|
16
|
+
// EVERY writer refuses (never silently erase tasks). strictCorrupt on the kernel guards
|
|
17
|
+
// the whole prefs file; `_stateForWrite` adds the nested-shape guard.
|
|
18
|
+
// - Other prefs writers stay tolerant by design; json-store now preserves the corrupt
|
|
19
|
+
// bytes to `<file>.corrupt-<ts>` before any tolerant self-heal, so nothing is lost.
|
|
20
|
+
//
|
|
21
|
+
// Legacy fallback (one-directional, read-only): when prefs has NO `scheduledTasks` key and
|
|
22
|
+
// a legacy `<root>/scheduled-tasks.json` exists with ≥1 valid task, reads fall back to it
|
|
23
|
+
// (tasks exposed with `legacy:true`, claim hidden, runs stripped). NO writer may touch the
|
|
24
|
+
// legacy path — update/delete/claim/sweep pre-check `legacy` and refuse without writing;
|
|
25
|
+
// only createTask may create the `scheduledTasks` key (which then tombstones the legacy
|
|
26
|
+
// file forever). The feature is unreleased, so this branch is a courtesy display only.
|
|
27
|
+
//
|
|
28
|
+
// Secret policy: prompts are user content (may carry secrets) → prefs stays 0600 and the
|
|
29
|
+
// whole prefs file is denied to the raw file API / IM readers (file-access-policy.js).
|
|
30
|
+
//
|
|
31
|
+
// Boundary: L1-lib. Imports L1 (cron/schedule-model/json-store/scheduled-task-runs-store)
|
|
32
|
+
// + L0-leaf (pid-alive) + node builtins. Must NOT import L2 (pty-manager/sdk-manager/…).
|
|
33
|
+
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { randomBytes } from 'node:crypto';
|
|
36
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { mutateJson, readJsonStrict } from './json-store.js';
|
|
38
|
+
import { isPidAlive } from './pid-alive.js';
|
|
39
|
+
import { normalizeSchedule, nextBaseFor, occurrencesAtOrBefore, jitterForTaskId } from './schedule-model.js';
|
|
40
|
+
import { appendRuns, appendRun, deleteRunsForTask, findRunByClaimId } from './scheduled-task-runs-store.js';
|
|
41
|
+
import { reportSwallowed } from './error-report.js';
|
|
42
|
+
|
|
43
|
+
const LEGACY_FILE_NAME = 'scheduled-tasks.json';
|
|
44
|
+
const PREFS_FILE_NAME = 'preferences.json';
|
|
45
|
+
const VERSION = 1;
|
|
46
|
+
const MAX_TASKS_PER_PROJECT = 50;
|
|
47
|
+
const MAX_RUNS = 20;
|
|
48
|
+
const MAX_PROMPT_LEN = 20_000;
|
|
49
|
+
const SUMMARY_LEN = 200;
|
|
50
|
+
const CATCH_UP_WINDOW_MS = 7 * 24 * 3600 * 1000; // 7 days
|
|
51
|
+
// A claim whose owner pid is unreadable is reclaimed after this age (pid-reuse
|
|
52
|
+
// backstop). 2h comfortably covers the longest legit run (spawn 30min).
|
|
53
|
+
const STALE_CLAIM_MS = 2 * 3600 * 1000;
|
|
54
|
+
// Jitter applies only to recurring tasks and is capped at half the period so a
|
|
55
|
+
// `*/n` cron doesn't turn every fire into a late catch-up.
|
|
56
|
+
const JITTER_MAX_MS = 120_000;
|
|
57
|
+
|
|
58
|
+
export function prefsFilePath(root) { return join(root, PREFS_FILE_NAME); }
|
|
59
|
+
export function legacyTasksFilePath(root) { return join(root, LEGACY_FILE_NAME); }
|
|
60
|
+
|
|
61
|
+
// ─── read path ───────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
let _legacyReported = false;
|
|
64
|
+
|
|
65
|
+
function _isValidTask(t) { return t && typeof t === 'object' && typeof t.id === 'string'; }
|
|
66
|
+
|
|
67
|
+
// Validate the nested scheduledTasks shape. Key ABSENT → null (legacy may apply).
|
|
68
|
+
// Present but wrong shape/version → 'corrupt' (never silently reset; never fall back to
|
|
69
|
+
// legacy, else a deliberately emptied registry could resurrect stale prompts).
|
|
70
|
+
function _tasksFromPrefs(prefs) {
|
|
71
|
+
if (!Object.prototype.hasOwnProperty.call(prefs, 'scheduledTasks')) return null;
|
|
72
|
+
const st = prefs.scheduledTasks;
|
|
73
|
+
if (!st || typeof st !== 'object' || Array.isArray(st)) return 'corrupt';
|
|
74
|
+
if (st.version !== VERSION) return 'corrupt';
|
|
75
|
+
if (!Array.isArray(st.tasks)) return 'corrupt';
|
|
76
|
+
return st.tasks.filter(_isValidTask);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Strip fields that must never cross the read boundary: runs live in the runs file,
|
|
80
|
+
// claim stays internal-ish (routes derive `running` from it) but legacy claims are
|
|
81
|
+
// neutralized to avoid a permanently-stuck "running" display.
|
|
82
|
+
function _exposeTask(t, { neutralizeClaim = false } = {}) {
|
|
83
|
+
const { runs, ...rest } = t;
|
|
84
|
+
if (neutralizeClaim) rest.claim = null;
|
|
85
|
+
return rest;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read the registry. Never throws. */
|
|
89
|
+
export function readScheduledTasks(root) {
|
|
90
|
+
const prefs = readJsonStrict(prefsFilePath(root));
|
|
91
|
+
if (prefs.state === 'corrupt') return { version: VERSION, tasks: [], corrupt: true, legacy: false };
|
|
92
|
+
if (prefs.state === 'ok') {
|
|
93
|
+
const tasks = _tasksFromPrefs(prefs.data);
|
|
94
|
+
if (tasks === 'corrupt') return { version: VERSION, tasks: [], corrupt: true, legacy: false };
|
|
95
|
+
if (tasks !== null) return { version: VERSION, tasks: tasks.map(t => _exposeTask(t)), corrupt: false, legacy: false };
|
|
96
|
+
// key absent → maybe legacy
|
|
97
|
+
}
|
|
98
|
+
// prefs missing entirely OR key absent: legacy fallback (read-only).
|
|
99
|
+
const legacyFile = legacyTasksFilePath(root);
|
|
100
|
+
if (existsSync(legacyFile)) {
|
|
101
|
+
let data = null;
|
|
102
|
+
try { data = JSON.parse(readFileSync(legacyFile, 'utf-8')); } catch { data = null; }
|
|
103
|
+
const tasks = data && Array.isArray(data.tasks) ? data.tasks.filter(_isValidTask) : [];
|
|
104
|
+
if (tasks.length > 0) {
|
|
105
|
+
if (!_legacyReported) {
|
|
106
|
+
_legacyReported = true;
|
|
107
|
+
reportSwallowed('scheduled-task-store.legacy', new Error('legacy scheduled-tasks.json present; read-only fallback (never written)'));
|
|
108
|
+
}
|
|
109
|
+
return { version: VERSION, tasks: tasks.map(t => _exposeTask(t, { neutralizeClaim: true })), corrupt: false, legacy: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { version: VERSION, tasks: [], corrupt: false, legacy: false };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** True when the registry is in legacy read-only mode (writers must refuse). */
|
|
116
|
+
function _isLegacy(root) { return readScheduledTasks(root).legacy === true; }
|
|
117
|
+
|
|
118
|
+
// ─── write path ──────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
// Validate + seed the nested state for a write. Throws (never silently overwrites) when
|
|
121
|
+
// the key exists but is malformed.
|
|
122
|
+
function _stateForWrite(prefs) {
|
|
123
|
+
if (!Object.prototype.hasOwnProperty.call(prefs, 'scheduledTasks')) {
|
|
124
|
+
prefs.scheduledTasks = { version: VERSION, tasks: [] };
|
|
125
|
+
return prefs.scheduledTasks;
|
|
126
|
+
}
|
|
127
|
+
const st = prefs.scheduledTasks;
|
|
128
|
+
const valid = st && typeof st === 'object' && !Array.isArray(st) && st.version === VERSION && Array.isArray(st.tasks);
|
|
129
|
+
if (!valid) {
|
|
130
|
+
const err = new Error('scheduled-task-store: refusing to overwrite malformed preferences.json scheduledTasks');
|
|
131
|
+
err.code = 'JSON_STORE_CORRUPT';
|
|
132
|
+
reportSwallowed('scheduled-task-store.state-corrupt', err);
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
return st;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Locked read-modify-write of the task registry inside preferences.json.
|
|
140
|
+
* `mutator(reg)` runs synchronously (reg = { version, tasks }); it must NOT touch the
|
|
141
|
+
* runs store or any other lock. Run records are returned via the shared `pendingRuns`
|
|
142
|
+
* array pattern at each call site and flushed AFTER this resolves (lock order: prefs →
|
|
143
|
+
* runs, except finalize which is runs → prefs; never nested).
|
|
144
|
+
*/
|
|
145
|
+
export function mutateScheduledTasks(root, mutator) {
|
|
146
|
+
return mutateJson(prefsFilePath(root), async (prefs) => {
|
|
147
|
+
const reg = _stateForWrite(prefs);
|
|
148
|
+
reg.version = VERSION;
|
|
149
|
+
const r = await mutator(reg);
|
|
150
|
+
return r === undefined ? reg : r;
|
|
151
|
+
}, { mode: 0o600, pretty: true, strictCorrupt: true, fallback: {} });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _newId(prefix) { return `${prefix}_${randomBytes(8).toString('hex')}`; }
|
|
155
|
+
function _summarize(prompt) {
|
|
156
|
+
const s = String(prompt == null ? '' : prompt).replace(/\s+/g, ' ').trim();
|
|
157
|
+
return s.length > SUMMARY_LEN ? `${s.slice(0, SUMMARY_LEN - 1)}…` : s;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _makeRun(task, fields) {
|
|
161
|
+
return { id: _newId('run'), summary: _summarize(task.prompt), ...fields };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// History is best-effort: a flush failure is reported but never fails the state change
|
|
165
|
+
// that produced it. (finalizeTask is the exception — see its own contract.)
|
|
166
|
+
async function _flushRuns(root, entries) {
|
|
167
|
+
if (!entries || entries.length === 0) return;
|
|
168
|
+
try { await appendRuns(root, entries); }
|
|
169
|
+
catch (err) { reportSwallowed('scheduled-task-store.runs-flush', err); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _computeNext(task, now) {
|
|
173
|
+
const base = nextBaseFor(task.schedule, now);
|
|
174
|
+
if (base === null) { task.nextBaseAt = null; task.nextRunAt = null; return; }
|
|
175
|
+
task.nextBaseAt = base;
|
|
176
|
+
if (task.schedule.kind === 'once') {
|
|
177
|
+
task.jitterMs = 0;
|
|
178
|
+
task.nextRunAt = base;
|
|
179
|
+
} else {
|
|
180
|
+
if (typeof task.jitterMs !== 'number') task.jitterMs = jitterForTaskId(task.id, _jitterCapFor(task.schedule));
|
|
181
|
+
task.jitterMs = Math.min(task.jitterMs, _jitterCapFor(task.schedule));
|
|
182
|
+
task.nextRunAt = base + task.jitterMs;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _jitterCapFor(schedule) {
|
|
187
|
+
if (schedule.kind === 'hourly') return Math.min(JITTER_MAX_MS, 30 * 60 * 1000 / 2);
|
|
188
|
+
if (schedule.kind === 'cron') {
|
|
189
|
+
const expr = schedule.expr || '';
|
|
190
|
+
const minuteField = expr.trim().split(/\s+/)[0] || '*';
|
|
191
|
+
if (minuteField === '*') return Math.min(JITTER_MAX_MS, 30 * 1000);
|
|
192
|
+
const m = /^\*\/(\d+)$/.exec(minuteField);
|
|
193
|
+
if (m) { const periodMs = Number(m[1]) * 60 * 1000; return Math.min(JITTER_MAX_MS, Math.floor(periodMs / 2)); }
|
|
194
|
+
}
|
|
195
|
+
return JITTER_MAX_MS;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── public CRUD / scheduler primitives ─────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export function listTasks(root, project) {
|
|
201
|
+
const { tasks } = readScheduledTasks(root);
|
|
202
|
+
return project ? tasks.filter(t => t.project === project) : tasks;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function getTask(root, id) {
|
|
206
|
+
return readScheduledTasks(root).tasks.find(t => t.id === id) || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Create a task. draft = { project, cwd, prompt, mode, schedule, enabled }.
|
|
211
|
+
* The ONLY writer allowed to create the `scheduledTasks` prefs key (legacy tombstone).
|
|
212
|
+
*/
|
|
213
|
+
export async function createTask(root, draft, now = Date.now()) {
|
|
214
|
+
const norm = normalizeSchedule(draft.schedule, now);
|
|
215
|
+
if (!norm.ok) return { ok: false, reason: norm.error, detail: norm.detail };
|
|
216
|
+
const prompt = String(draft.prompt == null ? '' : draft.prompt).trim();
|
|
217
|
+
if (!prompt) return { ok: false, reason: 'invalid_prompt' };
|
|
218
|
+
if (prompt.length > MAX_PROMPT_LEN) return { ok: false, reason: 'invalid_prompt', detail: 'too long' };
|
|
219
|
+
if (draft.mode !== 'inject' && draft.mode !== 'spawn') return { ok: false, reason: 'invalid_mode' };
|
|
220
|
+
|
|
221
|
+
return mutateScheduledTasks(root, (reg) => {
|
|
222
|
+
const sameProject = reg.tasks.filter(t => t.project === draft.project);
|
|
223
|
+
if (sameProject.length >= MAX_TASKS_PER_PROJECT) return { ok: false, reason: 'task_limit' };
|
|
224
|
+
const task = {
|
|
225
|
+
id: _newId('st'),
|
|
226
|
+
project: draft.project,
|
|
227
|
+
cwd: draft.cwd,
|
|
228
|
+
prompt,
|
|
229
|
+
mode: draft.mode,
|
|
230
|
+
schedule: norm.schedule,
|
|
231
|
+
permissionMode: draft.mode === 'spawn' ? (draft.permissionMode === 'inherit' ? 'inherit' : 'default') : undefined,
|
|
232
|
+
enabled: draft.enabled !== false,
|
|
233
|
+
jitterMs: undefined,
|
|
234
|
+
nextBaseAt: null,
|
|
235
|
+
nextRunAt: null,
|
|
236
|
+
lastRunAt: null,
|
|
237
|
+
lastResult: null,
|
|
238
|
+
lastError: null,
|
|
239
|
+
createdAt: now,
|
|
240
|
+
updatedAt: now,
|
|
241
|
+
pausedAt: null,
|
|
242
|
+
completedAt: null,
|
|
243
|
+
claim: null,
|
|
244
|
+
};
|
|
245
|
+
_computeNext(task, now);
|
|
246
|
+
reg.tasks.push(task);
|
|
247
|
+
return { ok: true, task };
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Update prompt / mode / schedule / enabled. Recomputes next ONLY when schedule or
|
|
253
|
+
* enabled changed. Never clobbers an active claim. Resume does NOT catch up; it records
|
|
254
|
+
* a skipped/paused run (flushed to the runs file after the prefs write).
|
|
255
|
+
*/
|
|
256
|
+
export async function updateTask(root, id, patch, now = Date.now()) {
|
|
257
|
+
if (_isLegacy(root)) return { ok: false, reason: 'task_not_found' };
|
|
258
|
+
const pendingRuns = [];
|
|
259
|
+
const out = await mutateScheduledTasks(root, (reg) => {
|
|
260
|
+
const task = reg.tasks.find(t => t.id === id);
|
|
261
|
+
if (!task) return { ok: false, reason: 'task_not_found' };
|
|
262
|
+
let scheduleChanged = false;
|
|
263
|
+
let enabledChanged = false;
|
|
264
|
+
|
|
265
|
+
if (patch.prompt !== undefined) {
|
|
266
|
+
const prompt = String(patch.prompt).trim();
|
|
267
|
+
if (!prompt || prompt.length > MAX_PROMPT_LEN) return { ok: false, reason: 'invalid_prompt' };
|
|
268
|
+
task.prompt = prompt;
|
|
269
|
+
}
|
|
270
|
+
if (patch.mode !== undefined) {
|
|
271
|
+
if (patch.mode !== 'inject' && patch.mode !== 'spawn') return { ok: false, reason: 'invalid_mode' };
|
|
272
|
+
task.mode = patch.mode;
|
|
273
|
+
}
|
|
274
|
+
if (patch.schedule !== undefined) {
|
|
275
|
+
// once.at>=now+60s is only enforced when `at` itself changes: the UI always sends the
|
|
276
|
+
// full schedule back on save, so a prompt edit on an expired once task would otherwise
|
|
277
|
+
// be rejected with once_in_past.
|
|
278
|
+
const atUnchanged = patch.schedule && patch.schedule.kind === 'once'
|
|
279
|
+
&& task.schedule && task.schedule.kind === 'once'
|
|
280
|
+
&& Number(patch.schedule.at) === Number(task.schedule.at);
|
|
281
|
+
const norm = normalizeSchedule(patch.schedule, now);
|
|
282
|
+
if (!norm.ok) {
|
|
283
|
+
if (!(atUnchanged && norm.error === 'once_in_past')) {
|
|
284
|
+
return { ok: false, reason: norm.error, detail: norm.detail };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
task.schedule = atUnchanged ? task.schedule : norm.schedule;
|
|
288
|
+
if (!atUnchanged) delete task.jitterMs;
|
|
289
|
+
scheduleChanged = true;
|
|
290
|
+
}
|
|
291
|
+
if (patch.enabled !== undefined && patch.enabled !== task.enabled) {
|
|
292
|
+
task.enabled = !!patch.enabled;
|
|
293
|
+
enabledChanged = true;
|
|
294
|
+
if (!task.enabled) {
|
|
295
|
+
task.pausedAt = now;
|
|
296
|
+
} else {
|
|
297
|
+
if (task.pausedAt != null) {
|
|
298
|
+
const occ = occurrencesAtOrBefore(task.schedule, task.pausedAt, now);
|
|
299
|
+
if (occ && occ.count > 0) {
|
|
300
|
+
pendingRuns.push({ taskId: task.id, run: _makeRun(task, { status: 'skipped', reason: 'paused', at: now, finishedAt: now, missedCount: occ.count, catchUp: false, manual: false }) });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
task.pausedAt = null;
|
|
304
|
+
task.completedAt = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (scheduleChanged || (enabledChanged && task.enabled)) _computeNext(task, now);
|
|
308
|
+
task.updatedAt = now;
|
|
309
|
+
return { ok: true, task };
|
|
310
|
+
});
|
|
311
|
+
await _flushRuns(root, pendingRuns);
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Delete a task (and its history). An actively-claimed task can't be deleted. */
|
|
316
|
+
export async function deleteTask(root, id, now = Date.now()) {
|
|
317
|
+
if (_isLegacy(root)) return { ok: false, reason: 'task_not_found' };
|
|
318
|
+
const out = await mutateScheduledTasks(root, (reg) => {
|
|
319
|
+
const idx = reg.tasks.findIndex(t => t.id === id);
|
|
320
|
+
if (idx < 0) return { ok: false, reason: 'task_not_found' };
|
|
321
|
+
const task = reg.tasks[idx];
|
|
322
|
+
if (task.claim && isPidAlive(task.claim.pid) && (now - task.claim.at) < STALE_CLAIM_MS) {
|
|
323
|
+
return { ok: false, reason: 'task_running' };
|
|
324
|
+
}
|
|
325
|
+
reg.tasks.splice(idx, 1);
|
|
326
|
+
return { ok: true };
|
|
327
|
+
});
|
|
328
|
+
if (out && out.ok) {
|
|
329
|
+
try { await deleteRunsForTask(root, id); }
|
|
330
|
+
catch (err) { reportSwallowed('scheduled-task-store.runs-delete', err); }
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Reclaim a dead/expired claim inside the prefs lock. If the runs file already holds a
|
|
337
|
+
* terminal run for this claimRunId (finalize appended but crashed before the CAS), the
|
|
338
|
+
* once-task restores its terminal state instead of recording a misleading 'interrupted'.
|
|
339
|
+
* Returns true when the claim was reclaimed.
|
|
340
|
+
*/
|
|
341
|
+
function _reclaimClaim(root, task, now, pendingRuns) {
|
|
342
|
+
const c = task.claim;
|
|
343
|
+
if (!c) return false;
|
|
344
|
+
// Does the runs file already know how this claim ended?
|
|
345
|
+
let terminal = null;
|
|
346
|
+
try { terminal = findRunByClaimId(root, task.id, c.runId); }
|
|
347
|
+
catch (err) { reportSwallowed('scheduled-task-store.reclaim-lookup', err); }
|
|
348
|
+
if (terminal && task.schedule.kind === 'once') {
|
|
349
|
+
// Finalize landed but the CAS crashed: restore the terminal state, don't re-mark.
|
|
350
|
+
task.lastRunAt = terminal.finishedAt || now;
|
|
351
|
+
task.lastResult = terminal.status;
|
|
352
|
+
task.lastError = terminal.error ?? terminal.reason ?? null;
|
|
353
|
+
if (terminal.status === 'dispatched' || terminal.status === 'completed') task.completedAt = terminal.finishedAt || now;
|
|
354
|
+
else task.enabled = false;
|
|
355
|
+
task.claim = null;
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
pendingRuns.push({ taskId: task.id, run: _makeRun(task, { status: 'error', reason: 'interrupted', at: c.at, finishedAt: now, catchUp: !!c.catchUp, manual: !!c.manual, claimRunId: c.runId }) });
|
|
359
|
+
task.claim = null;
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Atomically claim the NEXT fire of a task (the multi-process mutual-exclusion point).
|
|
365
|
+
* The schedule is advanced (or the once-task completed) INSIDE the prefs lock, so a
|
|
366
|
+
* crashing executor never double-fires.
|
|
367
|
+
*/
|
|
368
|
+
export async function claimTask(root, id, now = Date.now(), { manual = false, instanceId = '' } = {}) {
|
|
369
|
+
if (_isLegacy(root)) return { ok: false, reason: 'not-found' };
|
|
370
|
+
const pendingRuns = [];
|
|
371
|
+
const out = await mutateScheduledTasks(root, (reg) => {
|
|
372
|
+
const task = reg.tasks.find(t => t.id === id);
|
|
373
|
+
if (!task) return { ok: false, reason: 'not-found' };
|
|
374
|
+
|
|
375
|
+
if (task.claim) {
|
|
376
|
+
const alive = isPidAlive(task.claim.pid);
|
|
377
|
+
const fresh = (now - task.claim.at) < STALE_CLAIM_MS;
|
|
378
|
+
if (alive && fresh) return { ok: false, reason: 'running' };
|
|
379
|
+
_reclaimClaim(root, task, now, pendingRuns);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const runId = _newId('run');
|
|
383
|
+
if (manual) {
|
|
384
|
+
if (task.enabled && task.nextRunAt != null && task.nextRunAt <= now) _advancePastDue(task, now);
|
|
385
|
+
task.claim = { runId, at: now, pid: process.pid, instanceId, manual: true, catchUp: false, missedCount: 0 };
|
|
386
|
+
return { ok: true, action: 'claimed', task, runId, at: now, manual: true, catchUp: false, missedCount: 0 };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!task.enabled) return { ok: false, reason: 'disabled' };
|
|
390
|
+
if (task.nextRunAt == null || now < task.nextRunAt) return { ok: false, reason: 'not-due' };
|
|
391
|
+
|
|
392
|
+
if (task.schedule.kind === 'once') {
|
|
393
|
+
const overdue = now - task.schedule.at;
|
|
394
|
+
if (overdue > CATCH_UP_WINDOW_MS) {
|
|
395
|
+
pendingRuns.push({ taskId: task.id, run: _makeRun(task, { status: 'missed', reason: 'stale', at: now, finishedAt: now, claimRunId: runId }) });
|
|
396
|
+
task.enabled = false; task.nextBaseAt = null; task.nextRunAt = null;
|
|
397
|
+
return { ok: true, action: 'missed', task };
|
|
398
|
+
}
|
|
399
|
+
task.claim = { runId, at: now, pid: process.pid, instanceId, manual: false, catchUp: overdue > 0, missedCount: 0 };
|
|
400
|
+
task.nextBaseAt = null; task.nextRunAt = null;
|
|
401
|
+
return { ok: true, action: 'claimed', task, runId, at: now, manual: false, catchUp: overdue > 0, missedCount: 0 };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const occ = occurrencesAtOrBefore(task.schedule, task.nextBaseAt ?? now, now);
|
|
405
|
+
if (!occ || occ.stale || (now - occ.latest) > CATCH_UP_WINDOW_MS) {
|
|
406
|
+
pendingRuns.push({ taskId: task.id, run: _makeRun(task, { status: 'missed', reason: 'stale', at: now, finishedAt: now, missedCount: occ ? occ.count : 0, claimRunId: runId }) });
|
|
407
|
+
_computeNext(task, now);
|
|
408
|
+
return { ok: true, action: 'missed', task };
|
|
409
|
+
}
|
|
410
|
+
const missedCount = Math.max(0, occ.count - 1);
|
|
411
|
+
task.claim = { runId, at: now, pid: process.pid, instanceId, manual: false, catchUp: occ.count > 1, missedCount };
|
|
412
|
+
task.nextBaseAt = nextBaseFor(task.schedule, occ.latest);
|
|
413
|
+
task.nextRunAt = task.nextBaseAt === null ? null : task.nextBaseAt + task.jitterMs;
|
|
414
|
+
return { ok: true, action: 'claimed', task, runId, at: now, manual: false, catchUp: occ.count > 1, missedCount };
|
|
415
|
+
});
|
|
416
|
+
await _flushRuns(root, pendingRuns);
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Advance a due recurring task's schedule past the pending occurrence (used by manual run).
|
|
421
|
+
function _advancePastDue(task, now) {
|
|
422
|
+
if (task.schedule.kind === 'once') { task.nextBaseAt = null; task.nextRunAt = null; return; }
|
|
423
|
+
const occ = occurrencesAtOrBefore(task.schedule, task.nextBaseAt ?? now, now);
|
|
424
|
+
const anchor = occ && !occ.stale ? occ.latest : now;
|
|
425
|
+
task.nextBaseAt = nextBaseFor(task.schedule, anchor);
|
|
426
|
+
task.nextRunAt = task.nextBaseAt === null ? null : task.nextBaseAt + (task.jitterMs || 0);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Write the outcome of a claimed run. Ordering is runs-first, then the prefs CAS:
|
|
431
|
+
* - append fails → claim is NOT released (returns runs_unavailable); the stale sweep /
|
|
432
|
+
* next claim's reclaim restores the terminal state from the runs file (once) or marks
|
|
433
|
+
* interrupted (recurring). A completed run is never silently dropped while the claim
|
|
434
|
+
* is cleared underneath it.
|
|
435
|
+
* - CAS fails → the run is already recorded (orphan, accepted + reported); the newer
|
|
436
|
+
* claim is not touched.
|
|
437
|
+
* claimRunId makes a later reclaim/retry idempotent.
|
|
438
|
+
*/
|
|
439
|
+
export async function finalizeTask(root, id, claim, result, now = Date.now()) {
|
|
440
|
+
if (_isLegacy(root)) return { ok: false, reason: 'not-found' };
|
|
441
|
+
// Pre-check (unlocked): known-stale finalizes append nothing.
|
|
442
|
+
const cur = getTask(root, id);
|
|
443
|
+
if (!cur) return { ok: false, reason: 'not-found' };
|
|
444
|
+
if (!cur.claim || cur.claim.runId !== claim.runId) return { ok: false, reason: 'claim-superseded' };
|
|
445
|
+
|
|
446
|
+
const run = _makeRun(cur, {
|
|
447
|
+
status: result.status,
|
|
448
|
+
reason: result.reason ?? null,
|
|
449
|
+
error: result.error ?? null,
|
|
450
|
+
at: claim.at,
|
|
451
|
+
finishedAt: now,
|
|
452
|
+
durationMs: result.durationMs ?? (now - claim.at),
|
|
453
|
+
catchUp: !!claim.catchUp,
|
|
454
|
+
missedCount: claim.missedCount || 0,
|
|
455
|
+
manual: !!claim.manual,
|
|
456
|
+
sessionId: result.sessionId ?? null,
|
|
457
|
+
claimRunId: claim.runId,
|
|
458
|
+
});
|
|
459
|
+
try {
|
|
460
|
+
await appendRun(root, id, run);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
reportSwallowed('scheduled-task-store.finalize-run', err);
|
|
463
|
+
return { ok: false, reason: 'runs_unavailable' };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return mutateScheduledTasks(root, (reg) => {
|
|
467
|
+
const task = reg.tasks.find(t => t.id === id);
|
|
468
|
+
if (!task) return { ok: false, reason: 'not-found' };
|
|
469
|
+
if (!task.claim || task.claim.runId !== claim.runId) {
|
|
470
|
+
// The run is already in the history file — orphan is the accepted cost of runs-first.
|
|
471
|
+
reportSwallowed('scheduled-task-store.finalize-superseded-after-run', new Error(`run recorded but claim superseded for task ${id}`));
|
|
472
|
+
return { ok: false, reason: 'claim-superseded' };
|
|
473
|
+
}
|
|
474
|
+
task.lastRunAt = now;
|
|
475
|
+
task.lastResult = result.status;
|
|
476
|
+
task.lastError = result.error ?? result.reason ?? null;
|
|
477
|
+
if (task.schedule.kind === 'once') {
|
|
478
|
+
// A once task completes ONLY on a real success; a skipped/failed run disables it
|
|
479
|
+
// (terminal) so it never fires silently again.
|
|
480
|
+
if (result.status === 'dispatched' || result.status === 'completed') {
|
|
481
|
+
task.completedAt = now;
|
|
482
|
+
} else {
|
|
483
|
+
task.enabled = false;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
task.claim = null;
|
|
487
|
+
task.updatedAt = now;
|
|
488
|
+
return { ok: true };
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Reclaim claims whose owner pid died (called at scheduler start). */
|
|
493
|
+
export async function sweepStaleClaims(root, now = Date.now(), { selfInstanceId = '' } = {}) {
|
|
494
|
+
if (_isLegacy(root)) return { swept: 0 };
|
|
495
|
+
const pendingRuns = [];
|
|
496
|
+
const out = await mutateScheduledTasks(root, (reg) => {
|
|
497
|
+
let swept = 0;
|
|
498
|
+
for (const task of reg.tasks) {
|
|
499
|
+
const c = task.claim;
|
|
500
|
+
if (!c) continue;
|
|
501
|
+
const ownerAlive = isPidAlive(c.pid);
|
|
502
|
+
const staleGeneration = c.pid === process.pid && selfInstanceId && c.instanceId && c.instanceId !== selfInstanceId;
|
|
503
|
+
if (ownerAlive && !staleGeneration && (now - c.at) < STALE_CLAIM_MS) continue;
|
|
504
|
+
_reclaimClaim(root, task, now, pendingRuns);
|
|
505
|
+
swept += 1;
|
|
506
|
+
}
|
|
507
|
+
return { swept };
|
|
508
|
+
});
|
|
509
|
+
await _flushRuns(root, pendingRuns);
|
|
510
|
+
return out;
|
|
511
|
+
}
|