@desplega.ai/agent-env 0.0.0-stage → 0.3.0

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +343 -2
  3. package/assets/icon.svg +5 -0
  4. package/bin/agent-env.mjs +210 -0
  5. package/docs/agent-swarm.md +146 -0
  6. package/docs/releases.md +207 -0
  7. package/docs/screenshots/dashboard-desktop.png +0 -0
  8. package/docs/screenshots/dashboard-mobile.png +0 -0
  9. package/docs/snapshots-and-worktrees.md +221 -0
  10. package/examples/agent-swarm/README.md +5 -0
  11. package/examples/agent-swarm/agent-env.json +11 -0
  12. package/examples/agent-swarm/compose.agent-env.yaml +101 -0
  13. package/examples/agent-swarm/verify.mjs +55 -0
  14. package/examples/compose/.dockerignore +3 -0
  15. package/examples/compose/Dockerfile +4 -0
  16. package/examples/compose/README.md +109 -0
  17. package/examples/compose/compose.yaml +36 -0
  18. package/examples/compose/server.mjs +6 -0
  19. package/examples/hello/agent-env.json +20 -0
  20. package/examples/hello/index.html +12 -0
  21. package/package.json +49 -4
  22. package/src/cloud.mjs +314 -0
  23. package/src/config.mjs +133 -0
  24. package/src/daemon.mjs +484 -0
  25. package/src/environments.mjs +207 -0
  26. package/src/goodies.mjs +71 -0
  27. package/src/init.mjs +68 -0
  28. package/src/locks.mjs +56 -0
  29. package/src/maintenance.mjs +74 -0
  30. package/src/paths.mjs +5 -0
  31. package/src/providers/CLOUD_SANDBOX.md +240 -0
  32. package/src/providers/README.md +62 -0
  33. package/src/providers/compose.mjs +460 -0
  34. package/src/providers/docker.mjs +207 -0
  35. package/src/providers/index.mjs +13 -0
  36. package/src/providers/sandbox.mjs +170 -0
  37. package/src/proxy.mjs +149 -0
  38. package/src/runtime.mjs +83 -0
  39. package/src/snapshots.mjs +394 -0
  40. package/src/source-paths.mjs +15 -0
  41. package/src/system.mjs +39 -0
  42. package/src/tls.mjs +57 -0
  43. package/src/ui-client.js +51 -0
  44. package/src/ui.html +12 -0
  45. package/src/ui.mjs +32 -0
  46. package/src/watch.mjs +20 -0
  47. package/src/worktrees.mjs +154 -0
