@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
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
|
+
}
|