chainlesschain 0.45.80 → 0.46.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/package.json +1 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/{Analytics--dpzs0oZ.js → Analytics-C1AnPdMx.js} +2 -2
- package/src/assets/web-panel/assets/{AppLayout-DWXapZbP.js → AppLayout-BnvARObz.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-BoUUzGFw.js → Backup-D31iZX3l.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-CkSlXBzN.js → Chat-DiXJ3TuK.js} +1 -1
- package/src/assets/web-panel/assets/Cowork-B8ZDdRm4.js +7 -0
- package/src/assets/web-panel/assets/Cowork-CXuhlHew.css +1 -0
- package/src/assets/web-panel/assets/{Cron-xiBL6XfP.js → Cron-DBt1ueXh.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-CmgEtUKl.js → Dashboard-jt6XPIjB.js} +1 -1
- package/src/assets/web-panel/assets/{Git-DCDjvp5Z.js → Git-hwQ1oZHj.js} +2 -2
- package/src/assets/web-panel/assets/{Logs-Qz-GLplC.js → Logs-4D9p6PRM.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-qYf_sT-Y.js → McpTools-CyAUjbbs.js} +1 -1
- package/src/assets/web-panel/assets/{Memory-BxoM2XNZ.js → Memory-BMqOR7S-.js} +2 -2
- package/src/assets/web-panel/assets/{Notes-DltR8wq4.js → Notes-Cmas8i4E.js} +2 -2
- package/src/assets/web-panel/assets/{Organization-7m_PX3yo.js → Organization-DnSa58Tl.js} +4 -4
- package/src/assets/web-panel/assets/{P2P-e88KqFBm.js → P2P-BxksIBWs.js} +2 -2
- package/src/assets/web-panel/assets/{Permissions-DAY4Xy1h.js → Permissions-Bq5Qn2s3.js} +4 -4
- package/src/assets/web-panel/assets/{Projects-ylUhg9th.js → Projects-B7EM0uPg.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-DNIlBWLm.js → Providers-DAwgG5KV.js} +2 -2
- package/src/assets/web-panel/assets/{RssFeed-Dr_6vD69.js → RssFeed-HSZoRXvS.js} +2 -2
- package/src/assets/web-panel/assets/{Security-U57Q-VOj.js → Security-Cz17qBny.js} +3 -3
- package/src/assets/web-panel/assets/{Services-BUfO-jvr.js → Services-D2EsLq-v.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-D0NYT7Q8.js → Skills-C9v-f3vZ.js} +1 -1
- package/src/assets/web-panel/assets/{Tasks-WXqKX58l.js → Tasks-yMEcU0n7.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-B1zfqNTe.js → Templates-l7SvlKuB.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-CUSPGN3F.js → Wallet-BHWhLWn9.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-ZGz__UJi.js → WebAuthn-kWhFYaUK.js} +4 -4
- package/src/assets/web-panel/assets/{antd-BQNxIyr-.js → antd-D6h4fDFf.js} +82 -82
- package/src/assets/web-panel/assets/{index-BYqeR6ME.js → index-C1SPm_5l.js} +2 -2
- package/src/assets/web-panel/assets/{markdown-BeVIhIzs.js → markdown-BZsB-Dsv.js} +1 -1
- package/src/assets/web-panel/index.html +2 -2
- package/src/commands/cowork.js +695 -0
- package/src/gateways/ws/action-protocol.js +143 -2
- package/src/gateways/ws/message-dispatcher.js +3 -0
- package/src/gateways/ws/ws-server.js +18 -0
- package/src/lib/cowork-cron.js +474 -0
- package/src/lib/cowork-learning.js +438 -0
- package/src/lib/cowork-mcp-tools.js +182 -0
- package/src/lib/cowork-share.js +218 -0
- package/src/lib/cowork-task-runner.js +364 -4
- package/src/lib/cowork-task-templates.js +203 -12
- package/src/lib/cowork-template-marketplace.js +205 -0
- package/src/lib/cowork-workflow.js +571 -0
- package/src/lib/sub-agent-context.js +66 -0
- package/src/lib/workflow-expr.js +318 -0
- package/src/assets/web-panel/assets/Cowork-CPqYhoMI.css +0 -1
- package/src/assets/web-panel/assets/Cowork-DjAJ5ymV.js +0 -48
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Track running cowork tasks for cancellation
|
|
2
|
+
const _runningTasks = new Map();
|
|
3
|
+
|
|
1
4
|
export async function handleCoworkTask(server, id, ws, message) {
|
|
2
5
|
const { templateId = null, userMessage, files = [] } = message;
|
|
3
6
|
|
|
@@ -11,21 +14,66 @@ export async function handleCoworkTask(server, id, ws, message) {
|
|
|
11
14
|
return;
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
const ac = new AbortController();
|
|
18
|
+
const trackingId = `cowork-${id}`;
|
|
19
|
+
_runningTasks.set(trackingId, ac);
|
|
20
|
+
|
|
14
21
|
try {
|
|
15
|
-
const { runCoworkTask } =
|
|
22
|
+
const { runCoworkTask, runCoworkTaskParallel, runCoworkDebate } =
|
|
23
|
+
await import("../../lib/cowork-task-runner.js");
|
|
24
|
+
const { getTemplate } = await import("../../lib/cowork-task-templates.js");
|
|
25
|
+
|
|
26
|
+
// Determine execution mode: debate > parallel > sequential
|
|
27
|
+
const template = getTemplate(templateId);
|
|
28
|
+
const useDebate =
|
|
29
|
+
message.mode === "debate" ||
|
|
30
|
+
(message.mode !== "agent" && template.mode === "debate");
|
|
31
|
+
const useParallel =
|
|
32
|
+
!useDebate &&
|
|
33
|
+
(message.parallel === true ||
|
|
34
|
+
(message.parallel !== false && template.parallelStrategy === "always"));
|
|
16
35
|
|
|
17
36
|
server._send(ws, {
|
|
18
37
|
id,
|
|
19
38
|
type: "cowork:started",
|
|
20
39
|
templateId,
|
|
40
|
+
trackingId,
|
|
41
|
+
parallel: useParallel,
|
|
42
|
+
mode: useDebate ? "debate" : "agent",
|
|
21
43
|
});
|
|
22
44
|
|
|
23
|
-
const
|
|
45
|
+
const runner = useDebate
|
|
46
|
+
? runCoworkDebate
|
|
47
|
+
: useParallel
|
|
48
|
+
? runCoworkTaskParallel
|
|
49
|
+
: runCoworkTask;
|
|
50
|
+
|
|
51
|
+
const result = await runner({
|
|
24
52
|
templateId,
|
|
25
53
|
userMessage,
|
|
26
54
|
files,
|
|
27
55
|
cwd: server.projectRoot || process.cwd(),
|
|
28
56
|
llmOptions: {},
|
|
57
|
+
signal: ac.signal,
|
|
58
|
+
...(useParallel
|
|
59
|
+
? {
|
|
60
|
+
agents: message.agents || 3,
|
|
61
|
+
strategy: message.strategy,
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
...(useDebate && message.perspectives
|
|
65
|
+
? { perspectives: message.perspectives }
|
|
66
|
+
: {}),
|
|
67
|
+
onProgress: (progress) => {
|
|
68
|
+
server._send(ws, {
|
|
69
|
+
id,
|
|
70
|
+
type: "cowork:progress",
|
|
71
|
+
event: progress.type,
|
|
72
|
+
tool: progress.tool,
|
|
73
|
+
iterationCount: progress.iterationCount,
|
|
74
|
+
tokenCount: progress.tokenCount,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
29
77
|
});
|
|
30
78
|
|
|
31
79
|
server._send(ws, {
|
|
@@ -39,6 +87,18 @@ export async function handleCoworkTask(server, id, ws, message) {
|
|
|
39
87
|
artifacts: result.result?.artifacts || [],
|
|
40
88
|
toolsUsed: result.result?.toolsUsed || [],
|
|
41
89
|
iterationCount: result.result?.iterationCount || 0,
|
|
90
|
+
tokenCount: result.result?.tokenCount || 0,
|
|
91
|
+
parallel: result.parallel || false,
|
|
92
|
+
subtaskCount: result.result?.subtaskCount || 0,
|
|
93
|
+
mode: result.mode || (useDebate ? "debate" : "agent"),
|
|
94
|
+
...(useDebate
|
|
95
|
+
? {
|
|
96
|
+
verdict: result.result?.verdict,
|
|
97
|
+
consensusScore: result.result?.consensusScore,
|
|
98
|
+
reviews: result.result?.reviews || [],
|
|
99
|
+
perspectives: result.result?.perspectives || [],
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
42
102
|
});
|
|
43
103
|
} catch (err) {
|
|
44
104
|
server._send(ws, {
|
|
@@ -47,6 +107,36 @@ export async function handleCoworkTask(server, id, ws, message) {
|
|
|
47
107
|
code: "COWORK_FAILED",
|
|
48
108
|
message: err.message,
|
|
49
109
|
});
|
|
110
|
+
} finally {
|
|
111
|
+
_runningTasks.delete(trackingId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function handleCoworkCancel(server, id, ws, message) {
|
|
116
|
+
const { trackingId } = message;
|
|
117
|
+
|
|
118
|
+
if (!trackingId) {
|
|
119
|
+
server._send(ws, {
|
|
120
|
+
id,
|
|
121
|
+
type: "error",
|
|
122
|
+
code: "INVALID_MESSAGE",
|
|
123
|
+
message: "trackingId field required",
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ac = _runningTasks.get(trackingId);
|
|
129
|
+
if (ac) {
|
|
130
|
+
ac.abort();
|
|
131
|
+
_runningTasks.delete(trackingId);
|
|
132
|
+
server._send(ws, { id, type: "cowork:cancelled", trackingId });
|
|
133
|
+
} else {
|
|
134
|
+
server._send(ws, {
|
|
135
|
+
id,
|
|
136
|
+
type: "error",
|
|
137
|
+
code: "TASK_NOT_FOUND",
|
|
138
|
+
message: `No running cowork task: ${trackingId}`,
|
|
139
|
+
});
|
|
50
140
|
}
|
|
51
141
|
}
|
|
52
142
|
|
|
@@ -67,6 +157,57 @@ export function handleSlashCommand(server, id, ws, message) {
|
|
|
67
157
|
handler.handleSlashCommand(command, id);
|
|
68
158
|
}
|
|
69
159
|
|
|
160
|
+
export async function handleCoworkHistory(server, id, ws, message) {
|
|
161
|
+
const { limit = 50 } = message;
|
|
162
|
+
const cwd = server.projectRoot || process.cwd();
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
166
|
+
const { join } = await import("node:path");
|
|
167
|
+
const histPath = join(cwd, ".chainlesschain", "cowork", "history.jsonl");
|
|
168
|
+
|
|
169
|
+
if (!existsSync(histPath)) {
|
|
170
|
+
server._send(ws, { id, type: "cowork:history", entries: [] });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lines = readFileSync(histPath, "utf-8").split("\n").filter(Boolean);
|
|
175
|
+
const entries = [];
|
|
176
|
+
for (const line of lines.slice(-limit)) {
|
|
177
|
+
try {
|
|
178
|
+
entries.push(JSON.parse(line));
|
|
179
|
+
} catch (_e) {
|
|
180
|
+
// skip malformed lines
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
server._send(ws, { id, type: "cowork:history", entries });
|
|
185
|
+
} catch (err) {
|
|
186
|
+
server._send(ws, {
|
|
187
|
+
id,
|
|
188
|
+
type: "error",
|
|
189
|
+
code: "HISTORY_FAILED",
|
|
190
|
+
message: err.message,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function handleCoworkTemplates(server, id, ws) {
|
|
196
|
+
try {
|
|
197
|
+
const { getTemplatesForUI } =
|
|
198
|
+
await import("../../lib/cowork-task-templates.js");
|
|
199
|
+
const templates = getTemplatesForUI();
|
|
200
|
+
server._send(ws, { id, type: "cowork:templates", templates });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
server._send(ws, {
|
|
203
|
+
id,
|
|
204
|
+
type: "error",
|
|
205
|
+
code: "TEMPLATES_FAILED",
|
|
206
|
+
message: err.message,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
70
211
|
export async function handleOrchestrate(server, id, ws, message) {
|
|
71
212
|
const {
|
|
72
213
|
task,
|
|
@@ -44,6 +44,9 @@ export function createWsMessageDispatcher(server) {
|
|
|
44
44
|
"host-tool-result": () => server._handleHostToolResult(id, ws, message),
|
|
45
45
|
orchestrate: () => server._handleOrchestrate(id, ws, message),
|
|
46
46
|
"cowork-task": () => server._handleCoworkTask(id, ws, message),
|
|
47
|
+
"cowork-cancel": () => server._handleCoworkCancel(id, ws, message),
|
|
48
|
+
"cowork-templates": () => server._handleCoworkTemplates(id, ws),
|
|
49
|
+
"cowork-history": () => server._handleCoworkHistory(id, ws, message),
|
|
47
50
|
"tasks-list": () => server._handleTasksList(id, ws),
|
|
48
51
|
"tasks-stop": () => server._handleTasksStop(id, ws, message),
|
|
49
52
|
"tasks-detail": () => server._handleTaskDetail(id, ws, message),
|
|
@@ -52,6 +52,9 @@ import {
|
|
|
52
52
|
handleSlashCommand,
|
|
53
53
|
handleOrchestrate,
|
|
54
54
|
handleCoworkTask,
|
|
55
|
+
handleCoworkCancel,
|
|
56
|
+
handleCoworkTemplates,
|
|
57
|
+
handleCoworkHistory,
|
|
55
58
|
} from "./action-protocol.js";
|
|
56
59
|
import {
|
|
57
60
|
handleWorktreeDiff,
|
|
@@ -303,6 +306,21 @@ export class ChainlessChainWSServer extends EventEmitter {
|
|
|
303
306
|
return handleCoworkTask(this, id, ws, message);
|
|
304
307
|
}
|
|
305
308
|
|
|
309
|
+
/** @private — cancel a running cowork task */
|
|
310
|
+
_handleCoworkCancel(id, ws, message) {
|
|
311
|
+
return handleCoworkCancel(this, id, ws, message);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** @private — return cowork templates for UI */
|
|
315
|
+
_handleCoworkTemplates(id, ws) {
|
|
316
|
+
return handleCoworkTemplates(this, id, ws);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** @private — return cowork task history */
|
|
320
|
+
_handleCoworkHistory(id, ws, message) {
|
|
321
|
+
return handleCoworkHistory(this, id, ws, message);
|
|
322
|
+
}
|
|
323
|
+
|
|
306
324
|
/** @private – list background tasks */
|
|
307
325
|
async _handleTasksList(id, ws) {
|
|
308
326
|
try {
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cowork Cron — schedule and run daily Cowork tasks on a cron.
|
|
3
|
+
*
|
|
4
|
+
* Persists schedules to `.chainlesschain/cowork/schedules.jsonl` (one JSON
|
|
5
|
+
* object per line). Schedules have shape:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* id: "sch-...",
|
|
9
|
+
* cron: "0 9 * * 1-5", // 5-field POSIX cron
|
|
10
|
+
* templateId: "doc-convert", // or null for free mode
|
|
11
|
+
* userMessage: "...", // task prompt
|
|
12
|
+
* files: ["/abs/path/..."], // optional
|
|
13
|
+
* enabled: true,
|
|
14
|
+
* createdAt: ISO,
|
|
15
|
+
* lastRunAt: ISO|null,
|
|
16
|
+
* lastStatus: "completed"|"failed"|null,
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The scheduler ticks every 60s by default. If any schedule uses a 6-field
|
|
20
|
+
* (seconds-aware) cron expression, the tick rate auto-adapts to 1s.
|
|
21
|
+
* Each tick reloads schedules and runs any whose cron matches the current
|
|
22
|
+
* minute (or second, if 6-field). A schedule only runs once per fire-window.
|
|
23
|
+
*
|
|
24
|
+
* Supported cron syntax:
|
|
25
|
+
* - 5 fields: minute hour dom month dow (POSIX)
|
|
26
|
+
* - 6 fields: second minute hour dom month dow (Quartz-like, seconds first)
|
|
27
|
+
* - Aliases: @yearly @annually @monthly @weekly @daily @midnight @hourly
|
|
28
|
+
*
|
|
29
|
+
* @module cowork-cron
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import crypto from "node:crypto";
|
|
33
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
export const _deps = {
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
writeFileSync,
|
|
41
|
+
now: () => new Date(),
|
|
42
|
+
runTask: null, // injected at runtime to avoid circular import
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ─── Cron parser ─────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const FIELD_RANGES = [
|
|
48
|
+
[0, 59], // minute
|
|
49
|
+
[0, 23], // hour
|
|
50
|
+
[1, 31], // day-of-month
|
|
51
|
+
[1, 12], // month
|
|
52
|
+
[0, 6], // day-of-week (0=Sun, 6=Sat). 7 also maps to 0.
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const SECOND_RANGE = [0, 59];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Non-standard alias → 5-field expression. Aliases never carry seconds.
|
|
59
|
+
*/
|
|
60
|
+
export const ALIASES = Object.freeze({
|
|
61
|
+
"@yearly": "0 0 1 1 *",
|
|
62
|
+
"@annually": "0 0 1 1 *",
|
|
63
|
+
"@monthly": "0 0 1 * *",
|
|
64
|
+
"@weekly": "0 0 * * 0",
|
|
65
|
+
"@daily": "0 0 * * *",
|
|
66
|
+
"@midnight": "0 0 * * *",
|
|
67
|
+
"@hourly": "0 * * * *",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Expand a cron alias (e.g. "@daily") into its 5-field equivalent.
|
|
72
|
+
* Returns the original string unchanged if not an alias.
|
|
73
|
+
*/
|
|
74
|
+
export function _expandExpr(expr) {
|
|
75
|
+
if (typeof expr !== "string") return expr;
|
|
76
|
+
const trimmed = expr.trim();
|
|
77
|
+
if (trimmed.startsWith("@")) {
|
|
78
|
+
const alias = ALIASES[trimmed.toLowerCase()];
|
|
79
|
+
if (!alias) throw new Error(`unknown cron alias: ${trimmed}`);
|
|
80
|
+
return alias;
|
|
81
|
+
}
|
|
82
|
+
return trimmed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse a single cron field into a Set of matching integers.
|
|
87
|
+
* Supports:
|
|
88
|
+
* * — every value in range
|
|
89
|
+
* N — single number
|
|
90
|
+
* A-B — inclusive range
|
|
91
|
+
* *\/N or A-B/N — step (every Nth value)
|
|
92
|
+
* a,b,c — comma-separated list (any combination of the above)
|
|
93
|
+
*/
|
|
94
|
+
export function parseCronField(field, [min, max]) {
|
|
95
|
+
if (typeof field !== "string" || field.length === 0) {
|
|
96
|
+
throw new Error("empty cron field");
|
|
97
|
+
}
|
|
98
|
+
const values = new Set();
|
|
99
|
+
for (const part of field.split(",")) {
|
|
100
|
+
const slashIdx = part.indexOf("/");
|
|
101
|
+
const stepStr = slashIdx >= 0 ? part.slice(slashIdx + 1) : null;
|
|
102
|
+
const base = slashIdx >= 0 ? part.slice(0, slashIdx) : part;
|
|
103
|
+
const step = stepStr === null ? 1 : parseInt(stepStr, 10);
|
|
104
|
+
if (!Number.isFinite(step) || step < 1) {
|
|
105
|
+
throw new Error(`invalid cron step: ${part}`);
|
|
106
|
+
}
|
|
107
|
+
let lo, hi;
|
|
108
|
+
if (base === "*") {
|
|
109
|
+
lo = min;
|
|
110
|
+
hi = max;
|
|
111
|
+
} else if (base.includes("-")) {
|
|
112
|
+
const [a, b] = base.split("-").map((x) => parseInt(x, 10));
|
|
113
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
114
|
+
throw new Error(`invalid cron range: ${part}`);
|
|
115
|
+
}
|
|
116
|
+
lo = a;
|
|
117
|
+
hi = b;
|
|
118
|
+
} else {
|
|
119
|
+
const n = parseInt(base, 10);
|
|
120
|
+
if (!Number.isFinite(n)) {
|
|
121
|
+
throw new Error(`invalid cron value: ${part}`);
|
|
122
|
+
}
|
|
123
|
+
lo = n;
|
|
124
|
+
hi = n;
|
|
125
|
+
}
|
|
126
|
+
// Normalize day-of-week 7 → 0
|
|
127
|
+
if (max === 6 && lo === 7) lo = 0;
|
|
128
|
+
if (max === 6 && hi === 7) hi = 0;
|
|
129
|
+
if (lo < min || hi > max || lo > hi) {
|
|
130
|
+
throw new Error(`cron value out of range ${min}-${max}: ${part}`);
|
|
131
|
+
}
|
|
132
|
+
for (let v = lo; v <= hi; v += step) {
|
|
133
|
+
values.add(v);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return values;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse a 5- or 6-field cron expression (or alias). Returns a match function
|
|
141
|
+
* with `.hasSeconds` boolean property indicating whether the expression
|
|
142
|
+
* carries seconds resolution.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} expr
|
|
145
|
+
* @returns {((date: Date) => boolean) & { hasSeconds: boolean }}
|
|
146
|
+
*/
|
|
147
|
+
export function parseCron(expr) {
|
|
148
|
+
if (typeof expr !== "string") {
|
|
149
|
+
throw new Error("cron expression must be a string");
|
|
150
|
+
}
|
|
151
|
+
const expanded = _expandExpr(expr);
|
|
152
|
+
const parts = expanded.split(/\s+/);
|
|
153
|
+
if (parts.length !== 5 && parts.length !== 6) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`cron must have 5 or 6 fields (got ${parts.length}); use 6-field for seconds resolution or alias like @daily`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const hasSeconds = parts.length === 6;
|
|
159
|
+
let second = null;
|
|
160
|
+
let minute, hour, dom, month, dow;
|
|
161
|
+
if (hasSeconds) {
|
|
162
|
+
second = parseCronField(parts[0], SECOND_RANGE);
|
|
163
|
+
[minute, hour, dom, month, dow] = parts
|
|
164
|
+
.slice(1)
|
|
165
|
+
.map((p, i) => parseCronField(p, FIELD_RANGES[i]));
|
|
166
|
+
} else {
|
|
167
|
+
[minute, hour, dom, month, dow] = parts.map((p, i) =>
|
|
168
|
+
parseCronField(p, FIELD_RANGES[i]),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Pre-compute restriction flags for POSIX dom/dow OR-semantics
|
|
173
|
+
const domField = hasSeconds ? parts[3] : parts[2];
|
|
174
|
+
const dowField = hasSeconds ? parts[5] : parts[4];
|
|
175
|
+
const domRestricted = domField !== "*";
|
|
176
|
+
const dowRestricted = dowField !== "*";
|
|
177
|
+
|
|
178
|
+
function matches(date) {
|
|
179
|
+
const s = date.getSeconds();
|
|
180
|
+
const m = date.getMinutes();
|
|
181
|
+
const h = date.getHours();
|
|
182
|
+
const D = date.getDate();
|
|
183
|
+
const M = date.getMonth() + 1; // JS month is 0-based
|
|
184
|
+
const W = date.getDay();
|
|
185
|
+
if (hasSeconds && !second.has(s)) return false;
|
|
186
|
+
if (!minute.has(m)) return false;
|
|
187
|
+
if (!hour.has(h)) return false;
|
|
188
|
+
if (!month.has(M)) return false;
|
|
189
|
+
// POSIX: if both dom and dow are restricted (not *), match is OR.
|
|
190
|
+
if (domRestricted && dowRestricted) {
|
|
191
|
+
return dom.has(D) || dow.has(W);
|
|
192
|
+
}
|
|
193
|
+
if (domRestricted) return dom.has(D);
|
|
194
|
+
if (dowRestricted) return dow.has(W);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
matches.hasSeconds = hasSeconds;
|
|
198
|
+
return matches;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Returns true if the cron expression carries seconds resolution. */
|
|
202
|
+
export function hasSecondsResolution(expr) {
|
|
203
|
+
try {
|
|
204
|
+
return parseCron(expr).hasSeconds;
|
|
205
|
+
} catch (_e) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Validate a cron expression — returns null if valid, error string otherwise. */
|
|
211
|
+
export function validateCron(expr) {
|
|
212
|
+
try {
|
|
213
|
+
parseCron(expr);
|
|
214
|
+
return null;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return err.message;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Persistence ─────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function _scheduleFile(cwd) {
|
|
223
|
+
return join(cwd, ".chainlesschain", "cowork", "schedules.jsonl");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Load all schedules from disk. Returns [] if the file doesn't exist. */
|
|
227
|
+
export function loadSchedules(cwd) {
|
|
228
|
+
const file = _scheduleFile(cwd);
|
|
229
|
+
if (!_deps.existsSync(file)) return [];
|
|
230
|
+
const raw = _deps.readFileSync(file, "utf-8");
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const line of raw.split("\n")) {
|
|
233
|
+
const trimmed = line.trim();
|
|
234
|
+
if (!trimmed) continue;
|
|
235
|
+
try {
|
|
236
|
+
out.push(JSON.parse(trimmed));
|
|
237
|
+
} catch (_e) {
|
|
238
|
+
// Skip malformed lines — don't let one bad record break the rest
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Write the full schedule list back to disk, overwriting. */
|
|
245
|
+
export function saveSchedules(cwd, schedules) {
|
|
246
|
+
const dir = join(cwd, ".chainlesschain", "cowork");
|
|
247
|
+
_deps.mkdirSync(dir, { recursive: true });
|
|
248
|
+
const file = _scheduleFile(cwd);
|
|
249
|
+
const body = schedules.map((s) => JSON.stringify(s)).join("\n");
|
|
250
|
+
_deps.writeFileSync(file, body ? body + "\n" : "", "utf-8");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
export function addSchedule(cwd, input) {
|
|
256
|
+
const { cron, templateId = null, userMessage, files = [] } = input || {};
|
|
257
|
+
if (!userMessage || typeof userMessage !== "string") {
|
|
258
|
+
throw new Error("userMessage is required");
|
|
259
|
+
}
|
|
260
|
+
const err = validateCron(cron);
|
|
261
|
+
if (err) throw new Error(`invalid cron: ${err}`);
|
|
262
|
+
|
|
263
|
+
const schedules = loadSchedules(cwd);
|
|
264
|
+
const entry = {
|
|
265
|
+
id: `sch-${crypto.randomUUID().slice(0, 12)}`,
|
|
266
|
+
cron: cron.trim(),
|
|
267
|
+
templateId,
|
|
268
|
+
userMessage,
|
|
269
|
+
files: Array.isArray(files) ? files : [],
|
|
270
|
+
enabled: true,
|
|
271
|
+
createdAt: _deps.now().toISOString(),
|
|
272
|
+
lastRunAt: null,
|
|
273
|
+
lastStatus: null,
|
|
274
|
+
};
|
|
275
|
+
schedules.push(entry);
|
|
276
|
+
saveSchedules(cwd, schedules);
|
|
277
|
+
return entry;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function removeSchedule(cwd, id) {
|
|
281
|
+
const schedules = loadSchedules(cwd);
|
|
282
|
+
const idx = schedules.findIndex((s) => s.id === id);
|
|
283
|
+
if (idx === -1) return false;
|
|
284
|
+
schedules.splice(idx, 1);
|
|
285
|
+
saveSchedules(cwd, schedules);
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function setScheduleEnabled(cwd, id, enabled) {
|
|
290
|
+
const schedules = loadSchedules(cwd);
|
|
291
|
+
const s = schedules.find((x) => x.id === id);
|
|
292
|
+
if (!s) return false;
|
|
293
|
+
s.enabled = !!enabled;
|
|
294
|
+
saveSchedules(cwd, schedules);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function updateScheduleRunState(cwd, id, { lastRunAt, lastStatus }) {
|
|
299
|
+
const schedules = loadSchedules(cwd);
|
|
300
|
+
const s = schedules.find((x) => x.id === id);
|
|
301
|
+
if (!s) return false;
|
|
302
|
+
if (lastRunAt) s.lastRunAt = lastRunAt;
|
|
303
|
+
if (lastStatus) s.lastStatus = lastStatus;
|
|
304
|
+
saveSchedules(cwd, schedules);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── Scheduler ───────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Background scheduler that checks schedules every minute and runs due ones.
|
|
312
|
+
* Enforces once-per-minute-per-schedule via `_firedKeys` (schedule.id + minute).
|
|
313
|
+
*/
|
|
314
|
+
export class CoworkCronScheduler {
|
|
315
|
+
constructor(options = {}) {
|
|
316
|
+
this.cwd = options.cwd || process.cwd();
|
|
317
|
+
// If caller pins intervalMs we honor it; else auto-adapt based on schedules.
|
|
318
|
+
this._intervalPinned = typeof options.intervalMs === "number";
|
|
319
|
+
this.intervalMs = options.intervalMs || 60_000;
|
|
320
|
+
this.onEvent = options.onEvent || null; // (event) => void
|
|
321
|
+
this._timer = null;
|
|
322
|
+
this._firedKeys = new Set();
|
|
323
|
+
this._running = new Set();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
start() {
|
|
327
|
+
if (this._timer) return;
|
|
328
|
+
this._adaptInterval(); // pick 1s vs 60s based on current schedules
|
|
329
|
+
this._tick(); // immediate first tick so tests don't wait
|
|
330
|
+
this._timer = setInterval(() => this._tick(), this.intervalMs);
|
|
331
|
+
this._emit({ type: "scheduler-started", intervalMs: this.intervalMs });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Re-evaluate desired tick rate. If any active schedule uses seconds, drop
|
|
336
|
+
* to 1s; else use 60s. No-op if caller pinned intervalMs.
|
|
337
|
+
*/
|
|
338
|
+
_adaptInterval() {
|
|
339
|
+
if (this._intervalPinned) return;
|
|
340
|
+
let schedules = [];
|
|
341
|
+
try {
|
|
342
|
+
schedules = loadSchedules(this.cwd);
|
|
343
|
+
} catch (_e) {
|
|
344
|
+
// ignore — keep current interval
|
|
345
|
+
}
|
|
346
|
+
const wantSeconds = schedules.some(
|
|
347
|
+
(s) => s.enabled !== false && hasSecondsResolution(s.cron),
|
|
348
|
+
);
|
|
349
|
+
const desired = wantSeconds ? 1000 : 60_000;
|
|
350
|
+
if (desired !== this.intervalMs) {
|
|
351
|
+
this.intervalMs = desired;
|
|
352
|
+
if (this._timer) {
|
|
353
|
+
clearInterval(this._timer);
|
|
354
|
+
this._timer = setInterval(() => this._tick(), this.intervalMs);
|
|
355
|
+
this._emit({ type: "scheduler-retuned", intervalMs: this.intervalMs });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
stop() {
|
|
361
|
+
if (this._timer) {
|
|
362
|
+
clearInterval(this._timer);
|
|
363
|
+
this._timer = null;
|
|
364
|
+
}
|
|
365
|
+
this._emit({ type: "scheduler-stopped" });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
_emit(event) {
|
|
369
|
+
if (typeof this.onEvent === "function") {
|
|
370
|
+
try {
|
|
371
|
+
this.onEvent(event);
|
|
372
|
+
} catch (_e) {
|
|
373
|
+
// Never let observer errors break the scheduler
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async _tick() {
|
|
379
|
+
const now = _deps.now();
|
|
380
|
+
let schedules;
|
|
381
|
+
try {
|
|
382
|
+
schedules = loadSchedules(this.cwd);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
this._emit({ type: "load-error", error: err.message });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Re-adapt interval if schedule set changed (added/removed seconds-aware)
|
|
389
|
+
this._adaptInterval();
|
|
390
|
+
|
|
391
|
+
for (const s of schedules) {
|
|
392
|
+
if (!s.enabled) continue;
|
|
393
|
+
let matcher;
|
|
394
|
+
try {
|
|
395
|
+
matcher = parseCron(s.cron);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
this._emit({ type: "invalid-cron", id: s.id, error: err.message });
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const fireKey = `${s.id}:${
|
|
401
|
+
matcher.hasSeconds ? _secondKey(now) : _minuteKey(now)
|
|
402
|
+
}`;
|
|
403
|
+
if (this._firedKeys.has(fireKey)) continue;
|
|
404
|
+
if (this._running.has(s.id)) continue;
|
|
405
|
+
const isDue = matcher(now);
|
|
406
|
+
if (!isDue) continue;
|
|
407
|
+
|
|
408
|
+
this._firedKeys.add(fireKey);
|
|
409
|
+
this._running.add(s.id);
|
|
410
|
+
this._runSchedule(s).finally(() => {
|
|
411
|
+
this._running.delete(s.id);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Prevent unbounded growth of _firedKeys — keep only recent minute keys
|
|
416
|
+
if (this._firedKeys.size > 10_000) {
|
|
417
|
+
this._firedKeys.clear();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async _runSchedule(schedule) {
|
|
422
|
+
const runTask = _deps.runTask;
|
|
423
|
+
if (typeof runTask !== "function") {
|
|
424
|
+
this._emit({
|
|
425
|
+
type: "run-error",
|
|
426
|
+
id: schedule.id,
|
|
427
|
+
error: "runTask not injected",
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
this._emit({
|
|
432
|
+
type: "schedule-fired",
|
|
433
|
+
id: schedule.id,
|
|
434
|
+
cron: schedule.cron,
|
|
435
|
+
templateId: schedule.templateId,
|
|
436
|
+
});
|
|
437
|
+
try {
|
|
438
|
+
const result = await runTask({
|
|
439
|
+
templateId: schedule.templateId,
|
|
440
|
+
userMessage: schedule.userMessage,
|
|
441
|
+
files: schedule.files,
|
|
442
|
+
cwd: this.cwd,
|
|
443
|
+
});
|
|
444
|
+
updateScheduleRunState(this.cwd, schedule.id, {
|
|
445
|
+
lastRunAt: _deps.now().toISOString(),
|
|
446
|
+
lastStatus: result?.status || "completed",
|
|
447
|
+
});
|
|
448
|
+
this._emit({
|
|
449
|
+
type: "schedule-completed",
|
|
450
|
+
id: schedule.id,
|
|
451
|
+
taskId: result?.taskId,
|
|
452
|
+
status: result?.status,
|
|
453
|
+
});
|
|
454
|
+
} catch (err) {
|
|
455
|
+
updateScheduleRunState(this.cwd, schedule.id, {
|
|
456
|
+
lastRunAt: _deps.now().toISOString(),
|
|
457
|
+
lastStatus: "failed",
|
|
458
|
+
});
|
|
459
|
+
this._emit({
|
|
460
|
+
type: "schedule-failed",
|
|
461
|
+
id: schedule.id,
|
|
462
|
+
error: err.message,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function _minuteKey(date) {
|
|
469
|
+
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}-${date.getMinutes()}`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function _secondKey(date) {
|
|
473
|
+
return `${_minuteKey(date)}-${date.getSeconds()}`;
|
|
474
|
+
}
|