amalgm 0.1.246 → 0.1.247

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.
Files changed (49) hide show
  1. package/README.md +5 -3
  2. package/lib/cli.js +92 -4
  3. package/lib/shared-realtime-tunnel.js +82 -19
  4. package/package.json +1 -1
  5. package/runtime/scripts/amalgm-mcp/automations/deliveries.js +276 -0
  6. package/runtime/scripts/amalgm-mcp/automations/rest.js +51 -0
  7. package/runtime/scripts/amalgm-mcp/automations/scheduler.js +11 -67
  8. package/runtime/scripts/amalgm-mcp/automations/store.js +67 -20
  9. package/runtime/scripts/amalgm-mcp/events/ingress.js +69 -215
  10. package/runtime/scripts/amalgm-mcp/events/processor.js +189 -0
  11. package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +41 -0
  12. package/runtime/scripts/amalgm-mcp/lib/email-md.js +107 -1
  13. package/runtime/scripts/amalgm-mcp/lib/generated/email-render.json +10 -2
  14. package/runtime/scripts/amalgm-mcp/observer/README.md +8 -6
  15. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +262 -22
  16. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +86 -0
  17. package/runtime/scripts/amalgm-mcp/registration/index.js +4 -1
  18. package/runtime/scripts/amalgm-mcp/registration/service.js +102 -0
  19. package/runtime/scripts/amalgm-mcp/registration/tree.js +19 -5
  20. package/runtime/scripts/amalgm-mcp/registry/index.js +16 -1
  21. package/runtime/scripts/amalgm-mcp/registry/store.js +58 -7
  22. package/runtime/scripts/amalgm-mcp/server/routes/automations.js +8 -0
  23. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +56 -0
  24. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +14 -0
  25. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +1 -1
  26. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +2 -2
  27. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/citations.html +5 -5
  28. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/links.html +3 -3
  29. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/notification-shell.html +1 -1
  30. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/share-invite.html +13 -0
  31. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/text.html +1 -1
  32. package/runtime/scripts/amalgm-mcp/tests/email-render.test.js +17 -1
  33. package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +189 -0
  34. package/runtime/scripts/amalgm-mcp/tests/entity-content.test.js +126 -0
  35. package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +118 -0
  36. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +126 -11
  37. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +20 -6
  38. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +8 -0
  39. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +8 -1
  40. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +630 -102
  41. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +45 -10
  42. package/runtime/scripts/amalgm-mcp/tests/workspace-materialize.test.js +54 -0
  43. package/runtime/scripts/amalgm-mcp/workspace/materialize.js +292 -0
  44. package/runtime/scripts/chat-core/contract.js +21 -3
  45. package/runtime/scripts/chat-core/engine.js +8 -0
  46. package/runtime/scripts/chat-core/server.js +11 -1
  47. package/runtime/scripts/chat-core/tests/workspace-preflight.test.js +116 -0
  48. package/runtime/scripts/chat-core/workspace-preflight.js +89 -0
  49. package/runtime/scripts/local-gateway.js +0 -1
@@ -3,41 +3,32 @@
3
3
  /**
4
4
  * Automation scheduler.
5
5
  *
6
- * Scheduled delivery is now just one trigger kind. The scheduler claims due
7
- * scheduled triggers from the unified automation store and runs their
8
- * workflows; it does not wake an agent unless the workflow explicitly calls
9
- * an agent/tool cell.
6
+ * Scheduled delivery is now just one trigger kind. The scheduler materializes
7
+ * every due slot into the durable local delivery inbox, then asks the shared
8
+ * delivery worker to drain it.
10
9
  *
11
10
  * Reliability invariants:
12
- * - next_run_at is persisted, so triggers due while the process was down are
13
- * claimed by the first poll after boot/wake (single-shot catch-up).
11
+ * - next_run_at is persisted, so every slot due while the process was down is
12
+ * materialized by bounded batches after boot/wake.
14
13
  * - Boot runs a repair pass that re-arms enabled triggers whose next_run_at
15
14
  * was lost (the claim query skips NULL rows, so without repair those
16
15
  * triggers are permanently dormant).
17
- * - One automation failing to start must never abort the rest of a claim
18
- * batch: claims have already advanced next_run_at, so a lost claim is a
19
- * silently missed run.
16
+ * - A slot advances next_run_at only in the same transaction that inserts its
17
+ * delivery. Workflow failure leaves that delivery pending for retry.
20
18
  * - Each tick expires stale "running" run rows so crashes and hung cells do
21
19
  * not leave phantom in-progress runs in history.
22
20
  */
