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.
@@ -91,6 +91,7 @@ function summarizeAutomation(automation) {
91
91
  workflowIds: automation.workflowIds,
92
92
  triggers: automation.triggers.map(summarizeTrigger),
93
93
  workflow: summarizeWorkflow(automation.workflow || automation.workflows?.[0]),
94
+ workflows: (automation.workflows || []).map(summarizeWorkflow),
94
95
  updatedAt: automation.updatedAt,
95
96
  };
96
97
  }
@@ -214,7 +215,7 @@ module.exports = [
214
215
  {
215
216
  name: 'automations_create',
216
217
  description:
217
- 'Create a local Automation. An automation is one or more triggers plus one workflow. It is stored on the user computer in the Local Live SQLite store and runs locally. Scheduled/event triggers do not wake an agent unless the workflow explicitly calls an agent/tool.',
218
+ 'Create a local Automation. An automation is one or more triggers plus one or more workflows; every trigger firing runs each enabled workflow and records one run per workflow. It is stored on the user computer in the Local Live SQLite store and runs locally. Scheduled/event triggers do not wake an agent unless a workflow explicitly calls an agent/tool.',
218
219
  inputSchema: {
219
220
  type: 'object',
220
221
  properties: {
@@ -252,6 +253,27 @@ module.exports = [
252
253
  },
253
254
  workflowText: { type: 'string', description: WORKFLOW_DESCRIPTION },
254
255
  workflow: { type: 'string', description: 'Alias for workflowText.' },
256
+ workflows: {
257
+ type: 'array',
258
+ description: 'Multiple workflows for this automation. Each item is a workflow script string or `{ name, workflowText, enabled, allowlist, limits }`. Every trigger firing runs each enabled workflow in order and records one run per workflow. Use this OR workflowText, not both.',
259
+ items: {
260
+ anyOf: [
261
+ { type: 'string', description: WORKFLOW_DESCRIPTION },
262
+ {
263
+ type: 'object',
264
+ properties: {
265
+ id: { type: 'string', description: 'Optional stable workflow id.' },
266
+ name: { type: 'string' },
267
+ description: { type: 'string' },
268
+ enabled: { type: 'boolean' },
269
+ workflowText: { type: 'string', description: WORKFLOW_DESCRIPTION },
270
+ allowlist: { type: 'object' },
271
+ limits: { type: 'object' },
272
+ },
273
+ },
274
+ ],
275
+ },
276
+ },
255
277
  allowlist: { type: 'object', description: 'Workflow permissions: localCompute, network, secrets, actions.' },
256
278
  limits: { type: 'object', description: 'Workflow limits: maxConcurrentRuns, queueLimit, cellTimeoutMs, codeTimeoutMs.' },
257
279
  verbose: { type: 'boolean', description: 'Set false for a concise response with ids, status, triggers, webhook URL/secret, and workflow summary. Defaults to true.' },
@@ -260,13 +282,14 @@ module.exports = [
260
282
  },
261
283
  async handler(input = {}) {
262
284
  try {
263
- const workflowText = input.workflowText || input.workflow;
264
- if (typeof workflowText !== 'string' || !workflowText.trim()) {
265
- return errorResult('workflowText or workflow is required.');
285
+ const workflowText = input.workflowText || (typeof input.workflow === 'string' ? input.workflow : undefined);
286
+ const hasWorkflowsArray = Array.isArray(input.workflows) && input.workflows.length > 0;
287
+ if (!hasWorkflowsArray && (typeof workflowText !== 'string' || !workflowText.trim())) {
288
+ return errorResult('workflowText, workflow, or workflows is required.');
266
289
  }
267
290
  const automation = createAutomation({
268
291
  ...input,
269
- workflowText,
292
+ ...(workflowText ? { workflowText } : {}),
270
293
  }, { source: 'automations_create' });
271
294
  return textResult(`Automation created.\n\n${JSON.stringify(automationResponse(automation, {
272
295
  verbose: input.verbose !== false,
@@ -333,7 +356,7 @@ module.exports = [
333
356
  {
334
357
  name: 'automations_update',
335
358
  description:
336
- 'Update a local automation. You can edit metadata, enabled state, replace triggers, or update the workflow source/allowlist/limits. The next trigger firing uses the updated workflow.',
359
+ 'Update a local automation. You can edit metadata, enabled state, replace triggers, update the primary workflow source/allowlist/limits, or replace the full workflows array. Existing triggers and workflows are matched by id (then fingerprint/position) so webhook secrets, schedule clocks, and run-history linkage are preserved. The next trigger firing uses the updated workflows.',
337
360
  inputSchema: {
338
361
  type: 'object',
339
362
  properties: {
@@ -343,8 +366,29 @@ module.exports = [
343
366
  enabled: { type: 'boolean' },
344
367
  projectPath: { type: 'string' },
345
368
  triggers: { type: 'array', description: TRIGGER_DESCRIPTION },
346
- workflowText: { type: 'string', description: WORKFLOW_DESCRIPTION },
369
+ workflowText: { type: 'string', description: `${WORKFLOW_DESCRIPTION}\n\nApplies to the primary (first) workflow.` },
347
370
  workflow: { type: 'string', description: 'Alias for workflowText.' },
371
+ workflows: {
372
+ type: 'array',
373
+ description: 'Replace the full ordered workflows array. Items are workflow script strings or `{ id, name, workflowText, enabled, allowlist, limits }`; include ids from automations_get to update workflows in place rather than recreating them.',
374
+ items: {
375
+ anyOf: [
376
+ { type: 'string' },
377
+ {
378
+ type: 'object',
379
+ properties: {
380
+ id: { type: 'string' },
381
+ name: { type: 'string' },
382
+ description: { type: 'string' },
383
+ enabled: { type: 'boolean' },
384
+ workflowText: { type: 'string' },
385
+ allowlist: { type: 'object' },
386
+ limits: { type: 'object' },
387
+ },
388
+ },
389
+ ],
390
+ },
391
+ },
348
392
  allowlist: { type: 'object' },
349
393
  limits: { type: 'object' },
350
394
  verbose: { type: 'boolean', description: 'Set false for a concise response. Defaults to true.' },
@@ -397,7 +441,7 @@ module.exports = [
397
441
  },
398
442
  async handler({ automation_id, trigger_id, payload, verbose } = {}) {
399
443
  try {
400
- const run = await executeAutomationById(automation_id, {
444
+ const runs = await executeAutomationById(automation_id, {
401
445
  id: `manual:${Date.now()}`,
402
446
  source: 'amalgm.manual',
403
447
  event: 'run_now',
@@ -405,8 +449,9 @@ module.exports = [
405
449
  headers: {},
406
450
  receivedAt: new Date().toISOString(),
407
451
  }, { triggerId: trigger_id });
408
- return textResult(`Automation run started.\n\n${JSON.stringify(
409
- verbose === false ? summarizeRun(run) : run,
452
+ const payloadOut = verbose === false ? runs.map(summarizeRun) : runs;
453
+ return textResult(`Automation ran ${runs.length} workflow${runs.length === 1 ? '' : 's'}.\n\n${JSON.stringify(
454
+ runs.length === 1 ? payloadOut[0] : payloadOut,
410
455
  null,
411
456
  2,
412
457
  )}`);
@@ -10,13 +10,24 @@ const {
10
10
  extractSignature,
11
11
  matchAllBySignature,
12
12
  matchBySignature,
13
+ matchesEventRef,
13
14
  pickSourceLabel,
14
15
  collectPassthroughHeaders,
15
16
  } = require('./matcher');
16
- const { listEventTriggersForIngress, markTriggerFired } = require('../automations/store');
17
+ const { listEventTriggersForIngress, markTriggerFired, recordAutomationRun } = require('../automations/store');
17
18
  const { executeAutomationForTrigger } = require('../automations/runner');
18
19
  const ring = require('./ring-buffer');
19
20
 
21
+ /**
22
+ * Sanitize a sender-declared source/event ref part.
23
+ */
24
+ function cleanRefPart(value) {
25
+ if (typeof value !== 'string') return '';
26
+ const trimmed = value.trim();
27
+ if (!trimmed || trimmed.length > 128) return '';
28
+ return trimmed;
29
+ }
30
+
20
31
  /**
21
32
  * Read the raw request body as a string.
22
33
  */
@@ -30,6 +41,42 @@ async function readRawBody(req) {
30
41
  });
31
42
  }
32
43
 
44
+ function dayStamp(date = new Date()) {
45
+ return date.toISOString().slice(0, 10).replace(/-/g, '');
46
+ }
47
+
48
+ /**
49
+ * Record an authenticated-but-unmatched delivery as a small terminal
50
+ * 'unmatched' run on each authenticated automation. The run id is
51
+ * deterministic per automation + ref + day, so repeated deliveries of the
52
+ * same unmatched ref refresh ONE row (its events array logs each receipt)
53
+ * instead of spamming run history and the live event stream — a sender
54
+ * holding one secret cannot flood the store with junk refs.
55
+ */
56
+ function recordUnmatchedEventRuns(authenticated, eventSource, eventName, timestamp) {
57
+ const refSlug = `${eventSource}.${eventName}`.toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 80);
58
+ const seenAutomations = new Set();
59
+ for (const trigger of authenticated) {
60
+ if (!trigger.automationId || seenAutomations.has(trigger.automationId)) continue;
61
+ seenAutomations.add(trigger.automationId);
62
+ try {
63
+ recordAutomationRun(trigger.automationId, {
64
+ runId: `unmatched-${trigger.automationId}-${refSlug}-${dayStamp()}`,
65
+ automationId: trigger.automationId,
66
+ triggerId: trigger.id,
67
+ triggerName: trigger.name,
68
+ status: 'unmatched',
69
+ startedAt: timestamp,
70
+ finishedAt: timestamp,
71
+ output: { received: { source: eventSource, event: eventName, receivedAt: timestamp } },
72
+ error: `Received ${eventSource}.${eventName} — authenticated, but no trigger selector matched (trigger "${trigger.name}" wants ${trigger.source || '*'}.${trigger.event || '*'}).`,
73
+ }, { source: 'automation_runs:event-unmatched' });
74
+ } catch (error) {
75
+ console.warn('[AmalgmMCP:Events] Failed to record unmatched event run:', error.message || error);
76
+ }
77
+ }
78
+ }
79
+
33
80
  /**
34
81
  * Handle a POST /events request. Assumes CORS + method checking has already
35
82
  * happened. Writes the response via `sendJson`.
@@ -47,12 +94,49 @@ async function handleEventsPost(req, sendJson) {
47
94
 
48
95
  const triggers = listEventTriggersForIngress();
49
96
  const githubEvent = req.headers['x-github-event'];
50
- const eventSource = pickSourceLabel(req.headers);
51
- const eventName =
97
+
98
+ let parsedBody;
99
+ try {
100
+ parsedBody = JSON.parse(rawBody);
101
+ } catch {
102
+ parsedBody = rawBody;
103
+ }
104
+
105
+ // Vendor webhooks (GitHub/Stripe/Linear/GitLab) get their ref from vendor
106
+ // headers. Generic senders carry no vendor headers, so let them declare
107
+ // their own ref via x-amalgm-source/x-amalgm-event headers or body
108
+ // source/event fields — otherwise custom refs like "audit.ping" are
109
+ // permanently unmatchable. This is safe to read before matching: a sender
110
+ // still only matches triggers whose per-trigger secret verifies its proof
111
+ // (HMAC binds the body bytes; token compare authenticates the sender).
112
+ const vendorSource = pickSourceLabel(req.headers);
113
+ const vendorEvent =
52
114
  githubEvent ||
53
115
  req.headers['x-linear-event'] ||
54
116
  req.headers['x-gitlab-event'] ||
55
- 'webhook';
117
+ null;
118
+ let eventSource = vendorSource;
119
+ let eventName = vendorEvent || 'webhook';
120
+ let fallbackRef = null;
121
+ if (vendorSource === 'external' && !vendorEvent) {
122
+ const headerSource = cleanRefPart(req.headers['x-amalgm-source']);
123
+ const headerEvent = cleanRefPart(req.headers['x-amalgm-event']);
124
+ const bodySource = cleanRefPart(typeof parsedBody === 'object' ? parsedBody?.source : null);
125
+ const bodyEvent = cleanRefPart(typeof parsedBody === 'object' ? parsedBody?.event : null);
126
+ if (headerSource || headerEvent) {
127
+ // Explicit x-amalgm-* headers are an unambiguous declaration.
128
+ eventSource = headerSource || 'external';
129
+ eventName = headerEvent || 'webhook';
130
+ } else if (bodySource || bodyEvent) {
131
+ // Body source/event are often incidental keys in third-party payloads
132
+ // (e.g. {"event": "user.signup"}). Use them as the primary ref, but
133
+ // keep the legacy external.webhook ref as a matching fallback so
134
+ // pre-existing generic triggers pinned to it keep firing.
135
+ eventSource = bodySource || 'external';
136
+ eventName = bodyEvent || 'webhook';
137
+ fallbackRef = { source: 'external', event: 'webhook' };
138
+ }
139
+ }
56
140
 
57
141
  // GitHub webhook handshake: acknowledge verified ping events immediately.
58
142
  if (githubEvent === 'ping') {
@@ -65,29 +149,27 @@ async function handleEventsPost(req, sendJson) {
65
149
  return sendJson(200, { ok: true, message: 'pong' });
66
150
  }
67
151
 
68
- const matchedTriggers = matchAllBySignature(
69
- triggers,
70
- signature,
71
- rawBody,
72
- { source: eventSource, event: eventName },
73
- );
74
- if (!matchedTriggers?.length) {
75
- console.warn(`[AmalgmMCP:Events] No trigger matched ${eventSource}.${eventName}`);
76
- return sendJson(401, { error: 'Invalid webhook signature or source/event — no matching trigger' });
152
+ // Authenticate first, then filter by source/event, so a bad secret (401)
153
+ // is distinguishable from a valid-but-unmatched ref (202). Vendors treat
154
+ // 401 as delivery failure and may auto-disable the webhook.
155
+ const authenticated = matchAllBySignature(triggers, signature, rawBody);
156
+ if (!authenticated?.length) {
157
+ console.warn('[AmalgmMCP:Events] Webhook signature did not verify against any trigger');
158
+ return sendJson(401, { error: 'Invalid webhook signature' });
77
159
  }
78
160
 
79
- const eventHeaders = collectPassthroughHeaders(req.headers);
80
-
81
- let parsedBody;
82
- try {
83
- parsedBody = JSON.parse(rawBody);
84
- } catch {
85
- parsedBody = rawBody;
161
+ let matchedTriggers = authenticated.filter((trigger) => (
162
+ matchesEventRef(trigger, { source: eventSource, event: eventName })
163
+ ));
164
+ if (!matchedTriggers.length && fallbackRef) {
165
+ const fallbackMatches = authenticated.filter((trigger) => matchesEventRef(trigger, fallbackRef));
166
+ if (fallbackMatches.length > 0) {
167
+ eventSource = fallbackRef.source;
168
+ eventName = fallbackRef.event;
169
+ matchedTriggers = fallbackMatches;
170
+ }
86
171
  }
87
-
88
- console.log(
89
- `[AmalgmMCP:Events] Matched ${matchedTriggers.length} trigger(s) for ${eventSource}.${eventName}`,
90
- );
172
+ const eventHeaders = collectPassthroughHeaders(req.headers);
91
173
 
92
174
  const eventObj = {
93
175
  source: eventSource,
@@ -96,17 +178,48 @@ async function handleEventsPost(req, sendJson) {
96
178
  headers: eventHeaders,
97
179
  timestamp: new Date().toISOString(),
98
180
  };
181
+
182
+ if (!matchedTriggers.length) {
183
+ ring.push({ ...eventObj, disposition: 'unmatched' });
184
+ console.warn(`[AmalgmMCP:Events] Authenticated event ${eventSource}.${eventName} matched no trigger selector`);
185
+ // Receiving an authenticated event counts as a run: surface it in the
186
+ // automation's run history instead of a separate event log.
187
+ recordUnmatchedEventRuns(authenticated, eventSource, eventName, eventObj.timestamp);
188
+ return sendJson(202, {
189
+ ok: true,
190
+ triggered: false,
191
+ reason: `No enabled trigger matches ${eventSource}.${eventName}`,
192
+ authenticatedTriggerIds: authenticated.map((trigger) => trigger.id),
193
+ });
194
+ }
195
+
196
+ console.log(
197
+ `[AmalgmMCP:Events] Matched ${matchedTriggers.length} trigger(s) for ${eventSource}.${eventName}`,
198
+ );
99
199
  ring.push(eventObj);
100
200
 
101
201
  for (const matchedTrigger of matchedTriggers) {
102
- executeAutomationForTrigger(matchedTrigger.id, eventObj).catch((err) => {
202
+ // Isolate per trigger: a synchronous throw (missing workflow, IR drift)
203
+ // must not skip the remaining matched triggers or 5xx the sender.
204
+ try {
205
+ executeAutomationForTrigger(matchedTrigger.id, eventObj).catch((err) => {
206
+ console.error(
207
+ `[AmalgmMCP:Events] Automation workflow failed for trigger "${matchedTrigger.name}":`,
208
+ err.message,
209
+ );
210
+ });
211
+ } catch (err) {
103
212
  console.error(
104
- `[AmalgmMCP:Events] Automation workflow failed for trigger "${matchedTrigger.name}":`,
105
- err.message,
213
+ `[AmalgmMCP:Events] Automation could not start for trigger "${matchedTrigger.name}":`,
214
+ err.message || err,
106
215
  );
107
- });
216
+ }
108
217
 
109
- markTriggerFired(matchedTrigger.id, { source: 'events:ingress' });
218
+ try {
219
+ markTriggerFired(matchedTrigger.id, { source: 'events:ingress' });
220
+ } catch (err) {
221
+ console.error(`[AmalgmMCP:Events] Failed to mark trigger fired "${matchedTrigger.name}":`, err.message || err);
222
+ }
110
223
  }
111
224
 
112
225
  return sendJson(200, {
@@ -43,10 +43,10 @@ function extractSignature(headers) {
43
43
  }
44
44
 
45
45
  /**
46
- * Compute the expected `sha256=<hex>` HMAC over `rawBody` using `secret`.
46
+ * Compute the expected `<algorithm>=<hex>` HMAC over `rawBody` using `secret`.
47
47
  */
48
- function computeSignature(secret, rawBody) {
49
- return 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
48
+ function computeSignature(secret, rawBody, algorithm = 'sha256') {
49
+ return `${algorithm}=` + crypto.createHmac(algorithm, secret).update(rawBody).digest('hex');
50
50
  }
51
51
 
52
52
  /**
@@ -74,7 +74,12 @@ function verifiesSignature(trigger, signature, rawBody) {
74
74
  return timingSafeEqual(signature.value, trigger.secret);
75
75
  }
76
76
 
77
- const expected = computeSignature(trigger.secret, rawBody);
77
+ // Legacy x-hub-signature carries `sha1=<hex>` — match the algorithm the
78
+ // sender used instead of always computing sha256 (which can never verify).
79
+ const algorithm = typeof signature.value === 'string' && signature.value.startsWith('sha1=')
80
+ ? 'sha1'
81
+ : 'sha256';
82
+ const expected = computeSignature(trigger.secret, rawBody, algorithm);
78
83
  return timingSafeEqual(signature.value, expected);
79
84
  }
80
85
 
@@ -27,6 +27,10 @@ async function handleAutomationRoutes(ctx) {
27
27
  await automationsRest.handleRunNow(await ctx.readJsonBody(), ctx.sendJson);
28
28
  return true;
29
29
  }
30
+ if (ctx.pathname === '/automations/runs' && ctx.method === 'POST') {
31
+ await automationsRest.handleRuns(await ctx.readJsonBody(), ctx.sendJson);
32
+ return true;
33
+ }
30
34
  if (ctx.pathname === '/automations/validate' && ctx.method === 'POST') {
31
35
  await automationsRest.handleValidate(await ctx.readJsonBody(), ctx.sendJson);
32
36
  return true;
@@ -130,6 +130,18 @@ function migrate(database = openLocalDb()) {
130
130
  CREATE INDEX IF NOT EXISTS automation_triggers_due_idx
131
131
  ON automation_triggers(kind, enabled, next_run_at);
132
132
 
133
+ CREATE TABLE IF NOT EXISTS automation_workflow_links (
134
+ automation_id TEXT NOT NULL,
135
+ workflow_id TEXT NOT NULL,
136
+ position INTEGER NOT NULL DEFAULT 0,
137
+ PRIMARY KEY (automation_id, workflow_id),
138
+ FOREIGN KEY(automation_id) REFERENCES automation_definitions(id) ON DELETE CASCADE,
139
+ FOREIGN KEY(workflow_id) REFERENCES automation_workflows(id) ON DELETE CASCADE
140
+ );
141
+
142
+ CREATE INDEX IF NOT EXISTS automation_workflow_links_workflow_idx
143
+ ON automation_workflow_links(workflow_id);
144
+
133
145
  CREATE TABLE IF NOT EXISTS automation_runs (
134
146
  id TEXT PRIMARY KEY,
135
147
  automation_id TEXT NOT NULL,
@@ -19,9 +19,9 @@ function readResource(resource, cache) {
19
19
  cache.triggers ||= require('../automations/store').listTriggers();
20
20
  return cache.triggers;
21
21
  case 'automation_runs':
22
- return require('../automations/store').listAutomationRuns({ limit: 100 });
22
+ return require('../automations/store').listAutomationRuns({ limit: 300 });
23
23
  case 'workflow_cell_runs':
24
- return require('../automations/store').listWorkflowCellRuns({ limit: 100 });
24
+ return require('../automations/store').listWorkflowCellRuns({ limit: 300 });
25
25
  case 'tasks':
26
26
  return require('../tasks/store').loadTasks().tasks;
27
27
  case 'task_runs':