package/src/daemon.mjs ADDED
@@ -0,0 +1,484 @@
1
+ import http from 'node:http';
2
+ import { readFile, writeFile, rename, mkdir, chmod, mkdtemp, rm } from 'node:fs/promises';
3
+ import { once } from 'node:events';
4
+ import { join } from 'node:path';
5
+ import { snapshotInstance, restoreSnapshot, disposeRestoredVolumes } from './snapshots.mjs';
6
+ import { createWorktree, removeOwnedWorktree } from './worktrees.mjs';
7
+ import { hostname } from 'node:os';
8
+ import { isDeepStrictEqual } from 'node:util';
9
+ import { home, socketPath, statePath } from './paths.mjs';
10
+ import { hash, validName, validate } from './config.mjs';
11
+ import { providerFor, disposeProviders } from './providers/index.mjs';
12
+ import { RuntimeExtras, redact } from './runtime.mjs';
13
+ import { prepareCloud, sleepCloud, destroyCloud, commitCloud, disposeFailedCloud, cloudOptions } from './cloud.mjs';
14
+ import { createProxy, createTcpProxy } from './proxy.mjs';
15
+ import { cloudPeers, cloudCanSleep, maintainInstances, expireCloudMachine } from './maintenance.mjs';
16
+
17
+ await mkdir(home, { recursive: true, mode: 0o700 });
18
+ await chmod(home, 0o700);
19
+ let state;
20
+ try { state = JSON.parse(await readFile(statePath, 'utf8')); }
21
+ catch (error) { if (error.code !== 'ENOENT') throw error; state = { instances: {}, focus: {} }; }
22
+ const routes = new Map();
23
+ const connections = new Map();
24
+ const errors = new Map();
25
+ const owner = state.owner ?? hash(`${hostname()}:${home}`);
26
+ state.owner = owner;
27
+ const syncPending = new Set();
28
+ const retries = new Map();
29
+ function activity(name) { if (state.instances[name]) state.instances[name].lastActivity = Date.now(); }
30
+ const extras = new RuntimeExtras({
31
+ instances: () => state.instances, connections: () => connections, activity,
32
+ changed: name => {
33
+ if (syncPending.has(name)) return;
34
+ syncPending.add(name);
35
+ enqueue(async () => {
36
+ syncPending.delete(name);
37
+ const handle = state.instances[name];
38
+ if (!handle?.running) return;
39
+ try { await action({ type: 'sync', name }); handle.syncError = null; }
40
+ catch (error) { handle.syncError = redact(handle, error.message); }
41
+ }).catch(error => errors.set(name, error.message));
42
+ },
43
+ status: () => enqueue(() => action({ type: 'list' })),
44
+ logs: async (name, service) => {
45
+ try { return (await enqueue(() => action({ type: 'logs', name, service }))).output; }
46
+ catch (error) { throw new Error(redact(state.instances[name], error.message)); }
47
+ },
48
+ });
49
+ async function save() {
50
+ await writeFile(`${statePath}.tmp`, JSON.stringify(state, null, 2), { mode: 0o600 });
51
+ await rename(`${statePath}.tmp`, statePath);
52
+ }
53
+ async function reserve(spec) {
54
+ for (const service of Object.values(spec.services)) {
55
+ if (!service.port) continue;
56
+ const existing = routes.get(service.port);
57
+ if (existing) {
58
+ if (existing.protocol !== service.protocol) throw new Error(`Port ${service.port} already uses ${existing.protocol}.`);
59
+ continue;
60
+ }
61
+ const route = { protocol: service.protocol, targets: new Map(), selected: state.focus[service.port], listeners: [] };
62
+ routes.set(service.port, route);
63
+ try {
64
+ for (const host of ['127.0.0.1', '::1']) {
65
+ const listener = service.protocol === 'tcp' ? createTcpProxy(() => route, { onRequest: activity }) : createProxy(service.port, () => route, { onRequest: activity });
66
+ try {
67
+ listener.server.listen(service.port, host);
68
+ await once(listener.server, 'listening');
69
+ route.listeners.push(listener);
70
+ } catch (error) {
71
+ if (host === '::1' && ['EAFNOSUPPORT', 'EADDRNOTAVAIL'].includes(error.code)) continue;
72
+ throw new Error(`Cannot bind ${host}:${service.port}: ${error.code}. Stop its owner or choose another port.`);
73
+ }
74
+ }
75
+ } catch (error) { await releaseEmpty(); throw error; }
76
+ }
77
+ }
78
+ async function releaseEmpty() {
79
+ for (const [port, route] of routes) {
80
+ if (route.targets.size) continue;
81
+ for (const listener of route.listeners) await listener.close();
82
+ routes.delete(port);
83
+ }
84
+ }
85
+ async function disconnect(name) {
86
+ extras.unwatch(name);
87
+ for (const connection of connections.get(name) ?? []) connection.close();
88
+ connections.delete(name);
89
+ for (const [port, route] of routes) {
90
+ route.targets.delete(name);
91
+ if (route.selected === name) route.selected = route.targets.keys().next().value;
92
+ state.focus[port] = route.selected;
93
+ }
94
+ await releaseEmpty();
95
+ }
96
+ async function connect(handle) {
97
+ const name = handle.spec.name;
98
+ await reserve(handle.spec);
99
+ const opened = await providerFor(handle.spec).connect(handle);
100
+ connections.set(name, opened);
101
+ for (const connection of opened) {
102
+ const service = handle.services[connection.name];
103
+ if (!service?.port) continue;
104
+ const route = routes.get(service.port);
105
+ route.targets.set(name, { backendPort: connection.backendPort });
106
+ if (!route.selected || !route.targets.has(route.selected)) route.selected = name;
107
+ state.focus[service.port] = route.selected;
108
+ }
109
+ errors.delete(name);
110
+ retries.delete(name);
111
+ extras.watch(handle);
112
+ await extras.refreshCertificates();
113
+ }
114
+ function instance(name) {
115
+ const handle = Object.hasOwn(state.instances, name) ? state.instances[name] : undefined;
116
+ if (!handle) throw new Error(`Instance ${name} does not exist. Run agent-env list.`);
117
+ return handle;
118
+ }
119
+ function summary(handle) {
120
+ const primary = Object.keys(handle.services).find(key => handle.services[key].protocol === 'http' && handle.services[key].port);
121
+ const name = handle.spec.name;
122
+ const connected = connections.has(name) && connections.get(name).every(item => !item.child || (item.child.exitCode === null && item.child.signalCode === null && !item.child.killed));
123
+ return {
124
+ worktree: handle.worktree?.cwd,
125
+ name, provider: handle.spec.provider, host: handle.spec.host ?? 'local', running: handle.running, connected,
126
+ environment: handle.spec.environment ?? handle.spec.environmentName,
127
+ sync: handle.spec.host ? handle.syncError ?? (handle.spec.watch === false ? 'manual' : 'watching') : 'local files',
128
+ cloud: handle.spec.cloud ? { serverId: handle.spec.cloud.serverId, expiresAt: handle.spec.cloud.expiresAt, sleeping: !!handle.sleeping } : undefined,
129
+ error: redact(handle, errors.get(name)),
130
+ services: Object.fromEntries(Object.entries(handle.services).map(([key, service]) => [key, {
131
+ port: service.port, protocol: service.protocol,
132
+ httpsUrl: service.protocol === 'http' && service.port ? extras.httpsUrl(name, key, primary) : undefined,
133
+ url: !service.port ? null : service.protocol === 'http' ? `http://${name}.localhost:${service.port}` : `tcp://localhost:${service.port}`,
134
+ selected: routes.get(service.port)?.selected === name,
135
+ }])),
136
+ };
137
+ }
138
+ async function wakeForStorage(handle) {
139
+ if (!handle.sleeping || !handle.spec.cloud) return;
140
+ const prepared = await prepareCloud({ ...handle.spec, name: handle.spec.cloud.name }, handle);
141
+ handle.spec = { ...prepared, name: handle.spec.name };
142
+ for (const peer of cloudPeers(handle, state.instances)) { peer.sleeping = false; peer.spec.cloud = { ...handle.spec.cloud }; }
143
+ }
144
+ async function sleepUnusedCloud(handle) {
145
+ const cloud = handle.spec.cloud;
146
+ const peers = cloudPeers(handle, state.instances);
147
+ if (!cloudCanSleep(handle, state.instances) || handle.running) return;
148
+ await sleepCloud(handle);
149
+ for (const peer of peers) peer.sleeping = true;
150
+ await save();
151
+ }
152
+ function storageOptions(message) {
153
+ return { endpointUrl: message.endpoint, awsEnv: message.awsEnv, omitUnsupportedMounts: !!message.omitMounts };
154
+ }
155
+ async function action(message) {
156
+ const { name, type } = message;
157
+ if (type === 'resume') {
158
+ const spec = structuredClone(instance(name).spec);
159
+ if (message.startupTimeoutSeconds !== undefined) spec.startupTimeoutSeconds = message.startupTimeoutSeconds;
160
+ return action({ type: 'up', spec });
161
+ }
162
+ if (type === 'expire') {
163
+ const handle = instance(name);
164
+ if (!handle.spec.cloud?.expiresAt || Date.parse(handle.spec.cloud.expiresAt) > Date.now()) throw new Error('This cloud machine has not expired.');
165
+ await expireCloudMachine(handle, { instances: state.instances, destroy: destroyCloud, disconnect, save,
166
+ forget: peer => { errors.delete(peer.spec.name); retries.delete(peer.spec.name); } });
167
+ return { message: 'Expired cloud machine and its instances removed.' };
168
+ }
169
+ if (type === 'up' || type === 'restore') {
170
+ let spec = validate({ ...message.spec, owner });
171
+ const provider = providerFor(spec);
172
+ let handle = Object.hasOwn(state.instances, spec.name) ? state.instances[spec.name] : undefined;
173
+ if (type === 'restore' && handle) throw new Error('Restore requires a new instance name.');
174
+ if (type === 'restore' && !provider.docker) throw new Error('This provider does not support volume snapshots.');
175
+ let restored;
176
+ const identitySpec = (value, omitEnvironment = false) => {
177
+ const copy = structuredClone(value);
178
+ delete copy.startupTimeoutSeconds;
179
+ if (omitEnvironment) {
180
+ delete copy.env; delete copy.envFiles; delete copy.envFile; delete copy.sensitiveValues;
181
+ for (const service of Object.values(copy.services ?? {})) delete service.env;
182
+ if (copy.compose) {
183
+ delete copy.compose.environment;
184
+ for (const service of Object.values(copy.compose.model?.services ?? {})) delete service.environment;
185
+ }
186
+ }
187
+ if (copy.cloud) {
188
+ delete copy.host; delete copy.sshConfig;
189
+ const { idleTimeoutMs, expiresAt, ...identity } = cloudOptions(copy.cloud);
190
+ copy.cloud = identity;
191
+ }
192
+ return copy;
193
+ };
194
+ const changed = handle && !handle.removed && !isDeepStrictEqual(identitySpec(handle.spec), identitySpec(spec));
195
+ const canRecreate = provider.docker || (spec.provider === 'sandbox' && spec.sandbox?.adapter === 'docker');
196
+ if (changed && (!canRecreate || !isDeepStrictEqual(identitySpec(handle.spec, true), identitySpec(spec, true)))) {
197
+ throw new Error(`Configuration changed for ${spec.name}. Run agent-env rm ${spec.name}, then up. Volumes remain.`);
198
+ }
199
+ try {
200
+ await reserve(spec);
201
+ if (spec.cloud) {
202
+ const cloudName = handle?.spec.cloud?.name ?? spec.name;
203
+ const prepared = await prepareCloud({ ...spec, name: cloudName }, handle);
204
+ spec = { ...prepared, name: spec.name };
205
+ if (handle) handle.spec = spec;
206
+ for (const peer of cloudPeers({ spec }, state.instances)) {
207
+ peer.sleeping = false;
208
+ peer.spec.cloud = { ...spec.cloud };
209
+ }
210
+ }
211
+ if (changed) {
212
+ await disconnect(spec.name);
213
+ await provider.remove(handle, false);
214
+ handle.removed = true;
215
+ handle.running = false;
216
+ handle.spec = spec;
217
+ await save();
218
+ }
219
+ if (handle && !handle.removed) {
220
+ // Keep the public listeners while replacing only this instance's tunnels.
221
+ for (const connection of connections.get(spec.name) ?? []) connection.close();
222
+ connections.delete(spec.name);
223
+ handle.spec = spec;
224
+ await provider.start(handle);
225
+ } else {
226
+ if (type === 'restore') restored = await restoreSnapshot(spec, message.location, provider, storageOptions(message));
227
+ const worktree = handle?.worktree;
228
+ handle = await provider.create(spec);
229
+ if (worktree) handle.worktree = worktree;
230
+ state.instances[spec.name] = handle;
231
+ }
232
+ handle.running = true;
233
+ handle.removed = false;
234
+ handle.sleeping = false;
235
+ activity(handle.spec.name);
236
+ await connect(handle);
237
+ await save();
238
+ if (spec.cloud) { handle.spec.cloud = await commitCloud(handle); await save(); }
239
+ return { ...summary(handle), ...(restored ? { omittedMounts: restored.manifest.omittedMounts } : {}) };
240
+ } catch (error) {
241
+ errors.set(spec.name, error.message);
242
+ if (handle) {
243
+ await provider.stop(handle).catch(() => {});
244
+ handle.running = false;
245
+ await disconnect(spec.name);
246
+ await save();
247
+ }
248
+ if (!handle && restored) {
249
+ try { await disposeRestoredVolumes(restored, spec, provider); } catch (cleanup) { error.message += ` Volume cleanup failed: ${cleanup.message}`; }
250
+ }
251
+ if (!handle && (spec.cloud?.recordId || error.cloud)) {
252
+ try { await disposeFailedCloud(error.cloud ?? spec); } catch (cleanup) { error.message += ` Cleanup failed: ${cleanup.message}`; }
253
+ }
254
+ await releaseEmpty();
255
+ throw error;
256
+ }
257
+ }
258
+ if (type === 'list') {
259
+ return Promise.all(Object.values(state.instances).map(async handle => ({ ...summary(handle), status: handle.removed ? Object.fromEntries(Object.keys(handle.services).map(key => [key, 'data retained'])) : handle.sleeping ? Object.fromEntries(Object.keys(handle.services).map(key => [key, 'sleeping'])) : await providerFor(handle.spec).status(handle) })));
260
+ }
261
+ if (type === 'use') {
262
+ const handle = instance(name);
263
+ if (!summary(handle).connected) throw new Error('This instance has no active connection. Run agent-env up first.');
264
+ for (const service of Object.values(handle.services)) {
265
+ const route = routes.get(service.port);
266
+ if (!route) continue;
267
+ if (route.selected !== name) for (const listener of route.listeners) listener.resetFocus();
268
+ route.selected = name;
269
+ state.focus[service.port] = name;
270
+ }
271
+ await save();
272
+ return summary(handle);
273
+ }
274
+ if (type === 'https') {
275
+ const result = await extras.enableHttps(message.port ?? 8443);
276
+ state.httpsPort = result.port;
277
+ await save();
278
+ return { ...result, message: `HTTPS enabled on port ${result.port}. Trust the CA with agent-env trust.` };
279
+ }
280
+ if (type === 'ui') {
281
+ const result = await extras.enableUi(message.port ?? 4780);
282
+ state.uiPort = Number(new URL(result.url).port);
283
+ await save();
284
+ return result;
285
+ }
286
+ if (type === 'down' || type === 'rm') {
287
+ const handles = message.all ? Object.values(state.instances) : [instance(name)];
288
+ for (const handle of handles) {
289
+ if (state.instances[handle.spec.name] !== handle) continue;
290
+ const provider = providerFor(handle.spec);
291
+ const cloud = handle.spec.cloud;
292
+ const peers = () => cloudPeers(handle, state.instances).filter(other => other !== handle);
293
+ if (type === 'rm' && message.removeWorktree && handle.worktree) await removeOwnedWorktree(handle.worktree, { checkOnly: true, dirty: message.discardWorktree ? 'discard' : undefined });
294
+ if (type === 'rm' && message.purge && cloud?.expiresAt && Date.parse(cloud.expiresAt) <= Date.now()) {
295
+ await action({ type: 'expire', name: handle.spec.name });
296
+ if (message.removeWorktree && handle.worktree) await removeOwnedWorktree(handle.worktree, { dirty: message.discardWorktree ? 'discard' : undefined });
297
+ continue;
298
+ }
299
+ extras.unwatch(handle.spec.name);
300
+ if (type === 'rm' && cloud && message.purge && !peers().length) {
301
+ await disconnect(handle.spec.name);
302
+ await destroyCloud(handle);
303
+ } else {
304
+ if (handle.sleeping && type === 'rm') {
305
+ await wakeForStorage(handle);
306
+ }
307
+ if (!handle.sleeping) {
308
+ if (type === 'rm') { if (!handle.removed || message.purge) await provider.remove(handle, message.purge); }
309
+ else await provider.stop(handle);
310
+ }
311
+ await disconnect(handle.spec.name);
312
+ if (cloudCanSleep(handle, state.instances)) {
313
+ await sleepCloud(handle);
314
+ for (const peer of [handle, ...peers()]) peer.sleeping = true;
315
+ }
316
+ }
317
+ handle.running = false;
318
+ if (type === 'rm' && message.removeWorktree && handle.worktree) {
319
+ await removeOwnedWorktree(handle.worktree, { dirty: message.discardWorktree ? 'discard' : undefined });
320
+ delete handle.worktree;
321
+ }
322
+ if (type === 'rm') {
323
+ if (cloud && !message.purge) handle.removed = true;
324
+ else delete state.instances[handle.spec.name];
325
+ }
326
+ await save();
327
+ }
328
+ return { message: type === 'rm' ? (handles.some(handle => handle.removed) ? 'Containers removed. Cloud data remains. Use rm --purge to delete the machine and data.' : 'Instances removed.') : 'Instances stopped. Data remains.' };
329
+ }
330
+ if (type === 'snapshot') {
331
+ const source = instance(name);
332
+ if (source.running || source.removed) throw new Error('Stop the source containers before a snapshot. Removed containers cannot identify their volumes.');
333
+ const provider = providerFor(source.spec);
334
+ if (!provider.docker) throw new Error('This provider does not support volume snapshots.');
335
+ try {
336
+ await wakeForStorage(source);
337
+ const saved = await snapshotInstance(source, message.location, provider, storageOptions(message));
338
+ return { message: `Saved ${saved.manifest.volumes.length} volumes to ${saved.location}.`, location: saved.location, omittedMounts: saved.manifest.omittedMounts };
339
+ } finally { await sleepUnusedCloud(source); }
340
+ }
341
+ if (type === 'fork') {
342
+ const source = instance(name);
343
+ const destination = validName(message.destination);
344
+ if (Object.hasOwn(state.instances, destination)) throw new Error('The destination instance already exists.');
345
+ if (source.running || source.removed) throw new Error('Stop the source before a fork. The source containers must still exist.');
346
+ const provider = providerFor(source.spec);
347
+ let worktree, temporary, restored, handle;
348
+ let spec = { ...source.spec, name: destination };
349
+ try {
350
+ await wakeForStorage(source);
351
+ const status = await provider.status(source);
352
+ if (Object.values(status).some(value => value !== 'exited' && value !== 'created')) throw new Error('Stop the source before a fork. A running database cannot be copied safely.');
353
+ if (message.worktree || message.branch || message.ref) {
354
+ worktree = await createWorktree(source.spec, destination, { path: message.worktree, branch: message.branch, ref: message.ref });
355
+ spec = provider.relocate ? provider.relocate(spec, worktree.cwd) : { ...spec, cwd: worktree.cwd };
356
+ }
357
+ await reserve(spec);
358
+ if (spec.provider === 'compose') {
359
+ temporary = await mkdtemp(join(home, 'fork-'));
360
+ const location = join(temporary, 'snapshot');
361
+ await snapshotInstance(source, location, provider, storageOptions(message));
362
+ restored = await restoreSnapshot(spec, location, provider);
363
+ handle = await provider.create(spec);
364
+ } else handle = await provider.fork(source, spec);
365
+ if (worktree) handle.worktree = worktree;
366
+ state.instances[destination] = handle;
367
+ handle.running = true;
368
+ activity(destination);
369
+ await connect(handle);
370
+ await save();
371
+ return { ...summary(handle), omittedPaths: worktree?.omittedPaths };
372
+ } catch (error) {
373
+ errors.set(destination, error.message);
374
+ if (handle) {
375
+ await provider.stop(handle).catch(() => {});
376
+ handle.running = false;
377
+ await disconnect(destination);
378
+ } else {
379
+ if (restored) await disposeRestoredVolumes(restored, spec, provider).catch(cleanup => { error.message += ` Volume cleanup failed: ${cleanup.message}`; });
380
+ if (worktree) await removeOwnedWorktree(worktree, { dirty: 'discard' }).catch(cleanup => { error.message += ` Worktree cleanup failed: ${cleanup.message}`; });
381
+ }
382
+ await releaseEmpty();
383
+ await sleepUnusedCloud(source).catch(() => {});
384
+ await save();
385
+ throw error;
386
+ } finally { if (temporary) await rm(temporary, { recursive: true, force: true }); }
387
+ }
388
+ if (['logs', 'exec', 'sync'].includes(type)) {
389
+ const handle = instance(name);
390
+ const provider = providerFor(handle.spec);
391
+ activity(name);
392
+ if (handle.sleeping) throw new Error('The cloud machine is sleeping. Run agent-env up first.');
393
+ if (type === 'sync') { await provider.sync(handle); return { message: 'Files synchronized.' }; }
394
+ const service = message.service ?? Object.keys(handle.services)[0];
395
+ if (!handle.services[service]) throw new Error(`Service ${service} does not exist.`);
396
+ const output = type === 'logs' ? await provider.logs(handle, service) : await provider.exec(handle, service, message.command);
397
+ return { output: redact(handle, output) };
398
+ }
399
+ throw new Error(`Unknown action: ${type}.`);
400
+ }
401
+ let queue = Promise.resolve();
402
+ function enqueue(work) { const pending = queue.then(work); queue = pending.catch(() => {}); return pending; }
403
+ let shuttingDown = false;
404
+ const control = http.createServer(async (request, response) => {
405
+ let commandMessage;
406
+ try {
407
+ let body = '';
408
+ for await (const part of request) {
409
+ body += part;
410
+ if (body.length > 1024 * 1024) throw new Error('The request is too large.');
411
+ }
412
+ const message = JSON.parse(body);
413
+ commandMessage = message;
414
+ let result;
415
+ if (message.type === 'ping') result = { pid: process.pid };
416
+ else if (message.type === 'shutdown') {
417
+ shuttingDown = true;
418
+ result = { message: 'Routing stopped. Containers remain. Use down to stop containers.' };
419
+ queue = queue.then(async () => { await shutdown(); });
420
+ } else {
421
+ if (shuttingDown) throw new Error('The daemon is stopping.');
422
+ result = await enqueue(() => action(message));
423
+ }
424
+ response.writeHead(200, { 'content-type': 'application/json' });
425
+ response.end(JSON.stringify(result));
426
+ } catch (error) {
427
+ response.writeHead(400, { 'content-type': 'application/json' });
428
+ if (error.cleanupError) error.message += ` Cleanup needs attention: ${error.cleanupError.message}`;
429
+ if (error.cleanupErrors?.length) error.message += ` Cleanup needs attention: ${error.cleanupErrors.map(item => item.message).join(' ')}`;
430
+ let safeError = redact({ spec: { ...commandMessage?.spec, env: { ...commandMessage?.spec?.env, ...commandMessage?.awsEnv } } }, error.message);
431
+ for (const handle of Object.values(state.instances)) safeError = redact(handle, safeError);
432
+ response.end(JSON.stringify({ error: safeError, exitCode: error.exitCode ?? 1 }));
433
+ }
434
+ });
435
+ async function shutdown() {
436
+ clearInterval(maintenance);
437
+ try {
438
+ await extras.close();
439
+ for (const opened of connections.values()) for (const item of opened) item.close();
440
+ for (const route of routes.values()) for (const listener of route.listeners) await listener.close();
441
+ await disposeProviders();
442
+ } catch { console.error('Provider cleanup failed during shutdown. Check for remaining provider sessions.'); }
443
+ finally {
444
+ control.close(() => process.exit(0));
445
+ setTimeout(() => process.exit(0), 1000).unref();
446
+ }
447
+ }
448
+
449
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { shuttingDown = true; queue.finally(shutdown); });
450
+ control.listen(socketPath);
451
+ await once(control, 'listening');
452
+ await chmod(socketPath, 0o600);
453
+ queue = (async () => {
454
+ const preferred = { ...state.focus };
455
+ for (const handle of Object.values(state.instances)) {
456
+ if (!handle.running) continue;
457
+ try { await providerFor(handle.spec).wait(handle); await connect(handle); activity(handle.spec.name); }
458
+ catch (error) { errors.set(handle.spec.name, error.message); await releaseEmpty(); }
459
+ }
460
+ for (const [port, route] of routes) {
461
+ if (route.targets.has(preferred[port])) route.selected = preferred[port];
462
+ state.focus[port] = route.selected;
463
+ }
464
+ if (state.httpsPort !== undefined) await extras.enableHttps(state.httpsPort);
465
+ if (state.uiPort !== undefined) await extras.enableUi(state.uiPort);
466
+ await save();
467
+ })().catch(error => console.error(error.message));
468
+
469
+ let maintaining = false;
470
+ const maintenance = setInterval(() => {
471
+ if (shuttingDown || maintaining) return;
472
+ maintaining = true;
473
+ enqueue(() => maintainInstances({
474
+ instances: state.instances, action, retries, errors, connected: handle => summary(handle).connected,
475
+ reconnect: async handle => {
476
+ for (const connection of connections.get(handle.spec.name) ?? []) connection.close();
477
+ connections.delete(handle.spec.name);
478
+ await providerFor(handle.spec).wait(handle);
479
+ await connect(handle);
480
+ await save();
481
+ },
482
+ })).catch(error => console.error(error.message)).finally(() => { maintaining = false; });
483
+ }, 2000);
484
+ maintenance.unref();