@volter/supercode-orchestrator 0.4.4
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 +532 -0
- package/activation.mjs +270 -0
- package/adapters/api-server.mjs +615 -0
- package/adapters/chat-sdk.mjs +475 -0
- package/adapters/chunks.mjs +14 -0
- package/adapters/loopback.mjs +37 -0
- package/adapters/rh2.mjs +349 -0
- package/apply/doors.d.ts +2 -0
- package/apply/doors.mjs +292 -0
- package/apply/hermes_door.py +129 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.mjs +441 -0
- package/apply/state.mjs +142 -0
- package/bin/hermes-worker.mjs +11 -0
- package/bin/orchestrator.mjs +730 -0
- package/board.mjs +81 -0
- package/commands.mjs +37 -0
- package/cron.mjs +967 -0
- package/events.mjs +107 -0
- package/gates.mjs +281 -0
- package/hermes-cli.mjs +440 -0
- package/hermes-home.mjs +264 -0
- package/hermes-locks.mjs +188 -0
- package/hosted.mjs +81 -0
- package/index.d.ts +508 -0
- package/index.mjs +38 -0
- package/ir.mjs +205 -0
- package/kanban-worker.mjs +155 -0
- package/lease.mjs +51 -0
- package/loop.mjs +864 -0
- package/mcp/bridge.mjs +133 -0
- package/mcp/hermes-kanban-tools.json +477 -0
- package/mcp/hermes-tools.json +288 -0
- package/mcp/mount.mjs +161 -0
- package/mcp/server.mjs +160 -0
- package/mcp/tools.mjs +57 -0
- package/mcp/verbs.mjs +348 -0
- package/ontology.d.ts +557 -0
- package/operator.mjs +426 -0
- package/orchestration.mjs +166 -0
- package/package.json +51 -0
- package/reducer.mjs +792 -0
- package/webhook.mjs +263 -0
- package/worker-route.mjs +122 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The orchestrator daemon entry (ORC-7).
|
|
3
|
+
//
|
|
4
|
+
// node bin/orchestrator.mjs --root <home> [--once]
|
|
5
|
+
// node bin/orchestrator.mjs access <verb> [args] [--root <home>] [--profile <name>]
|
|
6
|
+
//
|
|
7
|
+
// `supercode orchestrator start` spawns the daemon form; the daemon itself
|
|
8
|
+
// writes the lease (`<home>/orchestrator.lock`) naming this process and
|
|
9
|
+
// removes it on the way out, so a service-managed daemon reports the same
|
|
10
|
+
// lease a foreground one does. `supercode orchestrator stop` sends SIGTERM.
|
|
11
|
+
// A lock left behind by a process that is gone is the mark of a daemon that
|
|
12
|
+
// died: this one dispatches `restart` (§4.7) before it accepts anything.
|
|
13
|
+
//
|
|
14
|
+
// The `access` form is the COLD path of §4.6's operator verbs: it loads the
|
|
15
|
+
// home, applies the reducer's own event, and saves through `save()`. It is
|
|
16
|
+
// the only writer of the folder — `supercode orchestrator pair|access ...`
|
|
17
|
+
// spawns this rather than encoding the YAML a second time.
|
|
18
|
+
//
|
|
19
|
+
// It is deliberately thin: load the home strictly through the IR, build the
|
|
20
|
+
// `Orchestrator` (ORC-4/ORC-5) over a `SupercodeHarnessClient` and the adapters
|
|
21
|
+
// the profiles' `platforms:` blocks name, `start()` it, and stay up until
|
|
22
|
+
// SIGTERM — at which point `stop()` closes the tick, the webhook listener,
|
|
23
|
+
// every runtime and every adapter. The loop owns its own clock and its own
|
|
24
|
+
// `/webhooks/<name>` listener; nothing here schedules or serves anything. The
|
|
25
|
+
// only policy in this file is which adapter class serves which platform:
|
|
26
|
+
// `loopback`, the `api_server`-compatible HTTP surface, and the Chat SDK
|
|
27
|
+
// sidecar a block selects with `extra.adapter: chat-sdk` (§7, ORC-9).
|
|
28
|
+
//
|
|
29
|
+
// Beside the daemon it carries the two migration verbs (ORC-12):
|
|
30
|
+
//
|
|
31
|
+
// node bin/orchestrator.mjs import --from hermes|openclaw [--home <path>] [--into <root>]
|
|
32
|
+
// node bin/orchestrator.mjs export --to hermes|openclaw [--from <root>] [--into <dest>]
|
|
33
|
+
//
|
|
34
|
+
// `import` is a compile followed by a `save`; `export` is the codec, and it
|
|
35
|
+
// prints the tier of every file it wrote. A refused write (the session half,
|
|
36
|
+
// behind the UNI-22 stability gate) prints its reason and exits 2 — a partial
|
|
37
|
+
// migration is never reported as a whole one.
|
|
38
|
+
//
|
|
39
|
+
// And it carries the COLD PATH of the operator door (ORC-13, §4.6):
|
|
40
|
+
//
|
|
41
|
+
// node bin/orchestrator.mjs <op> --root <home> [--profile <p>] --json '<args>'
|
|
42
|
+
//
|
|
43
|
+
// where `<op>` is one of `operator.mjs`'s `OPERATOR_OPS`. It loads the home,
|
|
44
|
+
// applies the verb through the very same `applyOperator` the daemon's socket
|
|
45
|
+
// uses, performs the effects (`save`, `remove_profile_dir`) and prints one
|
|
46
|
+
// `{ok, result}` line — the identical answer the socket gives, so `supercode
|
|
47
|
+
// jobs create --harness orchestrator` writes the same bytes whether the daemon
|
|
48
|
+
// is up or down. It exits 0 on `ok`, 1 on a refusal.
|
|
49
|
+
//
|
|
50
|
+
// Everything it prints is one JSON object per line on stdout.
|
|
51
|
+
|
|
52
|
+
import { existsSync, mkdirSync, rmdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
53
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
54
|
+
import { spawn } from 'node:child_process';
|
|
55
|
+
import { fileURLToPath } from 'node:url';
|
|
56
|
+
import { homedir } from 'node:os';
|
|
57
|
+
import { LOCK_FILE, leaseIsLive, readLease, thisHost } from '../lease.mjs';
|
|
58
|
+
import { socketPathFor } from '../mcp/server.mjs';
|
|
59
|
+
import { GATEWAY_KIND, holdGatewayLock } from '../hermes-locks.mjs';
|
|
60
|
+
import { adoptHome } from '../hermes-home.mjs';
|
|
61
|
+
import { hermesAccess } from '../hermes-cli.mjs';
|
|
62
|
+
|
|
63
|
+
import {
|
|
64
|
+
load, save, step, importFrom, exportTo, closeWorld, Orchestrator, LoopbackAdapter,
|
|
65
|
+
ChatSdkAdapter, ApiServerAdapter, Rh2Adapter,
|
|
66
|
+
TICK_MS, accessPolicy, applyOperator, performRemoveProfileDir, OPERATOR_OPS,
|
|
67
|
+
} from '../index.mjs';
|
|
68
|
+
import * as events from '../events.mjs';
|
|
69
|
+
import { httpJobExecutor } from '../hosted.mjs';
|
|
70
|
+
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
71
|
+
|
|
72
|
+
/// Adapter selector -> constructor. A `platforms:` block picks its adapter by
|
|
73
|
+
/// `extra.adapter` when it has one and by the block's own name otherwise, so
|
|
74
|
+
/// `platforms.slack.extra.adapter: chat-sdk` and `platforms.api_server` both
|
|
75
|
+
/// resolve here. §7's build order is loopback, then the api_server-compatible
|
|
76
|
+
/// HTTP surface, then the Chat SDK sidecar (ORC-9); Claude Code's channel
|
|
77
|
+
/// plugins are NOT an adapter family (ORC-1 F4) and have no entry. A block
|
|
78
|
+
/// whose selector is missing here is reported and left unconnected, never faked.
|
|
79
|
+
const ADAPTERS = {
|
|
80
|
+
loopback: (platform, profile) => new LoopbackAdapter({ platform, profile }),
|
|
81
|
+
'chat-sdk': (platform, profile, ctx) => new ChatSdkAdapter({ platform, profile, profileDir: ctx.state.profiles[profile]?.dir ?? null, channel: ctx.channel, vault: ctx.vault, log: ctx.log }),
|
|
82
|
+
api_server: (platform, profile, ctx) => new ApiServerAdapter({ platform, profile, channel: ctx.channel, vault: ctx.vault, state: ctx.state, log: ctx.log }),
|
|
83
|
+
rh2: (platform, profile, ctx) => new Rh2Adapter({ platform, profile, channel: ctx.channel, vault: ctx.vault, log: ctx.log }),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/// The selector a `platforms:` block resolves through: its `extra.adapter`
|
|
87
|
+
/// when it names one (the loader prefixes `extra:` keys with `extra.`), else
|
|
88
|
+
/// the block's own name.
|
|
89
|
+
function adapterSelector(platform, channel) {
|
|
90
|
+
const named = channel?.extra?.['extra.adapter'] ?? channel?.extra?.adapter;
|
|
91
|
+
if (typeof named === 'string' && named) return named;
|
|
92
|
+
// a Hermes-shaped slack/telegram/discord block is served by the Chat SDK
|
|
93
|
+
// sidecar, with Hermes's own variables and ingress (HERMES_PLATFORMS)
|
|
94
|
+
return ['slack', 'telegram', 'discord'].includes(platform) ? 'chat-sdk' : platform;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseArgs(argv) {
|
|
98
|
+
const args = { root: null, once: false, version: false };
|
|
99
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
100
|
+
const arg = argv[i];
|
|
101
|
+
const take = () => {
|
|
102
|
+
const value = argv[i + 1];
|
|
103
|
+
i += 1;
|
|
104
|
+
return value;
|
|
105
|
+
};
|
|
106
|
+
if (arg === '--executor-url') args.executorUrl = take();
|
|
107
|
+
else if (arg === '--executor-token-file') args.executorTokenFile = take();
|
|
108
|
+
else if (arg === '--root') args.root = take();
|
|
109
|
+
else if (arg.startsWith('--root=')) args.root = arg.slice('--root='.length);
|
|
110
|
+
else if (arg === '--once') args.once = true;
|
|
111
|
+
else if (arg === '--version') args.version = true;
|
|
112
|
+
else throw new Error(`unknown argument \`${arg}\``);
|
|
113
|
+
}
|
|
114
|
+
return args;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function say(event, fields) {
|
|
118
|
+
process.stdout.write(`${JSON.stringify({ event, ...fields })}\n`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function packageVersion() {
|
|
122
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
123
|
+
return JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function defaultRoot() {
|
|
127
|
+
return process.env.SUPERCODE_ORCHESTRATOR_HOME || join(homedir(), '.supercode/orchestrator');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --------------------------------------------------------------- migration
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Each gateway harness's own home resolution, mirrored exactly (the same
|
|
134
|
+
* semantics `crates/interchange/src/catalog.rs` mirrors), so an inherited
|
|
135
|
+
* environment means the same thing here as to the harness itself.
|
|
136
|
+
*/
|
|
137
|
+
const HOMES = {
|
|
138
|
+
hermes: () => process.env.HERMES_HOME || join(homedir(), '.hermes'),
|
|
139
|
+
openclaw: () => process.env.OPENCLAW_STATE_DIR
|
|
140
|
+
|| (process.env.OPENCLAW_HOME ? join(process.env.OPENCLAW_HOME, '.openclaw') : null)
|
|
141
|
+
|| join(homedir(), '.openclaw'),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
function parseMigrationArgs(argv, verb) {
|
|
145
|
+
const args = { harness: null, home: null, into: null };
|
|
146
|
+
const harnessFlag = verb === 'import' ? '--from' : '--to';
|
|
147
|
+
const homeFlag = verb === 'import' ? '--home' : '--from';
|
|
148
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
149
|
+
const arg = argv[i];
|
|
150
|
+
const take = () => { const value = argv[i + 1]; i += 1; return value; };
|
|
151
|
+
if (arg === harnessFlag) args.harness = take();
|
|
152
|
+
else if (arg.startsWith(`${harnessFlag}=`)) args.harness = arg.slice(harnessFlag.length + 1);
|
|
153
|
+
else if (arg === homeFlag) args.home = take();
|
|
154
|
+
else if (arg.startsWith(`${homeFlag}=`)) args.home = arg.slice(homeFlag.length + 1);
|
|
155
|
+
else if (arg === '--into') args.into = take();
|
|
156
|
+
else if (arg.startsWith('--into=')) args.into = arg.slice('--into='.length);
|
|
157
|
+
else throw new Error(`unknown argument \`${arg}\``);
|
|
158
|
+
}
|
|
159
|
+
if (!args.harness) throw new Error(`\`${verb}\` needs ${harnessFlag} ${Object.keys(HOMES).join('|')}`);
|
|
160
|
+
if (!HOMES[args.harness]) {
|
|
161
|
+
throw new Error(`unknown harness \`${args.harness}\`; ${verb} supports: ${Object.keys(HOMES).join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
return args;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function profileCounts(state) {
|
|
167
|
+
return Object.entries(state.profiles).map(([name, profile]) => profileReport(name, profile));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `import --from <harness>`: that harness's home becomes our folder, through
|
|
172
|
+
* `world.import` — a compile and a save composed in the harness process, so
|
|
173
|
+
* the credentials land in our `.env` without crossing the wire, and the
|
|
174
|
+
* files the world does not model (`MEMORY.md`, `skills/`, an agent's
|
|
175
|
+
* transcripts) are carried by path.
|
|
176
|
+
*/
|
|
177
|
+
async function runImport(argv) {
|
|
178
|
+
const args = parseMigrationArgs(argv, 'import');
|
|
179
|
+
const home = resolve(args.home ?? HOMES[args.harness]());
|
|
180
|
+
const into = resolve(args.into ?? defaultRoot());
|
|
181
|
+
const imported = await importFrom(args.harness, home, into);
|
|
182
|
+
say('imported', {
|
|
183
|
+
from: args.harness,
|
|
184
|
+
home,
|
|
185
|
+
into,
|
|
186
|
+
profiles: profileCounts(imported.orchestration),
|
|
187
|
+
carried: imported.carried,
|
|
188
|
+
secrets: [...imported.vault_keys].sort(),
|
|
189
|
+
});
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `export --to <harness>`: our folder becomes that harness's home, through
|
|
195
|
+
* `world.export`. Our `state.db` is never a Hermes session store — it carries
|
|
196
|
+
* the world's `bindings`, not Hermes's `sessions` — so the codec's own UNI-22
|
|
197
|
+
* gate refuses the session half, and a refused half is exit 2, never a whole
|
|
198
|
+
* migration.
|
|
199
|
+
*/
|
|
200
|
+
async function runExport(argv) {
|
|
201
|
+
const args = parseMigrationArgs(argv, 'export');
|
|
202
|
+
const root = resolve(args.home ?? defaultRoot());
|
|
203
|
+
const into = resolve(args.into ?? join(root, `export-${args.harness}`));
|
|
204
|
+
const report = await exportTo(args.harness, root, into);
|
|
205
|
+
say('exported', {
|
|
206
|
+
to: args.harness,
|
|
207
|
+
root,
|
|
208
|
+
into,
|
|
209
|
+
written: report.written,
|
|
210
|
+
notes: report.notes ?? [],
|
|
211
|
+
refused: report.refused,
|
|
212
|
+
});
|
|
213
|
+
for (const refusal of report.refused) say('refused', { file: refusal.file, reason: refusal.reason });
|
|
214
|
+
return report.refused.length ? 2 : 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --------------------------------------------------------------- operator
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `<op> --root <home> [--profile <p>] [--json '<args>']` — the cold path of
|
|
221
|
+
* the operator door.
|
|
222
|
+
*
|
|
223
|
+
* One code path with the daemon: `applyOperator` is the same function the
|
|
224
|
+
* socket calls, and the effects it produces are performed here exactly as the
|
|
225
|
+
* loop performs them (`save` writes the folder through `save()`;
|
|
226
|
+
* `remove_profile_dir` moves a deleted profile's folder into
|
|
227
|
+
* `profiles/.trash/`). Nothing else runs — no adapters, no tick, no worker.
|
|
228
|
+
*/
|
|
229
|
+
/** One JSON line to a live daemon's operator door and its one-line answer. */
|
|
230
|
+
async function overSocket(path, request) {
|
|
231
|
+
const { createConnection } = await import('node:net');
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
const socket = createConnection(path);
|
|
234
|
+
let buffer = '';
|
|
235
|
+
const done = (answer) => { socket.destroy(); resolve(answer); };
|
|
236
|
+
socket.setTimeout(30_000, () => done({ ok: false, error: `the daemon's door at ${path} did not answer` }));
|
|
237
|
+
socket.once('connect', () => socket.write(`${JSON.stringify(request)}\n`));
|
|
238
|
+
socket.on('data', (chunk) => {
|
|
239
|
+
buffer += chunk;
|
|
240
|
+
const nl = buffer.indexOf('\n');
|
|
241
|
+
if (nl < 0) return;
|
|
242
|
+
try { done(JSON.parse(buffer.slice(0, nl))); } catch { done({ ok: false, error: 'the daemon answered with no JSON' }); }
|
|
243
|
+
});
|
|
244
|
+
socket.once('error', (error) => done({ ok: false, error: `the daemon's lease is live but its door refused: ${error.message}` }));
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parseOperatorArgs(argv) {
|
|
249
|
+
const args = { root: null, profile: 'default', json: '{}', at: null };
|
|
250
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
251
|
+
const arg = argv[i];
|
|
252
|
+
const take = () => { const value = argv[i + 1]; i += 1; return value; };
|
|
253
|
+
if (arg === '--root') args.root = take();
|
|
254
|
+
else if (arg.startsWith('--root=')) args.root = arg.slice('--root='.length);
|
|
255
|
+
else if (arg === '--profile') args.profile = take();
|
|
256
|
+
else if (arg.startsWith('--profile=')) args.profile = arg.slice('--profile='.length);
|
|
257
|
+
else if (arg === '--json') args.json = take();
|
|
258
|
+
else if (arg.startsWith('--json=')) args.json = arg.slice('--json='.length);
|
|
259
|
+
// An injected clock, so a receipt is reproducible. Never a default.
|
|
260
|
+
else if (arg === '--at') args.at = take();
|
|
261
|
+
else if (arg.startsWith('--at=')) args.at = arg.slice('--at='.length);
|
|
262
|
+
else throw new Error(`unknown argument \`${arg}\``);
|
|
263
|
+
}
|
|
264
|
+
return args;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function runOperator(op, argv) {
|
|
268
|
+
const args = parseOperatorArgs(argv);
|
|
269
|
+
const root = resolve(args.root ?? defaultRoot());
|
|
270
|
+
let payload;
|
|
271
|
+
try {
|
|
272
|
+
payload = JSON.parse(args.json || '{}');
|
|
273
|
+
} catch (error) {
|
|
274
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: `--json is not JSON: ${error.message}` })}\n`);
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
// A live daemon holds the home's state in memory and re-saves it: a write made beside it would be lost on its next
|
|
278
|
+
// save. While its lease is live the verb goes over its socket, as supercode's own client sends it (ORC-13).
|
|
279
|
+
const lease = readLease(root);
|
|
280
|
+
if (lease && leaseIsLive(lease)) {
|
|
281
|
+
const answer = await overSocket(socketPathFor(root), { op, args: payload, profile: args.profile });
|
|
282
|
+
process.stdout.write(`${JSON.stringify({ ...answer, root, door: 'live', context_profile: args.profile })}\n`);
|
|
283
|
+
return answer.ok ? 0 : 1;
|
|
284
|
+
}
|
|
285
|
+
let state;
|
|
286
|
+
let vault;
|
|
287
|
+
try {
|
|
288
|
+
({ state, vault } = await load(root));
|
|
289
|
+
} catch (error) {
|
|
290
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: `${root}: ${String(error.message ?? error)}` })}\n`);
|
|
291
|
+
return 1;
|
|
292
|
+
}
|
|
293
|
+
const now = args.at ?? new Date().toISOString();
|
|
294
|
+
const logs = [];
|
|
295
|
+
const dispatch = async (event) => {
|
|
296
|
+
const effects = step(state, event);
|
|
297
|
+
for (const effect of effects) {
|
|
298
|
+
if (effect.type === 'save') await save(state, root);
|
|
299
|
+
else if (effect.type === 'remove_profile_dir') performRemoveProfileDir(root, effect, now);
|
|
300
|
+
else if (effect.type === 'log') logs.push(`${effect.level}: ${effect.message}`);
|
|
301
|
+
// Every other effect needs a running loop (a worker, an adapter). The
|
|
302
|
+
// cold path performs none of them and says so rather than pretending.
|
|
303
|
+
else logs.push(`skipped: ${effect.type} needs the daemon`);
|
|
304
|
+
}
|
|
305
|
+
return effects;
|
|
306
|
+
};
|
|
307
|
+
const answer = await applyOperator(state, op, payload, args.profile, now, dispatch);
|
|
308
|
+
// `root`/`door` ride BESIDE the result, never inside it: `profiles.create`
|
|
309
|
+
// answers with a `profile` record of its own, and the door must not clobber
|
|
310
|
+
// the verb's own row.
|
|
311
|
+
const line = answer.ok
|
|
312
|
+
? { ok: true, result: answer.result, root, door: 'cold', context_profile: args.profile }
|
|
313
|
+
: { ok: false, error: answer.error, root, door: 'cold', context_profile: args.profile };
|
|
314
|
+
if (logs.length) line.log = logs;
|
|
315
|
+
process.stdout.write(`${JSON.stringify(line)}\n`);
|
|
316
|
+
return answer.ok ? 0 : 1;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* One adapter per (platform, owning profile) named by an enabled `platforms:`
|
|
321
|
+
* block. A platform whose adapter does not exist yet is listed as
|
|
322
|
+
* `unsupported`: the daemon never pretends a channel is connected.
|
|
323
|
+
*/
|
|
324
|
+
function buildAdapters(state, vault = new Map(), log = () => {}) {
|
|
325
|
+
const adapters = {};
|
|
326
|
+
const report = [];
|
|
327
|
+
for (const [profileName, profile] of Object.entries(state.profiles)) {
|
|
328
|
+
for (const [platform, channel] of Object.entries(profile.channels)) {
|
|
329
|
+
if (!channel.enabled) {
|
|
330
|
+
report.push({ platform, profile: profileName, state: 'disabled' });
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (adapters[platform]) {
|
|
334
|
+
report.push({ platform, profile: profileName, state: 'already owned by another profile' });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const selector = adapterSelector(platform, channel);
|
|
338
|
+
const make = ADAPTERS[selector];
|
|
339
|
+
if (!make) {
|
|
340
|
+
report.push({ platform, profile: profileName, state: `unsupported (no adapter \`${selector}\` for this platform yet)` });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
adapters[platform] = make(platform, profileName, { channel, state, vault, log });
|
|
344
|
+
report.push({ platform, profile: profileName, adapter: selector, state: 'connecting' });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { adapters, report };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function profileReport(name, profile) {
|
|
351
|
+
return {
|
|
352
|
+
profile: name,
|
|
353
|
+
dir: profile.dir,
|
|
354
|
+
worker: profile.worker ? profile.worker.harness : null,
|
|
355
|
+
model: profile.worker?.model ?? null,
|
|
356
|
+
jobs: Object.keys(profile.jobs).length,
|
|
357
|
+
routes: profile.routes.length,
|
|
358
|
+
subscriptions: Object.keys(profile.subscriptions).length,
|
|
359
|
+
bindings: Object.keys(profile.bindings).length,
|
|
360
|
+
fires: profile.fires.length,
|
|
361
|
+
obligations: profile.obligations.length,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------- the lease
|
|
366
|
+
|
|
367
|
+
/** The lease file, shared with `supercode orchestrator status|stop` (Rust `Lease`). */
|
|
368
|
+
const readLock = readLease;
|
|
369
|
+
|
|
370
|
+
function writeLock(root, lease) {
|
|
371
|
+
writeFileSync(join(root, LOCK_FILE), `${JSON.stringify(lease, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Only ever removes a lease this process holds. */
|
|
375
|
+
function clearLock(root) {
|
|
376
|
+
const held = readLock(root);
|
|
377
|
+
if (held && held.pid !== process.pid) return;
|
|
378
|
+
try { unlinkSync(join(root, LOCK_FILE)); } catch { /* already gone */ }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------- access verbs
|
|
382
|
+
|
|
383
|
+
/** `<verb> <positional...>` -> the operator event §4.6 defines for it. */
|
|
384
|
+
|
|
385
|
+
function parseAccessArgs(argv) {
|
|
386
|
+
const args = { root: null, profile: 'default', rest: [] };
|
|
387
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
388
|
+
const arg = argv[i];
|
|
389
|
+
if (arg === '--root') args.root = argv[(i += 1)];
|
|
390
|
+
else if (arg.startsWith('--root=')) args.root = arg.slice('--root='.length);
|
|
391
|
+
else if (arg === '--profile') args.profile = argv[(i += 1)];
|
|
392
|
+
else if (arg.startsWith('--profile=')) args.profile = arg.slice('--profile='.length);
|
|
393
|
+
else if (arg.startsWith('--')) throw new Error(`unknown argument \`${arg}\``);
|
|
394
|
+
else args.rest.push(arg);
|
|
395
|
+
}
|
|
396
|
+
return args;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Pending pairings as Hermes's pairing store lists them (codes are stored hashed; a row names who asked). */
|
|
400
|
+
function pairingRows(profile) {
|
|
401
|
+
return hermesAccess(profile.dir, 'pending').map((p) => ({ ...p, policy: accessPolicy(profile, p.platform) }));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function accessReport(root, name, profile, extra = {}) {
|
|
405
|
+
return {
|
|
406
|
+
root,
|
|
407
|
+
profile: name,
|
|
408
|
+
policy: profile.access.policy,
|
|
409
|
+
default_policy: 'allowlist',
|
|
410
|
+
allowlist: profile.access.allowlist,
|
|
411
|
+
admins: profile.access.admins,
|
|
412
|
+
pending: pairingRows(profile),
|
|
413
|
+
...extra,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** What each access verb asks of Hermes's own store and writers (docs/plans/hermes-compat.md row 8). */
|
|
418
|
+
const ACCESS_OPS = {
|
|
419
|
+
'pair-approve': (profile, rest) => ['approve', { code: rest[0], platforms: [...new Set([...Object.keys(profile.channels ?? {}), ...hermesAccess(profile.dir, 'pending').map((p) => p.platform)])] }],
|
|
420
|
+
allow: (profile, rest) => ['allow', { platform: rest[0], user_id: rest[1] }],
|
|
421
|
+
revoke: (profile, rest) => ['revoke', { platform: rest[0], user_id: rest[1] }],
|
|
422
|
+
'admin-add': (profile, rest) => ['admin_add', { platform: rest[0], user_id: rest[1] }],
|
|
423
|
+
'admin-remove': (profile, rest) => ['admin_remove', { platform: rest[0], user_id: rest[1] }],
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* `access <verb> ...`: the COLD path. Access lives in Hermes's stores, so each verb is performed through
|
|
428
|
+
* Hermes's own pairing store and writers, then the home is loaded again to report what stands.
|
|
429
|
+
*/
|
|
430
|
+
async function accessMain(argv) {
|
|
431
|
+
let parsed;
|
|
432
|
+
try {
|
|
433
|
+
parsed = parseAccessArgs(argv);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
say('error', { message: error.message });
|
|
436
|
+
process.exit(2);
|
|
437
|
+
}
|
|
438
|
+
const [verb, ...rest] = parsed.rest;
|
|
439
|
+
const root = resolve(parsed.root ?? defaultRoot());
|
|
440
|
+
let state;
|
|
441
|
+
let vault;
|
|
442
|
+
try {
|
|
443
|
+
({ state, vault } = await load(root));
|
|
444
|
+
} catch (error) {
|
|
445
|
+
say('error', { root, message: String(error.message ?? error), name: error.name ?? 'Error' });
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
const profile = state.profiles[parsed.profile];
|
|
449
|
+
if (!profile) {
|
|
450
|
+
say('error', { root, message: `unknown profile \`${parsed.profile}\` (have: ${Object.keys(state.profiles).join(', ')})` });
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
if (verb === 'pair-list') {
|
|
454
|
+
say('access', accessReport(root, parsed.profile, profile, { verb }));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const make = ACCESS_OPS[verb];
|
|
458
|
+
if (!make) {
|
|
459
|
+
say('error', { root, message: `unknown access verb \`${verb ?? ''}\` (have: pair-list, ${Object.keys(ACCESS_OPS).join(', ')})` });
|
|
460
|
+
process.exit(2);
|
|
461
|
+
}
|
|
462
|
+
const [op, args] = make(profile, rest);
|
|
463
|
+
if ((op === 'approve' && !args.code) || (op !== 'approve' && (!args.platform || !args.user_id))) {
|
|
464
|
+
say('error', { root, profile: parsed.profile, verb, message: op === 'approve' ? 'pair-approve takes a code' : `${verb} takes a platform and a user id` });
|
|
465
|
+
process.exit(2);
|
|
466
|
+
}
|
|
467
|
+
let got;
|
|
468
|
+
try {
|
|
469
|
+
got = hermesAccess(profile.dir, op, args);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
say('error', { root, profile: parsed.profile, verb, message: error.message });
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
if (op === 'approve' && !got) {
|
|
475
|
+
say('error', { root, profile: parsed.profile, verb, message: `pairing code ${args.code} is not pending in Hermes's pairing store (wrong, expired, or locked out)` });
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
const fresh = (await load(root)).state.profiles[parsed.profile];
|
|
479
|
+
say('access', accessReport(root, parsed.profile, fresh, { verb, changed: true, ...(op === 'approve' ? { approved: got } : {}) }));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The agent-setup applier (plan §7), one home per call:
|
|
484
|
+
*
|
|
485
|
+
* apply plan|apply|adopt|resolve|provision --home-id <id> (--hermes <home> | --orchestrator <root> [--profile <p>])
|
|
486
|
+
* [--spec <file.json>] [--param name=value ...] [--plan-hash <h>] [--reviewed] [--keys a,b]
|
|
487
|
+
* [--python <py> --source <dir>] [--container <name>] (--container: HERMES_HOME is the container's path)
|
|
488
|
+
*
|
|
489
|
+
* Prints one {ok, result} line. `plan` never changes anything; `apply` refuses a
|
|
490
|
+
* stale --plan-hash; `delete` runs only with --reviewed; a home with no apply
|
|
491
|
+
* state is plan-only until `provision` or `adopt`.
|
|
492
|
+
*/
|
|
493
|
+
async function runApply(argv) {
|
|
494
|
+
const [verb, ...rest] = argv;
|
|
495
|
+
const opts = {};
|
|
496
|
+
const params = {};
|
|
497
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
498
|
+
const flag = rest[i];
|
|
499
|
+
if (flag === '--reviewed') { opts.reviewed = true; continue; }
|
|
500
|
+
if (flag === '--param') {
|
|
501
|
+
const pair = String(rest[i + 1] ?? '');
|
|
502
|
+
const eq = pair.indexOf('=');
|
|
503
|
+
if (eq < 1) throw new Error(`apply: --param takes name=value, got ${JSON.stringify(pair)}`);
|
|
504
|
+
params[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
505
|
+
i += 1;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (!flag.startsWith('--') || rest[i + 1] === undefined) throw new Error(`apply: unexpected argument ${JSON.stringify(flag)}`);
|
|
509
|
+
opts[flag.slice(2)] = rest[i + 1];
|
|
510
|
+
i += 1;
|
|
511
|
+
}
|
|
512
|
+
const applier = await import('../apply/index.mjs');
|
|
513
|
+
const doors = await import('../apply/doors.mjs');
|
|
514
|
+
if (!opts['home-id']) throw new Error('apply: --home-id is required (the runtime names each home)');
|
|
515
|
+
if (verb === 'provision') {
|
|
516
|
+
const state = applier.provision({ homeId: opts['home-id'] });
|
|
517
|
+
process.stdout.write(`${JSON.stringify({ ok: true, result: { provisioned: opts['home-id'], generation: state.generation } })}\n`);
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
520
|
+
let door;
|
|
521
|
+
if (opts.hermes && opts.container) door = doors.hermesDoor({ home: opts.hermes, runner: doors.containerRunner({ container: opts.container }) });
|
|
522
|
+
else if (opts.hermes) door = doors.hermesDoor({ home: resolve(opts.hermes), ...doors.locateHermes({ python: opts.python ?? null, source: opts.source ?? null }) });
|
|
523
|
+
else if (opts.orchestrator) door = await doors.orchestratorDoor({ root: resolve(opts.orchestrator), profile: opts.profile ?? 'default', load });
|
|
524
|
+
else throw new Error('apply: name the home with --hermes <HERMES_HOME> or --orchestrator <root>');
|
|
525
|
+
const spec = opts.spec ? JSON.parse(readFileSync(resolve(opts.spec), 'utf8')) : null;
|
|
526
|
+
let result;
|
|
527
|
+
if (verb === 'plan') result = await applier.plan({ door, homeId: opts['home-id'], spec, params });
|
|
528
|
+
else if (verb === 'apply') result = await applier.apply({ door, homeId: opts['home-id'], spec, params, planHash: opts['plan-hash'] ?? null, reviewedPlan: Boolean(opts.reviewed) });
|
|
529
|
+
else if (verb === 'adopt') result = await applier.adopt({ door, homeId: opts['home-id'], keys: String(opts.keys ?? '').split(',').filter(Boolean) });
|
|
530
|
+
else if (verb === 'resolve') result = await applier.resolve({ door, homeId: opts['home-id'], keys: String(opts.keys ?? '').split(',').filter(Boolean) });
|
|
531
|
+
else throw new Error(`apply: ${JSON.stringify(verb)} is not plan | apply | adopt | resolve | provision`);
|
|
532
|
+
process.stdout.write(`${JSON.stringify({ ok: true, result })}\n`);
|
|
533
|
+
return 0;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function main() {
|
|
537
|
+
const argv = process.argv.slice(2);
|
|
538
|
+
if (OPERATOR_OPS.includes(argv[0])) {
|
|
539
|
+
try {
|
|
540
|
+
const code = await runOperator(argv[0], argv.slice(1));
|
|
541
|
+
await closeWorld();
|
|
542
|
+
process.exit(code);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: String(error.message ?? error) })}\n`);
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (argv[0] === 'import' || argv[0] === 'export') {
|
|
549
|
+
try {
|
|
550
|
+
const code = await (argv[0] === 'import' ? runImport(argv.slice(1)) : runExport(argv.slice(1)));
|
|
551
|
+
await closeWorld();
|
|
552
|
+
process.exit(code);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
say('error', { verb: argv[0], message: String(error.message ?? error), name: error.name ?? 'Error' });
|
|
555
|
+
await closeWorld();
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (argv[0] === 'access') {
|
|
560
|
+
await accessMain(argv.slice(1));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (argv[0] === 'apply') {
|
|
564
|
+
try {
|
|
565
|
+
const code = await runApply(argv.slice(1));
|
|
566
|
+
await closeWorld();
|
|
567
|
+
process.exit(code);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: String(error.message ?? error) })}\n`);
|
|
570
|
+
await closeWorld();
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
let args;
|
|
575
|
+
try {
|
|
576
|
+
args = parseArgs(argv);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
say('error', { message: error.message });
|
|
579
|
+
process.exit(2);
|
|
580
|
+
}
|
|
581
|
+
if (args.version) {
|
|
582
|
+
say('version', { version: packageVersion() });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
// The daemon runs as `hermes-gateway`: its home is a Hermes home, and Hermes calls a lock holder live only
|
|
586
|
+
// when its command line is a gateway's (hermes-locks.mjs). A launcher that did not name it so is re-run as it.
|
|
587
|
+
if (!args.once && basename(process.argv0) !== GATEWAY_KIND) {
|
|
588
|
+
const child = spawn(process.execPath, process.argv.slice(1), { argv0: GATEWAY_KIND, stdio: 'inherit', env: process.env });
|
|
589
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => child.kill(signal));
|
|
590
|
+
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const root = resolve(args.root ?? defaultRoot());
|
|
594
|
+
let crashed = false;
|
|
595
|
+
let gatewayLock = null;
|
|
596
|
+
if (!args.once) {
|
|
597
|
+
// Serialize lease replacement before loading state. An acquisition guard
|
|
598
|
+
// left by a crash fails closed; inspect it before removing it manually.
|
|
599
|
+
const guard = join(root, 'orchestrator.acquire');
|
|
600
|
+
mkdirSync(guard);
|
|
601
|
+
try {
|
|
602
|
+
const previous = readLock(root);
|
|
603
|
+
if (previous && leaseIsLive(previous)) {
|
|
604
|
+
say('error', { root, message: `already serving ${root} (pid ${previous.pid})` });
|
|
605
|
+
throw new Error(`already serving ${root}`);
|
|
606
|
+
}
|
|
607
|
+
crashed = Boolean(previous);
|
|
608
|
+
if (previous) unlinkSync(join(root, LOCK_FILE));
|
|
609
|
+
writeLock(root, { pid: process.pid, started_at: new Date().toISOString(), root, ...thisHost(), executor_url: args.executorUrl ?? null });
|
|
610
|
+
} finally {
|
|
611
|
+
rmdirSync(guard);
|
|
612
|
+
}
|
|
613
|
+
// One runtime per home: Hermes's own gateway lock, held for as long as this daemon lives, so a Hermes
|
|
614
|
+
// gateway on this home refuses to start, and this daemon refuses a home a Hermes gateway serves.
|
|
615
|
+
try {
|
|
616
|
+
gatewayLock = await holdGatewayLock(root);
|
|
617
|
+
// the home in both forms before it is loaded: a Hermes home is adopted as it stands
|
|
618
|
+
for (const change of adoptHome(root)) say('log', { level: 'info', message: `home: ${change}` });
|
|
619
|
+
} catch (error) {
|
|
620
|
+
say('error', { root, message: error.message });
|
|
621
|
+
gatewayLock?.release();
|
|
622
|
+
clearLock(root);
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
let state;
|
|
627
|
+
let vault;
|
|
628
|
+
try {
|
|
629
|
+
({ state, vault } = await load(root));
|
|
630
|
+
} catch (error) {
|
|
631
|
+
// A home that does not load is a refused start, never a partial one.
|
|
632
|
+
say('error', { root, message: String(error.message ?? error), name: error.name ?? 'Error' });
|
|
633
|
+
process.exit(1);
|
|
634
|
+
}
|
|
635
|
+
// Under Hermes each named profile is a home of its own (`<root>/profiles/<name>`) with its own gateway; this daemon
|
|
636
|
+
// serves them all, so it holds each profile's gateway lock too: `hermes -p <name> cron status` reads the profile's
|
|
637
|
+
// scheduler as running, and a Hermes gateway started on the profile refuses, as it does on the root.
|
|
638
|
+
const profileLocks = [];
|
|
639
|
+
if (gatewayLock) {
|
|
640
|
+
for (const profile of Object.values(state.profiles)) {
|
|
641
|
+
if (!profile?.dir || resolve(profile.dir) === root) continue;
|
|
642
|
+
try {
|
|
643
|
+
profileLocks.push(await holdGatewayLock(profile.dir));
|
|
644
|
+
} catch (error) {
|
|
645
|
+
say('error', { root, message: error.message });
|
|
646
|
+
for (const lock of profileLocks) lock.release();
|
|
647
|
+
gatewayLock.release();
|
|
648
|
+
clearLock(root);
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const { adapters, report } = buildAdapters(state, vault, (level, message) => say('log', { level, message }));
|
|
655
|
+
// `SUPERCODE_BIN` is set by `supercode orchestrator start` to the very
|
|
656
|
+
// binary that spawned this daemon, so the worker doors it opens are that
|
|
657
|
+
// build's, not whatever `supercode` a PATH lookup happens to find.
|
|
658
|
+
const client = new SupercodeHarnessClient(process.env.SUPERCODE_BIN ? { command: process.env.SUPERCODE_BIN } : {});
|
|
659
|
+
const orchestrator = new Orchestrator({
|
|
660
|
+
executeJob: args.executorUrl ? httpJobExecutor({ url: args.executorUrl, tokenFile: args.executorTokenFile, root }) : null,
|
|
661
|
+
state,
|
|
662
|
+
vault,
|
|
663
|
+
client,
|
|
664
|
+
adapters,
|
|
665
|
+
log: (level, message) => say('log', { level, message }),
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
say('loaded', {
|
|
669
|
+
root,
|
|
670
|
+
pid: process.pid,
|
|
671
|
+
version: packageVersion(),
|
|
672
|
+
started_at: new Date().toISOString(),
|
|
673
|
+
profiles: Object.entries(state.profiles).map(([name, profile]) => profileReport(name, profile)),
|
|
674
|
+
adapters: report,
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (args.once) {
|
|
678
|
+
// `--once` proves the home loads and the loop builds without connecting
|
|
679
|
+
// anything: the check `setup` and a service unit's dry run want.
|
|
680
|
+
say('ready', { root, pid: process.pid, connected: [], once: true });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
await orchestrator.start();
|
|
686
|
+
// A daemon that came BACK replays pending obligations, re-attaches or
|
|
687
|
+
// interrupts the bindings that held a lease, and clears the stale ones
|
|
688
|
+
// before it accepts anything new (§4.7). A clean stop removes its own lock,
|
|
689
|
+
// so an ordinary start finds none and replays nothing.
|
|
690
|
+
if (crashed) await orchestrator.dispatch(events.restart(new Date().toISOString()));
|
|
691
|
+
say('ready', {
|
|
692
|
+
root,
|
|
693
|
+
pid: process.pid,
|
|
694
|
+
// an adapter that refused to come up is `down`, never counted as connected
|
|
695
|
+
connected: Object.entries(state.runtime.adapters ?? {}).filter(([, a]) => a.state === 'up').map(([p]) => p),
|
|
696
|
+
down: Object.entries(state.runtime.adapters ?? {}).filter(([, a]) => a.state !== 'up').map(([p]) => p),
|
|
697
|
+
webhook_port: orchestrator.webhookPort,
|
|
698
|
+
tick_ms: TICK_MS,
|
|
699
|
+
restarted: crashed,
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// The loop's own tick timer is `unref`'d, and the webhook listener may bind
|
|
703
|
+
// nothing a profile uses; this timer is what keeps the daemon alive on
|
|
704
|
+
// purpose, so "up" always means a process that will still be here next tick.
|
|
705
|
+
const keepalive = setInterval(() => {}, TICK_MS);
|
|
706
|
+
|
|
707
|
+
let stopping = false;
|
|
708
|
+
const stop = async (signal) => {
|
|
709
|
+
if (stopping) return;
|
|
710
|
+
stopping = true;
|
|
711
|
+
clearInterval(keepalive);
|
|
712
|
+
say('stopping', { root, pid: process.pid, signal });
|
|
713
|
+
try {
|
|
714
|
+
await orchestrator.stop();
|
|
715
|
+
await client.close?.();
|
|
716
|
+
await closeWorld();
|
|
717
|
+
} catch (error) {
|
|
718
|
+
say('log', { level: 'error', message: `stop: ${String(error.message ?? error)}` });
|
|
719
|
+
}
|
|
720
|
+
for (const lock of profileLocks) lock.release();
|
|
721
|
+
gatewayLock?.release();
|
|
722
|
+
clearLock(root);
|
|
723
|
+
say('stopped', { root, pid: process.pid });
|
|
724
|
+
process.exit(0);
|
|
725
|
+
};
|
|
726
|
+
process.on('SIGTERM', () => void stop('SIGTERM'));
|
|
727
|
+
process.on('SIGINT', () => void stop('SIGINT'));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
await main();
|