amalgm 0.1.41 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,19 @@ amalgm login --setup-code ABCD-EFGH-JKLM-NPQR
22
22
 
23
23
  `amalgm doctor` checks the installed runtime, login record, service supervisor, required local ports, daemon state, and whether an HMAC proxy token is available.
24
24
 
25
- `amalgm start` installs/starts the best available service supervisor for the machine, then the supervisor runs `amalgm run` and keeps the local essentials alive:
25
+ `amalgm run` is the foreground runtime supervisor. It reads the declared service manifest, starts each local service, health-checks them, and restarts unhealthy children with backoff. In a container you control, make this the container command/entrypoint, ideally behind a tiny init such as Docker `--init`, `tini`, or the provider's init wrapper:
26
+
27
+ ```sh
28
+ amalgm run
29
+ ```
30
+
31
+ `amalgm start` is the host attachment helper. It installs/starts the best available launcher for the machine, then that launcher runs `amalgm run`:
32
+
33
+ - macOS host: LaunchAgent
34
+ - Linux host with real user systemd: systemd user service
35
+ - random/container shell where no service manager is available: portable best-effort watchdog
36
+
37
+ The runtime supervisor keeps the declared local essentials alive:
26
38
 
27
39
  - port monitor on `8081`
28
40
  - filesystem/auth watcher on `8082`
@@ -31,7 +43,7 @@ amalgm login --setup-code ABCD-EFGH-JKLM-NPQR
31
43
  - events/previews/artifact tunnel to `wire.events.amalgm.ai`
32
44
  - chat tunnel to `amalgm-chat-gateway`
33
45
 
34
- The npm supervisor binds local services to `127.0.0.1` by default and connects them outward through the registered Amalgm tunnels. For development without cloud registration, use `amalgm start --local-only`. For direct foreground debugging, use `amalgm start --foreground`.
46
+ The npm supervisor binds local services to `127.0.0.1` by default and connects them outward through the registered Amalgm tunnels. For development without cloud registration, use `amalgm start --local-only`. For direct foreground debugging, use `amalgm run` or `amalgm start --foreground`.
35
47
 
36
48
  Useful commands:
37
49
 
package/lib/cli.js CHANGED
@@ -28,6 +28,17 @@ const {
28
28
  stopService,
29
29
  uninstallService,
30
30
  } = require('./service');
