amalgm 0.1.148 → 0.1.149

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.148",
3
+ "version": "0.1.149",
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,216 @@
1
+ /**
2
+ * App health contract — the push side of supervision.
3
+ *
4
+ * Apps announce their own liveness; the runtime never probes them. Two
5
+ * signals cover every failure mode:
6
+ *
7
+ * - process exit events (supervisor.js) detect death — instant and free
8
+ * - missed heartbeats (this module) detect zombies: alive but hung
9
+ *
10
+ * An app opts in by POSTing /apps/ready (or just /apps/heartbeat) using the
11
+ * AMALGM_MCP_URL + AMALGM_RUNTIME_TOKEN already in its environment. Apps that
12
+ * never opt in keep exactly today's behavior: the runtime spends zero effort
13
+ * on them beyond watching the exit event.
14
+ *
15
+ * Liveness bookkeeping lives in memory: a heartbeat is runtime-scoped truth
16
+ * (a restarted runtime starts blank and apps re-announce), and persisting
17
+ * every beat would churn apps.json + the state stream for no reader. Only
18
+ * *transitions* (ready, running<->unresponsive) touch the app record.
19
+ */
20
+
21
+ const { getApp, updateAppMeta } = require('./store');
22
+ const { syncAppRoutesToGateway } = require('./advertise');
23
+
24
+ const APP_HEALTH_POLICY = {
25
+ defaultIntervalMs: 30_000,
26
+ minIntervalMs: 5_000,
27
+ maxIntervalMs: 300_000,
28
+ // Beats an app may miss before it is considered unresponsive.
29
+ missedBeats: 3,
30
+ sweepIntervalMs: 10_000,
31
+ };
32
+
33
+ // appId -> { readyAt, lastHeartbeatAt, intervalMs } (epoch ms)
34
+ const enrolled = new Map();
35
+ let sweepTimer = null;
36
+
37
+ function clampIntervalMs(value) {
38
+ const parsed = Number(value);
39
+ if (!Number.isFinite(parsed)) return APP_HEALTH_POLICY.defaultIntervalMs;
40
+ return Math.min(Math.max(parsed, APP_HEALTH_POLICY.minIntervalMs), APP_HEALTH_POLICY.maxIntervalMs);
41
+ }
42
+
43
+ function unresponsiveAfterMs(entry) {
44
+ return entry.intervalMs * APP_HEALTH_POLICY.missedBeats;
45
+ }
46
+
47
+ // A beat only counts for an app the supervisor wants running. Anything else
48
+ // (stopped, degraded, mid-restart) is a straggler process talking past its
49
+ // lifecycle; enrolling it would resurrect state the supervisor just settled.
50
+ function acceptsHealthSignals(app) {
51
+ return Boolean(
52
+ app
53
+ && app.desiredState === 'running'
54
+ && (app.status === 'running' || app.status === 'unresponsive'),
55
+ );
56
+ }
57
+
58
+ function snapshotEntry(appId, entry, now = Date.now()) {
59
+ return {
60
+ appId,
61
+ readyAt: entry.readyAt ? new Date(entry.readyAt).toISOString() : null,
62
+ lastHeartbeatAt: entry.lastHeartbeatAt ? new Date(entry.lastHeartbeatAt).toISOString() : null,
63
+ heartbeatIntervalMs: entry.intervalMs,
64
+ heartbeatAgeMs: entry.lastHeartbeatAt ? Math.max(0, now - entry.lastHeartbeatAt) : null,
65
+ };
66
+ }
67
+
68
+ async function syncRoutesBestEffort(reason) {
69
+ try {
70
+ await syncAppRoutesToGateway(reason);
71
+ } catch (err) {
72
+ const message = err instanceof Error ? err.message : String(err);
73
+ console.warn(`[Apps] App route sync failed (${reason}): ${message}`);
74
+ }
75
+ }
76
+
77
+ function recoverIfUnresponsive(app, now) {
78
+ if (app.status !== 'unresponsive') return;
79
+ console.log(`[App:${app.id}] heartbeat resumed; marking running again`);
80
+ updateAppMeta(app.id, {
81
+ status: 'running',
82
+ error: null,
83
+ lastHeartbeatAt: new Date(now).toISOString(),
84
+ });
85
+ void syncRoutesBestEffort(`heartbeat-recovered:${app.id}`);
86
+ }
87
+
88
+ /**
89
+ * The app announced it is up. Optional first call — a bare heartbeat enrolls
90
+ * too — but ready() is the place to declare a custom heartbeat interval and
91
+ * it stamps readyAt on the record once per process generation.
92
+ */
93
+ function recordReady(appId, options = {}, now = Date.now()) {
94
+ const app = getApp(appId);
95
+ if (!app) return { accepted: false, reason: 'not_found' };
96
+ if (!acceptsHealthSignals(app)) return { accepted: false, reason: `status_${app.status}` };
97
+
98
+ const entry = {
99
+ readyAt: now,
100
+ lastHeartbeatAt: now,
101
+ intervalMs: clampIntervalMs(options.heartbeatIntervalMs),
102
+ };
103
+ enrolled.set(appId, entry);
104
+
105
+ updateAppMeta(appId, {
106
+ readyAt: new Date(now).toISOString(),
107
+ lastHeartbeatAt: new Date(now).toISOString(),
108
+ heartbeatIntervalMs: entry.intervalMs,
109
+ });
110
+ recoverIfUnresponsive(app, now);
111
+ return { accepted: true, health: snapshotEntry(appId, entry, now) };
112
+ }
113
+
114
+ /**
115
+ * Periodic "still fine" from the app. Memory-only on the steady path; touches
116
+ * the record only when it flips an unresponsive app back to running.
117
+ */
118
+ function recordHeartbeat(appId, now = Date.now()) {
119
+ let entry = enrolled.get(appId);
120
+ if (!entry) {
121
+ // First contact from this process generation: validate against the store
122
+ // once, then stay off disk for every subsequent beat.
123
+ const app = getApp(appId);
124
+ if (!app) return { accepted: false, reason: 'not_found' };
125
+ if (!acceptsHealthSignals(app)) return { accepted: false, reason: `status_${app.status}` };
126
+ entry = {
127
+ readyAt: null,
128
+ lastHeartbeatAt: now,
129
+ intervalMs: clampIntervalMs(app.heartbeatIntervalMs),
130
+ };
131
+ enrolled.set(appId, entry);
132
+ recoverIfUnresponsive(app, now);
133
+ return { accepted: true, health: snapshotEntry(appId, entry, now) };
134
+ }
135
+
136
+ const wasOverdue = now - entry.lastHeartbeatAt > unresponsiveAfterMs(entry);
137
+ entry.lastHeartbeatAt = now;
138
+ if (wasOverdue) {
139
+ const app = getApp(appId);
140
+ if (app) recoverIfUnresponsive(app, now);
141
+ }
142
+ return { accepted: true, health: snapshotEntry(appId, entry, now) };
143
+ }
144
+
145
+ /** Live view for list/status surfaces. Null for apps that never enrolled. */
146
+ function healthSnapshot(appId, now = Date.now()) {
147
+ const entry = enrolled.get(appId);
148
+ return entry ? snapshotEntry(appId, entry, now) : null;
149
+ }
150
+
151
+ /**
152
+ * Forget an app's enrollment. Called by the supervisor whenever the process
153
+ * generation ends (exit, stop, delete) — the next process must re-announce.
154
+ */
155
+ function clearAppHealth(appId) {
156
+ enrolled.delete(appId);
157
+ }
158
+
159
+ /**
160
+ * One monitor pass. Pure bookkeeping over the in-memory map — no process
161
+ * probing, no disk reads unless an app actually transitions. Exported so
162
+ * tests can drive time explicitly instead of racing timers.
163
+ */
164
+ function sweepOnce(now = Date.now()) {
165
+ const transitioned = [];
166
+ for (const [appId, entry] of enrolled) {
167
+ if (now - entry.lastHeartbeatAt <= unresponsiveAfterMs(entry)) continue;
168
+
169
+ const app = getApp(appId);
170
+ if (!app || app.desiredState !== 'running' || app.status !== 'running') {
171
+ // Lifecycle moved on (stopped/degraded/deleted); the supervisor owns
172
+ // that state — drop the stale enrollment instead of fighting it.
173
+ if (!app || app.status === 'stopped' || app.status === 'degraded') enrolled.delete(appId);
174
+ continue;
175
+ }
176
+
177
+ const silentSeconds = Math.round((now - entry.lastHeartbeatAt) / 1000);
178
+ console.error(`[App:${appId}] unresponsive: no heartbeat for ${silentSeconds}s (expected every ${Math.round(entry.intervalMs / 1000)}s)`);
179
+ updateAppMeta(appId, {
180
+ status: 'unresponsive',
181
+ error: `No heartbeat for ${silentSeconds}s (app declared one every ${Math.round(entry.intervalMs / 1000)}s). Process is alive but not responding.`,
182
+ lastHeartbeatAt: new Date(entry.lastHeartbeatAt).toISOString(),
183
+ });
184
+ void syncRoutesBestEffort(`unresponsive:${appId}`);
185
+ transitioned.push(appId);
186
+ }
187
+ return transitioned;
188
+ }
189
+
190
+ function startHealthMonitor() {
191
+ if (sweepTimer) return;
192
+ sweepTimer = setInterval(() => {
193
+ try {
194
+ sweepOnce();
195
+ } catch (err) {
196
+ console.warn('[Apps] Health sweep failed:', err instanceof Error ? err.message : String(err));
197
+ }
198
+ }, APP_HEALTH_POLICY.sweepIntervalMs);
199
+ sweepTimer.unref();
200
+ }
201
+
202
+ function stopHealthMonitor() {
203
+ if (sweepTimer) clearInterval(sweepTimer);
204
+ sweepTimer = null;
205
+ }
206
+
207
+ module.exports = {
208
+ APP_HEALTH_POLICY,
209
+ clearAppHealth,
210
+ healthSnapshot,
211
+ recordHeartbeat,
212
+ recordReady,
213
+ startHealthMonitor,
214
+ stopHealthMonitor,
215
+ sweepOnce,
216
+ };
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  const { getApp, getConnectedRoutes, loadApps, updateAppMeta } = require('./store');
6
+ const { healthSnapshot, recordHeartbeat, recordReady } = require('./health');
6
7
  const {
7
8
  deleteApp,
8
9
  connectDns,
@@ -32,6 +33,14 @@ function appPayload(app, options = {}) {
32
33
  return options.legacy ? { artifact: legacyApp(app) } : { app };
33
34
  }
34
35
 
36
+ // The persisted record carries health *transitions* (readyAt, status flips);
37
+ // per-beat liveness lives in memory only. List responses merge the live view
38
+ // in under `health` so readers see heartbeat age without apps.json churn.
39
+ function withLiveHealth(app) {
40
+ const health = healthSnapshot(app.id);
41
+ return health ? { ...app, health } : app;
42
+ }
43
+
35
44
  async function handleList(sendJson, options = {}) {
36
45
  const data = loadApps();
37
46
  if (options.legacy) {
@@ -40,7 +49,7 @@ async function handleList(sendJson, options = {}) {
40
49
  artifacts: data.apps.map(legacyApp),
41
50
  });
42
51
  }
43
- sendJson(200, data);
52
+ sendJson(200, { version: data.version, apps: data.apps.map(withLiveHealth) });
44
53
  }
