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.
- package/README.md +5 -3
- package/lib/cli.js +92 -4
- package/lib/shared-realtime-tunnel.js +82 -19
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/automations/deliveries.js +276 -0
- package/runtime/scripts/amalgm-mcp/automations/rest.js +51 -0
- package/runtime/scripts/amalgm-mcp/automations/scheduler.js +11 -67
- package/runtime/scripts/amalgm-mcp/automations/store.js +67 -20
- package/runtime/scripts/amalgm-mcp/events/ingress.js +69 -215
- package/runtime/scripts/amalgm-mcp/events/processor.js +189 -0
- package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +41 -0
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +107 -1
- package/runtime/scripts/amalgm-mcp/lib/generated/email-render.json +10 -2
- package/runtime/scripts/amalgm-mcp/observer/README.md +8 -6
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +262 -22
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +86 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +4 -1
- package/runtime/scripts/amalgm-mcp/registration/service.js +102 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +19 -5
- package/runtime/scripts/amalgm-mcp/registry/index.js +16 -1
- package/runtime/scripts/amalgm-mcp/registry/store.js +58 -7
- package/runtime/scripts/amalgm-mcp/server/routes/automations.js +8 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +56 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +14 -0
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +1 -1
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +2 -2
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/citations.html +5 -5
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/links.html +3 -3
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/notification-shell.html +1 -1
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/share-invite.html +13 -0
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/text.html +1 -1
- package/runtime/scripts/amalgm-mcp/tests/email-render.test.js +17 -1
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +189 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-content.test.js +126 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +126 -11
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +20 -6
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +8 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +8 -1
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +630 -102
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +45 -10
- package/runtime/scripts/amalgm-mcp/tests/workspace-materialize.test.js +54 -0
- package/runtime/scripts/amalgm-mcp/workspace/materialize.js +292 -0
- package/runtime/scripts/chat-core/contract.js +21 -3
- package/runtime/scripts/chat-core/engine.js +8 -0
- package/runtime/scripts/chat-core/server.js +11 -1
- package/runtime/scripts/chat-core/tests/workspace-preflight.test.js +116 -0
- package/runtime/scripts/chat-core/workspace-preflight.js +89 -0
- 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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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
|
-
* -
|
|
18
|
-
*
|
|
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 {
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
1155
|
+
let trigger = automation.triggers.find((item) => item.id === row.id);
|
|
1154
1156
|
if (!trigger) continue;
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
|
4
|
+
* /events — durable webhook receipt.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 = {
|
|
84
|
+
module.exports = {
|
|
85
|
+
handleEventsPost,
|
|
86
|
+
readRawBody,
|
|
87
|
+
storedHeaders,
|
|
88
|
+
};
|