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 +1 -1
- package/runtime/scripts/amalgm-mcp/apps/health.js +216 -0
- package/runtime/scripts/amalgm-mcp/apps/rest.js +37 -1
- package/runtime/scripts/amalgm-mcp/apps/store.js +3 -0
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +8 -0
- package/runtime/scripts/amalgm-mcp/apps/tools.js +11 -0
- package/runtime/scripts/amalgm-mcp/automations/store.js +8 -0
- package/runtime/scripts/amalgm-mcp/index.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/apps.js +8 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +1 -75
- package/runtime/scripts/amalgm-mcp/state/events.js +20 -2
- package/runtime/scripts/amalgm-mcp/state/migrations.js +91 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +18 -1
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +23 -8
- package/runtime/scripts/amalgm-mcp/tests/apps-health-e2e.test.js +116 -0
- package/runtime/scripts/amalgm-mcp/tests/apps-health.test.js +150 -0
- package/runtime/scripts/amalgm-mcp/tests/state-stream-replay.test.js +93 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
return cache.automations;
|
|
21
|
+
return automationGraph(cache).automations;
|
|
18
22
|
case 'triggers':
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|