@volter/supercode-orchestrator 0.4.98 → 0.4.100

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