amalgm 0.1.123 → 0.1.124

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/lib/supervisor.js CHANGED
@@ -42,6 +42,7 @@ const {
42
42
  } = require('./process-cleanup');
43
43
  const {
44
44
  runtimeLaunchServices,
45
+ runtimePortsFromState,
45
46
  runtimePortsState,
46
47
  runtimeServiceScripts,
47
48
  } = require('./runtime-manifest');
@@ -52,6 +53,10 @@ const {
52
53
  } = require('./updater');
53
54
  const PACKAGE_VERSION = require('../package.json').version;
54
55
  const PACKAGE_ROOT_DIR = path.join(__dirname, '..');
56
+ const PORT_ALLOCATION_LOCK_DIR = path.join(AMALGM_HOME, 'runtime-port-allocation.lock');
57
+ const PORT_ALLOCATION_LOCK_FILE = path.join(PORT_ALLOCATION_LOCK_DIR, 'owner.json');
58
+ const PORT_ALLOCATION_LOCK_TIMEOUT_MS = 45_000;
59
+ const PORT_ALLOCATION_LOCK_STALE_MS = 120_000;
55
60
 
56
61
  // npm installs land in the npm prefix, which is only the tree this process runs
57
62
  // from when the runtime was started from that prefix. When the service points at
@@ -289,6 +294,11 @@ function serviceSpecs(record, ports, options = {}) {
289
294
  env: service.name === 'port-monitor' ? { ...env, PORT: process.env.PORT || '0' } : env,
290
295
  port: ports[service.portKey],
291
296
  healthPath: '/healthz',
297
+ expectedRuntimeInstanceId: AMALGM_RUNTIME_INSTANCE_ID,
298
+ expectedService: service.name,
299
+ healthHeaders: env.AMALGM_RUNTIME_TOKEN
300
+ ? { 'x-amalgm-runtime-token': env.AMALGM_RUNTIME_TOKEN }
301
+ : {},
292
302
  }));
293
303
  }
294
304
 
@@ -352,8 +362,114 @@ async function pickPort(name, envName, preferred, used) {
352
362
  throw new Error(`Could not allocate a free port for ${name}`);
353
363
  }
354
364
 
