amalgm 0.1.49 → 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.
@@ -1,19 +1,18 @@
1
1
  /**
2
- * Tasks storage — ~/.amalgm/tasks.json + ~/.amalgm/task-runs/{id}.jsonl
2
+ * Tasks storage — SQLite-backed scheduled task definitions and run receipts.
3
3
  *
4
- * Single writer, atomic rename. No dual-write to /persist (local-first).
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
- ensureDir,
11
- readJson,
12
- writeJsonAtomic,
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
- const { appendStateEvent } = require('../state/events');
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 migrateTasksData(data) {
76
- let changed = false;
77
- const tasks = Array.isArray(data.tasks)
78
- ? data.tasks.map((task) => {
79
- const normalizedTask = normalizeStoredTask(task);
80
- if (JSON.stringify(normalizedTask) !== JSON.stringify(task)) {
81
- changed = true;
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
- changed,
89
- data: {
90
- version: typeof data.version === 'number' ? data.version : 1,
91
- tasks,
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
- if (!readJson(TASKS_FILE, null)) writeJsonAtomic(TASKS_FILE, { version: 1, tasks: [] });
526
+ openLocalDb();
527
+ migrateTasksFromJsonOnce();
101
528
  }
102
529
 
103
530
  function loadTasks() {
104
- const raw = readJson(TASKS_FILE, { version: 1, tasks: [] });
105
- const migrated = migrateTasksData(raw);
106
- if (migrated.changed) {
107
- writeJsonAtomic(TASKS_FILE, migrated.data);
108
- }
109
- return migrated.data;
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 publishTasksChange(data, source = 'tasks') {
113
- try {
114
- appendStateEvent({
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: 'replace',
117
- value: Array.isArray(data?.tasks) ? data.tasks : [],
607
+ op: 'update',
608
+ id: task.id,
609
+ value: task,
118
610
  source,
119
611
  });
120
- } catch (error) {
121
- console.warn('[Tasks] Local Live Store publish failed:', error.message);
122
- }
123
- }
612
+ })();
124
613
 
125
- function saveTasks(data, options = {}) {
126
- writeJsonAtomic(TASKS_FILE, data);
127
- publishTasksChange(data, options.source || 'tasks:save');
614
+ publishEvents(event ? [event] : []);
615
+ return task;
128
616
  }
129
617
 
130
- function updateTaskMeta(taskId, updates) {
131
- const data = loadTasks();
132
- const task = data.tasks.find((t) => t.id === taskId);
133
- if (task) {
134
- Object.assign(task, updates);
135
- saveTasks(data);
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
- appendJsonl(path.join(TASK_RUNS_DIR, `${taskId}.jsonl`), entry);
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 tailJsonl(path.join(TASK_RUNS_DIR, `${taskId}.jsonl`), limit);
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
  };