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
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
|
|
26
|
-
existing cloud
|
|
27
|
-
|
|
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> [
|
|
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 (
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
})).
|
|
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
|
@@ -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,
|