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
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-automations-reliability-test-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
|
|
12
|
+
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
13
|
+
const {
|
|
14
|
+
createAutomation,
|
|
15
|
+
deleteAutomation,
|
|
16
|
+
expireStaleAutomationRuns,
|
|
17
|
+
getAutomation,
|
|
18
|
+
getAutomationRun,
|
|
19
|
+
listAutomationRuns,
|
|
20
|
+
pruneAutomationRuns,
|
|
21
|
+
recordAutomationRun,
|
|
22
|
+
repairScheduledTriggers,
|
|
23
|
+
updateAutomation,
|
|
24
|
+
} = require('../automations/store');
|
|
25
|
+
const { executeAutomation } = require('../automations/runner');
|
|
26
|
+
const { handleEventsPost } = require('../events/ingress');
|
|
27
|
+
|
|
28
|
+
const SIMPLE_WORKFLOW = `export default workflow({
|
|
29
|
+
trigger: event("*.*"),
|
|
30
|
+
cells: [code("ok", async () => ({ ok: true }))]
|
|
31
|
+
})`;
|
|
32
|
+
|
|
33
|
+
function fakeRequest(headers, body) {
|
|
34
|
+
return {
|
|
35
|
+
headers,
|
|
36
|
+
on(event, callback) {
|
|
37
|
+
if (event === 'data') setImmediate(() => callback(body));
|
|
38
|
+
if (event === 'end') setImmediate(() => callback());
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function captureJson() {
|
|
44
|
+
const result = { status: null, body: null };
|
|
45
|
+
result.send = (status, body) => {
|
|
46
|
+
result.status = status;
|
|
47
|
+
result.body = body;
|
|
48
|
+
};
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test.after(() => {
|
|
53
|
+
closeLocalDb();
|
|
54
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('updating the trigger array preserves trigger identity, secret, and schedule clock', () => {
|
|
58
|
+
const automation = createAutomation({
|
|
59
|
+
id: 'automation-preserve',
|
|
60
|
+
name: 'Preserve identity',
|
|
61
|
+
triggers: [
|
|
62
|
+
{ id: 'trigger-keep-event', kind: 'event', source: 'audit', event: 'ping' },
|
|
63
|
+
{ id: 'trigger-keep-schedule', kind: 'scheduled', schedule: { kind: 'interval', ms: 3_600_000 } },
|
|
64
|
+
],
|
|
65
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
66
|
+
});
|
|
67
|
+
const originalEvent = automation.triggers.find((trigger) => trigger.id === 'trigger-keep-event');
|
|
68
|
+
const originalScheduled = automation.triggers.find((trigger) => trigger.id === 'trigger-keep-schedule');
|
|
69
|
+
assert.ok(originalEvent.secret);
|
|
70
|
+
assert.ok(originalScheduled.nextRunAt);
|
|
71
|
+
|
|
72
|
+
// Round-trip the trigger array with ids (the UI add-trigger path).
|
|
73
|
+
const updated = updateAutomation('automation-preserve', {
|
|
74
|
+
triggers: [
|
|
75
|
+
{ id: 'trigger-keep-event', kind: 'event', source: 'audit', event: 'ping', name: 'Renamed event trigger' },
|
|
76
|
+
{ id: 'trigger-keep-schedule', kind: 'scheduled', schedule: { kind: 'interval', ms: 3_600_000 } },
|
|
77
|
+
{ kind: 'event', source: 'github', event: 'push' },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
const keptEvent = updated.triggers.find((trigger) => trigger.id === 'trigger-keep-event');
|
|
81
|
+
const keptScheduled = updated.triggers.find((trigger) => trigger.id === 'trigger-keep-schedule');
|
|
82
|
+
assert.equal(updated.triggers.length, 3);
|
|
83
|
+
assert.equal(keptEvent.secret, originalEvent.secret, 'webhook secret must survive trigger-array updates');
|
|
84
|
+
assert.equal(keptEvent.name, 'Renamed event trigger');
|
|
85
|
+
assert.equal(keptScheduled.nextRunAt, originalScheduled.nextRunAt, 'interval clock must not reset on update');
|
|
86
|
+
|
|
87
|
+
// Without ids, fingerprint matching still preserves identity.
|
|
88
|
+
const refingerprinted = updateAutomation('automation-preserve', {
|
|
89
|
+
triggers: [
|
|
90
|
+
{ kind: 'event', source: 'audit', event: 'ping' },
|
|
91
|
+
{ kind: 'scheduled', schedule: { kind: 'interval', ms: 3_600_000 } },
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
assert.equal(refingerprinted.triggers.length, 2);
|
|
95
|
+
assert.equal(
|
|
96
|
+
refingerprinted.triggers.find((trigger) => trigger.kind === 'event').secret,
|
|
97
|
+
originalEvent.secret,
|
|
98
|
+
'fingerprint-matched event trigger keeps its secret',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('editing a schedule recomputes next_run_at immediately', () => {
|
|
103
|
+
const automation = createAutomation({
|
|
104
|
+
id: 'automation-reschedule',
|
|
105
|
+
name: 'Reschedule',
|
|
106
|
+
triggers: [{ id: 'trigger-reschedule', kind: 'scheduled', schedule: { kind: 'interval', ms: 3_600_000 } }],
|
|
107
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
108
|
+
});
|
|
109
|
+
const before = automation.triggers[0].nextRunAt;
|
|
110
|
+
assert.ok(before);
|
|
111
|
+
|
|
112
|
+
const updated = updateAutomation('automation-reschedule', {
|
|
113
|
+
trigger: { id: 'trigger-reschedule', schedule: { kind: 'interval', ms: 7_200_000 } },
|
|
114
|
+
});
|
|
115
|
+
const after = updated.triggers[0].nextRunAt;
|
|
116
|
+
assert.ok(after);
|
|
117
|
+
assert.notEqual(after, before, 'schedule change must re-arm next_run_at');
|
|
118
|
+
assert.ok(new Date(after) > new Date(before), 'new 2h cadence lands later than old 1h cadence');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('invalid cron expressions are rejected at save time', () => {
|
|
122
|
+
assert.throws(() => createAutomation({
|
|
123
|
+
id: 'automation-bad-cron',
|
|
124
|
+
name: 'Bad cron',
|
|
125
|
+
triggers: [{ kind: 'scheduled', schedule: { kind: 'cron', expr: 'not a cron expr' } }],
|
|
126
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
127
|
+
}), /cron/i);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('repairScheduledTriggers re-arms enabled triggers with NULL next_run_at', () => {
|
|
131
|
+
createAutomation({
|
|
132
|
+
id: 'automation-dormant',
|
|
133
|
+
name: 'Dormant trigger',
|
|
134
|
+
triggers: [{ id: 'trigger-dormant', kind: 'scheduled', schedule: { kind: 'interval', ms: 3_600_000 } }],
|
|
135
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
136
|
+
});
|
|
137
|
+
const db = openLocalDb();
|
|
138
|
+
db.prepare('UPDATE automation_triggers SET next_run_at = NULL WHERE id = ?').run('trigger-dormant');
|
|
139
|
+
|
|
140
|
+
const repaired = repairScheduledTriggers();
|
|
141
|
+
assert.equal(repaired.some((trigger) => trigger.id === 'trigger-dormant'), true);
|
|
142
|
+
const trigger = getAutomation('automation-dormant').triggers[0];
|
|
143
|
+
assert.ok(trigger.nextRunAt, 'dormant trigger must be re-armed');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('expireStaleAutomationRuns marks stalled running runs as interrupted', () => {
|
|
147
|
+
createAutomation({
|
|
148
|
+
id: 'automation-stale-run',
|
|
149
|
+
name: 'Stale run',
|
|
150
|
+
triggers: [{ kind: 'event', source: 'stale', event: 'run' }],
|
|
151
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
152
|
+
});
|
|
153
|
+
recordAutomationRun('automation-stale-run', {
|
|
154
|
+
runId: 'run-stale',
|
|
155
|
+
status: 'running',
|
|
156
|
+
startedAt: new Date(Date.now() - 60 * 60_000).toISOString(),
|
|
157
|
+
});
|
|
158
|
+
const db = openLocalDb();
|
|
159
|
+
const staleTimestamp = new Date(Date.now() - 30 * 60_000).toISOString();
|
|
160
|
+
db.prepare('UPDATE automation_runs SET updated_at = ? WHERE id = ?').run(staleTimestamp, 'run-stale');
|
|
161
|
+
|
|
162
|
+
const expired = expireStaleAutomationRuns({ staleAfterMs: 10 * 60_000 });
|
|
163
|
+
assert.equal(expired.some((run) => run.id === 'run-stale'), true);
|
|
164
|
+
const run = getAutomationRun('run-stale');
|
|
165
|
+
assert.equal(run.status, 'interrupted');
|
|
166
|
+
assert.ok(run.finishedAt);
|
|
167
|
+
|
|
168
|
+
// Terminal status is final: a late cell event cannot resurrect the run.
|
|
169
|
+
recordAutomationRun('automation-stale-run', { runId: 'run-stale', status: 'running' });
|
|
170
|
+
assert.equal(getAutomationRun('run-stale').status, 'interrupted');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('pruneAutomationRuns keeps the most recent runs per automation', () => {
|
|
174
|
+
createAutomation({
|
|
175
|
+
id: 'automation-prune',
|
|
176
|
+
name: 'Prune runs',
|
|
177
|
+
triggers: [{ kind: 'event', source: 'prune', event: 'run' }],
|
|
178
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
179
|
+
});
|
|
180
|
+
for (let index = 0; index < 5; index += 1) {
|
|
181
|
+
recordAutomationRun('automation-prune', {
|
|
182
|
+
runId: `run-prune-${index}`,
|
|
183
|
+
status: 'completed',
|
|
184
|
+
startedAt: new Date(Date.now() - (10 - index) * 60_000).toISOString(),
|
|
185
|
+
finishedAt: new Date(Date.now() - (9 - index) * 60_000).toISOString(),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
pruneAutomationRuns({ keepPerAutomation: 2 });
|
|
189
|
+
const runs = listAutomationRuns({ automationId: 'automation-prune' });
|
|
190
|
+
assert.equal(runs.length, 2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('multiple workflows per automation: each firing records one run per enabled workflow', async () => {
|
|
194
|
+
const automation = createAutomation({
|
|
195
|
+
id: 'automation-multi',
|
|
196
|
+
name: 'Multi workflow',
|
|
197
|
+
triggers: [{ id: 'trigger-multi', kind: 'event', source: 'multi', event: 'go' }],
|
|
198
|
+
workflows: [
|
|
199
|
+
{
|
|
200
|
+
id: 'wf-multi-a',
|
|
201
|
+
name: 'First workflow',
|
|
202
|
+
workflowText: `export default workflow({
|
|
203
|
+
trigger: event("multi.go"),
|
|
204
|
+
cells: [code("a", async () => ({ which: "a" }))]
|
|
205
|
+
})`,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'wf-multi-b',
|
|
209
|
+
name: 'Second workflow',
|
|
210
|
+
workflowText: `export default workflow({
|
|
211
|
+
trigger: event("multi.go"),
|
|
212
|
+
cells: [code("b", async () => ({ which: "b" }))]
|
|
213
|
+
})`,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
assert.equal(automation.workflows.length, 2);
|
|
218
|
+
assert.deepEqual(automation.workflowIds, ['wf-multi-a', 'wf-multi-b']);
|
|
219
|
+
assert.equal(automation.workflowId, 'wf-multi-a');
|
|
220
|
+
|
|
221
|
+
const runs = await executeAutomation(automation, automation.triggers[0], {
|
|
222
|
+
source: 'multi', event: 'go', payload: {}, headers: {},
|
|
223
|
+
});
|
|
224
|
+
assert.equal(runs.length, 2);
|
|
225
|
+
assert.deepEqual(runs.map((run) => run.status), ['completed', 'completed']);
|
|
226
|
+
assert.deepEqual(runs.map((run) => run.workflowId), ['wf-multi-a', 'wf-multi-b']);
|
|
227
|
+
assert.equal(listAutomationRuns({ automationId: 'automation-multi' }).length, 2);
|
|
228
|
+
|
|
229
|
+
// getAutomation round-trips the full workflow set from the link table.
|
|
230
|
+
const reread = getAutomation('automation-multi');
|
|
231
|
+
assert.deepEqual(reread.workflowIds, ['wf-multi-a', 'wf-multi-b']);
|
|
232
|
+
|
|
233
|
+
// Workflows-array update by id preserves identity and can disable one.
|
|
234
|
+
const updated = updateAutomation('automation-multi', {
|
|
235
|
+
workflows: [
|
|
236
|
+
{ id: 'wf-multi-a' },
|
|
237
|
+
{ id: 'wf-multi-b', enabled: false },
|
|
238
|
+
],
|
|
239
|
+
});
|
|
240
|
+
assert.deepEqual(updated.workflowIds, ['wf-multi-a', 'wf-multi-b']);
|
|
241
|
+
assert.equal(updated.workflows[1].enabled, false);
|
|
242
|
+
|
|
243
|
+
const secondRound = await executeAutomation(updated, updated.triggers[0], {
|
|
244
|
+
source: 'multi', event: 'go', payload: {}, headers: {},
|
|
245
|
+
});
|
|
246
|
+
assert.equal(secondRound.length, 1, 'disabled workflow is skipped');
|
|
247
|
+
assert.equal(secondRound[0].workflowId, 'wf-multi-a');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('one failing workflow does not block the others in the same firing', async () => {
|
|
251
|
+
const automation = createAutomation({
|
|
252
|
+
id: 'automation-partial-fail',
|
|
253
|
+
name: 'Partial failure',
|
|
254
|
+
triggers: [{ kind: 'event', source: 'partial', event: 'go' }],
|
|
255
|
+
workflows: [
|
|
256
|
+
`export default workflow({
|
|
257
|
+
trigger: event("partial.go"),
|
|
258
|
+
cells: [code("boom", async () => { throw new Error("boom") })]
|
|
259
|
+
})`,
|
|
260
|
+
`export default workflow({
|
|
261
|
+
trigger: event("partial.go"),
|
|
262
|
+
cells: [code("fine", async () => ({ ok: true }))]
|
|
263
|
+
})`,
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const runs = await executeAutomation(automation, automation.triggers[0], {
|
|
268
|
+
source: 'partial', event: 'go', payload: {}, headers: {},
|
|
269
|
+
});
|
|
270
|
+
assert.equal(runs.length, 2);
|
|
271
|
+
assert.deepEqual(runs.map((run) => run.status).sort(), ['completed', 'failed']);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('deleting a multi-workflow automation removes its orphaned workflow rows', () => {
|
|
275
|
+
createAutomation({
|
|
276
|
+
id: 'automation-delete-multi',
|
|
277
|
+
name: 'Delete multi',
|
|
278
|
+
triggers: [{ kind: 'event', source: 'del', event: 'go' }],
|
|
279
|
+
workflows: [
|
|
280
|
+
{ id: 'wf-del-a', workflowText: SIMPLE_WORKFLOW },
|
|
281
|
+
{ id: 'wf-del-b', workflowText: SIMPLE_WORKFLOW },
|
|
282
|
+
],
|
|
283
|
+
});
|
|
284
|
+
deleteAutomation('automation-delete-multi');
|
|
285
|
+
const db = openLocalDb();
|
|
286
|
+
const remaining = db.prepare(
|
|
287
|
+
"SELECT COUNT(*) AS count FROM automation_workflows WHERE id IN ('wf-del-a', 'wf-del-b')",
|
|
288
|
+
).get();
|
|
289
|
+
assert.equal(remaining.count, 0);
|
|
290
|
+
const links = db.prepare(
|
|
291
|
+
"SELECT COUNT(*) AS count FROM automation_workflow_links WHERE automation_id = 'automation-delete-multi'",
|
|
292
|
+
).get();
|
|
293
|
+
assert.equal(links.count, 0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('allowlist action aliases authorize first-party tool calls', async () => {
|
|
297
|
+
const automation = createAutomation({
|
|
298
|
+
id: 'automation-alias-allowlist',
|
|
299
|
+
name: 'Alias allowlist',
|
|
300
|
+
triggers: [{ kind: 'event', source: 'alias', event: 'go' }],
|
|
301
|
+
allowlist: { actions: ['amalgm.talk_to_agent'] },
|
|
302
|
+
limits: { cellTimeoutMs: 1500 },
|
|
303
|
+
workflowText: `export default workflow({
|
|
304
|
+
trigger: event("alias.go"),
|
|
305
|
+
allowlist: { actions: ["amalgm.talk_to_agent"] },
|
|
306
|
+
limits: { cellTimeoutMs: 1500 },
|
|
307
|
+
cells: [tool("ask", "agents.talk_to_agent", { description: "test", prompt: "hi", run_in_background: true })]
|
|
308
|
+
})`,
|
|
309
|
+
});
|
|
310
|
+
let error = null;
|
|
311
|
+
try {
|
|
312
|
+
await executeAutomation(automation, automation.triggers[0], {
|
|
313
|
+
source: 'alias', event: 'go', payload: {}, headers: {},
|
|
314
|
+
});
|
|
315
|
+
} catch (err) {
|
|
316
|
+
error = err;
|
|
317
|
+
}
|
|
318
|
+
// The agent call itself may fail in the test environment (no agent runtime),
|
|
319
|
+
// but the allowlist gate must accept the amalgm.* alias for agents.talk_to_agent.
|
|
320
|
+
if (error) {
|
|
321
|
+
assert.doesNotMatch(error.message || '', /not allowed to call/i);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('authenticated-but-unmatched events are recorded as small unmatched runs', async () => {
|
|
326
|
+
const automation = createAutomation({
|
|
327
|
+
id: 'automation-unmatched-run',
|
|
328
|
+
name: 'Unmatched run',
|
|
329
|
+
triggers: [{ id: 'trigger-unmatched-run', kind: 'event', source: 'audit', event: 'ping' }],
|
|
330
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
331
|
+
});
|
|
332
|
+
const secret = automation.triggers[0].secret;
|
|
333
|
+
|
|
334
|
+
const response = captureJson();
|
|
335
|
+
await handleEventsPost(
|
|
336
|
+
fakeRequest({ 'x-amalgm-webhook-secret': secret }, JSON.stringify({ source: 'other', event: 'nope' })),
|
|
337
|
+
response.send,
|
|
338
|
+
);
|
|
339
|
+
assert.equal(response.status, 202);
|
|
340
|
+
|
|
341
|
+
const runs = listAutomationRuns({ automationId: 'automation-unmatched-run' });
|
|
342
|
+
assert.equal(runs.length, 1);
|
|
343
|
+
assert.equal(runs[0].status, 'unmatched');
|
|
344
|
+
assert.equal(runs[0].triggerId, 'trigger-unmatched-run');
|
|
345
|
+
assert.equal(runs[0].output.received.source, 'other');
|
|
346
|
+
assert.equal(runs[0].output.received.event, 'nope');
|
|
347
|
+
assert.ok(runs[0].finishedAt, 'unmatched runs are terminal');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('workflows[] cannot reference a workflow id owned by another automation', () => {
|
|
351
|
+
createAutomation({
|
|
352
|
+
id: 'automation-owner-a',
|
|
353
|
+
name: 'Owner A',
|
|
354
|
+
triggers: [{ kind: 'event', source: 'own', event: 'a' }],
|
|
355
|
+
workflows: [{ id: 'wf-owned-a', workflowText: SIMPLE_WORKFLOW }],
|
|
356
|
+
});
|
|
357
|
+
createAutomation({
|
|
358
|
+
id: 'automation-owner-b',
|
|
359
|
+
name: 'Owner B',
|
|
360
|
+
triggers: [{ kind: 'event', source: 'own', event: 'b' }],
|
|
361
|
+
workflows: [{ id: 'wf-owned-b', workflowText: SIMPLE_WORKFLOW }],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Update path: foreign id must be rejected, not clobbered.
|
|
365
|
+
assert.throws(() => updateAutomation('automation-owner-b', {
|
|
366
|
+
workflows: [{ id: 'wf-owned-a', workflowText: SIMPLE_WORKFLOW }],
|
|
367
|
+
}), /another automation/);
|
|
368
|
+
|
|
369
|
+
// Create path too.
|
|
370
|
+
assert.throws(() => createAutomation({
|
|
371
|
+
id: 'automation-owner-c',
|
|
372
|
+
name: 'Owner C',
|
|
373
|
+
triggers: [{ kind: 'event', source: 'own', event: 'c' }],
|
|
374
|
+
workflows: [{ id: 'wf-owned-a', workflowText: SIMPLE_WORKFLOW }],
|
|
375
|
+
}), /another automation/);
|
|
376
|
+
|
|
377
|
+
// Owner A untouched.
|
|
378
|
+
const ownerA = getAutomation('automation-owner-a');
|
|
379
|
+
assert.deepEqual(ownerA.workflowIds, ['wf-owned-a']);
|
|
380
|
+
|
|
381
|
+
// Duplicate ids in one array are rejected cleanly.
|
|
382
|
+
assert.throws(() => updateAutomation('automation-owner-b', {
|
|
383
|
+
workflows: [{ id: 'wf-owned-b' }, { id: 'wf-owned-b' }],
|
|
384
|
+
}), /Duplicate workflow id/);
|
|
385
|
+
|
|
386
|
+
// workflowText alongside workflows is ambiguous — rejected.
|
|
387
|
+
assert.throws(() => updateAutomation('automation-owner-b', {
|
|
388
|
+
workflows: [{ id: 'wf-owned-b' }],
|
|
389
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
390
|
+
}), /not both/);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('mixed id/no-id workflows[] update pairs positionless entries with surviving workflows', () => {
|
|
394
|
+
createAutomation({
|
|
395
|
+
id: 'automation-mixed',
|
|
396
|
+
name: 'Mixed update',
|
|
397
|
+
triggers: [{ kind: 'event', source: 'mixed', event: 'go' }],
|
|
398
|
+
workflows: [
|
|
399
|
+
{ id: 'wf-mixed-1', workflowText: SIMPLE_WORKFLOW },
|
|
400
|
+
{ id: 'wf-mixed-2', workflowText: SIMPLE_WORKFLOW },
|
|
401
|
+
],
|
|
402
|
+
});
|
|
403
|
+
// Reorder with one explicit id and one positionless entry: the positionless
|
|
404
|
+
// entry must bind to the remaining workflow instead of orphan-deleting it.
|
|
405
|
+
const updated = updateAutomation('automation-mixed', {
|
|
406
|
+
workflows: [{ id: 'wf-mixed-2' }, { workflowText: SIMPLE_WORKFLOW }],
|
|
407
|
+
});
|
|
408
|
+
assert.deepEqual([...updated.workflowIds].sort(), ['wf-mixed-1', 'wf-mixed-2']);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('top-level allowlist/limits apply to every workflows[] entry', () => {
|
|
412
|
+
const automation = createAutomation({
|
|
413
|
+
id: 'automation-allowlist',
|
|
414
|
+
name: 'Allowlist propagation',
|
|
415
|
+
triggers: [{ kind: 'event', source: 'al', event: 'go' }],
|
|
416
|
+
allowlist: { network: true },
|
|
417
|
+
limits: { cellTimeoutMs: 5000 },
|
|
418
|
+
workflows: [
|
|
419
|
+
SIMPLE_WORKFLOW,
|
|
420
|
+
{ workflowText: SIMPLE_WORKFLOW, allowlist: { localCompute: true } },
|
|
421
|
+
],
|
|
422
|
+
});
|
|
423
|
+
assert.deepEqual(automation.workflows[0].allowlist, { network: true });
|
|
424
|
+
assert.deepEqual(automation.workflows[0].limits, { cellTimeoutMs: 5000 });
|
|
425
|
+
// Per-entry allowlist wins over the top-level default.
|
|
426
|
+
assert.deepEqual(automation.workflows[1].allowlist, { localCompute: true });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('a real completion overrides a reaper-written interrupted status', () => {
|
|
430
|
+
createAutomation({
|
|
431
|
+
id: 'automation-override',
|
|
432
|
+
name: 'Override interrupted',
|
|
433
|
+
triggers: [{ kind: 'event', source: 'ovr', event: 'go' }],
|
|
434
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
435
|
+
});
|
|
436
|
+
recordAutomationRun('automation-override', {
|
|
437
|
+
runId: 'run-override',
|
|
438
|
+
status: 'running',
|
|
439
|
+
startedAt: new Date(Date.now() - 30 * 60_000).toISOString(),
|
|
440
|
+
});
|
|
441
|
+
const db = openLocalDb();
|
|
442
|
+
db.prepare('UPDATE automation_runs SET updated_at = ? WHERE id = ?')
|
|
443
|
+
.run(new Date(Date.now() - 20 * 60_000).toISOString(), 'run-override');
|
|
444
|
+
expireStaleAutomationRuns({ staleAfterMs: 10 * 60_000 });
|
|
445
|
+
assert.equal(getAutomationRun('run-override').status, 'interrupted');
|
|
446
|
+
|
|
447
|
+
// The cell was actually still running and now finishes legitimately.
|
|
448
|
+
const finishedAt = new Date().toISOString();
|
|
449
|
+
recordAutomationRun('automation-override', {
|
|
450
|
+
runId: 'run-override',
|
|
451
|
+
status: 'completed',
|
|
452
|
+
finishedAt,
|
|
453
|
+
});
|
|
454
|
+
const run = getAutomationRun('run-override');
|
|
455
|
+
assert.equal(run.status, 'completed', 'in-process completion wins over reaper interruption');
|
|
456
|
+
assert.equal(run.finishedAt, finishedAt);
|
|
457
|
+
assert.equal(run.error, null, 'reaper error message cleared by the real outcome');
|
|
458
|
+
|
|
459
|
+
// But interrupted -> running is still rejected.
|
|
460
|
+
recordAutomationRun('automation-override', { runId: 'run-override', status: 'running' });
|
|
461
|
+
assert.equal(getAutomationRun('run-override').status, 'completed');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('repeated unmatched deliveries of the same ref collapse into one run row per day', async () => {
|
|
465
|
+
const automation = createAutomation({
|
|
466
|
+
id: 'automation-unmatched-dedupe',
|
|
467
|
+
name: 'Unmatched dedupe',
|
|
468
|
+
triggers: [{ id: 'trigger-unmatched-dedupe', kind: 'event', source: 'audit', event: 'ping' }],
|
|
469
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
470
|
+
});
|
|
471
|
+
const secret = automation.triggers[0].secret;
|
|
472
|
+
for (let i = 0; i < 3; i += 1) {
|
|
473
|
+
const response = captureJson();
|
|
474
|
+
await handleEventsPost(
|
|
475
|
+
fakeRequest({ 'x-amalgm-webhook-secret': secret }, JSON.stringify({ source: 'spam', event: 'ref' })),
|
|
476
|
+
response.send,
|
|
477
|
+
);
|
|
478
|
+
assert.equal(response.status, 202);
|
|
479
|
+
}
|
|
480
|
+
const runs = listAutomationRuns({ automationId: 'automation-unmatched-dedupe' })
|
|
481
|
+
.filter((run) => run.status === 'unmatched');
|
|
482
|
+
assert.equal(runs.length, 1, 'same unmatched ref must refresh one row, not spam history');
|
|
483
|
+
assert.ok(runs[0].events.length >= 3, 'each receipt is still logged on the row');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('incidental body source/event fields fall back to external.webhook for legacy triggers', async () => {
|
|
487
|
+
const automation = createAutomation({
|
|
488
|
+
id: 'automation-legacy-generic',
|
|
489
|
+
name: 'Legacy generic webhook',
|
|
490
|
+
triggers: [{ id: 'trigger-legacy-generic', kind: 'event', source: 'external', event: 'webhook' }],
|
|
491
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
492
|
+
});
|
|
493
|
+
const secret = automation.triggers[0].secret;
|
|
494
|
+
// A third-party payload that happens to contain an "event" key must still
|
|
495
|
+
// fire the trigger pinned to the legacy external.webhook ref.
|
|
496
|
+
const response = captureJson();
|
|
497
|
+
await handleEventsPost(
|
|
498
|
+
fakeRequest(
|
|
499
|
+
{ 'x-amalgm-webhook-secret': secret },
|
|
500
|
+
JSON.stringify({ event: 'invitee.created', payloadField: 1 }),
|
|
501
|
+
),
|
|
502
|
+
response.send,
|
|
503
|
+
);
|
|
504
|
+
assert.equal(response.status, 200);
|
|
505
|
+
assert.equal(response.body.triggered, true);
|
|
506
|
+
assert.equal(response.body.event.source, 'external');
|
|
507
|
+
assert.equal(response.body.event.event, 'webhook');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('ingress matches body-declared refs for generic signed senders and 202s unmatched events', async () => {
|
|
511
|
+
const automation = createAutomation({
|
|
512
|
+
id: 'automation-custom-ref',
|
|
513
|
+
name: 'Custom ref webhook',
|
|
514
|
+
triggers: [{ id: 'trigger-custom-ref', kind: 'event', source: 'audit', event: 'ping' }],
|
|
515
|
+
workflowText: SIMPLE_WORKFLOW,
|
|
516
|
+
});
|
|
517
|
+
const secret = automation.triggers[0].secret;
|
|
518
|
+
assert.ok(secret);
|
|
519
|
+
|
|
520
|
+
// Body-declared source/event must reach the trigger selector.
|
|
521
|
+
const matched = captureJson();
|
|
522
|
+
await handleEventsPost(
|
|
523
|
+
fakeRequest({ 'x-amalgm-webhook-secret': secret }, JSON.stringify({ source: 'audit', event: 'ping' })),
|
|
524
|
+
matched.send,
|
|
525
|
+
);
|
|
526
|
+
assert.equal(matched.status, 200);
|
|
527
|
+
assert.equal(matched.body.triggered, true);
|
|
528
|
+
assert.equal(matched.body.event.source, 'audit');
|
|
529
|
+
assert.equal(matched.body.event.event, 'ping');
|
|
530
|
+
|
|
531
|
+
// Valid secret + non-matching ref is not an auth failure.
|
|
532
|
+
const unmatched = captureJson();
|
|
533
|
+
await handleEventsPost(
|
|
534
|
+
fakeRequest({ 'x-amalgm-webhook-secret': secret }, JSON.stringify({ source: 'other', event: 'nope' })),
|
|
535
|
+
unmatched.send,
|
|
536
|
+
);
|
|
537
|
+
assert.equal(unmatched.status, 202);
|
|
538
|
+
assert.equal(unmatched.body.triggered, false);
|
|
539
|
+
|
|
540
|
+
// Wrong secret stays a 401.
|
|
541
|
+
const rejected = captureJson();
|
|
542
|
+
await handleEventsPost(
|
|
543
|
+
fakeRequest({ 'x-amalgm-webhook-secret': 'wrong-secret' }, JSON.stringify({ source: 'audit', event: 'ping' })),
|
|
544
|
+
rejected.send,
|
|
545
|
+
);
|
|
546
|
+
assert.equal(rejected.status, 401);
|
|
547
|
+
});
|
|
@@ -83,7 +83,7 @@ test('trigger update changes trigger fields without renaming parent automation',
|
|
|
83
83
|
test('automation runner records unified run and cell receipts', async () => {
|
|
84
84
|
const automation = buildSnapshot('automations').resources.automations
|
|
85
85
|
.find((item) => item.id === 'automation-webhook');
|
|
86
|
-
const run = await executeAutomation(automation, automation.triggers[0], {
|
|
86
|
+
const [run] = await executeAutomation(automation, automation.triggers[0], {
|
|
87
87
|
source: 'github',
|
|
88
88
|
event: 'push',
|
|
89
89
|
payload: { ref: 'refs/heads/main' },
|
|
@@ -137,7 +137,7 @@ test('workflow ctx exposes previous cell outputs through cells aliases', async (
|
|
|
137
137
|
})`,
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
const run = await executeAutomation(automation, automation.triggers[0], {
|
|
140
|
+
const [run] = await executeAutomation(automation, automation.triggers[0], {
|
|
141
141
|
source: 'test',
|
|
142
142
|
event: 'cells',
|
|
143
143
|
payload: {},
|
|
@@ -262,7 +262,7 @@ test('dotted toolbox action refs validate and run after the action is registered
|
|
|
262
262
|
triggers: [{ id: 'trigger-dotted-toolbox', kind: 'event', source: 'test', event: 'quote' }],
|
|
263
263
|
workflowText,
|
|
264
264
|
});
|
|
265
|
-
const run = await executeAutomation(automation, automation.triggers[0], {
|
|
265
|
+
const [run] = await executeAutomation(automation, automation.triggers[0], {
|
|
266
266
|
source: 'test',
|
|
267
267
|
event: 'quote',
|
|
268
268
|
payload: {},
|
|
@@ -32,9 +32,12 @@ function canonicalModel(modelId, harness) {
|
|
|
32
32
|
'opus': 'anthropic/claude-opus-4.8',
|
|
33
33
|
'opus1m': 'anthropic/claude-opus-4.8-1m',
|
|
34
34
|
'opus[1m]': 'anthropic/claude-opus-4.8-1m',
|
|
35
|
+
'fable': 'anthropic/claude-fable-5',
|
|
35
36
|
'claude-code-haiku': 'anthropic/claude-haiku-4.5',
|
|
36
37
|
'claude-code-sonnet': 'anthropic/claude-sonnet-4.6',
|
|
37
38
|
'claude-code-sonnet-45': 'anthropic/claude-sonnet-4.6',
|
|
39
|
+
'claude-code-fable': 'anthropic/claude-fable-5',
|
|
40
|
+
'claude-code-fable-5': 'anthropic/claude-fable-5',
|
|
38
41
|
'claude-code-opus': 'anthropic/claude-opus-4.8',
|
|
39
42
|
'claude-code-opus-45': 'anthropic/claude-opus-4.8',
|
|
40
43
|
'claude-code-opus-46': 'anthropic/claude-opus-4.8',
|
|
@@ -61,6 +64,7 @@ function canonicalModel(modelId, harness) {
|
|
|
61
64
|
'claude-code-haiku-4.5': 'anthropic/claude-haiku-4.5',
|
|
62
65
|
'claude-code-haiku-4-5': 'anthropic/claude-haiku-4.5',
|
|
63
66
|
'claude-haiku-4-5': 'anthropic/claude-haiku-4.5',
|
|
67
|
+
'claude-fable-5': 'anthropic/claude-fable-5',
|
|
64
68
|
'claude-sonnet-4-5': 'anthropic/claude-sonnet-4.6',
|
|
65
69
|
'claude-sonnet-4-6': 'anthropic/claude-sonnet-4.6',
|
|
66
70
|
'claude-opus-4-5': 'anthropic/claude-opus-4.8',
|
|
@@ -73,6 +77,7 @@ function canonicalModel(modelId, harness) {
|
|
|
73
77
|
'claude-opus-4.7-1m': 'anthropic/claude-opus-4.8-1m',
|
|
74
78
|
'anthropic/claude-haiku-4-5': 'anthropic/claude-haiku-4.5',
|
|
75
79
|
'anthropic/claude-haiku-4.5': 'anthropic/claude-haiku-4.5',
|
|
80
|
+
'anthropic/claude-fable-5': 'anthropic/claude-fable-5',
|
|
76
81
|
'anthropic/claude-sonnet-4-5': 'anthropic/claude-sonnet-4.6',
|
|
77
82
|
'anthropic/claude-sonnet-4-6': 'anthropic/claude-sonnet-4.6',
|
|
78
83
|
'anthropic/claude-sonnet-4.5': 'anthropic/claude-sonnet-4.6',
|
|
@@ -115,11 +120,16 @@ function cliModelFor({ harness, modelId, cliModel, reasoningEffort }) {
|
|
|
115
120
|
'anthropic/claude-opus-4.8-1m': 'opus[1m]',
|
|
116
121
|
'anthropic/claude-opus-4.7': 'opus',
|
|
117
122
|
'anthropic/claude-opus-4.7-1m': 'opus[1m]',
|
|
123
|
+
'anthropic/claude-fable-5': 'claude-fable-5',
|
|
118
124
|
'haiku': 'haiku',
|
|
119
125
|
'sonnet': 'sonnet',
|
|
120
126
|
'opus': 'opus',
|
|
121
127
|
'opus1m': 'opus[1m]',
|
|
122
128
|
'opus[1m]': 'opus[1m]',
|
|
129
|
+
'fable': 'claude-fable-5',
|
|
130
|
+
'claude-code-fable': 'claude-fable-5',
|
|
131
|
+
'claude-code-fable-5': 'claude-fable-5',
|
|
132
|
+
'claude-fable-5': 'claude-fable-5',
|
|
123
133
|
};
|
|
124
134
|
return aliases[canonical] || aliases[clean.toLowerCase()] || clean;
|
|
125
135
|
}
|
|
@@ -37,10 +37,11 @@ const MODEL_CATALOG = [
|
|
|
37
37
|
{ id: "amazon/nova-lite", name: "Nova Lite", provider: "amazon", contextWindow: 300000, maxOutput: 8192, inputPrice: 0.06, outputPrice: 0.24, cacheRead: null, cacheWrite: null, tags: [], released: "2024-12-03" },
|
|
38
38
|
{ id: "amazon/nova-micro", name: "Nova Micro", provider: "amazon", contextWindow: 128000, maxOutput: 8192, inputPrice: 0.035, outputPrice: 0.14, cacheRead: null, cacheWrite: null, tags: [], released: null },
|
|
39
39
|
{ id: "amazon/nova-pro", name: "Nova Pro", provider: "amazon", contextWindow: 300000, maxOutput: 8192, inputPrice: 0.8, outputPrice: 3.2, cacheRead: null, cacheWrite: null, tags: [], released: "2024-12-03" },
|
|
40
|
-
// ── anthropic (
|
|
40
|
+
// ── anthropic (17) ─────────────────────────────────────────
|
|
41
41
|
{ id: "anthropic/claude-3-haiku", name: "Claude 3 Haiku", provider: "anthropic", contextWindow: 200000, maxOutput: 4096, inputPrice: 0.25, outputPrice: 1.25, cacheRead: 0.03, cacheWrite: 0.3, tags: ["tool-use", "vision", "explicit-caching"], released: "2023-03-01" },
|
|
42
42
|
{ id: "anthropic/claude-3.5-haiku", name: "Claude 3.5 Haiku", provider: "anthropic", contextWindow: 200000, maxOutput: 8192, inputPrice: 0.8, outputPrice: 4, cacheRead: 0.08, cacheWrite: 1, tags: ["file-input", "tool-use", "vision", "explicit-caching"], released: "2023-11-06" },
|
|
43
43
|
{ id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet", provider: "anthropic", contextWindow: 200000, maxOutput: 8192, inputPrice: 3, outputPrice: 15, cacheRead: 0.3, cacheWrite: 3.75, tags: ["file-input", "reasoning", "tool-use", "vision", "explicit-caching"], released: "2024-01-25" },
|
|
44
|
+
{ id: "anthropic/claude-fable-5", name: "Claude Fable 5", provider: "anthropic", contextWindow: 1000000, maxOutput: 128000, inputPrice: 10, outputPrice: 50, cacheRead: 1, cacheWrite: 12.5, tags: ["tool-use", "reasoning", "vision", "file-input", "explicit-caching", "web-search"], released: null },
|
|
44
45
|
{ id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5", provider: "anthropic", contextWindow: 200000, maxOutput: 64000, inputPrice: 1, outputPrice: 5, cacheRead: 0.1, cacheWrite: 1.25, tags: ["file-input", "reasoning", "tool-use", "vision", "explicit-caching"], released: "2025-10-15" },
|
|
45
46
|
{ id: "anthropic/claude-opus-4", name: "Claude Opus 4", provider: "anthropic", contextWindow: 200000, maxOutput: 32000, inputPrice: 15, outputPrice: 75, cacheRead: 1.5, cacheWrite: 18.75, tags: ["file-input", "reasoning", "tool-use", "vision", "explicit-caching"], released: "2025-08-05" },
|
|
46
47
|
{ id: "anthropic/claude-opus-4.1", name: "Claude Opus 4.1", provider: "anthropic", contextWindow: 200000, maxOutput: 32000, inputPrice: 15, outputPrice: 75, cacheRead: 1.5, cacheWrite: 18.75, tags: ["file-input", "reasoning", "tool-use", "vision", "explicit-caching"], released: "2025-05-22" },
|