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.
- package/package.json +1 -1
- package/runtime/lib/harnesses.js +15 -2
- package/runtime/scripts/amalgm-mcp/automations/rest.js +41 -3
- package/runtime/scripts/amalgm-mcp/automations/runner.js +111 -15
- package/runtime/scripts/amalgm-mcp/automations/scheduler.js +116 -7
- package/runtime/scripts/amalgm-mcp/automations/store.js +508 -66
- package/runtime/scripts/amalgm-mcp/automations/tools.js +55 -10
- package/runtime/scripts/amalgm-mcp/events/ingress.js +142 -29
- package/runtime/scripts/amalgm-mcp/events/matcher.js +9 -4
- package/runtime/scripts/amalgm-mcp/server/routes/automations.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +12 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +2 -2
- package/runtime/scripts/amalgm-mcp/tests/automations-reliability.test.js +547 -0
- package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +3 -3
- package/runtime/scripts/chat-core/contract.js +10 -0
- package/runtime/scripts/chat-server/model-catalog.js +2 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
}
|
|
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,
|
|
690
|
+
status: automationStatus(base, normalizedTriggers, normalizedWorkflows),
|
|
503
691
|
triggerIds: normalizedTriggers.map((trigger) => trigger.id),
|
|
504
|
-
workflowIds
|
|
692
|
+
workflowIds,
|
|
505
693
|
triggers: normalizedTriggers,
|
|
506
|
-
workflow:
|
|
507
|
-
workflows:
|
|
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) =>
|
|
532
|
-
automation
|
|
533
|
-
|
|
534
|
-
|
|
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.
|
|
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:
|
|
541
|
-
|
|
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
|
|
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
|
|
593
|
-
|
|
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,
|
|
596
|
-
automation.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,
|
|
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
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
},
|
|
656
|
-
base.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
|
-
|
|
663
|
-
triggers =
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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:
|
|
1278
|
+
finishedAt: frozen
|
|
1279
|
+
? existing.finishedAt
|
|
1280
|
+
: (entry.finishedAt || (overridesInterrupted ? null : existing?.finishedAt) || null),
|
|
951
1281
|
projectPath: entry.projectPath ?? existing?.projectPath ?? null,
|
|
952
|
-
|
|
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
|
};
|