@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/operator.mjs ADDED
@@ -0,0 +1,426 @@
1
+ // The orchestrator's OPERATOR door: one implementation of every §4.6 verb,
2
+ // shared by the daemon socket (`mcp/bridge.mjs`) and the cold-path CLI
3
+ // (`bin/orchestrator.mjs`).
4
+ //
5
+ // applyOperator(state, op, args, profile, now, dispatch)
6
+ // -> {ok: true, result: {ran, …}} | {ok: false, error: "…"}
7
+ //
8
+ // The ops, exactly [`OPERATOR_OPS`]:
9
+ //
10
+ // jobs.create | jobs.update | jobs.pause | jobs.resume | jobs.run | jobs.delete
11
+ // profiles.create | profiles.delete
12
+ // sessions.new | sessions.reset
13
+ // access.pair_approve
14
+ //
15
+ // `dispatch(event)` applies ONE reducer event and performs its effects; the
16
+ // loop passes `orch.dispatch` (so an operator verb is ordered against inbound
17
+ // messages and ticks like anything else) and the CLI passes a `step` + a save.
18
+ // Nothing here performs I/O of its own: the folder is only ever written by
19
+ // `save()`, through a `save` effect the reducer emits.
20
+ //
21
+ // Two rules this door inherits from the controlled tier it serves:
22
+ //
23
+ // 1. **The reducer is the only thing that changes state.** Every op below
24
+ // validates its arguments, builds ONE `events.operator(...)`, and reads the
25
+ // affected record back out of `state` afterwards. It never mutates state.
26
+ // 2. **A refusal names its reason.** The reducer refuses by pushing a
27
+ // `{type:'log', level:'warn'}` effect and no `save`; that message becomes
28
+ // `{ok: false, error}` verbatim, so a verb the reducer rejected can never
29
+ // look like a silent no-op.
30
+
31
+ import { existsSync, mkdirSync, renameSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { keyFromString, parseSchedule } from './mcp/verbs.mjs';
34
+ import { emptyProfile, parseTarget, renderTarget, surfaceKeyString, decodeContextFrom } from './ir.mjs';
35
+ import { liveBinding } from './reducer.mjs';
36
+ import { compactStamp, gateFields, homeZone, nextRunAt, payloadError } from './cron.mjs';
37
+ import * as events from './events.mjs';
38
+ import { modelError } from './activation.mjs';
39
+
40
+ /** Every op the operator door answers, in §4.6 order. */
41
+ export const OPERATOR_OPS = Object.freeze([
42
+ 'jobs.create', 'jobs.update', 'jobs.pause', 'jobs.resume', 'jobs.run', 'jobs.delete',
43
+ 'profiles.create', 'profiles.delete',
44
+ 'sessions.new', 'sessions.reset',
45
+ 'access.pair_approve',
46
+ ]);
47
+
48
+ /** Where a deleted profile folder is renamed to, relative to the root. */
49
+ export const TRASH_DIR = 'profiles/.trash';
50
+
51
+ class Refused extends Error {}
52
+
53
+ /**
54
+ * Perform the reducer's `remove_profile_dir` effect (the ONLY effect this door
55
+ * adds, §5). The folder is never unlinked: it is renamed into
56
+ * `profiles/.trash/<name>-<stamp>/`, which `load` skips because a profile
57
+ * folder's name may not start with a dot. One implementation, called by the
58
+ * loop and by the cold path alike.
59
+ *
60
+ * @returns {string|null} where the folder went, or `null` when there was none
61
+ */
62
+ export function performRemoveProfileDir(root, { profile, dir }, now) {
63
+ if (!dir || !existsSync(dir)) return null;
64
+ const trash = join(root, TRASH_DIR);
65
+ mkdirSync(trash, { recursive: true });
66
+ const stamp = String(now).replace(/[^0-9]/g, '').slice(0, 14);
67
+ let dest = join(trash, `${profile}-${stamp}`);
68
+ for (let n = 2; existsSync(dest); n += 1) dest = join(trash, `${profile}-${stamp}-${n}`);
69
+ renameSync(dir, dest);
70
+ return dest;
71
+ }
72
+
73
+ /** A job id that is free in this profile — the same stem the MCP door uses. */
74
+ function jobId(profile, now) {
75
+ const stem = `job_${compactStamp(now)}`;
76
+ if (!profile.jobs[stem]) return stem;
77
+ for (let n = 2; ; n++) if (!profile.jobs[`${stem}_${n}`]) return `${stem}_${n}`;
78
+ }
79
+
80
+ /**
81
+ * Hermes's cron-tool words as the job's own (tools/cronjob_tools.py): `monitor` is a URL source when it
82
+ * starts with http(s)://, else a script, and setting one clears the other (`_split_monitor_arg`);
83
+ * `continuity` adds or removes `self` in `context_from` (`_apply_continuity`).
84
+ */
85
+ export function hermesJobArgs(args, job = null) {
86
+ const out = { ...args };
87
+ if (typeof args.monitor === 'string') {
88
+ const value = args.monitor.trim();
89
+ const url = /^https?:\/\//i.test(value);
90
+ out.monitor_script = url ? '' : value;
91
+ out.monitor_url = url ? value : '';
92
+ delete out.monitor;
93
+ }
94
+ if (typeof args.continuity === 'boolean') {
95
+ const refs = (decodeContextFrom('cronjob', 'context_from', args.context_from !== undefined ? args.context_from : job?.context_from) ?? []);
96
+ const self = refs.some((r) => r.toLowerCase() === 'self');
97
+ out.context_from = args.continuity && !self ? [...refs, 'self'] : !args.continuity && self ? refs.filter((r) => r.toLowerCase() !== 'self') : refs;
98
+ delete out.continuity;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * A complete §2.6 Job record from operator arguments. The caller's words are
105
+ * the orchestrator's own (`prompt`, `schedule`, `deliver`, `name`, …); a field
106
+ * the model has no home for is refused by name rather than dropped.
107
+ */
108
+ export function buildJob(profile, args, now, { origin = null } = {}) {
109
+ const gate = gateFields(args);
110
+ const empty = payloadError(args, gate);
111
+ if (empty) throw new Error(empty);
112
+ if (args.metadata !== undefined && (!args.metadata || typeof args.metadata !== 'object' || Array.isArray(args.metadata) || JSON.stringify(args.metadata).length > 16384)) throw new Error('metadata must be an object of at most 16 KiB');
113
+ const schedule = parseSchedule(args.schedule, now, profile.dir);
114
+ const unusable = modelError(profile.worker?.harness, args.model ? String(args.model) : null);
115
+ if (unusable) throw new Error(unusable);
116
+ const id = args.id ? String(args.id) : jobId(profile, now);
117
+ return {
118
+ id,
119
+ schedule,
120
+ prompt: typeof args.prompt === 'string' ? args.prompt : '',
121
+ workdir: args.workdir ? String(args.workdir) : null,
122
+ model: args.model ? String(args.model) : null,
123
+ skills: Array.isArray(args.skills) ? args.skills.map(String) : [],
124
+ context_from: decodeContextFrom('jobs.create', 'context_from', args.context_from),
125
+ deliver: parseTarget('operator/jobs.create', 'deliver', args.deliver) ?? { kind: 'origin' },
126
+ failure_deliver: parseTarget('operator/jobs.create', 'failure_deliver', args.failure_deliver),
127
+ origin,
128
+ attach_to_session: typeof args.attach_to_session === 'boolean' ? args.attach_to_session : null,
129
+ repeat: Number.isInteger(args.repeat) && args.repeat > 0 ? { times: args.repeat, completed: 0 } : null,
130
+ enabled: args.enabled !== false,
131
+ next_run_at: args.enabled === false ? null : nextRunAt(schedule, now, homeZone(profile)),
132
+ last_run_at: null,
133
+ last_status: null,
134
+ created_at: now,
135
+ // Hermes's `name` has no IR field; it rides the residue channel (§1 rule 6).
136
+ residue: { ...(args.metadata ? { host: structuredClone(args.metadata) } : {}), ...(args.name ? { name: String(args.name) } : {}), ...toolsets(args.enabled_toolsets), ...gate },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * The operator's patch for `jobs.update`, in the job's own field names.
142
+ * `job` is the record being patched: the reducer `Object.assign`s, so any
143
+ * field it replaces wholesale (`residue`) is merged HERE rather than dropping
144
+ * the keys the operator did not name (§1 rule 6).
145
+ */
146
+ /** Hermes's `enabled_toolsets` (its agent's own setting, carried in the job for when Hermes runs it). */
147
+ function toolsets(value) {
148
+ if (!Array.isArray(value)) return {};
149
+ const names = value.map((x) => String(x).trim()).filter(Boolean);
150
+ return names.length ? { enabled_toolsets: names } : {};
151
+ }
152
+
153
+ function buildPatch(args, now, job, zone, home) {
154
+ const patch = {};
155
+ if (args.prompt !== undefined) {
156
+ if (typeof args.prompt !== 'string' || !args.prompt.trim()) throw new Error('prompt must be a non-empty string');
157
+ patch.prompt = args.prompt;
158
+ }
159
+ if (args.schedule !== undefined) {
160
+ patch.schedule = parseSchedule(args.schedule, now, home);
161
+ // A schedule that changed re-arms the job; `next_run_at` is derived, never
162
+ // carried over from the schedule it no longer describes (§4.3).
163
+ patch.next_run_at = nextRunAt(patch.schedule, now, zone);
164
+ }
165
+ if (args.deliver !== undefined) patch.deliver = parseTarget('operator/jobs.update', 'deliver', args.deliver) ?? { kind: 'origin' };
166
+ if (args.failure_deliver !== undefined) patch.failure_deliver = parseTarget('operator/jobs.update', 'failure_deliver', args.failure_deliver);
167
+ if (args.workdir !== undefined) patch.workdir = args.workdir ? String(args.workdir) : null;
168
+ if (args.model !== undefined) patch.model = args.model ? String(args.model) : null;
169
+ if (args.enabled !== undefined) patch.enabled = Boolean(args.enabled);
170
+ if (args.name !== undefined) patch.residue = { ...(patch.residue ?? job?.residue ?? {}), name: String(args.name) };
171
+ // Hermes's update words: an empty list clears (update_job)
172
+ if (args.skills !== undefined) patch.skills = Array.isArray(args.skills) ? args.skills.map(String).filter((x) => x.trim()) : [];
173
+ if (args.context_from !== undefined) patch.context_from = decodeContextFrom('jobs.update', 'context_from', args.context_from);
174
+ if (args.attach_to_session !== undefined) patch.attach_to_session = typeof args.attach_to_session === 'boolean' ? args.attach_to_session : null;
175
+ if (args.repeat !== undefined) patch.repeat = Number.isInteger(args.repeat) && args.repeat > 0 ? { times: args.repeat, completed: 0 } : null;
176
+ if (args.enabled_toolsets !== undefined) {
177
+ const { enabled_toolsets: _, ...rest } = patch.residue ?? job?.residue ?? {};
178
+ patch.residue = { ...rest, ...toolsets(args.enabled_toolsets) };
179
+ }
180
+ // the gate fields, in Hermes's names; an empty value clears one, as update_job does
181
+ const GATE_KEYS = ['script', 'no_agent', 'monitor_script', 'monitor_url'];
182
+ if (GATE_KEYS.some((k) => args[k] !== undefined)) {
183
+ const residue = { ...(patch.residue ?? job?.residue ?? {}) };
184
+ const next = Object.fromEntries(GATE_KEYS.map((k) => [k, args[k] !== undefined ? args[k] : residue[k]]));
185
+ for (const k of GATE_KEYS) delete residue[k];
186
+ patch.residue = { ...residue, ...gateFields(next) }; // validated; an empty value is gone
187
+ }
188
+ // Hermes's update_job refuses an edit that leaves nothing to run (EMPTY_PAYLOAD_ERROR)
189
+ if (patch.prompt !== undefined || GATE_KEYS.some((k) => args[k] !== undefined)) {
190
+ const merged = { prompt: patch.prompt ?? job?.prompt, skills: patch.skills ?? job?.skills };
191
+ const empty = payloadError(merged, { script: (patch.residue ?? job?.residue ?? {}).script ?? null });
192
+ if (empty) throw new Error(empty);
193
+ }
194
+ if (!Object.keys(patch).length) throw new Error('jobs.update changes nothing; name at least one of prompt, schedule, deliver, failure_deliver, workdir, model, enabled, name, skills, context_from, repeat, attach_to_session, enabled_toolsets, script, no_agent, monitor_script, monitor_url');
195
+ return patch;
196
+ }
197
+
198
+ function describeSchedule(s) {
199
+ if (s.kind === 'once') return `once at ${s.run_at}`;
200
+ if (s.kind === 'interval') return `every ${s.minutes} min`;
201
+ return `cron ${s.expr}${s.tz ? ` ${s.tz}` : ''}`;
202
+ }
203
+
204
+ /** The public view of one job. */
205
+ function jobView(job) {
206
+ return job ? { ...job } : null;
207
+ }
208
+
209
+ /** The public view of one profile folder record. */
210
+ function profileView(profile) {
211
+ return {
212
+ name: profile.name,
213
+ dir: profile.dir,
214
+ worker: profile.worker ? { ...profile.worker } : null,
215
+ jobs: Object.keys(profile.jobs).length,
216
+ bindings: Object.keys(profile.bindings).length,
217
+ };
218
+ }
219
+
220
+ function bindingView(binding, keyString) {
221
+ return {
222
+ surface: keyString,
223
+ session_id: binding.worker?.session_id || null,
224
+ harness: binding.worker?.harness ?? null,
225
+ started_at: binding.started_at,
226
+ ended_at: binding.ended_at ?? null,
227
+ end_reason: binding.end_reason ?? null,
228
+ };
229
+ }
230
+
231
+ /** The ended binding for `keyString` with the newest `started_at`. */
232
+ function endedBinding(profile, keyString) {
233
+ let best = null;
234
+ for (const [slot, b] of Object.entries(profile.bindings)) {
235
+ if (!slot.startsWith(`${keyString}#`) || !b.ended_at) continue;
236
+ if (!best || b.started_at > best.started_at) best = b;
237
+ }
238
+ return best;
239
+ }
240
+
241
+ /**
242
+ * The reducer's own refusal, when it refused: a `log` effect and no `save`
243
+ * means the state did not change and the message says why.
244
+ */
245
+ function refusalIn(effects) {
246
+ if (effects.some((e) => e.type === 'save')) return null;
247
+ const warn = effects.find((e) => e.type === 'log' && e.level === 'warn');
248
+ return warn ? warn.message : null;
249
+ }
250
+
251
+ /**
252
+ * Apply one operator verb.
253
+ *
254
+ * @param {object} state the orchestrator's single state value
255
+ * @param {string} op one of [`OPERATOR_OPS`]
256
+ * @param {object} args the verb's own arguments
257
+ * @param {string} profileName profile the verb acts in
258
+ * @param {string} now RFC 3339 instant (the caller's clock)
259
+ * @param {(event: object) => Promise<object[]>|object[]} dispatch
260
+ * applies one reducer event and performs its effects, returning them
261
+ * @returns {Promise<{ok: true, result: object} | {ok: false, error: string}>}
262
+ */
263
+ export async function applyOperator(state, op, args = {}, profileName = 'default', now = new Date().toISOString(), dispatch) {
264
+ if (typeof dispatch !== 'function') throw new TypeError('applyOperator needs a dispatch(event) that applies one reducer event');
265
+ if (!OPERATOR_OPS.includes(op)) {
266
+ return { ok: false, error: `unknown operator op ${JSON.stringify(op)}; the orchestrator answers: ${OPERATOR_OPS.join(', ')}` };
267
+ }
268
+ const profile = state.profiles[profileName];
269
+ if (!profile) {
270
+ return { ok: false, error: `unknown profile ${JSON.stringify(profileName)}; this home holds: ${Object.keys(state.profiles).sort().join(', ')}` };
271
+ }
272
+ const send = async (verb, verbArgs, target = profileName) => {
273
+ const effects = (await dispatch(events.operator({ verb, profile: target, args: verbArgs, at: now }))) ?? [];
274
+ const refusal = refusalIn(effects);
275
+ if (refusal) throw new Refused(refusal);
276
+ return effects;
277
+ };
278
+ try {
279
+ return { ok: true, result: await run(state, profile, op, args ?? {}, now, send) };
280
+ } catch (error) {
281
+ return { ok: false, error: error.message };
282
+ }
283
+ }
284
+
285
+ async function run(state, profile, op, args, now, send) {
286
+ switch (op) {
287
+ case 'jobs.create': {
288
+ const origin = args.surface ? keyOrigin(args.surface) : null;
289
+ const job = buildJob(profile, args, now, { origin });
290
+ await send('jobs_create', { job });
291
+ return {
292
+ ran: `created cron job ${job.id} (${describeSchedule(job.schedule)}) in profile ${profile.name}, delivering to ${renderTarget(job.deliver)}`,
293
+ job_id: job.id,
294
+ job: jobView(profile.jobs[job.id]),
295
+ };
296
+ }
297
+
298
+ case 'jobs.update': {
299
+ const id = requireId(args, 'jobs.update');
300
+ const patch = buildPatch(args, now, profile.jobs[id], homeZone(profile), profile.dir);
301
+ const unusable = modelError(profile.worker?.harness, patch.model);
302
+ if (unusable) throw new Error(unusable);
303
+ await send('jobs_update', { id, patch });
304
+ return {
305
+ ran: `updated cron job ${id} in profile ${profile.name} (${Object.keys(patch).sort().join(', ')})`,
306
+ job_id: id,
307
+ job: jobView(profile.jobs[id]),
308
+ };
309
+ }
310
+
311
+ case 'jobs.pause':
312
+ case 'jobs.resume': {
313
+ const id = requireId(args, op);
314
+ await send(op === 'jobs.pause' ? 'jobs_pause' : 'jobs_resume', { id });
315
+ return {
316
+ ran: `${op === 'jobs.pause' ? 'paused' : 'resumed'} cron job ${id} in profile ${profile.name}`,
317
+ job_id: id,
318
+ job: jobView(profile.jobs[id]),
319
+ };
320
+ }
321
+
322
+ case 'jobs.run': {
323
+ const id = requireId(args, 'jobs.run');
324
+ await send('jobs_run', { id });
325
+ return {
326
+ ran: `cron job ${id} in profile ${profile.name} is due now; the next tick claims it`,
327
+ job_id: id,
328
+ job: jobView(profile.jobs[id]),
329
+ };
330
+ }
331
+
332
+ case 'jobs.delete': {
333
+ const id = requireId(args, 'jobs.delete');
334
+ await send('jobs_delete', { id });
335
+ return { ran: `removed cron job ${id} from profile ${profile.name}`, job_id: id, job: null, deleted: true };
336
+ }
337
+
338
+ case 'profiles.create': {
339
+ const name = String(args.name ?? '').trim();
340
+ if (!name) throw new Error('name is required');
341
+ if (name === 'default') throw new Error('the root folder IS the `default` profile; it exists as soon as the home does');
342
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.startsWith('.')) {
343
+ throw new Error(`profile name ${JSON.stringify(name)} is not a folder name: use letters, digits, '.', '_' and '-', starting with a letter or digit`);
344
+ }
345
+ const dir = args.dir ? String(args.dir) : `${state.root}/profiles/${name}`;
346
+ const record = emptyProfile(name, dir);
347
+ if (args.worker !== undefined && args.worker !== null) record.worker = buildWorker(args.worker);
348
+ // `profiles.create` is dispatched against a profile that EXISTS (the
349
+ // caller's context), because the reducer resolves `ev.profile` first.
350
+ await send('profiles_create', { profile: record }, profile.name);
351
+ const made = state.profiles[name];
352
+ if (!made) throw new Error(`profiles.create: profile ${name} was not added`);
353
+ return { ran: `created profile ${name} at ${dir}`, name, profile: profileView(made) };
354
+ }
355
+
356
+ case 'profiles.delete': {
357
+ const name = String(args.name ?? '').trim();
358
+ if (!name) throw new Error('name is required');
359
+ await send('profiles_delete', { name }, profile.name);
360
+ if (state.profiles[name]) throw new Error(`profiles.delete: profile ${name} is still in state`);
361
+ return { ran: `removed profile ${name}; its folder was moved into ${TRASH_DIR}/`, name, profile: null, deleted: true };
362
+ }
363
+
364
+ case 'sessions.new':
365
+ case 'sessions.reset': {
366
+ const surface = String(args.surface ?? '').trim();
367
+ if (!surface) throw new Error(`${op} needs the surface to act on (platform|kind|chat_id|thread_id|participant_id)`);
368
+ const key = keyFromString(surface);
369
+ if (!key) throw new Error(`surface ${JSON.stringify(surface)} is not a surface key: write it as platform|kind|chat_id|thread_id|participant_id`);
370
+ const keyString = surfaceKeyString(key);
371
+ const live = liveBinding(profile, keyString);
372
+ if (!live) {
373
+ throw new Error(`no live conversation on ${keyString} in profile ${profile.name}; \`${op}\` ends a binding that exists — \`supercode sessions list --harness orchestrator\` shows what does`);
374
+ }
375
+ const verb = op === 'sessions.new' ? 'sessions_new' : 'sessions_reset';
376
+ await send(verb, { keyString });
377
+ const ended = endedBinding(profile, keyString);
378
+ if (!ended) throw new Error(`${op}: the binding on ${keyString} did not end`);
379
+ return {
380
+ ran: `ended the conversation on ${keyString} in profile ${profile.name} (${ended.end_reason})`,
381
+ surface: keyString,
382
+ binding: bindingView(ended, keyString),
383
+ };
384
+ }
385
+
386
+ case 'access.pair_approve': {
387
+ const code = String(args.code ?? '').trim();
388
+ if (!code) throw new Error('code is required');
389
+ // Hermes's pairing store holds the code; the daemon asks it, and a grant reaches the allowlist
390
+ await send('pair_approve', { code });
391
+ return { ran: `asked Hermes's pairing store to approve ${code} for profile ${profile.name}`, code };
392
+ }
393
+
394
+ default:
395
+ throw new Error(`unknown operator op ${op}`);
396
+ }
397
+ }
398
+
399
+ function requireId(args, op) {
400
+ const id = String(args.id ?? args.job_id ?? '').trim();
401
+ if (!id) throw new Error(`${op} needs the job id to act on`);
402
+ return id;
403
+ }
404
+
405
+ /** A job's `origin`, from a surface key the operator named. */
406
+ function keyOrigin(surface) {
407
+ const key = keyFromString(String(surface));
408
+ if (!key) throw new Error(`surface ${JSON.stringify(surface)} is not a surface key`);
409
+ return { platform: key.platform, chat_type: key.kind ?? null, chat_id: key.chat_id ?? null, thread_id: key.thread_id ?? null };
410
+ }
411
+
412
+ /** The §2.2 worker block from operator arguments. */
413
+ function buildWorker(raw) {
414
+ if (typeof raw === 'string') return buildWorker({ harness: raw });
415
+ if (typeof raw !== 'object') throw new Error('worker must be a harness id or a {harness, model?, cwd?} object');
416
+ const harness = String(raw.harness ?? '').trim();
417
+ if (!harness) throw new Error('worker.harness is required');
418
+ return {
419
+ harness,
420
+ model: raw.model ? String(raw.model) : null,
421
+ preset: raw.preset ? String(raw.preset) : null,
422
+ cwd: raw.cwd ? String(raw.cwd) : '.',
423
+ env: {},
424
+ permission: { timeout_seconds: 300, default: 'deny' },
425
+ };
426
+ }
@@ -0,0 +1,166 @@
1
+ // The folder, through the harness's orchestration doors.
2
+ //
3
+ // ONT-7: this package reads and writes its home through `harness.v1.orchestration.*`
4
+ // (`crates/harness/src/orchestration_doors.rs`), never with a codec of its own. The
5
+ // ontology's one codec is the Rust one; this module is its client side:
6
+ //
7
+ // - `load(root)` asks `orchestration.load` for the orchestration value and adds the runtime
8
+ // half (`OrchestratorState` = orchestration + runtime, docs/ORCHESTRATOR-IR.md §2.1).
9
+ // The vault is read out of the home's own `.env` files here: a secret
10
+ // VALUE never crosses the wire, `orchestration.load` answers key names only.
11
+ // - `save(state, root)` hands the persisted view to `orchestration.save`. It sends
12
+ // no secret: the loop never mints one, and `.env` is the home's own file.
13
+ // - `compile`, `importFrom`, `exportTo` are the migration doors. A migration
14
+ // moves credentials, so `import` and `export` are composed inside the
15
+ // harness process (see the orchestration_doors module doc).
16
+ //
17
+ // One door per process: the first verb spawns `supercode harness serve`
18
+ // (the binary that spawned us, else this checkout's build) and every verb
19
+ // after it reuses it. The door is held open while a loop runs
20
+ // (`retainWorld` / `releaseWorld`, the loop's `start` / `stop`) or a verb is
21
+ // in flight; otherwise it closes itself after a short idle, so a process
22
+ // that only loaded or saved (a test, a one-shot migration) exits on its own.
23
+ // `closeWorld()` closes it now.
24
+
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { dirname, join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
29
+ import { emptyRuntime, persistedView } from './ir.mjs';
30
+
31
+ const here = dirname(fileURLToPath(import.meta.url));
32
+
33
+ /**
34
+ * The binary the doors are opened with: `SUPERCODE_BIN` (set by `supercode
35
+ * orchestrator start` to the very binary that spawned the daemon), else this
36
+ * checkout's debug build, else `supercode` on PATH.
37
+ */
38
+ export function supercodeCommand() {
39
+ if (process.env.SUPERCODE_BIN) return process.env.SUPERCODE_BIN;
40
+ const built = resolve(here, '../../target/debug/supercode');
41
+ return existsSync(built) ? built : 'supercode';
42
+ }
43
+
44
+ const IDLE_MS = 250;
45
+ let shared = null;
46
+ let holders = 0;
47
+ let inFlight = 0;
48
+ let idle = null;
49
+
50
+ function door() {
51
+ if (!shared) shared = new SupercodeHarnessClient({ command: supercodeCommand(), args: ['harness', 'serve'] });
52
+ return shared;
53
+ }
54
+
55
+ /** Close the shared door. The next verb opens it again. */
56
+ export async function closeWorld() {
57
+ if (idle) { clearTimeout(idle); idle = null; }
58
+ const client = shared;
59
+ shared = null;
60
+ if (client) await client.close();
61
+ }
62
+
63
+ function settle() {
64
+ if (idle) { clearTimeout(idle); idle = null; }
65
+ if (holders > 0 || inFlight > 0 || !shared) return;
66
+ idle = setTimeout(() => { idle = null; if (holders === 0 && inFlight === 0) void closeWorld(); }, IDLE_MS);
67
+ idle.unref?.();
68
+ }
69
+
70
+ /** Hold the door open (a running loop). Balanced by `releaseWorld`. */
71
+ export function retainWorld() {
72
+ holders += 1;
73
+ if (idle) { clearTimeout(idle); idle = null; }
74
+ }
75
+
76
+ /** Let go of the door; it closes itself once nothing holds it and nothing is in flight. */
77
+ export function releaseWorld() {
78
+ holders = Math.max(0, holders - 1);
79
+ settle();
80
+ }
81
+
82
+ async function through(verb) {
83
+ inFlight += 1;
84
+ if (idle) { clearTimeout(idle); idle = null; }
85
+ try {
86
+ return await verb(door());
87
+ } finally {
88
+ inFlight -= 1;
89
+ settle();
90
+ }
91
+ }
92
+
93
+ /** `KEY=value` lines; quotes stripped; `#` lines and blanks ignored. */
94
+ export function parseDotenv(text) {
95
+ const out = new Map();
96
+ for (const line of text.split(/\r?\n/)) {
97
+ const t = line.trim();
98
+ if (!t || t.startsWith('#')) continue;
99
+ const eq = t.indexOf('=');
100
+ if (eq < 0) continue;
101
+ let v = t.slice(eq + 1).trim();
102
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
103
+ out.set(t.slice(0, eq).trim(), v);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Every profile folder's `.env`, merged; the root's first, a named profile's over it. */
109
+ function readVault(orchestration) {
110
+ const vault = new Map();
111
+ for (const profile of Object.values(orchestration.profiles)) {
112
+ const file = join(profile.dir, '.env');
113
+ if (!existsSync(file)) continue;
114
+ for (const [k, v] of parseDotenv(readFileSync(file, 'utf8'))) vault.set(k, v);
115
+ }
116
+ return vault;
117
+ }
118
+
119
+ /** An orchestration value as the loop's state: the persisted half plus an empty runtime half. */
120
+ export function stateOf(orchestration) {
121
+ return { ...orchestration, runtime: emptyRuntime() };
122
+ }
123
+
124
+ /**
125
+ * `load(root)` — our folder, strict: an unknown key, a bad value, or a missing
126
+ * secret ref refuses the load and names the file and key.
127
+ *
128
+ * A credential VALUE a person pasted into `config.yaml` or
129
+ * `webhook_subscriptions.json` is not refused: the codec has already moved it
130
+ * into its vault under a `${NAME}` reference, and one save writes that vault
131
+ * into `.env` and the file back with the ref (§1 rule 3 — the folder converges
132
+ * to its own invariant on first contact). The value never crosses the wire;
133
+ * it is read back out of `.env` like every other secret.
134
+ */
135
+ export async function load(root) {
136
+ const { orchestration, vault_keys } = await through((d) => d.orchestrationLoad({ root }));
137
+ let vault = readVault(orchestration);
138
+ if (vault_keys.some((k) => !vault.has(k))) {
139
+ await through((d) => d.orchestrationSave({ root, orchestration }));
140
+ vault = readVault(orchestration);
141
+ const missing = vault_keys.filter((k) => !vault.has(k));
142
+ if (missing.length) throw new Error(`${root}: ${missing.join(', ')} referenced but not in .env after normalization`);
143
+ }
144
+ return { state: stateOf(orchestration), vault };
145
+ }
146
+
147
+ /** `save(state, root)` — our folder, canonical and byte-stable: an artifact whose record has not changed keeps its bytes. */
148
+ export async function save(state, root = state.root) {
149
+ await through((d) => d.orchestrationSave({ root, orchestration: persistedView(state) }));
150
+ }
151
+
152
+ /** Another harness's home as a state value (no secret value; `vault_keys` names the `.env` keys it would need). */
153
+ export async function compile(from, home) {
154
+ const { orchestration, vault_keys } = await through((d) => d.orchestrationCompile({ from, home }));
155
+ return { state: stateOf(orchestration), vault_keys };
156
+ }
157
+
158
+ /** Another harness's home becomes our folder at `into`, credentials along. */
159
+ export function importFrom(from, home, into) {
160
+ return through((d) => d.orchestrationImport({ from, home, into }));
161
+ }
162
+
163
+ /** Our folder at `root` becomes another harness's home at `dest`, credentials along. */
164
+ export function exportTo(to, root, dest) {
165
+ return through((d) => d.orchestrationExport({ to, root, dest }));
166
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@volter/supercode-orchestrator",
3
+ "version": "0.4.4",
4
+ "type": "module",
5
+ "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./index.d.ts",
9
+ "import": "./index.mjs"
10
+ },
11
+ "./bin": "./bin/orchestrator.mjs",
12
+ "./apply": {
13
+ "types": "./apply/index.d.ts",
14
+ "import": "./apply/index.mjs"
15
+ },
16
+ "./apply/doors": {
17
+ "types": "./apply/doors.d.ts",
18
+ "import": "./apply/doors.mjs"
19
+ }
20
+ },
21
+ "types": "./index.d.ts",
22
+ "scripts": {
23
+ "generate-types": "node scripts/generate-types.mjs",
24
+ "check-types": "node scripts/generate-types.mjs --check",
25
+ "test": "npm run check-types && node --test test/*.test.mjs"
26
+ },
27
+ "dependencies": {
28
+ "yaml": "^2.9.0",
29
+ "@volter/supercode-harness-sdk": "^0.3.41"
30
+ },
31
+ "devDependencies": {
32
+ "@chat-adapter/state-memory": "4.39.0",
33
+ "chat": "4.39.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.13"
37
+ },
38
+ "license": "MIT OR Apache-2.0",
39
+ "bin": {
40
+ "supercode-orchestrator": "./bin/orchestrator.mjs"
41
+ },
42
+ "files": [
43
+ "*.mjs",
44
+ "*.d.ts",
45
+ "bin",
46
+ "mcp",
47
+ "adapters",
48
+ "apply",
49
+ "README.md"
50
+ ]
51
+ }