amalgm 0.1.0-canary.29 → 0.1.36
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 +4 -4
- package/lib/cli.js +2 -2
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +29 -11
- package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +6 -3
- package/runtime/scripts/amalgm-mcp/tests/events-matcher.test.js +45 -0
- package/runtime/scripts/amalgm-mcp/tests/local-live-snapshot.test.js +44 -0
- package/runtime/scripts/amalgm-mcp/tests/scheduler.test.js +64 -0
package/README.md
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
Install the local Amalgm computer runtime:
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
npm i -g amalgm
|
|
6
|
+
npm i -g amalgm
|
|
7
7
|
amalgm login
|
|
8
8
|
amalgm doctor
|
|
9
9
|
amalgm start
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
To move an existing install to the newest published build, run `amalgm update
|
|
13
|
-
Set `AMALGM_AUTO_UPDATE=1` when running `amalgm start` to let the CLI update itself
|
|
12
|
+
To move an existing install to the newest stable published build, run `amalgm update`.
|
|
13
|
+
Set `AMALGM_AUTO_UPDATE=1` when running `amalgm start` to let the CLI update itself to the latest stable release before launching.
|
|
14
14
|
|
|
15
15
|
`amalgm login` opens a browser approval page, then registers the machine and stores its local tunnel/computer record in `~/.amalgm/computer.json`.
|
|
16
16
|
|
|
@@ -37,7 +37,7 @@ Useful commands:
|
|
|
37
37
|
|
|
38
38
|
```sh
|
|
39
39
|
amalgm status
|
|
40
|
-
amalgm update
|
|
40
|
+
amalgm update
|
|
41
41
|
amalgm logs
|
|
42
42
|
amalgm logs chat-server
|
|
43
43
|
amalgm stop
|
package/lib/cli.js
CHANGED
|
@@ -85,7 +85,7 @@ function usage() {
|
|
|
85
85
|
'',
|
|
86
86
|
'Environment:',
|
|
87
87
|
' AMALGM_APP_URL Web app base URL for login/register',
|
|
88
|
-
' AMALGM_AUTO_UPDATE Set to 1 to update
|
|
88
|
+
' AMALGM_AUTO_UPDATE Set to 1 to update to the latest stable release before start',
|
|
89
89
|
' AMALGM_DIR Runtime state dir (default ~/.amalgm)',
|
|
90
90
|
' AMALGM_PROXY_TOKEN Optional short-lived proxy token override',
|
|
91
91
|
].join('\n');
|
|
@@ -419,7 +419,7 @@ function npmCommand() {
|
|
|
419
419
|
function npmInstallTag(options = {}) {
|
|
420
420
|
const explicit = options.tag || options.channel;
|
|
421
421
|
if (explicit && explicit !== true) return String(explicit).replace(/^@/, '');
|
|
422
|
-
return
|
|
422
|
+
return 'latest';
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
function npmPackageVersionForTag(tag) {
|
package/package.json
CHANGED
|
@@ -52,20 +52,38 @@ function readResource(resource, cache) {
|
|
|
52
52
|
|
|
53
53
|
function buildSnapshot(resourcesInput) {
|
|
54
54
|
const resources = normalizeResources(resourcesInput);
|
|
55
|
-
|
|
56
|
-
const cache = {};
|
|
57
|
-
const data = {};
|
|
55
|
+
let lastUnstable = null;
|
|
58
56
|
|
|
59
|
-
for (
|
|
60
|
-
const
|
|
61
|
-
|
|
57
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
58
|
+
const beforeSeq = currentSeq();
|
|
59
|
+
const cache = {};
|
|
60
|
+
const data = {};
|
|
61
|
+
|
|
62
|
+
for (const resource of resources) {
|
|
63
|
+
const value = readResource(resource, cache);
|
|
64
|
+
if (value !== undefined) data[resource] = value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const afterSeq = currentSeq();
|
|
68
|
+
if (beforeSeq === afterSeq) {
|
|
69
|
+
return {
|
|
70
|
+
seq: afterSeq,
|
|
71
|
+
stable: true,
|
|
72
|
+
resources: data,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
lastUnstable = {
|
|
77
|
+
seq: beforeSeq,
|
|
78
|
+
stable: false,
|
|
79
|
+
resources: data,
|
|
80
|
+
};
|
|
62
81
|
}
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
resources: data,
|
|
83
|
+
return lastUnstable || {
|
|
84
|
+
seq: currentSeq(),
|
|
85
|
+
stable: true,
|
|
86
|
+
resources: {},
|
|
69
87
|
};
|
|
70
88
|
}
|
|
71
89
|
|
|
@@ -57,7 +57,9 @@ function isTaskDue(task, now) {
|
|
|
57
57
|
currentDate: now,
|
|
58
58
|
tz: schedule.tz || 'UTC',
|
|
59
59
|
});
|
|
60
|
-
const prev = interval.
|
|
60
|
+
const prev = interval.includesDate(now)
|
|
61
|
+
? new Date(Math.floor(now.getTime() / 1000) * 1000)
|
|
62
|
+
: interval.prev().toDate();
|
|
61
63
|
const createdAt = validDate(task.createdAt);
|
|
62
64
|
const lastRunAt = validDate(task.lastRunAt);
|
|
63
65
|
|
|
@@ -68,8 +70,9 @@ function isTaskDue(task, now) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
if (schedule.kind === 'interval') {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
const baseline = validDate(task.lastRunAt) || validDate(task.createdAt);
|
|
74
|
+
if (!baseline) return true;
|
|
75
|
+
return now - baseline >= schedule.ms;
|
|
73
76
|
}
|
|
74
77
|
return false;
|
|
75
78
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
computeSignature,
|
|
8
|
+
extractSignature,
|
|
9
|
+
matchBySignature,
|
|
10
|
+
} = require('../events/matcher');
|
|
11
|
+
|
|
12
|
+
function trigger(overrides = {}) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'trigger-1',
|
|
15
|
+
enabled: true,
|
|
16
|
+
secret: 'super-secret',
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('event matcher accepts generic HMAC webhook signatures', () => {
|
|
22
|
+
const rawBody = JSON.stringify({ ok: true });
|
|
23
|
+
const signature = extractSignature({
|
|
24
|
+
'x-webhook-signature': computeSignature('super-secret', rawBody),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
assert.equal(matchBySignature([trigger()], signature, rawBody)?.id, 'trigger-1');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('event matcher accepts bearer token webhook secrets', () => {
|
|
31
|
+
const signature = extractSignature({
|
|
32
|
+
authorization: 'Bearer super-secret',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(matchBySignature([trigger()], signature, '{}')?.id, 'trigger-1');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('event matcher rejects disabled triggers and wrong secrets', () => {
|
|
39
|
+
const rawBody = JSON.stringify({ ok: true });
|
|
40
|
+
const signature = extractSignature({
|
|
41
|
+
'x-webhook-signature': computeSignature('wrong-secret', rawBody),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
assert.equal(matchBySignature([trigger(), trigger({ id: 'disabled', enabled: false })], signature, rawBody), null);
|
|
45
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
let seq = 0;
|
|
7
|
+
const eventsPath = require.resolve('../state/events');
|
|
8
|
+
require.cache[eventsPath] = {
|
|
9
|
+
id: eventsPath,
|
|
10
|
+
filename: eventsPath,
|
|
11
|
+
loaded: true,
|
|
12
|
+
exports: {
|
|
13
|
+
currentSeq: () => seq,
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const taskStore = require('../tasks/store');
|
|
18
|
+
const { buildSnapshot } = require('../state/snapshot');
|
|
19
|
+
|
|
20
|
+
test.after(() => {
|
|
21
|
+
taskStore.loadTasks = originalLoadTasks;
|
|
22
|
+
delete require.cache[eventsPath];
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const originalLoadTasks = taskStore.loadTasks;
|
|
26
|
+
|
|
27
|
+
test('snapshot retries when a state event lands during resource reads', () => {
|
|
28
|
+
let calls = 0;
|
|
29
|
+
taskStore.loadTasks = () => {
|
|
30
|
+
calls += 1;
|
|
31
|
+
if (calls === 1) {
|
|
32
|
+
seq = 1;
|
|
33
|
+
return { version: 1, tasks: [{ id: 'stale-task', name: 'Stale task' }] };
|
|
34
|
+
}
|
|
35
|
+
return { version: 1, tasks: [{ id: 'new-task', name: 'New task' }] };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const snapshot = buildSnapshot('tasks');
|
|
39
|
+
|
|
40
|
+
assert.equal(calls, 2);
|
|
41
|
+
assert.equal(snapshot.stable, true);
|
|
42
|
+
assert.equal(snapshot.seq, 1);
|
|
43
|
+
assert.deepEqual(snapshot.resources.tasks, [{ id: 'new-task', name: 'New task' }]);
|
|
44
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { isTaskDue } = require('../tasks/scheduler');
|
|
7
|
+
|
|
8
|
+
function baseTask(overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: 'task-1',
|
|
11
|
+
enabled: true,
|
|
12
|
+
createdAt: '2026-05-18T10:00:00.000Z',
|
|
13
|
+
lastRunAt: null,
|
|
14
|
+
endsAt: null,
|
|
15
|
+
schedule: { kind: 'once', at: '2026-05-18T10:01:00.000Z' },
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('interval task waits for the first interval after creation', () => {
|
|
21
|
+
const task = baseTask({
|
|
22
|
+
schedule: { kind: 'interval', ms: 60_000 },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
assert.equal(isTaskDue(task, new Date('2026-05-18T10:00:30.000Z')), false);
|
|
26
|
+
assert.equal(isTaskDue(task, new Date('2026-05-18T10:01:00.000Z')), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('interval task uses lastRunAt after the first run', () => {
|
|
30
|
+
const task = baseTask({
|
|
31
|
+
lastRunAt: '2026-05-18T10:03:00.000Z',
|
|
32
|
+
schedule: { kind: 'interval', ms: 60_000 },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(isTaskDue(task, new Date('2026-05-18T10:03:59.000Z')), false);
|
|
36
|
+
assert.equal(isTaskDue(task, new Date('2026-05-18T10:04:00.000Z')), true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('cron task is due at the exact scheduled boundary in its timezone', () => {
|
|
40
|
+
const task = baseTask({
|
|
41
|
+
createdAt: '2026-05-18T15:55:00.000Z',
|
|
42
|
+
schedule: {
|
|
43
|
+
kind: 'cron',
|
|
44
|
+
expr: '0 9 * * *',
|
|
45
|
+
tz: 'America/Los_Angeles',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
assert.equal(isTaskDue(task, new Date('2026-05-18T16:00:00.000Z')), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('cron task does not rerun for the same scheduled instant', () => {
|
|
53
|
+
const task = baseTask({
|
|
54
|
+
createdAt: '2026-05-18T15:55:00.000Z',
|
|
55
|
+
lastRunAt: '2026-05-18T16:00:00.000Z',
|
|
56
|
+
schedule: {
|
|
57
|
+
kind: 'cron',
|
|
58
|
+
expr: '0 9 * * *',
|
|
59
|
+
tz: 'America/Los_Angeles',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.equal(isTaskDue(task, new Date('2026-05-18T16:00:30.000Z')), false);
|
|
64
|
+
});
|