355
- async function resolveRuntimePorts() {
365
+ function runtimeStateFilesUnder(dir) {
366
+ const files = [];
367
+ let entries;
368
+ try {
369
+ entries = fs.readdirSync(dir, { withFileTypes: true });
370
+ } catch {
371
+ return files;
372
+ }
373
+
374
+ for (const entry of entries) {
375
+ const fullPath = path.join(dir, entry.name);
376
+ if (entry.isDirectory()) {
377
+ files.push(...runtimeStateFilesUnder(fullPath));
378
+ } else if (entry.isFile() && entry.name === 'runtime-state.json') {
379
+ files.push(fullPath);
380
+ }
381
+ }
382
+ return files;
383
+ }
384
+
385
+ function stateProcessRunning(state) {
386
+ const pids = [
387
+ state?.supervisor_pid,
388
+ state?.pid,
389
+ state?.gateway_pid,
390
+ ...(Array.isArray(state?.services) ? state.services.map((service) => service?.pid) : []),
391
+ ];
392
+ return pids.some((pid) => Number.isInteger(pid) && pid > 0 && isPidRunning(pid));
393
+ }
394
+
395
+ function liveDeclaredRuntimePorts() {
356
396
  const used = new Set();
397
+ const stateFiles = [
398
+ ...runtimeStateFilesUnder(path.join(AMALGM_HOME, 'users')),
399
+ ...runtimeStateFilesUnder(path.join(AMALGM_HOME, 'runtimes')),
400
+ ];
401
+ for (const stateFile of new Set(stateFiles)) {
402
+ if (path.resolve(stateFile) === path.resolve(RUNTIME_STATE_FILE)) continue;
403
+ const state = readJson(stateFile, null);
404
+ if (!state || !stateProcessRunning(state)) continue;
405
+ for (const port of Object.values(runtimePortsFromState(state))) {
406
+ if (Number.isInteger(port) && port > 0 && port <= 65535) used.add(port);
407
+ }
408
+ }
409
+ return used;
410
+ }
411
+
412
+ async function acquireRuntimePortAllocationLock(options = {}) {
413
+ const timeoutMs = options.timeoutMs || PORT_ALLOCATION_LOCK_TIMEOUT_MS;
414
+ const staleMs = options.staleMs || PORT_ALLOCATION_LOCK_STALE_MS;
415
+ const started = Date.now();
416
+ ensureDir(AMALGM_HOME);
417
+
418
+ while (true) {
419
+ try {
420
+ fs.mkdirSync(PORT_ALLOCATION_LOCK_DIR, { mode: 0o700 });
421
+ writeJsonSecret(PORT_ALLOCATION_LOCK_FILE, {
422
+ pid: process.pid,
423
+ runtime_instance_id: AMALGM_RUNTIME_INSTANCE_ID,
424
+ runtime_label: AMALGM_RUNTIME_LABEL,
425
+ user_scope: AMALGM_RUNTIME_USER_SCOPE,
426
+ acquired_at: new Date().toISOString(),
427
+ });
428
+ return () => {
429
+ const owner = readJson(PORT_ALLOCATION_LOCK_FILE, null);
430
+ if (owner?.pid === process.pid) {
431
+ fs.rmSync(PORT_ALLOCATION_LOCK_DIR, { recursive: true, force: true });
432
+ }
433
+ };
434
+ } catch (error) {
435
+ if (error?.code !== 'EEXIST') throw error;
436
+ const owner = readJson(PORT_ALLOCATION_LOCK_FILE, null);
437
+ let mtimeMs = 0;
438
+ try {
439
+ mtimeMs = fs.statSync(PORT_ALLOCATION_LOCK_DIR).mtimeMs;
440
+ } catch {
441
+ mtimeMs = 0;
442
+ }
443
+ const stale = !owner?.pid
444
+ || !isPidRunning(owner.pid)
445
+ || (mtimeMs > 0 && Date.now() - mtimeMs > staleMs);
446
+ if (stale) {
447
+ try {
448
+ fs.rmSync(PORT_ALLOCATION_LOCK_DIR, { recursive: true, force: true });
449
+ } catch {
450
+ // Try again on the next loop.
451
+ }
452
+ continue;
453
+ }
454
+ if (Date.now() - started > timeoutMs) {
455
+ throw new Error(`Timed out waiting for Amalgm runtime port allocation lock held by pid ${owner.pid}`);
456
+ }
457
+ await sleep(100);
458
+ }
459
+ }
460
+ }
461
+
462
+ async function withRuntimePortAllocationLock(fn) {
463
+ const release = await acquireRuntimePortAllocationLock();
464
+ try {
465
+ return await fn();
466
+ } finally {
467
+ release();
468
+ }
469
+ }
470
+
471
+ async function resolveRuntimePorts() {
472
+ const used = liveDeclaredRuntimePorts();
357
473
  const ports = {};
358
474
  for (const service of runtimeLaunchServices()) {
359
475
  ports[service.portKey] = await pickPort(service.name, service.envName, service.defaultPort, used);
@@ -401,6 +517,26 @@ function appendServiceState(name, update) {
401
517
  updateRuntimeState({ services: next });
402
518
  }
403
519
 
520
+ function removePidFileForThisSupervisor() {
521
+ try {
522
+ const pidRecord = readJson(PID_FILE, null);
523
+ if (pidRecord?.pid === process.pid) fs.unlinkSync(PID_FILE);
524
+ } catch {
525
+ // noop
526
+ }
527
+ }
528
+
529
+ function removeRuntimeStateForThisSupervisor() {
530
+ try {
531
+ const stateRecord = readJson(RUNTIME_STATE_FILE, null);
532
+ if (stateRecord?.pid === process.pid || stateRecord?.supervisor_pid === process.pid) {
533
+ fs.unlinkSync(RUNTIME_STATE_FILE);
534
+ }
535
+ } catch {
536
+ // noop
537
+ }
538
+ }
539
+
404
540
  function numberFromEnv(name, fallback, minimum = 0) {
405
541
  const value = Number(process.env[name]);
406
542
  if (!Number.isFinite(value) || value <= 0) return fallback;
@@ -780,10 +916,34 @@ function checkServiceHealth(spec, timeoutMs = CHILD_HEALTH_POLICY.timeoutMs) {
780
916
  path: spec.healthPath || '/healthz',
781
917
  method: 'GET',
782
918
  timeout: timeoutMs,
919
+ headers: spec.healthHeaders || {},
783
920
  },
784
921
  (res) => {
785
- res.resume();
786
- res.on('end', () => resolve(res.statusCode >= 200 && res.statusCode < 500));
922
+ const chunks = [];
923
+ res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
924
+ res.on('end', () => {
925
+ if (res.statusCode < 200 || res.statusCode >= 500) {
926
+ resolve(false);
927
+ return;
928
+ }
929
+ let body = {};
930
+ try {
931
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
932
+ } catch {
933
+ resolve(false);
934
+ return;
935
+ }
936
+ const runtimeInstanceId = body.runtime_instance_id || body.runtimeInstanceId || '';
937
+ if (spec.expectedRuntimeInstanceId && runtimeInstanceId !== spec.expectedRuntimeInstanceId) {
938
+ resolve(false);
939
+ return;
940
+ }
941
+ if (spec.expectedService && body.service && body.service !== spec.expectedService) {
942
+ resolve(false);
943
+ return;
944
+ }
945
+ resolve(true);
946
+ });
787
947
  },
788
948
  );
789
949
  req.on('timeout', () => {
@@ -806,6 +966,23 @@ function createManagedProcess(spec, options) {
806
966
  let restartDelayMs = CHILD_RESTART_POLICY.initialDelayMs;
807
967
  let restartHistory = [];
808
968
  let state = 'starting';
969
+ let readySettled = false;
970
+ let readyTimer = null;
971
+ let resolveReady;
972
+ let rejectReady;
973
+ const ready = new Promise((resolve, reject) => {
974
+ resolveReady = resolve;
975
+ rejectReady = reject;
976
+ });
977
+
978
+ const settleReady = (error = null) => {
979
+ if (readySettled) return;
980
+ readySettled = true;
981
+ if (readyTimer) clearTimeout(readyTimer);
982
+ readyTimer = null;
983
+ if (error) rejectReady(error);
984
+ else resolveReady();
985
+ };
809
986
 
810
987
  const setState = (nextState, extra = {}) => {
811
988
  state = nextState;
@@ -823,20 +1000,24 @@ function createManagedProcess(spec, options) {
823
1000
  };
824
1001
 
825
1002
  const scheduleHealthCheck = () => {
1003
+ const delayMs = state === 'running' ? CHILD_HEALTH_POLICY.intervalMs : 500;
826
1004
  clearHealthTimer();
827
1005
  if (stopped) return;
828
1006
  healthTimer = setTimeout(async () => {
829
1007
  if (stopped || !child) return;
830
- if (Date.now() - startedAt < CHILD_HEALTH_POLICY.graceMs) {
831
- scheduleHealthCheck();
832
- return;
833
- }
834
1008
  const ok = await checkServiceHealth(spec);
835
1009
  if (stopped || !child) return;
836
1010
  if (ok) {
837
1011
  healthFailures = 0;
838
- if (state !== 'running') setState('running');
1012
+ if (state !== 'running') {
1013
+ setState('running');
1014
+ settleReady();
1015
+ }
839
1016
  } else {
1017
+ if (Date.now() - startedAt < CHILD_HEALTH_POLICY.graceMs) {
1018
+ scheduleHealthCheck();
1019
+ return;
1020
+ }
840
1021
  healthFailures += 1;
841
1022
  writePrefixed(
842
1023
  stream,
@@ -865,7 +1046,7 @@ function createManagedProcess(spec, options) {
865
1046
  }
866
1047
  }
867
1048
  scheduleHealthCheck();
868
- }, CHILD_HEALTH_POLICY.intervalMs);
1049
+ }, delayMs);
869
1050
  };
870
1051
 
871
1052
  const canRestart = () => {
@@ -894,6 +1075,12 @@ function createManagedProcess(spec, options) {
894
1075
  healthFailures = 0;
895
1076
  startedAt = Date.now();
896
1077
  setState('starting');
1078
+ if (!readySettled) {
1079
+ if (readyTimer) clearTimeout(readyTimer);
1080
+ readyTimer = setTimeout(() => {
1081
+ settleReady(new Error(`${spec.name} did not become healthy on port ${spec.port}`));
1082
+ }, CHILD_HEALTH_POLICY.graceMs + (CHILD_HEALTH_POLICY.intervalMs * CHILD_HEALTH_POLICY.maxFailures));
1083
+ }
897
1084
  child = spawn(spec.command, spec.args, {
898
1085
  cwd: RUNTIME_DIR,
899
1086
  env: spec.env,
@@ -907,7 +1094,7 @@ function createManagedProcess(spec, options) {
907
1094
  `started pid=${child.pid} command=${[spec.command, ...spec.args].join(' ')}\n`,
908
1095
  options.foreground ? process.stdout : null,
909
1096
  );
910
- setState('running', { started_at: new Date(startedAt).toISOString() });
1097
+ setState('starting', { started_at: new Date(startedAt).toISOString() });
911
1098
  scheduleHealthCheck();
912
1099
 
913
1100
  child.stdout.on('data', (chunk) => {
@@ -926,6 +1113,9 @@ function createManagedProcess(spec, options) {
926
1113
  );
927
1114
  const runtimeMs = Date.now() - startedAt;
928
1115
  child = null;
1116
+ if (!readySettled) {
1117
+ settleReady(new Error(`${spec.name} exited before becoming healthy`));
1118
+ }
929
1119
  if (!stopped) {
930
1120
  if (runtimeMs >= CHILD_RESTART_POLICY.stableAfterMs) {
931
1121
  restartDelayMs = CHILD_RESTART_POLICY.initialDelayMs;
@@ -955,6 +1145,7 @@ function createManagedProcess(spec, options) {
955
1145
  return {
956
1146
  stop() {
957
1147
  stopped = true;
1148
+ settleReady(new Error(`${spec.name} stopped before becoming healthy`));
958
1149
  if (restartTimer) clearTimeout(restartTimer);
959
1150
  clearHealthTimer();
960
1151
  setState('stopping');
@@ -967,6 +1158,7 @@ function createManagedProcess(spec, options) {
967
1158
  }
968
1159
  stream.end();
969
1160
  },
1161
+ ready,
970
1162
  };
971
1163
  }
972
1164
 
@@ -1022,6 +1214,7 @@ async function startSupervisor(options = {}) {
1022
1214
  supervisor_pid: process.pid,
1023
1215
  source: 'npm',
1024
1216
  runtime_label: AMALGM_RUNTIME_LABEL,
1217
+ runtime_instance_id: AMALGM_RUNTIME_INSTANCE_ID,
1025
1218
  label: AMALGM_RUNTIME_LABEL,
1026
1219
  branch: AMALGM_BRANCH,
1027
1220
  user_scope: AMALGM_RUNTIME_USER_SCOPE,
@@ -1038,15 +1231,27 @@ async function startSupervisor(options = {}) {
1038
1231
  console.warn('No registered computer found. Run `amalgm login` to enable event/chat tunnels.');
1039
1232
  }
1040
1233
 
1041
- const ports = await resolveRuntimePorts();
1042
- Object.assign(process.env, {
1043
- AMALGM_GATEWAY_PORT: String(ports.gateway),
1044
- AMALGM_MCP_PORT: String(ports.amalgmMcp),
1045
- CHAT_SERVER_PORT: String(ports.chatServer),
1046
- PORT_MONITOR_PORT: String(ports.portMonitor),
1234
+ let ports = null;
1235
+ let managed = [];
1236
+ await withRuntimePortAllocationLock(async () => {
1237
+ ports = await resolveRuntimePorts();
1238
+ Object.assign(process.env, {
1239
+ AMALGM_GATEWAY_PORT: String(ports.gateway),
1240
+ AMALGM_MCP_PORT: String(ports.amalgmMcp),
1241
+ CHAT_SERVER_PORT: String(ports.chatServer),
1242
+ PORT_MONITOR_PORT: String(ports.portMonitor),
1243
+ });
1244
+ updateRuntimeState(runtimePortsState(ports));
1245
+ managed = serviceSpecs(record, ports, options).map((spec) => createManagedProcess(spec, options));
1246
+ try {
1247
+ await Promise.all(managed.map((proc) => proc.ready));
1248
+ } catch (error) {
1249
+ for (const proc of managed) proc.stop();
1250
+ removePidFileForThisSupervisor();
1251
+ removeRuntimeStateForThisSupervisor();
1252
+ throw error;
1253
+ }
1047
1254
  });
1048
- updateRuntimeState(runtimePortsState(ports));
1049
- const managed = serviceSpecs(record, ports, options).map((spec) => createManagedProcess(spec, options));
1050
1255
  const tunnels = [];
1051
1256
 
1052
1257
  if (!options.localOnly && record?.tunnel_url && record?.tunnel_token) {
@@ -1079,18 +1284,8 @@ async function startSupervisor(options = {}) {
1079
1284
  writeShutdownReason(signal);
1080
1285
  for (const tunnel of tunnels) tunnel.stop();
1081
1286
  for (const proc of managed) proc.stop();
1082
- try {
1083
- const pidRecord = readJson(PID_FILE, null);
1084
- if (pidRecord?.pid === process.pid) fs.unlinkSync(PID_FILE);
1085
- } catch {
1086
- // noop
1087
- }
1088
- try {
1089
- const stateRecord = readJson(RUNTIME_STATE_FILE, null);
1090
- if (stateRecord?.pid === process.pid) fs.unlinkSync(RUNTIME_STATE_FILE);
1091
- } catch {
1092
- // noop
1093
- }
1287
+ removePidFileForThisSupervisor();
1288
+ removeRuntimeStateForThisSupervisor();
1094
1289
  resolve();
1095
1290
  };
1096
1291
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.123",
3
+ "version": "0.1.124",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -12,6 +12,10 @@ async function handleHealthRoutes(ctx) {
12
12
  const automations = listAutomations();
13
13
  ctx.sendJson(200, {
14
14
  status: 'ok',
15
+ service: 'amalgm-mcp',
16
+ runtime_label: process.env.AMALGM_RUNTIME_LABEL || 'main',
17
+ runtime_instance_id: process.env.AMALGM_RUNTIME_INSTANCE_ID || '',
18
+ pid: process.pid,
15
19
  automations: automations.length,
16
20
  enabledAutomations: automations.filter((automation) => automation.enabled !== false).length,
17
21
  apps: loadApps().apps.length,
@@ -72,7 +72,17 @@ function createServer(core = createCore()) {
72
72
  const route = parseRoute(req.url);
73
73
  if (!route) return sendJson(res, 404, { error: 'Not found' });
74
74
  try {
75
- if (route.handler === 'healthz') return sendJson(res, 200, { status: 'ok', runtime: 'chat-core', uptime: process.uptime() });
75
+ if (route.handler === 'healthz') {
76
+ return sendJson(res, 200, {
77
+ status: 'ok',
78
+ service: 'chat-server',
79
+ runtime: 'chat-core',
80
+ runtime_label: process.env.AMALGM_RUNTIME_LABEL || 'main',
81
+ runtime_instance_id: process.env.AMALGM_RUNTIME_INSTANCE_ID || '',
82
+ pid: process.pid,
83
+ uptime: process.uptime(),
84
+ });
85
+ }
76
86
  if (route.handler === 'active') return sendJson(res, 200, core.active(route.id));
77
87
  if (route.handler === 'runtime-idle') return sendJson(res, 200, core.idleStatus());
78
88
  if (route.handler === 'stop') {
@@ -58,6 +58,7 @@ const LOG_DIR = runtimeLogDir();
58
58
  const BIND_HOST = process.env.AMALGM_BIND_HOST || '127.0.0.1';
59
59
  const OWNER = process.env.AMALGM_RUNTIME_SOURCE || 'local';
60
60
  const RUNTIME_LABEL = process.env.AMALGM_RUNTIME_LABEL || 'main';
61
+ const RUNTIME_INSTANCE_ID = process.env.AMALGM_RUNTIME_INSTANCE_ID || '';
61
62
  const RUNTIME_BRANCH = process.env.AMALGM_BRANCH || (RUNTIME_LABEL === 'preview' ? 'preview' : 'main');
62
63
  const RUNTIME_USER_SCOPE = process.env.AMALGM_RUNTIME_USER_ID
63
64
  || process.env.AMALGM_USER_SCOPE
@@ -377,6 +378,7 @@ function writeRuntimeState(actualPort) {
377
378
  owner: OWNER,
378
379
  source: OWNER,
379
380
  runtime_label: RUNTIME_LABEL,
381
+ runtime_instance_id: RUNTIME_INSTANCE_ID,
380
382
  label: RUNTIME_LABEL,
381
383
  branch: RUNTIME_BRANCH,
382
384
  user_scope: RUNTIME_USER_SCOPE,
@@ -982,6 +984,7 @@ const server = http.createServer(async (req, res) => {
982
984
  service: 'local-gateway',
983
985
  owner: OWNER,
984
986
  runtime_label: RUNTIME_LABEL,
987
+ runtime_instance_id: RUNTIME_INSTANCE_ID,
985
988
  branch: RUNTIME_BRANCH,
986
989
  user_scope: RUNTIME_USER_SCOPE,
987
990
  pid: process.pid,
@@ -15,6 +15,8 @@ const { runtimePort } = require('../lib/runtime-manifest');
15
15
 
16
16
  const PORT = runtimePort('port-monitor');
17
17
  const BIND_HOST = process.env.AMALGM_BIND_HOST || '0.0.0.0';
18
+ const RUNTIME_INSTANCE_ID = process.env.AMALGM_RUNTIME_INSTANCE_ID || '';
19
+ const RUNTIME_LABEL = process.env.AMALGM_RUNTIME_LABEL || 'main';
18
20
  const AGENT_PORT = parseInt(process.env.PORT || '8080');
19
21
  const AMALGM_MCP_PORT = runtimePort('amalgm-mcp');
20
22
  const CHAT_SERVER_PORT = runtimePort('chat-server');
@@ -186,6 +188,10 @@ const server = http.createServer((req, res) => {
186
188
  res.writeHead(200, { 'Content-Type': 'application/json' });
187
189
  res.end(JSON.stringify({
188
190
  status: 'ok',
191
+ service: 'port-monitor',
192
+ runtime_label: RUNTIME_LABEL,
193
+ runtime_instance_id: RUNTIME_INSTANCE_ID,
194
+ pid: process.pid,
189
195
  activePorts: [...knownPorts.entries()].map(([port, processInfo]) => ({ port, processName: publicProcessName(processInfo) })),
190
196
  }));
191
197
  return;