23
21
 
24
- const crypto = require('crypto');
25
-
26
22
  const { SCHEDULER_INTERVAL_MS } = require('../config');
27
23
  const {
28
24
  claimDueScheduledTriggers,
29
25
  expireStaleAutomationRuns,
30
26
  getSchedulerHeartbeat,
31
27
  pruneAutomationRuns,
32
- recordAutomationRun,
33
28
  recordSchedulerHeartbeat,
34
29
  repairScheduledTriggers,
35
30
  } = require('./store');
36
- const { executeAutomation } = require('./runner');
37
-
38
- // Runs claimed more than this far past their slot are stamped as catch-up
39
- // runs so workflows and the UI can distinguish "on time" from "made up".
40
- const CATCH_UP_GRACE_MS = 5 * 60_000;
31
+ const { drainAutomationDeliveries } = require('./deliveries');
41
32
  const MAINTENANCE_INTERVAL_MS = 60 * 60_000;
42
33
 
43
34
  let schedulerTimer = null;
@@ -80,26 +71,6 @@ function startAutomationScheduler() {
80
71
  schedulerTimer = setInterval(pollAutomationScheduler, SCHEDULER_INTERVAL_MS);
81
72
  }
82
73
 
83
- function recordLostClaim(claim, error) {
84
- try {
85
- const timestamp = new Date().toISOString();
86
- recordAutomationRun(claim.automation.id, {
87
- runId: crypto.randomUUID(),
88
- automationId: claim.automation.id,
89
- automationName: claim.automation.name,
90
- triggerId: claim.trigger?.id || null,
91
- triggerName: claim.trigger?.name || null,
92
- workflowId: claim.automation.workflowId || null,
93
- startedAt: timestamp,
94
- finishedAt: timestamp,
95
- status: 'failed',
96
- error: `Scheduled run could not start: ${error.message || error}`,
97
- }, { source: 'automation_runs:workflow:start-failed' });
98
- } catch (recordError) {
99
- console.error('[AmalgmMCP:Automations] Failed to record lost claim:', recordError.message || recordError);
100
- }
101
- }
102
-
103
74
  function pollAutomationScheduler() {
104
75
  if (isPolling) return;
105
76
  isPolling = true;
@@ -111,36 +82,9 @@ function pollAutomationScheduler() {
111
82
  schedulerStatus.lastClaimed = claims.length;
112
83
  schedulerStatus.lastError = null;
113
84
 
114
- for (const claim of claims) {
115
- if (!claim?.automation || !claim?.trigger) continue;
116
- const scheduledFor = claim.scheduledFor || now.toISOString();
117
- const lateMs = Math.max(0, now.getTime() - new Date(scheduledFor).getTime());
118
- const eventObj = {
119
- id: scheduledFor,
120
- source: 'amalgm.scheduler',
121
- event: 'scheduled',
122
- payload: {
123
- scheduledFor,
124
- catchUp: lateMs > CATCH_UP_GRACE_MS,
125
- lateMs,
126
- triggerId: claim.trigger.id,
127
- automationId: claim.automation.id,
128
- },
129
- headers: {},
130
- receivedAt: now.toISOString(),
131
- };
132
- // The claim already advanced next_run_at, so this run will not be
133
- // retried — isolate every failure (sync throws included) per claim and
134
- // leave a failed run row instead of losing the run invisibly.
135
- try {
136
- executeAutomation(claim.automation, claim.trigger, eventObj).catch((error) => {
137
- console.error(`[AmalgmMCP:Automations] Automation ${claim.automation.id} failed:`, error.message);
138
- });
139
- } catch (error) {
140
- console.error(`[AmalgmMCP:Automations] Automation ${claim.automation.id} could not start:`, error.message || error);
141
- recordLostClaim(claim, error);
142
- }
143
- }
85
+ void drainAutomationDeliveries().catch((error) => {
86
+ console.error('[AmalgmMCP:Automations] Delivery drain failed:', error.message || error);
87
+ });
144
88
 
145
89
  runSchedulerMaintenance(now);
146
90
  } catch (error) {
@@ -1121,6 +1121,7 @@ function claimDueScheduledTriggers(now = new Date(), options = {}) {
1121
1121
  const db = openLocalDb();
1122
1122
  const nowDate = validDate(now) || new Date();
1123
1123
  const timestamp = nowDate.toISOString();
1124
+ const limit = Math.max(1, Math.min(Number(options.limit) || 100, 1000));
1124
1125
  const rows = db.prepare(`
1125
1126
  SELECT automation_triggers.*
1126
1127
  FROM automation_triggers
@@ -1148,29 +1149,75 @@ function claimDueScheduledTriggers(now = new Date(), options = {}) {
1148
1149
  const claims = [];
1149
1150
  db.transaction(() => {
1150
1151
  for (const row of rows) {
1152
+ if (claims.length >= limit) break;
1151
1153
  const automation = getAutomation(row.automation_id);
1152
1154
  if (!automation) continue;
1153
- const trigger = automation.triggers.find((item) => item.id === row.id);
1155
+ let trigger = automation.triggers.find((item) => item.id === row.id);
1154
1156
  if (!trigger) continue;
1155
- const fired = {
1156
- ...trigger,
1157
- lastFiredAt: timestamp,
1158
- updatedAt: timestamp,
1159
- };
1160
- if (fired.schedule?.kind === 'once') fired.enabled = false;
1161
- fired.nextRunAt = fired.enabled === false
1162
- ? null
1163
- : computeNextRunAt(fired, nowDate, { anchor: row.next_run_at });
1164
- upsertTriggerRow(db, fired);
1165
- claims.push({
1166
- automation: composeAutomation(
1167
- automation,
1168
- automation.triggers.map((item) => (item.id === fired.id ? fired : item)),
1169
- automation.workflows,
1170
- ),
1171
- trigger: fired,
1172
- scheduledFor: row.next_run_at,
1173
- });
1157
+ let scheduledFor = row.next_run_at;
1158
+
1159
+ // Materialize every overdue slot in bounded batches. If the batch fills,
1160
+ // next_run_at deliberately remains overdue so the next scheduler tick
1161
+ // continues from the exact first unmaterialized slot.
1162
+ while (
1163
+ claims.length < limit
1164
+ && scheduledFor
1165
+ && scheduledFor <= timestamp
1166
+ && trigger.enabled !== false
1167
+ ) {
1168
+ const lateMs = Math.max(0, nowDate.getTime() - new Date(scheduledFor).getTime());
1169
+ const deliveryId = `scheduled:${trigger.id}:${scheduledFor}`;
1170
+ db.prepare(`
1171
+ INSERT INTO automation_deliveries (
1172
+ id, kind, trigger_id, scheduled_for, payload_json, status,
1173
+ next_attempt_at, created_at, updated_at
1174
+ ) VALUES (?, 'scheduled', ?, ?, ?, 'pending', ?, ?, ?)
1175
+ ON CONFLICT(id) DO NOTHING
1176
+ `).run(
1177
+ deliveryId,
1178
+ trigger.id,
1179
+ scheduledFor,
1180
+ JSON.stringify({
1181
+ scheduledFor,
1182
+ catchUp: lateMs > 5 * 60_000,
1183
+ lateMs,
1184
+ triggerId: trigger.id,
1185
+ automationId: automation.id,
1186
+ }),
1187
+ timestamp,
1188
+ timestamp,
1189
+ timestamp,
1190
+ );
1191
+
1192
+ const fired = {
1193
+ ...trigger,
1194
+ lastFiredAt: scheduledFor,
1195
+ updatedAt: timestamp,
1196
+ };
1197
+ if (fired.schedule?.kind === 'once') {
1198
+ fired.enabled = false;
1199
+ fired.nextRunAt = null;
1200
+ } else {
1201
+ fired.nextRunAt = computeNextRunAt(
1202
+ fired,
1203
+ new Date(scheduledFor),
1204
+ { anchor: scheduledFor },
1205
+ );
1206
+ }
1207
+ upsertTriggerRow(db, fired);
1208
+ claims.push({
1209
+ deliveryId,
1210
+ automation: composeAutomation(
1211
+ automation,
1212
+ automation.triggers.map((item) => (item.id === fired.id ? fired : item)),
1213
+ automation.workflows,
1214
+ ),
1215
+ trigger: fired,
1216
+ scheduledFor,
1217
+ });
1218
+ trigger = fired;
1219
+ scheduledFor = fired.nextRunAt;
1220
+ }
1174
1221
  }
1175
1222
  })();
1176
1223
 
@@ -1,234 +1,88 @@
1
+ 'use strict';
2
+
1
3
  /**
2
- * /events — webhook ingress.
4
+ * /events — durable webhook receipt.
3
5
  *
4
- * Accepts raw POST body, requires a verified signature, and on a hit fires a
5
- * automation workflow run via automations/runner.js and returns 200 with
6
- * `{ ok, event, triggered }`.
6
+ * Success means the immutable envelope is in the local SQLite inbox. Workflow
7
+ * execution happens from that inbox and may outlive this HTTP request.
7
8
  */
8
9
 
9
- const {
10
- extractSignature,
11
- matchAllBySignature,
12
- matchBySignature,
13
- matchesEventRef,
14
- pickSourceLabel,
15
- collectPassthroughHeaders,
16
- } = require('./matcher');
17
- const { listEventTriggersForIngress, markTriggerFired, recordAutomationRun } = require('../automations/store');
18
- const { executeAutomationForTrigger } = require('../automations/runner');
19
- const ring = require('./ring-buffer');
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;
10
+ const { enqueueEventDelivery, drainAutomationDeliveries } = require('../automations/deliveries');
11
+
12
+ const MAX_EVENT_BODY_BYTES = 2 * 1024 * 1024;
13
+ const STORED_HEADERS = new Set([
14
+ 'authorization',
15
+ 'content-type',
16
+ 'stripe-signature',
17
+ 'x-amalgm-delivery-id',
18
+ 'x-amalgm-event',
19
+ 'x-amalgm-source',
20
+ 'x-amalgm-webhook-secret',
21
+ 'x-github-delivery',
22
+ 'x-github-event',
23
+ 'x-gitlab-event',
24
+ 'x-gitlab-token',
25
+ 'x-hub-signature',
26
+ 'x-hub-signature-256',
27
+ 'x-linear-event',
28
+ 'x-linear-signature',
29
+ 'x-webhook-secret',
30
+ 'x-webhook-signature',
31
+ ]);
32
+
33
+ function storedHeaders(headers = {}) {
34
+ const result = {};
35
+ for (const [key, value] of Object.entries(headers)) {
36
+ const normalized = key.toLowerCase();
37
+ if (!STORED_HEADERS.has(normalized)) continue;
38
+ if (Array.isArray(value)) result[normalized] = value.join(', ');
39
+ else if (value != null) result[normalized] = String(value);
40
+ }
41
+ return result;
29
42
  }
30
43
 
31
- /**
32
- * Read the raw request body as a string.
33
- */
34
44
  async function readRawBody(req) {
35
- return new Promise((resolve) => {
36
- let data = '';
37
- req.on('data', (chunk) => {
38
- data += chunk;
39
- });
40
- req.on('end', () => resolve(data));
41
- });
42
- }
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);
45
+ const chunks = [];
46
+ let bytes = 0;
47
+ for await (const chunk of req) {
48
+ const buffer = Buffer.from(chunk);
49
+ bytes += buffer.length;
50
+ if (bytes > MAX_EVENT_BODY_BYTES) {
51
+ const error = new Error(`Event body exceeds ${MAX_EVENT_BODY_BYTES} bytes`);
52
+ error.status = 413;
53
+ throw error;
76
54
  }
55
+ chunks.push(buffer);
77
56
  }
57
+ return Buffer.concat(chunks).toString('utf8');
78
58
  }
79
59
 
80
- /**
81
- * Handle a POST /events request. Assumes CORS + method checking has already
82
- * happened. Writes the response via `sendJson`.
83
- *
84
- * @param req - IncomingMessage
85
- * @param sendJson - (status, body) => void
86
- */
87
60
  async function handleEventsPost(req, sendJson) {
88
- const rawBody = await readRawBody(req);
89
- const signature = extractSignature(req.headers);
90
- if (!signature) {
91
- console.warn('[AmalgmMCP:Events] Rejected unsigned event request');
92
- return sendJson(401, { error: 'Webhook signature required' });
93
- }
94
-
95
- const triggers = listEventTriggersForIngress();
96
- const githubEvent = req.headers['x-github-event'];
97
-
98
- let parsedBody;
99
61
  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 =
114
- githubEvent ||
115
- req.headers['x-linear-event'] ||
116
- req.headers['x-gitlab-event'] ||
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
- }
140
-
141
- // GitHub webhook handshake: acknowledge verified ping events immediately.
142
- if (githubEvent === 'ping') {
143
- const pingTrigger = matchBySignature(triggers, signature, rawBody);
144
- if (!pingTrigger) {
145
- console.warn('[AmalgmMCP:Events] No trigger matched the GitHub ping signature');
146
- return sendJson(401, { error: 'Invalid webhook signature — no matching trigger' });
147
- }
148
- console.log('[AmalgmMCP:Events] GitHub ping — pong');
149
- return sendJson(200, { ok: true, message: 'pong' });
150
- }
151
-
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' });
159
- }
160
-
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
- }
171
- }
172
- const eventHeaders = collectPassthroughHeaders(req.headers);
173
-
174
- const eventObj = {
175
- source: eventSource,
176
- event: eventName,
177
- payload: parsedBody,
178
- headers: eventHeaders,
179
- timestamp: new Date().toISOString(),
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);
62
+ const rawBody = await readRawBody(req);
63
+ const headers = storedHeaders(req.headers);
64
+ const delivery = enqueueEventDelivery({
65
+ id: headers['x-amalgm-delivery-id'],
66
+ rawBody,
67
+ headers,
68
+ receivedAt: new Date().toISOString(),
69
+ });
70
+ void drainAutomationDeliveries().catch((error) => {
71
+ console.error('[AmalgmMCP:Events] Delivery drain failed:', error.message || error);
72
+ });
188
73
  return sendJson(202, {
189
74
  ok: true,
190
- triggered: false,
191
- reason: `No enabled trigger matches ${eventSource}.${eventName}`,
192
- authenticatedTriggerIds: authenticated.map((trigger) => trigger.id),
75
+ accepted: true,
76
+ deliveryId: delivery.id,
77
+ status: delivery.status,
193
78
  });
79
+ } catch (error) {
80
+ return sendJson(error.status || 500, { error: error.message || String(error) });
194
81
  }
195
-
196
- console.log(
197
- `[AmalgmMCP:Events] Matched ${matchedTriggers.length} trigger(s) for ${eventSource}.${eventName}`,
198
- );
199
- ring.push(eventObj);
200
-
201
- for (const matchedTrigger of matchedTriggers) {
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) {
212
- console.error(
213
- `[AmalgmMCP:Events] Automation could not start for trigger "${matchedTrigger.name}":`,
214
- err.message || err,
215
- );
216
- }
217
-
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
- }
223
- }
224
-
225
- return sendJson(200, {
226
- ok: true,
227
- event: eventObj,
228
- triggered: true,
229
- triggerCount: matchedTriggers.length,
230
- triggerIds: matchedTriggers.map((trigger) => trigger.id),
231
- });
232
82
  }
233
83
 
234
- module.exports = { handleEventsPost };
84
+ module.exports = {
85
+ handleEventsPost,
86
+ readRawBody,
87
+ storedHeaders,
88
+ };