45
54
 
46
55
  async function handleRoutes(sendJson, options = {}) {
@@ -131,6 +140,31 @@ async function handleUpdate(body, sendJson, options = {}) {
131
140
  }
132
141
  }
133
142
 
143
+ // Health contract endpoints. Called by the app itself (it has AMALGM_MCP_URL
144
+ // + AMALGM_RUNTIME_TOKEN in its env), so they must stay cheap: heartbeat is
145
+ // memory-only after first contact.
146
+ async function handleReady(body, sendJson) {
147
+ const appId = appIdFromBody(body);
148
+ if (!appId) return sendJson(400, { error: 'app_id is required' });
149
+ const result = recordReady(appId, {
150
+ heartbeatIntervalMs: body?.heartbeat_interval_ms ?? body?.heartbeatIntervalMs,
151
+ });
152
+ if (!result.accepted && result.reason === 'not_found') {
153
+ return sendJson(404, { error: 'App not found' });
154
+ }
155
+ sendJson(200, { ok: result.accepted, ...(result.reason ? { reason: result.reason } : {}), health: result.health || null });
156
+ }
157
+
158
+ async function handleHeartbeat(body, sendJson) {
159
+ const appId = appIdFromBody(body);
160
+ if (!appId) return sendJson(400, { error: 'app_id is required' });
161
+ const result = recordHeartbeat(appId);
162
+ if (!result.accepted && result.reason === 'not_found') {
163
+ return sendJson(404, { error: 'App not found' });
164
+ }
165
+ sendJson(200, { ok: result.accepted, ...(result.reason ? { reason: result.reason } : {}), health: result.health || null });
166
+ }
167
+
134
168
  async function handleConnectDns(body, sendJson, options = {}) {
135
169
  try {
136
170
  const appId = appIdFromBody(body);
@@ -157,7 +191,9 @@ module.exports = {
157
191
  handleConnectDns,
158
192
  handleDelete,
159
193
  handleDisconnectDns,
194
+ handleHeartbeat,
160
195
  handleList,
196
+ handleReady,
161
197
  handleRedeploy,
162
198
  handleRegister,
163
199
  handleRoutes,
@@ -164,6 +164,9 @@ function getConnectedRoutes() {
164
164
  && app.status !== 'stopped'
165
165
  && app.status !== 'error'
166
166
  && app.status !== 'degraded'
167
+ // Declared a heartbeat and stopped sending it: alive but hung. Routing
168
+ // traffic to a zombie is worse than routing none.
169
+ && app.status !== 'unresponsive'
167
170
  && app.port
168
171
  && app.appRef,
169
172
  )
@@ -11,6 +11,7 @@ const { spawn } = require('child_process');
11
11
  const { APPS_DIR, DEFAULT_CWD } = require('../config');
12
12
  const { runtimePort } = require('../../../lib/runtime-manifest');
13
13
  const { syncAppRoutesToGateway } = require('./advertise');
14
+ const { clearAppHealth } = require('./health');
14
15
  const {
15
16
  allocatePort,
16
17
  allocateUniqueAppRef,
@@ -346,6 +347,8 @@ function startAppProcess(app) {
346
347
  if (activeChild && activeChild !== child) return;
347
348
  running.delete(app.id);
348
349
  closeAppLog(app.id);
350
+ // This process generation is over; the next one must re-announce health.
351
+ clearAppHealth(app.id);
349
352
  const wasStopping = stopping.has(app.id);
350
353
  stopping.delete(app.id);
351
354
  if (wasStopping) return;
@@ -452,6 +455,10 @@ async function startApp(appId) {
452
455
  envFingerprint: runtimeEnvFingerprint(),
453
456
  error: null,
454
457
  lastStartedAt: new Date().toISOString(),
458
+ // Health announcements belong to a process generation; a fresh spawn
459
+ // starts unannounced until it calls /apps/ready or /apps/heartbeat.
460
+ readyAt: null,
461
+ lastHeartbeatAt: null,
455
462
  });
456
463
  await syncRoutesBestEffort(`start:${appId}`);
457
464
  return current;
@@ -477,6 +484,7 @@ async function stopApp(appId, options = {}) {
477
484
  });
478
485
  running.delete(appId);
479
486
  closeAppLog(appId);
487
+ clearAppHealth(appId);
480
488
 
481
489
  setTimeout(() => stopping.delete(appId), 1000);
482
490
  const current = updateAppMeta(appId, {
@@ -8,6 +8,7 @@
8
8
 
9
9
  const { textResult, errorResult } = require('../lib/tool-result');
10
10
  const { getConnectedRoutes, loadApps } = require('./store');
11
+ const { healthSnapshot } = require('./health');
11
12
  const {
12
13
  connectDns,
13
14
  disconnectDns,
@@ -17,6 +18,15 @@ const {
17
18
  stopApp,
18
19
  } = require('./supervisor');
19
20
 
21
+ function healthSummary(app) {
22
+ const live = healthSnapshot(app.id);
23
+ if (live?.lastHeartbeatAt) {
24
+ return `health: heartbeat ${Math.round(live.heartbeatAgeMs / 1000)}s ago (every ${Math.round(live.heartbeatIntervalMs / 1000)}s)`;
25
+ }
26
+ if (app.readyAt) return `health: ready at ${app.readyAt}`;
27
+ return null;
28
+ }
29
+
20
30
  function appSummary(app) {
21
31
  return [
22
32
  `${app.name} (${app.id})`,
@@ -26,6 +36,7 @@ function appSummary(app) {
26
36
  `dns: ${app.dnsConnected ? app.publicUrl : 'disconnected'}`,
27
37
  `autostart: ${app.autostart !== false}`,
28
38
  `keepAlive: ${app.keepAlive !== false}`,
39
+ healthSummary(app),
29
40
  app.error ? `error: ${app.error}` : null,
30
41
  ].filter(Boolean).join('\n');
31
42
  }
@@ -1052,6 +1052,13 @@ function listPublicAutomations() {
1052
1052
  return publicAutomationGraph().automations;
1053
1053
  }
1054
1054
 
1055
+ // One graph build for callers that need automations + triggers + workflows
1056
+ // together (the state snapshot); the per-list functions above each rebuild
1057
+ // the full graph.
1058
+ function listPublicAutomationGraph() {
1059
+ return publicAutomationGraph();
1060
+ }
1061
+
1055
1062
  function listTriggers() {
1056
1063
  return readAutomationGraph().triggers;
1057
1064
  }
@@ -1666,6 +1673,7 @@ module.exports = {
1666
1673
  listAutomationRuns,
1667
1674
  listAutomations,
1668
1675
  listEventTriggersForIngress,
1676
+ listPublicAutomationGraph,
1669
1677
  listPublicAutomations,
1670
1678
  listPublicTriggers,
1671
1679
  listPublicWorkflows,
@@ -101,6 +101,7 @@ async function boot() {
101
101
  const { ensureAgentsDirs } = require('./agents/store');
102
102
  const { ensureAppsDirs } = require('./apps/store');
103
103
  const { startAppRouteSyncLoop } = require('./apps/advertise');
104
+ const { startHealthMonitor } = require('./apps/health');
104
105
  const { startRegisteredApps } = require('./apps/supervisor');
105
106
  const { startProjectContextService } = require('./project-context/store');
106
107
 
@@ -111,6 +112,7 @@ async function boot() {
111
112
 
112
113
  await startRegisteredApps();
113
114
  startAppRouteSyncLoop();
115
+ startHealthMonitor();
114
116
  require('./state/ports').startPortsBridge();
115
117
  }
116
118
 
@@ -49,6 +49,14 @@ async function handleAppRoutes(ctx) {
49
49
  await appsRest.handleUpdate(await ctx.readJsonBody(), ctx.sendJson, options);
50
50
  return true;
51
51
  }
52
+ if (route.path === '/ready' && ctx.method === 'POST') {
53
+ await appsRest.handleReady(await ctx.readJsonBody(), ctx.sendJson);
54
+ return true;
55
+ }
56
+ if (route.path === '/heartbeat' && ctx.method === 'POST') {
57
+ await appsRest.handleHeartbeat(await ctx.readJsonBody(), ctx.sendJson);
58
+ return true;
59
+ }
52
60
  if (route.path === '/connect-dns' && ctx.method === 'POST') {
53
61
  await appsRest.handleConnectDns(await ctx.readJsonBody(), ctx.sendJson, options);
54
62
  return true;
@@ -444,81 +444,7 @@ function migrate(database = openLocalDb()) {
444
444
  ON browser_login_sessions(expires_at);
445
445
 
446
446
  `);
447
- migrateLegacyToolboxTables(database);
448
- removeInheritedHarnessTools(database);
449
- }
450
-
451
- function tableExists(database, tableName) {
452
- const row = database.prepare(
453
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
454
- ).get(tableName);
455
- return Boolean(row);
456
- }
457
-
458
- function migrateLegacyToolboxTables(database) {
459
- if (tableExists(database, 'toolbox_tools')) {
460
- database.prepare(`
461
- INSERT OR IGNORE INTO tools (
462
- id,
463
- name,
464
- type,
465
- owner,
466
- origin,
467
- status,
468
- updated_at,
469
- tool_json
470
- )
471
- SELECT
472
- id,
473
- name,
474
- type,
475
- owner,
476
- origin,
477
- status,
478
- updated_at,
479
- tool_json
480
- FROM toolbox_tools
481
- `).run();
482
- }
483
-
484
- if (tableExists(database, 'toolbox_actions')) {
485
- database.prepare(`
486
- INSERT OR IGNORE INTO tool_actions (
487
- id,
488
- tool_id,
489
- name,
490
- status,
491
- updated_at,
492
- action_json
493
- )
494
- SELECT
495
- legacy.id,
496
- legacy.tool_id,
497
- legacy.name,
498
- legacy.status,
499
- legacy.updated_at,
500
- legacy.action_json
501
- FROM toolbox_actions legacy
502
- INNER JOIN tools ON tools.id = legacy.tool_id
503
- `).run();
504
- }
505
- }
506
-
507
- function removeInheritedHarnessTools(database) {
508
- database.prepare(`
509
- DELETE FROM tool_actions
510
- WHERE tool_id IN (
511
- SELECT id FROM tools
512
- WHERE owner IN ('codex')
513
- AND origin = 'catalog'
514
- )
515
- `).run();
516
-
517
- database.prepare(`
518
- DELETE FROM tools
519
- WHERE owner IN ('codex')
520
- AND origin = 'catalog'
521
- `).run();
447
+ require('./migrations').runLegacyMigrations(database);
522
448
  }
523
449
 
524
450
  function closeLocalDb() {
@@ -6,11 +6,17 @@ const { openLocalDb } = require('./db');
6
6
  const emitter = new EventEmitter();
7
7
  emitter.setMaxListeners(200);
8
8
 
9
- const DEFAULT_EVENT_LOG_RETENTION_HOURS = 24;
9
+ // The event log is a short reconnect buffer, not durable history: clients
10
+ // bridge brief stream blips from it, and anything older is served better by a
11
+ // fresh snapshot (the stream sends `reset` when history is gone). Kept just
12
+ // long enough to ride out sleep/wake and flaky-network windows.
13
+ const DEFAULT_EVENT_LOG_RETENTION_HOURS = 0.25;
10
14
  const DEFAULT_EVENT_LOG_PRUNE_BATCH_SIZE = 1000;
11
15
  const DEFAULT_EVENT_LOG_PRUNE_INTERVAL_MS = 60_000;
16
+ const EVENT_LOG_PRUNE_DELAY_MS = 1_000;
12
17
 
13
18
  let lastPruneAttemptAt = 0;
19
+ let pruneTimer = null;
14
20
 
15
21
  function positiveNumber(value, fallback) {
16
22
  const parsed = Number(value);
@@ -111,7 +117,19 @@ function insertStateEvent(database, input) {
111
117
 
112
118
  function publishStateEvent(event) {
113
119
  if (event) emitter.emit('event', event);
114
- maybePruneEventLog();
120
+ scheduleEventLogPrune();
121
+ }
122
+
123
+ // Retention runs off the publish hot path: publishing only arms a short
124
+ // one-shot timer, so the DELETE never adds latency to a write. The 60s
125
+ // throttle inside maybePruneEventLog still bounds how often it runs.
126
+ function scheduleEventLogPrune() {
127
+ if (pruneTimer) return;
128
+ pruneTimer = setTimeout(() => {
129
+ pruneTimer = null;
130
+ maybePruneEventLog();
131
+ }, EVENT_LOG_PRUNE_DELAY_MS);
132
+ if (typeof pruneTimer.unref === 'function') pruneTimer.unref();
115
133
  }
116
134
 
117
135
  function currentSeq() {
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * One-time data migrations that run after the schema is ensured. Everything
5
+ * here is idempotent (guarded by table existence or INSERT OR IGNORE /
6
+ * targeted DELETEs), so re-running on every open is safe.
7
+ */
8
+
9
+ function tableExists(database, tableName) {
10
+ const row = database.prepare(
11
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
12
+ ).get(tableName);
13
+ return Boolean(row);
14
+ }
15
+
16
+ // Imports rows from the pre-rename toolbox_tools/toolbox_actions tables into
17
+ // tools/tool_actions on machines that still carry them.
18
+ function migrateLegacyToolboxTables(database) {
19
+ if (tableExists(database, 'toolbox_tools')) {
20
+ database.prepare(`
21
+ INSERT OR IGNORE INTO tools (
22
+ id,
23
+ name,
24
+ type,
25
+ owner,
26
+ origin,
27
+ status,
28
+ updated_at,
29
+ tool_json
30
+ )
31
+ SELECT
32
+ id,
33
+ name,
34
+ type,
35
+ owner,
36
+ origin,
37
+ status,
38
+ updated_at,
39
+ tool_json
40
+ FROM toolbox_tools
41
+ `).run();
42
+ }
43
+
44
+ if (tableExists(database, 'toolbox_actions')) {
45
+ database.prepare(`
46
+ INSERT OR IGNORE INTO tool_actions (
47
+ id,
48
+ tool_id,
49
+ name,
50
+ status,
51
+ updated_at,
52
+ action_json
53
+ )
54
+ SELECT
55
+ legacy.id,
56
+ legacy.tool_id,
57
+ legacy.name,
58
+ legacy.status,
59
+ legacy.updated_at,
60
+ legacy.action_json
61
+ FROM toolbox_actions legacy
62
+ INNER JOIN tools ON tools.id = legacy.tool_id
63
+ `).run();
64
+ }
65
+ }
66
+
67
+ // Harness-inherited codex catalog tools were seeded by an older release and
68
+ // are now provided dynamically; stale rows are removed on open.
69
+ function removeInheritedHarnessTools(database) {
70
+ database.prepare(`
71
+ DELETE FROM tool_actions
72
+ WHERE tool_id IN (
73
+ SELECT id FROM tools
74
+ WHERE owner IN ('codex')
75
+ AND origin = 'catalog'
76
+ )
77
+ `).run();
78
+
79
+ database.prepare(`
80
+ DELETE FROM tools
81
+ WHERE owner IN ('codex')
82
+ AND origin = 'catalog'
83
+ `).run();
84
+ }
85
+
86
+ function runLegacyMigrations(database) {
87
+ migrateLegacyToolboxTables(database);
88
+ removeInheritedHarnessTools(database);
89
+ }
90
+
91
+ module.exports = { runLegacyMigrations };
@@ -12,6 +12,11 @@ function parsePositiveInt(value, fallback) {
12
12
  return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
13
13
  }
14
14
 
15
+ // Streams replay at most this many missed events on connect. Beyond it a
16
+ // fresh snapshot is strictly cheaper than replaying history that mostly
17
+ // overwrites itself, so the client is told to reset instead.
18
+ const STREAM_REPLAY_MAX_EVENTS = 1000;
19
+
15
20
  function sendSseEvent(res, event) {
16
21
  res.write(`id: ${event.seq}\n`);
17
22
  res.write('event: state\n');
@@ -60,11 +65,23 @@ function handleStream(req, res, query) {
60
65
  return;
61
66
  }
62
67
 
68
+ const backlog = listEventsAfter(after, { limit: STREAM_REPLAY_MAX_EVENTS + 1 });
69
+ if (backlog.length > STREAM_REPLAY_MAX_EVENTS) {
70
+ sendSseControl(res, 'reset', {
71
+ code: 'state_replay_too_large',
72
+ message: 'Too many missed events to replay; fetch a fresh snapshot.',
73
+ after,
74
+ reset: true,
75
+ });
76
+ res.end();
77
+ return;
78
+ }
79
+
63
80
  const unsubscribe = subscribeStateEvents((event) => {
64
81
  if (event.seq > after) sendSseEvent(res, event);
65
82
  });
66
83
 
67
- for (const event of listEventsAfter(after, { limit: 5000 })) {
84
+ for (const event of backlog) {
68
85
  sendSseEvent(res, event);
69
86
  }
70
87
 
@@ -10,14 +10,17 @@ function normalizeResources(resources) {
10
10
  return clean.length > 0 ? Array.from(new Set(clean)) : DEFAULT_RESOURCES;
11
11
  }
12
12
 
13
+ function automationGraph(cache) {
14
+ cache.automationGraph ||= require('../automations/store').listPublicAutomationGraph();
15
+ return cache.automationGraph;
16
+ }
17
+
13
18
  function readResource(resource, cache) {
14
19
  switch (resource) {
15
20
  case 'automations':
16
- cache.automations ||= require('../automations/store').listPublicAutomations();
17
- return cache.automations;
21
+ return automationGraph(cache).automations;
18
22
  case 'triggers':
19
- cache.triggers ||= require('../automations/store').listPublicTriggers();
20
- return cache.triggers;
23
+ return automationGraph(cache).triggers;
21
24
  case 'automation_runs':
22
25
  return require('../automations/store').listAutomationRuns({ limit: 300 });
23
26
  case 'workflow_cell_runs':
@@ -31,8 +34,7 @@ function readResource(resource, cache) {
31
34
  case 'event_triggers':
32
35
  return require('../events/store').loadEventTriggers().triggers;
33
36
  case 'workflows':
34
- cache.workflows ||= require('../automations/store').listPublicWorkflows();
35
- return cache.workflows;
37
+ return automationGraph(cache).workflows;
36
38
  case 'agents': {
37
39
  const { agentWithConfig } = require('../agent-config/store');
38
40
  return require('../agents/store').getAllAgentsWithBuiltins()
@@ -97,10 +99,19 @@ function buildSnapshot(resourcesInput) {
97
99
 
98
100
  for (let attempt = 0; attempt < 3; attempt += 1) {
99
101
  const beforeSeq = currentSeq();
102
+ // The cache is per-attempt on purpose: a retry exists because a write
103
+ // landed mid-read, so reusing reads across attempts would serve the very
104
+ // staleness the retry is trying to escape.
100
105
  const cache = {};
101
106
  const data = {};
102
107
  for (const resource of resources) {
103
- const value = readResource(resource, cache);
108
+ let value;
109
+ try {
110
+ value = readResource(resource, cache);
111
+ } catch (error) {
112
+ console.warn(`[LocalLive] Failed to read snapshot resource "${resource}":`, error?.message || error);
113
+ throw error;
114
+ }
104
115
  if (value !== undefined) data[resource] = value;
105
116
  }
106
117
  const afterSeq = currentSeq();
@@ -110,7 +121,11 @@ function buildSnapshot(resourcesInput) {
110
121
  lastUnstable = { seq: beforeSeq, stable: false, resources: data };
111
122
  }
112
123
 
113
- return lastUnstable || { seq: currentSeq(), stable: true, resources: {} };
124
+ if (lastUnstable) {
125
+ console.warn(`[LocalLive] Snapshot did not stabilize after 3 attempts (writes kept landing mid-read); serving best-effort data at seq ${lastUnstable.seq}.`);
126
+ return lastUnstable;
127
+ }
128
+ return { seq: currentSeq(), stable: true, resources: {} };
114
129
  }
115
130
 
116
131
  module.exports = { DEFAULT_RESOURCES, buildSnapshot, normalizeResources };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * End-to-end dogfood of the app health contract: the supervisor spawns a real
5
+ * app process that uses @amalgm/app (packages/app) to announce itself over
6
+ * real HTTP to the real /apps routes. Nothing is mocked.
7
+ */
8
+
9
+ const test = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-app-health-e2e-'));
16
+ process.env.AMALGM_DIR = path.join(tempRoot, 'user');
17
+ process.env.AMALGM_HOME = path.join(tempRoot, 'home');
18
+ process.env.AMALGM_RUNTIME_LABEL = 'test';
19
+ process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
20
+ process.env.AMALGM_RUNTIME_AUTH = 'disabled';
21
+
22
+ const { createRuntimeHttpServer } = require('../server/http-service');
23
+ const { handleAppRoutes } = require('../server/routes/apps');
24
+ const { getApp, saveApps } = require('../apps/store');
25
+ const { healthSnapshot } = require('../apps/health');
26
+ const { startApp, stopApp } = require('../apps/supervisor');
27
+
28
+ const SDK_PATH = path.resolve(__dirname, '../../../../packages/app');
29
+
30
+ function waitFor(predicate, label, timeoutMs = 10_000) {
31
+ const deadline = Date.now() + timeoutMs;
32
+ return new Promise((resolve, reject) => {
33
+ const tick = async () => {
34
+ try {
35
+ if (await predicate()) return resolve();
36
+ } catch {}
37
+ if (Date.now() >= deadline) return reject(new Error(`Timed out waiting for ${label}`));
38
+ setTimeout(tick, 100);
39
+ };
40
+ tick();
41
+ });
42
+ }
43
+
44
+ function getFreePort() {
45
+ return new Promise((resolve, reject) => {
46
+ const net = require('net');
47
+ const server = net.createServer();
48
+ server.on('error', reject);
49
+ server.listen(0, '127.0.0.1', () => {
50
+ const port = server.address().port;
51
+ server.close(() => resolve(port));
52
+ });
53
+ });
54
+ }
55
+
56
+ test('a spawned app announces itself through @amalgm/app and the runtime hears it', async (t) => {
57
+ // Real /apps routes on an ephemeral port; the supervisor hands this port to
58
+ // the app as AMALGM_MCP_URL via AMALGM_MCP_PORT.
59
+ const server = createRuntimeHttpServer((ctx) => handleAppRoutes(ctx));
60
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
61
+ t.after(() => server.close());
62
+ process.env.AMALGM_MCP_PORT = String(server.address().port);
63
+
64
+ const appCwd = fs.mkdtempSync(path.join(tempRoot, 'lease-app-'));
65
+ const script = path.join(appCwd, 'server.js');
66
+ fs.writeFileSync(script, [
67
+ `const sdk = require(${JSON.stringify(SDK_PATH)});`,
68
+ "require('http').createServer((req, res) => res.end('ok'))",
69
+ " .listen(Number(process.env.PORT), '127.0.0.1', () => {",
70
+ ' sdk.connect({ heartbeatIntervalMs: 5000 });',
71
+ ' });',
72
+ '',
73
+ ].join('\n'));
74
+
75
+ const appPort = await getFreePort();
76
+ saveApps({
77
+ version: 1,
78
+ apps: [{
79
+ id: 'app-lease-e2e',
80
+ kind: 'app',
81
+ name: 'Lease E2E',
82
+ cwd: appCwd,
83
+ port: appPort,
84
+ startCommand: `"${process.execPath}" "${script}"`,
85
+ appRef: 'leasee2e0000',
86
+ publicUrl: 'https://leasee2e0000.apps.amalgm.ai/',
87
+ dnsConnected: false,
88
+ autostart: true,
89
+ keepAlive: false,
90
+ desiredState: 'running',
91
+ status: 'registered',
92
+ pid: null,
93
+ }],
94
+ });
95
+
96
+ const app = await startApp('app-lease-e2e');
97
+ t.after(() => stopApp('app-lease-e2e').catch(() => {}));
98
+ assert.equal(app.status, 'running');
99
+ assert.equal(app.readyAt, null, 'a fresh spawn starts unannounced');
100
+
101
+ await waitFor(() => Boolean(getApp('app-lease-e2e').readyAt), 'app to announce ready');
102
+
103
+ const announced = getApp('app-lease-e2e');
104
+ assert.equal(announced.status, 'running');
105
+ assert.equal(announced.heartbeatIntervalMs, 5000);
106
+
107
+ const live = healthSnapshot('app-lease-e2e');
108
+ assert.notEqual(live, null);
109
+ assert.equal(live.heartbeatIntervalMs, 5000);
110
+ assert.equal(typeof live.lastHeartbeatAt, 'string');
111
+
112
+ // Stopping the app ends the process generation: enrollment is forgotten.
113
+ await stopApp('app-lease-e2e');
114
+ assert.equal(healthSnapshot('app-lease-e2e'), null);
115
+ assert.equal(getApp('app-lease-e2e').status, 'stopped');
116
+ });
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-app-health-'));
10
+ process.env.AMALGM_DIR = path.join(tempRoot, 'user');
11
+ process.env.AMALGM_HOME = path.join(tempRoot, 'home');
12
+ process.env.AMALGM_RUNTIME_LABEL = 'test';
13
+ process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
14
+ delete process.env.AMALGM_RUNTIME_TOKEN;
15
+ delete process.env.AMALGM_LOCAL_MODE;
16
+ process.env.AMALGM_RUNTIME_AUTH = 'disabled';
17
+
18
+ const { getApp, getConnectedRoutes, saveApps } = require('../apps/store');
19
+ const {
20
+ APP_HEALTH_POLICY,
21
+ clearAppHealth,
22
+ healthSnapshot,
23
+ recordHeartbeat,
24
+ recordReady,
25
+ sweepOnce,
26
+ } = require('../apps/health');
27
+
28
+ let appCounter = 0;
29
+
30
+ function seedApp(overrides = {}) {
31
+ appCounter += 1;
32
+ const id = overrides.id || `app-health-${appCounter}`;
33
+ saveApps({
34
+ version: 1,
35
+ apps: [{
36
+ id,
37
+ kind: 'app',
38
+ name: `Health ${appCounter}`,
39
+ cwd: tempRoot,
40
+ port: 9100 + appCounter,
41
+ startCommand: 'node server.js',
42
+ appRef: `health${appCounter}00000`.slice(0, 12),
43
+ publicUrl: `https://health${appCounter}.apps.amalgm.ai/`,
44
+ dnsConnected: true,
45
+ autostart: true,
46
+ keepAlive: true,
47
+ desiredState: 'running',
48
+ status: 'running',
49
+ pid: 4242,
50
+ ...overrides,
51
+ }],
52
+ });
53
+ return id;
54
+ }
55
+
56
+ test('a silent app is never touched by the monitor', () => {
57
+ const id = seedApp();
58
+ const transitioned = sweepOnce(Date.now() + 60 * 60_000);
59
+ assert.deepEqual(transitioned, []);
60
+ assert.equal(getApp(id).status, 'running');
61
+ assert.equal(healthSnapshot(id), null);
62
+ });
63
+
64
+ test('ready enrolls, stamps the record, and clamps the declared interval', () => {
65
+ const id = seedApp();
66
+ const result = recordReady(id, { heartbeatIntervalMs: 1 });
67
+ assert.equal(result.accepted, true);
68
+ assert.equal(result.health.heartbeatIntervalMs, APP_HEALTH_POLICY.minIntervalMs);
69
+
70
+ const app = getApp(id);
71
+ assert.equal(typeof app.readyAt, 'string');
72
+ assert.equal(typeof app.lastHeartbeatAt, 'string');
73
+ assert.equal(app.heartbeatIntervalMs, APP_HEALTH_POLICY.minIntervalMs);
74
+ clearAppHealth(id);
75
+ });
76
+
77
+ test('missed heartbeats mark a running app unresponsive and drop its route', () => {
78
+ const id = seedApp();
79
+ const start = Date.now();
80
+ recordHeartbeat(id, start);
81
+ assert.equal(getConnectedRoutes().some((route) => route.port === getApp(id).port), true);
82
+
83
+ const interval = APP_HEALTH_POLICY.defaultIntervalMs;
84
+ const withinBudget = sweepOnce(start + interval * APP_HEALTH_POLICY.missedBeats);
85
+ assert.deepEqual(withinBudget, [], 'not overdue until the missed-beat budget is spent');
86
+
87
+ const overdue = sweepOnce(start + interval * APP_HEALTH_POLICY.missedBeats + 1);
88
+ assert.deepEqual(overdue, [id]);
89
+ const app = getApp(id);
90
+ assert.equal(app.status, 'unresponsive');
91
+ assert.match(app.error, /No heartbeat/);
92
+ assert.equal(getConnectedRoutes().some((route) => route.port === app.port), false);
93
+ clearAppHealth(id);
94
+ });
95
+
96
+ test('a resumed heartbeat recovers an unresponsive app', () => {
97
+ const id = seedApp();
98
+ const start = Date.now();
99
+ recordHeartbeat(id, start);
100
+ sweepOnce(start + APP_HEALTH_POLICY.defaultIntervalMs * APP_HEALTH_POLICY.missedBeats + 1);
101
+ assert.equal(getApp(id).status, 'unresponsive');
102
+
103
+ const result = recordHeartbeat(id, start + APP_HEALTH_POLICY.defaultIntervalMs * 4);
104
+ assert.equal(result.accepted, true);
105
+ const app = getApp(id);
106
+ assert.equal(app.status, 'running');
107
+ assert.equal(app.error, null);
108
+ assert.equal(getConnectedRoutes().some((route) => route.port === app.port), true);
109
+ clearAppHealth(id);
110
+ });
111
+
112
+ test('signals from unknown or settled apps are rejected, not enrolled', () => {
113
+ assert.equal(recordReady('app-none').accepted, false);
114
+ assert.equal(recordReady('app-none').reason, 'not_found');
115
+ assert.equal(recordHeartbeat('app-none').accepted, false);
116
+
117
+ const stopped = seedApp({ desiredState: 'stopped', status: 'stopped' });
118
+ const result = recordHeartbeat(stopped);
119
+ assert.equal(result.accepted, false);
120
+ assert.equal(result.reason, 'status_stopped');
121
+ assert.equal(healthSnapshot(stopped), null);
122
+
123
+ const degraded = seedApp({ status: 'degraded' });
124
+ assert.equal(recordReady(degraded).accepted, false);
125
+ });
126
+
127
+ test('the monitor defers to the supervisor when the lifecycle moved on', () => {
128
+ const id = seedApp();
129
+ const start = Date.now();
130
+ recordHeartbeat(id, start);
131
+
132
+ // Supervisor stopped the app after enrollment (e.g. user stop raced a beat).
133
+ seedApp({ id, desiredState: 'stopped', status: 'stopped' });
134
+ const transitioned = sweepOnce(start + APP_HEALTH_POLICY.defaultIntervalMs * 10);
135
+ assert.deepEqual(transitioned, []);
136
+ assert.equal(getApp(id).status, 'stopped');
137
+ assert.equal(healthSnapshot(id), null, 'stale enrollment is dropped');
138
+ });
139
+
140
+ test('clearAppHealth ends the process generation', () => {
141
+ const id = seedApp();
142
+ recordHeartbeat(id);
143
+ assert.notEqual(healthSnapshot(id), null);
144
+ clearAppHealth(id);
145
+ assert.equal(healthSnapshot(id), null);
146
+
147
+ const transitioned = sweepOnce(Date.now() + 60 * 60_000);
148
+ assert.deepEqual(transitioned, []);
149
+ assert.equal(getApp(id).status, 'running', 'a cleared app is silent, not unresponsive');
150
+ });
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { EventEmitter } = require('events');
9
+
10
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-state-stream-test-'));
11
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
12
+
13
+ const { closeLocalDb, openLocalDb } = require('../state/db');
14
+ const { insertStateEvent } = require('../state/events');
15
+ const { handleStream } = require('../state/rest');
16
+
17
+ test.after(() => {
18
+ closeLocalDb();
19
+ fs.rmSync(tempRoot, { recursive: true, force: true });
20
+ });
21
+
22
+ function fakeStream() {
23
+ const req = new EventEmitter();
24
+ const chunks = [];
25
+ let ended = false;
26
+ const res = {
27
+ writeHead: () => {},
28
+ write: (chunk) => {
29
+ chunks.push(String(chunk));
30
+ },
31
+ end: () => {
32
+ ended = true;
33
+ },
34
+ };
35
+ const close = () => req.emit('close');
36
+ return {
37
+ req,
38
+ res,
39
+ close,
40
+ output: () => chunks.join(''),
41
+ ended: () => ended,
42
+ };
43
+ }
44
+
45
+ function insertEvents(count, startId = 0) {
46
+ const db = openLocalDb();
47
+ for (let i = 0; i < count; i += 1) {
48
+ insertStateEvent(db, {
49
+ resource: 'apps',
50
+ op: 'replace',
51
+ value: [{ id: `app-${startId + i}` }],
52
+ source: 'test',
53
+ });
54
+ }
55
+ }
56
+
57
+ test('stream replays a small backlog as state events', () => {
58
+ insertEvents(3);
59
+ const stream = fakeStream();
60
+
61
+ handleStream(stream.req, stream.res, { after: '0' });
62
+ stream.close();
63
+
64
+ const output = stream.output();
65
+ assert.equal((output.match(/event: state/g) || []).length, 3);
66
+ assert.ok(!output.includes('event: reset'), 'small backlog must not reset');
67
+ assert.equal(stream.ended(), false, 'stream stays open for live events');
68
+ });
69
+
70
+ test('stream resets instead of replaying an oversized backlog', () => {
71
+ insertEvents(1100, 1000);
72
+ const stream = fakeStream();
73
+
74
+ handleStream(stream.req, stream.res, { after: '0' });
75
+
76
+ const output = stream.output();
77
+ assert.ok(output.includes('event: reset'), 'oversized backlog must reset');
78
+ assert.ok(output.includes('state_replay_too_large'));
79
+ assert.ok(!output.includes('event: state'), 'no partial replay before reset');
80
+ assert.equal(stream.ended(), true, 'stream ends after reset');
81
+ });
82
+
83
+ test('stream replays only events after the requested seq', () => {
84
+ const stream = fakeStream();
85
+
86
+ // 1103 events exist; ask for everything after all but two of them.
87
+ handleStream(stream.req, stream.res, { after: '1101' });
88
+ stream.close();
89
+
90
+ const output = stream.output();
91
+ assert.equal((output.match(/event: state/g) || []).length, 2);
92
+ assert.ok(!output.includes('event: reset'));
93
+ });