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 CHANGED
@@ -3,14 +3,14 @@
3
3
  Install the local Amalgm computer runtime:
4
4
 
5
5
  ```sh
6
- npm i -g amalgm@canary
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 --tag canary`.
13
- Set `AMALGM_AUTO_UPDATE=1` when running `amalgm start` to let the CLI update itself before launching if its current dist-tag has moved.
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 --tag canary
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 before start when a newer dist-tag exists',
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 PACKAGE_VERSION.includes('-canary.') ? 'canary' : 'latest';
422
+ return 'latest';
423
423
  }
424
424
 
425
425
  function npmPackageVersionForTag(tag) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.0-canary.29",
3
+ "version": "0.1.36",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -52,20 +52,38 @@ function readResource(resource, cache) {
52
52
 
53
53
  function buildSnapshot(resourcesInput) {
54
54
  const resources = normalizeResources(resourcesInput);
55
- const beforeSeq = currentSeq();
56
- const cache = {};
57
- const data = {};
55
+ let lastUnstable = null;
58
56
 
59
- for (const resource of resources) {
60
- const value = readResource(resource, cache);
61
- if (value !== undefined) data[resource] = value;
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
- const afterSeq = currentSeq();
65
- return {
66
- seq: afterSeq,
67
- stable: beforeSeq === afterSeq,
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.prev().toDate();
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
- if (!task.lastRunAt) return true;
72
- return now - new Date(task.lastRunAt) >= schedule.ms;
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
+ });