amalgm 0.1.48 → 0.1.50
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/runtime/scripts/amalgm-mcp/config.js +1 -1
- package/runtime/scripts/amalgm-mcp/events/executor.js +31 -0
- package/runtime/scripts/amalgm-mcp/events/store.js +202 -2
- package/runtime/scripts/amalgm-mcp/fs/rest.js +348 -16
- package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +26 -5
- package/runtime/scripts/amalgm-mcp/server/http.js +2 -1
- package/runtime/scripts/amalgm-mcp/state/db.js +72 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +12 -1
- package/runtime/scripts/amalgm-mcp/tasks/executor.js +13 -4
- package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +60 -22
- package/runtime/scripts/amalgm-mcp/tasks/store.js +783 -55
- package/runtime/scripts/amalgm-mcp/tasks/tools.js +12 -4
- package/runtime/scripts/amalgm-mcp/tests/tasks-store.test.js +113 -0
- package/runtime/scripts/chat-core/adapters/claude.js +7 -4
- package/runtime/scripts/chat-core/adapters/codex.js +12 -4
- package/runtime/scripts/chat-core/tests/native-config.test.js +127 -0
- package/runtime/scripts/chat-core/tooling/native-config.js +129 -18
- package/runtime/scripts/local-gateway.js +13 -0
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tasks storage —
|
|
2
|
+
* Tasks storage — SQLite-backed scheduled task definitions and run receipts.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* tasks.json and task-runs/*.jsonl are imported once as a compatibility
|
|
5
|
+
* migration. After that, SQLite is the source of truth.
|
|
5
6
|
*/
|
|
6
7
|
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const fs = require('fs');
|
|
7
10
|
const path = require('path');
|
|
8
11
|
const { TASKS_FILE, TASK_RUNS_DIR, STORAGE_DIR } = require('../config');
|
|
9
|
-
const {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
cleanupStaleTmp,
|
|
14
|
-
appendJsonl,
|
|
15
|
-
tailJsonl,
|
|
16
|
-
} = require('../lib/storage');
|
|
12
|
+
const { ensureDir, readJson, cleanupStaleTmp, tailJsonl } = require('../lib/storage');
|
|
13
|
+
const { openLocalDb } = require('../state/db');
|
|
14
|
+
const { insertStateEvent, publishStateEvent } = require('../state/events');
|
|
15
|
+
const { loadCronParser } = require('../deps');
|
|
17
16
|
const {
|
|
18
17
|
chatInputToLegacyFields,
|
|
19
18
|
getChatInputText,
|
|
@@ -22,9 +21,127 @@ const {
|
|
|
22
21
|
const { normalizeTaskSchedule } = require('./schedule-normalization');
|
|
23
22
|
const { DEFAULT_SELECTED_MODELS, getSelectedModel } = require('../lib/prefs');
|
|
24
23
|
const credentialAdapter = require('../../credential-adapter');
|
|
25
|
-
|
|
24
|
+
|
|
25
|
+
const TASKS_JSON_MIGRATION_KEY = 'tasks_json_migrated_v1';
|
|
26
|
+
const TASK_CLAIM_TTL_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
const DEFAULT_RUN_LIMIT = 50;
|
|
28
|
+
|
|
29
|
+
let _cronParser = null;
|
|
30
|
+
|
|
31
|
+
function cron() {
|
|
32
|
+
if (!_cronParser) _cronParser = loadCronParser();
|
|
33
|
+
return _cronParser;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nowIso() {
|
|
37
|
+
return new Date().toISOString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJson(value, fallback = null) {
|
|
41
|
+
if (typeof value !== 'string' || !value) return fallback;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(value);
|
|
44
|
+
} catch {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validDate(value) {
|
|
50
|
+
if (!value) return null;
|
|
51
|
+
const date = new Date(value);
|
|
52
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isoOrNull(value) {
|
|
56
|
+
const date = validDate(value);
|
|
57
|
+
return date ? date.toISOString() : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function addMs(date, ms) {
|
|
61
|
+
return new Date(date.getTime() + ms);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function computeNextRunAt(task, afterDate) {
|
|
65
|
+
if (!task || task.enabled === false || !task.schedule) return null;
|
|
66
|
+
const schedule = task.schedule;
|
|
67
|
+
const after = validDate(afterDate) || new Date();
|
|
68
|
+
const endsAt = validDate(task.endsAt);
|
|
69
|
+
if (endsAt && endsAt <= after) return null;
|
|
70
|
+
|
|
71
|
+
if (schedule.kind === 'once') {
|
|
72
|
+
const runAt = validDate(schedule.at);
|
|
73
|
+
if (!runAt) return null;
|
|
74
|
+
const lastRunAt = validDate(task.lastRunAt);
|
|
75
|
+
if (lastRunAt && lastRunAt >= runAt) return null;
|
|
76
|
+
if (endsAt && runAt > endsAt) return null;
|
|
77
|
+
return runAt.toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (schedule.kind === 'interval') {
|
|
81
|
+
const ms = Number(schedule.ms);
|
|
82
|
+
if (!Number.isFinite(ms) || ms < 60_000) return null;
|
|
83
|
+
const next = addMs(after, ms);
|
|
84
|
+
if (endsAt && next > endsAt) return null;
|
|
85
|
+
return next.toISOString();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (schedule.kind === 'cron') {
|
|
89
|
+
try {
|
|
90
|
+
const interval = cron().CronExpressionParser.parse(schedule.expr, {
|
|
91
|
+
currentDate: after,
|
|
92
|
+
tz: schedule.tz || 'UTC',
|
|
93
|
+
});
|
|
94
|
+
const next = interval.next().toDate();
|
|
95
|
+
if (endsAt && next > endsAt) return null;
|
|
96
|
+
return next.toISOString();
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function nextRunBaseline(task) {
|
|
106
|
+
return validDate(task.lastRunAt) || validDate(task.createdAt) || new Date();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function scheduledInstantForTask(task, now = new Date()) {
|
|
110
|
+
const schedule = task?.schedule;
|
|
111
|
+
if (!schedule) return null;
|
|
112
|
+
|
|
113
|
+
if (schedule.kind === 'once') {
|
|
114
|
+
return isoOrNull(schedule.at);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (schedule.kind === 'interval') {
|
|
118
|
+
return isoOrNull(task.nextRunAt) || computeNextRunAt(task, nextRunBaseline(task));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (schedule.kind === 'cron') {
|
|
122
|
+
try {
|
|
123
|
+
const currentDate = validDate(now) || new Date();
|
|
124
|
+
const interval = cron().CronExpressionParser.parse(schedule.expr, {
|
|
125
|
+
currentDate,
|
|
126
|
+
tz: schedule.tz || 'UTC',
|
|
127
|
+
});
|
|
128
|
+
const prev = interval.includesDate(currentDate)
|
|
129
|
+
? new Date(Math.floor(currentDate.getTime() / 1000) * 1000)
|
|
130
|
+
: interval.prev().toDate();
|
|
131
|
+
const createdAt = validDate(task.createdAt);
|
|
132
|
+
if (createdAt && prev <= createdAt) return isoOrNull(task.nextRunAt);
|
|
133
|
+
return prev.toISOString();
|
|
134
|
+
} catch {
|
|
135
|
+
return isoOrNull(task.nextRunAt);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
26
141
|
|
|
27
142
|
function normalizeStoredTask(task) {
|
|
143
|
+
const createdAt = isoOrNull(task.createdAt || task.created_at) || nowIso();
|
|
144
|
+
const updatedAt = isoOrNull(task.updatedAt || task.updated_at) || createdAt;
|
|
28
145
|
const harness =
|
|
29
146
|
(task.chatInput && task.chatInput.agent && task.chatInput.agent.harness)
|
|
30
147
|
|| task.harness
|
|
@@ -62,93 +179,704 @@ function normalizeStoredTask(task) {
|
|
|
62
179
|
|
|
63
180
|
return {
|
|
64
181
|
...task,
|
|
182
|
+
id: task.id || crypto.randomUUID(),
|
|
183
|
+
name: String(task.name || 'Scheduled task'),
|
|
184
|
+
description: task.description || '',
|
|
185
|
+
enabled: task.enabled !== false,
|
|
65
186
|
schedule,
|
|
66
187
|
chatInput,
|
|
67
188
|
prompt: getChatInputText(chatInput, task.prompt) || legacy.prompt,
|
|
189
|
+
endsAt: isoOrNull(task.endsAt || task.ends_at),
|
|
190
|
+
maxConcurrentRuns: Math.max(1, Number(task.maxConcurrentRuns || task.max_concurrent_runs || 1)),
|
|
68
191
|
harness: legacy.harness || harness,
|
|
69
192
|
model: legacy.modelId || model,
|
|
193
|
+
modelSettings: task.modelSettings || task.model_settings || null,
|
|
70
194
|
authMethod: legacy.authMethod || authMethod,
|
|
71
195
|
projectPath: legacy.cwd || task.projectPath || null,
|
|
196
|
+
createdAt,
|
|
197
|
+
updatedAt,
|
|
198
|
+
lastRunAt: isoOrNull(task.lastRunAt || task.last_run_at),
|
|
199
|
+
lastStatus: task.lastStatus || task.last_status || null,
|
|
200
|
+
nextRunAt: isoOrNull(task.nextRunAt || task.next_run_at),
|
|
72
201
|
};
|
|
73
202
|
}
|
|
74
203
|
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
204
|
+
function taskForStorage(task, options = {}) {
|
|
205
|
+
const normalized = normalizeStoredTask(task);
|
|
206
|
+
if (options.preserveNextRunAt && normalized.nextRunAt) {
|
|
207
|
+
return normalized;
|
|
208
|
+
}
|
|
209
|
+
normalized.nextRunAt = computeNextRunAt(normalized, nextRunBaseline(normalized));
|
|
210
|
+
return normalized;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function rowToTask(row) {
|
|
214
|
+
if (!row) return null;
|
|
215
|
+
const parsed = parseJson(row.task_json, {});
|
|
216
|
+
return normalizeStoredTask({
|
|
217
|
+
...parsed,
|
|
218
|
+
id: row.id,
|
|
219
|
+
name: row.name,
|
|
220
|
+
enabled: row.enabled === 1,
|
|
221
|
+
schedule: parseJson(row.schedule_json, parsed.schedule || {}),
|
|
222
|
+
nextRunAt: row.next_run_at,
|
|
223
|
+
lastRunAt: row.last_run_at,
|
|
224
|
+
lastStatus: row.last_status,
|
|
225
|
+
createdAt: row.created_at,
|
|
226
|
+
updatedAt: row.updated_at,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function taskRowParams(task) {
|
|
231
|
+
const taskJson = JSON.stringify(task);
|
|
232
|
+
return {
|
|
233
|
+
id: task.id,
|
|
234
|
+
name: task.name,
|
|
235
|
+
enabled: task.enabled ? 1 : 0,
|
|
236
|
+
schedule_kind: task.schedule?.kind || 'unknown',
|
|
237
|
+
schedule_json: JSON.stringify(task.schedule || {}),
|
|
238
|
+
next_run_at: task.nextRunAt || null,
|
|
239
|
+
last_run_at: task.lastRunAt || null,
|
|
240
|
+
last_status: task.lastStatus || null,
|
|
241
|
+
created_at: task.createdAt || nowIso(),
|
|
242
|
+
updated_at: task.updatedAt || nowIso(),
|
|
243
|
+
task_json: taskJson,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function upsertTaskRow(db, task) {
|
|
248
|
+
const params = taskRowParams(task);
|
|
249
|
+
db.prepare(`
|
|
250
|
+
INSERT INTO scheduled_tasks (
|
|
251
|
+
id,
|
|
252
|
+
name,
|
|
253
|
+
enabled,
|
|
254
|
+
schedule_kind,
|
|
255
|
+
schedule_json,
|
|
256
|
+
next_run_at,
|
|
257
|
+
last_run_at,
|
|
258
|
+
last_status,
|
|
259
|
+
created_at,
|
|
260
|
+
updated_at,
|
|
261
|
+
task_json
|
|
262
|
+
)
|
|
263
|
+
VALUES (
|
|
264
|
+
@id,
|
|
265
|
+
@name,
|
|
266
|
+
@enabled,
|
|
267
|
+
@schedule_kind,
|
|
268
|
+
@schedule_json,
|
|
269
|
+
@next_run_at,
|
|
270
|
+
@last_run_at,
|
|
271
|
+
@last_status,
|
|
272
|
+
@created_at,
|
|
273
|
+
@updated_at,
|
|
274
|
+
@task_json
|
|
275
|
+
)
|
|
276
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
277
|
+
name = excluded.name,
|
|
278
|
+
enabled = excluded.enabled,
|
|
279
|
+
schedule_kind = excluded.schedule_kind,
|
|
280
|
+
schedule_json = excluded.schedule_json,
|
|
281
|
+
next_run_at = excluded.next_run_at,
|
|
282
|
+
last_run_at = excluded.last_run_at,
|
|
283
|
+
last_status = excluded.last_status,
|
|
284
|
+
updated_at = excluded.updated_at,
|
|
285
|
+
task_json = excluded.task_json
|
|
286
|
+
`).run(params);
|
|
287
|
+
return params;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function publishEvents(events) {
|
|
291
|
+
for (const event of events) publishStateEvent(event);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function metaValue(db, key) {
|
|
295
|
+
return db.prepare('SELECT value FROM local_meta WHERE key = ?').get(key)?.value || null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function setMetaValue(db, key, value) {
|
|
299
|
+
db.prepare(`
|
|
300
|
+
INSERT INTO local_meta (key, value, updated_at)
|
|
301
|
+
VALUES (?, ?, ?)
|
|
302
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
303
|
+
value = excluded.value,
|
|
304
|
+
updated_at = excluded.updated_at
|
|
305
|
+
`).run(key, value, nowIso());
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function readLegacyRunEntries(taskId) {
|
|
309
|
+
const file = path.join(TASK_RUNS_DIR, `${taskId}.jsonl`);
|
|
310
|
+
if (!fs.existsSync(file)) return [];
|
|
311
|
+
try {
|
|
312
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
313
|
+
return lines
|
|
314
|
+
.map((line) => {
|
|
315
|
+
try {
|
|
316
|
+
return JSON.parse(line);
|
|
317
|
+
} catch {
|
|
318
|
+
return null;
|
|
82
319
|
}
|
|
83
|
-
return normalizedTask;
|
|
84
320
|
})
|
|
85
|
-
|
|
321
|
+
.filter(Boolean);
|
|
322
|
+
} catch {
|
|
323
|
+
return tailJsonl(file, 5000);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function rowToTaskRun(row) {
|
|
328
|
+
if (!row) return null;
|
|
329
|
+
const parsed = parseJson(row.run_json, {});
|
|
330
|
+
return {
|
|
331
|
+
...parsed,
|
|
332
|
+
id: row.id,
|
|
333
|
+
runId: row.id,
|
|
334
|
+
taskId: row.task_id,
|
|
335
|
+
scheduledFor: row.scheduled_for,
|
|
336
|
+
status: row.status,
|
|
337
|
+
startedAt: row.started_at || parsed.startedAt || null,
|
|
338
|
+
finishedAt: row.finished_at || parsed.finishedAt || null,
|
|
339
|
+
claimedAt: row.claimed_at || parsed.claimedAt || null,
|
|
340
|
+
claimExpiresAt: row.claim_expires_at || parsed.claimExpiresAt || null,
|
|
341
|
+
runnerId: row.runner_id || parsed.runnerId || null,
|
|
342
|
+
sessionId: row.session_id || parsed.sessionId || null,
|
|
343
|
+
error: row.error || parsed.error || null,
|
|
344
|
+
createdAt: row.created_at,
|
|
345
|
+
updatedAt: row.updated_at,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function taskRunRowParams(run) {
|
|
350
|
+
const timestamp = nowIso();
|
|
351
|
+
const createdAt = run.createdAt || timestamp;
|
|
352
|
+
const updatedAt = run.updatedAt || timestamp;
|
|
353
|
+
const runJson = {
|
|
354
|
+
...run,
|
|
355
|
+
id: run.id,
|
|
356
|
+
runId: run.id,
|
|
357
|
+
taskId: run.taskId,
|
|
358
|
+
scheduledFor: run.scheduledFor,
|
|
359
|
+
status: run.status,
|
|
360
|
+
startedAt: run.startedAt || null,
|
|
361
|
+
finishedAt: run.finishedAt || null,
|
|
362
|
+
claimedAt: run.claimedAt || null,
|
|
363
|
+
claimExpiresAt: run.claimExpiresAt || null,
|
|
364
|
+
runnerId: run.runnerId || null,
|
|
365
|
+
sessionId: run.sessionId || null,
|
|
366
|
+
error: run.error || null,
|
|
367
|
+
createdAt,
|
|
368
|
+
updatedAt,
|
|
369
|
+
};
|
|
370
|
+
return {
|
|
371
|
+
id: run.id,
|
|
372
|
+
task_id: run.taskId,
|
|
373
|
+
scheduled_for: run.scheduledFor,
|
|
374
|
+
status: run.status || 'running',
|
|
375
|
+
started_at: run.startedAt || null,
|
|
376
|
+
finished_at: run.finishedAt || null,
|
|
377
|
+
claimed_at: run.claimedAt || null,
|
|
378
|
+
claim_expires_at: run.claimExpiresAt || null,
|
|
379
|
+
runner_id: run.runnerId || null,
|
|
380
|
+
session_id: run.sessionId || null,
|
|
381
|
+
error: run.error || null,
|
|
382
|
+
run_json: JSON.stringify(runJson),
|
|
383
|
+
created_at: createdAt,
|
|
384
|
+
updated_at: updatedAt,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function upsertTaskRunRow(db, run) {
|
|
389
|
+
const params = taskRunRowParams(run);
|
|
390
|
+
const existingBySchedule = db.prepare(`
|
|
391
|
+
SELECT id FROM task_runs WHERE task_id = ? AND scheduled_for = ?
|
|
392
|
+
`).get(params.task_id, params.scheduled_for);
|
|
393
|
+
if (existingBySchedule && existingBySchedule.id !== params.id) {
|
|
394
|
+
params.id = existingBySchedule.id;
|
|
395
|
+
const parsed = parseJson(params.run_json, {});
|
|
396
|
+
params.run_json = JSON.stringify({
|
|
397
|
+
...parsed,
|
|
398
|
+
id: params.id,
|
|
399
|
+
runId: params.id,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
db.prepare(`
|
|
403
|
+
INSERT INTO task_runs (
|
|
404
|
+
id,
|
|
405
|
+
task_id,
|
|
406
|
+
scheduled_for,
|
|
407
|
+
status,
|
|
408
|
+
started_at,
|
|
409
|
+
finished_at,
|
|
410
|
+
claimed_at,
|
|
411
|
+
claim_expires_at,
|
|
412
|
+
runner_id,
|
|
413
|
+
session_id,
|
|
414
|
+
error,
|
|
415
|
+
run_json,
|
|
416
|
+
created_at,
|
|
417
|
+
updated_at
|
|
418
|
+
)
|
|
419
|
+
VALUES (
|
|
420
|
+
@id,
|
|
421
|
+
@task_id,
|
|
422
|
+
@scheduled_for,
|
|
423
|
+
@status,
|
|
424
|
+
@started_at,
|
|
425
|
+
@finished_at,
|
|
426
|
+
@claimed_at,
|
|
427
|
+
@claim_expires_at,
|
|
428
|
+
@runner_id,
|
|
429
|
+
@session_id,
|
|
430
|
+
@error,
|
|
431
|
+
@run_json,
|
|
432
|
+
@created_at,
|
|
433
|
+
@updated_at
|
|
434
|
+
)
|
|
435
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
436
|
+
status = excluded.status,
|
|
437
|
+
started_at = excluded.started_at,
|
|
438
|
+
finished_at = excluded.finished_at,
|
|
439
|
+
claimed_at = excluded.claimed_at,
|
|
440
|
+
claim_expires_at = excluded.claim_expires_at,
|
|
441
|
+
runner_id = excluded.runner_id,
|
|
442
|
+
session_id = excluded.session_id,
|
|
443
|
+
error = excluded.error,
|
|
444
|
+
run_json = excluded.run_json,
|
|
445
|
+
updated_at = excluded.updated_at
|
|
446
|
+
`).run(params);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function mergeRunEntry(existing, taskId, entry) {
|
|
450
|
+
const timestamp = nowIso();
|
|
451
|
+
const startedAt = isoOrNull(entry.startedAt) || existing?.startedAt || null;
|
|
452
|
+
const finishedAt = isoOrNull(entry.finishedAt) || existing?.finishedAt || null;
|
|
453
|
+
const scheduledFor = isoOrNull(entry.scheduledFor)
|
|
454
|
+
|| existing?.scheduledFor
|
|
455
|
+
|| startedAt
|
|
456
|
+
|| finishedAt
|
|
457
|
+
|| timestamp;
|
|
458
|
+
const status = entry.status || existing?.status || 'running';
|
|
459
|
+
const events = Array.isArray(existing?.events) ? existing.events.slice(-99) : [];
|
|
460
|
+
events.push({ ...entry, recordedAt: timestamp });
|
|
86
461
|
|
|
87
462
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
463
|
+
...(existing || {}),
|
|
464
|
+
id: entry.runId || entry.id || existing?.id || crypto.randomUUID(),
|
|
465
|
+
runId: entry.runId || entry.id || existing?.id || null,
|
|
466
|
+
taskId,
|
|
467
|
+
scheduledFor,
|
|
468
|
+
status,
|
|
469
|
+
startedAt,
|
|
470
|
+
finishedAt,
|
|
471
|
+
claimedAt: isoOrNull(entry.claimedAt) || existing?.claimedAt || startedAt || timestamp,
|
|
472
|
+
claimExpiresAt: isoOrNull(entry.claimExpiresAt) || existing?.claimExpiresAt || null,
|
|
473
|
+
runnerId: entry.runnerId || existing?.runnerId || null,
|
|
474
|
+
sessionId: entry.sessionId || existing?.sessionId || null,
|
|
475
|
+
error: entry.error || existing?.error || null,
|
|
476
|
+
stopReason: entry.stopReason || existing?.stopReason || null,
|
|
477
|
+
durationMs: entry.durationMs ?? existing?.durationMs ?? null,
|
|
478
|
+
outputLength: entry.outputLength ?? existing?.outputLength ?? null,
|
|
479
|
+
prompt: entry.prompt || existing?.prompt || null,
|
|
480
|
+
events,
|
|
481
|
+
createdAt: existing?.createdAt || startedAt || timestamp,
|
|
482
|
+
updatedAt: timestamp,
|
|
93
483
|
};
|
|
94
484
|
}
|
|
95
485
|
|
|
486
|
+
function migrateLegacyRuns(db, tasks) {
|
|
487
|
+
for (const task of tasks) {
|
|
488
|
+
const entries = readLegacyRunEntries(task.id);
|
|
489
|
+
const byRunId = new Map();
|
|
490
|
+
for (const entry of entries) {
|
|
491
|
+
const runId = entry.runId || entry.id;
|
|
492
|
+
if (!runId) continue;
|
|
493
|
+
byRunId.set(runId, mergeRunEntry(byRunId.get(runId), task.id, { ...entry, runId }));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
for (const run of byRunId.values()) {
|
|
497
|
+
const existing = db.prepare('SELECT id FROM task_runs WHERE id = ?').get(run.id);
|
|
498
|
+
if (!existing) upsertTaskRunRow(db, run);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function migrateTasksFromJsonOnce() {
|
|
504
|
+
const db = openLocalDb();
|
|
505
|
+
if (metaValue(db, TASKS_JSON_MIGRATION_KEY)) return;
|
|
506
|
+
|
|
507
|
+
const raw = readJson(TASKS_FILE, { version: 1, tasks: [] });
|
|
508
|
+
const legacyTasks = Array.isArray(raw?.tasks)
|
|
509
|
+
? raw.tasks.map((task) => taskForStorage(task))
|
|
510
|
+
: [];
|
|
511
|
+
|
|
512
|
+
db.transaction(() => {
|
|
513
|
+
for (const task of legacyTasks) {
|
|
514
|
+
const existing = db.prepare('SELECT id FROM scheduled_tasks WHERE id = ?').get(task.id);
|
|
515
|
+
if (!existing) upsertTaskRow(db, task);
|
|
516
|
+
}
|
|
517
|
+
migrateLegacyRuns(db, legacyTasks);
|
|
518
|
+
setMetaValue(db, TASKS_JSON_MIGRATION_KEY, new Date().toISOString());
|
|
519
|
+
})();
|
|
520
|
+
}
|
|
521
|
+
|
|
96
522
|
function ensureTasksDirs() {
|
|
97
523
|
ensureDir(STORAGE_DIR);
|
|
98
524
|
ensureDir(TASK_RUNS_DIR);
|
|
99
525
|
cleanupStaleTmp(STORAGE_DIR, '.tasks.json');
|
|
100
|
-
|
|
526
|
+
openLocalDb();
|
|
527
|
+
migrateTasksFromJsonOnce();
|
|
101
528
|
}
|
|
102
529
|
|
|
103
530
|
function loadTasks() {
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
531
|
+
ensureTasksDirs();
|
|
532
|
+
const tasks = openLocalDb()
|
|
533
|
+
.prepare('SELECT * FROM scheduled_tasks ORDER BY datetime(created_at) DESC, lower(name)')
|
|
534
|
+
.all()
|
|
535
|
+
.map(rowToTask)
|
|
536
|
+
.filter(Boolean);
|
|
537
|
+
return { version: 2, tasks };
|
|
110
538
|
}
|
|
111
539
|
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
540
|
+
function saveTasks(data, options = {}) {
|
|
541
|
+
ensureTasksDirs();
|
|
542
|
+
const db = openLocalDb();
|
|
543
|
+
const incomingTasks = Array.isArray(data?.tasks)
|
|
544
|
+
? data.tasks.map((task) => taskForStorage(task, {
|
|
545
|
+
preserveNextRunAt: options.preserveNextRunAt === true,
|
|
546
|
+
}))
|
|
547
|
+
: [];
|
|
548
|
+
const source = options.source || 'tasks:save';
|
|
549
|
+
const events = [];
|
|
550
|
+
|
|
551
|
+
db.transaction(() => {
|
|
552
|
+
const existingRows = db.prepare('SELECT * FROM scheduled_tasks').all();
|
|
553
|
+
const existingById = new Map(existingRows.map((row) => [row.id, row]));
|
|
554
|
+
const seenIds = new Set();
|
|
555
|
+
|
|
556
|
+
for (const task of incomingTasks) {
|
|
557
|
+
task.updatedAt = nowIso();
|
|
558
|
+
if (!task.nextRunAt) task.nextRunAt = computeNextRunAt(task, nextRunBaseline(task));
|
|
559
|
+
const existing = existingById.get(task.id);
|
|
560
|
+
const params = taskRowParams(task);
|
|
561
|
+
seenIds.add(task.id);
|
|
562
|
+
if (!existing || existing.task_json !== params.task_json) {
|
|
563
|
+
upsertTaskRow(db, task);
|
|
564
|
+
events.push(insertStateEvent(db, {
|
|
565
|
+
resource: 'tasks',
|
|
566
|
+
op: existing ? 'update' : 'insert',
|
|
567
|
+
id: task.id,
|
|
568
|
+
value: task,
|
|
569
|
+
source,
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
for (const row of existingRows) {
|
|
575
|
+
if (seenIds.has(row.id)) continue;
|
|
576
|
+
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(row.id);
|
|
577
|
+
events.push(insertStateEvent(db, {
|
|
578
|
+
resource: 'tasks',
|
|
579
|
+
op: 'delete',
|
|
580
|
+
id: row.id,
|
|
581
|
+
source,
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
})();
|
|
585
|
+
|
|
586
|
+
publishEvents(events);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function updateTaskMeta(taskId, updates, options = {}) {
|
|
590
|
+
ensureTasksDirs();
|
|
591
|
+
const db = openLocalDb();
|
|
592
|
+
const source = options.source || 'tasks:meta';
|
|
593
|
+
let task = null;
|
|
594
|
+
let event = null;
|
|
595
|
+
|
|
596
|
+
db.transaction(() => {
|
|
597
|
+
const row = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(taskId);
|
|
598
|
+
if (!row) return;
|
|
599
|
+
task = taskForStorage({
|
|
600
|
+
...rowToTask(row),
|
|
601
|
+
...updates,
|
|
602
|
+
updatedAt: nowIso(),
|
|
603
|
+
});
|
|
604
|
+
upsertTaskRow(db, task);
|
|
605
|
+
event = insertStateEvent(db, {
|
|
115
606
|
resource: 'tasks',
|
|
116
|
-
op: '
|
|
117
|
-
|
|
607
|
+
op: 'update',
|
|
608
|
+
id: task.id,
|
|
609
|
+
value: task,
|
|
118
610
|
source,
|
|
119
611
|
});
|
|
120
|
-
}
|
|
121
|
-
console.warn('[Tasks] Local Live Store publish failed:', error.message);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
612
|
+
})();
|
|
124
613
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
publishTasksChange(data, options.source || 'tasks:save');
|
|
614
|
+
publishEvents(event ? [event] : []);
|
|
615
|
+
return task;
|
|
128
616
|
}
|
|
129
617
|
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
618
|
+
function listTaskRuns(options = {}) {
|
|
619
|
+
ensureTasksDirs();
|
|
620
|
+
const limit = Math.max(1, Math.min(Number(options.limit) || DEFAULT_RUN_LIMIT, 1000));
|
|
621
|
+
const db = openLocalDb();
|
|
622
|
+
const rows = options.taskId
|
|
623
|
+
? db.prepare(`
|
|
624
|
+
SELECT * FROM task_runs
|
|
625
|
+
WHERE task_id = ?
|
|
626
|
+
ORDER BY datetime(updated_at) DESC, datetime(started_at) DESC
|
|
627
|
+
LIMIT ?
|
|
628
|
+
`).all(options.taskId, limit)
|
|
629
|
+
: db.prepare(`
|
|
630
|
+
SELECT * FROM task_runs
|
|
631
|
+
ORDER BY datetime(updated_at) DESC, datetime(started_at) DESC
|
|
632
|
+
LIMIT ?
|
|
633
|
+
`).all(limit);
|
|
634
|
+
return rows.map(rowToTaskRun).filter(Boolean);
|
|
137
635
|
}
|
|
138
636
|
|
|
139
637
|
function appendRunLog(taskId, entry) {
|
|
140
|
-
|
|
638
|
+
if (!entry?.runId && !entry?.id) return null;
|
|
639
|
+
ensureTasksDirs();
|
|
640
|
+
|
|
641
|
+
const db = openLocalDb();
|
|
642
|
+
let run = null;
|
|
643
|
+
let event = null;
|
|
644
|
+
|
|
645
|
+
db.transaction(() => {
|
|
646
|
+
const runId = entry.runId || entry.id;
|
|
647
|
+
const existingRow = db.prepare('SELECT * FROM task_runs WHERE id = ?').get(runId);
|
|
648
|
+
const existing = rowToTaskRun(existingRow);
|
|
649
|
+
run = mergeRunEntry(existing, taskId, { ...entry, runId });
|
|
650
|
+
upsertTaskRunRow(db, run);
|
|
651
|
+
run = rowToTaskRun(db.prepare(`
|
|
652
|
+
SELECT * FROM task_runs
|
|
653
|
+
WHERE id = ? OR (task_id = ? AND scheduled_for = ?)
|
|
654
|
+
LIMIT 1
|
|
655
|
+
`).get(run.id, taskId, run.scheduledFor));
|
|
656
|
+
event = insertStateEvent(db, {
|
|
657
|
+
resource: 'task_runs',
|
|
658
|
+
op: existing ? 'update' : 'insert',
|
|
659
|
+
id: run.id,
|
|
660
|
+
value: run,
|
|
661
|
+
source: entry.source || 'task_runs:append',
|
|
662
|
+
});
|
|
663
|
+
})();
|
|
664
|
+
|
|
665
|
+
publishEvents(event ? [event] : []);
|
|
666
|
+
return run;
|
|
141
667
|
}
|
|
142
668
|
|
|
143
669
|
function readRunLog(taskId, limit = 20) {
|
|
144
|
-
return
|
|
670
|
+
return listTaskRuns({ taskId, limit });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function runningRunCount(db, taskId, now) {
|
|
674
|
+
const row = db.prepare(`
|
|
675
|
+
SELECT COUNT(*) AS count
|
|
676
|
+
FROM task_runs
|
|
677
|
+
WHERE task_id = ?
|
|
678
|
+
AND status = 'running'
|
|
679
|
+
AND (claim_expires_at IS NULL OR datetime(claim_expires_at) > datetime(?))
|
|
680
|
+
`).get(taskId, now.toISOString());
|
|
681
|
+
return Number(row?.count || 0);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function claimTaskRun(task, options = {}) {
|
|
685
|
+
ensureTasksDirs();
|
|
686
|
+
const db = openLocalDb();
|
|
687
|
+
const now = validDate(options.now) || new Date();
|
|
688
|
+
const timestamp = now.toISOString();
|
|
689
|
+
const source = options.source || 'scheduler';
|
|
690
|
+
const runnerId = options.runnerId || 'amalgm-mcp';
|
|
691
|
+
let claimed = null;
|
|
692
|
+
const events = [];
|
|
693
|
+
|
|
694
|
+
db.transaction(() => {
|
|
695
|
+
const latestRow = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(task.id);
|
|
696
|
+
if (!latestRow) return;
|
|
697
|
+
let latestTask = rowToTask(latestRow);
|
|
698
|
+
|
|
699
|
+
if (runningRunCount(db, latestTask.id, now) >= Math.max(1, Number(latestTask.maxConcurrentRuns || 1))) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const scheduledFor = isoOrNull(options.scheduledFor)
|
|
704
|
+
|| scheduledInstantForTask(latestTask, now)
|
|
705
|
+
|| timestamp;
|
|
706
|
+
const existingRun = db.prepare(`
|
|
707
|
+
SELECT * FROM task_runs WHERE task_id = ? AND scheduled_for = ?
|
|
708
|
+
`).get(latestTask.id, scheduledFor);
|
|
709
|
+
|
|
710
|
+
const nextRunAt = computeNextRunAt({
|
|
711
|
+
...latestTask,
|
|
712
|
+
lastRunAt: timestamp,
|
|
713
|
+
}, now);
|
|
714
|
+
|
|
715
|
+
if (existingRun) {
|
|
716
|
+
latestTask = taskForStorage({
|
|
717
|
+
...latestTask,
|
|
718
|
+
nextRunAt,
|
|
719
|
+
updatedAt: timestamp,
|
|
720
|
+
}, { preserveNextRunAt: true });
|
|
721
|
+
upsertTaskRow(db, latestTask);
|
|
722
|
+
events.push(insertStateEvent(db, {
|
|
723
|
+
resource: 'tasks',
|
|
724
|
+
op: 'update',
|
|
725
|
+
id: latestTask.id,
|
|
726
|
+
value: latestTask,
|
|
727
|
+
source,
|
|
728
|
+
}));
|
|
729
|
+
claimed = null;
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
latestTask = taskForStorage({
|
|
734
|
+
...latestTask,
|
|
735
|
+
lastRunAt: timestamp,
|
|
736
|
+
lastStatus: 'running',
|
|
737
|
+
nextRunAt,
|
|
738
|
+
updatedAt: timestamp,
|
|
739
|
+
}, { preserveNextRunAt: true });
|
|
740
|
+
|
|
741
|
+
upsertTaskRow(db, latestTask);
|
|
742
|
+
events.push(insertStateEvent(db, {
|
|
743
|
+
resource: 'tasks',
|
|
744
|
+
op: 'update',
|
|
745
|
+
id: latestTask.id,
|
|
746
|
+
value: latestTask,
|
|
747
|
+
source,
|
|
748
|
+
}));
|
|
749
|
+
|
|
750
|
+
const run = {
|
|
751
|
+
id: options.runId || crypto.randomUUID(),
|
|
752
|
+
runId: null,
|
|
753
|
+
taskId: latestTask.id,
|
|
754
|
+
scheduledFor,
|
|
755
|
+
status: 'running',
|
|
756
|
+
startedAt: timestamp,
|
|
757
|
+
finishedAt: null,
|
|
758
|
+
claimedAt: timestamp,
|
|
759
|
+
claimExpiresAt: addMs(now, TASK_CLAIM_TTL_MS).toISOString(),
|
|
760
|
+
runnerId,
|
|
761
|
+
sessionId: null,
|
|
762
|
+
error: null,
|
|
763
|
+
prompt: latestTask.prompt || null,
|
|
764
|
+
events: [{ runId: options.runId || null, startedAt: timestamp, status: 'running', recordedAt: timestamp }],
|
|
765
|
+
createdAt: timestamp,
|
|
766
|
+
updatedAt: timestamp,
|
|
767
|
+
};
|
|
768
|
+
run.runId = run.id;
|
|
769
|
+
upsertTaskRunRow(db, run);
|
|
770
|
+
const storedRun = rowToTaskRun(db.prepare('SELECT * FROM task_runs WHERE id = ?').get(run.id));
|
|
771
|
+
events.push(insertStateEvent(db, {
|
|
772
|
+
resource: 'task_runs',
|
|
773
|
+
op: 'insert',
|
|
774
|
+
id: storedRun.id,
|
|
775
|
+
value: storedRun,
|
|
776
|
+
source,
|
|
777
|
+
}));
|
|
778
|
+
claimed = { task: latestTask, run: storedRun };
|
|
779
|
+
})();
|
|
780
|
+
|
|
781
|
+
publishEvents(events);
|
|
782
|
+
return claimed;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function claimDueTaskRuns(now = new Date(), options = {}) {
|
|
786
|
+
ensureTasksDirs();
|
|
787
|
+
const db = openLocalDb();
|
|
788
|
+
const current = validDate(now) || new Date();
|
|
789
|
+
const limit = Math.max(1, Math.min(Number(options.limit) || 50, 500));
|
|
790
|
+
const rows = db.prepare(`
|
|
791
|
+
SELECT * FROM scheduled_tasks
|
|
792
|
+
WHERE enabled = 1
|
|
793
|
+
AND next_run_at IS NOT NULL
|
|
794
|
+
AND datetime(next_run_at) <= datetime(?)
|
|
795
|
+
ORDER BY datetime(next_run_at) ASC
|
|
796
|
+
LIMIT ?
|
|
797
|
+
`).all(current.toISOString(), limit);
|
|
798
|
+
|
|
799
|
+
const claims = [];
|
|
800
|
+
for (const row of rows) {
|
|
801
|
+
const task = rowToTask(row);
|
|
802
|
+
if (!task) continue;
|
|
803
|
+
const claim = claimTaskRun(task, {
|
|
804
|
+
now: current,
|
|
805
|
+
scheduledFor: scheduledInstantForTask(task, current),
|
|
806
|
+
runnerId: options.runnerId,
|
|
807
|
+
source: options.source || 'scheduler',
|
|
808
|
+
});
|
|
809
|
+
if (claim) claims.push(claim);
|
|
810
|
+
}
|
|
811
|
+
return claims;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function expireStaleTaskRuns(now = new Date(), options = {}) {
|
|
815
|
+
ensureTasksDirs();
|
|
816
|
+
const db = openLocalDb();
|
|
817
|
+
const current = validDate(now) || new Date();
|
|
818
|
+
const source = options.source || 'task_runs:expire';
|
|
819
|
+
const events = [];
|
|
820
|
+
|
|
821
|
+
db.transaction(() => {
|
|
822
|
+
const rows = db.prepare(`
|
|
823
|
+
SELECT * FROM task_runs
|
|
824
|
+
WHERE status = 'running'
|
|
825
|
+
AND claim_expires_at IS NOT NULL
|
|
826
|
+
AND datetime(claim_expires_at) <= datetime(?)
|
|
827
|
+
`).all(current.toISOString());
|
|
828
|
+
for (const row of rows) {
|
|
829
|
+
const run = {
|
|
830
|
+
...rowToTaskRun(row),
|
|
831
|
+
status: 'failed',
|
|
832
|
+
finishedAt: current.toISOString(),
|
|
833
|
+
error: 'Task runner stopped before this run completed.',
|
|
834
|
+
updatedAt: current.toISOString(),
|
|
835
|
+
};
|
|
836
|
+
upsertTaskRunRow(db, run);
|
|
837
|
+
events.push(insertStateEvent(db, {
|
|
838
|
+
resource: 'task_runs',
|
|
839
|
+
op: 'update',
|
|
840
|
+
id: run.id,
|
|
841
|
+
value: run,
|
|
842
|
+
source,
|
|
843
|
+
}));
|
|
844
|
+
|
|
845
|
+
const taskRow = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(run.taskId);
|
|
846
|
+
const task = rowToTask(taskRow);
|
|
847
|
+
if (task && task.lastStatus === 'running') {
|
|
848
|
+
const updatedTask = taskForStorage({
|
|
849
|
+
...task,
|
|
850
|
+
lastStatus: 'failed',
|
|
851
|
+
updatedAt: current.toISOString(),
|
|
852
|
+
}, { preserveNextRunAt: true });
|
|
853
|
+
upsertTaskRow(db, updatedTask);
|
|
854
|
+
events.push(insertStateEvent(db, {
|
|
855
|
+
resource: 'tasks',
|
|
856
|
+
op: 'update',
|
|
857
|
+
id: updatedTask.id,
|
|
858
|
+
value: updatedTask,
|
|
859
|
+
source,
|
|
860
|
+
}));
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
})();
|
|
864
|
+
|
|
865
|
+
publishEvents(events);
|
|
866
|
+
return events.length;
|
|
145
867
|
}
|
|
146
868
|
|
|
147
869
|
module.exports = {
|
|
870
|
+
appendRunLog,
|
|
871
|
+
claimDueTaskRuns,
|
|
872
|
+
claimTaskRun,
|
|
873
|
+
computeNextRunAt,
|
|
148
874
|
ensureTasksDirs,
|
|
875
|
+
expireStaleTaskRuns,
|
|
876
|
+
listTaskRuns,
|
|
149
877
|
loadTasks,
|
|
878
|
+
readRunLog,
|
|
150
879
|
saveTasks,
|
|
880
|
+
scheduledInstantForTask,
|
|
151
881
|
updateTaskMeta,
|
|
152
|
-
appendRunLog,
|
|
153
|
-
readRunLog,
|
|
154
882
|
};
|