@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/apply/state.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The applier's own state (docs/plans/content-spec-status.md §7 "Base"): per home, what it owns —
|
|
2
|
+
// each record's key, the harness's id, the declared values it applied, their normalized read-back,
|
|
3
|
+
// and the last lifecycle it saw — plus the intents of creates in flight. It lives in the applier's
|
|
4
|
+
// state directory, never in the harness's folder and never in a git checkout, with generations and a
|
|
5
|
+
// lock. A home with no state file is plan-only until it is provisioned or adopted.
|
|
6
|
+
|
|
7
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { leaseIsLive, thisHost } from '../lease.mjs';
|
|
12
|
+
|
|
13
|
+
export const STATE_VERSION = 1;
|
|
14
|
+
/** Earlier generations kept beside the current state. */
|
|
15
|
+
export const GENERATIONS = 5;
|
|
16
|
+
|
|
17
|
+
/** Canonical JSON: keys sorted at every depth, so equal values hash equally. */
|
|
18
|
+
export function canonical(value) {
|
|
19
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
20
|
+
if (value && typeof value === 'object') {
|
|
21
|
+
return `{${Object.keys(value).filter((k) => value[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
22
|
+
}
|
|
23
|
+
return JSON.stringify(value ?? null);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hash(value) {
|
|
27
|
+
return createHash('sha256').update(canonical(value)).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The applier's state root: `$XDG_STATE_HOME/supercode/apply`, else `~/.local/state/supercode/apply`. */
|
|
31
|
+
export function stateRoot(env = process.env) {
|
|
32
|
+
return join(env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'supercode', 'apply');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function homeDir(root, homeId) {
|
|
36
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(String(homeId ?? ''))) throw new Error(`home id ${JSON.stringify(homeId)} must be 1-128 letters, digits, '.', '_' or '-'`);
|
|
37
|
+
return join(root, homeId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The home's state, or null when the applier has none (plan-only). */
|
|
41
|
+
export function readState(root, homeId) {
|
|
42
|
+
const file = join(homeDir(root, homeId), 'state.json');
|
|
43
|
+
if (!existsSync(file)) return null;
|
|
44
|
+
const state = JSON.parse(readFileSync(file, 'utf8'));
|
|
45
|
+
if (state.version !== STATE_VERSION) throw new Error(`${file}: apply state version ${state.version} is not ${STATE_VERSION}`);
|
|
46
|
+
return state;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Write the state. A new generation keeps the previous one as `state.<n>.json`
|
|
51
|
+
* (the oldest beyond GENERATIONS dropped); `{ generation: false }` rewrites the
|
|
52
|
+
* current one in place, which apply does after every verb so a crash loses no
|
|
53
|
+
* record, one generation per apply.
|
|
54
|
+
*/
|
|
55
|
+
export function writeState(root, homeId, state, { generation = true } = {}) {
|
|
56
|
+
const dir = homeDir(root, homeId);
|
|
57
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
58
|
+
const file = join(dir, 'state.json');
|
|
59
|
+
const next = { ...state, version: STATE_VERSION, generation: (state.generation ?? 0) + (generation ? 1 : 0) };
|
|
60
|
+
if (generation && existsSync(file)) {
|
|
61
|
+
const previous = JSON.parse(readFileSync(file, 'utf8'));
|
|
62
|
+
writeFileSync(join(dir, `state.${previous.generation ?? 0}.json`), JSON.stringify(previous, null, 2));
|
|
63
|
+
const old = readdirSync(dir).map((f) => /^state\.(\d+)\.json$/.exec(f)).filter(Boolean).map((m) => Number(m[1])).sort((a, b) => b - a);
|
|
64
|
+
for (const n of old.slice(GENERATIONS)) rmSync(join(dir, `state.${n}.json`), { force: true });
|
|
65
|
+
}
|
|
66
|
+
const tmp = join(dir, `state.json.${process.pid}.tmp`);
|
|
67
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
68
|
+
renameSync(tmp, file);
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates in flight, kept apart from the state so writing one is not a
|
|
74
|
+
* generation: each is written before the create's verb and cleared after its
|
|
75
|
+
* record reaches the state.
|
|
76
|
+
*/
|
|
77
|
+
export function readIntents(root, homeId) {
|
|
78
|
+
const file = join(homeDir(root, homeId), 'intents.json');
|
|
79
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function writeIntents(root, homeId, intents) {
|
|
83
|
+
const dir = homeDir(root, homeId);
|
|
84
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
const file = join(dir, 'intents.json');
|
|
86
|
+
const tmp = join(dir, `intents.json.${process.pid}.tmp`);
|
|
87
|
+
writeFileSync(tmp, JSON.stringify(intents, null, 2), { mode: 0o600 });
|
|
88
|
+
renameSync(tmp, file);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A record's durable data, captured before its delete, kept beside the state. */
|
|
92
|
+
export function writeCapture(root, homeId, key, id, data) {
|
|
93
|
+
const dir = join(homeDir(root, homeId), 'captures');
|
|
94
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
95
|
+
const file = join(dir, `${String(key).replace(/[^A-Za-z0-9._-]/g, '_')}-${String(id).replace(/[^A-Za-z0-9._-]/g, '_')}-${Date.now()}.json`);
|
|
96
|
+
writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
97
|
+
return file;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Provisioning: an empty state, so the runtime's own home creates its declared records from the first apply. */
|
|
101
|
+
export function provision(root, homeId) {
|
|
102
|
+
return readState(root, homeId) ?? writeState(root, homeId, { records: {}, generation: 0 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One apply per home at a time: a lock file naming its holder. A lock whose
|
|
107
|
+
* holder is gone is stale; it is taken over under a takeover file only one
|
|
108
|
+
* taker can create, which re-checks the staleness before it replaces the lock.
|
|
109
|
+
*/
|
|
110
|
+
export function lock(root, homeId) {
|
|
111
|
+
const dir = homeDir(root, homeId);
|
|
112
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
113
|
+
const file = join(dir, 'lock');
|
|
114
|
+
const take = () => {
|
|
115
|
+
const fd = openSync(file, 'wx', 0o600);
|
|
116
|
+
// the holder's machine too: a home on a shared volume outlives the machine that locked it
|
|
117
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), ...thisHost() }));
|
|
118
|
+
closeSync(fd);
|
|
119
|
+
return () => rmSync(file, { force: true });
|
|
120
|
+
};
|
|
121
|
+
const holder = () => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } };
|
|
122
|
+
try { return take(); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
123
|
+
const seen = holder();
|
|
124
|
+
if (seen && leaseIsLive(seen)) throw new Error(`home ${homeId} is being applied by pid ${seen.pid} since ${seen.at}`);
|
|
125
|
+
const takeover = `${file}.takeover`;
|
|
126
|
+
// a takeover whose taker died mid-way is itself stale after a minute
|
|
127
|
+
try { if (Date.now() - statSync(takeover).mtimeMs > 60_000) rmSync(takeover, { force: true }); } catch { /* none */ }
|
|
128
|
+
let fd;
|
|
129
|
+
try { fd = openSync(takeover, 'wx', 0o600); } catch (error) {
|
|
130
|
+
if (error.code === 'EEXIST') throw new Error(`home ${homeId}: another process is taking over a stale lock (${takeover})`);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
closeSync(fd);
|
|
135
|
+
const now = holder();
|
|
136
|
+
if (now && leaseIsLive(now)) throw new Error(`home ${homeId} is being applied by pid ${now.pid} since ${now.at}`);
|
|
137
|
+
rmSync(file, { force: true });
|
|
138
|
+
return take();
|
|
139
|
+
} finally {
|
|
140
|
+
rmSync(takeover, { force: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The command Hermes's board dispatcher runs as its worker when the orchestrator serves the home: the
|
|
3
|
+
// loop sets `HERMES_BIN` to this file (docs/plans/hermes-compat.md row 14). Hermes's own argv and
|
|
4
|
+
// environment; see `kanban-worker.mjs`.
|
|
5
|
+
import { runKanbanWorker } from '../kanban-worker.mjs';
|
|
6
|
+
|
|
7
|
+
// Hermes watches this pid: it exits the moment the turn is over, whatever handle is still open.
|
|
8
|
+
process.exit(await runKanbanWorker(process.argv.slice(2), process.env).catch((error) => {
|
|
9
|
+
process.stdout.write(`hermes-worker: ${error.stack ?? error.message}\n`);
|
|
10
|
+
return 1;
|
|
11
|
+
}));
|