@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/reducer.mjs
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
// The loop as a pure reducer (docs/ORCHESTRATOR-IR.md §4).
|
|
2
|
+
//
|
|
3
|
+
// step(state, event) -> Effect[]
|
|
4
|
+
//
|
|
5
|
+
// Mutates `state` in place (it is the orchestrator's single value) and emits
|
|
6
|
+
// effects (§5). It never performs I/O and imports no node module: the test
|
|
7
|
+
// suite asserts that statically. Time comes from the event's `at`.
|
|
8
|
+
|
|
9
|
+
import { surfaceKeyString, renderSurfaceKey, resolveRoute, sessionReset, homeChannel } from './ir.mjs';
|
|
10
|
+
import { parseCommand, frame, REPLY } from './commands.mjs';
|
|
11
|
+
import { cronTick, gateResult, fireFinal, fireFailed, finishHostedFire, homeZone, isSynthetic, nextRunAt, scheduleError, unattendedRequest } from './cron.mjs';
|
|
12
|
+
import { handleWebhook } from './webhook.mjs';
|
|
13
|
+
import { modelError } from './activation.mjs';
|
|
14
|
+
|
|
15
|
+
export function keyOf(event) {
|
|
16
|
+
return surfaceKeyString(event.key);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function profileOf(state, name) {
|
|
20
|
+
const p = state.profiles[name];
|
|
21
|
+
if (!p) throw new Error(`unknown profile ${name}`);
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ensureRuntime(state) {
|
|
26
|
+
const r = state.runtime;
|
|
27
|
+
r.leases ??= {}; r.queues ??= {}; r.adapters ??= {}; r.pending_requests ??= {}; r.turns ??= {}; r.starting ??= {}; r.activity ??= {}; r.gates ??= {};
|
|
28
|
+
return r;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function leaseId(profile, keyString) {
|
|
32
|
+
return `${profile} ${keyString}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------- expiry
|
|
36
|
+
|
|
37
|
+
function minutesBetween(a, b) {
|
|
38
|
+
return (Date.parse(b) - Date.parse(a)) / 60000;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A daily boundary at local `hour` lies in (a, b] (Hermes resets at a local hour). */
|
|
42
|
+
function crossedDailyBoundary(a, b, hour) {
|
|
43
|
+
const start = new Date(a);
|
|
44
|
+
const boundary = new Date(start.getFullYear(), start.getMonth(), start.getDate(), hour, 0, 0, 0);
|
|
45
|
+
if (boundary <= start) boundary.setDate(boundary.getDate() + 1);
|
|
46
|
+
return boundary.getTime() <= Date.parse(b);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether a session has reset, by Hermes's `session_reset` (`sessionReset`): its end reason, or null. */
|
|
50
|
+
export function expired(binding, reset, now) {
|
|
51
|
+
if (binding.ended_at) return binding.end_reason ?? 'reset';
|
|
52
|
+
const idle = reset.mode === 'idle' || reset.mode === 'both';
|
|
53
|
+
const daily = reset.mode === 'daily' || reset.mode === 'both';
|
|
54
|
+
if (idle && minutesBetween(binding.last_activity_at, now) >= reset.idle_minutes) return 'idle';
|
|
55
|
+
if (daily && crossedDailyBoundary(binding.last_activity_at, now, reset.at_hour)) return 'daily';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function liveBinding(profile, keyString) {
|
|
60
|
+
const b = profile.bindings[keyString];
|
|
61
|
+
return b && !b.ended_at ? b : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------- access
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Hermes's `_get_unauthorized_dm_behavior`, transcribed: a platform's own `unauthorized_dm_behavior`,
|
|
68
|
+
* email ignored, a global override, the platform's `dm_policy`, then `ignore` where an allowlist is
|
|
69
|
+
* configured and `pair` on an open gateway.
|
|
70
|
+
*/
|
|
71
|
+
export function dmBehavior(profile, platform) {
|
|
72
|
+
const extra = profile.channels?.[platform]?.extra ?? {};
|
|
73
|
+
const own = extra['extra.unauthorized_dm_behavior'] ?? extra.unauthorized_dm_behavior;
|
|
74
|
+
if (own) return String(own);
|
|
75
|
+
if (platform === 'email') return 'ignore';
|
|
76
|
+
const global = profile.residue?.config?.unauthorized_dm_behavior;
|
|
77
|
+
if (global && global !== 'pair') return String(global);
|
|
78
|
+
const policy = String(extra['extra.dm_policy'] ?? extra.dm_policy ?? '').trim().toLowerCase();
|
|
79
|
+
if (policy === 'pairing') return 'pair';
|
|
80
|
+
if (policy === 'allowlist' || policy === 'disabled') return 'ignore';
|
|
81
|
+
return (profile.access.allowlist[platform] ?? []).length ? 'ignore' : 'pair';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** §4.1 step 1: a platform with no `policy` entry is `allowlist`, never open. */
|
|
85
|
+
export function accessPolicy(profile, platform) {
|
|
86
|
+
return profile.access.policy[platform] ?? 'allowlist';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
export function endBinding(state, profileName, keyString, reason, now, effects) {
|
|
91
|
+
const profile = state.profiles[profileName];
|
|
92
|
+
const b = profile.bindings[keyString];
|
|
93
|
+
if (!b || b.ended_at) return;
|
|
94
|
+
b.ended_at = now;
|
|
95
|
+
b.end_reason = reason;
|
|
96
|
+
if (b.residue) delete b.residue.runtime_id; // the lease is gone with the binding (§4.7)
|
|
97
|
+
// ended bindings keep their history under a started_at-qualified slot;
|
|
98
|
+
// two sessions ended within one clock instant get distinct slots
|
|
99
|
+
delete profile.bindings[keyString];
|
|
100
|
+
let slot = `${keyString}#${b.started_at}`;
|
|
101
|
+
for (let n = 2; profile.bindings[slot]; n++) slot = `${keyString}#${b.started_at}#${n}`;
|
|
102
|
+
profile.bindings[slot] = b;
|
|
103
|
+
const rt = ensureRuntime(state);
|
|
104
|
+
const lid = leaseId(profileName, keyString);
|
|
105
|
+
if (rt.leases[lid]) { effects.push({ type: 'runtime_close', profile: profileName, keyString, lease: rt.leases[lid] }); delete rt.leases[lid]; }
|
|
106
|
+
delete rt.turns[lid];
|
|
107
|
+
delete rt.queues[lid];
|
|
108
|
+
delete rt.activity[lid];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------- obligations
|
|
112
|
+
|
|
113
|
+
let obligationCounter = 0;
|
|
114
|
+
export function newObligation(profile, key, text, source, now) {
|
|
115
|
+
// The stamp is a second and the counter is per PROCESS, so a daemon that
|
|
116
|
+
// came back inside the same second as the last one would otherwise mint an
|
|
117
|
+
// id the store already holds (`delivery_obligations.obligation_id` is
|
|
118
|
+
// unique). The profile's own rows are the authority on what is taken.
|
|
119
|
+
const stamp = now.replace(/[^0-9]/g, '').slice(0, 14);
|
|
120
|
+
let id = `obl-${stamp}-${(++obligationCounter).toString(36)}`;
|
|
121
|
+
while (profile.obligations.some((o) => o.id === id)) id = `obl-${stamp}-${(++obligationCounter).toString(36)}`;
|
|
122
|
+
const o = {
|
|
123
|
+
id,
|
|
124
|
+
target: { platform: key.platform, kind: key.kind, chat_id: key.chat_id ?? '', thread_id: key.thread_id ?? null },
|
|
125
|
+
session_key: renderSurfaceKey(profile.name, key),
|
|
126
|
+
content: { text },
|
|
127
|
+
state: 'pending', attempts: 0, last_error: null,
|
|
128
|
+
created_at: String(Date.parse(now) / 1000), updated_at: String(Date.parse(now) / 1000), delivered_at: null,
|
|
129
|
+
source, residue: {},
|
|
130
|
+
};
|
|
131
|
+
profile.obligations.push(o);
|
|
132
|
+
return o;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function reply(state, profile, key, text, now, effects, source = { kind: 'turn' }) {
|
|
136
|
+
const o = newObligation(profile, key, text, source, now);
|
|
137
|
+
effects.push({ type: 'deliver', profile: profile.name, obligation_id: o.id });
|
|
138
|
+
return o;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- inbound
|
|
142
|
+
|
|
143
|
+
function handleInbound(state, ev, effects) {
|
|
144
|
+
const owner = profileOf(state, ev.profile);
|
|
145
|
+
const now = ev.at;
|
|
146
|
+
|
|
147
|
+
// 0. our own echo (§3 `is_me`): a platform that replays what we just posted
|
|
148
|
+
// is not a turn. Answering it would loop the conversation against itself.
|
|
149
|
+
if (ev.is_me) return;
|
|
150
|
+
|
|
151
|
+
// 1. access
|
|
152
|
+
const policy = accessPolicy(owner, ev.key.platform);
|
|
153
|
+
const allowed = policy === 'open' || (owner.access.allowlist[ev.key.platform] ?? []).includes(ev.sender);
|
|
154
|
+
if (!allowed) {
|
|
155
|
+
// Hermes's gateway: in a DM whose platform pairs, a stranger is offered a code from Hermes's own
|
|
156
|
+
// pairing store (its codes, expiry, rate limit and lockout; docs/plans/hermes-compat.md row 8);
|
|
157
|
+
// anywhere else, and wherever the owner restricted access, the message is dropped silently
|
|
158
|
+
if (ev.key.kind === 'dm' && dmBehavior(owner, ev.key.platform) === 'pair') {
|
|
159
|
+
effects.push({ type: 'pairing_issue', profile: owner.name, platform: ev.key.platform, user_id: ev.sender, user_name: ev.sender_name ?? '', key: ev.key });
|
|
160
|
+
} else {
|
|
161
|
+
effects.push({ type: 'log', level: 'info', message: `unauthorized ${ev.key.platform} sender ${ev.sender} ignored` });
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 2. route
|
|
167
|
+
const routed = resolveRoute(owner.routes, ev.key, ev.guild_id) ?? owner.name;
|
|
168
|
+
const profile = profileOf(state, routed);
|
|
169
|
+
const keyString = keyOf(ev);
|
|
170
|
+
|
|
171
|
+
// 3. command
|
|
172
|
+
const cmd = parseCommand(ev.text);
|
|
173
|
+
if (cmd) { handleCommand(state, profile, ev, keyString, cmd, effects); return; }
|
|
174
|
+
|
|
175
|
+
// 4. get-or-create
|
|
176
|
+
let b = liveBinding(profile, keyString);
|
|
177
|
+
if (b) {
|
|
178
|
+
const reason = expired(b, sessionReset(profile), now);
|
|
179
|
+
if (reason) { endBinding(state, profile.name, keyString, reason, now, effects); b = null; }
|
|
180
|
+
}
|
|
181
|
+
if (!b) {
|
|
182
|
+
b = {
|
|
183
|
+
key: ev.key,
|
|
184
|
+
worker: { harness: profile.worker?.harness ?? 'unknown', session_id: null, locator: null },
|
|
185
|
+
started_at: now, last_activity_at: now, ended_at: null, end_reason: null, handoff: null, recurrence: null, residue: {},
|
|
186
|
+
};
|
|
187
|
+
profile.bindings[keyString] = b;
|
|
188
|
+
if (!profile.worker) { reply(state, profile, ev.key, `Profile ${profile.name} has no worker configured.`, now, effects); return; }
|
|
189
|
+
startWorker(state, profile, keyString, effects);
|
|
190
|
+
} else {
|
|
191
|
+
// A live binding whose lease is gone — a restart, a worker that died — must
|
|
192
|
+
// get its worker back on the next message rather than queue it forever
|
|
193
|
+
// (ORC-8 finding: "a resent message after a restart is never answered").
|
|
194
|
+
const rt0 = ensureRuntime(state);
|
|
195
|
+
const lid0 = leaseId(profile.name, keyString);
|
|
196
|
+
if (!rt0.leases[lid0] && !rt0.turns[lid0] && !rt0.starting[lid0] && profile.worker) {
|
|
197
|
+
if (b.worker.session_id) {
|
|
198
|
+
b.residue.resumed_from = b.worker.session_id;
|
|
199
|
+
// The conversation's session comes back, not a blank one; one that will not reopen starts fresh (loop).
|
|
200
|
+
b.residue.resume_runtime_id ??= b.residue.runtime_id ?? b.worker.session_id;
|
|
201
|
+
}
|
|
202
|
+
startWorker(state, profile, keyString, effects);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
b.last_activity_at = now;
|
|
206
|
+
|
|
207
|
+
// 5. frame and send, or queue behind the running turn
|
|
208
|
+
const rt = ensureRuntime(state);
|
|
209
|
+
const lid = leaseId(profile.name, keyString);
|
|
210
|
+
const text = frame(profile.name, ev.key, ev.sender, ev.sender_name, now, ev.text);
|
|
211
|
+
const inFlight = Boolean(rt.turns[lid]);
|
|
212
|
+
const leased = Boolean(rt.leases[lid]);
|
|
213
|
+
if (inFlight && leased && rt.leases[lid].steer) {
|
|
214
|
+
// A worker that takes input mid-turn (the harness's `steer`) hears the line now, inside the turn it is running:
|
|
215
|
+
// a person steering a long piece of work is not held until the work is done. Its reply is that turn's.
|
|
216
|
+
effects.push({ type: 'steer_input', profile: profile.name, keyString, text });
|
|
217
|
+
} else if (inFlight || !leased) {
|
|
218
|
+
(rt.queues[lid] ??= []).push({ text, at: now });
|
|
219
|
+
} else {
|
|
220
|
+
rt.turns[lid] = { started_at: now, buffer: '' };
|
|
221
|
+
effects.push({ type: 'send_input', profile: profile.name, keyString, text });
|
|
222
|
+
}
|
|
223
|
+
effects.push({ type: 'save' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Emit one `runtime_start` for a surface and remember that it is starting, so
|
|
228
|
+
* a burst of messages before the lease exists queues behind ONE start (the
|
|
229
|
+
* `started`/`error` worker events clear the mark).
|
|
230
|
+
*/
|
|
231
|
+
function startWorker(state, profile, keyString, effects) {
|
|
232
|
+
const rt = ensureRuntime(state);
|
|
233
|
+
rt.starting[leaseId(profile.name, keyString)] = true;
|
|
234
|
+
// A binding whose runtime went away with the daemon reopens that same session where its harness can, so the
|
|
235
|
+
// conversation it held is not lost; the loop starts fresh when the harness cannot.
|
|
236
|
+
const resume = profile.bindings[keyString]?.residue?.resume_runtime_id ?? null;
|
|
237
|
+
effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, resume_runtime_id: resume });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------- commands
|
|
242
|
+
|
|
243
|
+
function handleCommand(state, profile, ev, keyString, cmd, effects) {
|
|
244
|
+
const now = ev.at;
|
|
245
|
+
switch (cmd.name) {
|
|
246
|
+
case 'new':
|
|
247
|
+
case 'reset': {
|
|
248
|
+
endBinding(state, profile.name, keyString, cmd.name, now, effects);
|
|
249
|
+
reply(state, profile, ev.key, cmd.name === 'new' ? REPLY.newSession : REPLY.reset, now, effects);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case 'sethome': {
|
|
253
|
+
// Hermes's `/sethome`: the platform's `home_channel` in its `platforms:` block
|
|
254
|
+
const channel = (profile.channels[ev.key.platform] ??= { platform: ev.key.platform, enabled: true, credentials: {}, extra: {} });
|
|
255
|
+
channel.extra = { ...(channel.extra ?? {}), home_channel: { platform: ev.key.platform, chat_id: ev.key.chat_id ?? '', name: ev.sender_name ?? ev.key.chat_id ?? '', ...(ev.key.thread_id ? { thread_id: ev.key.thread_id } : {}) } };
|
|
256
|
+
effects.push({ type: 'save' });
|
|
257
|
+
reply(state, profile, ev.key, REPLY.homeSet, now, effects);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case 'status': {
|
|
261
|
+
const b = liveBinding(profile, keyString);
|
|
262
|
+
const lines = [
|
|
263
|
+
`profile: ${profile.name}`,
|
|
264
|
+
`worker: ${profile.worker ? profile.worker.harness + (profile.worker.model ? ` (${profile.worker.model})` : '') : 'none'}`,
|
|
265
|
+
`session: ${b ? b.worker.session_id || 'starting' : 'none'}`,
|
|
266
|
+
`session reset: ${(({ mode, at_hour, idle_minutes }) => (mode === 'none' ? 'none' : [mode !== 'daily' ? `idle ${idle_minutes} min` : null, mode !== 'idle' ? `daily at ${at_hour}:00` : null].filter(Boolean).join(', ')))(sessionReset(profile))}`,
|
|
267
|
+
`queued: ${(ensureRuntime(state).queues[leaseId(profile.name, keyString)] ?? []).length}`,
|
|
268
|
+
];
|
|
269
|
+
reply(state, profile, ev.key, lines.join('\n'), now, effects);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'whoami': {
|
|
273
|
+
reply(state, profile, ev.key, `${ev.sender}${ev.sender_name ? ` (${ev.sender_name})` : ''} on ${renderSurfaceKey(profile.name, ev.key)}`, now, effects);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case 'model': {
|
|
277
|
+
if (!profile.worker) { reply(state, profile, ev.key, `Profile ${profile.name} has no worker configured.`, now, effects); break; }
|
|
278
|
+
// refused here, not at the next start, where it would end every conversation
|
|
279
|
+
const unusable = modelError(profile.worker.harness, cmd.args);
|
|
280
|
+
if (unusable) { reply(state, profile, ev.key, `Model not changed: ${unusable}.`, now, effects); break; }
|
|
281
|
+
const b = liveBinding(profile, keyString);
|
|
282
|
+
if (b) b.residue.model_override = cmd.args;
|
|
283
|
+
profile.worker.model = cmd.args || null;
|
|
284
|
+
reply(state, profile, ev.key, REPLY.modelApplied(cmd.args || 'default'), now, effects);
|
|
285
|
+
effects.push({ type: 'save' });
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
case 'sessions': {
|
|
289
|
+
const rows = Object.values(profile.bindings).filter((b) => surfaceKeyString(b.key) === keyString).sort((a, b) => (a.started_at < b.started_at ? 1 : -1)).slice(0, 10);
|
|
290
|
+
const text = rows.length ? rows.map((b, i) => `${i + 1}. ${b.worker.session_id || '(starting)'} · ${b.started_at}${b.ended_at ? ` · ended (${b.end_reason})` : ' · live'}`).join('\n') : 'No sessions on this surface.';
|
|
291
|
+
reply(state, profile, ev.key, text, now, effects);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
case 'handoff': {
|
|
295
|
+
const outcome = doHandoff(state, profile, keyString, cmd.args || null, null, now, effects);
|
|
296
|
+
reply(state, profile, ev.key, outcome.message, now, effects);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case 'bg': {
|
|
300
|
+
const id = `bg-${now.replace(/[^0-9]/g, '').slice(0, 14)}`;
|
|
301
|
+
profile.jobs[id] = {
|
|
302
|
+
id, schedule: { kind: 'once', run_at: now }, prompt: cmd.args, workdir: null, model: null, skills: [], context_from: null,
|
|
303
|
+
deliver: { kind: 'origin' }, failure_deliver: { kind: 'origin' },
|
|
304
|
+
origin: { platform: ev.key.platform, chat_type: ev.key.kind, chat_id: ev.key.chat_id ?? null, thread_id: ev.key.thread_id ?? null },
|
|
305
|
+
attach_to_session: null, repeat: null, enabled: true, next_run_at: now, last_run_at: null, last_status: null, created_at: now, residue: {},
|
|
306
|
+
};
|
|
307
|
+
reply(state, profile, ev.key, REPLY.bgQueued(id), now, effects);
|
|
308
|
+
effects.push({ type: 'save' });
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
default:
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* `/handoff <platform>` and the `session_handoff` tool share this (§4.5): mark
|
|
318
|
+
* the live binding pending, announce it on the profile's home chat on that
|
|
319
|
+
* platform, or fail by name when there is none. Returns what to tell the caller.
|
|
320
|
+
*/
|
|
321
|
+
export function doHandoff(state, profile, keyString, to, note, now, effects) {
|
|
322
|
+
const b = liveBinding(profile, keyString);
|
|
323
|
+
if (!b) return { ok: false, message: 'No live session to hand off.' };
|
|
324
|
+
if (!to) return { ok: false, message: 'Cannot hand off: no platform named.' };
|
|
325
|
+
b.handoff = { to, state: 'pending', error: null };
|
|
326
|
+
const target = homeChannel(profile, to);
|
|
327
|
+
if (target) {
|
|
328
|
+
reply(state, profile, target, `Continuing session ${b.worker.session_id} from ${b.key.platform}. Reply here to pick it up.${note ? `\n${note}` : ''}`, now, effects);
|
|
329
|
+
} else {
|
|
330
|
+
b.handoff.state = 'failed';
|
|
331
|
+
b.handoff.error = `no home chat on ${to}`;
|
|
332
|
+
}
|
|
333
|
+
effects.push({ type: 'save' });
|
|
334
|
+
return b.handoff.state === 'pending'
|
|
335
|
+
? { ok: true, message: `Handed off to ${to}.` }
|
|
336
|
+
: { ok: false, message: `Cannot hand off: ${b.handoff.error}.` };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------- worker events
|
|
340
|
+
|
|
341
|
+
function handleWorker(state, ev, effects) {
|
|
342
|
+
const profile = profileOf(state, ev.profile);
|
|
343
|
+
const rt = ensureRuntime(state);
|
|
344
|
+
const lid = leaseId(profile.name, ev.keyString);
|
|
345
|
+
const binding = liveBinding(profile, ev.keyString);
|
|
346
|
+
const now = ev.at;
|
|
347
|
+
const w = ev.ev;
|
|
348
|
+
// A runtime that no longer serves this surface (the one a timed-out fire's
|
|
349
|
+
// binding closed, while the next fire starts on the same key) is not heard:
|
|
350
|
+
// its late `closed` would otherwise fail the new fire.
|
|
351
|
+
if (w.kind !== 'started' && ev.runtime_id) {
|
|
352
|
+
const current = rt.leases[lid]?.runtime_id ?? binding?.residue?.runtime_id ?? null;
|
|
353
|
+
if (current !== ev.runtime_id) return;
|
|
354
|
+
}
|
|
355
|
+
// any word from the worker is activity; a fire's inactivity limit counts from the last (§4.3)
|
|
356
|
+
rt.activity[lid] = now;
|
|
357
|
+
switch (w.kind) {
|
|
358
|
+
case 'activity':
|
|
359
|
+
break;
|
|
360
|
+
case 'started': {
|
|
361
|
+
delete rt.starting[lid];
|
|
362
|
+
// A runtime whose binding ended while it was starting (/new, /reset or
|
|
363
|
+
// idle expiry during boot) is closed: nothing will use it.
|
|
364
|
+
// A reattach whose binding was replaced while it attached (a cron fire,
|
|
365
|
+
// /new then a message) is not that binding's runtime either.
|
|
366
|
+
if (!binding || (w.reattach_of && binding.residue?.runtime_id !== w.reattach_of)) {
|
|
367
|
+
effects.push({ type: 'runtime_close', profile: profile.name, keyString: ev.keyString, runtime_id: w.runtime_id });
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
// Starts run one at a time, so a `started` is always the newest runtime
|
|
371
|
+
// on this surface (the loop has already made it the one it sends to).
|
|
372
|
+
// It takes the lease; the runtime it displaces is closed by name.
|
|
373
|
+
if (rt.leases[lid] && rt.leases[lid].runtime_id !== w.runtime_id) {
|
|
374
|
+
effects.push({ type: 'runtime_close', profile: profile.name, keyString: ev.keyString, runtime_id: rt.leases[lid].runtime_id });
|
|
375
|
+
}
|
|
376
|
+
delete rt.starting[lid];
|
|
377
|
+
rt.leases[lid] = { runtime_id: w.runtime_id, connection: w.connection ?? null, since: now, steer: w.steer === true };
|
|
378
|
+
if (binding) {
|
|
379
|
+
binding.worker.session_id = w.session_id ?? w.runtime_id;
|
|
380
|
+
binding.worker.harness = w.harness ?? binding.worker.harness;
|
|
381
|
+
// §2.8 never persists the lease table, so the binding carries the one
|
|
382
|
+
// fact a restart needs from it: which runtime was serving this surface.
|
|
383
|
+
binding.residue.runtime_id = w.runtime_id;
|
|
384
|
+
delete binding.residue.interrupted_at;
|
|
385
|
+
}
|
|
386
|
+
// the first queued message goes out now that the lease exists
|
|
387
|
+
const q = rt.queues[lid] ?? [];
|
|
388
|
+
if (q.length && !rt.turns[lid]) {
|
|
389
|
+
const next = q.shift();
|
|
390
|
+
rt.turns[lid] = { started_at: now, buffer: '' };
|
|
391
|
+
effects.push(queuedInput(profile.name, ev.keyString, next));
|
|
392
|
+
}
|
|
393
|
+
effects.push({ type: 'save' });
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
case 'steer_refused': {
|
|
397
|
+
// The harness would not take the line mid-turn: it waits for the turn's end like any other.
|
|
398
|
+
(rt.queues[lid] ??= []).push({ text: w.text, at: now });
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
case 'text': {
|
|
402
|
+
const t = rt.turns[lid] ??= { started_at: now, buffer: '' };
|
|
403
|
+
// A conversation hears a working turn as it goes: a whole message is delivered as it arrives. Once the turn has
|
|
404
|
+
// spoken through the tool, a message waits for the next, since the last one is narration of that reply (§4.2
|
|
405
|
+
// `final`). A fire's session delivers once.
|
|
406
|
+
if (w.block && binding && !isSynthetic(binding)) {
|
|
407
|
+
if (t.buffer.trim()) reply(state, profile, binding.key, t.buffer.trim(), now, effects);
|
|
408
|
+
t.buffer = '';
|
|
409
|
+
if (!t.replied && (w.delta ?? '').trim()) {
|
|
410
|
+
reply(state, profile, binding.key, w.delta.trim(), now, effects);
|
|
411
|
+
effects.push({ type: 'save' });
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
effects.push({ type: 'save' });
|
|
415
|
+
}
|
|
416
|
+
// Whole messages are paragraphs; streamed chunks continue the text they belong to, and a chunk of another
|
|
417
|
+
// message starts a paragraph (a fire's report read "…the raw results.1) Shell output…" without it).
|
|
418
|
+
const newMessage = !w.block && w.item && t.item && w.item !== t.item;
|
|
419
|
+
if ((w.block || newMessage) && t.buffer && !t.buffer.endsWith('\n')) t.buffer += '\n\n';
|
|
420
|
+
if (w.item) t.item = w.item;
|
|
421
|
+
t.buffer += w.delta ?? '';
|
|
422
|
+
// the turn's last message alone: what a fire delivers, as Hermes delivers a fire's final response
|
|
423
|
+
t.last = w.block || newMessage || t.last === undefined ? (w.delta ?? '') : t.last + (w.delta ?? '');
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
case 'final': {
|
|
427
|
+
const t = rt.turns[lid];
|
|
428
|
+
const text = (w.text ?? t?.buffer ?? '').trim();
|
|
429
|
+
delete rt.turns[lid];
|
|
430
|
+
if (binding) binding.last_activity_at = now;
|
|
431
|
+
// a turn that ended in error (§4.2; the SDK's `turn_completed` with
|
|
432
|
+
// `outcome: error`) FAILS a fire and tells a chat surface once — it is
|
|
433
|
+
// never mistaken for an empty answer (ORC-8 finding)
|
|
434
|
+
const failed = w.outcome === 'error';
|
|
435
|
+
const message = w.message ?? 'the worker reported an error';
|
|
436
|
+
// a fire's (or a webhook turn's) session delivers per the job/subscription target
|
|
437
|
+
const report = (w.text ?? t?.last ?? t?.buffer ?? '').trim();
|
|
438
|
+
const synthetic = binding ? (failed ? fireFailed(state, profile, binding, message, now, effects) : fireFinal(state, profile, binding, report, now, effects)) : false;
|
|
439
|
+
if (!synthetic && binding) {
|
|
440
|
+
if (failed) reply(state, profile, binding.key, `Turn failed: ${message}`, now, effects);
|
|
441
|
+
else if (text && !t?.replied) reply(state, profile, binding.key, text, now, effects);
|
|
442
|
+
}
|
|
443
|
+
const q = rt.queues[lid] ?? [];
|
|
444
|
+
if (q.length && rt.leases[lid]) {
|
|
445
|
+
const next = q.shift();
|
|
446
|
+
rt.turns[lid] = { started_at: now, buffer: '' };
|
|
447
|
+
effects.push(queuedInput(profile.name, ev.keyString, next));
|
|
448
|
+
}
|
|
449
|
+
effects.push({ type: 'save' });
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
case 'request': {
|
|
453
|
+
if (!binding) break;
|
|
454
|
+
// Hermes's approval rule allowed it (a safe command, any other tool): it runs without asking, as under Hermes
|
|
455
|
+
if (w.hermes?.verdict === 'allow') {
|
|
456
|
+
effects.push({ type: 'respond', profile: profile.name, keyString: ev.keyString, request_id: String(w.id), response: permissionResponse(w.options, true) });
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
// a cron fire or a webhook turn has nobody to ask: answered now (§4.2)
|
|
460
|
+
if (unattendedRequest(state, profile, binding, ev.keyString, w, now, effects)) break;
|
|
461
|
+
rt.pending_requests[String(w.id)] = { profile: profile.name, keyString: ev.keyString, runtime_id: rt.leases[lid]?.runtime_id ?? null, asked_at: now, options: w.options ?? [] };
|
|
462
|
+
const options = (w.options ?? []).map((o, i) => `${i + 1}. ${o.name}`).join('\n');
|
|
463
|
+
reply(state, profile, binding.key, `Permission needed: ${w.subject ?? 'the worker wants to act'}\nReply with a number:\n${options}`, now, effects);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case 'ended':
|
|
467
|
+
case 'error': {
|
|
468
|
+
delete rt.turns[lid];
|
|
469
|
+
delete rt.starting[lid];
|
|
470
|
+
const synthetic = binding ? fireFailed(state, profile, binding, w.message ?? w.reason ?? 'worker exited', now, effects) : false;
|
|
471
|
+
if (binding) {
|
|
472
|
+
endBinding(state, profile.name, ev.keyString, 'error', now, effects);
|
|
473
|
+
if (!synthetic) reply(state, profile, binding.key, `Session ended: ${w.message ?? w.reason ?? 'worker exited'}.`, now, effects);
|
|
474
|
+
} else {
|
|
475
|
+
delete rt.leases[lid];
|
|
476
|
+
}
|
|
477
|
+
effects.push({ type: 'save' });
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
default:
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The worker's own option for a decision nobody typed (the timeout's default,
|
|
487
|
+
* an unattended surface's policy): the first whose kind or name reads as
|
|
488
|
+
* allow/approve, or as reject/deny; `cancelled` when the worker offered neither.
|
|
489
|
+
*/
|
|
490
|
+
export function permissionResponse(options, allow, reason = null) {
|
|
491
|
+
const pattern = allow ? /allow|approve/i : /reject|deny/i;
|
|
492
|
+
const answer = (options ?? []).find((o) => pattern.test(o.kind ?? '') || pattern.test(o.name ?? '')) ?? null;
|
|
493
|
+
const response = answer ? (answer.response ?? answer) : { outcome: { outcome: 'cancelled' } };
|
|
494
|
+
// a denial that carries words (Claude Code's `message`) says why, so the worker can find another way
|
|
495
|
+
return !allow && reason && typeof response.message === 'string' ? { ...response, message: reason } : response;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* A queued line as the worker receives it. A fire's line keeps what frames it
|
|
500
|
+
* (`context_from`, the job's `skills`) whichever flush sends it; the loop reads
|
|
501
|
+
* those from disk at send time.
|
|
502
|
+
*/
|
|
503
|
+
function queuedInput(profile, keyString, next) {
|
|
504
|
+
const framed = next.job_id && (next.context_from || next.skills?.length);
|
|
505
|
+
return { type: 'send_input', profile, keyString, text: next.text, ...(framed ? { context_from: next.context_from ?? null, skills: next.skills ?? [], job_id: next.job_id } : {}) };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** An admin's numeric reply resolves the oldest pending request on that surface. */
|
|
509
|
+
function tryResolveRequest(state, ev, effects) {
|
|
510
|
+
const rt = ensureRuntime(state);
|
|
511
|
+
const keyString = keyOf(ev);
|
|
512
|
+
const owner = profileOf(state, ev.profile);
|
|
513
|
+
const isAdmin = (owner.access.admins[ev.key.platform] ?? []).includes(ev.sender) || accessPolicy(owner, ev.key.platform) === 'open';
|
|
514
|
+
const m = /^\s*(\d+)\s*$/.exec(ev.text);
|
|
515
|
+
if (!m || !isAdmin) return false;
|
|
516
|
+
const entry = Object.entries(rt.pending_requests).find(([, p]) => p.keyString === keyString);
|
|
517
|
+
if (!entry) return false;
|
|
518
|
+
const [id, p] = entry;
|
|
519
|
+
const choice = p.options[Number(m[1]) - 1];
|
|
520
|
+
if (!choice) return false;
|
|
521
|
+
delete rt.pending_requests[id];
|
|
522
|
+
effects.push({ type: 'respond', profile: p.profile, keyString, request_id: id, response: choice.response ?? choice });
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------- tick (expiry half, then §4.3's cron half)
|
|
527
|
+
|
|
528
|
+
function handleTick(state, ev, effects) {
|
|
529
|
+
for (const profile of Object.values(state.profiles)) {
|
|
530
|
+
for (const [ks, b] of Object.entries(profile.bindings)) {
|
|
531
|
+
if (b.ended_at || ks.includes('#')) continue;
|
|
532
|
+
const reason = expired(b, sessionReset(profile), ev.at);
|
|
533
|
+
if (reason) endBinding(state, profile.name, ks, reason, ev.at, effects);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// unanswered permission requests take the worker's default (deny unless it says allow) after its timeout
|
|
537
|
+
const rt = ensureRuntime(state);
|
|
538
|
+
for (const [id, p] of Object.entries(rt.pending_requests)) {
|
|
539
|
+
const worker = state.profiles[p.profile]?.worker;
|
|
540
|
+
const timeout = worker?.permission?.timeout_seconds ?? 300;
|
|
541
|
+
if ((Date.parse(ev.at) - Date.parse(p.asked_at)) / 1000 >= timeout) {
|
|
542
|
+
delete rt.pending_requests[id];
|
|
543
|
+
effects.push({ type: 'respond', profile: p.profile, keyString: p.keyString, request_id: id, response: permissionResponse(p.options, worker?.permission?.default === 'allow') });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
cronTick(state, ev, effects); // §4.3: due jobs, obligation retry, breakers
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---------------------------------------------------------------- restart / adapter / operator
|
|
550
|
+
|
|
551
|
+
// §4.7 on a clean stop: the daemon closes every runtime, so each binding that held one keeps it as the runtime to
|
|
552
|
+
// resume, as a crash's pass marks it, and the next line reopens the same session. A turn cut off mid-way is told so
|
|
553
|
+
// on its surface; an idle binding is not.
|
|
554
|
+
function handleStopping(state, ev, effects) {
|
|
555
|
+
const rt = ensureRuntime(state);
|
|
556
|
+
for (const profile of Object.values(state.profiles)) {
|
|
557
|
+
for (const [ks, b] of Object.entries(profile.bindings)) {
|
|
558
|
+
if (b.ended_at || ks.includes('#') || !b.residue?.runtime_id) continue;
|
|
559
|
+
b.residue.resume_runtime_id = b.residue.runtime_id;
|
|
560
|
+
delete b.residue.runtime_id;
|
|
561
|
+
if (rt.turns[leaseId(profile.name, ks)] && !b.recurrence && b.key.platform !== 'webhook' && b.key.platform !== 'cron') {
|
|
562
|
+
b.residue.interrupted_at = ev.at;
|
|
563
|
+
reply(state, profile, b.key, REPLY.interrupted, ev.at, effects);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
effects.push({ type: 'save' });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function handleRestart(state, ev, effects) {
|
|
571
|
+
const rt = ensureRuntime(state);
|
|
572
|
+
rt.leases = {}; rt.turns = {}; rt.queues = {}; rt.pending_requests = {};
|
|
573
|
+
let live = 0;
|
|
574
|
+
const replays = [];
|
|
575
|
+
for (const profile of Object.values(state.profiles)) {
|
|
576
|
+
// §4.7: every binding that held a lease when the daemon went away. The
|
|
577
|
+
// lease table itself is never persisted (§2.8); `residue.runtime_id`,
|
|
578
|
+
// written when the lease started, is the one fact that survived. The loop
|
|
579
|
+
// attaches where the worker's door allows it and marks the rest
|
|
580
|
+
// interrupted (`binding_interrupted` below).
|
|
581
|
+
for (const [ks, b] of Object.entries(profile.bindings)) {
|
|
582
|
+
if (b.ended_at || ks.includes('#') || !b.residue?.runtime_id) continue;
|
|
583
|
+
live += 1;
|
|
584
|
+
// a message arriving while the attach is in flight queues behind it,
|
|
585
|
+
// rather than starting a fresh runtime that would replace the session
|
|
586
|
+
ensureRuntime(state).starting[leaseId(profile.name, ks)] = true;
|
|
587
|
+
effects.push({ type: 'runtime_reattach', profile: profile.name, keyString: ks, runtime_id: b.residue.runtime_id, harness: b.worker.harness });
|
|
588
|
+
}
|
|
589
|
+
for (const o of profile.obligations) if (o.state === 'pending') replays.push({ type: 'deliver', profile: profile.name, obligation_id: o.id });
|
|
590
|
+
}
|
|
591
|
+
// every reattach runs before any replayed delivery: the loop stops an
|
|
592
|
+
// event's effects at the first that throws (a save), and a reattach left
|
|
593
|
+
// unrun would leave its surface marked starting with nothing to clear it
|
|
594
|
+
effects.push(...replays);
|
|
595
|
+
effects.push({ type: 'log', level: 'info', message: `restart at ${ev.at}: leases cleared, ${live} binding(s) to re-attach, pending obligations replayed` });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function handleAdapter(state, ev) {
|
|
599
|
+
const rt = ensureRuntime(state);
|
|
600
|
+
const a = rt.adapters[ev.platform] ??= { state: 'down', breaker: { failures: 0 } };
|
|
601
|
+
a.state = ev.state;
|
|
602
|
+
a.configured = ev.configured !== false;
|
|
603
|
+
if (ev.state === 'up') a.breaker = { failures: 0 };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Hermes's pairing store issued a code for a stranger: tell them, as Hermes's gateway does. */
|
|
607
|
+
export function handlePairingIssued(state, ev, effects) {
|
|
608
|
+
const owner = profileOf(state, ev.profile);
|
|
609
|
+
const text = ev.code ? REPLY.notPaired(ev.code, ev.key.platform, owner.name) : REPLY.pairingBusy;
|
|
610
|
+
reply(state, owner, ev.key, text, ev.at, effects);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function handleOperator(state, ev, effects) {
|
|
614
|
+
const profile = profileOf(state, ev.profile);
|
|
615
|
+
switch (ev.verb) {
|
|
616
|
+
case 'pair_approve': {
|
|
617
|
+
// Hermes's pairing store holds the code (hashed); the loop asks it, and a grant comes back as `access_granted`
|
|
618
|
+
effects.push({ type: 'access_write', profile: profile.name, op: 'approve', code: ev.args.code, platforms: Object.keys(profile.channels ?? {}) });
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
// what Hermes's store granted, mirrored into the loaded access
|
|
622
|
+
case 'access_granted': {
|
|
623
|
+
const { platform, user_id: userId } = ev.args;
|
|
624
|
+
profile.access.allowlist[platform] = [...new Set([...(profile.access.allowlist[platform] ?? []), userId])].sort();
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
// §2.4's access verbs: the operator CLI's `pair approve`, `access
|
|
628
|
+
// allow|revoke` and `access admin add|remove` are these four events.
|
|
629
|
+
case 'access_allow':
|
|
630
|
+
case 'access_revoke':
|
|
631
|
+
case 'admin_add':
|
|
632
|
+
case 'admin_remove': {
|
|
633
|
+
const { platform, user_id: userId } = ev.args;
|
|
634
|
+
if (typeof platform !== 'string' || !platform || typeof userId !== 'string' || !userId) {
|
|
635
|
+
effects.push({ type: 'log', level: 'warn', message: `${ev.verb}: platform and user_id are required` });
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
const list = ev.verb.startsWith('admin') ? 'admins' : 'allowlist';
|
|
639
|
+
const users = new Set(profile.access[list][platform] ?? []);
|
|
640
|
+
if (ev.verb === 'access_allow' || ev.verb === 'admin_add') users.add(userId); else users.delete(userId);
|
|
641
|
+
if (users.size) profile.access[list][platform] = [...users].sort();
|
|
642
|
+
else delete profile.access[list][platform];
|
|
643
|
+
// written where Hermes keeps it: `.env` allowlists, `config.yaml` admins (row 8)
|
|
644
|
+
effects.push({ type: 'access_write', profile: profile.name, op: ev.verb.replace('access_', ''), platform, user_id: userId });
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
// §4.7's other half: a live binding the loop could not re-attach to. The
|
|
648
|
+
// surface is told once, in one line, on the loop's next pass.
|
|
649
|
+
case 'binding_interrupted': {
|
|
650
|
+
const rt = ensureRuntime(state);
|
|
651
|
+
const lid = leaseId(profile.name, ev.args.keyString);
|
|
652
|
+
const b = liveBinding(profile, ev.args.keyString);
|
|
653
|
+
// only the binding the attach was for: a fire or /new may have replaced it meanwhile
|
|
654
|
+
if (!b || (ev.args.runtime_id && b.residue?.runtime_id !== ev.args.runtime_id)) {
|
|
655
|
+
if (!b) delete rt.starting[lid];
|
|
656
|
+
effects.push({ type: 'log', level: 'info', message: `binding_interrupted: ${ev.args.keyString} no longer holds ${ev.args.runtime_id ?? 'that runtime'}` });
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
b.residue.resume_runtime_id = b.residue.runtime_id;
|
|
660
|
+
delete b.residue.runtime_id;
|
|
661
|
+
b.residue.interrupted_at = ev.at;
|
|
662
|
+
// cron fires and webhook turns have no reader on their synthetic surface
|
|
663
|
+
if (!b.recurrence && b.key.platform !== 'webhook' && b.key.platform !== 'cron') {
|
|
664
|
+
reply(state, profile, b.key, REPLY.interrupted, ev.at, effects);
|
|
665
|
+
}
|
|
666
|
+
// the attach is over; what queued behind it gets a worker now (resumed where the harness can)
|
|
667
|
+
delete rt.starting[lid];
|
|
668
|
+
if (rt.queues[lid]?.length && profile.worker) startWorker(state, profile, ev.args.keyString, effects);
|
|
669
|
+
effects.push({ type: 'save' });
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
case 'sessions_new':
|
|
673
|
+
case 'sessions_reset': {
|
|
674
|
+
const keyString = ev.args.keyString;
|
|
675
|
+
endBinding(state, profile.name, keyString, ev.verb === 'sessions_new' ? 'new' : 'reset', ev.at, effects);
|
|
676
|
+
effects.push({ type: 'save' });
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
// Job verbs (§4.6): the same events the MCP tools, the daemon socket
|
|
680
|
+
// (ORC-13) and the operator CLI use. The caller builds a complete Job
|
|
681
|
+
// record (§2.6); the reducer only owns the state change.
|
|
682
|
+
case 'jobs_create': {
|
|
683
|
+
const job = ev.args.job;
|
|
684
|
+
if (!job || typeof job.id !== 'string' || !job.id) { effects.push({ type: 'log', level: 'warn', message: 'jobs_create: a job with an id is required' }); break; }
|
|
685
|
+
if (profile.jobs[job.id]) { effects.push({ type: 'log', level: 'warn', message: `jobs_create: job ${job.id} already exists` }); break; }
|
|
686
|
+
// a schedule nothing can evaluate would sit due on every tick (§4.3)
|
|
687
|
+
const broken = scheduleError(job.schedule) ?? modelError(profile.worker?.harness, job.model);
|
|
688
|
+
if (broken) { effects.push({ type: 'log', level: 'warn', message: `jobs_create: job ${job.id}: ${broken}` }); break; }
|
|
689
|
+
profile.jobs[job.id] = job;
|
|
690
|
+
effects.push({ type: 'save' });
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
case 'jobs_update': {
|
|
694
|
+
const job = profile.jobs[ev.args.id];
|
|
695
|
+
if (!job) { effects.push({ type: 'log', level: 'warn', message: `jobs_update: no job ${ev.args.id}` }); break; }
|
|
696
|
+
const broken = (ev.args.patch?.schedule ? scheduleError(ev.args.patch.schedule) : null) ?? modelError(profile.worker?.harness, ev.args.patch?.model);
|
|
697
|
+
if (broken) { effects.push({ type: 'log', level: 'warn', message: `jobs_update: job ${ev.args.id}: ${broken}` }); break; }
|
|
698
|
+
Object.assign(job, ev.args.patch ?? {});
|
|
699
|
+
effects.push({ type: 'save' });
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
case 'jobs_pause':
|
|
703
|
+
case 'jobs_resume': {
|
|
704
|
+
const job = profile.jobs[ev.args.id];
|
|
705
|
+
if (!job) { effects.push({ type: 'log', level: 'warn', message: `${ev.verb}: no job ${ev.args.id}` }); break; }
|
|
706
|
+
const broken = ev.verb === 'jobs_resume' ? scheduleError(job.schedule) : null;
|
|
707
|
+
if (broken) { effects.push({ type: 'log', level: 'warn', message: `jobs_resume: job ${ev.args.id} cannot run: ${broken}` }); break; }
|
|
708
|
+
job.enabled = ev.verb === 'jobs_resume';
|
|
709
|
+
if (job.enabled && !job.next_run_at) job.next_run_at = nextRunAt(job.schedule, ev.at, homeZone(profile));
|
|
710
|
+
effects.push({ type: 'save' });
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
case 'jobs_run': {
|
|
714
|
+
const job = profile.jobs[ev.args.id];
|
|
715
|
+
if (!job) { effects.push({ type: 'log', level: 'warn', message: `jobs_run: no job ${ev.args.id}` }); break; }
|
|
716
|
+
job.next_run_at = ev.at; // the next tick claims it (§4.3)
|
|
717
|
+
effects.push({ type: 'save' });
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
case 'jobs_delete': {
|
|
721
|
+
if (!profile.jobs[ev.args.id]) { effects.push({ type: 'log', level: 'warn', message: `jobs_delete: no job ${ev.args.id}` }); break; }
|
|
722
|
+
delete profile.jobs[ev.args.id];
|
|
723
|
+
effects.push({ type: 'save' });
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
case 'send_message': {
|
|
727
|
+
const key = ev.args.key;
|
|
728
|
+
if (!key || typeof key.platform !== 'string' || typeof ev.args.text !== 'string') { effects.push({ type: 'log', level: 'warn', message: 'send_message: key and text are required' }); break; }
|
|
729
|
+
reply(state, profile, key, ev.args.text, ev.at, effects);
|
|
730
|
+
// A turn that has spoken on its own surface through the tool has said what it meant there: what is left of
|
|
731
|
+
// its text at the end is narration about that, and is not delivered a second time.
|
|
732
|
+
const own = ensureRuntime(state).turns[leaseId(profile.name, surfaceKeyString(key))];
|
|
733
|
+
if (own) own.replied = true;
|
|
734
|
+
effects.push({ type: 'save' });
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
case 'session_handoff': {
|
|
738
|
+
const outcome = doHandoff(state, profile, ev.args.keyString, ev.args.to ?? null, ev.args.note ?? null, ev.at, effects);
|
|
739
|
+
if (!outcome.ok) effects.push({ type: 'log', level: 'warn', message: `session_handoff: ${outcome.message}` });
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
// Profile verbs (§4.6): a profile IS a folder, so `create` adds the record
|
|
743
|
+
// and lets `save` write the folder, and `delete` removes the record and
|
|
744
|
+
// asks the loop to move the folder aside — the reducer performs no I/O and
|
|
745
|
+
// nothing here ever unlinks anything.
|
|
746
|
+
case 'profiles_create': {
|
|
747
|
+
const made = ev.args.profile;
|
|
748
|
+
if (!made || typeof made.name !== 'string' || !made.name) { effects.push({ type: 'log', level: 'warn', message: 'profiles_create: a profile record with a name is required' }); break; }
|
|
749
|
+
if (state.profiles[made.name]) { effects.push({ type: 'log', level: 'warn', message: `profiles_create: profile ${made.name} already exists` }); break; }
|
|
750
|
+
state.profiles[made.name] = made;
|
|
751
|
+
effects.push({ type: 'save' });
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
case 'profiles_delete': {
|
|
755
|
+
const name = ev.args.name;
|
|
756
|
+
if (name === 'default') { effects.push({ type: 'log', level: 'warn', message: 'profiles_delete: the root folder IS the default profile; it is never removed' }); break; }
|
|
757
|
+
const doomed = state.profiles[name];
|
|
758
|
+
if (!doomed) { effects.push({ type: 'log', level: 'warn', message: `profiles_delete: no profile ${name}` }); break; }
|
|
759
|
+
delete state.profiles[name];
|
|
760
|
+
effects.push({ type: 'remove_profile_dir', profile: name, dir: doomed.dir });
|
|
761
|
+
effects.push({ type: 'save' });
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
default:
|
|
765
|
+
effects.push({ type: 'log', level: 'warn', message: `unknown operator verb ${ev.verb}` });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ---------------------------------------------------------------- step
|
|
770
|
+
|
|
771
|
+
export function step(state, event) {
|
|
772
|
+
const effects = [];
|
|
773
|
+
switch (event.type) {
|
|
774
|
+
case 'inbound':
|
|
775
|
+
if (!tryResolveRequest(state, event, effects)) handleInbound(state, event, effects);
|
|
776
|
+
break;
|
|
777
|
+
case 'worker': handleWorker(state, event, effects); break;
|
|
778
|
+
case 'tick': handleTick(state, event, effects); break;
|
|
779
|
+
case 'hosted_result': finishHostedFire(state, event, effects); break;
|
|
780
|
+
case 'gate_result': gateResult(state, event, effects); break;
|
|
781
|
+
case 'webhook': handleWebhook(state, event, effects); break;
|
|
782
|
+
case 'restart': handleRestart(state, event, effects); break;
|
|
783
|
+
case 'stopping': handleStopping(state, event, effects); break;
|
|
784
|
+
case 'adapter': handleAdapter(state, event); break;
|
|
785
|
+
case 'operator': handleOperator(state, event, effects); break;
|
|
786
|
+
case 'pairing_issued': handlePairingIssued(state, event, effects); break;
|
|
787
|
+
default: throw new Error(`unknown event type ${event.type}`);
|
|
788
|
+
}
|
|
789
|
+
return effects;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export { leaseId };
|