amalgm 0.1.51 → 0.1.53
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/lib/tunnel-events.js +48 -23
- package/package.json +2 -2
- package/runtime/lib/harnesses.js +12 -4
- package/runtime/scripts/amalgm-mcp/agents/store.js +5 -5
- package/runtime/scripts/amalgm-mcp/{artifacts → apps}/advertise.js +39 -24
- package/runtime/scripts/amalgm-mcp/apps/rest.js +144 -0
- package/runtime/scripts/amalgm-mcp/apps/store.js +171 -0
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +439 -0
- package/runtime/scripts/amalgm-mcp/apps/tools.js +176 -0
- package/runtime/scripts/amalgm-mcp/automations/cell-references.js +237 -0
- package/runtime/scripts/amalgm-mcp/automations/context.js +41 -0
- package/runtime/scripts/amalgm-mcp/automations/rest.js +148 -0
- package/runtime/scripts/amalgm-mcp/automations/runner.js +613 -0
- package/runtime/scripts/amalgm-mcp/automations/scheduler.js +90 -0
- package/runtime/scripts/amalgm-mcp/automations/store.js +1125 -0
- package/runtime/scripts/amalgm-mcp/automations/tool-actions.js +177 -0
- package/runtime/scripts/amalgm-mcp/automations/tools.js +418 -0
- package/runtime/scripts/amalgm-mcp/automations/validator.js +225 -0
- package/runtime/scripts/amalgm-mcp/browser/agent-browser.js +547 -0
- package/runtime/scripts/amalgm-mcp/browser/electron-bridge.js +222 -0
- package/runtime/scripts/amalgm-mcp/browser/page.js +13 -631
- package/runtime/scripts/amalgm-mcp/browser/tools.js +9 -7
- package/runtime/scripts/amalgm-mcp/config.js +33 -48
- package/runtime/scripts/amalgm-mcp/deps.js +1 -31
- package/runtime/scripts/amalgm-mcp/events/ingress.js +50 -42
- package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +169 -0
- package/runtime/scripts/amalgm-mcp/events/matcher.js +45 -14
- package/runtime/scripts/amalgm-mcp/events/store.js +106 -57
- package/runtime/scripts/amalgm-mcp/index.js +12 -14
- package/runtime/scripts/amalgm-mcp/lib/prefs.js +229 -65
- package/runtime/scripts/amalgm-mcp/lib/tool-result.js +13 -27
- package/runtime/scripts/amalgm-mcp/server/core-tools.js +2 -3
- package/runtime/scripts/amalgm-mcp/server/http.js +106 -56
- package/runtime/scripts/amalgm-mcp/slack/inbound.js +1 -1
- package/runtime/scripts/amalgm-mcp/state/db.js +119 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +16 -3
- package/runtime/scripts/amalgm-mcp/tasks/executor.js +1 -1
- package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +348 -0
- package/runtime/scripts/amalgm-mcp/tests/events-matcher.test.js +23 -0
- package/runtime/scripts/amalgm-mcp/tests/workflows-store-runner.test.js +67 -0
- package/runtime/scripts/amalgm-mcp/toolbox/tools.js +16 -3
- package/runtime/scripts/amalgm-mcp/workflows/compiler.js +222 -0
- package/runtime/scripts/amalgm-mcp/workflows/runner.js +593 -0
- package/runtime/scripts/amalgm-mcp/workflows/store.js +237 -0
- package/runtime/scripts/chat-core/adapters/claude.js +2 -1
- package/runtime/scripts/chat-core/auth.js +82 -12
- package/runtime/scripts/chat-core/contract.js +5 -1
- package/runtime/scripts/chat-core/engine.js +103 -62
- package/runtime/scripts/chat-core/event-schema.js +8 -0
- package/runtime/scripts/chat-core/events.js +5 -0
- package/runtime/scripts/chat-core/normalizers/codex.js +13 -1
- package/runtime/scripts/chat-core/parts.js +21 -6
- package/runtime/scripts/chat-core/sse.js +3 -0
- package/runtime/scripts/chat-core/tests/auth.test.js +84 -6
- package/runtime/scripts/chat-core/tests/engine.test.js +312 -0
- package/runtime/scripts/chat-core/tests/native-config.test.js +23 -0
- package/runtime/scripts/chat-core/tool-shape.js +4 -4
- package/runtime/scripts/chat-core/tooling/active-memory.js +5 -4
- package/runtime/scripts/chat-core/tooling/native-binaries.js +34 -9
- package/runtime/scripts/chat-core/tooling/native-config.js +34 -3
- package/runtime/scripts/local-gateway.js +34 -27
- package/runtime/scripts/platform-context.txt +76 -94
- package/runtime/scripts/amalgm-mcp/artifacts/rest.js +0 -103
- package/runtime/scripts/amalgm-mcp/artifacts/store.js +0 -157
- package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +0 -439
- package/runtime/scripts/amalgm-mcp/artifacts/tools.js +0 -176
- package/runtime/scripts/amalgm-mcp/events/executor.js +0 -258
- package/runtime/scripts/amalgm-mcp/events/rest.js +0 -214
- package/runtime/scripts/amalgm-mcp/events/tools.js +0 -323
- package/runtime/scripts/amalgm-mcp/tasks/rest.js +0 -110
- package/runtime/scripts/amalgm-mcp/tasks/tools.js +0 -416
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const { loadCronParser } = require('../deps');
|
|
6
|
+
const { openLocalDb } = require('../state/db');
|
|
7
|
+
const { appendStateEvent, insertStateEvent, publishStateEvent } = require('../state/events');
|
|
8
|
+
const { normalizeTaskSchedule } = require('../tasks/schedule-normalization');
|
|
9
|
+
const { compileWorkflowText, splitEventRef } = require('../workflows/compiler');
|
|
10
|
+
const { getWebhookUrl } = require('../events/webhook-url');
|
|
11
|
+
const { validateCellReferences } = require('./cell-references');
|
|
12
|
+
const { validateWorkflowToolActions } = require('./tool-actions');
|
|
13
|
+
|
|
14
|
+
const LEGACY_MIGRATION_KEY = 'automations_legacy_migrated_v1';
|
|
15
|
+
const DEFAULT_RUN_LIMIT = 100;
|
|
16
|
+
|
|
17
|
+
let cronParser = null;
|
|
18
|
+
|
|
19
|
+
function cron() {
|
|
20
|
+
if (!cronParser) cronParser = loadCronParser();
|
|
21
|
+
return cronParser;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function nowIso() {
|
|
25
|
+
return new Date().toISOString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseJson(value, fallback = null) {
|
|
29
|
+
if (typeof value !== 'string' || !value) return fallback;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(value);
|
|
32
|
+
} catch {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stringifyJson(value, fallback) {
|
|
38
|
+
return JSON.stringify(value === undefined ? fallback : value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isObject(value) {
|
|
42
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cleanString(value) {
|
|
46
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isoOrNull(value) {
|
|
50
|
+
if (!value) return null;
|
|
51
|
+
const date = new Date(value);
|
|
52
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validDate(value) {
|
|
56
|
+
if (!value) return null;
|
|
57
|
+
const date = new Date(value);
|
|
58
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function metaValue(db, key) {
|
|
62
|
+
return db.prepare('SELECT value FROM local_meta WHERE key = ?').get(key)?.value || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setMetaValue(db, key, value) {
|
|
66
|
+
db.prepare(`
|
|
67
|
+
INSERT INTO local_meta (key, value, updated_at)
|
|
68
|
+
VALUES (?, ?, ?)
|
|
69
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
70
|
+
value = excluded.value,
|
|
71
|
+
updated_at = excluded.updated_at
|
|
72
|
+
`).run(key, value, nowIso());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function defaultWorkflowText(label = 'Automation', triggerRef = 'automation.manual') {
|
|
76
|
+
const safeLabel = JSON.stringify(label || 'Automation');
|
|
77
|
+
return `export default workflow({
|
|
78
|
+
trigger: event(${JSON.stringify(triggerRef)}),
|
|
79
|
+
cells: [
|
|
80
|
+
code("start", async () => ({
|
|
81
|
+
ok: true,
|
|
82
|
+
message: ${safeLabel} + " has no workflow steps configured yet."
|
|
83
|
+
}))
|
|
84
|
+
]
|
|
85
|
+
})`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function workflowTriggerRefForInput(input = {}) {
|
|
89
|
+
const firstTrigger = Array.isArray(input.triggers) ? input.triggers[0] : input.trigger;
|
|
90
|
+
if (firstTrigger?.kind === 'scheduled') return 'scheduled.run';
|
|
91
|
+
if (firstTrigger?.source || firstTrigger?.event || input.source || input.event) {
|
|
92
|
+
const source = cleanString(firstTrigger?.source || input.source) || '*';
|
|
93
|
+
const event = cleanString(firstTrigger?.event || input.event) || '*';
|
|
94
|
+
return `${source}.${event}`;
|
|
95
|
+
}
|
|
96
|
+
return 'automation.manual';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeWorkflowRecord(input = {}, existing = null) {
|
|
100
|
+
const timestamp = nowIso();
|
|
101
|
+
const workflowText = cleanString(
|
|
102
|
+
input.workflowText ?? input.workflow ?? existing?.workflowText,
|
|
103
|
+
) || defaultWorkflowText(input.name || existing?.name || 'Automation', workflowTriggerRefForInput(input));
|
|
104
|
+
let workflowIr = isObject(input.workflowIr)
|
|
105
|
+
? input.workflowIr
|
|
106
|
+
: isObject(existing?.workflowIr)
|
|
107
|
+
? existing.workflowIr
|
|
108
|
+
: null;
|
|
109
|
+
let compilerErrors = null;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
workflowIr = compileWorkflowText(workflowText);
|
|
113
|
+
const staticErrors = [
|
|
114
|
+
...validateCellReferences(workflowIr),
|
|
115
|
+
...validateWorkflowToolActions(workflowIr),
|
|
116
|
+
];
|
|
117
|
+
if (staticErrors.length > 0) {
|
|
118
|
+
throw new Error(staticErrors.map((item) => item.message).join('\n'));
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
workflowIr = null;
|
|
122
|
+
compilerErrors = [error.message || String(error)];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
id: cleanString(input.id) || existing?.id || crypto.randomUUID(),
|
|
127
|
+
name: cleanString(input.name) || existing?.name || 'Untitled workflow',
|
|
128
|
+
description: input.description ?? existing?.description ?? '',
|
|
129
|
+
enabled: input.enabled ?? existing?.enabled ?? true,
|
|
130
|
+
projectPath: input.projectPath ?? existing?.projectPath ?? null,
|
|
131
|
+
workflowText,
|
|
132
|
+
workflowIr,
|
|
133
|
+
compilerErrors,
|
|
134
|
+
allowlist: isObject(input.allowlist) ? input.allowlist : existing?.allowlist || {},
|
|
135
|
+
limits: isObject(input.limits) ? input.limits : existing?.limits || {},
|
|
136
|
+
internal: input.internal ?? existing?.internal ?? false,
|
|
137
|
+
createdAt: isoOrNull(input.createdAt) || existing?.createdAt || timestamp,
|
|
138
|
+
updatedAt: existing ? timestamp : (isoOrNull(input.updatedAt) || null),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function workflowRowParams(workflow) {
|
|
143
|
+
return {
|
|
144
|
+
id: workflow.id,
|
|
145
|
+
name: workflow.name,
|
|
146
|
+
description: workflow.description || '',
|
|
147
|
+
enabled: workflow.enabled === false ? 0 : 1,
|
|
148
|
+
project_path: workflow.projectPath || null,
|
|
149
|
+
workflow_text: workflow.workflowText || '',
|
|
150
|
+
workflow_ir_json: workflow.workflowIr ? JSON.stringify(workflow.workflowIr) : null,
|
|
151
|
+
compiler_errors_json: workflow.compilerErrors ? JSON.stringify(workflow.compilerErrors) : null,
|
|
152
|
+
allowlist_json: stringifyJson(workflow.allowlist, {}),
|
|
153
|
+
limits_json: stringifyJson(workflow.limits, {}),
|
|
154
|
+
created_at: workflow.createdAt || nowIso(),
|
|
155
|
+
updated_at: workflow.updatedAt || workflow.createdAt || nowIso(),
|
|
156
|
+
workflow_json: JSON.stringify(workflow),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function rowToWorkflow(row) {
|
|
161
|
+
if (!row) return null;
|
|
162
|
+
const parsed = parseJson(row.workflow_json, {});
|
|
163
|
+
return normalizeWorkflowRecord({
|
|
164
|
+
...parsed,
|
|
165
|
+
id: row.id,
|
|
166
|
+
name: row.name,
|
|
167
|
+
description: row.description || '',
|
|
168
|
+
enabled: row.enabled === 1,
|
|
169
|
+
projectPath: row.project_path || null,
|
|
170
|
+
workflowText: row.workflow_text || parsed.workflowText,
|
|
171
|
+
workflowIr: parseJson(row.workflow_ir_json, parsed.workflowIr || null),
|
|
172
|
+
compilerErrors: parseJson(row.compiler_errors_json, parsed.compilerErrors || null),
|
|
173
|
+
allowlist: parseJson(row.allowlist_json, parsed.allowlist || {}),
|
|
174
|
+
limits: parseJson(row.limits_json, parsed.limits || {}),
|
|
175
|
+
createdAt: row.created_at,
|
|
176
|
+
updatedAt: row.updated_at,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function upsertWorkflowRow(db, workflow) {
|
|
181
|
+
const params = workflowRowParams(workflow);
|
|
182
|
+
db.prepare(`
|
|
183
|
+
INSERT INTO automation_workflows (
|
|
184
|
+
id, name, description, enabled, project_path, workflow_text,
|
|
185
|
+
workflow_ir_json, compiler_errors_json, allowlist_json, limits_json,
|
|
186
|
+
created_at, updated_at, workflow_json
|
|
187
|
+
)
|
|
188
|
+
VALUES (
|
|
189
|
+
@id, @name, @description, @enabled, @project_path, @workflow_text,
|
|
190
|
+
@workflow_ir_json, @compiler_errors_json, @allowlist_json, @limits_json,
|
|
191
|
+
@created_at, @updated_at, @workflow_json
|
|
192
|
+
)
|
|
193
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
194
|
+
name = excluded.name,
|
|
195
|
+
description = excluded.description,
|
|
196
|
+
enabled = excluded.enabled,
|
|
197
|
+
project_path = excluded.project_path,
|
|
198
|
+
workflow_text = excluded.workflow_text,
|
|
199
|
+
workflow_ir_json = excluded.workflow_ir_json,
|
|
200
|
+
compiler_errors_json = excluded.compiler_errors_json,
|
|
201
|
+
allowlist_json = excluded.allowlist_json,
|
|
202
|
+
limits_json = excluded.limits_json,
|
|
203
|
+
updated_at = excluded.updated_at,
|
|
204
|
+
workflow_json = excluded.workflow_json
|
|
205
|
+
`).run(params);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeSchedule(inputSchedule, existingSchedule = null) {
|
|
209
|
+
const candidate = inputSchedule || existingSchedule;
|
|
210
|
+
const result = normalizeTaskSchedule(candidate);
|
|
211
|
+
if (result.error) throw new Error(result.error);
|
|
212
|
+
return result.schedule;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function computeNextRunAt(trigger, afterDate = new Date()) {
|
|
216
|
+
if (!trigger || trigger.enabled === false || trigger.kind !== 'scheduled') return null;
|
|
217
|
+
const schedule = trigger.schedule;
|
|
218
|
+
if (!schedule) return null;
|
|
219
|
+
const after = validDate(afterDate) || new Date();
|
|
220
|
+
|
|
221
|
+
if (schedule.kind === 'once') {
|
|
222
|
+
const at = validDate(schedule.at);
|
|
223
|
+
if (!at) return null;
|
|
224
|
+
if (trigger.lastFiredAt && validDate(trigger.lastFiredAt) >= at) return null;
|
|
225
|
+
return at > after ? at.toISOString() : at.toISOString();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (schedule.kind === 'interval') {
|
|
229
|
+
const ms = Number(schedule.ms);
|
|
230
|
+
if (!Number.isFinite(ms) || ms < 60_000) return null;
|
|
231
|
+
return new Date(after.getTime() + ms).toISOString();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (schedule.kind === 'cron') {
|
|
235
|
+
try {
|
|
236
|
+
const interval = cron().CronExpressionParser.parse(schedule.expr, {
|
|
237
|
+
currentDate: after,
|
|
238
|
+
tz: schedule.tz || 'UTC',
|
|
239
|
+
});
|
|
240
|
+
return interval.next().toDate().toISOString();
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function scheduleLabel(schedule) {
|
|
250
|
+
if (!schedule) return null;
|
|
251
|
+
if (schedule.kind === 'once') return schedule.at || 'Once';
|
|
252
|
+
if (schedule.kind === 'interval') {
|
|
253
|
+
const ms = Number(schedule.ms);
|
|
254
|
+
if (!Number.isFinite(ms)) return 'Interval';
|
|
255
|
+
if (ms >= 86_400_000) return `Every ${Math.round(ms / 86_400_000)}d`;
|
|
256
|
+
if (ms >= 3_600_000) return `Every ${Math.round(ms / 3_600_000)}h`;
|
|
257
|
+
return `Every ${Math.max(1, Math.round(ms / 60_000))}m`;
|
|
258
|
+
}
|
|
259
|
+
if (schedule.kind === 'cron') return schedule.expr || 'Cron';
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizeTriggerRecord(input = {}, automation, existing = null) {
|
|
264
|
+
const timestamp = nowIso();
|
|
265
|
+
const rawKind = cleanString(input.kind || existing?.kind || 'event').toLowerCase();
|
|
266
|
+
const kind = rawKind === 'webhook' ? 'event' : rawKind;
|
|
267
|
+
if (!['event', 'scheduled'].includes(kind)) {
|
|
268
|
+
throw new Error(`Unsupported trigger kind: ${rawKind}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const trigger = {
|
|
272
|
+
id: cleanString(input.id) || existing?.id || crypto.randomUUID(),
|
|
273
|
+
automationId: automation.id,
|
|
274
|
+
workflowId: automation.workflowId,
|
|
275
|
+
kind,
|
|
276
|
+
name: cleanString(input.name) || existing?.name || automation.name || 'Automation trigger',
|
|
277
|
+
description: input.description ?? existing?.description ?? '',
|
|
278
|
+
enabled: input.enabled ?? existing?.enabled ?? automation.enabled !== false,
|
|
279
|
+
projectPath: input.projectPath ?? existing?.projectPath ?? automation.projectPath ?? null,
|
|
280
|
+
createdAt: isoOrNull(input.createdAt) || existing?.createdAt || timestamp,
|
|
281
|
+
updatedAt: existing ? timestamp : (isoOrNull(input.updatedAt) || null),
|
|
282
|
+
lastFiredAt: isoOrNull(input.lastFiredAt) || existing?.lastFiredAt || null,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
if (kind === 'scheduled') {
|
|
286
|
+
trigger.schedule = normalizeSchedule(input.schedule, existing?.schedule);
|
|
287
|
+
trigger.scheduleLabel = scheduleLabel(trigger.schedule);
|
|
288
|
+
trigger.nextRunAt = input.nextRunAt === null
|
|
289
|
+
? null
|
|
290
|
+
: isoOrNull(input.nextRunAt) || existing?.nextRunAt || computeNextRunAt(trigger, trigger.createdAt);
|
|
291
|
+
trigger.source = null;
|
|
292
|
+
trigger.event = null;
|
|
293
|
+
trigger.sourceUrl = null;
|
|
294
|
+
trigger.webhookUrl = null;
|
|
295
|
+
trigger.secret = null;
|
|
296
|
+
} else {
|
|
297
|
+
const irTrigger = automation.workflow?.workflowIr?.trigger || {};
|
|
298
|
+
const source = cleanString(input.source ?? existing?.source ?? irTrigger.source) || '*';
|
|
299
|
+
const event = cleanString(input.event ?? input.eventName ?? existing?.event ?? irTrigger.event) || '*';
|
|
300
|
+
trigger.source = source;
|
|
301
|
+
trigger.event = event;
|
|
302
|
+
trigger.sourceUrl = input.sourceUrl ?? existing?.sourceUrl ?? null;
|
|
303
|
+
trigger.webhookUrl = input.webhookUrl || existing?.webhookUrl || getWebhookUrl();
|
|
304
|
+
trigger.secret = input.secret || existing?.secret || crypto.randomUUID().replace(/-/g, '');
|
|
305
|
+
trigger.schedule = null;
|
|
306
|
+
trigger.scheduleLabel = null;
|
|
307
|
+
trigger.nextRunAt = null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return trigger;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function triggerRowParams(trigger) {
|
|
314
|
+
return {
|
|
315
|
+
id: trigger.id,
|
|
316
|
+
automation_id: trigger.automationId,
|
|
317
|
+
kind: trigger.kind,
|
|
318
|
+
name: trigger.name,
|
|
319
|
+
description: trigger.description || '',
|
|
320
|
+
enabled: trigger.enabled === false ? 0 : 1,
|
|
321
|
+
schedule_kind: trigger.schedule?.kind || null,
|
|
322
|
+
schedule_json: trigger.schedule ? JSON.stringify(trigger.schedule) : null,
|
|
323
|
+
next_run_at: trigger.nextRunAt || null,
|
|
324
|
+
last_fired_at: trigger.lastFiredAt || null,
|
|
325
|
+
source: trigger.source || null,
|
|
326
|
+
event: trigger.event || null,
|
|
327
|
+
source_url: trigger.sourceUrl || null,
|
|
328
|
+
webhook_url: trigger.webhookUrl || null,
|
|
329
|
+
secret: trigger.secret || null,
|
|
330
|
+
created_at: trigger.createdAt || nowIso(),
|
|
331
|
+
updated_at: trigger.updatedAt || trigger.createdAt || nowIso(),
|
|
332
|
+
trigger_json: JSON.stringify(trigger),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function rowToTrigger(row) {
|
|
337
|
+
if (!row) return null;
|
|
338
|
+
const parsed = parseJson(row.trigger_json, {});
|
|
339
|
+
return {
|
|
340
|
+
...parsed,
|
|
341
|
+
id: row.id,
|
|
342
|
+
automationId: row.automation_id,
|
|
343
|
+
workflowId: parsed.workflowId || null,
|
|
344
|
+
kind: row.kind,
|
|
345
|
+
name: row.name,
|
|
346
|
+
description: row.description || '',
|
|
347
|
+
enabled: row.enabled === 1,
|
|
348
|
+
schedule: parseJson(row.schedule_json, parsed.schedule || null),
|
|
349
|
+
scheduleLabel: parsed.scheduleLabel || scheduleLabel(parseJson(row.schedule_json, parsed.schedule || null)),
|
|
350
|
+
nextRunAt: row.next_run_at || null,
|
|
351
|
+
lastFiredAt: row.last_fired_at || null,
|
|
352
|
+
source: row.source || null,
|
|
353
|
+
event: row.event || null,
|
|
354
|
+
sourceUrl: row.source_url || null,
|
|
355
|
+
webhookUrl: row.webhook_url || null,
|
|
356
|
+
secret: row.secret || null,
|
|
357
|
+
createdAt: row.created_at,
|
|
358
|
+
updatedAt: row.updated_at,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function upsertTriggerRow(db, trigger) {
|
|
363
|
+
const params = triggerRowParams(trigger);
|
|
364
|
+
db.prepare(`
|
|
365
|
+
INSERT INTO automation_triggers (
|
|
366
|
+
id, automation_id, kind, name, description, enabled, schedule_kind,
|
|
367
|
+
schedule_json, next_run_at, last_fired_at, source, event, source_url,
|
|
368
|
+
webhook_url, secret, created_at, updated_at, trigger_json
|
|
369
|
+
)
|
|
370
|
+
VALUES (
|
|
371
|
+
@id, @automation_id, @kind, @name, @description, @enabled, @schedule_kind,
|
|
372
|
+
@schedule_json, @next_run_at, @last_fired_at, @source, @event, @source_url,
|
|
373
|
+
@webhook_url, @secret, @created_at, @updated_at, @trigger_json
|
|
374
|
+
)
|
|
375
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
376
|
+
automation_id = excluded.automation_id,
|
|
377
|
+
kind = excluded.kind,
|
|
378
|
+
name = excluded.name,
|
|
379
|
+
description = excluded.description,
|
|
380
|
+
enabled = excluded.enabled,
|
|
381
|
+
schedule_kind = excluded.schedule_kind,
|
|
382
|
+
schedule_json = excluded.schedule_json,
|
|
383
|
+
next_run_at = excluded.next_run_at,
|
|
384
|
+
last_fired_at = excluded.last_fired_at,
|
|
385
|
+
source = excluded.source,
|
|
386
|
+
event = excluded.event,
|
|
387
|
+
source_url = excluded.source_url,
|
|
388
|
+
webhook_url = excluded.webhook_url,
|
|
389
|
+
secret = excluded.secret,
|
|
390
|
+
updated_at = excluded.updated_at,
|
|
391
|
+
trigger_json = excluded.trigger_json
|
|
392
|
+
`).run(params);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function normalizeAutomationRecord(input = {}, workflow, existing = null) {
|
|
396
|
+
const timestamp = nowIso();
|
|
397
|
+
return {
|
|
398
|
+
id: cleanString(input.id) || existing?.id || crypto.randomUUID(),
|
|
399
|
+
name: cleanString(input.name) || existing?.name || workflow?.name || 'Untitled automation',
|
|
400
|
+
description: input.description ?? existing?.description ?? workflow?.description ?? '',
|
|
401
|
+
enabled: input.enabled ?? existing?.enabled ?? true,
|
|
402
|
+
projectPath: input.projectPath ?? existing?.projectPath ?? workflow?.projectPath ?? null,
|
|
403
|
+
workflowId: workflow.id,
|
|
404
|
+
createdAt: isoOrNull(input.createdAt) || existing?.createdAt || timestamp,
|
|
405
|
+
updatedAt: existing ? timestamp : (isoOrNull(input.updatedAt) || null),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function automationRowParams(automation) {
|
|
410
|
+
return {
|
|
411
|
+
id: automation.id,
|
|
412
|
+
name: automation.name,
|
|
413
|
+
description: automation.description || '',
|
|
414
|
+
enabled: automation.enabled === false ? 0 : 1,
|
|
415
|
+
project_path: automation.projectPath || null,
|
|
416
|
+
workflow_id: automation.workflowId,
|
|
417
|
+
created_at: automation.createdAt || nowIso(),
|
|
418
|
+
updated_at: automation.updatedAt || automation.createdAt || nowIso(),
|
|
419
|
+
automation_json: JSON.stringify(automation),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function rowToAutomationBase(row) {
|
|
424
|
+
if (!row) return null;
|
|
425
|
+
const parsed = parseJson(row.automation_json, {});
|
|
426
|
+
return {
|
|
427
|
+
...parsed,
|
|
428
|
+
id: row.id,
|
|
429
|
+
name: row.name,
|
|
430
|
+
description: row.description || '',
|
|
431
|
+
enabled: row.enabled === 1,
|
|
432
|
+
projectPath: row.project_path || null,
|
|
433
|
+
workflowId: row.workflow_id,
|
|
434
|
+
createdAt: row.created_at,
|
|
435
|
+
updatedAt: row.updated_at,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function upsertAutomationRow(db, automation) {
|
|
440
|
+
const params = automationRowParams(automation);
|
|
441
|
+
db.prepare(`
|
|
442
|
+
INSERT INTO automation_definitions (
|
|
443
|
+
id, name, description, enabled, project_path, workflow_id,
|
|
444
|
+
created_at, updated_at, automation_json
|
|
445
|
+
)
|
|
446
|
+
VALUES (
|
|
447
|
+
@id, @name, @description, @enabled, @project_path, @workflow_id,
|
|
448
|
+
@created_at, @updated_at, @automation_json
|
|
449
|
+
)
|
|
450
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
451
|
+
name = excluded.name,
|
|
452
|
+
description = excluded.description,
|
|
453
|
+
enabled = excluded.enabled,
|
|
454
|
+
project_path = excluded.project_path,
|
|
455
|
+
workflow_id = excluded.workflow_id,
|
|
456
|
+
updated_at = excluded.updated_at,
|
|
457
|
+
automation_json = excluded.automation_json
|
|
458
|
+
`).run(params);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function workflowStatus(workflow) {
|
|
462
|
+
if (!workflow || workflow.enabled === false) return 'paused';
|
|
463
|
+
if (Array.isArray(workflow.compilerErrors) && workflow.compilerErrors.length > 0) return 'error';
|
|
464
|
+
return 'idle';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function triggerStatus(trigger) {
|
|
468
|
+
if (!trigger || trigger.enabled === false) return 'paused';
|
|
469
|
+
return 'idle';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function automationStatus(automation, triggers, workflow) {
|
|
473
|
+
if (automation.enabled === false) return 'paused';
|
|
474
|
+
if (workflowStatus(workflow) === 'error') return 'error';
|
|
475
|
+
if (triggers.some((trigger) => triggerStatus(trigger) === 'error')) return 'error';
|
|
476
|
+
if (triggers.length > 0 && triggers.every((trigger) => trigger.enabled === false)) return 'paused';
|
|
477
|
+
return 'idle';
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function composeAutomation(base, triggers, workflow) {
|
|
481
|
+
const normalizedTriggers = triggers.map((trigger) => ({
|
|
482
|
+
...trigger,
|
|
483
|
+
workflowId: base.workflowId,
|
|
484
|
+
workflowIds: [base.workflowId],
|
|
485
|
+
status: triggerStatus(trigger),
|
|
486
|
+
backing: {
|
|
487
|
+
resource: 'triggers',
|
|
488
|
+
id: trigger.id,
|
|
489
|
+
},
|
|
490
|
+
}));
|
|
491
|
+
const normalizedWorkflow = workflow ? {
|
|
492
|
+
...workflow,
|
|
493
|
+
triggerIds: normalizedTriggers.map((trigger) => trigger.id),
|
|
494
|
+
automationIds: [base.id],
|
|
495
|
+
status: workflowStatus(workflow),
|
|
496
|
+
} : null;
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
...base,
|
|
500
|
+
kind: normalizedTriggers[0]?.kind || 'automation',
|
|
501
|
+
status: automationStatus(base, normalizedTriggers, normalizedWorkflow),
|
|
502
|
+
triggerIds: normalizedTriggers.map((trigger) => trigger.id),
|
|
503
|
+
workflowIds: [base.workflowId],
|
|
504
|
+
triggers: normalizedTriggers,
|
|
505
|
+
workflow: normalizedWorkflow,
|
|
506
|
+
workflows: normalizedWorkflow ? [normalizedWorkflow] : [],
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function readAutomationGraph(db = openLocalDb()) {
|
|
511
|
+
ensureAutomationsStore();
|
|
512
|
+
const automationRows = db.prepare('SELECT * FROM automation_definitions ORDER BY datetime(created_at) DESC, lower(name)').all();
|
|
513
|
+
const workflowRows = db.prepare('SELECT * FROM automation_workflows').all();
|
|
514
|
+
const triggerRows = db.prepare('SELECT * FROM automation_triggers ORDER BY datetime(created_at) DESC, lower(name)').all();
|
|
515
|
+
|
|
516
|
+
const workflowsById = new Map(workflowRows.map((row) => {
|
|
517
|
+
const workflow = rowToWorkflow(row);
|
|
518
|
+
return [workflow.id, workflow];
|
|
519
|
+
}));
|
|
520
|
+
const triggersByAutomation = new Map();
|
|
521
|
+
for (const row of triggerRows) {
|
|
522
|
+
const trigger = rowToTrigger(row);
|
|
523
|
+
if (!triggersByAutomation.has(trigger.automationId)) triggersByAutomation.set(trigger.automationId, []);
|
|
524
|
+
triggersByAutomation.get(trigger.automationId).push(trigger);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const automations = automationRows
|
|
528
|
+
.map(rowToAutomationBase)
|
|
529
|
+
.filter(Boolean)
|
|
530
|
+
.map((automation) => composeAutomation(
|
|
531
|
+
automation,
|
|
532
|
+
triggersByAutomation.get(automation.id) || [],
|
|
533
|
+
workflowsById.get(automation.workflowId) || null,
|
|
534
|
+
));
|
|
535
|
+
const triggers = automations.flatMap((automation) => automation.triggers);
|
|
536
|
+
const usedWorkflowIds = new Set(automations.map((automation) => automation.workflowId));
|
|
537
|
+
const workflows = Array.from(workflowsById.values()).map((workflow) => ({
|
|
538
|
+
...workflow,
|
|
539
|
+
triggerIds: triggers.filter((trigger) => trigger.workflowId === workflow.id).map((trigger) => trigger.id),
|
|
540
|
+
automationIds: automations.filter((automation) => automation.workflowId === workflow.id).map((automation) => automation.id),
|
|
541
|
+
status: workflowStatus(workflow),
|
|
542
|
+
orphaned: !usedWorkflowIds.has(workflow.id),
|
|
543
|
+
}));
|
|
544
|
+
|
|
545
|
+
return { automations, triggers, workflows };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function publishResources(source = 'automations:save') {
|
|
549
|
+
const resources = readAutomationGraph();
|
|
550
|
+
appendStateEvent({ resource: 'automations', op: 'replace', value: resources.automations, source });
|
|
551
|
+
appendStateEvent({ resource: 'triggers', op: 'replace', value: resources.triggers, source });
|
|
552
|
+
appendStateEvent({ resource: 'workflows', op: 'replace', value: resources.workflows, source });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function defaultTriggersForInput(input, automation) {
|
|
556
|
+
if (Array.isArray(input.triggers) && input.triggers.length > 0) return input.triggers;
|
|
557
|
+
if (input.trigger && typeof input.trigger === 'object') return [input.trigger];
|
|
558
|
+
if (input.schedule) {
|
|
559
|
+
return [{
|
|
560
|
+
kind: 'scheduled',
|
|
561
|
+
name: input.name,
|
|
562
|
+
description: input.description,
|
|
563
|
+
schedule: input.schedule,
|
|
564
|
+
enabled: input.enabled,
|
|
565
|
+
projectPath: input.projectPath,
|
|
566
|
+
}];
|
|
567
|
+
}
|
|
568
|
+
return [{
|
|
569
|
+
kind: 'event',
|
|
570
|
+
name: input.name,
|
|
571
|
+
description: input.description,
|
|
572
|
+
source: input.source,
|
|
573
|
+
event: input.event,
|
|
574
|
+
sourceUrl: input.sourceUrl,
|
|
575
|
+
enabled: input.enabled,
|
|
576
|
+
projectPath: input.projectPath,
|
|
577
|
+
}];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function insertAutomationBundle(db, input = {}) {
|
|
581
|
+
const workflowInput = {
|
|
582
|
+
...(isObject(input.workflow) ? input.workflow : {}),
|
|
583
|
+
name: input.workflowName || input.name,
|
|
584
|
+
description: input.workflowDescription ?? input.description,
|
|
585
|
+
enabled: input.workflowEnabled ?? input.enabled,
|
|
586
|
+
projectPath: input.workflowProjectPath ?? input.projectPath,
|
|
587
|
+
workflowText: input.workflowText || (typeof input.workflow === 'string' ? input.workflow : input.workflow?.workflowText),
|
|
588
|
+
allowlist: input.allowlist || input.workflow?.allowlist,
|
|
589
|
+
limits: input.limits || input.workflow?.limits,
|
|
590
|
+
};
|
|
591
|
+
const workflow = normalizeWorkflowRecord(workflowInput);
|
|
592
|
+
if (workflow.compilerErrors?.length) throw new Error(workflow.compilerErrors.join('\n'));
|
|
593
|
+
|
|
594
|
+
const automation = normalizeAutomationRecord(input, workflow);
|
|
595
|
+
automation.workflow = workflow;
|
|
596
|
+
const triggerInputs = defaultTriggersForInput(input, automation);
|
|
597
|
+
const triggers = triggerInputs.map((triggerInput) => normalizeTriggerRecord(triggerInput, automation));
|
|
598
|
+
if (triggers.length === 0) throw new Error('Automation requires at least one trigger.');
|
|
599
|
+
|
|
600
|
+
upsertWorkflowRow(db, workflow);
|
|
601
|
+
upsertAutomationRow(db, automation);
|
|
602
|
+
for (const trigger of triggers) upsertTriggerRow(db, trigger);
|
|
603
|
+
return composeAutomation(automation, triggers, workflow);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function createAutomation(input = {}, options = {}) {
|
|
607
|
+
ensureAutomationsStore();
|
|
608
|
+
let automation = null;
|
|
609
|
+
const db = openLocalDb();
|
|
610
|
+
db.transaction(() => {
|
|
611
|
+
automation = insertAutomationBundle(db, input);
|
|
612
|
+
})();
|
|
613
|
+
publishResources(options.source || 'automations:create');
|
|
614
|
+
return automation;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function getAutomation(automationId) {
|
|
618
|
+
const id = cleanString(automationId);
|
|
619
|
+
if (!id) return null;
|
|
620
|
+
return readAutomationGraph().automations.find((automation) => automation.id === id) || null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function getAutomationByTriggerId(triggerId) {
|
|
624
|
+
const id = cleanString(triggerId);
|
|
625
|
+
if (!id) return null;
|
|
626
|
+
return readAutomationGraph().automations.find((automation) => (
|
|
627
|
+
automation.triggers.some((trigger) => trigger.id === id)
|
|
628
|
+
)) || null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function updateAutomation(automationId, updates = {}, options = {}) {
|
|
632
|
+
ensureAutomationsStore();
|
|
633
|
+
const existing = getAutomation(automationId);
|
|
634
|
+
if (!existing) throw new Error(`Automation not found: ${automationId}`);
|
|
635
|
+
let saved = null;
|
|
636
|
+
const db = openLocalDb();
|
|
637
|
+
|
|
638
|
+
db.transaction(() => {
|
|
639
|
+
const workflow = normalizeWorkflowRecord({
|
|
640
|
+
...existing.workflow,
|
|
641
|
+
...(isObject(updates.workflow) ? updates.workflow : {}),
|
|
642
|
+
...(updates.workflowText !== undefined ? { workflowText: updates.workflowText } : {}),
|
|
643
|
+
...(updates.workflow !== undefined && typeof updates.workflow === 'string' ? { workflowText: updates.workflow } : {}),
|
|
644
|
+
...(updates.workflowName !== undefined ? { name: updates.workflowName } : {}),
|
|
645
|
+
...(updates.allowlist !== undefined ? { allowlist: updates.allowlist } : {}),
|
|
646
|
+
...(updates.limits !== undefined ? { limits: updates.limits } : {}),
|
|
647
|
+
}, existing.workflow);
|
|
648
|
+
if (workflow.compilerErrors?.length) throw new Error(workflow.compilerErrors.join('\n'));
|
|
649
|
+
|
|
650
|
+
const base = normalizeAutomationRecord({
|
|
651
|
+
...existing,
|
|
652
|
+
...updates,
|
|
653
|
+
id: existing.id,
|
|
654
|
+
}, workflow, existing);
|
|
655
|
+
base.workflow = workflow;
|
|
656
|
+
upsertWorkflowRow(db, workflow);
|
|
657
|
+
upsertAutomationRow(db, base);
|
|
658
|
+
|
|
659
|
+
let triggers = existing.triggers;
|
|
660
|
+
if (Array.isArray(updates.triggers)) {
|
|
661
|
+
db.prepare('DELETE FROM automation_triggers WHERE automation_id = ?').run(existing.id);
|
|
662
|
+
triggers = updates.triggers.map((triggerInput) => normalizeTriggerRecord(triggerInput, base));
|
|
663
|
+
for (const trigger of triggers) upsertTriggerRow(db, trigger);
|
|
664
|
+
} else if (isObject(updates.trigger) || updates.trigger_id || updates.triggerId) {
|
|
665
|
+
const triggerId = updates.trigger_id || updates.triggerId || updates.trigger?.id;
|
|
666
|
+
triggers = existing.triggers.map((trigger) => {
|
|
667
|
+
if (trigger.id !== triggerId) return trigger;
|
|
668
|
+
const next = normalizeTriggerRecord({
|
|
669
|
+
...trigger,
|
|
670
|
+
...(updates.trigger || {}),
|
|
671
|
+
id: trigger.id,
|
|
672
|
+
}, base, trigger);
|
|
673
|
+
upsertTriggerRow(db, next);
|
|
674
|
+
return next;
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
saved = composeAutomation(base, triggers, workflow);
|
|
679
|
+
})();
|
|
680
|
+
|
|
681
|
+
publishResources(options.source || 'automations:update');
|
|
682
|
+
return saved;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function updateAutomationByTriggerId(triggerId, updates = {}, options = {}) {
|
|
686
|
+
const automation = getAutomationByTriggerId(triggerId);
|
|
687
|
+
if (!automation) throw new Error(`Trigger not found: ${triggerId}`);
|
|
688
|
+
const {
|
|
689
|
+
trigger_id: _trigger_id,
|
|
690
|
+
triggerId: _triggerId,
|
|
691
|
+
id: _id,
|
|
692
|
+
kind,
|
|
693
|
+
name,
|
|
694
|
+
description,
|
|
695
|
+
enabled,
|
|
696
|
+
schedule,
|
|
697
|
+
source,
|
|
698
|
+
event,
|
|
699
|
+
eventName,
|
|
700
|
+
sourceUrl,
|
|
701
|
+
projectPath,
|
|
702
|
+
nextRunAt,
|
|
703
|
+
webhookUrl,
|
|
704
|
+
secret,
|
|
705
|
+
...automationUpdates
|
|
706
|
+
} = updates || {};
|
|
707
|
+
const triggerUpdates = isObject(updates.trigger) ? updates.trigger : {};
|
|
708
|
+
return updateAutomation(automation.id, {
|
|
709
|
+
...automationUpdates,
|
|
710
|
+
trigger: {
|
|
711
|
+
...triggerUpdates,
|
|
712
|
+
id: triggerId,
|
|
713
|
+
kind: triggerUpdates.kind ?? kind,
|
|
714
|
+
name: triggerUpdates.name ?? name,
|
|
715
|
+
description: triggerUpdates.description ?? description,
|
|
716
|
+
enabled: triggerUpdates.enabled ?? enabled,
|
|
717
|
+
schedule: triggerUpdates.schedule ?? schedule,
|
|
718
|
+
source: triggerUpdates.source ?? source,
|
|
719
|
+
event: triggerUpdates.event ?? event,
|
|
720
|
+
eventName: triggerUpdates.eventName ?? eventName,
|
|
721
|
+
sourceUrl: triggerUpdates.sourceUrl ?? sourceUrl,
|
|
722
|
+
projectPath: triggerUpdates.projectPath ?? projectPath,
|
|
723
|
+
nextRunAt: triggerUpdates.nextRunAt ?? nextRunAt,
|
|
724
|
+
webhookUrl: triggerUpdates.webhookUrl ?? webhookUrl,
|
|
725
|
+
secret: triggerUpdates.secret ?? secret,
|
|
726
|
+
},
|
|
727
|
+
}, options);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function deleteAutomation(automationId, options = {}) {
|
|
731
|
+
ensureAutomationsStore();
|
|
732
|
+
const existing = getAutomation(automationId);
|
|
733
|
+
if (!existing) throw new Error(`Automation not found: ${automationId}`);
|
|
734
|
+
const db = openLocalDb();
|
|
735
|
+
db.transaction(() => {
|
|
736
|
+
db.prepare('DELETE FROM automation_definitions WHERE id = ?').run(existing.id);
|
|
737
|
+
db.prepare(`
|
|
738
|
+
DELETE FROM automation_workflows
|
|
739
|
+
WHERE id = ?
|
|
740
|
+
AND NOT EXISTS (
|
|
741
|
+
SELECT 1 FROM automation_definitions WHERE workflow_id = automation_workflows.id
|
|
742
|
+
)
|
|
743
|
+
`).run(existing.workflowId);
|
|
744
|
+
})();
|
|
745
|
+
publishResources(options.source || 'automations:delete');
|
|
746
|
+
return existing;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function deleteAutomationByTriggerId(triggerId, options = {}) {
|
|
750
|
+
const automation = getAutomationByTriggerId(triggerId);
|
|
751
|
+
if (!automation) throw new Error(`Trigger not found: ${triggerId}`);
|
|
752
|
+
return deleteAutomation(automation.id, options);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function listAutomations() {
|
|
756
|
+
return readAutomationGraph().automations;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function listTriggers() {
|
|
760
|
+
return readAutomationGraph().triggers;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function listWorkflows() {
|
|
764
|
+
return readAutomationGraph().workflows;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function listEventTriggersForIngress() {
|
|
768
|
+
return listTriggers().filter((trigger) => trigger.kind === 'event' && trigger.enabled !== false);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function markTriggerFired(triggerId, options = {}) {
|
|
772
|
+
ensureAutomationsStore();
|
|
773
|
+
const automation = getAutomationByTriggerId(triggerId);
|
|
774
|
+
if (!automation) return null;
|
|
775
|
+
const trigger = automation.triggers.find((item) => item.id === triggerId);
|
|
776
|
+
if (!trigger) return null;
|
|
777
|
+
const timestamp = options.firedAt || nowIso();
|
|
778
|
+
const next = {
|
|
779
|
+
...trigger,
|
|
780
|
+
lastFiredAt: timestamp,
|
|
781
|
+
updatedAt: timestamp,
|
|
782
|
+
};
|
|
783
|
+
if (next.kind === 'scheduled') {
|
|
784
|
+
if (next.schedule?.kind === 'once') next.enabled = false;
|
|
785
|
+
next.nextRunAt = next.enabled === false ? null : computeNextRunAt(next, timestamp);
|
|
786
|
+
}
|
|
787
|
+
openLocalDb().transaction(() => {
|
|
788
|
+
upsertTriggerRow(openLocalDb(), next);
|
|
789
|
+
})();
|
|
790
|
+
publishResources(options.source || 'automations:trigger-fired');
|
|
791
|
+
return next;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function claimDueScheduledTriggers(now = new Date(), options = {}) {
|
|
795
|
+
ensureAutomationsStore();
|
|
796
|
+
const db = openLocalDb();
|
|
797
|
+
const nowDate = validDate(now) || new Date();
|
|
798
|
+
const timestamp = nowDate.toISOString();
|
|
799
|
+
const rows = db.prepare(`
|
|
800
|
+
SELECT automation_triggers.*
|
|
801
|
+
FROM automation_triggers
|
|
802
|
+
INNER JOIN automation_definitions
|
|
803
|
+
ON automation_definitions.id = automation_triggers.automation_id
|
|
804
|
+
INNER JOIN automation_workflows
|
|
805
|
+
ON automation_workflows.id = automation_definitions.workflow_id
|
|
806
|
+
WHERE automation_triggers.kind = 'scheduled'
|
|
807
|
+
AND automation_triggers.enabled = 1
|
|
808
|
+
AND automation_definitions.enabled = 1
|
|
809
|
+
AND automation_workflows.enabled = 1
|
|
810
|
+
AND automation_triggers.next_run_at IS NOT NULL
|
|
811
|
+
AND automation_triggers.next_run_at <= ?
|
|
812
|
+
ORDER BY datetime(automation_triggers.next_run_at) ASC
|
|
813
|
+
`).all(timestamp);
|
|
814
|
+
|
|
815
|
+
const claims = [];
|
|
816
|
+
db.transaction(() => {
|
|
817
|
+
for (const row of rows) {
|
|
818
|
+
const automation = getAutomation(row.automation_id);
|
|
819
|
+
if (!automation) continue;
|
|
820
|
+
const trigger = automation.triggers.find((item) => item.id === row.id);
|
|
821
|
+
if (!trigger) continue;
|
|
822
|
+
const fired = {
|
|
823
|
+
...trigger,
|
|
824
|
+
lastFiredAt: timestamp,
|
|
825
|
+
updatedAt: timestamp,
|
|
826
|
+
};
|
|
827
|
+
if (fired.schedule?.kind === 'once') fired.enabled = false;
|
|
828
|
+
fired.nextRunAt = fired.enabled === false ? null : computeNextRunAt(fired, nowDate);
|
|
829
|
+
upsertTriggerRow(db, fired);
|
|
830
|
+
claims.push({
|
|
831
|
+
automation: composeAutomation(
|
|
832
|
+
automation,
|
|
833
|
+
automation.triggers.map((item) => (item.id === fired.id ? fired : item)),
|
|
834
|
+
automation.workflow,
|
|
835
|
+
),
|
|
836
|
+
trigger: fired,
|
|
837
|
+
scheduledFor: row.next_run_at,
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
})();
|
|
841
|
+
|
|
842
|
+
if (claims.length > 0) publishResources(options.source || 'automations:scheduler-claim');
|
|
843
|
+
return claims;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function rowToAutomationRun(row) {
|
|
847
|
+
if (!row) return null;
|
|
848
|
+
const parsed = parseJson(row.run_json, {});
|
|
849
|
+
return {
|
|
850
|
+
...parsed,
|
|
851
|
+
id: row.id,
|
|
852
|
+
runId: row.id,
|
|
853
|
+
automationId: row.automation_id,
|
|
854
|
+
triggerId: row.trigger_id || parsed.triggerId || null,
|
|
855
|
+
workflowId: row.workflow_id || parsed.workflowId || null,
|
|
856
|
+
status: row.status,
|
|
857
|
+
startedAt: row.started_at || parsed.startedAt || null,
|
|
858
|
+
finishedAt: row.finished_at || parsed.finishedAt || null,
|
|
859
|
+
projectPath: row.project_path || parsed.projectPath || null,
|
|
860
|
+
error: row.error || parsed.error || null,
|
|
861
|
+
createdAt: row.created_at,
|
|
862
|
+
updatedAt: row.updated_at,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function automationRunParams(run) {
|
|
867
|
+
const timestamp = nowIso();
|
|
868
|
+
const createdAt = run.createdAt || timestamp;
|
|
869
|
+
const updatedAt = run.updatedAt || timestamp;
|
|
870
|
+
const runJson = {
|
|
871
|
+
...run,
|
|
872
|
+
id: run.id,
|
|
873
|
+
runId: run.id,
|
|
874
|
+
automationId: run.automationId,
|
|
875
|
+
triggerId: run.triggerId || null,
|
|
876
|
+
workflowId: run.workflowId || null,
|
|
877
|
+
status: run.status || 'running',
|
|
878
|
+
startedAt: run.startedAt || null,
|
|
879
|
+
finishedAt: run.finishedAt || null,
|
|
880
|
+
projectPath: run.projectPath || null,
|
|
881
|
+
error: run.error || null,
|
|
882
|
+
createdAt,
|
|
883
|
+
updatedAt,
|
|
884
|
+
};
|
|
885
|
+
return {
|
|
886
|
+
id: run.id,
|
|
887
|
+
automation_id: run.automationId,
|
|
888
|
+
trigger_id: run.triggerId || null,
|
|
889
|
+
workflow_id: run.workflowId || null,
|
|
890
|
+
status: run.status || 'running',
|
|
891
|
+
started_at: run.startedAt || null,
|
|
892
|
+
finished_at: run.finishedAt || null,
|
|
893
|
+
project_path: run.projectPath || null,
|
|
894
|
+
error: run.error || null,
|
|
895
|
+
run_json: JSON.stringify(runJson),
|
|
896
|
+
created_at: createdAt,
|
|
897
|
+
updated_at: updatedAt,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function upsertAutomationRunRow(db, run) {
|
|
902
|
+
const params = automationRunParams(run);
|
|
903
|
+
db.prepare(`
|
|
904
|
+
INSERT INTO automation_runs (
|
|
905
|
+
id, automation_id, trigger_id, workflow_id, status, started_at,
|
|
906
|
+
finished_at, project_path, error, run_json, created_at, updated_at
|
|
907
|
+
)
|
|
908
|
+
VALUES (
|
|
909
|
+
@id, @automation_id, @trigger_id, @workflow_id, @status, @started_at,
|
|
910
|
+
@finished_at, @project_path, @error, @run_json, @created_at, @updated_at
|
|
911
|
+
)
|
|
912
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
913
|
+
automation_id = excluded.automation_id,
|
|
914
|
+
trigger_id = excluded.trigger_id,
|
|
915
|
+
workflow_id = excluded.workflow_id,
|
|
916
|
+
status = excluded.status,
|
|
917
|
+
started_at = excluded.started_at,
|
|
918
|
+
finished_at = excluded.finished_at,
|
|
919
|
+
project_path = excluded.project_path,
|
|
920
|
+
error = excluded.error,
|
|
921
|
+
run_json = excluded.run_json,
|
|
922
|
+
updated_at = excluded.updated_at
|
|
923
|
+
`).run(params);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function recordAutomationRun(automationId, entry, options = {}) {
|
|
927
|
+
if (!automationId || (!entry?.runId && !entry?.id)) return null;
|
|
928
|
+
ensureAutomationsStore();
|
|
929
|
+
const db = openLocalDb();
|
|
930
|
+
const runId = entry.runId || entry.id;
|
|
931
|
+
let event = null;
|
|
932
|
+
let run = null;
|
|
933
|
+
db.transaction(() => {
|
|
934
|
+
const existing = rowToAutomationRun(db.prepare('SELECT * FROM automation_runs WHERE id = ?').get(runId));
|
|
935
|
+
const timestamp = nowIso();
|
|
936
|
+
const events = Array.isArray(existing?.events) ? existing.events.slice(-99) : [];
|
|
937
|
+
events.push({ ...entry, recordedAt: timestamp });
|
|
938
|
+
run = {
|
|
939
|
+
...(existing || {}),
|
|
940
|
+
id: runId,
|
|
941
|
+
automationId,
|
|
942
|
+
triggerId: entry.triggerId ?? existing?.triggerId ?? null,
|
|
943
|
+
workflowId: entry.workflowId ?? existing?.workflowId ?? null,
|
|
944
|
+
automationName: entry.automationName ?? existing?.automationName ?? null,
|
|
945
|
+
triggerName: entry.triggerName ?? existing?.triggerName ?? null,
|
|
946
|
+
workflowName: entry.workflowName ?? existing?.workflowName ?? null,
|
|
947
|
+
status: entry.status || existing?.status || 'running',
|
|
948
|
+
startedAt: entry.startedAt || existing?.startedAt || timestamp,
|
|
949
|
+
finishedAt: entry.finishedAt || existing?.finishedAt || null,
|
|
950
|
+
projectPath: entry.projectPath ?? existing?.projectPath ?? null,
|
|
951
|
+
error: entry.error || existing?.error || null,
|
|
952
|
+
output: entry.output !== undefined ? entry.output : existing?.output,
|
|
953
|
+
events,
|
|
954
|
+
createdAt: existing?.createdAt || timestamp,
|
|
955
|
+
updatedAt: timestamp,
|
|
956
|
+
};
|
|
957
|
+
upsertAutomationRunRow(db, run);
|
|
958
|
+
event = insertStateEvent(db, {
|
|
959
|
+
resource: 'automation_runs',
|
|
960
|
+
op: existing ? 'update' : 'insert',
|
|
961
|
+
id: run.id,
|
|
962
|
+
value: run,
|
|
963
|
+
source: options.source || 'automation_runs:record',
|
|
964
|
+
});
|
|
965
|
+
})();
|
|
966
|
+
publishStateEvent(event);
|
|
967
|
+
return run;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function listAutomationRuns(options = {}) {
|
|
971
|
+
ensureAutomationsStore();
|
|
972
|
+
const limit = Math.max(1, Math.min(Number(options.limit) || DEFAULT_RUN_LIMIT, 1000));
|
|
973
|
+
const db = openLocalDb();
|
|
974
|
+
const rows = options.automationId
|
|
975
|
+
? db.prepare(`
|
|
976
|
+
SELECT * FROM automation_runs
|
|
977
|
+
WHERE automation_id = ?
|
|
978
|
+
ORDER BY datetime(updated_at) DESC
|
|
979
|
+
LIMIT ?
|
|
980
|
+
`).all(options.automationId, limit)
|
|
981
|
+
: db.prepare(`
|
|
982
|
+
SELECT * FROM automation_runs
|
|
983
|
+
ORDER BY datetime(updated_at) DESC
|
|
984
|
+
LIMIT ?
|
|
985
|
+
`).all(limit);
|
|
986
|
+
return rows.map(rowToAutomationRun).filter(Boolean);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function recordWorkflowCellRun(run, cell, options = {}) {
|
|
990
|
+
if (!run?.id || !cell?.name) return null;
|
|
991
|
+
ensureAutomationsStore();
|
|
992
|
+
const db = openLocalDb();
|
|
993
|
+
const timestamp = nowIso();
|
|
994
|
+
const cellRun = {
|
|
995
|
+
id: `${run.id}:${cell.name}`,
|
|
996
|
+
runId: run.id,
|
|
997
|
+
automationId: run.automationId,
|
|
998
|
+
triggerId: run.triggerId || null,
|
|
999
|
+
workflowId: run.workflowId || null,
|
|
1000
|
+
cellName: cell.name,
|
|
1001
|
+
cellKind: cell.kind || 'code',
|
|
1002
|
+
status: cell.status || 'running',
|
|
1003
|
+
startedAt: cell.startedAt || timestamp,
|
|
1004
|
+
finishedAt: cell.finishedAt || null,
|
|
1005
|
+
output: cell.output,
|
|
1006
|
+
error: cell.error || null,
|
|
1007
|
+
createdAt: cell.createdAt || timestamp,
|
|
1008
|
+
updatedAt: timestamp,
|
|
1009
|
+
};
|
|
1010
|
+
let event = null;
|
|
1011
|
+
db.transaction(() => {
|
|
1012
|
+
db.prepare(`
|
|
1013
|
+
INSERT INTO workflow_cell_runs (
|
|
1014
|
+
id, run_id, automation_id, trigger_id, workflow_id, cell_name,
|
|
1015
|
+
cell_kind, status, started_at, finished_at, output_json, error,
|
|
1016
|
+
cell_json, created_at, updated_at
|
|
1017
|
+
)
|
|
1018
|
+
VALUES (
|
|
1019
|
+
@id, @run_id, @automation_id, @trigger_id, @workflow_id, @cell_name,
|
|
1020
|
+
@cell_kind, @status, @started_at, @finished_at, @output_json, @error,
|
|
1021
|
+
@cell_json, @created_at, @updated_at
|
|
1022
|
+
)
|
|
1023
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1024
|
+
status = excluded.status,
|
|
1025
|
+
finished_at = excluded.finished_at,
|
|
1026
|
+
output_json = excluded.output_json,
|
|
1027
|
+
error = excluded.error,
|
|
1028
|
+
cell_json = excluded.cell_json,
|
|
1029
|
+
updated_at = excluded.updated_at
|
|
1030
|
+
`).run({
|
|
1031
|
+
id: cellRun.id,
|
|
1032
|
+
run_id: cellRun.runId,
|
|
1033
|
+
automation_id: cellRun.automationId,
|
|
1034
|
+
trigger_id: cellRun.triggerId,
|
|
1035
|
+
workflow_id: cellRun.workflowId,
|
|
1036
|
+
cell_name: cellRun.cellName,
|
|
1037
|
+
cell_kind: cellRun.cellKind,
|
|
1038
|
+
status: cellRun.status,
|
|
1039
|
+
started_at: cellRun.startedAt,
|
|
1040
|
+
finished_at: cellRun.finishedAt,
|
|
1041
|
+
output_json: cellRun.output === undefined ? null : JSON.stringify(cellRun.output),
|
|
1042
|
+
error: cellRun.error,
|
|
1043
|
+
cell_json: JSON.stringify(cellRun),
|
|
1044
|
+
created_at: cellRun.createdAt,
|
|
1045
|
+
updated_at: cellRun.updatedAt,
|
|
1046
|
+
});
|
|
1047
|
+
event = insertStateEvent(db, {
|
|
1048
|
+
resource: 'workflow_cell_runs',
|
|
1049
|
+
op: 'update',
|
|
1050
|
+
id: cellRun.id,
|
|
1051
|
+
value: cellRun,
|
|
1052
|
+
source: options.source || 'workflow_cell_runs:record',
|
|
1053
|
+
});
|
|
1054
|
+
})();
|
|
1055
|
+
publishStateEvent(event);
|
|
1056
|
+
return cellRun;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function listWorkflowCellRuns(options = {}) {
|
|
1060
|
+
ensureAutomationsStore();
|
|
1061
|
+
const limit = Math.max(1, Math.min(Number(options.limit) || DEFAULT_RUN_LIMIT, 1000));
|
|
1062
|
+
const db = openLocalDb();
|
|
1063
|
+
const rows = options.runId
|
|
1064
|
+
? db.prepare(`
|
|
1065
|
+
SELECT * FROM workflow_cell_runs
|
|
1066
|
+
WHERE run_id = ?
|
|
1067
|
+
ORDER BY datetime(created_at) ASC
|
|
1068
|
+
LIMIT ?
|
|
1069
|
+
`).all(options.runId, limit)
|
|
1070
|
+
: db.prepare(`
|
|
1071
|
+
SELECT * FROM workflow_cell_runs
|
|
1072
|
+
ORDER BY datetime(updated_at) DESC
|
|
1073
|
+
LIMIT ?
|
|
1074
|
+
`).all(limit);
|
|
1075
|
+
return rows.map((row) => parseJson(row.cell_json, null)).filter(Boolean);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function migrateLegacyAutomations(db) {
|
|
1079
|
+
if (metaValue(db, LEGACY_MIGRATION_KEY)) return;
|
|
1080
|
+
setMetaValue(db, LEGACY_MIGRATION_KEY, nowIso());
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
let ensuring = false;
|
|
1084
|
+
let ensured = false;
|
|
1085
|
+
|
|
1086
|
+
function ensureAutomationsStore() {
|
|
1087
|
+
if (ensured || ensuring) return;
|
|
1088
|
+
ensuring = true;
|
|
1089
|
+
try {
|
|
1090
|
+
const db = openLocalDb();
|
|
1091
|
+
migrateLegacyAutomations(db);
|
|
1092
|
+
ensured = true;
|
|
1093
|
+
} finally {
|
|
1094
|
+
ensuring = false;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function eventRefForTrigger(trigger) {
|
|
1099
|
+
return splitEventRef(`${trigger?.source || '*'}.${trigger?.event || '*'}`);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
module.exports = {
|
|
1103
|
+
claimDueScheduledTriggers,
|
|
1104
|
+
createAutomation,
|
|
1105
|
+
defaultWorkflowText,
|
|
1106
|
+
deleteAutomation,
|
|
1107
|
+
deleteAutomationByTriggerId,
|
|
1108
|
+
ensureAutomationsStore,
|
|
1109
|
+
eventRefForTrigger,
|
|
1110
|
+
getAutomation,
|
|
1111
|
+
getAutomationByTriggerId,
|
|
1112
|
+
listAutomationRuns,
|
|
1113
|
+
listAutomations,
|
|
1114
|
+
listEventTriggersForIngress,
|
|
1115
|
+
listTriggers,
|
|
1116
|
+
listWorkflowCellRuns,
|
|
1117
|
+
listWorkflows,
|
|
1118
|
+
markTriggerFired,
|
|
1119
|
+
normalizeWorkflowRecord,
|
|
1120
|
+
publishResources,
|
|
1121
|
+
recordAutomationRun,
|
|
1122
|
+
recordWorkflowCellRun,
|
|
1123
|
+
updateAutomation,
|
|
1124
|
+
updateAutomationByTriggerId,
|
|
1125
|
+
};
|