31
+ const {
32
+ cleanupStaleRuntimeProcesses,
33
+ runtimeServiceProcesses,
34
+ supervisorProcesses,
35
+ } = require('./process-cleanup');
36
+ const {
37
+ runtimePortsForStatus,
38
+ runtimeServiceNames,
39
+ runtimeServiceScripts,
40
+ runtimeServices,
41
+ } = require('./runtime-manifest');
31
42
  const {
32
43
  deleteComputerAuth,
33
44
  loadComputerRecord,
@@ -84,6 +95,7 @@ function usage() {
84
95
  'Usage:',
85
96
  ' amalgm login [--app-url https://amalgm.ai] [--name "My Mac"] [--setup-code ABCD-EFGH-JKLM-NPQR] [--no-open] [--no-poll]',
86
97
  ' amalgm start [--foreground] [--local-only]',
98
+ ' amalgm run [--local-only]',
87
99
  ' amalgm stop',
88
100
  ' amalgm status',
89
101
  ' amalgm doctor',
@@ -139,36 +151,13 @@ function runtimeEntry(relativePath) {
139
151
  }
140
152
 
141
153
  function runtimeMissingFiles() {
142
- const required = [
143
- runtimeEntry('scripts/amalgm-mcp/index.js'),
144
- runtimeEntry('scripts/chat-server.js'),
145
- runtimeEntry('scripts/fs-watcher.js'),
146
- runtimeEntry('scripts/local-gateway.js'),
147
- runtimeEntry('scripts/port-monitor.js'),
148
- ];
154
+ const required = runtimeServiceScripts().map((script) => runtimeEntry(script));
149
155
  return required.filter((file) => !fs.existsSync(file));
150
156
  }
151
157
 
152
158
  function servicePorts() {
153
159
  const state = readJson(RUNTIME_STATE_FILE, null);
154
- const statePorts = state?.ports || {};
155
- if (Number.isInteger(state?.gateway_port)) {
156
- return [
157
- ['local-gateway', Number(state.gateway_port)],
158
- ['port-monitor', Number(statePorts.port_monitor || 0)],
159
- ['fs-watcher', Number(statePorts.fs_watcher || 0)],
160
- ['amalgm-mcp', Number(statePorts.amalgm_mcp || 0)],
161
- ['chat-server', Number(statePorts.chat_server || 0)],
162
- ].filter(([, port]) => Number.isInteger(port) && port > 0);
163
- }
164
-
165
- return [
166
- ['local-gateway', Number(process.env.AMALGM_GATEWAY_PORT || 28781)],
167
- ['port-monitor', Number(process.env.PORT_MONITOR_PORT || 8081)],
168
- ['fs-watcher', Number(process.env.FS_WATCHER_PORT || 8082)],
169
- ['amalgm-mcp', Number(process.env.AMALGM_MCP_PORT || 8083)],
170
- ['chat-server', Number(process.env.CHAT_SERVER_PORT || 8084)],
171
- ];
160
+ return runtimePortsForStatus(state, process.env);
172
161
  }
173
162
 
174
163
  function hasRequiredComputerFields(record) {
@@ -352,13 +341,7 @@ function isPortFree(port, host = '127.0.0.1') {
352
341
 
353
342
  async function unavailableServicePorts() {
354
343
  const results = [];
355
- const explicitEnvByService = new Map([
356
- ['local-gateway', 'AMALGM_GATEWAY_PORT'],
357
- ['port-monitor', 'PORT_MONITOR_PORT'],
358
- ['fs-watcher', 'FS_WATCHER_PORT'],
359
- ['amalgm-mcp', 'AMALGM_MCP_PORT'],
360
- ['chat-server', 'CHAT_SERVER_PORT'],
361
- ]);
344
+ const explicitEnvByService = new Map(runtimeServices().map((service) => [service.name, service.envName]));
362
345
  for (const [name, port] of servicePorts()) {
363
346
  const envName = explicitEnvByService.get(name);
364
347
  if (!envName || !process.env[envName]) continue;
@@ -846,6 +829,8 @@ async function start(options) {
846
829
  await stop();
847
830
  }
848
831
 
832
+ cleanupStaleRuntimeProcesses({ includeSupervisors: true });
833
+
849
834
  const blockedPorts = await unavailableServicePorts();
850
835
  if (blockedPorts.length > 0) {
851
836
  const detail = blockedPorts.map(([name, port]) => `${name} (${port})`).join(', ');
@@ -928,6 +913,7 @@ async function stop() {
928
913
  } catch {
929
914
  // noop
930
915
  }
916
+ cleanupStaleRuntimeProcesses({ includeSupervisors: true });
931
917
  console.log('Amalgm is not running.');
932
918
  return;
933
919
  }
@@ -951,10 +937,11 @@ async function stop() {
951
937
  } catch {
952
938
  // noop
953
939
  }
940
+ cleanupStaleRuntimeProcesses({ includeSupervisors: true });
954
941
  console.log('Amalgm stopped.');
955
942
  }
956
943
 
957
- async function checkHttp(port, pathName = '/healthz') {
944
+ async function checkHttpOnce(port, pathName = '/healthz', timeoutMs = 1500) {
958
945
  return new Promise((resolve) => {
959
946
  const req = http.request(
960
947
  {
@@ -962,7 +949,7 @@ async function checkHttp(port, pathName = '/healthz') {
962
949
  port,
963
950
  path: pathName,
964
951
  method: 'GET',
965
- timeout: 1000,
952
+ timeout: timeoutMs,
966
953
  },
967
954
  (res) => {
968
955
  res.resume();
@@ -978,6 +965,16 @@ async function checkHttp(port, pathName = '/healthz') {
978
965
  });
979
966
  }
980
967
 
968
+ async function checkHttp(port, pathName = '/healthz', options = {}) {
969
+ const attempts = Math.max(1, Number(options.attempts || 3));
970
+ const timeoutMs = Math.max(500, Number(options.timeoutMs || 1500));
971
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
972
+ if (await checkHttpOnce(port, pathName, timeoutMs)) return true;
973
+ if (attempt < attempts - 1) await sleep(150);
974
+ }
975
+ return false;
976
+ }
977
+
981
978
  async function waitForServices(timeoutMs) {
982
979
  const deadline = Date.now() + timeoutMs;
983
980
  let latest = [];
@@ -1028,9 +1025,26 @@ async function status() {
1028
1025
  }
1029
1026
  console.log(`HMAC proxy: ${proxyTokenFromRecord(record) ? 'configured' : 'not configured'}`);
1030
1027
 
1028
+ const runtimeChildren = runtimeServiceProcesses();
1029
+ const runtimeSupervisors = supervisorProcesses();
1030
+ const expectedServiceCount = runtimeServiceNames().length;
1031
+ if (runtimeChildren.length > expectedServiceCount || runtimeSupervisors.length > 1) {
1032
+ console.log(
1033
+ `Warning: multiple Amalgm runtime processes detected (${runtimeSupervisors.length} supervisor, ${runtimeChildren.length} services). Run \`amalgm stop && amalgm start\`.`,
1034
+ );
1035
+ }
1036
+
1037
+ const serviceStateByName = new Map(
1038
+ (Array.isArray(runtimeState?.services) ? runtimeState.services : [])
1039
+ .map((service) => [service.name, service]),
1040
+ );
1031
1041
  for (const [name, port] of servicePorts()) {
1032
1042
  const ok = await checkHttp(port);
1033
- console.log(`${name.padEnd(13)} ${ok ? 'up' : 'down'} http://127.0.0.1:${port}`);
1043
+ const serviceState = serviceStateByName.get(name);
1044
+ const detail = serviceState?.status && serviceState.status !== 'running'
1045
+ ? ` (${serviceState.status}${serviceState.reason ? `: ${serviceState.reason}` : ''})`
1046
+ : '';
1047
+ console.log(`${name.padEnd(13)} ${ok ? 'up' : 'down'} http://127.0.0.1:${port}${detail}`);
1034
1048
  }
1035
1049
  }
1036
1050
 
@@ -1101,6 +1115,16 @@ async function doctor() {
1101
1115
  );
1102
1116
  }
1103
1117
  add('daemon', daemonRunning, daemonRunning ? `pid ${pid}` : 'stopped', daemonRunning ? 'ok' : 'warn');
1118
+ const runtimeChildren = runtimeServiceProcesses();
1119
+ const runtimeSupervisors = supervisorProcesses();
1120
+ const expectedServices = daemonRunning ? runtimeServiceNames().length : 0;
1121
+ const cleanProcessShape = runtimeSupervisors.length <= 1 && runtimeChildren.length <= expectedServices;
1122
+ add(
1123
+ 'runtime processes',
1124
+ cleanProcessShape,
1125
+ `${runtimeSupervisors.length} supervisor, ${runtimeChildren.length} services`,
1126
+ cleanProcessShape ? 'ok' : 'warn',
1127
+ );
1104
1128
  if (lastShutdown?.signal || lastShutdown?.reason) {
1105
1129
  add(
1106
1130
  'last shutdown',
@@ -1109,15 +1133,25 @@ async function doctor() {
1109
1133
  'warn',
1110
1134
  );
1111
1135
  }
1136
+ const runtimeServiceState = Array.isArray(readJson(RUNTIME_STATE_FILE, null)?.services)
1137
+ ? readJson(RUNTIME_STATE_FILE, null).services
1138
+ : [];
1139
+ const unhealthyState = runtimeServiceState.filter((service) => (
1140
+ service.status && !['running', 'starting'].includes(service.status)
1141
+ ));
1142
+ if (runtimeServiceState.length > 0) {
1143
+ add(
1144
+ 'service states',
1145
+ unhealthyState.length === 0,
1146
+ unhealthyState.length === 0
1147
+ ? `${runtimeServiceState.length} declared service(s) running/starting`
1148
+ : unhealthyState.map((service) => `${service.name}:${service.status}`).join(', '),
1149
+ unhealthyState.length === 0 ? 'ok' : 'warn',
1150
+ );
1151
+ }
1112
1152
 
1113
1153
  for (const [name, port] of servicePorts()) {
1114
- const envName = {
1115
- 'local-gateway': 'AMALGM_GATEWAY_PORT',
1116
- 'port-monitor': 'PORT_MONITOR_PORT',
1117
- 'fs-watcher': 'FS_WATCHER_PORT',
1118
- 'amalgm-mcp': 'AMALGM_MCP_PORT',
1119
- 'chat-server': 'CHAT_SERVER_PORT',
1120
- }[name];
1154
+ const envName = runtimeServices().find((service) => service.name === name)?.envName;
1121
1155
  const ok = daemonRunning ? await checkHttp(port) : (!process.env[envName] || await isPortFree(port));
1122
1156
  add(
1123
1157
  `${name} port`,
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const {
8
+ AMALGM_DIR,
9
+ PACKAGE_ROOT,
10
+ RUNTIME_DIR,
11
+ } = require('./paths');
12
+ const {
13
+ runtimeServiceScripts,
14
+ } = require('./runtime-manifest');
15
+
16
+ const RUNTIME_SERVICE_MARKERS = runtimeServiceScripts()
17
+ .map((script) => path.join(RUNTIME_DIR, script).replace(/\\/g, '/'));
18
+ const RUNTIME_SERVICE_SUFFIXES = runtimeServiceScripts()
19
+ .map((script) => `runtime/${script}`.replace(/\\/g, '/'));
20
+
21
+ const SUPERVISOR_MARKER = path.join(PACKAGE_ROOT, 'bin', 'amalgm.js').replace(/\\/g, '/');
22
+ const SUPERVISOR_SUFFIX = '/bin/amalgm.js';
23
+
24
+ function isPidRunning(pid) {
25
+ if (!Number.isInteger(pid) || pid <= 0) return false;
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function waitForExit(pid, timeoutMs = 5000) {
35
+ const deadline = Date.now() + timeoutMs;
36
+ while (Date.now() < deadline && isPidRunning(pid)) {
37
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
38
+ }
39
+ return !isPidRunning(pid);
40
+ }
41
+
42
+ function readProcString(file) {
43
+ try {
44
+ return fs.readFileSync(file).toString('utf8');
45
+ } catch {
46
+ return '';
47
+ }
48
+ }
49
+
50
+ function readProcCommand(pid) {
51
+ const cmdline = readProcString(`/proc/${pid}/cmdline`).replace(/\0/g, ' ').trim();
52
+ if (cmdline) return cmdline;
53
+ try {
54
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], {
55
+ encoding: 'utf8',
56
+ timeout: 1000,
57
+ });
58
+ return String(result.stdout || '').trim();
59
+ } catch {
60
+ return '';
61
+ }
62
+ }
63
+
64
+ function readProcPpid(pid) {
65
+ try {
66
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
67
+ const lastParen = stat.lastIndexOf(')');
68
+ if (lastParen === -1) return null;
69
+ const parts = stat.slice(lastParen + 2).trim().split(/\s+/);
70
+ const ppid = Number(parts[1]);
71
+ return Number.isInteger(ppid) ? ppid : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function readProcEnv(pid) {
78
+ const raw = readProcString(`/proc/${pid}/environ`);
79
+ if (!raw) return null;
80
+ const env = {};
81
+ for (const entry of raw.split('\0')) {
82
+ if (!entry) continue;
83
+ const index = entry.indexOf('=');
84
+ if (index <= 0) continue;
85
+ env[entry.slice(0, index)] = entry.slice(index + 1);
86
+ }
87
+ return env;
88
+ }
89
+
90
+ function listProcProcesses() {
91
+ try {
92
+ return fs.readdirSync('/proc')
93
+ .filter((entry) => /^\d+$/.test(entry))
94
+ .map((entry) => Number(entry))
95
+ .filter((pid) => Number.isInteger(pid) && pid > 0)
96
+ .map((pid) => ({
97
+ pid,
98
+ ppid: readProcPpid(pid),
99
+ command: readProcCommand(pid),
100
+ env: readProcEnv(pid),
101
+ }))
102
+ .filter((item) => item.command);
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function listPsProcesses() {
109
+ try {
110
+ const result = spawnSync('ps', ['-eo', 'pid=,ppid=,command='], {
111
+ encoding: 'utf8',
112
+ timeout: 2000,
113
+ });
114
+ if (result.status !== 0) return [];
115
+ return String(result.stdout || '')
116
+ .split('\n')
117
+ .map((line) => {
118
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
119
+ if (!match) return null;
120
+ return {
121
+ pid: Number(match[1]),
122
+ ppid: Number(match[2]),
123
+ command: match[3],
124
+ env: null,
125
+ };
126
+ })
127
+ .filter(Boolean);
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+
133
+ function listProcesses() {
134
+ const processes = listProcProcesses() || listPsProcesses();
135
+ const commandByPid = new Map(processes.map((item) => [item.pid, item.command]));
136
+ return processes.map((item) => ({
137
+ ...item,
138
+ parentCommand: commandByPid.get(item.ppid) || readProcCommand(item.ppid),
139
+ }));
140
+ }
141
+
142
+ function normalizedCommand(command) {
143
+ return String(command || '').replace(/\\/g, '/');
144
+ }
145
+
146
+ function envMatchesAmalgmDir(env) {
147
+ if (!env || !env.AMALGM_DIR) return true;
148
+ return path.resolve(env.AMALGM_DIR) === path.resolve(AMALGM_DIR);
149
+ }
150
+
151
+ function commandLooksLikeRuntimeService(command, item = null, options = {}) {
152
+ const normalized = normalizedCommand(command);
153
+ if (RUNTIME_SERVICE_MARKERS.some((marker) => normalized.includes(marker))) return true;
154
+ if (!RUNTIME_SERVICE_SUFFIXES.some((marker) => normalized.includes(marker))) return false;
155
+ if (item?.env && envMatchesAmalgmDir(item.env)) return true;
156
+ if (!options.includeOrphanedForeign) return false;
157
+ const parent = normalizedCommand(item?.parentCommand || '');
158
+ return !commandLooksLikeAnySupervisor(parent);
159
+ }
160
+
161
+ function commandLooksLikeSupervisor(command) {
162
+ const normalized = normalizedCommand(command);
163
+ return normalized.includes(SUPERVISOR_MARKER) && /\brun\b/.test(normalized);
164
+ }
165
+
166
+ function commandLooksLikeAnySupervisor(command) {
167
+ const normalized = normalizedCommand(command);
168
+ return normalized.includes(SUPERVISOR_SUFFIX) && /\brun\b/.test(normalized);
169
+ }
170
+
171
+ function runtimeServiceProcesses(options = {}) {
172
+ const exclude = new Set([process.pid, ...(options.excludePids || [])].filter(Number.isInteger));
173
+ return listProcesses()
174
+ .filter((item) => !exclude.has(item.pid))
175
+ .filter((item) => commandLooksLikeRuntimeService(item.command, item, options))
176
+ .filter((item) => envMatchesAmalgmDir(item.env));
177
+ }
178
+
179
+ function supervisorProcesses(options = {}) {
180
+ const exclude = new Set([process.pid, ...(options.excludePids || [])].filter(Number.isInteger));
181
+ return listProcesses()
182
+ .filter((item) => !exclude.has(item.pid))
183
+ .filter((item) => commandLooksLikeSupervisor(item.command))
184
+ .filter((item) => envMatchesAmalgmDir(item.env));
185
+ }
186
+
187
+ function terminateProcesses(processes, options = {}) {
188
+ const logger = options.logger || null;
189
+ const unique = [...new Map(processes.map((item) => [item.pid, item])).values()]
190
+ .filter((item) => isPidRunning(item.pid));
191
+ if (unique.length === 0) return [];
192
+
193
+ for (const item of unique) {
194
+ try {
195
+ process.kill(item.pid, 'SIGTERM');
196
+ } catch {
197
+ // It may have exited between discovery and shutdown.
198
+ }
199
+ }
200
+
201
+ const survivors = [];
202
+ for (const item of unique) {
203
+ if (!waitForExit(item.pid, options.termTimeoutMs || 3000)) survivors.push(item);
204
+ }
205
+
206
+ for (const item of survivors) {
207
+ try {
208
+ process.kill(item.pid, 'SIGKILL');
209
+ } catch {
210
+ // noop
211
+ }
212
+ }
213
+ for (const item of survivors) waitForExit(item.pid, options.killTimeoutMs || 1500);
214
+
215
+ const stopped = unique.filter((item) => !isPidRunning(item.pid));
216
+ if (stopped.length > 0 && logger?.log) {
217
+ logger.log(`cleaned stale Amalgm runtime process(es): ${stopped.map((item) => item.pid).join(', ')}`);
218
+ }
219
+ return stopped;
220
+ }
221
+
222
+ function cleanupStaleRuntimeProcesses(options = {}) {
223
+ const stale = runtimeServiceProcesses({
224
+ includeOrphanedForeign: true,
225
+ ...options,
226
+ });
227
+ const supervisors = options.includeSupervisors ? supervisorProcesses(options) : [];
228
+ return terminateProcesses([...stale, ...supervisors], options);
229
+ }
230
+
231
+ module.exports = {
232
+ cleanupStaleRuntimeProcesses,
233
+ commandLooksLikeRuntimeService,
234
+ commandLooksLikeSupervisor,
235
+ isPidRunning,
236
+ runtimeServiceProcesses,
237
+ supervisorProcesses,
238
+ terminateProcesses,
239
+ };
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ const RUNTIME_SERVICES = [
4
+ {
5
+ name: 'local-gateway',
6
+ script: 'scripts/local-gateway.js',
7
+ portKey: 'gateway',
8
+ envName: 'AMALGM_GATEWAY_PORT',
9
+ stateKey: 'gateway',
10
+ defaultPort: 28781,
11
+ launchOrder: 50,
12
+ },
13
+ {
14
+ name: 'port-monitor',
15
+ script: 'scripts/port-monitor.js',
16
+ portKey: 'portMonitor',
17
+ envName: 'PORT_MONITOR_PORT',
18
+ stateKey: 'port_monitor',
19
+ defaultPort: 8081,
20
+ launchOrder: 10,
21
+ },
22
+ {
23
+ name: 'fs-watcher',
24
+ script: 'scripts/fs-watcher.js',
25
+ portKey: 'fsWatcher',
26
+ envName: 'FS_WATCHER_PORT',
27
+ stateKey: 'fs_watcher',
28
+ defaultPort: 8082,
29
+ launchOrder: 20,
30
+ },
31
+ {
32
+ name: 'amalgm-mcp',
33
+ script: 'scripts/amalgm-mcp/index.js',
34
+ portKey: 'amalgmMcp',
35
+ envName: 'AMALGM_MCP_PORT',
36
+ stateKey: 'amalgm_mcp',
37
+ defaultPort: 8083,
38
+ launchOrder: 40,
39
+ },
40
+ {
41
+ name: 'chat-server',
42
+ script: 'scripts/chat-server.js',
43
+ portKey: 'chatServer',
44
+ envName: 'CHAT_SERVER_PORT',
45
+ stateKey: 'chat_server',
46
+ defaultPort: 8084,
47
+ launchOrder: 30,
48
+ },
49
+ ];
50
+
51
+ function runtimeServices() {
52
+ return RUNTIME_SERVICES.map((service) => ({ ...service }));
53
+ }
54
+
55
+ function runtimeLaunchServices() {
56
+ return runtimeServices().sort((left, right) => left.launchOrder - right.launchOrder);
57
+ }
58
+
59
+ function runtimeServiceNames() {
60
+ return RUNTIME_SERVICES.map((service) => service.name);
61
+ }
62
+
63
+ function runtimeServiceScripts() {
64
+ return RUNTIME_SERVICES.map((service) => service.script);
65
+ }
66
+
67
+ function runtimeServiceByName(name) {
68
+ return RUNTIME_SERVICES.find((service) => service.name === name) || null;
69
+ }
70
+
71
+ function runtimePortsFromState(state) {
72
+ const statePorts = state?.ports || {};
73
+ const values = {};
74
+ for (const service of RUNTIME_SERVICES) {
75
+ if (service.stateKey === 'gateway' && Number.isInteger(state?.gateway_port)) {
76
+ values[service.name] = Number(state.gateway_port);
77
+ continue;
78
+ }
79
+ const port = Number(statePorts[service.stateKey] || 0);
80
+ if (Number.isInteger(port) && port > 0) values[service.name] = port;
81
+ }
82
+ return values;
83
+ }
84
+
85
+ function runtimePortsFromEnv(env = process.env) {
86
+ const values = {};
87
+ for (const service of RUNTIME_SERVICES) {
88
+ const port = Number(env[service.envName] || service.defaultPort);
89
+ if (Number.isInteger(port) && port > 0) values[service.name] = port;
90
+ }
91
+ return values;
92
+ }
93
+
94
+ function runtimePortsForStatus(state, env = process.env) {
95
+ const fromState = runtimePortsFromState(state);
96
+ const fromEnv = runtimePortsFromEnv(env);
97
+ return RUNTIME_SERVICES
98
+ .map((service) => [service.name, fromState[service.name] || fromEnv[service.name]])
99
+ .filter(([, port]) => Number.isInteger(port) && port > 0);
100
+ }
101
+
102
+ function runtimePortsState(ports) {
103
+ const statePorts = {};
104
+ for (const service of RUNTIME_SERVICES) {
105
+ const port = Number(ports?.[service.portKey] || 0);
106
+ if (!Number.isInteger(port) || port <= 0) continue;
107
+ if (service.stateKey !== 'gateway') statePorts[service.stateKey] = port;
108
+ }
109
+ return {
110
+ gateway_port: Number(ports?.gateway || 0) || undefined,
111
+ gateway_url: ports?.gateway ? `http://127.0.0.1:${ports.gateway}` : undefined,
112
+ ports: statePorts,
113
+ services: RUNTIME_SERVICES.map((service) => ({
114
+ name: service.name,
115
+ port: Number(ports?.[service.portKey] || 0) || null,
116
+ health_path: '/healthz',
117
+ })),
118
+ };
119
+ }
120
+
121
+ module.exports = {
122
+ runtimeLaunchServices,
123
+ runtimePortsForStatus,
124
+ runtimePortsFromEnv,
125
+ runtimePortsFromState,
126
+ runtimePortsState,
127
+ runtimeServiceByName,
128
+ runtimeServiceNames,
129
+ runtimeServiceScripts,
130
+ runtimeServices,
131
+ };