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
package/README.md CHANGED
@@ -22,9 +22,11 @@ amalgm register path/to/file path/to/folder
22
22
 
23
23
  `amalgm register` watches the selected local ground and saves its entity
24
24
  records and file contents to the signed-in user's private Amalgm cloud
25
- resource. `amalgm add` is reserved for the inverse operation—bringing an
26
- existing cloud entity onto a machine—and is not available until
27
- materialization ships.
25
+ resource. Use `amalgm add <entity-uuid> [directory]` on another machine to
26
+ bring an existing cloud-backed workspace or file tree onto that machine. It
27
+ uses one verified current cloud snapshot, downloads its immutable file bytes,
28
+ preserves the UUIDs, and starts a local watcher. It does not replay old cloud
29
+ mutations to rebuild the tree.
28
30
 
29
31
  If you start from the Amalgm web app or are setting up a remote/headless machine, create a setup code from the web app and run:
30
32
 
package/lib/cli.js CHANGED
@@ -119,7 +119,7 @@ function usage() {
119
119
  ' amalgm run [--local-only]',
120
120
  ' amalgm stop',
121
121
  ' amalgm register [path ...] Register local ground (default: current directory)',
122
- ' amalgm add <entity-uuid> [path] Add a cloud-backed entity to this machine (coming with materialization)',
122
+ ' amalgm add <entity-uuid> [directory] Add a cloud-backed tree to this machine',
123
123
  ' amalgm status',
124
124
  ' amalgm doctor',
125
125
  ' amalgm service [install|start|stop|status|uninstall] [--mode auto|systemd|launchd|windows|portable]',
@@ -1491,6 +1491,72 @@ async function register(positionals) {
1491
1491
  }
1492
1492
  }
1493
1493
 
1494
+ /** `amalgm add <cloud-root-uuid> [directory]` — ask the local runtime to
1495
+ * materialize one compacted cloud head beneath a fresh child in `directory`.
1496
+ * The CLI never speaks cloud storage itself: the runtime owns the pinned
1497
+ * snapshot, content verification, and watcher binding. */
1498
+ async function add(positionals) {
1499
+ if (positionals.length < 1 || positionals.length > 2) {
1500
+ throw new Error('Usage: amalgm add <entity-uuid> [directory]');
1501
+ }
1502
+ const uuid = positionals[0];
1503
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(uuid)) {
1504
+ throw new Error('amalgm add requires a cloud entity UUID');
1505
+ }
1506
+ const targetPath = path.resolve(positionals[1] || process.cwd());
1507
+ const port = servicePorts().find(([name]) => name === 'amalgm-mcp')?.[1];
1508
+ let answer = null;
1509
+ if (port) {
1510
+ // A live entity commit may have reached this machine a moment before
1511
+ // the gateway returns its newly compacted head. The runtime refuses to
1512
+ // materialize the old snapshot plus that tail; wait for the tunnel's
1513
+ // immediate refresh instead of weakening that boundary.
1514
+ const refreshDeadline = Date.now() + 30_000;
1515
+ while (!answer) {
1516
+ try {
1517
+ const candidate = await runtimePost(port, '/entities/add', { uuid, path: targetPath });
1518
+ if (candidate.status !== 409
1519
+ || !/catalog is not pinned to the active cloud head/i.test(candidate.payload?.error || '')
1520
+ || Date.now() >= refreshDeadline) {
1521
+ answer = candidate;
1522
+ break;
1523
+ }
1524
+ await sleep(200);
1525
+ } catch (error) {
1526
+ if (error.code !== 'ECONNREFUSED') throw error;
1527
+ break;
1528
+ }
1529
+ }
1530
+ }
1531
+ if (!answer) {
1532
+ console.error('Amalgm runtime is not running. Start it with `amalgm start`.');
1533
+ process.exitCode = 1;
1534
+ return;
1535
+ }
1536
+ if (![200, 202].includes(answer.status)) {
1537
+ console.error(`Error: ${answer.payload?.error || `runtime responded ${answer.status}`}`);
1538
+ process.exitCode = 1;
1539
+ return;
1540
+ }
1541
+ let job = answer.payload;
1542
+ const deadline = Date.now() + 120_000;
1543
+ while (job?.state === 'waiting_for_content' || job?.state === 'materializing') {
1544
+ if (Date.now() > deadline) {
1545
+ console.error(`Still fetching cloud content for ${uuid}. Run the same command again to check progress.`);
1546
+ process.exitCode = 1;
1547
+ return;
1548
+ }
1549
+ await new Promise((resolve) => setTimeout(resolve, 200));
1550
+ job = (await runtimeGet(port, `/entities/add/${encodeURIComponent(job.jobId)}`)).payload;
1551
+ }
1552
+ if (job?.state !== 'complete') {
1553
+ console.error(`Error: ${job?.error || 'cloud materialization failed'}`);
1554
+ process.exitCode = 1;
1555
+ return;
1556
+ }
1557
+ console.log(`Added ${job.entityUuid} to ${job.targetPath}`);
1558
+ }
1559
+
1494
1560
  /** POST JSON to a local runtime service, carrying the runtime token. */
