amalgm 0.1.43 → 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 +14 -2
- package/lib/cli.js +51 -44
- package/lib/process-cleanup.js +49 -23
- package/lib/runtime-manifest.js +131 -0
- package/lib/service.js +11 -1
- package/lib/supervisor.js +204 -51
- package/package.json +2 -2
- package/runtime/scripts/local-gateway.js +4 -0
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
|
|
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
|
@@ -33,6 +33,12 @@ const {
|
|
|
33
33
|
runtimeServiceProcesses,
|
|
34
34
|
supervisorProcesses,
|
|
35
35
|
} = require('./process-cleanup');
|
|
36
|
+
const {
|
|
37
|
+
runtimePortsForStatus,
|
|
38
|
+
runtimeServiceNames,
|
|
39
|
+
runtimeServiceScripts,
|
|
40
|
+
runtimeServices,
|
|
41
|
+
} = require('./runtime-manifest');
|
|
36
42
|
const {
|
|
37
43
|
deleteComputerAuth,
|
|
38
44
|
loadComputerRecord,
|
|
@@ -89,6 +95,7 @@ function usage() {
|
|
|
89
95
|
'Usage:',
|
|
90
96
|
' amalgm login [--app-url https://amalgm.ai] [--name "My Mac"] [--setup-code ABCD-EFGH-JKLM-NPQR] [--no-open] [--no-poll]',
|
|
91
97
|
' amalgm start [--foreground] [--local-only]',
|
|
98
|
+
' amalgm run [--local-only]',
|
|
92
99
|
' amalgm stop',
|
|
93
100
|
' amalgm status',
|
|
94
101
|
' amalgm doctor',
|
|
@@ -144,36 +151,13 @@ function runtimeEntry(relativePath) {
|
|
|
144
151
|
}
|
|
145
152
|
|
|
146
153
|
function runtimeMissingFiles() {
|
|
147
|
-
const required =
|
|
148
|
-
runtimeEntry('scripts/amalgm-mcp/index.js'),
|
|
149
|
-
runtimeEntry('scripts/chat-server.js'),
|
|
150
|
-
runtimeEntry('scripts/fs-watcher.js'),
|
|
151
|
-
runtimeEntry('scripts/local-gateway.js'),
|
|
152
|
-
runtimeEntry('scripts/port-monitor.js'),
|
|
153
|
-
];
|
|
154
|
+
const required = runtimeServiceScripts().map((script) => runtimeEntry(script));
|
|
154
155
|
return required.filter((file) => !fs.existsSync(file));
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
function servicePorts() {
|
|
158
159
|
const state = readJson(RUNTIME_STATE_FILE, null);
|
|
159
|
-
|
|
160
|
-
if (Number.isInteger(state?.gateway_port)) {
|
|
161
|
-
return [
|
|
162
|
-
['local-gateway', Number(state.gateway_port)],
|
|
163
|
-
['port-monitor', Number(statePorts.port_monitor || 0)],
|
|
164
|
-
['fs-watcher', Number(statePorts.fs_watcher || 0)],
|
|
165
|
-
['amalgm-mcp', Number(statePorts.amalgm_mcp || 0)],
|
|
166
|
-
['chat-server', Number(statePorts.chat_server || 0)],
|
|
167
|
-
].filter(([, port]) => Number.isInteger(port) && port > 0);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return [
|
|
171
|
-
['local-gateway', Number(process.env.AMALGM_GATEWAY_PORT || 28781)],
|
|
172
|
-
['port-monitor', Number(process.env.PORT_MONITOR_PORT || 8081)],
|
|
173
|
-
['fs-watcher', Number(process.env.FS_WATCHER_PORT || 8082)],
|
|
174
|
-
['amalgm-mcp', Number(process.env.AMALGM_MCP_PORT || 8083)],
|
|
175
|
-
['chat-server', Number(process.env.CHAT_SERVER_PORT || 8084)],
|
|
176
|
-
];
|
|
160
|
+
return runtimePortsForStatus(state, process.env);
|
|
177
161
|
}
|
|
178
162
|
|
|
179
163
|
function hasRequiredComputerFields(record) {
|
|
@@ -357,13 +341,7 @@ function isPortFree(port, host = '127.0.0.1') {
|
|
|
357
341
|
|
|
358
342
|
async function unavailableServicePorts() {
|
|
359
343
|
const results = [];
|
|
360
|
-
const explicitEnvByService = new Map([
|
|
361
|
-
['local-gateway', 'AMALGM_GATEWAY_PORT'],
|
|
362
|
-
['port-monitor', 'PORT_MONITOR_PORT'],
|
|
363
|
-
['fs-watcher', 'FS_WATCHER_PORT'],
|
|
364
|
-
['amalgm-mcp', 'AMALGM_MCP_PORT'],
|
|
365
|
-
['chat-server', 'CHAT_SERVER_PORT'],
|
|
366
|
-
]);
|
|
344
|
+
const explicitEnvByService = new Map(runtimeServices().map((service) => [service.name, service.envName]));
|
|
367
345
|
for (const [name, port] of servicePorts()) {
|
|
368
346
|
const envName = explicitEnvByService.get(name);
|
|
369
347
|
if (!envName || !process.env[envName]) continue;
|
|
@@ -963,7 +941,7 @@ async function stop() {
|
|
|
963
941
|
console.log('Amalgm stopped.');
|
|
964
942
|
}
|
|
965
943
|
|
|
966
|
-
async function
|
|
944
|
+
async function checkHttpOnce(port, pathName = '/healthz', timeoutMs = 1500) {
|
|
967
945
|
return new Promise((resolve) => {
|
|
968
946
|
const req = http.request(
|
|
969
947
|
{
|
|
@@ -971,7 +949,7 @@ async function checkHttp(port, pathName = '/healthz') {
|
|
|
971
949
|
port,
|
|
972
950
|
path: pathName,
|
|
973
951
|
method: 'GET',
|
|
974
|
-
timeout:
|
|
952
|
+
timeout: timeoutMs,
|
|
975
953
|
},
|
|
976
954
|
(res) => {
|
|
977
955
|
res.resume();
|
|
@@ -987,6 +965,16 @@ async function checkHttp(port, pathName = '/healthz') {
|
|
|
987
965
|
});
|
|
988
966
|
}
|
|
989
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
|
+
|
|
990
978
|
async function waitForServices(timeoutMs) {
|
|
991
979
|
const deadline = Date.now() + timeoutMs;
|
|
992
980
|
let latest = [];
|
|
@@ -1039,15 +1027,24 @@ async function status() {
|
|
|
1039
1027
|
|
|
1040
1028
|
const runtimeChildren = runtimeServiceProcesses();
|
|
1041
1029
|
const runtimeSupervisors = supervisorProcesses();
|
|
1042
|
-
|
|
1030
|
+
const expectedServiceCount = runtimeServiceNames().length;
|
|
1031
|
+
if (runtimeChildren.length > expectedServiceCount || runtimeSupervisors.length > 1) {
|
|
1043
1032
|
console.log(
|
|
1044
1033
|
`Warning: multiple Amalgm runtime processes detected (${runtimeSupervisors.length} supervisor, ${runtimeChildren.length} services). Run \`amalgm stop && amalgm start\`.`,
|
|
1045
1034
|
);
|
|
1046
1035
|
}
|
|
1047
1036
|
|
|
1037
|
+
const serviceStateByName = new Map(
|
|
1038
|
+
(Array.isArray(runtimeState?.services) ? runtimeState.services : [])
|
|
1039
|
+
.map((service) => [service.name, service]),
|
|
1040
|
+
);
|
|
1048
1041
|
for (const [name, port] of servicePorts()) {
|
|
1049
1042
|
const ok = await checkHttp(port);
|
|
1050
|
-
|
|
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}`);
|
|
1051
1048
|
}
|
|
1052
1049
|
}
|
|
1053
1050
|
|
|
@@ -1120,7 +1117,7 @@ async function doctor() {
|
|
|
1120
1117
|
add('daemon', daemonRunning, daemonRunning ? `pid ${pid}` : 'stopped', daemonRunning ? 'ok' : 'warn');
|
|
1121
1118
|
const runtimeChildren = runtimeServiceProcesses();
|
|
1122
1119
|
const runtimeSupervisors = supervisorProcesses();
|
|
1123
|
-
const expectedServices = daemonRunning ?
|
|
1120
|
+
const expectedServices = daemonRunning ? runtimeServiceNames().length : 0;
|
|
1124
1121
|
const cleanProcessShape = runtimeSupervisors.length <= 1 && runtimeChildren.length <= expectedServices;
|
|
1125
1122
|
add(
|
|
1126
1123
|
'runtime processes',
|
|
@@ -1136,15 +1133,25 @@ async function doctor() {
|
|
|
1136
1133
|
'warn',
|
|
1137
1134
|
);
|
|
1138
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
|
+
}
|
|
1139
1152
|
|
|
1140
1153
|
for (const [name, port] of servicePorts()) {
|
|
1141
|
-
const envName =
|
|
1142
|
-
'local-gateway': 'AMALGM_GATEWAY_PORT',
|
|
1143
|
-
'port-monitor': 'PORT_MONITOR_PORT',
|
|
1144
|
-
'fs-watcher': 'FS_WATCHER_PORT',
|
|
1145
|
-
'amalgm-mcp': 'AMALGM_MCP_PORT',
|
|
1146
|
-
'chat-server': 'CHAT_SERVER_PORT',
|
|
1147
|
-
}[name];
|
|
1154
|
+
const envName = runtimeServices().find((service) => service.name === name)?.envName;
|
|
1148
1155
|
const ok = daemonRunning ? await checkHttp(port) : (!process.env[envName] || await isPortFree(port));
|
|
1149
1156
|
add(
|
|
1150
1157
|
`${name} port`,
|
package/lib/process-cleanup.js
CHANGED
|
@@ -9,23 +9,17 @@ const {
|
|
|
9
9
|
PACKAGE_ROOT,
|
|
10
10
|
RUNTIME_DIR,
|
|
11
11
|
} = require('./paths');
|
|
12
|
+
const {
|
|
13
|
+
runtimeServiceScripts,
|
|
14
|
+
} = require('./runtime-manifest');
|
|
12
15
|
|
|
13
|
-
const RUNTIME_SERVICE_MARKERS =
|
|
14
|
-
path.join(RUNTIME_DIR,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
path.join(RUNTIME_DIR, 'scripts', 'local-gateway.js'),
|
|
18
|
-
path.join(RUNTIME_DIR, 'scripts', 'amalgm-mcp', 'index.js'),
|
|
19
|
-
].map((value) => value.replace(/\\/g, '/'));
|
|
20
|
-
const RUNTIME_SERVICE_SUFFIXES = [
|
|
21
|
-
'runtime/scripts/port-monitor.js',
|
|
22
|
-
'runtime/scripts/fs-watcher.js',
|
|
23
|
-
'runtime/scripts/chat-server.js',
|
|
24
|
-
'runtime/scripts/local-gateway.js',
|
|
25
|
-
'runtime/scripts/amalgm-mcp/index.js',
|
|
26
|
-
];
|
|
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, '/'));
|
|
27
20
|
|
|
28
21
|
const SUPERVISOR_MARKER = path.join(PACKAGE_ROOT, 'bin', 'amalgm.js').replace(/\\/g, '/');
|
|
22
|
+
const SUPERVISOR_SUFFIX = '/bin/amalgm.js';
|
|
29
23
|
|
|
30
24
|
function isPidRunning(pid) {
|
|
31
25
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -67,6 +61,19 @@ function readProcCommand(pid) {
|
|
|
67
61
|
}
|
|
68
62
|
}
|
|
69
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
|
+
|
|
70
77
|
function readProcEnv(pid) {
|
|
71
78
|
const raw = readProcString(`/proc/${pid}/environ`);
|
|
72
79
|
if (!raw) return null;
|
|
@@ -88,6 +95,7 @@ function listProcProcesses() {
|
|
|
88
95
|
.filter((pid) => Number.isInteger(pid) && pid > 0)
|
|
89
96
|
.map((pid) => ({
|
|
90
97
|
pid,
|
|
98
|
+
ppid: readProcPpid(pid),
|
|
91
99
|
command: readProcCommand(pid),
|
|
92
100
|
env: readProcEnv(pid),
|
|
93
101
|
}))
|
|
@@ -99,7 +107,7 @@ function listProcProcesses() {
|
|
|
99
107
|
|
|
100
108
|
function listPsProcesses() {
|
|
101
109
|
try {
|
|
102
|
-
const result = spawnSync('ps', ['-eo', 'pid=,command='], {
|
|
110
|
+
const result = spawnSync('ps', ['-eo', 'pid=,ppid=,command='], {
|
|
103
111
|
encoding: 'utf8',
|
|
104
112
|
timeout: 2000,
|
|
105
113
|
});
|
|
@@ -107,11 +115,12 @@ function listPsProcesses() {
|
|
|
107
115
|
return String(result.stdout || '')
|
|
108
116
|
.split('\n')
|
|
109
117
|
.map((line) => {
|
|
110
|
-
const match = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
118
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
111
119
|
if (!match) return null;
|
|
112
120
|
return {
|
|
113
121
|
pid: Number(match[1]),
|
|
114
|
-
|
|
122
|
+
ppid: Number(match[2]),
|
|
123
|
+
command: match[3],
|
|
115
124
|
env: null,
|
|
116
125
|
};
|
|
117
126
|
})
|
|
@@ -122,7 +131,12 @@ function listPsProcesses() {
|
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
function listProcesses() {
|
|
125
|
-
|
|
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
|
+
}));
|
|
126
140
|
}
|
|
127
141
|
|
|
128
142
|
function normalizedCommand(command) {
|
|
@@ -134,10 +148,14 @@ function envMatchesAmalgmDir(env) {
|
|
|
134
148
|
return path.resolve(env.AMALGM_DIR) === path.resolve(AMALGM_DIR);
|
|
135
149
|
}
|
|
136
150
|
|
|
137
|
-
function commandLooksLikeRuntimeService(command) {
|
|
151
|
+
function commandLooksLikeRuntimeService(command, item = null, options = {}) {
|
|
138
152
|
const normalized = normalizedCommand(command);
|
|
139
|
-
|
|
140
|
-
|
|
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);
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
function commandLooksLikeSupervisor(command) {
|
|
@@ -145,11 +163,16 @@ function commandLooksLikeSupervisor(command) {
|
|
|
145
163
|
return normalized.includes(SUPERVISOR_MARKER) && /\brun\b/.test(normalized);
|
|
146
164
|
}
|
|
147
165
|
|
|
166
|
+
function commandLooksLikeAnySupervisor(command) {
|
|
167
|
+
const normalized = normalizedCommand(command);
|
|
168
|
+
return normalized.includes(SUPERVISOR_SUFFIX) && /\brun\b/.test(normalized);
|
|
169
|
+
}
|
|
170
|
+
|
|
148
171
|
function runtimeServiceProcesses(options = {}) {
|
|
149
172
|
const exclude = new Set([process.pid, ...(options.excludePids || [])].filter(Number.isInteger));
|
|
150
173
|
return listProcesses()
|
|
151
174
|
.filter((item) => !exclude.has(item.pid))
|
|
152
|
-
.filter((item) => commandLooksLikeRuntimeService(item.command))
|
|
175
|
+
.filter((item) => commandLooksLikeRuntimeService(item.command, item, options))
|
|
153
176
|
.filter((item) => envMatchesAmalgmDir(item.env));
|
|
154
177
|
}
|
|
155
178
|
|
|
@@ -197,7 +220,10 @@ function terminateProcesses(processes, options = {}) {
|
|
|
197
220
|
}
|
|
198
221
|
|
|
199
222
|
function cleanupStaleRuntimeProcesses(options = {}) {
|
|
200
|
-
const stale = runtimeServiceProcesses(
|
|
223
|
+
const stale = runtimeServiceProcesses({
|
|
224
|
+
includeOrphanedForeign: true,
|
|
225
|
+
...options,
|
|
226
|
+
});
|
|
201
227
|
const supervisors = options.includeSupervisors ? supervisorProcesses(options) : [];
|
|
202
228
|
return terminateProcesses([...stale, ...supervisors], options);
|
|
203
229
|
}
|
|
@@ -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
|
+
};
|
package/lib/service.js
CHANGED
|
@@ -292,6 +292,8 @@ function writePortableNodeScript(localOnly) {
|
|
|
292
292
|
daemonLog: path.join(LOG_DIR, 'daemon.log'),
|
|
293
293
|
serviceLog: SERVICE_LOG_FILE,
|
|
294
294
|
restartDelayMs: Math.max(1000, Number(process.env.AMALGM_SERVICE_RESTART_DELAY || 5) * 1000),
|
|
295
|
+
maxRestartDelayMs: Math.max(5000, Number(process.env.AMALGM_SERVICE_MAX_RESTART_DELAY || 60) * 1000),
|
|
296
|
+
stableAfterMs: Math.max(10000, Number(process.env.AMALGM_SERVICE_STABLE_AFTER || 60) * 1000),
|
|
295
297
|
env: buildServiceEnv(localOnly),
|
|
296
298
|
};
|
|
297
299
|
const source = `'use strict';
|
|
@@ -307,6 +309,8 @@ const config = ${JSON.stringify(config, null, 2)};
|
|
|
307
309
|
let child = null;
|
|
308
310
|
let stopping = false;
|
|
309
311
|
let exiting = false;
|
|
312
|
+
let restartDelayMs = config.restartDelayMs;
|
|
313
|
+
let childStartedAt = 0;
|
|
310
314
|
|
|
311
315
|
function ensureDir(dir) {
|
|
312
316
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -418,6 +422,7 @@ function launch() {
|
|
|
418
422
|
});
|
|
419
423
|
append(config.serviceLog, 'starting amalgm run');
|
|
420
424
|
const fd = fs.openSync(config.daemonLog, 'a');
|
|
425
|
+
childStartedAt = Date.now();
|
|
421
426
|
child = spawn(config.nodeBin, [config.amalgmBin, 'run'], {
|
|
422
427
|
cwd: process.env.HOME || process.cwd(),
|
|
423
428
|
env: { ...process.env, ...config.env },
|
|
@@ -426,10 +431,15 @@ function launch() {
|
|
|
426
431
|
});
|
|
427
432
|
try { fs.closeSync(fd); } catch {}
|
|
428
433
|
child.on('exit', (code, signal) => {
|
|
434
|
+
const runtimeMs = Date.now() - childStartedAt;
|
|
429
435
|
append(config.serviceLog, \`amalgm run exited code=\${code ?? ''} signal=\${signal ?? ''}\`);
|
|
430
436
|
child = null;
|
|
431
437
|
if (stopping || fs.existsSync(config.stopFile)) return cleanup(0);
|
|
432
|
-
|
|
438
|
+
if (runtimeMs >= config.stableAfterMs) restartDelayMs = config.restartDelayMs;
|
|
439
|
+
const delay = restartDelayMs;
|
|
440
|
+
restartDelayMs = Math.min(restartDelayMs * 2, config.maxRestartDelayMs);
|
|
441
|
+
append(config.serviceLog, \`restarting amalgm run in \${delay}ms\`);
|
|
442
|
+
setTimeout(launch, delay);
|
|
433
443
|
});
|
|
434
444
|
}
|
|
435
445
|
|
package/lib/supervisor.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const http = require('http');
|
|
5
6
|
const os = require('os');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
const { spawn, spawnSync } = require('child_process');
|
|
@@ -31,8 +32,28 @@ const {
|
|
|
31
32
|
isPidRunning,
|
|
32
33
|
supervisorProcesses,
|
|
33
34
|
} = require('./process-cleanup');
|
|
35
|
+
const {
|
|
36
|
+
runtimeLaunchServices,
|
|
37
|
+
runtimePortsState,
|
|
38
|
+
runtimeServiceScripts,
|
|
39
|
+
} = require('./runtime-manifest');
|
|
34
40
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
35
41
|
|
|
42
|
+
const CHILD_RESTART_POLICY = {
|
|
43
|
+
initialDelayMs: 1000,
|
|
44
|
+
maxDelayMs: 30000,
|
|
45
|
+
stableAfterMs: 30000,
|
|
46
|
+
maxRestarts: 8,
|
|
47
|
+
windowMs: 60000,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const CHILD_HEALTH_POLICY = {
|
|
51
|
+
graceMs: 8000,
|
|
52
|
+
intervalMs: 10000,
|
|
53
|
+
timeoutMs: 2500,
|
|
54
|
+
maxFailures: 3,
|
|
55
|
+
};
|
|
56
|
+
|
|
36
57
|
function ensureDir(dir, mode = 0o700) {
|
|
37
58
|
fs.mkdirSync(dir, { recursive: true, mode });
|
|
38
59
|
try {
|
|
@@ -103,13 +124,7 @@ function runtimeEntry(relativePath) {
|
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
function assertRuntimePresent() {
|
|
106
|
-
const required =
|
|
107
|
-
runtimeEntry('scripts/amalgm-mcp/index.js'),
|
|
108
|
-
runtimeEntry('scripts/chat-server.js'),
|
|
109
|
-
runtimeEntry('scripts/fs-watcher.js'),
|
|
110
|
-
runtimeEntry('scripts/local-gateway.js'),
|
|
111
|
-
runtimeEntry('scripts/port-monitor.js'),
|
|
112
|
-
];
|
|
127
|
+
const required = runtimeServiceScripts().map((script) => runtimeEntry(script));
|
|
113
128
|
const missing = required.filter((file) => !fs.existsSync(file));
|
|
114
129
|
if (missing.length > 0) {
|
|
115
130
|
throw new Error(
|
|
@@ -177,7 +192,7 @@ function ensureRuntimeToken() {
|
|
|
177
192
|
return token;
|
|
178
193
|
}
|
|
179
194
|
|
|
180
|
-
function baseRuntimeEnv(record, ports) {
|
|
195
|
+
function baseRuntimeEnv(record, ports, options = {}) {
|
|
181
196
|
const workspaceRoot =
|
|
182
197
|
process.env.AMALGM_WORKSPACES_DIR ||
|
|
183
198
|
process.env.AMALGM_PROJECTS_DIR ||
|
|
@@ -185,11 +200,13 @@ function baseRuntimeEnv(record, ports) {
|
|
|
185
200
|
ensureDir(workspaceRoot);
|
|
186
201
|
const defaultCwd = process.env.AMALGM_DEFAULT_CWD || workspaceRoot;
|
|
187
202
|
const proxyToken = proxyTokenFromRecord(record);
|
|
203
|
+
const localOnly = !!options.localOnly || process.env.AMALGM_LOCAL_ONLY === 'true';
|
|
188
204
|
const env = {
|
|
189
205
|
...safeBaseProcessEnv(),
|
|
190
206
|
AMALGM_RUNTIME_SOURCE: 'npm',
|
|
191
207
|
AMALGM_RUNTIME_TOKEN: record?.runtime_token || process.env.AMALGM_RUNTIME_TOKEN || '',
|
|
192
208
|
AMALGM_LOCAL_MODE: 'true',
|
|
209
|
+
AMALGM_LOCAL_ONLY: localOnly ? 'true' : 'false',
|
|
193
210
|
AMALGM_BIND_HOST: process.env.AMALGM_BIND_HOST || '127.0.0.1',
|
|
194
211
|
AMALGM_AUTH_BACKUP_ENABLED: process.env.AMALGM_AUTH_BACKUP_ENABLED || 'false',
|
|
195
212
|
AMALGM_CREATE_AUTH_WATCH_DIRS: process.env.AMALGM_CREATE_AUTH_WATCH_DIRS || 'false',
|
|
@@ -221,40 +238,16 @@ function baseRuntimeEnv(record, ports) {
|
|
|
221
238
|
return env;
|
|
222
239
|
}
|
|
223
240
|
|
|
224
|
-
function serviceSpecs(record, ports) {
|
|
225
|
-
const env = baseRuntimeEnv(record, ports);
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
name: 'fs-watcher',
|
|
235
|
-
command: process.execPath,
|
|
236
|
-
args: [runtimeEntry('scripts/fs-watcher.js')],
|
|
237
|
-
env,
|
|
238
|
-
},
|
|
239
|
-
{
|
|
240
|
-
name: 'chat-server',
|
|
241
|
-
command: process.execPath,
|
|
242
|
-
args: [runtimeEntry('scripts/chat-server.js')],
|
|
243
|
-
env,
|
|
244
|
-
},
|
|
245
|
-
{
|
|
246
|
-
name: 'amalgm-mcp',
|
|
247
|
-
command: process.execPath,
|
|
248
|
-
args: [runtimeEntry('scripts/amalgm-mcp/index.js')],
|
|
249
|
-
env,
|
|
250
|
-
},
|
|
251
|
-
{
|
|
252
|
-
name: 'local-gateway',
|
|
253
|
-
command: process.execPath,
|
|
254
|
-
args: [runtimeEntry('scripts/local-gateway.js')],
|
|
255
|
-
env,
|
|
256
|
-
},
|
|
257
|
-
];
|
|
241
|
+
function serviceSpecs(record, ports, options = {}) {
|
|
242
|
+
const env = baseRuntimeEnv(record, ports, options);
|
|
243
|
+
return runtimeLaunchServices().map((service) => ({
|
|
244
|
+
name: service.name,
|
|
245
|
+
command: process.execPath,
|
|
246
|
+
args: [runtimeEntry(service.script)],
|
|
247
|
+
env: service.name === 'port-monitor' ? { ...env, PORT: process.env.PORT || '0' } : env,
|
|
248
|
+
port: ports[service.portKey],
|
|
249
|
+
healthPath: '/healthz',
|
|
250
|
+
}));
|
|
258
251
|
}
|
|
259
252
|
|
|
260
253
|
function isPortFree(port, host = '127.0.0.1') {
|
|
@@ -319,13 +312,11 @@ async function pickPort(name, envName, preferred, used) {
|
|
|
319
312
|
|
|
320
313
|
async function resolveRuntimePorts() {
|
|
321
314
|
const used = new Set();
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
chatServer: await pickPort('chat-server', 'CHAT_SERVER_PORT', 8084, used),
|
|
328
|
-
};
|
|
315
|
+
const ports = {};
|
|
316
|
+
for (const service of runtimeLaunchServices()) {
|
|
317
|
+
ports[service.portKey] = await pickPort(service.name, service.envName, service.defaultPort, used);
|
|
318
|
+
}
|
|
319
|
+
return ports;
|
|
329
320
|
}
|
|
330
321
|
|
|
331
322
|
function openServiceLog(name) {
|
|
@@ -348,14 +339,150 @@ function writePrefixed(stream, name, chunk, consoleStream) {
|
|
|
348
339
|
}
|
|
349
340
|
}
|
|
350
341
|
|
|
342
|
+
function updateRuntimeState(update) {
|
|
343
|
+
const current = readJson(RUNTIME_STATE_FILE, {});
|
|
344
|
+
if (!current || (current.pid !== process.pid && current.supervisor_pid !== process.pid)) return;
|
|
345
|
+
writeJsonSecret(RUNTIME_STATE_FILE, {
|
|
346
|
+
...current,
|
|
347
|
+
...update,
|
|
348
|
+
updated_at: new Date().toISOString(),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function appendServiceState(name, update) {
|
|
353
|
+
const current = readJson(RUNTIME_STATE_FILE, {});
|
|
354
|
+
if (!current || (current.pid !== process.pid && current.supervisor_pid !== process.pid)) return;
|
|
355
|
+
const services = Array.isArray(current.services) ? current.services : [];
|
|
356
|
+
const next = services.map((service) => (
|
|
357
|
+
service.name === name ? { ...service, ...update } : service
|
|
358
|
+
));
|
|
359
|
+
updateRuntimeState({ services: next });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function checkServiceHealth(spec, timeoutMs = CHILD_HEALTH_POLICY.timeoutMs) {
|
|
363
|
+
if (!spec.port) return Promise.resolve(true);
|
|
364
|
+
return new Promise((resolve) => {
|
|
365
|
+
const req = http.request(
|
|
366
|
+
{
|
|
367
|
+
host: '127.0.0.1',
|
|
368
|
+
port: spec.port,
|
|
369
|
+
path: spec.healthPath || '/healthz',
|
|
370
|
+
method: 'GET',
|
|
371
|
+
timeout: timeoutMs,
|
|
372
|
+
},
|
|
373
|
+
(res) => {
|
|
374
|
+
res.resume();
|
|
375
|
+
res.on('end', () => resolve(res.statusCode >= 200 && res.statusCode < 500));
|
|
376
|
+
},
|
|
377
|
+
);
|
|
378
|
+
req.on('timeout', () => {
|
|
379
|
+
req.destroy();
|
|
380
|
+
resolve(false);
|
|
381
|
+
});
|
|
382
|
+
req.on('error', () => resolve(false));
|
|
383
|
+
req.end();
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
351
387
|
function createManagedProcess(spec, options) {
|
|
352
388
|
const { stream } = openServiceLog(spec.name);
|
|
353
389
|
let child = null;
|
|
354
390
|
let stopped = false;
|
|
355
391
|
let restartTimer = null;
|
|
392
|
+
let healthTimer = null;
|
|
393
|
+
let healthFailures = 0;
|
|
394
|
+
let startedAt = 0;
|
|
395
|
+
let restartDelayMs = CHILD_RESTART_POLICY.initialDelayMs;
|
|
396
|
+
let restartHistory = [];
|
|
397
|
+
let state = 'starting';
|
|
398
|
+
|
|
399
|
+
const setState = (nextState, extra = {}) => {
|
|
400
|
+
state = nextState;
|
|
401
|
+
appendServiceState(spec.name, {
|
|
402
|
+
status: state,
|
|
403
|
+
pid: child?.pid || null,
|
|
404
|
+
restart_count_window: restartHistory.length,
|
|
405
|
+
...extra,
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const clearHealthTimer = () => {
|
|
410
|
+
if (healthTimer) clearTimeout(healthTimer);
|
|
411
|
+
healthTimer = null;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const scheduleHealthCheck = () => {
|
|
415
|
+
clearHealthTimer();
|
|
416
|
+
if (stopped) return;
|
|
417
|
+
healthTimer = setTimeout(async () => {
|
|
418
|
+
if (stopped || !child) return;
|
|
419
|
+
if (Date.now() - startedAt < CHILD_HEALTH_POLICY.graceMs) {
|
|
420
|
+
scheduleHealthCheck();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const ok = await checkServiceHealth(spec);
|
|
424
|
+
if (stopped || !child) return;
|
|
425
|
+
if (ok) {
|
|
426
|
+
healthFailures = 0;
|
|
427
|
+
if (state !== 'running') setState('running');
|
|
428
|
+
} else {
|
|
429
|
+
healthFailures += 1;
|
|
430
|
+
writePrefixed(
|
|
431
|
+
stream,
|
|
432
|
+
spec.name,
|
|
433
|
+
`health check failed (${healthFailures}/${CHILD_HEALTH_POLICY.maxFailures})\n`,
|
|
434
|
+
options.foreground ? process.stderr : null,
|
|
435
|
+
);
|
|
436
|
+
if (healthFailures >= CHILD_HEALTH_POLICY.maxFailures) {
|
|
437
|
+
setState('restarting', { reason: 'health_check_failed' });
|
|
438
|
+
try {
|
|
439
|
+
child.kill('SIGTERM');
|
|
440
|
+
} catch {
|
|
441
|
+
// noop
|
|
442
|
+
}
|
|
443
|
+
const pid = child.pid;
|
|
444
|
+
setTimeout(() => {
|
|
445
|
+
if (child && child.pid === pid) {
|
|
446
|
+
try {
|
|
447
|
+
child.kill('SIGKILL');
|
|
448
|
+
} catch {
|
|
449
|
+
// noop
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}, 5000);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
scheduleHealthCheck();
|
|
457
|
+
}, CHILD_HEALTH_POLICY.intervalMs);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const canRestart = () => {
|
|
461
|
+
const now = Date.now();
|
|
462
|
+
restartHistory = restartHistory.filter((time) => now - time <= CHILD_RESTART_POLICY.windowMs);
|
|
463
|
+
if (restartHistory.length >= CHILD_RESTART_POLICY.maxRestarts) {
|
|
464
|
+
setState('degraded', {
|
|
465
|
+
reason: 'restart_limit_exceeded',
|
|
466
|
+
next_retry_at: null,
|
|
467
|
+
});
|
|
468
|
+
writePrefixed(
|
|
469
|
+
stream,
|
|
470
|
+
spec.name,
|
|
471
|
+
`entered degraded state after ${restartHistory.length} restarts in ${CHILD_RESTART_POLICY.windowMs}ms\n`,
|
|
472
|
+
options.foreground ? process.stderr : null,
|
|
473
|
+
);
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
restartHistory.push(now);
|
|
477
|
+
return true;
|
|
478
|
+
};
|
|
356
479
|
|
|
357
480
|
const launch = () => {
|
|
358
481
|
if (stopped) return;
|
|
482
|
+
clearHealthTimer();
|
|
483
|
+
healthFailures = 0;
|
|
484
|
+
startedAt = Date.now();
|
|
485
|
+
setState('starting');
|
|
359
486
|
child = spawn(spec.command, spec.args, {
|
|
360
487
|
cwd: RUNTIME_DIR,
|
|
361
488
|
env: spec.env,
|
|
@@ -369,6 +496,8 @@ function createManagedProcess(spec, options) {
|
|
|
369
496
|
`started pid=${child.pid} command=${[spec.command, ...spec.args].join(' ')}\n`,
|
|
370
497
|
options.foreground ? process.stdout : null,
|
|
371
498
|
);
|
|
499
|
+
setState('running', { started_at: new Date(startedAt).toISOString() });
|
|
500
|
+
scheduleHealthCheck();
|
|
372
501
|
|
|
373
502
|
child.stdout.on('data', (chunk) => {
|
|
374
503
|
writePrefixed(stream, spec.name, chunk, options.foreground ? process.stdout : null);
|
|
@@ -377,15 +506,35 @@ function createManagedProcess(spec, options) {
|
|
|
377
506
|
writePrefixed(stream, spec.name, chunk, options.foreground ? process.stderr : null);
|
|
378
507
|
});
|
|
379
508
|
child.on('exit', (code, signal) => {
|
|
509
|
+
clearHealthTimer();
|
|
380
510
|
writePrefixed(
|
|
381
511
|
stream,
|
|
382
512
|
spec.name,
|
|
383
513
|
`exited code=${code ?? ''} signal=${signal ?? ''}\n`,
|
|
384
514
|
options.foreground ? process.stderr : null,
|
|
385
515
|
);
|
|
516
|
+
const runtimeMs = Date.now() - startedAt;
|
|
386
517
|
child = null;
|
|
387
518
|
if (!stopped) {
|
|
388
|
-
|
|
519
|
+
if (runtimeMs >= CHILD_RESTART_POLICY.stableAfterMs) {
|
|
520
|
+
restartDelayMs = CHILD_RESTART_POLICY.initialDelayMs;
|
|
521
|
+
restartHistory = [];
|
|
522
|
+
}
|
|
523
|
+
if (!canRestart()) return;
|
|
524
|
+
const delay = restartDelayMs;
|
|
525
|
+
restartDelayMs = Math.min(restartDelayMs * 2, CHILD_RESTART_POLICY.maxDelayMs);
|
|
526
|
+
setState('restarting', {
|
|
527
|
+
exit_code: code ?? null,
|
|
528
|
+
exit_signal: signal || null,
|
|
529
|
+
next_retry_at: new Date(Date.now() + delay).toISOString(),
|
|
530
|
+
});
|
|
531
|
+
writePrefixed(
|
|
532
|
+
stream,
|
|
533
|
+
spec.name,
|
|
534
|
+
`restart scheduled in ${delay}ms\n`,
|
|
535
|
+
options.foreground ? process.stderr : null,
|
|
536
|
+
);
|
|
537
|
+
restartTimer = setTimeout(launch, delay);
|
|
389
538
|
}
|
|
390
539
|
});
|
|
391
540
|
};
|
|
@@ -396,6 +545,8 @@ function createManagedProcess(spec, options) {
|
|
|
396
545
|
stop() {
|
|
397
546
|
stopped = true;
|
|
398
547
|
if (restartTimer) clearTimeout(restartTimer);
|
|
548
|
+
clearHealthTimer();
|
|
549
|
+
setState('stopping');
|
|
399
550
|
if (child && !child.killed) {
|
|
400
551
|
try {
|
|
401
552
|
child.kill('SIGTERM');
|
|
@@ -465,6 +616,7 @@ async function startSupervisor(options = {}) {
|
|
|
465
616
|
device_id: record?.device_id || null,
|
|
466
617
|
event_ref: record?.event_ref || null,
|
|
467
618
|
local_only: !!options.localOnly,
|
|
619
|
+
services: [],
|
|
468
620
|
started_at: new Date().toISOString(),
|
|
469
621
|
});
|
|
470
622
|
if (!storedRecord?.computer_id && options.foreground) {
|
|
@@ -479,7 +631,8 @@ async function startSupervisor(options = {}) {
|
|
|
479
631
|
FS_WATCHER_PORT: String(ports.fsWatcher),
|
|
480
632
|
PORT_MONITOR_PORT: String(ports.portMonitor),
|
|
481
633
|
});
|
|
482
|
-
|
|
634
|
+
updateRuntimeState(runtimePortsState(ports));
|
|
635
|
+
const managed = serviceSpecs(record, ports, options).map((spec) => createManagedProcess(spec, options));
|
|
483
636
|
const tunnels = [];
|
|
484
637
|
|
|
485
638
|
if (!options.localOnly && record?.tunnel_url && record?.tunnel_token) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amalgm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.44",
|
|
4
4
|
"description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"sync-runtime": "node ../../scripts/sync-npm-package-runtime.mjs",
|
|
18
18
|
"prepack": "node ../../scripts/sync-npm-package-runtime.mjs",
|
|
19
19
|
"pack:dry": "npm pack --dry-run",
|
|
20
|
-
"check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/service.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/fs-watcher.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js"
|
|
20
|
+
"check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/fs-watcher.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=20"
|
|
@@ -293,11 +293,14 @@ function ensurePtySpawnHelperExecutable() {
|
|
|
293
293
|
|
|
294
294
|
function writeRuntimeState(actualPort) {
|
|
295
295
|
const computer = readJson(path.join(AMALGM_DIR, 'computer.json'), null);
|
|
296
|
+
const previous = readJson(STATE_FILE, {});
|
|
296
297
|
writeJsonSecret(STATE_FILE, {
|
|
298
|
+
...previous,
|
|
297
299
|
schema_version: 1,
|
|
298
300
|
owner: OWNER,
|
|
299
301
|
source: OWNER,
|
|
300
302
|
pid: process.pid,
|
|
303
|
+
gateway_pid: process.pid,
|
|
301
304
|
supervisor_pid: process.ppid,
|
|
302
305
|
version: VERSION,
|
|
303
306
|
package_version: VERSION,
|
|
@@ -315,6 +318,7 @@ function writeRuntimeState(actualPort) {
|
|
|
315
318
|
},
|
|
316
319
|
computer_id: computer?.computer_id || process.env.AMALGM_COMPUTER_ID || '',
|
|
317
320
|
user_id: computer?.user_id || process.env.AMALGM_USER_ID || '',
|
|
321
|
+
services: Array.isArray(previous.services) ? previous.services : [],
|
|
318
322
|
updated_at: new Date().toISOString(),
|
|
319
323
|
});
|
|
320
324
|
}
|