@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/events.mjs ADDED
@@ -0,0 +1,107 @@
1
+ // Events: the only inputs to the reducer (docs/ORCHESTRATOR-IR.md §3).
2
+ // Adapters, the clock, the HTTP listener and the SDK event stream produce
3
+ // them; nothing else does. Constructors validate shape so a malformed adapter
4
+ // fails here, not inside the reducer.
5
+
6
+ import { CHAT_TYPES } from './ir.mjs';
7
+
8
+ function req(cond, message) {
9
+ if (!cond) throw new TypeError(`event: ${message}`);
10
+ }
11
+
12
+ /**
13
+ * §3's `inbound`. Beside the surface key and the text it carries the four
14
+ * fields ORC-1 F4 found every real transport produces and the IR had no room
15
+ * for: `message_id` (the threading / reaction / edit target), and the three
16
+ * sender flags `is_me` / `is_bot` / `mention`. `is_me` is the loop-safety one —
17
+ * without it the loop cannot recognise its own echo (§4.1 step 0).
18
+ * All four are optional: an adapter that cannot produce one says so with
19
+ * `null`, never with a guess.
20
+ */
21
+ export function inbound({
22
+ profile, key, sender, sender_name = null, text, attachments = [], at, adapter = key?.platform, guild_id = null,
23
+ message_id = null, is_me = false, is_bot = null, mention = false,
24
+ }) {
25
+ req(typeof profile === 'string' && profile, 'profile (the adapter-owning profile) is required');
26
+ req(key && typeof key.platform === 'string' && CHAT_TYPES.has(key.kind), 'key needs platform and a kind');
27
+ req(typeof sender === 'string' && sender, 'sender is required');
28
+ req(typeof text === 'string', 'text must be a string');
29
+ req(typeof at === 'string', 'at must be an RFC 3339 string');
30
+ req(is_bot === null || typeof is_bot === 'boolean', 'is_bot must be a boolean or null (the Chat SDK\'s "unknown")');
31
+ const k = { platform: key.platform, kind: key.kind };
32
+ if (key.chat_id) k.chat_id = String(key.chat_id);
33
+ if (key.thread_id) k.thread_id = String(key.thread_id);
34
+ if (key.participant_id) k.participant_id = String(key.participant_id);
35
+ return {
36
+ type: 'inbound', profile, key: k, sender, sender_name, text, attachments, at, adapter, guild_id,
37
+ message_id: message_id === null ? null : String(message_id),
38
+ is_me: Boolean(is_me), is_bot, mention: Boolean(mention),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * `inactivity_s` is how long a fire's worker may go silent before the fire is
44
+ * closed as a timeout (Hermes's `HERMES_CRON_TIMEOUT`; 0 = no limit); null
45
+ * means Hermes's default, 600.
46
+ */
47
+ export function tick(at, inactivity_s = null, max_parallel = null) {
48
+ req(typeof at === 'string', 'at must be an RFC 3339 string');
49
+ req(inactivity_s === null || (typeof inactivity_s === 'number' && inactivity_s >= 0), 'inactivity_s must be a non-negative number or null');
50
+ req(max_parallel === null || (Number.isInteger(max_parallel) && max_parallel > 0), 'max_parallel must be a positive integer or null');
51
+ return { type: 'tick', at, inactivity_s, max_parallel };
52
+ }
53
+
54
+ /**
55
+ * §3's `webhook`. `event` is the event type the source named (its header, else
56
+ * the body's own), `delivery_id` the source's id for this delivery; either is
57
+ * null when the call carried none.
58
+ */
59
+ export function webhook({ name, payload = null, verified = false, at, event = null, delivery_id = null }) {
60
+ req(typeof name === 'string' && name, 'name is required');
61
+ req(typeof at === 'string', 'at must be an RFC 3339 string');
62
+ req(event === null || typeof event === 'string', 'event must be a string or null');
63
+ req(delivery_id === null || typeof delivery_id === 'string', 'delivery_id must be a string or null');
64
+ return { type: 'webhook', name, payload, verified: Boolean(verified), at, event, delivery_id };
65
+ }
66
+
67
+ export function worker({ profile, keyString, runtime_id, ev, at }) {
68
+ req(typeof profile === 'string', 'profile is required');
69
+ req(typeof keyString === 'string', 'keyString is required');
70
+ req(ev && typeof ev.kind === 'string', 'ev.kind is required');
71
+ return { type: 'worker', profile, keyString, runtime_id: runtime_id ?? null, ev, at };
72
+ }
73
+
74
+ /**
75
+ * `configured: false` marks an adapter that can never connect as set up (a
76
+ * missing package or credential), as against one that is down for now:
77
+ * Hermes's delivery preflight counts only a configured platform as connected.
78
+ */
79
+ export function adapterStatus({ platform, state, error = null, at, configured = true }) {
80
+ req(state === 'up' || state === 'down', 'state must be up|down');
81
+ return { type: 'adapter', platform, state, error, at, configured: configured !== false };
82
+ }
83
+
84
+ export function operator({ verb, profile = 'default', args = {}, at }) {
85
+ req(typeof verb === 'string', 'verb is required');
86
+ return { type: 'operator', verb, profile, args, at };
87
+ }
88
+
89
+ /**
90
+ * A fire's gate finished (the loop ran it off the queue). `script`:
91
+ * `{ok, output}`; `monitor`: `{ok, changed, hash, block, error}`; either null
92
+ * when the job has none.
93
+ */
94
+ export function gateResult({ profile, fire_id, at, script = null, monitor = null }) {
95
+ req(typeof profile === 'string' && profile, 'profile is required');
96
+ req(typeof fire_id === 'string' && fire_id, 'fire_id is required');
97
+ req(typeof at === 'string', 'at must be an RFC 3339 string');
98
+ return { type: 'gate_result', profile, fire_id, at, script, monitor };
99
+ }
100
+
101
+ export function restart(at) {
102
+ return { type: 'restart', at };
103
+ }
104
+
105
+ export function stopping(at) {
106
+ return { type: 'stopping', at };
107
+ }
package/gates.mjs ADDED
@@ -0,0 +1,281 @@
1
+ // A fire's gate, the I/O half (docs/architecture/orchestrator.md §4.3 step 2a): the job's
2
+ // pre-run script and its monitor source, run by the loop off the event queue. Transcribed from
3
+ // Hermes 0.21.0: `cron/scheduler.py` `_run_job_script` / `_get_script_timeout`, and
4
+ // `cron/monitor.py`. The reducer half (what the answer means) is `gateResult` in cron.mjs.
5
+
6
+ import { spawn } from 'node:child_process';
7
+ import { createHash } from 'node:crypto';
8
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
9
+ import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+
12
+ /** Hermes `_DEFAULT_SCRIPT_TIMEOUT`. */
13
+ export const DEFAULT_SCRIPT_TIMEOUT_S = 3600;
14
+ /** `cron/monitor.py`: diff and output caps, and the bounded GET. */
15
+ export const MAX_DIFF_CHARS = 4000;
16
+ export const MAX_OUTPUT_CHARS = 8000;
17
+ export const URL_TIMEOUT_MS = 30_000;
18
+ export const MAX_URL_BYTES = 262_144;
19
+ const SNAPSHOT = 'monitor_last_output.txt';
20
+ /** Bytes kept from a script's stdout or stderr; the pipe keeps draining past it. */
21
+ export const MAX_SCRIPT_OUTPUT_BYTES = 1_048_576;
22
+ /** Node's timer ceiling (a longer delay fires at once). */
23
+ const MAX_TIMER_MS = 2_147_483_647;
24
+
25
+ /**
26
+ * Secrets out of a script's output before it reaches a prompt, a chat or a
27
+ * hash. Hermes runs its `agent/redact.py` redactor (13 pattern families);
28
+ * this is a partial transcription: every value the daemon holds as a secret
29
+ * (the profile vault) by value, and the common credential shapes Hermes's
30
+ * prefix and private-key patterns catch.
31
+ */
32
+ const SECRET_SHAPES = [
33
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
34
+ /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,
35
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
36
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
37
+ /\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
38
+ /\bxapp-[A-Za-z0-9-]{10,}/g,
39
+ /\bAKIA[0-9A-Z]{16}\b/g,
40
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
41
+ /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/g,
42
+ /(authorization:\s*(?:bearer|basic)\s+)[^\s]+/gi,
43
+ ];
44
+
45
+ export function redactSecrets(text, secrets = []) {
46
+ let out = String(text ?? '');
47
+ for (const value of secrets) if (typeof value === 'string' && value.length >= 8) out = out.split(value).join('***');
48
+ for (const re of SECRET_SHAPES) out = out.replace(re, (m, head) => (typeof head === 'string' && m.startsWith(head) ? `${head}***` : '***'));
49
+ return out;
50
+ }
51
+
52
+ /** Hermes `_get_script_timeout`: `HERMES_CRON_SCRIPT_TIMEOUT`, then `cron.script_timeout_seconds`, then an hour. */
53
+ export function scriptTimeoutSeconds(profile, env = process.env) {
54
+ for (const raw of [env.HERMES_CRON_SCRIPT_TIMEOUT, profile?.residue?.config?.cron?.script_timeout_seconds]) {
55
+ if (raw === undefined || raw === null || String(raw).trim() === '') continue;
56
+ const n = Math.trunc(Number(String(raw).trim()));
57
+ if (Number.isFinite(n) && n > 0) return n;
58
+ }
59
+ return DEFAULT_SCRIPT_TIMEOUT_S;
60
+ }
61
+
62
+ /**
63
+ * A child's environment: a short allowlist of what a script needs to run,
64
+ * plus `HERMES_HOME` (the profile folder). Stricter than Hermes, which passes
65
+ * its whole environment minus the secrets it scrubs (`build_subprocess_env`);
66
+ * a script that needs another variable reads it from its own files.
67
+ */
68
+ function scriptEnv() {
69
+ const env = {};
70
+ for (const k of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'USER', 'SHELL']) if (process.env[k] !== undefined) env[k] = process.env[k];
71
+ return env;
72
+ }
73
+
74
+ /**
75
+ * Hermes `_run_job_script`: the script must live under the profile's
76
+ * `scripts/` (a relative path resolves there; an absolute or `~` path must
77
+ * resolve inside it, symlinks followed); `.sh`/`.bash` run with bash, anything
78
+ * else with python3; the cwd is the job's workdir, else the scripts folder.
79
+ * Returns `{ok, output}`; a failure's output is the reason, in Hermes's words.
80
+ */
81
+ export async function runJobScript(profile, scriptPath, workdir = null, { env = process.env, signal = null, secrets = [] } = {}) {
82
+ const raw = String(scriptPath ?? '');
83
+ if (raw.includes('\0')) return { ok: false, output: `Blocked: script path contains a NUL byte: ${JSON.stringify(raw)}` };
84
+ const scriptsDir = join(profile.dir, 'scripts');
85
+ mkdirSync(scriptsDir, { recursive: true });
86
+ const root = realpathSync(scriptsDir);
87
+ const expanded = raw === '~' || raw.startsWith('~/') ? join(homedir(), raw.slice(1)) : raw;
88
+ const candidate = resolve(isAbsolute(expanded) ? expanded : join(root, expanded));
89
+ // containment before existence, as Hermes checks it: nothing is said about a path outside
90
+ const inside = (p) => p === root || p.startsWith(`${root}/`);
91
+ if (!inside(candidate)) return { ok: false, output: `Blocked: script path resolves outside the scripts directory (${root}): ${JSON.stringify(raw)}` };
92
+ if (!existsSync(candidate)) return { ok: false, output: `Script not found: ${candidate}` };
93
+ const path = realpathSync(candidate);
94
+ if (!inside(path)) return { ok: false, output: `Blocked: script path resolves outside the scripts directory (${root}): ${JSON.stringify(raw)}` };
95
+ if (!statSync(path).isFile()) return { ok: false, output: `Script path is not a file: ${path}` };
96
+ const argv = ['.sh', '.bash'].includes(extname(path).toLowerCase()) ? ['bash', [path]] : ['python3', [path]];
97
+ const cwd = workdir && existsSync(workdir) ? workdir : dirname(path);
98
+ const timeoutS = scriptTimeoutSeconds(profile, env);
99
+ return new Promise((done) => {
100
+ const out = { stdout: [], stderr: [] };
101
+ const size = { stdout: 0, stderr: 0 };
102
+ let finished = false;
103
+ const child = spawn(argv[0], argv[1], { cwd, env: { ...scriptEnv(), HERMES_HOME: profile.dir }, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
104
+ const kill = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already gone */ } };
105
+ const end = (result) => {
106
+ if (finished) return;
107
+ finished = true;
108
+ clearTimeout(timer);
109
+ signal?.removeEventListener?.('abort', onAbort);
110
+ done(result);
111
+ };
112
+ const onAbort = () => { kill(); end({ ok: false, output: 'Script cancelled because the daemon is stopping' }); };
113
+ const timer = setTimeout(() => { kill(); end({ ok: false, output: `Script timed out after ${timeoutS}s: ${path}` }); }, Math.min(timeoutS * 1000, MAX_TIMER_MS));
114
+ timer.unref?.();
115
+ if (signal?.aborted) onAbort(); else signal?.addEventListener?.('abort', onAbort, { once: true });
116
+ // kept up to the cap and decoded once, so a split multi-byte character is whole
117
+ for (const name of ['stdout', 'stderr']) {
118
+ child[name].on('data', (chunk) => {
119
+ if (size[name] >= MAX_SCRIPT_OUTPUT_BYTES) return;
120
+ const room = MAX_SCRIPT_OUTPUT_BYTES - size[name];
121
+ out[name].push(chunk.length > room ? chunk.subarray(0, room) : chunk);
122
+ size[name] += Math.min(chunk.length, room);
123
+ });
124
+ }
125
+ const text = (name) => {
126
+ const decoded = Buffer.concat(out[name]).toString('utf8').trim();
127
+ return redactSecrets(size[name] >= MAX_SCRIPT_OUTPUT_BYTES ? `${decoded}\n... [output truncated]` : decoded, secrets);
128
+ };
129
+ child.on('error', (error) => end({ ok: false, output: `Script execution failed: ${error.message}` }));
130
+ child.on('close', (code) => {
131
+ const stdout = text('stdout');
132
+ const stderr = text('stderr');
133
+ if (code !== 0) {
134
+ const parts = [`Script exited with code ${code}`];
135
+ if (stderr) parts.push(`stderr:\n${stderr}`);
136
+ if (stdout) parts.push(`stdout:\n${stdout}`);
137
+ end({ ok: false, output: parts.join('\n') });
138
+ } else end({ ok: true, output: stdout });
139
+ });
140
+ });
141
+ }
142
+
143
+ /** `cron/monitor.py` `_fetch_monitor_url`: a bounded GET, the body as text. */
144
+ async function fetchMonitorUrl(url, signal = null) {
145
+ if (!/^https?:\/\//i.test(url)) return { ok: false, output: `monitor_url must be http(s): ${JSON.stringify(url)}` };
146
+ try {
147
+ const res = await fetch(url, { headers: { 'user-agent': 'hermes-cron-monitor' }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(URL_TIMEOUT_MS)]) : AbortSignal.timeout(URL_TIMEOUT_MS) });
148
+ if (!res.ok) return { ok: false, output: `monitor_url fetch failed: HTTP Error ${res.status}: ${res.statusText}` };
149
+ const reader = res.body?.getReader();
150
+ const chunks = [];
151
+ let size = 0;
152
+ while (reader) {
153
+ const { done, value } = await reader.read();
154
+ if (done) break;
155
+ chunks.push(value);
156
+ size += value.length;
157
+ if (size > MAX_URL_BYTES) { await reader.cancel(); break; }
158
+ }
159
+ return { ok: true, output: Buffer.concat(chunks).subarray(0, MAX_URL_BYTES).toString('utf8') };
160
+ } catch (error) {
161
+ return { ok: false, output: `monitor_url fetch failed: ${error.message}` };
162
+ }
163
+ }
164
+
165
+ /**
166
+ * `difflib.unified_diff(old, new, fromfile="previous", tofile="current",
167
+ * lineterm="")` over lines, with its three lines of context and range format.
168
+ * The matching is a longest common subsequence (difflib's matcher differs on
169
+ * some inputs; the block is context for a model, not a patch).
170
+ */
171
+ export function unifiedDiff(oldText, newText) {
172
+ const a = splitlines(oldText);
173
+ const b = splitlines(newText);
174
+ const codes = opcodes(a, b);
175
+ const out = [];
176
+ for (const group of grouped(codes, 3)) {
177
+ if (!out.length) out.push('--- previous', '+++ current');
178
+ const first = group[0];
179
+ const last = group[group.length - 1];
180
+ out.push(`@@ -${range(first[1], last[2])} +${range(first[3], last[4])} @@`);
181
+ for (const [tag, i1, i2, j1, j2] of group) {
182
+ if (tag === 'equal') { for (const line of a.slice(i1, i2)) out.push(` ${line}`); continue; }
183
+ if (tag === 'replace' || tag === 'delete') for (const line of a.slice(i1, i2)) out.push(`-${line}`);
184
+ if (tag === 'replace' || tag === 'insert') for (const line of b.slice(j1, j2)) out.push(`+${line}`);
185
+ }
186
+ }
187
+ const diff = out.join('\n');
188
+ return diff.length > MAX_DIFF_CHARS ? `${diff.slice(0, MAX_DIFF_CHARS)}\n... [diff truncated]` : diff;
189
+ }
190
+
191
+ /** Python's `str.splitlines()`: every line boundary it knows, no trailing empty line. */
192
+ function splitlines(text) {
193
+ const lines = String(text).split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
194
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
195
+ return lines;
196
+ }
197
+
198
+ function range(start, stop) {
199
+ let beginning = start + 1;
200
+ const length = stop - start;
201
+ if (length === 1) return `${beginning}`;
202
+ if (!length) beginning -= 1;
203
+ return `${beginning},${length}`;
204
+ }
205
+
206
+ /** difflib-shaped opcodes from a longest common subsequence (a whole replace past 4M cells). */
207
+ function opcodes(a, b) {
208
+ const n = a.length;
209
+ const m = b.length;
210
+ if (n * m > 4_000_000) return n || m ? [['replace', 0, n, 0, m]] : [];
211
+ const L = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
212
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
213
+ const codes = [];
214
+ const push = (tag, i1, i2, j1, j2) => {
215
+ const prev = codes[codes.length - 1];
216
+ if (prev && prev[0] === tag) { prev[2] = i2; prev[4] = j2; return; }
217
+ if (prev && tag !== 'equal' && prev[0] !== 'equal') { prev[0] = 'replace'; prev[2] = i2; prev[4] = j2; return; }
218
+ codes.push([tag, i1, i2, j1, j2]);
219
+ };
220
+ let i = 0;
221
+ let j = 0;
222
+ while (i < n || j < m) {
223
+ if (i < n && j < m && a[i] === b[j]) { push('equal', i, i + 1, j, j + 1); i++; j++; }
224
+ else if (j < m && (i === n || L[i][j + 1] >= L[i + 1][j])) { push('insert', i, i, j, j + 1); j++; }
225
+ else { push('delete', i, i + 1, j, j); i++; }
226
+ }
227
+ return codes;
228
+ }
229
+
230
+ /** difflib `get_grouped_opcodes(n)`. */
231
+ function grouped(codes, n) {
232
+ if (!codes.length) return [];
233
+ const cs = codes.map((c) => [...c]);
234
+ if (cs[0][0] === 'equal') { const [t, i1, i2, j1, j2] = cs[0]; cs[0] = [t, Math.max(i1, i2 - n), i2, Math.max(j1, j2 - n), j2]; }
235
+ const z = cs.length - 1;
236
+ if (cs[z][0] === 'equal') { const [t, i1, i2, j1, j2] = cs[z]; cs[z] = [t, i1, Math.min(i2, i1 + n), j1, Math.min(j2, j1 + n)]; }
237
+ const groups = [];
238
+ let group = [];
239
+ for (let [tag, i1, i2, j1, j2] of cs) {
240
+ if (tag === 'equal' && i2 - i1 > n + n) {
241
+ group.push([tag, i1, Math.min(i2, i1 + n), j1, Math.min(j2, j1 + n)]);
242
+ groups.push(group);
243
+ group = [];
244
+ i1 = Math.max(i1, i2 - n);
245
+ j1 = Math.max(j1, j2 - n);
246
+ }
247
+ group.push([tag, i1, i2, j1, j2]);
248
+ }
249
+ if (group.length && !(group.length === 1 && group[0][0] === 'equal')) groups.push(group);
250
+ return groups;
251
+ }
252
+
253
+ /**
254
+ * `cron/monitor.py` `check_monitor`: run the source; its exact output bytes are
255
+ * hashed. Unchanged → `{changed: false}`. Changed or first → the snapshot is
256
+ * written before the agent runs (detection is the state boundary) and the
257
+ * block goes into the prompt; the hash rides back for the reducer to keep in
258
+ * `monitor_state`. A source that fails persists nothing.
259
+ */
260
+ export async function checkMonitor(profile, job, gate, { workdir = null, signal = null, secrets = [] } = {}) {
261
+ const source = gate.monitor_script
262
+ ? await runJobScript(profile, gate.monitor_script, workdir, { signal, secrets })
263
+ : await fetchMonitorUrl(gate.monitor_url, signal);
264
+ if (!source.ok) return { ok: false, error: source.output };
265
+ const output = source.output;
266
+ const hash = createHash('sha256').update(output, 'utf8').digest('hex');
267
+ const last = job.residue?.monitor_state?.last_output_hash ?? null;
268
+ if (last !== null && hash === last) return { ok: true, changed: false };
269
+ const snapshot = join(profile.dir, 'cron', 'output', job.id, SNAPSHOT);
270
+ let old = '';
271
+ try { if (last !== null && existsSync(snapshot)) old = readFileSync(snapshot, 'utf8'); } catch { /* as in Hermes: read failures give no previous output */ }
272
+ const shown = output.length > MAX_OUTPUT_CHARS ? `${output.slice(0, MAX_OUTPUT_CHARS)}\n... [output truncated]` : output;
273
+ const block = last === null
274
+ ? `## Monitor Baseline (first run)\n\nThis is the first observation of the monitored source — there is no previous output to diff against.\n\n### Current output\n\n\`\`\`\n${shown}\n\`\`\``
275
+ : `## MONITOR CHANGE DETECTED\n\nThe monitored source's output changed since the last run.\n\n### Diff (previous → current)\n\n\`\`\`diff\n${unifiedDiff(old, output)}\n\`\`\`\n\n### Current output\n\n\`\`\`\n${shown}\n\`\`\``;
276
+ try {
277
+ mkdirSync(dirname(snapshot), { recursive: true });
278
+ writeFileSync(snapshot, output);
279
+ } catch { /* as in Hermes: a failed snapshot write is logged there, not fatal */ }
280
+ return { ok: true, changed: true, hash, block };
281
+ }