amalgm 0.1.86 → 0.1.87

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.
@@ -14,6 +14,9 @@ const { validateWorkflowToolActions } = require('./tool-actions');
14
14
 
15
15
  const LEGACY_MIGRATION_KEY = 'automations_legacy_migrated_v1';
16
16
  const DEFAULT_RUN_LIMIT = 100;
17
+ const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'stopped', 'rejected', 'interrupted', 'unmatched']);
18
+ const DEFAULT_STALE_RUN_MS = Math.max(60_000, Number(process.env.AMALGM_AUTOMATION_RUN_STALE_MS) || 10 * 60_000);
19
+ const DEFAULT_RUN_RETENTION = Math.max(10, Number(process.env.AMALGM_AUTOMATION_RUN_RETENTION) || 500);
17
20
 
18
21
  let cronParser = null;
19
22
 
@@ -178,6 +181,104 @@ function rowToWorkflow(row) {
178
181
  });
179
182
  }
180
183
 
184
+ /**
185
+ * Ordered automation→workflow links. automation_definitions.workflow_id stays
186
+ * the primary (first) workflow for backwards compatibility; the link table is
187
+ * the source of truth for the full ordered set.
188
+ */
189
+ function replaceWorkflowLinks(db, automationId, workflowIds) {
190
+ db.prepare('DELETE FROM automation_workflow_links WHERE automation_id = ?').run(automationId);
191
+ const insert = db.prepare(`
192
+ INSERT INTO automation_workflow_links (automation_id, workflow_id, position)
193
+ VALUES (?, ?, ?)
194
+ `);
195
+ workflowIds.forEach((workflowId, position) => {
196
+ insert.run(automationId, workflowId, position);
197
+ });
198
+ }
199
+
200
+ function readWorkflowLinks(db) {
201
+ const links = new Map();
202
+ const rows = db.prepare(`
203
+ SELECT automation_id, workflow_id
204
+ FROM automation_workflow_links
205
+ ORDER BY automation_id, position ASC
206
+ `).all();
207
+ for (const row of rows) {
208
+ if (!links.has(row.automation_id)) links.set(row.automation_id, []);
209
+ links.get(row.automation_id).push(row.workflow_id);
210
+ }
211
+ return links;
212
+ }
213
+
214
+ /**
215
+ * Normalize the `workflows` input on create/update. Entries may be workflow
216
+ * objects or bare workflowText strings. Falls back to the legacy single
217
+ * workflow fields when no array is provided.
218
+ */
219
+ function workflowInputsForAutomation(input = {}) {
220
+ if (Array.isArray(input.workflows) && input.workflows.length > 0) {
221
+ return input.workflows.map((entry) => (
222
+ typeof entry === 'string' ? { workflowText: entry } : (isObject(entry) ? entry : {})
223
+ ));
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /**
229
+ * Match incoming workflow inputs against an automation's existing workflows so
230
+ * workflow identity (id, run history linkage) survives an array update.
231
+ * Matching order: explicit id, then the next unclaimed existing workflow —
232
+ * a mixed id/no-id array must not mis-bind and orphan-delete a survivor.
233
+ */
234
+ function matchWorkflowsToExisting(workflowInputs, existingWorkflows) {
235
+ const unmatched = [...existingWorkflows];
236
+ return workflowInputs.map((input) => {
237
+ const inputId = cleanString(input?.id);
238
+ let matchIndex = inputId ? unmatched.findIndex((item) => item.id === inputId) : -1;
239
+ if (matchIndex < 0 && !inputId && unmatched.length > 0) matchIndex = 0;
240
+ const existing = matchIndex >= 0 ? unmatched.splice(matchIndex, 1)[0] : null;
241
+ return { input: input || {}, existing };
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Guard a normalized workflows array before writing: ids must be unique, and
247
+ * an id the automation does not already own must not collide with a workflow
248
+ * row owned elsewhere — upsertWorkflowRow's ON CONFLICT would silently rewrite
249
+ * another automation's workflow (and its allowlist) in place.
250
+ */
251
+ function assertWorkflowIdsWritable(db, workflows, ownedIds = new Set()) {
252
+ const seen = new Set();
253
+ for (const workflow of workflows) {
254
+ if (seen.has(workflow.id)) {
255
+ throw new Error(`Duplicate workflow id in workflows: ${workflow.id}`);
256
+ }
257
+ seen.add(workflow.id);
258
+ if (ownedIds.has(workflow.id)) continue;
259
+ const taken = db.prepare('SELECT 1 FROM automation_workflows WHERE id = ?').get(workflow.id);
260
+ if (taken) {
261
+ throw new Error(`Workflow id ${workflow.id} already belongs to another automation. Omit the id to create a new workflow.`);
262
+ }
263
+ }
264
+ }
265
+
266
+ function deleteOrphanedWorkflowRows(db, workflowIds) {
267
+ const remove = db.prepare(`
268
+ DELETE FROM automation_workflows
269
+ WHERE id = ?
270
+ AND NOT EXISTS (
271
+ SELECT 1 FROM automation_definitions WHERE workflow_id = automation_workflows.id
272
+ )
273
+ AND NOT EXISTS (
274
+ SELECT 1 FROM automation_workflow_links WHERE workflow_id = automation_workflows.id
275
+ )
276
+ `);
277
+ for (const workflowId of workflowIds) {
278
+ if (workflowId) remove.run(workflowId);
279
+ }
280
+ }
281
+
181
282
  function upsertWorkflowRow(db, workflow) {
182
283
  const params = workflowRowParams(workflow);
183
284
  db.prepare(`
@@ -210,10 +311,21 @@ function normalizeSchedule(inputSchedule, existingSchedule = null) {
210
311
  const candidate = inputSchedule || existingSchedule;
211
312
  const result = normalizeTaskSchedule(candidate);
212
313
  if (result.error) throw new Error(result.error);
213
- return result.schedule;
314
+ const schedule = result.schedule;
315
+ if (schedule?.kind === 'cron') {
316
+ // Reject unparseable cron expressions at save time. computeNextRunAt
317
+ // swallows parse errors and returns null, which leaves the trigger
318
+ // permanently dormant with status "active" — fail loudly here instead.
319
+ try {
320
+ cron().CronExpressionParser.parse(schedule.expr, { tz: schedule.tz || 'UTC' });
321
+ } catch (error) {
322
+ throw new Error(`Invalid cron schedule "${schedule.expr}" (tz ${schedule.tz || 'UTC'}): ${error?.message || error}`);
323
+ }
324
+ }
325
+ return schedule;
214
326
  }
215
327
 
216
- function computeNextRunAt(trigger, afterDate = new Date()) {
328
+ function computeNextRunAt(trigger, afterDate = new Date(), options = {}) {
217
329
  if (!trigger || trigger.enabled === false || trigger.kind !== 'scheduled') return null;
218
330
  const schedule = trigger.schedule;
219
331
  if (!schedule) return null;
@@ -223,13 +335,20 @@ function computeNextRunAt(trigger, afterDate = new Date()) {
223
335
  const at = validDate(schedule.at);
224
336
  if (!at) return null;
225
337
  if (trigger.lastFiredAt && validDate(trigger.lastFiredAt) >= at) return null;
226
- return at > after ? at.toISOString() : at.toISOString();
338
+ // A past "at" stays claimable so a missed one-shot run catches up at boot.
339
+ return at.toISOString();
227
340
  }
228
341
 
229
342
  if (schedule.kind === 'interval') {
230
343
  const ms = Number(schedule.ms);
231
344
  if (!Number.isFinite(ms) || ms < 60_000) return null;
232
- return new Date(after.getTime() + ms).toISOString();
345
+ // Anchor to the scheduled slot (not the claim time) when available so the
346
+ // cadence keeps its phase across poll latency, sleep, and restarts, and
347
+ // missed slots are skipped rather than replayed.
348
+ const anchor = validDate(options.anchor);
349
+ let next = (anchor || after).getTime() + ms;
350
+ while (next <= after.getTime()) next += ms;
351
+ return new Date(next).toISOString();
233
352
  }
234
353
 
235
354
  if (schedule.kind === 'cron') {
@@ -286,9 +405,22 @@ function normalizeTriggerRecord(input = {}, automation, existing = null) {
286
405
  if (kind === 'scheduled') {
287
406
  trigger.schedule = normalizeSchedule(input.schedule, existing?.schedule);
288
407
  trigger.scheduleLabel = scheduleLabel(trigger.schedule);
408
+ // When the schedule itself changes, the old next_run_at is stale — always
409
+ // recompute so the new cadence applies immediately instead of after one
410
+ // wrongly-timed fire. Callers often round-trip the full trigger record,
411
+ // so an input nextRunAt identical to the existing one is treated as
412
+ // round-tripped state, not an explicit override.
413
+ const scheduleChanged = !!existing
414
+ && JSON.stringify(trigger.schedule) !== JSON.stringify(existing.schedule || null);
415
+ const inputNextRunAt = isoOrNull(input.nextRunAt);
416
+ const roundTrippedNextRunAt = !!inputNextRunAt
417
+ && !!existing?.nextRunAt
418
+ && inputNextRunAt === isoOrNull(existing.nextRunAt);
289
419
  trigger.nextRunAt = input.nextRunAt === null
290
420
  ? null
291
- : isoOrNull(input.nextRunAt) || existing?.nextRunAt || computeNextRunAt(trigger, trigger.createdAt);
421
+ : ((scheduleChanged && roundTrippedNextRunAt) ? null : inputNextRunAt)
422
+ || (!scheduleChanged && existing?.nextRunAt)
423
+ || computeNextRunAt(trigger, timestamp);
292
424
  trigger.source = null;
293
425
  trigger.event = null;
294
426
  trigger.sourceUrl = null;
@@ -311,6 +443,57 @@ function normalizeTriggerRecord(input = {}, automation, existing = null) {
311
443
  return trigger;
312
444
  }
313
445
 
446
+ function triggerMatchKind(trigger = {}) {
447
+ const rawKind = cleanString(trigger.kind || 'event').toLowerCase();
448
+ return rawKind === 'webhook' ? 'event' : rawKind;
449
+ }
450
+
451
+ function triggerFingerprint(trigger = {}) {
452
+ const kind = triggerMatchKind(trigger);
453
+ if (kind === 'scheduled') {
454
+ let schedule = trigger.schedule || null;
455
+ try {
456
+ schedule = normalizeTaskSchedule(trigger.schedule).schedule || schedule;
457
+ } catch {
458
+ // Fingerprint with the raw schedule if it does not normalize.
459
+ }
460
+ return `scheduled:${JSON.stringify(schedule)}`;
461
+ }
462
+ const source = cleanString(trigger.source) || '*';
463
+ const event = cleanString(trigger.event ?? trigger.eventName) || '*';
464
+ return `event:${source}.${event}`;
465
+ }
466
+
467
+ /**
468
+ * Match incoming trigger inputs against existing trigger records so trigger
469
+ * identity (id, webhook secret, schedule clock, fire history) survives a
470
+ * full-array update instead of being regenerated by delete-and-recreate.
471
+ * Matching order: explicit id, exact kind+selector/schedule fingerprint, then
472
+ * a single remaining trigger of the same kind (covers in-place edits).
473
+ */
474
+ function matchTriggersToExisting(triggerInputs, existingTriggers) {
475
+ const unmatched = [...existingTriggers];
476
+ const inputs = Array.isArray(triggerInputs) ? triggerInputs : [];
477
+ return inputs.map((input) => {
478
+ const inputId = cleanString(input?.id);
479
+ let index = inputId ? unmatched.findIndex((item) => item.id === inputId) : -1;
480
+ if (index < 0 && !inputId) {
481
+ const fingerprint = triggerFingerprint(input || {});
482
+ index = unmatched.findIndex((item) => triggerFingerprint(item) === fingerprint);
483
+ if (index < 0) {
484
+ const kind = triggerMatchKind(input || {});
485
+ const sameKindExisting = unmatched.filter((item) => triggerMatchKind(item) === kind);
486
+ const sameKindIncoming = inputs.filter((item) => triggerMatchKind(item || {}) === kind);
487
+ if (sameKindExisting.length === 1 && sameKindIncoming.length === 1) {
488
+ index = unmatched.indexOf(sameKindExisting[0]);
489
+ }
490
+ }
491
+ }
492
+ const existing = index >= 0 ? unmatched.splice(index, 1)[0] : null;
493
+ return { input: input || {}, existing };
494
+ });
495
+ }
496
+
314
497
  function triggerRowParams(trigger) {
315
498
  return {
316
499
  id: trigger.id,
@@ -470,41 +653,46 @@ function triggerStatus(trigger) {
470
653
  return 'active';
471
654
  }
472
655
 
473
- function automationStatus(automation, triggers, workflow) {
656
+ function automationStatus(automation, triggers, workflows) {
657
+ const workflowList = (Array.isArray(workflows) ? workflows : [workflows]).filter(Boolean);
474
658
  if (automation.enabled === false) return 'paused';
475
- if (workflowStatus(workflow) === 'error') return 'error';
659
+ if (workflowList.some((workflow) => workflowStatus(workflow) === 'error')) return 'error';
476
660
  if (triggers.some((trigger) => triggerStatus(trigger) === 'error')) return 'error';
477
661
  if (triggers.length > 0 && triggers.every((trigger) => trigger.enabled === false)) return 'paused';
478
662
  return 'active';
479
663
  }
480
664
 
481
- function composeAutomation(base, triggers, workflow) {
665
+ function composeAutomation(base, triggers, workflowOrWorkflows) {
666
+ const workflowList = (Array.isArray(workflowOrWorkflows) ? workflowOrWorkflows : [workflowOrWorkflows])
667
+ .filter(Boolean);
668
+ const workflowIds = workflowList.map((workflow) => workflow.id);
482
669
  const normalizedTriggers = triggers.map((trigger) => ({
483
670
  ...trigger,
484
671
  workflowId: base.workflowId,
485
- workflowIds: [base.workflowId],
672
+ workflowIds,
486
673
  status: triggerStatus(trigger),
487
674
  backing: {
488
675
  resource: 'triggers',
489
676
  id: trigger.id,
490
677
  },
491
678
  }));
492
- const normalizedWorkflow = workflow ? {
679
+ const normalizedWorkflows = workflowList.map((workflow) => ({
493
680
  ...workflow,
494
681
  triggerIds: normalizedTriggers.map((trigger) => trigger.id),
495
682
  automationIds: [base.id],
496
683
  status: workflowStatus(workflow),
497
- } : null;
684
+ }));
685
+ const primaryWorkflow = normalizedWorkflows[0] || null;
498
686
 
499
687
  return {
500
688
  ...base,
501
689
  kind: normalizedTriggers[0]?.kind || 'automation',
502
- status: automationStatus(base, normalizedTriggers, normalizedWorkflow),
690
+ status: automationStatus(base, normalizedTriggers, normalizedWorkflows),
503
691
  triggerIds: normalizedTriggers.map((trigger) => trigger.id),
504
- workflowIds: [base.workflowId],
692
+ workflowIds,
505
693
  triggers: normalizedTriggers,
506
- workflow: normalizedWorkflow,
507
- workflows: normalizedWorkflow ? [normalizedWorkflow] : [],
694
+ workflow: primaryWorkflow,
695
+ workflows: normalizedWorkflows,
508
696
  };
509
697
  }
510
698
 
@@ -525,20 +713,32 @@ function readAutomationGraph(db = openLocalDb()) {
525
713
  triggersByAutomation.get(trigger.automationId).push(trigger);
526
714
  }
527
715
 
716
+ const links = readWorkflowLinks(db);
528
717
  const automations = automationRows
529
718
  .map(rowToAutomationBase)
530
719
  .filter(Boolean)
531
- .map((automation) => composeAutomation(
532
- automation,
533
- triggersByAutomation.get(automation.id) || [],
534
- workflowsById.get(automation.workflowId) || null,
535
- ));
720
+ .map((automation) => {
721
+ const linkedIds = links.get(automation.id) || [];
722
+ const workflowIds = linkedIds.length > 0 ? linkedIds : [automation.workflowId];
723
+ const automationWorkflows = workflowIds
724
+ .map((workflowId) => workflowsById.get(workflowId) || null)
725
+ .filter(Boolean);
726
+ return composeAutomation(
727
+ automation,
728
+ triggersByAutomation.get(automation.id) || [],
729
+ automationWorkflows,
730
+ );
731
+ });
536
732
  const triggers = automations.flatMap((automation) => automation.triggers);
537
- const usedWorkflowIds = new Set(automations.map((automation) => automation.workflowId));
733
+ const usedWorkflowIds = new Set(automations.flatMap((automation) => automation.workflowIds));
538
734
  const workflows = Array.from(workflowsById.values()).map((workflow) => ({
539
735
  ...workflow,
540
- triggerIds: triggers.filter((trigger) => trigger.workflowId === workflow.id).map((trigger) => trigger.id),
541
- automationIds: automations.filter((automation) => automation.workflowId === workflow.id).map((automation) => automation.id),
736
+ triggerIds: automations
737
+ .filter((automation) => automation.workflowIds.includes(workflow.id))
738
+ .flatMap((automation) => automation.triggerIds),
739
+ automationIds: automations
740
+ .filter((automation) => automation.workflowIds.includes(workflow.id))
741
+ .map((automation) => automation.id),
542
742
  status: workflowStatus(workflow),
543
743
  orphaned: !usedWorkflowIds.has(workflow.id),
544
744
  }));
@@ -579,7 +779,11 @@ function defaultTriggersForInput(input, automation) {
579
779
  }
580
780
 
581
781
  function insertAutomationBundle(db, input = {}) {
582
- const workflowInput = {
782
+ const arrayInputs = workflowInputsForAutomation(input);
783
+ if (arrayInputs && (cleanString(input.workflowText) || typeof input.workflow === 'string')) {
784
+ throw new Error('Pass either workflowText/workflow or workflows, not both.');
785
+ }
786
+ const workflowInputs = arrayInputs || [{
583
787
  ...(isObject(input.workflow) ? input.workflow : {}),
584
788
  name: input.workflowName || input.name,
585
789
  description: input.workflowDescription ?? input.description,
@@ -588,20 +792,32 @@ function insertAutomationBundle(db, input = {}) {
588
792
  workflowText: input.workflowText || (typeof input.workflow === 'string' ? input.workflow : input.workflow?.workflowText),
589
793
  allowlist: input.allowlist || input.workflow?.allowlist,
590
794
  limits: input.limits || input.workflow?.limits,
591
- };
592
- const workflow = normalizeWorkflowRecord(workflowInput);
593
- if (workflow.compilerErrors?.length) throw new Error(workflow.compilerErrors.join('\n'));
795
+ }];
796
+ const workflows = workflowInputs.map((workflowInput, index) => normalizeWorkflowRecord({
797
+ projectPath: input.projectPath,
798
+ // Top-level allowlist/limits apply to every workflow unless an entry
799
+ // carries its own (the spread below wins for per-entry values).
800
+ allowlist: input.allowlist,
801
+ limits: input.limits,
802
+ ...workflowInput,
803
+ name: workflowInput.name
804
+ || (index === 0 ? (input.workflowName || input.name) : `${input.name || 'Workflow'} ${index + 1}`),
805
+ }));
806
+ const compileErrors = workflows.flatMap((workflow) => workflow.compilerErrors || []);
807
+ if (compileErrors.length > 0) throw new Error(compileErrors.join('\n'));
808
+ assertWorkflowIdsWritable(db, workflows);
594
809
 
595
- const automation = normalizeAutomationRecord(input, workflow);
596
- automation.workflow = workflow;
810
+ const automation = normalizeAutomationRecord(input, workflows[0]);
811
+ automation.workflow = workflows[0];
597
812
  const triggerInputs = defaultTriggersForInput(input, automation);
598
813
  const triggers = triggerInputs.map((triggerInput) => normalizeTriggerRecord(triggerInput, automation));
599
814
  if (triggers.length === 0) throw new Error('Automation requires at least one trigger.');
600
815
 
601
- upsertWorkflowRow(db, workflow);
816
+ for (const workflow of workflows) upsertWorkflowRow(db, workflow);
602
817
  upsertAutomationRow(db, automation);
818
+ replaceWorkflowLinks(db, automation.id, workflows.map((workflow) => workflow.id));
603
819
  for (const trigger of triggers) upsertTriggerRow(db, trigger);
604
- return composeAutomation(automation, triggers, workflow);
820
+ return composeAutomation(automation, triggers, workflows);
605
821
  }
606
822
 
607
823
  function createAutomation(input = {}, options = {}) {
@@ -637,30 +853,71 @@ function updateAutomation(automationId, updates = {}, options = {}) {
637
853
  const db = openLocalDb();
638
854
 
639
855
  db.transaction(() => {
640
- const workflow = normalizeWorkflowRecord({
641
- ...existing.workflow,
642
- ...(isObject(updates.workflow) ? updates.workflow : {}),
643
- ...(updates.workflowText !== undefined ? { workflowText: updates.workflowText } : {}),
644
- ...(updates.workflow !== undefined && typeof updates.workflow === 'string' ? { workflowText: updates.workflow } : {}),
645
- ...(updates.workflowName !== undefined ? { name: updates.workflowName } : {}),
646
- ...(updates.allowlist !== undefined ? { allowlist: updates.allowlist } : {}),
647
- ...(updates.limits !== undefined ? { limits: updates.limits } : {}),
648
- }, existing.workflow);
649
- if (workflow.compilerErrors?.length) throw new Error(workflow.compilerErrors.join('\n'));
856
+ const existingWorkflows = Array.isArray(existing.workflows) ? existing.workflows : [];
857
+ const workflowArrayInputs = workflowInputsForAutomation(updates);
858
+ let workflows;
859
+ let workflowsToUpsert;
860
+ if (workflowArrayInputs) {
861
+ if (cleanString(updates.workflowText) || typeof updates.workflow === 'string') {
862
+ throw new Error('Pass either workflowText/workflow or workflows, not both.');
863
+ }
864
+ // Full workflows-array update: match by id (then next unclaimed) so
865
+ // workflow identity and run-history linkage survive, like trigger
866
+ // updates.
867
+ const matched = matchWorkflowsToExisting(workflowArrayInputs, existingWorkflows);
868
+ workflows = matched.map(({ input, existing: prior }, index) => normalizeWorkflowRecord({
869
+ projectPath: existing.projectPath,
870
+ allowlist: updates.allowlist,
871
+ limits: updates.limits,
872
+ ...input,
873
+ name: cleanString(input.name) || prior?.name
874
+ || (index === 0 ? (updates.workflowName || existing.name) : `${existing.name || 'Workflow'} ${index + 1}`),
875
+ }, prior));
876
+ if (workflows.length === 0) throw new Error('Automation requires at least one workflow.');
877
+ assertWorkflowIdsWritable(db, workflows, new Set(existingWorkflows.map((workflow) => workflow.id)));
878
+ workflowsToUpsert = workflows;
879
+ } else {
880
+ // Legacy single-workflow updates apply to the primary workflow only.
881
+ const primary = normalizeWorkflowRecord({
882
+ ...existing.workflow,
883
+ ...(isObject(updates.workflow) ? updates.workflow : {}),
884
+ ...(updates.workflowText !== undefined ? { workflowText: updates.workflowText } : {}),
885
+ ...(updates.workflow !== undefined && typeof updates.workflow === 'string' ? { workflowText: updates.workflow } : {}),
886
+ ...(updates.workflowName !== undefined ? { name: updates.workflowName } : {}),
887
+ ...(updates.allowlist !== undefined ? { allowlist: updates.allowlist } : {}),
888
+ ...(updates.limits !== undefined ? { limits: updates.limits } : {}),
889
+ }, existing.workflow);
890
+ workflows = [primary, ...existingWorkflows.slice(1)];
891
+ workflowsToUpsert = [primary];
892
+ }
893
+ const compileErrors = workflows.flatMap((workflow) => workflow.compilerErrors || []);
894
+ if (compileErrors.length > 0) throw new Error(compileErrors.join('\n'));
650
895
 
651
896
  const base = normalizeAutomationRecord({
652
897
  ...existing,
653
898
  ...updates,
654
899
  id: existing.id,
655
- }, workflow, existing);
656
- base.workflow = workflow;
657
- upsertWorkflowRow(db, workflow);
900
+ }, workflows[0], existing);
901
+ base.workflow = workflows[0];
902
+ for (const workflow of workflowsToUpsert) upsertWorkflowRow(db, workflow);
658
903
  upsertAutomationRow(db, base);
904
+ replaceWorkflowLinks(db, base.id, workflows.map((workflow) => workflow.id));
905
+ const keepWorkflowIds = new Set(workflows.map((workflow) => workflow.id));
906
+ deleteOrphanedWorkflowRows(
907
+ db,
908
+ existingWorkflows.map((workflow) => workflow.id).filter((id) => !keepWorkflowIds.has(id)),
909
+ );
659
910
 
660
911
  let triggers = existing.triggers;
661
912
  if (Array.isArray(updates.triggers)) {
662
- db.prepare('DELETE FROM automation_triggers WHERE automation_id = ?').run(existing.id);
663
- triggers = updates.triggers.map((triggerInput) => normalizeTriggerRecord(triggerInput, base));
913
+ const matched = matchTriggersToExisting(updates.triggers, existing.triggers);
914
+ triggers = matched.map(({ input, existing: prior }) => normalizeTriggerRecord(input, base, prior));
915
+ const keepIds = new Set(triggers.map((trigger) => trigger.id));
916
+ for (const prior of existing.triggers) {
917
+ if (!keepIds.has(prior.id)) {
918
+ db.prepare('DELETE FROM automation_triggers WHERE id = ?').run(prior.id);
919
+ }
920
+ }
664
921
  for (const trigger of triggers) upsertTriggerRow(db, trigger);
665
922
  } else if (isObject(updates.trigger) || updates.trigger_id || updates.triggerId) {
666
923
  const triggerId = updates.trigger_id || updates.triggerId || updates.trigger?.id;
@@ -676,7 +933,7 @@ function updateAutomation(automationId, updates = {}, options = {}) {
676
933
  });
677
934
  }
678
935
 
679
- saved = composeAutomation(base, triggers, workflow);
936
+ saved = composeAutomation(base, triggers, workflows);
680
937
  })();
681
938
 
682
939
  publishResources(options.source || 'automations:update');
@@ -734,14 +991,10 @@ function deleteAutomation(automationId, options = {}) {
734
991
  if (!existing) throw new Error(`Automation not found: ${automationId}`);
735
992
  const db = openLocalDb();
736
993
  db.transaction(() => {
994
+ // Deleting the definition cascades this automation's workflow links;
995
+ // workflow rows are then removed only if nothing else references them.
737
996
  db.prepare('DELETE FROM automation_definitions WHERE id = ?').run(existing.id);
738
- db.prepare(`
739
- DELETE FROM automation_workflows
740
- WHERE id = ?
741
- AND NOT EXISTS (
742
- SELECT 1 FROM automation_definitions WHERE workflow_id = automation_workflows.id
743
- )
744
- `).run(existing.workflowId);
997
+ deleteOrphanedWorkflowRows(db, existing.workflowIds || [existing.workflowId]);
745
998
  })();
746
999
  publishResources(options.source || 'automations:delete');
747
1000
  return existing;
@@ -802,12 +1055,20 @@ function claimDueScheduledTriggers(now = new Date(), options = {}) {
802
1055
  FROM automation_triggers
803
1056
  INNER JOIN automation_definitions
804
1057
  ON automation_definitions.id = automation_triggers.automation_id
805
- INNER JOIN automation_workflows
806
- ON automation_workflows.id = automation_definitions.workflow_id
807
1058
  WHERE automation_triggers.kind = 'scheduled'
808
1059
  AND automation_triggers.enabled = 1
809
1060
  AND automation_definitions.enabled = 1
810
- AND automation_workflows.enabled = 1
1061
+ AND EXISTS (
1062
+ SELECT 1 FROM automation_workflows
1063
+ WHERE automation_workflows.enabled = 1
1064
+ AND (
1065
+ automation_workflows.id = automation_definitions.workflow_id
1066
+ OR automation_workflows.id IN (
1067
+ SELECT workflow_id FROM automation_workflow_links
1068
+ WHERE automation_workflow_links.automation_id = automation_definitions.id
1069
+ )
1070
+ )
1071
+ )
811
1072
  AND automation_triggers.next_run_at IS NOT NULL
812
1073
  AND automation_triggers.next_run_at <= ?
813
1074
  ORDER BY datetime(automation_triggers.next_run_at) ASC
@@ -826,13 +1087,15 @@ function claimDueScheduledTriggers(now = new Date(), options = {}) {
826
1087
  updatedAt: timestamp,
827
1088
  };
828
1089
  if (fired.schedule?.kind === 'once') fired.enabled = false;
829
- fired.nextRunAt = fired.enabled === false ? null : computeNextRunAt(fired, nowDate);
1090
+ fired.nextRunAt = fired.enabled === false
1091
+ ? null
1092
+ : computeNextRunAt(fired, nowDate, { anchor: row.next_run_at });
830
1093
  upsertTriggerRow(db, fired);
831
1094
  claims.push({
832
1095
  automation: composeAutomation(
833
1096
  automation,
834
1097
  automation.triggers.map((item) => (item.id === fired.id ? fired : item)),
835
- automation.workflow,
1098
+ automation.workflows,
836
1099
  ),
837
1100
  trigger: fired,
838
1101
  scheduledFor: row.next_run_at,
@@ -844,6 +1107,60 @@ function claimDueScheduledTriggers(now = new Date(), options = {}) {
844
1107
  return claims;
845
1108
  }
846
1109
 
1110
+ /**
1111
+ * Re-arm enabled scheduled triggers whose next_run_at is NULL. Re-arming
1112
+ * normally only happens as a side effect of a successful claim, so any path
1113
+ * that nulls next_run_at (historical cron parse failure, explicit
1114
+ * nextRunAt:null, partial writes) leaves the trigger permanently dormant —
1115
+ * the claim query filters `next_run_at IS NOT NULL`. Run this on scheduler
1116
+ * boot so dormant triggers come back without a manual enable-toggle.
1117
+ */
1118
+ function repairScheduledTriggers(options = {}) {
1119
+ ensureAutomationsStore();
1120
+ const db = openLocalDb();
1121
+ const rows = db.prepare(`
1122
+ SELECT automation_triggers.*
1123
+ FROM automation_triggers
1124
+ INNER JOIN automation_definitions
1125
+ ON automation_definitions.id = automation_triggers.automation_id
1126
+ WHERE automation_triggers.kind = 'scheduled'
1127
+ AND automation_triggers.enabled = 1
1128
+ AND automation_definitions.enabled = 1
1129
+ AND automation_triggers.next_run_at IS NULL
1130
+ `).all();
1131
+
1132
+ const repaired = [];
1133
+ for (const row of rows) {
1134
+ const trigger = rowToTrigger(row);
1135
+ const nextRunAt = computeNextRunAt(trigger, new Date());
1136
+ if (!nextRunAt) continue; // e.g. once-schedules that already fired
1137
+ repaired.push({ ...trigger, nextRunAt, updatedAt: nowIso() });
1138
+ }
1139
+ if (repaired.length > 0) {
1140
+ db.transaction(() => {
1141
+ for (const trigger of repaired) upsertTriggerRow(db, trigger);
1142
+ })();
1143
+ publishResources(options.source || 'automations:repair');
1144
+ }
1145
+ return repaired;
1146
+ }
1147
+
1148
+ function recordSchedulerHeartbeat(timestamp = nowIso()) {
1149
+ try {
1150
+ setMetaValue(openLocalDb(), 'automation_scheduler_heartbeat', timestamp);
1151
+ } catch {
1152
+ // Heartbeat is diagnostics only; never let it break the poll.
1153
+ }
1154
+ }
1155
+
1156
+ function getSchedulerHeartbeat() {
1157
+ try {
1158
+ return metaValue(openLocalDb(), 'automation_scheduler_heartbeat');
1159
+ } catch {
1160
+ return null;
1161
+ }
1162
+ }
1163
+
847
1164
  function rowToAutomationRun(row) {
848
1165
  if (!row) return null;
849
1166
  const parsed = parseJson(row.run_json, {});
@@ -936,6 +1253,17 @@ function recordAutomationRun(automationId, entry, options = {}) {
936
1253
  const timestamp = nowIso();
937
1254
  const events = Array.isArray(existing?.events) ? existing.events.slice(-99) : [];
938
1255
  events.push({ ...entry, recordedAt: timestamp });
1256
+ // Terminal statuses are final: a late or out-of-order cell event must not
1257
+ // resurrect a finished/interrupted run back to "running". One exception:
1258
+ // 'interrupted' is reaper-written and can race a legitimately long run in
1259
+ // flight — a real in-process terminal outcome wins over it.
1260
+ const existingTerminal = !!existing
1261
+ && (existing.finishedAt || TERMINAL_RUN_STATUSES.has(existing.status));
1262
+ const overridesInterrupted = !!existing
1263
+ && existing.status === 'interrupted'
1264
+ && TERMINAL_RUN_STATUSES.has(entry.status)
1265
+ && entry.status !== 'interrupted';
1266
+ const frozen = existingTerminal && !overridesInterrupted;
939
1267
  run = {
940
1268
  ...(existing || {}),
941
1269
  id: runId,
@@ -945,11 +1273,14 @@ function recordAutomationRun(automationId, entry, options = {}) {
945
1273
  automationName: entry.automationName ?? existing?.automationName ?? null,
946
1274
  triggerName: entry.triggerName ?? existing?.triggerName ?? null,
947
1275
  workflowName: entry.workflowName ?? existing?.workflowName ?? null,
948
- status: entry.status || existing?.status || 'running',
1276
+ status: frozen ? existing.status : (entry.status || existing?.status || 'running'),
949
1277
  startedAt: entry.startedAt || existing?.startedAt || timestamp,
950
- finishedAt: entry.finishedAt || existing?.finishedAt || null,
1278
+ finishedAt: frozen
1279
+ ? existing.finishedAt
1280
+ : (entry.finishedAt || (overridesInterrupted ? null : existing?.finishedAt) || null),
951
1281
  projectPath: entry.projectPath ?? existing?.projectPath ?? null,
952
- error: entry.error || existing?.error || null,
1282
+ // Overriding a reaper-written 'interrupted' must also clear its error.
1283
+ error: entry.error || (overridesInterrupted ? null : existing?.error) || null,
953
1284
  output: entry.output !== undefined ? entry.output : existing?.output,
954
1285
  events,
955
1286
  createdAt: existing?.createdAt || timestamp,
@@ -971,22 +1302,126 @@ function recordAutomationRun(automationId, entry, options = {}) {
971
1302
  function listAutomationRuns(options = {}) {
972
1303
  ensureAutomationsStore();
973
1304
  const limit = Math.max(1, Math.min(Number(options.limit) || DEFAULT_RUN_LIMIT, 1000));
1305
+ const offset = Math.max(0, Number(options.offset) || 0);
974
1306
  const db = openLocalDb();
975
1307
  const rows = options.automationId
976
1308
  ? db.prepare(`
977
1309
  SELECT * FROM automation_runs
978
1310
  WHERE automation_id = ?
979
1311
  ORDER BY datetime(updated_at) DESC
980
- LIMIT ?
981
- `).all(options.automationId, limit)
1312
+ LIMIT ? OFFSET ?
1313
+ `).all(options.automationId, limit, offset)
982
1314
  : db.prepare(`
983
1315
  SELECT * FROM automation_runs
984
1316
  ORDER BY datetime(updated_at) DESC
985
- LIMIT ?
986
- `).all(limit);
1317
+ LIMIT ? OFFSET ?
1318
+ `).all(limit, offset);
987
1319
  return rows.map(rowToAutomationRun).filter(Boolean);
988
1320
  }
989
1321
 
1322
+ function getAutomationRun(runId) {
1323
+ const id = cleanString(runId);
1324
+ if (!id) return null;
1325
+ ensureAutomationsStore();
1326
+ const row = openLocalDb().prepare('SELECT * FROM automation_runs WHERE id = ?').get(id);
1327
+ return rowToAutomationRun(row);
1328
+ }
1329
+
1330
+ /**
1331
+ * Liveness heartbeat for an in-flight run. The runner only writes at cell
1332
+ * boundaries, so a single cell longer than the stale threshold would look
1333
+ * orphaned to the reaper — touching updated_at while a cell executes keeps
1334
+ * legitimate long runs out of the reaper's reach. No event is published;
1335
+ * this is reaper bookkeeping, not UI state.
1336
+ */
1337
+ function touchAutomationRun(runId) {
1338
+ const id = cleanString(runId);
1339
+ if (!id) return;
1340
+ try {
1341
+ openLocalDb().prepare(`
1342
+ UPDATE automation_runs SET updated_at = ? WHERE id = ? AND status = 'running'
1343
+ `).run(nowIso(), id);
1344
+ } catch {
1345
+ // Never let a heartbeat break a run.
1346
+ }
1347
+ }
1348
+
1349
+ /**
1350
+ * Mark "running" runs that have made no progress for staleAfterMs as
1351
+ * interrupted. Runs execute in-process and every cell boundary touches
1352
+ * updated_at, so a stale running row means the runtime died mid-run or a
1353
+ * cell hung past its timeout. Without this, run history shows phantom
1354
+ * in-progress runs forever.
1355
+ */
1356
+ function expireStaleAutomationRuns(options = {}) {
1357
+ ensureAutomationsStore();
1358
+ const staleAfterMs = Math.max(60_000, Number(options.staleAfterMs) || DEFAULT_STALE_RUN_MS);
1359
+ const db = openLocalDb();
1360
+ const cutoff = new Date(Date.now() - staleAfterMs).toISOString();
1361
+ const rows = db.prepare(`
1362
+ SELECT * FROM automation_runs
1363
+ WHERE status = 'running' AND updated_at <= ?
1364
+ `).all(cutoff);
1365
+
1366
+ const expired = [];
1367
+ for (const row of rows) {
1368
+ const run = rowToAutomationRun(row);
1369
+ if (!run) continue;
1370
+ const timestamp = nowIso();
1371
+ const error = `Run interrupted: no progress since ${row.updated_at} (runtime restart or hung cell).`;
1372
+ const cellRows = db.prepare(`
1373
+ SELECT * FROM workflow_cell_runs WHERE run_id = ? AND status = 'running'
1374
+ `).all(run.id);
1375
+ for (const cellRow of cellRows) {
1376
+ recordWorkflowCellRun(run, {
1377
+ name: cellRow.cell_name,
1378
+ kind: cellRow.cell_kind,
1379
+ status: 'interrupted',
1380
+ startedAt: cellRow.started_at,
1381
+ finishedAt: timestamp,
1382
+ error,
1383
+ }, { source: 'workflow_cell_runs:interrupted' });
1384
+ }
1385
+ const updated = recordAutomationRun(run.automationId, {
1386
+ runId: run.id,
1387
+ status: 'interrupted',
1388
+ finishedAt: timestamp,
1389
+ error,
1390
+ }, { source: 'automation_runs:interrupted' });
1391
+ if (updated) expired.push(updated);
1392
+ }
1393
+ return expired;
1394
+ }
1395
+
1396
+ /**
1397
+ * Keep run history bounded: retain the most recent N runs per automation
1398
+ * (never deleting in-flight runs). workflow_cell_runs rows follow via the
1399
+ * run_id ON DELETE CASCADE foreign key.
1400
+ */
1401
+ function pruneAutomationRuns(options = {}) {
1402
+ ensureAutomationsStore();
1403
+ const keep = Math.max(1, Number(options.keepPerAutomation) || DEFAULT_RUN_RETENTION);
1404
+ const db = openLocalDb();
1405
+ const automationIds = db.prepare('SELECT DISTINCT automation_id FROM automation_runs').all()
1406
+ .map((row) => row.automation_id);
1407
+ let pruned = 0;
1408
+ for (const automationId of automationIds) {
1409
+ const result = db.prepare(`
1410
+ DELETE FROM automation_runs
1411
+ WHERE automation_id = ?
1412
+ AND status != 'running'
1413
+ AND id IN (
1414
+ SELECT id FROM automation_runs
1415
+ WHERE automation_id = ?
1416
+ ORDER BY datetime(updated_at) DESC
1417
+ LIMIT -1 OFFSET ?
1418
+ )
1419
+ `).run(automationId, automationId, keep);
1420
+ pruned += result.changes;
1421
+ }
1422
+ return pruned;
1423
+ }
1424
+
990
1425
  function recordWorkflowCellRun(run, cell, options = {}) {
991
1426
  if (!run?.id || !cell?.name) return null;
992
1427
  ensureAutomationsStore();
@@ -1108,8 +1543,11 @@ module.exports = {
1108
1543
  deleteAutomationByTriggerId,
1109
1544
  ensureAutomationsStore,
1110
1545
  eventRefForTrigger,
1546
+ expireStaleAutomationRuns,
1111
1547
  getAutomation,
1112
1548
  getAutomationByTriggerId,
1549
+ getAutomationRun,
1550
+ getSchedulerHeartbeat,
1113
1551
  listAutomationRuns,
1114
1552
  listAutomations,
1115
1553
  listEventTriggersForIngress,
@@ -1118,9 +1556,13 @@ module.exports = {
1118
1556
  listWorkflows,
1119
1557
  markTriggerFired,
1120
1558
  normalizeWorkflowRecord,
1559
+ pruneAutomationRuns,
1121
1560
  publishResources,
1122
1561
  recordAutomationRun,
1562
+ recordSchedulerHeartbeat,
1123
1563
  recordWorkflowCellRun,
1564
+ repairScheduledTriggers,
1565
+ touchAutomationRun,
1124
1566
  updateAutomation,
1125
1567
  updateAutomationByTriggerId,
1126
1568
  };