1495
1561
  function runtimePost(port, requestPath, body) {
1496
1562
  const payload = JSON.stringify(body);
@@ -1525,6 +1591,30 @@ function runtimePost(port, requestPath, body) {
1525
1591
  });
1526
1592
  }
1527
1593
 
1594
+ function runtimeGet(port, requestPath) {
1595
+ const token = readJson(RUNTIME_TOKEN_FILE, null)?.token || '';
1596
+ return new Promise((resolve, reject) => {
1597
+ const req = http.request({
1598
+ host: '127.0.0.1',
1599
+ port,
1600
+ path: requestPath,
1601
+ method: 'GET',
1602
+ headers: token ? { 'x-amalgm-runtime-token': token } : {},
1603
+ }, (res) => {
1604
+ let text = '';
1605
+ res.setEncoding('utf8');
1606
+ res.on('data', (chunk) => { text += chunk; });
1607
+ res.on('end', () => {
1608
+ let parsed = null;
1609
+ try { parsed = JSON.parse(text); } catch {}
1610
+ resolve({ status: res.statusCode, payload: parsed });
1611
+ });
1612
+ });
1613
+ req.on('error', reject);
1614
+ req.end();
1615
+ });
1616
+ }
1617
+
1528
1618
  async function main(argv) {
1529
1619
  const { command, options, positionals } = parseArgs(argv);
1530
1620
 
@@ -1548,9 +1638,7 @@ async function main(argv) {
1548
1638
  }
1549
1639
  if (command === 'stop') return stop();
1550
1640
  if (command === 'register') return register(positionals);
1551
- if (command === 'add') {
1552
- throw new Error('`amalgm add` is reserved for cloud-to-local materialization, which is not available yet. Use `amalgm register <path>` for local ground.');
1553
- }
1641
+ if (command === 'add') return add(positionals);
1554
1642
  if (command === 'status') return status();
1555
1643
  if (command === 'doctor') return doctor();
1556
1644
  if (command === 'service') return serviceCommand(options, positionals);
@@ -33,6 +33,7 @@ function createSharedRealtimeTunnel({
33
33
  const mutationInflight = new Map();
34
34
  const entityMutationInflight = new Map();
35
35
  const entityContentInflight = new Map();
36
+ const entityContentDownloadInflight = new Map();
36
37
  const promotionInflight = new Map();
37
38
  const attachmentInflight = new Map();
38
39
  const privateEnsureInflight = new Map();
@@ -369,25 +370,44 @@ function createSharedRealtimeTunnel({
369
370
  leaseMs: CLAIM_LEASE_MS,
370
371
  });
371
372
  const upload = result?.upload;
372
- if (!upload?.resourceId || !upload?.contentHash || !upload?.kind) return;
373
- const requestId = `entity_content_${crypto.randomBytes(12).toString('hex')}`;
374
- if (!sendLarge({
375
- type: 'private.entity-content.put',
376
- request_id: requestId,
377
- resource_id: upload.resourceId,
378
- content_hash: upload.contentHash,
379
- kind: upload.kind,
380
- part_index: upload.partIndex,
381
- part_count: upload.partCount,
382
- sha256: upload.sha256,
383
- bytes: upload.bytes,
384
- data_b64: upload.dataBase64,
385
- })) {
386
- await failEntityContent(upload, 'shared tunnel backpressure: entity content was not queued');
387
- scheduleEntityContentDrain(RETRY_MS);
388
- return;
373
+ if (upload?.resourceId && upload?.contentHash && upload?.kind) {
374
+ const requestId = `entity_content_${crypto.randomBytes(12).toString('hex')}`;
375
+ if (!sendLarge({
376
+ type: 'private.entity-content.put',
377
+ request_id: requestId,
378
+ resource_id: upload.resourceId,
379
+ content_hash: upload.contentHash,
380
+ kind: upload.kind,
381
+ part_index: upload.partIndex,
382
+ part_count: upload.partCount,
383
+ sha256: upload.sha256,
384
+ bytes: upload.bytes,
385
+ data_b64: upload.dataBase64,
386
+ })) {
387
+ await failEntityContent(upload, 'shared tunnel backpressure: entity content was not queued');
388
+ scheduleEntityContentDrain(RETRY_MS);
389
+ } else {
390
+ entityContentInflight.set(requestId, upload);
391
+ }
392
+ }
393
+ if (entityContentDownloadInflight.size === 0) {
394
+ const downloadResult = await localStateJson('POST', '/entities/cloud/content/downloads/claim', {});
395
+ const download = downloadResult?.download;
396
+ if (download?.resourceId && download?.contentHash && download?.kind) {
397
+ const requestId = `entity_content_get_${crypto.randomBytes(12).toString('hex')}`;
398
+ if (send({
399
+ type: 'private.entity-content.get',
400
+ request_id: requestId,
401
+ resource_id: download.resourceId,
402
+ content_hash: download.contentHash,
403
+ kind: download.kind,
404
+ part_index: download.partIndex,
405
+ ...(download.sha256 ? { sha256: download.sha256 } : {}),
406
+ })) {
407
+ entityContentDownloadInflight.set(requestId, download);
408
+ }
409
+ }
389
410
  }
390
- entityContentInflight.set(requestId, upload);
391
411
  })().catch((error) => {
392
412
  localFailures += 1;
393
413
  if (localFailures === 1 || localFailures % 20 === 0) {
@@ -790,6 +810,27 @@ function createSharedRealtimeTunnel({
790
810
  });
791
811
  }
792
812
 
813
+ function handlePrivateEntityContentData(frame) {
814
+ const download = entityContentDownloadInflight.get(frame.request_id);
815
+ if (!download || download.contentHash !== frame.content_hash || download.kind !== frame.kind
816
+ || download.partIndex !== frame.part_index) return;
817
+ void localStateJson('POST', '/entities/cloud/content/downloads/receive', {
818
+ resourceId: download.resourceId,
819
+ contentHash: download.contentHash,
820
+ kind: download.kind,
821
+ partIndex: download.partIndex,
822
+ sha256: frame.sha256,
823
+ dataBase64: frame.data_b64,
824
+ }).then(() => {
825
+ entityContentDownloadInflight.delete(frame.request_id);
826
+ scheduleEntityContentDrain();
827
+ }).catch((error) => {
828
+ warn(`entity content ${download.contentHash} was not stored locally: ${error?.message || error}`);
829
+ entityContentDownloadInflight.delete(frame.request_id);
830
+ scheduleEntityContentDrain(RETRY_MS);
831
+ });
832
+ }
833
+
793
834
  function handleAttached(frame) {
794
835
  const identity = attachmentInflight.get(frame.request_id);
795
836
  if (!identity) return;
@@ -897,6 +938,11 @@ function createSharedRealtimeTunnel({
897
938
  committedAt: frame.committed_at,
898
939
  }).then(() => {
899
940
  entityMutationInflight.delete(frame.request_id);
941
+ // An acknowledgement advances the official head. Re-read the
942
+ // private resource immediately: the gateway compacts that head into
943
+ // one immutable snapshot before replying, so this device never has
944
+ // to use its mutation tail as an `amalgm add` starting point.
945
+ void ensurePrivateEntityRegistry();
900
946
  scheduleEntityMutationDrain();
901
947
  }).catch(() => {
902
948
  setTimeout(() => enabled && handleAck(frame), RETRY_MS).unref?.();
@@ -942,6 +988,11 @@ function createSharedRealtimeTunnel({
942
988
  });
943
989
  return;
944
990
  }
991
+ if (entityContentDownloadInflight.has(frame.request_id)) {
992
+ entityContentDownloadInflight.delete(frame.request_id);
993
+ scheduleEntityContentDrain(RETRY_MS);
994
+ return;
995
+ }
945
996
  const privateResource = privateEnsureInflight.get(frame.request_id);
946
997
  if (privateResource) {
947
998
  privateEnsureInflight.delete(frame.request_id);
@@ -1049,12 +1100,20 @@ function createSharedRealtimeTunnel({
1049
1100
  ? '/entities/cloud/mutations/ingest'
1050
1101
  : '/state/mutations/ingest';
1051
1102
  const prior = inboundChains.get(resourceId) || Promise.resolve();
1103
+ const entityRegistryMutation = endpoint === '/entities/cloud/mutations/ingest';
1052
1104
  const next = prior.then(() => localStateJson('POST', endpoint, {
1053
1105
  envelope: frame.envelope,
1054
1106
  version: frame.version,
1055
1107
  committedAt: frame.committed_at,
1056
1108
  actorUserId: frame.actor_user_id,
1057
- })).catch((error) => {
1109
+ })).then(() => {
1110
+ if (entityRegistryMutation) {
1111
+ // The live frame is useful for collaboration, but cannot become the
1112
+ // reconstruction path for a new local binding. Ask the authority to
1113
+ // compact and return its active snapshot before `add` can continue.
1114
+ void ensurePrivateEntityRegistry();
1115
+ }
1116
+ }).catch((error) => {
1058
1117
  warn(`shared mutation ${frame.mutation_id || 'unknown'} was not materialized: ${error?.message || error}`);
1059
1118
  setTimeout(() => enabled && void joinReplicas(), RETRY_MS).unref?.();
1060
1119
  }).finally(() => {
@@ -1135,6 +1194,9 @@ function createSharedRealtimeTunnel({
1135
1194
  case 'private.entity-content.stored':
1136
1195
  handlePrivateEntityContentStored(frame);
1137
1196
  return true;
1197
+ case 'private.entity-content.data':
1198
+ handlePrivateEntityContentData(frame);
1199
+ return true;
1138
1200
  case 'repo.object.stored':
1139
1201
  handleRepoObjectStored(frame);
1140
1202
  return true;
@@ -1162,6 +1224,7 @@ function createSharedRealtimeTunnel({
1162
1224
  mutationInflight.clear();
1163
1225
  entityMutationInflight.clear();
1164
1226
  entityContentInflight.clear();
1227
+ entityContentDownloadInflight.clear();
1165
1228
  promotionInflight.clear();
1166
1229
  attachmentInflight.clear();
1167
1230
  privateEnsureInflight.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.246",
3
+ "version": "0.1.247",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -0,0 +1,276 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Durable local automation delivery inbox.
5
+ *
6
+ * Both scheduled slots and cloud-relayed events enter this table before any
7
+ * workflow starts. Receipt and execution are deliberately separate: HTTP and
8
+ * schedule producers only commit immutable input; this worker owns leases,
9
+ * retries, and terminal receipts.
10
+ */
11
+
12
+ const crypto = require('crypto');
13
+
14
+ const { openLocalDb } = require('../state/db');
15
+ const { executeAutomationForTrigger } = require('./runner');
16
+
17
+ const DEFAULT_BATCH_SIZE = 25;
18
+ const DEFAULT_LEASE_MS = 10 * 60_000;
19
+ const MAX_RETRY_DELAY_MS = 5 * 60_000;
20
+
21
+ let draining = false;
22
+
23
+ function nowIso(date = new Date()) {
24
+ return date.toISOString();
25
+ }
26
+
27
+ function parseJson(value, fallback) {
28
+ try {
29
+ return JSON.parse(value);
30
+ } catch {
31
+ return fallback;
32
+ }
33
+ }
34
+
35
+ function rowToDelivery(row) {
36
+ if (!row) return null;
37
+ return {
38
+ id: row.id,
39
+ kind: row.kind,
40
+ triggerId: row.trigger_id || null,
41
+ scheduledFor: row.scheduled_for || null,
42
+ payload: parseJson(row.payload_json, {}),
43
+ status: row.status,
44
+ attemptCount: Number(row.attempt_count) || 0,
45
+ nextAttemptAt: row.next_attempt_at || null,
46
+ leaseExpiresAt: row.lease_expires_at || null,
47
+ completedTriggerIds: parseJson(row.completed_trigger_ids_json, []),
48
+ result: parseJson(row.result_json, null),
49
+ lastError: row.last_error || null,
50
+ createdAt: row.created_at,
51
+ updatedAt: row.updated_at,
52
+ completedAt: row.completed_at || null,
53
+ };
54
+ }
55
+
56
+ function eventDeliveryId(value) {
57
+ const cleaned = String(value || '').trim();
58
+ if (cleaned && cleaned.length <= 200 && /^[A-Za-z0-9._:-]+$/.test(cleaned)) {
59
+ return cleaned;
60
+ }
61
+ return `event:${crypto.randomUUID()}`;
62
+ }
63
+
64
+ function enqueueEventDelivery(input = {}) {
65
+ const timestamp = nowIso();
66
+ const id = eventDeliveryId(input.id);
67
+ const payload = {
68
+ rawBody: typeof input.rawBody === 'string' ? input.rawBody : '',
69
+ headers: input.headers && typeof input.headers === 'object' ? input.headers : {},
70
+ receivedAt: input.receivedAt || timestamp,
71
+ };
72
+ const db = openLocalDb();
73
+ db.prepare(`
74
+ INSERT INTO automation_deliveries (
75
+ id, kind, payload_json, status, next_attempt_at, created_at, updated_at
76
+ ) VALUES (?, 'event', ?, 'pending', ?, ?, ?)
77
+ ON CONFLICT(id) DO NOTHING
78
+ `).run(id, JSON.stringify(payload), timestamp, timestamp, timestamp);
79
+ return rowToDelivery(db.prepare('SELECT * FROM automation_deliveries WHERE id = ?').get(id));
80
+ }
81
+
82
+ function claimDeliveries(options = {}) {
83
+ const db = openLocalDb();
84
+ const now = options.now instanceof Date ? options.now : new Date();
85
+ const timestamp = nowIso(now);
86
+ const leaseExpiresAt = nowIso(new Date(now.getTime() + (Number(options.leaseMs) || DEFAULT_LEASE_MS)));
87
+ const limit = Math.max(1, Math.min(Number(options.limit) || DEFAULT_BATCH_SIZE, 100));
88
+
89
+ return db.transaction(() => {
90
+ const rows = db.prepare(`
91
+ SELECT *
92
+ FROM automation_deliveries
93
+ WHERE (
94
+ status = 'pending'
95
+ AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
96
+ ) OR (
97
+ status = 'running'
98
+ AND lease_expires_at IS NOT NULL
99
+ AND lease_expires_at <= ?
100
+ )
101
+ ORDER BY datetime(created_at) ASC, id ASC
102
+ LIMIT ?
103
+ `).all(timestamp, timestamp, limit);
104
+
105
+ const update = db.prepare(`
106
+ UPDATE automation_deliveries
107
+ SET status = 'running',
108
+ attempt_count = attempt_count + 1,
109
+ lease_expires_at = ?,
110
+ next_attempt_at = NULL,
111
+ updated_at = ?
112
+ WHERE id = ?
113
+ `);
114
+ for (const row of rows) update.run(leaseExpiresAt, timestamp, row.id);
115
+ return rows.map((row) => rowToDelivery({
116
+ ...row,
117
+ status: 'running',
118
+ attempt_count: Number(row.attempt_count) + 1,
119
+ lease_expires_at: leaseExpiresAt,
120
+ next_attempt_at: null,
121
+ updated_at: timestamp,
122
+ }));
123
+ })();
124
+ }
125
+
126
+ function recordTriggerCompletion(deliveryId, triggerId) {
127
+ const db = openLocalDb();
128
+ return db.transaction(() => {
129
+ const row = db.prepare('SELECT * FROM automation_deliveries WHERE id = ?').get(deliveryId);
130
+ if (!row) return null;
131
+ const completed = new Set(parseJson(row.completed_trigger_ids_json, []));
132
+ completed.add(triggerId);
133
+ const timestamp = nowIso();
134
+ db.prepare(`
135
+ UPDATE automation_deliveries
136
+ SET completed_trigger_ids_json = ?, updated_at = ?
137
+ WHERE id = ?
138
+ `).run(JSON.stringify([...completed]), timestamp, deliveryId);
139
+ return [...completed];
140
+ })();
141
+ }
142
+
143
+ function finishDelivery(deliveryId, status, result = null) {
144
+ if (!['completed', 'rejected'].includes(status)) {
145
+ throw new Error(`Invalid terminal delivery status: ${status}`);
146
+ }
147
+ const timestamp = nowIso();
148
+ openLocalDb().prepare(`
149
+ UPDATE automation_deliveries
150
+ SET status = ?,
151
+ result_json = ?,
152
+ last_error = NULL,
153
+ lease_expires_at = NULL,
154
+ next_attempt_at = NULL,
155
+ completed_at = ?,
156
+ updated_at = ?
157
+ WHERE id = ?
158
+ `).run(status, result == null ? null : JSON.stringify(result), timestamp, timestamp, deliveryId);
159
+ }
160
+
161
+ function retryDelivery(deliveryId, error, options = {}) {
162
+ const db = openLocalDb();
163
+ const row = db.prepare('SELECT attempt_count FROM automation_deliveries WHERE id = ?').get(deliveryId);
164
+ if (!row) return null;
165
+ const attemptCount = Math.max(1, Number(row.attempt_count) || 1);
166
+ const delayMs = options.immediate
167
+ ? 0
168
+ : Math.min(MAX_RETRY_DELAY_MS, 1000 * (2 ** Math.min(attemptCount - 1, 8)));
169
+ const now = options.now instanceof Date ? options.now : new Date();
170
+ const timestamp = nowIso(now);
171
+ const nextAttemptAt = nowIso(new Date(now.getTime() + delayMs));
172
+ db.prepare(`
173
+ UPDATE automation_deliveries
174
+ SET status = 'pending',
175
+ next_attempt_at = ?,
176
+ lease_expires_at = NULL,
177
+ last_error = ?,
178
+ updated_at = ?,
179
+ completed_at = NULL
180
+ WHERE id = ?
181
+ `).run(nextAttemptAt, String(error?.message || error || 'Delivery failed').slice(0, 8000), timestamp, deliveryId);
182
+ return getDelivery(deliveryId);
183
+ }
184
+
185
+ function getDelivery(deliveryId) {
186
+ return rowToDelivery(openLocalDb().prepare(
187
+ 'SELECT * FROM automation_deliveries WHERE id = ?',
188
+ ).get(String(deliveryId || '')));
189
+ }
190
+
191
+ function listDeliveries(options = {}) {
192
+ const conditions = [];
193
+ const params = [];
194
+ if (options.status) {
195
+ conditions.push('status = ?');
196
+ params.push(String(options.status));
197
+ }
198
+ if (options.kind) {
199
+ conditions.push('kind = ?');
200
+ params.push(String(options.kind));
201
+ }
202
+ const limit = Math.max(1, Math.min(Number(options.limit) || 100, 500));
203
+ const offset = Math.max(0, Number(options.offset) || 0);
204
+ params.push(limit, offset);
205
+ return openLocalDb().prepare(`
206
+ SELECT *
207
+ FROM automation_deliveries
208
+ ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
209
+ ORDER BY datetime(created_at) DESC, id DESC
210
+ LIMIT ? OFFSET ?
211
+ `).all(...params).map(rowToDelivery);
212
+ }
213
+
214
+ async function processDelivery(delivery) {
215
+ if (delivery.kind === 'scheduled') {
216
+ if (!delivery.triggerId) {
217
+ finishDelivery(delivery.id, 'rejected', { reason: 'Scheduled trigger no longer exists' });
218
+ return;
219
+ }
220
+ const event = {
221
+ id: delivery.id,
222
+ source: 'amalgm.scheduler',
223
+ event: 'scheduled',
224
+ payload: delivery.payload,
225
+ headers: {},
226
+ receivedAt: delivery.createdAt,
227
+ };
228
+ await executeAutomationForTrigger(delivery.triggerId, event);
229
+ recordTriggerCompletion(delivery.id, delivery.triggerId);
230
+ finishDelivery(delivery.id, 'completed', { triggerIds: [delivery.triggerId] });
231
+ return;
232
+ }
233
+
234
+ const { processEventEnvelope } = require('../events/processor');
235
+ const result = await processEventEnvelope({
236
+ deliveryId: delivery.id,
237
+ rawBody: delivery.payload.rawBody,
238
+ headers: delivery.payload.headers,
239
+ receivedAt: delivery.payload.receivedAt || delivery.createdAt,
240
+ completedTriggerIds: delivery.completedTriggerIds,
241
+ onTriggerComplete: (triggerId) => recordTriggerCompletion(delivery.id, triggerId),
242
+ });
243
+ finishDelivery(delivery.id, result.terminalStatus, result);
244
+ }
245
+
246
+ async function drainAutomationDeliveries(options = {}) {
247
+ if (draining) return { claimed: 0, busy: true };
248
+ draining = true;
249
+ let claimed = 0;
250
+ try {
251
+ const deliveries = claimDeliveries(options);
252
+ claimed = deliveries.length;
253
+ for (const delivery of deliveries) {
254
+ try {
255
+ await processDelivery(delivery);
256
+ } catch (error) {
257
+ retryDelivery(delivery.id, error);
258
+ console.error(`[AmalgmMCP:Automations] Delivery ${delivery.id} failed; retry remains pending:`, error.message || error);
259
+ }
260
+ }
261
+ return { claimed, busy: false };
262
+ } finally {
263
+ draining = false;
264
+ }
265
+ }
266
+
267
+ module.exports = {
268
+ claimDeliveries,
269
+ drainAutomationDeliveries,
270
+ enqueueEventDelivery,
271
+ finishDelivery,
272
+ getDelivery,
273
+ listDeliveries,
274
+ recordTriggerCompletion,
275
+ retryDelivery,
276
+ };
@@ -21,6 +21,12 @@ const {
21
21
  } = require('./store');
22
22
  const { executeAutomationById } = require('./runner');
23
23
  const { validateWorkflowText } = require('./validator');
24
+ const {
25
+ getDelivery,
26
+ listDeliveries,
27
+ retryDelivery,
28
+ drainAutomationDeliveries,
29
+ } = require('./deliveries');
24
30
 
25
31
  function sendError(sendJson, status, error) {
26
32
  sendJson(status, { error: error.message || String(error) });
@@ -158,6 +164,49 @@ async function handleRunNow(body, sendJson) {
158
164
  }
159
165
  }
160
166
 
167
+ async function handleDeliveries(body, sendJson) {
168
+ try {
169
+ const deliveryId = body?.delivery_id || body?.deliveryId;
170
+ if (deliveryId) {
171
+ const delivery = getDelivery(deliveryId);
172
+ if (!delivery) return sendJson(404, { error: 'Delivery not found' });
173
+ return sendJson(200, { ok: true, delivery });
174
+ }
175
+ const limit = Math.max(1, Math.min(Number(body?.limit) || 100, 500));
176
+ const offset = Math.max(0, Number(body?.offset) || 0);
177
+ const deliveries = listDeliveries({
178
+ status: body?.status,
179
+ kind: body?.kind,
180
+ limit: limit + 1,
181
+ offset,
182
+ });
183
+ sendJson(200, {
184
+ ok: true,
185
+ deliveries: deliveries.slice(0, limit),
186
+ hasMore: deliveries.length > limit,
187
+ limit,
188
+ offset,
189
+ });
190
+ } catch (error) {
191
+ sendError(sendJson, 400, error);
192
+ }
193
+ }
194
+
195
+ async function handleDeliveryRetry(body, sendJson) {
196
+ try {
197
+ const deliveryId = body?.delivery_id || body?.deliveryId;
198
+ if (!deliveryId) return sendJson(400, { error: 'delivery_id is required' });
199
+ if (!getDelivery(deliveryId)) return sendJson(404, { error: 'Delivery not found' });
200
+ const delivery = retryDelivery(deliveryId, 'Manual retry requested', { immediate: true });
201
+ void drainAutomationDeliveries().catch((error) => {
202
+ console.error('[AmalgmMCP:Automations] Manual delivery drain failed:', error.message || error);
203
+ });
204
+ sendJson(200, { ok: true, delivery });
205
+ } catch (error) {
206
+ sendError(sendJson, 400, error);
207
+ }
208
+ }
209
+
161
210
  async function handleTriggerUpdate(body, sendJson) {
162
211
  try {
163
212
  const id = body?.trigger_id || body?.triggerId;
@@ -184,6 +233,8 @@ async function handleTriggerDelete(body, sendJson) {
184
233
 
185
234
  module.exports = {
186
235
  handleCreate,
236
+ handleDeliveries,
237
+ handleDeliveryRetry,
187
238
  handleDelete,
188
239
  handleGet,
189
240
  handleList,