@volter/supercode-orchestrator 0.5.205 → 0.5.207
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 +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +278 -97
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +305 -80
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +92 -27
- package/board/files.mjs +17 -3
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +8 -2
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/notices.mjs +5 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +84 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +96 -31
- package/board/ztrack.mjs +207 -32
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Fleet facts are mail obligations, independent of card transitions and dispatch ticks. Intent comes only from the
|
|
2
|
+
// canonical workflow IR. The sender supplies observations from its admitted sources; this router grants nothing.
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { chmodSync, mkdirSync, existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
const KINDS = new Set(['session_started', 'session_resumed', 'session_ended', 'duplicate_session_process', 'card_waiting_on_user', 'machine_health_crit']);
|
|
9
|
+
const ADDRESS = /^sc:[^:\s]+:[^:\s]+:[^:\s]+$/;
|
|
10
|
+
const MACHINE = /^[a-z0-9_-]+$/;
|
|
11
|
+
const MAX_PENDING = 4096, MAX_PENDING_BYTES = 16 * 1024 * 1024, MAX_EVENT_BYTES = 64 * 1024;
|
|
12
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
const refused = message => Object.assign(new Error(message), { code: 'fleet_listener_refused' });
|
|
14
|
+
|
|
15
|
+
/** A retained mail outbox. `file` is the board's existing admitted mailbox filing door, never a transport or
|
|
16
|
+
* provider selected from the observed payload. No runtime database is opened until the home opts into listeners. */
|
|
17
|
+
export class FleetListenerRouter {
|
|
18
|
+
constructor({ root, file, enrich = event => event, retry = () => {}, log = () => {} }) {
|
|
19
|
+
if (typeof file !== 'function') throw refused('fleet listener router needs the admitted mailbox filing door');
|
|
20
|
+
this.root = root; this.file = file; this.enrich = enrich; this.retry = retry; this.log = log; this.definitions = {}; this.closed = false; this.suspended = false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The workflow's decoded declarations. An invalid replacement refuses without discarding the prior outbox. */
|
|
24
|
+
configure(definitions = {}) {
|
|
25
|
+
if (this.closed) throw refused('fleet listener router is closed');
|
|
26
|
+
if (!definitions || typeof definitions !== 'object' || Array.isArray(definitions)) throw refused('listeners must be a role map');
|
|
27
|
+
const next = Object.create(null);
|
|
28
|
+
for (const [role, declaration] of Object.entries(definitions)) {
|
|
29
|
+
if (!role || !declaration || Object.keys(declaration).some(key => !['events', 'addresses', 'machines'].includes(key))) throw refused(`invalid fleet listener ${role}`);
|
|
30
|
+
const { events, addresses, machines } = declaration;
|
|
31
|
+
if (!Array.isArray(events) || !events.length || events.some(kind => !KINDS.has(kind))) throw refused(`fleet listener ${role} names unsupported events`);
|
|
32
|
+
if (!Array.isArray(addresses) || !addresses.length || addresses.some(address => typeof address !== 'string' || !ADDRESS.test(address) || /[\x00-\x1f\x7f]/.test(address))) throw refused(`fleet listener ${role} needs full mail addresses`);
|
|
33
|
+
if (machines != null && (!Array.isArray(machines) || machines.some(machine => typeof machine !== 'string' || !MACHINE.test(machine)))) throw refused(`fleet listener ${role} needs normalized machine names`);
|
|
34
|
+
next[role] = { events: [...new Set(events)], addresses: [...new Set(addresses)], machines: machines == null ? null : [...new Set(machines)] };
|
|
35
|
+
}
|
|
36
|
+
this.definitions = next;
|
|
37
|
+
this.suspended = false;
|
|
38
|
+
if (Object.keys(next).length) {
|
|
39
|
+
this.store();
|
|
40
|
+
if (!this.timer) { this.timer = setInterval(() => { if (!this.suspended) { try { this.retry(); } catch (error) { this.log(`fleet listener source: ${String(error.message).slice(0, 300)}`); } } this.wake(); }, 1000); this.timer.unref?.(); }
|
|
41
|
+
this.wake();
|
|
42
|
+
} else if (this.db) {
|
|
43
|
+
this.db.prepare('INSERT INTO fleet_listener_runtime(id,needs_baseline) VALUES(1,1) ON CONFLICT(id) DO UPDATE SET needs_baseline=1').run();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
suspend() { this.suspended = true; }
|
|
48
|
+
get enabled() { return !this.closed && !this.suspended && Object.keys(this.definitions).length > 0; }
|
|
49
|
+
|
|
50
|
+
store() {
|
|
51
|
+
if (this.db) return this.db;
|
|
52
|
+
const directory = join(this.root, 'runtime');
|
|
53
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
54
|
+
const path = join(directory, 'fleet-listeners.sqlite');
|
|
55
|
+
const db = new DatabaseSync(path);
|
|
56
|
+
try {
|
|
57
|
+
chmodSync(path, 0o600);
|
|
58
|
+
db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
59
|
+
CREATE TABLE IF NOT EXISTS fleet_listener_mail (
|
|
60
|
+
id TEXT PRIMARY KEY, role TEXT NOT NULL, address TEXT NOT NULL,
|
|
61
|
+
kind TEXT NOT NULL, machine TEXT NOT NULL, digest TEXT NOT NULL,
|
|
62
|
+
body TEXT, bytes INTEGER NOT NULL, state TEXT NOT NULL,
|
|
63
|
+
created_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
next_at INTEGER NOT NULL DEFAULT 0, error TEXT, message_id TEXT, filed_at INTEGER
|
|
65
|
+
) STRICT;
|
|
66
|
+
CREATE INDEX IF NOT EXISTS fleet_listener_due ON fleet_listener_mail(state,next_at,created_at,id);
|
|
67
|
+
CREATE TABLE IF NOT EXISTS fleet_listener_sources (
|
|
68
|
+
source TEXT PRIMARY KEY, sequence INTEGER NOT NULL, baseline INTEGER NOT NULL, error TEXT,
|
|
69
|
+
gaps INTEGER NOT NULL DEFAULT 0, last_gap TEXT, updated_at INTEGER NOT NULL
|
|
70
|
+
) STRICT;
|
|
71
|
+
CREATE TABLE IF NOT EXISTS fleet_listener_waits (
|
|
72
|
+
address TEXT PRIMARY KEY, active INTEGER NOT NULL, wait_key TEXT, event TEXT, routed INTEGER NOT NULL DEFAULT 0
|
|
73
|
+
) STRICT;
|
|
74
|
+
CREATE TABLE IF NOT EXISTS fleet_listener_runtime (id INTEGER PRIMARY KEY CHECK(id=1), needs_baseline INTEGER NOT NULL) STRICT;`);
|
|
75
|
+
} catch (error) { db.close(); throw error; }
|
|
76
|
+
this.db = db;
|
|
77
|
+
return db;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
matches(role, event, address) {
|
|
81
|
+
const declaration = this.definitions[role];
|
|
82
|
+
return Boolean(declaration?.events.includes(event.kind) && declaration.addresses.includes(address)
|
|
83
|
+
&& (declaration.machines == null || declaration.machines.includes(event.machine)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Commit each matched obligation before a source advances its own cursor. A full outbox or a conflicting replay
|
|
87
|
+
* throws; the source must retain/retry that event rather than mark it consumed. IDs come from the source's retained
|
|
88
|
+
* observation, never from this process's current time. The same event/role/address keeps the same filing id. */
|
|
89
|
+
enqueue(event) {
|
|
90
|
+
if (this.closed) throw refused('fleet listener router is closed');
|
|
91
|
+
if (this.suspended) throw refused('fleet listener configuration is not current');
|
|
92
|
+
if (!Object.keys(this.definitions).length) return { queued: 0 };
|
|
93
|
+
if (!event || !KINDS.has(event.kind) || typeof event.source !== 'string' || !event.source
|
|
94
|
+
|| typeof event.id !== 'string' || !event.id || typeof event.machine !== 'string' || !MACHINE.test(event.machine)
|
|
95
|
+
|| !Number.isSafeInteger(event.observed_at_ms) || event.observed_at_ms < 0) throw refused('fleet event needs its kind, source, retained id, machine and observation time');
|
|
96
|
+
const original = JSON.stringify(event), hash = digest(original);
|
|
97
|
+
const body = JSON.stringify(this.enrich(event)), bytes = Buffer.byteLength(body);
|
|
98
|
+
if (bytes > MAX_EVENT_BYTES) throw refused('fleet event exceeds 64 KiB; source observation remains owed');
|
|
99
|
+
const db = this.store();
|
|
100
|
+
let queued = 0;
|
|
101
|
+
db.exec('BEGIN IMMEDIATE');
|
|
102
|
+
try {
|
|
103
|
+
const held = db.prepare("SELECT COUNT(*) AS count,COALESCE(SUM(bytes),0) AS bytes FROM fleet_listener_mail WHERE state='pending'").get();
|
|
104
|
+
for (const [role, declaration] of Object.entries(this.definitions)) for (const address of declaration.addresses) {
|
|
105
|
+
if (!this.matches(role, event, address)) continue;
|
|
106
|
+
const id = `fleet-${digest(JSON.stringify([event.source, event.id, role, address]))}`;
|
|
107
|
+
const previous = db.prepare('SELECT digest FROM fleet_listener_mail WHERE id=?').get(id);
|
|
108
|
+
if (previous) { if (previous.digest !== hash) throw refused('fleet event id was replayed with different contents'); continue; }
|
|
109
|
+
if (held.count + queued >= MAX_PENDING || held.bytes + (queued + 1) * bytes > MAX_PENDING_BYTES) throw refused('fleet listener outbox full; source observation remains owed');
|
|
110
|
+
db.prepare("INSERT INTO fleet_listener_mail(id,role,address,kind,machine,digest,body,bytes,state,created_at) VALUES(?,?,?,?,?,?,?,?, 'pending',?)")
|
|
111
|
+
.run(id, role, address, event.kind, event.machine, hash, body, bytes, Date.now());
|
|
112
|
+
queued++;
|
|
113
|
+
}
|
|
114
|
+
db.exec('COMMIT');
|
|
115
|
+
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
116
|
+
this.wake();
|
|
117
|
+
return { queued };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Existing machines establish a baseline on first enrollment (or after all listeners were explicitly removed).
|
|
121
|
+
* An ordinary owner restart preserves its cursors. Machines first seen after enrollment offer their retained window. */
|
|
122
|
+
bootstrap(records) {
|
|
123
|
+
if (!this.enabled) return;
|
|
124
|
+
const db = this.store(), runtime = db.prepare('SELECT needs_baseline FROM fleet_listener_runtime WHERE id=1').get();
|
|
125
|
+
if (runtime?.needs_baseline === 0) return;
|
|
126
|
+
db.exec('BEGIN IMMEDIATE');
|
|
127
|
+
try {
|
|
128
|
+
for (const [machine, held] of Object.entries(records)) {
|
|
129
|
+
for (const [section, prefix] of [['fleet', 'machine-fleet'], ['health_events', 'machine-health']]) {
|
|
130
|
+
const fleet = ['self', 'open'].includes(held.stream) && !held.record?.unread?.[section] ? held.record?.[section] : null;
|
|
131
|
+
if (!fleet || typeof fleet.source !== 'string' || !fleet.source.startsWith(`${prefix}:${machine}:`) || !Number.isSafeInteger(fleet.last) || fleet.last < 0) continue;
|
|
132
|
+
db.prepare(`INSERT INTO fleet_listener_sources(source,sequence,baseline,updated_at) VALUES(?,?,?,?)
|
|
133
|
+
ON CONFLICT(source) DO UPDATE SET sequence=excluded.sequence,baseline=excluded.baseline,error=NULL,updated_at=excluded.updated_at`).run(fleet.source, fleet.last, fleet.last, Date.now());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
db.prepare('INSERT INTO fleet_listener_runtime(id,needs_baseline) VALUES(1,0) ON CONFLICT(id) DO UPDATE SET needs_baseline=0').run();
|
|
137
|
+
db.exec('COMMIT');
|
|
138
|
+
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A source cursor moves only after every matched filing obligation is committed. Refusal retains the event and
|
|
142
|
+
* cursor for retry. A new machine starts with the first observation still available in its retained window. */
|
|
143
|
+
baseline(source, head) {
|
|
144
|
+
if (!this.enabled) return null;
|
|
145
|
+
const db = this.store();
|
|
146
|
+
if (typeof source !== 'string' || !source || !Number.isSafeInteger(head) || head < 0) throw refused('invalid fleet source cursor');
|
|
147
|
+
db.prepare('INSERT INTO fleet_listener_sources(source,sequence,baseline,updated_at) VALUES(?,?,?,?) ON CONFLICT(source) DO NOTHING').run(source, head, head, Date.now());
|
|
148
|
+
return db.prepare('SELECT * FROM fleet_listener_sources WHERE source=?').get(source);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
consume(source, sequence, event = null) {
|
|
152
|
+
if (!this.enabled) return false;
|
|
153
|
+
const held = this.db?.prepare('SELECT sequence FROM fleet_listener_sources WHERE source=?').get(source);
|
|
154
|
+
if (!held || !Number.isSafeInteger(sequence) || sequence <= held.sequence) return false;
|
|
155
|
+
try {
|
|
156
|
+
if (event) this.enqueue(event);
|
|
157
|
+
this.db.prepare('UPDATE fleet_listener_sources SET sequence=?,error=NULL,updated_at=? WHERE source=?').run(sequence, Date.now(), source);
|
|
158
|
+
return true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.db.prepare('UPDATE fleet_listener_sources SET error=?,updated_at=? WHERE source=?').run(String(error.message).slice(0, 500), Date.now(), source);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The machine's bounded sequence says precisely which older observations it cannot replay. Keep that gap as
|
|
166
|
+
* unknown in the status and log, then continue the retained window; never claim the missing events were filed. */
|
|
167
|
+
follow({ source, first, last, events }) {
|
|
168
|
+
if (!this.enabled) return;
|
|
169
|
+
if (!Number.isSafeInteger(first) || first < 1 || !Number.isSafeInteger(last) || last < first - 1 || !Array.isArray(events)
|
|
170
|
+
|| events.length !== last - first + 1 || events.some((event, at) => event.source !== source || event.sequence !== first + at || event.id !== String(event.sequence))) throw refused('fleet source retained window is not contiguous');
|
|
171
|
+
let held = this.baseline(source, first - 1);
|
|
172
|
+
if (held.sequence > last) throw refused('fleet source head moved backwards');
|
|
173
|
+
if (held.sequence < first - 1) {
|
|
174
|
+
const gap = `upstream history pruned observations ${held.sequence + 1}..${first - 1}; their content and delivery remain unknown`;
|
|
175
|
+
this.db.prepare('UPDATE fleet_listener_sources SET sequence=?,gaps=gaps+1,last_gap=?,updated_at=? WHERE source=?').run(first - 1, gap, Date.now(), source);
|
|
176
|
+
this.log(`fleet listener ${source}: ${gap}`);
|
|
177
|
+
held = { ...held, sequence: first - 1 };
|
|
178
|
+
}
|
|
179
|
+
let count = 0;
|
|
180
|
+
for (const event of events) if (event.sequence > held.sequence) {
|
|
181
|
+
this.consume(source, event.sequence, event);
|
|
182
|
+
if (++count >= 64) break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The prompt's retained episode, independent of card-dispatch rounds. Persist its payload/id before enqueue so a
|
|
187
|
+
* lost answer or outbox refusal retries the same observation. Only a fresh native idle/working reading clears it. */
|
|
188
|
+
cardWait(address, observation) {
|
|
189
|
+
if (!this.enabled) return;
|
|
190
|
+
const db = this.store();
|
|
191
|
+
if (!observation) { db.prepare('UPDATE fleet_listener_waits SET active=0 WHERE address=?').run(address); return; }
|
|
192
|
+
const key = JSON.stringify([observation.waiting_for, observation.since_ms]);
|
|
193
|
+
let held = db.prepare('SELECT * FROM fleet_listener_waits WHERE address=?').get(address);
|
|
194
|
+
if (!held?.active || held.wait_key !== key) {
|
|
195
|
+
// A previous episode whose filing was refused is still owed before a replacement can take its slot.
|
|
196
|
+
if (held?.event && !held.routed) this.enqueue(JSON.parse(held.event));
|
|
197
|
+
const event = { source: `card-wait:${this.root}`, id: randomUUID(), ...observation };
|
|
198
|
+
db.prepare(`INSERT INTO fleet_listener_waits(address,active,wait_key,event,routed) VALUES(?,1,?,?,0)
|
|
199
|
+
ON CONFLICT(address) DO UPDATE SET active=1,wait_key=excluded.wait_key,event=excluded.event,routed=0`).run(address, key, JSON.stringify(event));
|
|
200
|
+
held = { event: JSON.stringify(event), routed: 0 };
|
|
201
|
+
}
|
|
202
|
+
if (!held.routed) {
|
|
203
|
+
this.enqueue(JSON.parse(held.event));
|
|
204
|
+
db.prepare('UPDATE fleet_listener_waits SET routed=1 WHERE address=?').run(address);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
wake() { if (!this.closed) void this.flush().catch(error => this.log(`fleet listener filing: ${String(error.message).slice(0, 300)}`)); }
|
|
209
|
+
|
|
210
|
+
/** A lost filing answer leaves the obligation pending. Retrying its stable id goes through the mailbox's own
|
|
211
|
+
* idempotent filing. This receipt proves filing only; native turn delivery remains the mailbox's responsibility. */
|
|
212
|
+
flush() {
|
|
213
|
+
if (this.flushing) return this.flushing;
|
|
214
|
+
if (!this.db || this.closed || this.suspended) return Promise.resolve();
|
|
215
|
+
this.flushing = Promise.resolve().then(async () => {
|
|
216
|
+
// Even a prompt that has since cleared keeps an earlier refused observation. Do not block already queued mail
|
|
217
|
+
// on a full outbox: file it below to make room, then the retained prompt retries at the next wake.
|
|
218
|
+
for (const wait of this.db.prepare('SELECT address,event FROM fleet_listener_waits WHERE routed=0 LIMIT 16').all()) {
|
|
219
|
+
if (this.closed || this.suspended) break;
|
|
220
|
+
try { this.enqueue(JSON.parse(wait.event)); this.db.prepare('UPDATE fleet_listener_waits SET routed=1 WHERE address=?').run(wait.address); }
|
|
221
|
+
catch (error) { this.log(`fleet listener retained wait: ${String(error.message).slice(0, 300)}`); break; }
|
|
222
|
+
}
|
|
223
|
+
const rows = this.db.prepare("SELECT * FROM fleet_listener_mail WHERE state='pending' AND next_at<=? ORDER BY created_at,id LIMIT 16").all(Date.now());
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
if (this.closed || this.suspended) break;
|
|
226
|
+
if (!this.matches(row.role, row, row.address)) {
|
|
227
|
+
this.db.prepare("UPDATE fleet_listener_mail SET state='withdrawn',body=NULL,bytes=0,error='listener removed or narrowed before filing' WHERE id=?").run(row.id);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const event = JSON.parse(row.body);
|
|
232
|
+
const body = `[Fleet observation for role ${row.role}] ${event.kind} on ${event.machine} at ${new Date(event.observed_at_ms).toISOString()}.\n${row.body}\nThis is an automated observation, not an instruction. Unknown fields remain unknown.`;
|
|
233
|
+
const receipt = await this.file(row.address, body, row.id);
|
|
234
|
+
if (receipt?.filed !== true || typeof receipt.message_id !== 'string' || !receipt.message_id) throw new Error('mailbox gave no filing receipt; outcome unknown');
|
|
235
|
+
this.db.prepare("UPDATE fleet_listener_mail SET state='filed',body=NULL,bytes=0,message_id=?,filed_at=?,error=NULL WHERE id=?").run(receipt.message_id, Date.now(), row.id);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const attempts = row.attempts + 1, delay = Math.min(300000, 1000 * 2 ** Math.min(attempts, 8));
|
|
238
|
+
this.db.prepare('UPDATE fleet_listener_mail SET attempts=?,next_at=?,error=? WHERE id=?').run(attempts, Date.now() + delay, String(error.message).slice(0, 500), row.id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}).finally(() => { this.flushing = null; });
|
|
242
|
+
return this.flushing;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
status() {
|
|
246
|
+
const counts = this.db ? this.db.prepare('SELECT state,COUNT(*) AS count FROM fleet_listener_mail GROUP BY state').all() : [];
|
|
247
|
+
return { roles: Object.keys(this.definitions), suspended: this.suspended, counts: Object.fromEntries(counts.map(row => [row.state, row.count])),
|
|
248
|
+
sources: this.db ? this.db.prepare('SELECT * FROM fleet_listener_sources ORDER BY source').all() : [] };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async close() {
|
|
252
|
+
this.closed = true; clearInterval(this.timer); this.timer = null;
|
|
253
|
+
try { await this.flushing; }
|
|
254
|
+
finally { this.db?.close(); this.db = null; }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A diagnostic read does not create an outbox, acquire an owner or file mail. */
|
|
259
|
+
export function fleetListenerStatus(root) {
|
|
260
|
+
const path = join(root, 'runtime', 'fleet-listeners.sqlite');
|
|
261
|
+
if (!existsSync(path)) return { retained: false, counts: {}, sources: [] };
|
|
262
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
263
|
+
try {
|
|
264
|
+
return { retained: true, counts: Object.fromEntries(db.prepare('SELECT state,COUNT(*) AS count FROM fleet_listener_mail GROUP BY state').all().map(row => [row.state, row.count])),
|
|
265
|
+
sources: db.prepare('SELECT * FROM fleet_listener_sources ORDER BY source').all(),
|
|
266
|
+
unrouted_waits: db.prepare('SELECT COUNT(*) AS count FROM fleet_listener_waits WHERE routed=0').get().count };
|
|
267
|
+
} finally { db.close(); }
|
|
268
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// A board owner's read side over admitted, committed machine records. The machine emits facts; this home supplies
|
|
2
|
+
// role intent and its own card associations. No actor identity or authority is inferred from an observed payload.
|
|
3
|
+
import { listBoards, openBoard, view } from './store.mjs';
|
|
4
|
+
|
|
5
|
+
/** Associations in this home's current runs, observed when a notice is retained. Other homes' cards are unknown. */
|
|
6
|
+
export function fleetCards(root, addresses) {
|
|
7
|
+
const cards = [];
|
|
8
|
+
for (const slug of listBoards(root)) {
|
|
9
|
+
const board = openBoard(root, slug);
|
|
10
|
+
try {
|
|
11
|
+
for (const address of addresses) {
|
|
12
|
+
const [, machine, harness, sessionId] = address.split(':');
|
|
13
|
+
const rows = view(board, db => db.prepare(`SELECT t.id,r.id AS run FROM tasks t JOIN task_runs r ON r.id=t.current_run_id
|
|
14
|
+
WHERE json_extract(r.metadata,'$.supercode.machine')=? AND json_extract(r.metadata,'$.supercode.harness')=?
|
|
15
|
+
AND json_extract(r.metadata,'$.supercode.session_id')=? LIMIT 65`).all(machine, harness, sessionId));
|
|
16
|
+
if (rows.length > 64 || cards.length + rows.length > 256) throw new Error('fleet card associations exceed the retained notice bound');
|
|
17
|
+
cards.push(...rows.map(row => ({ board: slug, card: row.id, run: row.run, address })));
|
|
18
|
+
}
|
|
19
|
+
} finally { board.close(); }
|
|
20
|
+
}
|
|
21
|
+
return cards;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function enrichFleetEvent(root, event) {
|
|
25
|
+
if (event.card_associations) return event;
|
|
26
|
+
const addresses = [...new Set([event.session?.address, event.address, ...(event.sessions ?? []).map(row => row.address)].filter(Boolean))];
|
|
27
|
+
if (!addresses.length) return event;
|
|
28
|
+
let cards = null, unread = null;
|
|
29
|
+
try { cards = fleetCards(root, addresses); } catch (error) { unread = String(error.message).slice(0, 300); }
|
|
30
|
+
return { ...event, card_associations: { observed_at_ms: Date.now(), basis: 'current native session bindings in this owner home', cards, ...(unread ? { unread } : {}), other_homes: 'unknown' } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function observeRoleFleet(root, router, records, log = () => {}, { waiting = true } = {}) {
|
|
34
|
+
if (!router.enabled) return;
|
|
35
|
+
for (const [machine, held] of Object.entries(records ?? {})) {
|
|
36
|
+
if (!['self', 'open'].includes(held.stream) || !held.record) continue;
|
|
37
|
+
const record = held.record;
|
|
38
|
+
for (const [section, prefix, kinds] of [
|
|
39
|
+
['fleet', 'machine-fleet', ['session_started', 'session_resumed', 'session_ended', 'duplicate_session_process']],
|
|
40
|
+
['health_events', 'machine-health', ['machine_health_crit']],
|
|
41
|
+
]) if (record[section] && !record.unread?.[section]) {
|
|
42
|
+
try {
|
|
43
|
+
const source = record[section];
|
|
44
|
+
if (typeof source.source !== 'string' || !source.source.startsWith(`${prefix}:${machine}:`)) throw new Error('fleet source is not scoped to its admitted machine and grant section');
|
|
45
|
+
if (source.events.some(event => event.machine !== machine || !kinds.includes(event.kind))) throw new Error('fleet event differs from its admitted machine or grant section');
|
|
46
|
+
router.follow(source);
|
|
47
|
+
} catch (error) { log(`fleet listener source ${machine}: ${String(error.message).slice(0, 300)}`); }
|
|
48
|
+
}
|
|
49
|
+
if (!waiting) continue;
|
|
50
|
+
// The existing pane activity grant is still required (operator plus whole-machine view on a peer). A lost index,
|
|
51
|
+
// unread activity or absent record never clears a wait. Waiting on a live native session is not inferred from idle.
|
|
52
|
+
if (record.unread?.panes || record.unread?.sessions_index || record.unread?.sessions_index_parts
|
|
53
|
+
|| record.unread?.sessions_activity || record.unread?.claude_registry_watch || record.panes?.activities_omitted || record.panes?.sessions_unread) continue;
|
|
54
|
+
const grouped = new Map();
|
|
55
|
+
for (const { pid, activity } of record.panes?.activities ?? []) {
|
|
56
|
+
if (activity?.presence !== 'running' || !activity.harness || !activity.session_id
|
|
57
|
+
|| !(record.panes.panes ?? []).some(pane => pane.pid === pid && !pane.dead)) continue;
|
|
58
|
+
const address = `sc:${machine}:${activity.harness}:${activity.session_id}`;
|
|
59
|
+
const rows = grouped.get(address) ?? []; rows.push(activity); grouped.set(address, rows);
|
|
60
|
+
}
|
|
61
|
+
for (const [address, activities] of grouped) {
|
|
62
|
+
try {
|
|
63
|
+
const asked = activities.find(activity => activity.turn === 'needs_input');
|
|
64
|
+
if (!asked) {
|
|
65
|
+
if (activities.every(activity => ['idle', 'working'].includes(activity.turn))) router.cardWait(address, null);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const cards = fleetCards(root, [address]);
|
|
69
|
+
if (!cards.length) continue;
|
|
70
|
+
const evidence = asked.evidence ?? {};
|
|
71
|
+
router.cardWait(address, { kind: 'card_waiting_on_user', machine,
|
|
72
|
+
observed_at_ms: Number.isSafeInteger(evidence.observed_at_ms) ? evidence.observed_at_ms : record.at,
|
|
73
|
+
address, harness: asked.harness, session_id: asked.session_id, waiting_for: evidence.waiting_for ?? null,
|
|
74
|
+
since_ms: evidence.native_state_since_ms ?? null, native_state: evidence.native_state ?? null, native_source: evidence.source ?? null,
|
|
75
|
+
card_associations: { observed_at_ms: Date.now(), basis: 'current native session bindings in this owner home', cards, other_homes: 'unknown' } });
|
|
76
|
+
} catch (error) { log(`fleet listener card wait ${address}: ${String(error.message).slice(0, 300)}`); }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The machine universe must be complete before a fleet reading can decide presence.
|
|
2
|
+
// Bound candidates before applying live/platform predicates: an all-inactive board still yields.
|
|
3
|
+
import { setImmediate as nextTurn } from 'node:timers/promises';
|
|
4
|
+
import { listBoards, openBoard } from './store.mjs';
|
|
5
|
+
import { LIST_BATCH, TASK_SUMMARY_INDEX, listRevision } from './listing.mjs';
|
|
6
|
+
|
|
7
|
+
const sameRevision = (a, b) => Object.keys(a).every(key => a[key] === b[key]);
|
|
8
|
+
const rowPage = (db, table, fields, ceiling, after) => db.prepare(`SELECT rowid AS candidate_id, ${fields} FROM ${table}
|
|
9
|
+
WHERE rowid <= ?${after === null ? '' : ' AND rowid > ?'} ORDER BY rowid LIMIT ${LIST_BATCH}`)
|
|
10
|
+
.all(ceiling, ...(after === null ? [] : [after]));
|
|
11
|
+
|
|
12
|
+
async function addBoardMachines(board, machines, normalise, unchanged) {
|
|
13
|
+
const db = board.db;
|
|
14
|
+
// Card rowids are indexed even in a TEMP document overlay, where supercode_cards has no task-id index.
|
|
15
|
+
// Tasks have the listing's covering ID index in both main and TEMP projections.
|
|
16
|
+
const ceiling = db.prepare('SELECT MAX(rowid) AS id FROM supercode_cards').get().id;
|
|
17
|
+
const liveCards = new Set();
|
|
18
|
+
let cursor = null;
|
|
19
|
+
while (ceiling !== null && (cursor === null || cursor < ceiling)) {
|
|
20
|
+
if (!unchanged()) return false;
|
|
21
|
+
const rows = rowPage(db, 'supercode_cards', 'task_id, machine, NOT COALESCE(archived, 0) AS visible', ceiling, cursor);
|
|
22
|
+
if (!rows.length) break;
|
|
23
|
+
const active = new Set(db.prepare(`SELECT id FROM tasks INDEXED BY ${TASK_SUMMARY_INDEX}
|
|
24
|
+
WHERE id IN (SELECT value FROM json_each(?)) AND status NOT IN ('done', 'cancelled', 'archived')`)
|
|
25
|
+
.all(JSON.stringify(rows.map(row => row.task_id))).map(row => row.id));
|
|
26
|
+
for (const row of rows) {
|
|
27
|
+
if (!row.visible || !active.has(row.task_id)) continue;
|
|
28
|
+
liveCards.add(row.task_id);
|
|
29
|
+
if (row.machine !== null && row.machine !== '') machines.add(normalise(row.machine));
|
|
30
|
+
}
|
|
31
|
+
cursor = rows.at(-1).candidate_id;
|
|
32
|
+
await nextTurn();
|
|
33
|
+
}
|
|
34
|
+
if (!unchanged()) return false;
|
|
35
|
+
// Subscriptions have an ordinary SQLite rowid in every board backing. Page all rows before filtering,
|
|
36
|
+
// rather than paging matching subscriptions with an unbounded search through nonmatching rows.
|
|
37
|
+
const last = db.prepare('SELECT MAX(rowid) AS id FROM kanban_notify_subs').get().id;
|
|
38
|
+
let after = null;
|
|
39
|
+
while (last !== null && (after === null || after < last)) {
|
|
40
|
+
if (!unchanged()) return false;
|
|
41
|
+
const rows = rowPage(db, 'kanban_notify_subs', "task_id, chat_id, platform = 'supercode' AND chat_id LIKE 'sc:%' AS supercode_address", last, after);
|
|
42
|
+
if (!rows.length) break;
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
if (liveCards.has(row.task_id) && row.supercode_address) machines.add(normalise(row.chat_id.split(':')[1]));
|
|
45
|
+
}
|
|
46
|
+
after = rows.at(-1).candidate_id;
|
|
47
|
+
await nextTurn();
|
|
48
|
+
}
|
|
49
|
+
return unchanged();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A stable union across all boards; no read transaction or native cursor survives a yield. */
|
|
53
|
+
export async function homeFleetMachines(root, normalise) {
|
|
54
|
+
if (!root) return new Set();
|
|
55
|
+
try {
|
|
56
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
57
|
+
const slugs = listBoards(root).sort(), opened = [];
|
|
58
|
+
try {
|
|
59
|
+
for (const slug of slugs) {
|
|
60
|
+
const board = openBoard(root, slug);
|
|
61
|
+
const held = { board };
|
|
62
|
+
opened.push(held);
|
|
63
|
+
held.revision = listRevision(board.db);
|
|
64
|
+
}
|
|
65
|
+
const unchanged = () => opened.every(({ board, revision }) => sameRevision(revision, listRevision(board.db)));
|
|
66
|
+
const machines = new Set();
|
|
67
|
+
let stable = true;
|
|
68
|
+
for (const { board } of opened) {
|
|
69
|
+
if (!await addBoardMachines(board, machines, normalise, unchanged)) { stable = false; break; }
|
|
70
|
+
await nextTurn();
|
|
71
|
+
}
|
|
72
|
+
if (stable && unchanged() && JSON.stringify(slugs) === JSON.stringify(listBoards(root).sort())) return machines;
|
|
73
|
+
} finally { for (const { board } of opened) board.close(); }
|
|
74
|
+
await nextTurn();
|
|
75
|
+
}
|
|
76
|
+
throw new Error('The home changed during its fleet-target reading; retry the reading.');
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// Do not pass an incomplete machine set to readMachineFleet, or publish presence from it.
|
|
79
|
+
error.fleetTargetsUnread = true;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/board/incidents.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Service supervision retains evidence; only a manager/creator act releases dispatch.
|
|
2
2
|
import { event, json, now } from './store.mjs';
|
|
3
|
+
import { detachedCard, detachedSession } from './custody.mjs';
|
|
3
4
|
import { statusOf, creatorOf, requestSessionClose } from './workflow.mjs';
|
|
4
5
|
|
|
5
6
|
export const recoversRuns = entry => entry?.params?.run_failure_policy === 'recover';
|
|
@@ -19,6 +20,7 @@ export function retainRecovery(db, id, name, evidence = {}, runId = null) {
|
|
|
19
20
|
if (!row || row.ended_at != null) return;
|
|
20
21
|
if (db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='run_incident'").get(id, runId)) return;
|
|
21
22
|
const session = json(row.metadata, {})?.supercode ?? {};
|
|
23
|
+
if (detachedCard(db, id) || detachedSession(db, session)) return;
|
|
22
24
|
const reason = evidence.error ?? `run ${runId} needs continuation (${name})`;
|
|
23
25
|
const payload = { observation: name, run: runId, reason, policy: 'recover',
|
|
24
26
|
session: session.session_id ?? null, machine: session.machine ?? null, pane: session.pane ?? null,
|
|
@@ -35,6 +37,7 @@ export function surfaceIncident(db, board, entry, id, name, evidence = {}, runId
|
|
|
35
37
|
const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(runId);
|
|
36
38
|
if (!row || row.task_id !== id) return false;
|
|
37
39
|
const meta = json(row.metadata, {}) ?? {}, session = meta.supercode ?? {};
|
|
40
|
+
if (detachedCard(db, id) || detachedSession(db, session)) return false;
|
|
38
41
|
const prior = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='run_incident' AND run_id=? ORDER BY id DESC LIMIT 1").get(id,runId);
|
|
39
42
|
const continued = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='session_continued' AND run_id=? ORDER BY id DESC LIMIT 1").get(id,runId);
|
|
40
43
|
if (prior && !(continued?.id > prior.id)) return false;
|
|
@@ -69,6 +72,7 @@ export function auditEndedRuns(db, board, entry) {
|
|
|
69
72
|
WHERE r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id) AND r.ended_at IS NOT NULL
|
|
70
73
|
AND t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived')`).all()) {
|
|
71
74
|
const metadata = json(row.metadata, {}), session = metadata.supercode;
|
|
75
|
+
if (detachedSession(db, session)) continue;
|
|
72
76
|
if (session?.mode !== 'resume' || session.seen_at || session.resume_failed_before_live) continue;
|
|
73
77
|
const exit = db.prepare("SELECT 1 FROM task_events WHERE run_id=? AND kind='run_incident' AND json_extract(payload,'$.observation')='exited' AND json_extract(payload,'$.evidence.code')=1").get(row.id);
|
|
74
78
|
if (!exit) continue;
|
|
@@ -80,6 +84,7 @@ export function auditEndedRuns(db, board, entry) {
|
|
|
80
84
|
for (const task of db.prepare(`SELECT t.id,c.paused FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
81
85
|
WHERE t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived') AND c.paused IS NOT NULL AND NOT COALESCE(c.archived,0)`).all()) {
|
|
82
86
|
const pause = json(task.paused, null);
|
|
87
|
+
if (detachedCard(db, task.id)) continue;
|
|
83
88
|
const last = db.prepare("SELECT kind,payload FROM task_events WHERE task_id=? AND kind IN ('closed_by_hand','paused','resumed') ORDER BY id DESC LIMIT 1").get(task.id);
|
|
84
89
|
const ended = json(last?.payload, {});
|
|
85
90
|
// The machine may have moved since this run. Match the retained board
|
|
@@ -95,6 +100,7 @@ export function auditEndedRuns(db, board, entry) {
|
|
|
95
100
|
ON r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id)
|
|
96
101
|
WHERE t.current_run_id IS NULL AND r.ended_at IS NOT NULL AND t.status IN ('running','review')`).all()) {
|
|
97
102
|
const session = json(task.run_metadata, {})?.supercode;
|
|
103
|
+
if (detachedCard(db, task.id) || detachedSession(db, session)) continue;
|
|
98
104
|
if (!session?.monitor_held) continue;
|
|
99
105
|
const state = db.prepare('SELECT paused,blocked_by,archived FROM supercode_cards WHERE task_id=?').get(task.id);
|
|
100
106
|
if (json(state?.paused, null) || json(state?.blocked_by, [])?.length || state?.archived) continue;
|
|
@@ -110,6 +116,7 @@ export function auditEndedRuns(db, board, entry) {
|
|
|
110
116
|
for (const task of db.prepare(`SELECT t.*,r.id run_id,r.outcome,r.error,r.metadata run_metadata FROM tasks t JOIN task_runs r ON r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id)
|
|
111
117
|
WHERE t.current_run_id IS NULL AND r.ended_at IS NOT NULL AND t.status NOT IN ('done','archived')`).all()) {
|
|
112
118
|
const session = json(task.run_metadata, {})?.supercode;
|
|
119
|
+
if (detachedCard(db, task.id) || detachedSession(db, session)) continue;
|
|
113
120
|
const status = statusOf(db, task);
|
|
114
121
|
if (session?.status && session.status !== status) continue; // a card transition intentionally advances its work
|
|
115
122
|
const incident = db.prepare("SELECT id FROM task_events WHERE task_id=? AND run_id=? AND kind='run_incident'").get(task.id, task.run_id);
|
package/board/listing.mjs
CHANGED
|
@@ -5,6 +5,11 @@ import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
|
5
5
|
import { executionOf } from './session-work.mjs';
|
|
6
6
|
|
|
7
7
|
export const LIST_BATCH = 64;
|
|
8
|
+
// A listing on the owner's read door (owner-door.mjs) that can read no committed state without waiting on a writer (a
|
|
9
|
+
// backing with no commit point a reader can take, a store not in WAL, SQLite's own contention) is answered by the
|
|
10
|
+
// owner, as every other verb is.
|
|
11
|
+
export const READ_BY_OWNER = 'board_read_by_owner';
|
|
12
|
+
export const byOwner = (reason) => Object.assign(new Error(`this listing is the board owner's to answer: ${reason}`), { code: READ_BY_OWNER });
|
|
8
13
|
export const LIST_CONNECTION = Symbol.for('supercode.board.listing.connection');
|
|
9
14
|
export const LIST_REVISION = Symbol.for('supercode.board.listing.revision');
|
|
10
15
|
const parse = (text, fallback) => { try { return JSON.parse(text) ?? fallback; } catch { return fallback; } };
|
|
@@ -63,11 +68,12 @@ export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload EN
|
|
|
63
68
|
|
|
64
69
|
export function listPageSql(detailed, tagCount = 0) {
|
|
65
70
|
return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
|
|
66
|
-
c.machine,c.stage${detailed ? ',c.subtask_of' : ''},
|
|
71
|
+
n.number,c.machine,c.stage${detailed ? ',c.subtask_of' : ''},
|
|
67
72
|
r.id AS run_id,r.status AS run_status,r.started_at AS run_started_at,r.ended_at AS run_ended_at,${RUN_METADATA} AS run_metadata
|
|
68
73
|
${detailed ? `,(SELECT json_group_array(parent_id) FROM (SELECT parent_id FROM task_links WHERE child_id = t.id ORDER BY parent_id)) AS parents_json,
|
|
69
74
|
(${LAST_BLOCK_SQL}) AS block_payload` : ''}
|
|
70
|
-
FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN
|
|
75
|
+
FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN supercode_card_numbers n ON n.task_id = t.id
|
|
76
|
+
LEFT JOIN task_runs r ON r.id = t.current_run_id
|
|
71
77
|
WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR t.status = ? OR c.stage = ?)
|
|
72
78
|
AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
73
79
|
AND (? IS NULL OR t.tenant = ?) AND (? IS NULL OR t.session_id = ?)
|
package/board/machine-fleet.mjs
CHANGED
|
@@ -25,11 +25,14 @@ const paneFacts = (held) => {
|
|
|
25
25
|
]);
|
|
26
26
|
};
|
|
27
27
|
const paneListeners = new Set();
|
|
28
|
+
const recordListeners = new Set();
|
|
29
|
+
recordView.onUnavailable = () => { for (const listener of recordListeners) { try { listener({}); } catch { /* cursor retained for a current reading */ } } };
|
|
28
30
|
let factsHeld = new Map();
|
|
29
31
|
recordView.onApplied = (records) => {
|
|
30
32
|
const next = new Map(Object.entries(records).map(([machine, held]) => [machine, paneFacts(held)]));
|
|
31
33
|
const changed = [...next].filter(([machine, facts]) => factsHeld.has(machine) && factsHeld.get(machine) !== facts).map(([machine]) => machine);
|
|
32
34
|
factsHeld = next;
|
|
35
|
+
for (const listener of recordListeners) { try { listener(records); } catch { /* its retained source cursor is retried */ } }
|
|
33
36
|
if (changed.length) for (const listener of paneListeners) { try { listener(changed); } catch { /* a listener cannot stop the record's reading */ } }
|
|
34
37
|
};
|
|
35
38
|
/**
|
|
@@ -38,6 +41,10 @@ recordView.onApplied = (records) => {
|
|
|
38
41
|
* opened by a round's first reading and held from then on.
|
|
39
42
|
*/
|
|
40
43
|
export function onPanesChanged(listener) { paneListeners.add(listener); return () => paneListeners.delete(listener); }
|
|
44
|
+
/** The admitted, committed records, including native turn/health changes which need not change a pane. */
|
|
45
|
+
export function onRecordsChanged(listener) { recordListeners.add(listener); return () => recordListeners.delete(listener); }
|
|
46
|
+
export async function readFleetRecords() { return recordView.read(DEADLINE_MS); }
|
|
47
|
+
export function fleetRecordsCurrent() { return Boolean(recordView.ready && recordView.stream && Date.now() - recordView.heardAt <= 45_000); }
|
|
41
48
|
const DEADLINE_MS = 10_000;
|
|
42
49
|
const within = (promise, ms) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error(`no answer within ${ms} ms`), { code: 'timeout' })), ms).unref?.())]);
|
|
43
50
|
// One machine's reading, from its held record: its panes, what a board launch needs of it, and the session under each
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Locate filed/bounced notices outside the writer, without an unscoped JSON payload scan per expiry.
|
|
2
|
+
import { setImmediate as nextTurn } from 'node:timers/promises';
|
|
3
|
+
import { listRevision, LIST_BATCH } from './listing.mjs';
|
|
4
|
+
|
|
5
|
+
const FILED = new Set(['notice_filed', 'session_notice_filed', 'actor_notice_filed']);
|
|
6
|
+
const KINDS = "('notice_filed', 'session_notice_filed', 'actor_notice_filed', 'notice_bounced')";
|
|
7
|
+
const same = (a, b, ownWrites = false) => Object.keys(a).every(key => (ownWrites && key === 'changes') || a[key] === b[key]);
|
|
8
|
+
export const noticeKey = (taskId, messageId) => JSON.stringify([taskId, messageId]);
|
|
9
|
+
const unread = () => Object.assign(new Error('Notice receipts changed during their reading; bounced notices will be retried next round.'), { code: 'board_notice_receipts_unread' });
|
|
10
|
+
|
|
11
|
+
/** Check inside the write before any mutation. After our own synchronous write, only total_changes may differ. */
|
|
12
|
+
export function requireNoticeBasis(board, revision, { ownWrites = false } = {}) {
|
|
13
|
+
const current = listRevision(board.db);
|
|
14
|
+
if (!same(revision, current, ownWrites)) throw unread();
|
|
15
|
+
return current;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Backing write preparation can clear TEMP bookkeeping or import card fields before the callback.
|
|
19
|
+
* Notice events are immutable log entries: a new filing/bounce must advance their primary-key ceiling.
|
|
20
|
+
* Check that ceiling in the transaction, and revalidate each selected filing by ID before using it. */
|
|
21
|
+
export function noticeWriterBasis(board, revision, ceiling) {
|
|
22
|
+
const basis = listRevision(board.db);
|
|
23
|
+
if (['connection', 'data', 'mainSchema'].some(key => basis[key] !== revision[key])
|
|
24
|
+
|| board.db.prepare('SELECT MAX(id) AS id FROM task_events').get().id !== ceiling) throw unread();
|
|
25
|
+
return basis;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function noticeReceiptsInTurns(board, messageIds) {
|
|
29
|
+
const wanted = new Set(messageIds);
|
|
30
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
31
|
+
const revision = listRevision(board.db);
|
|
32
|
+
const found = await readReceipts(board, wanted, revision);
|
|
33
|
+
if (found !== null && same(revision, listRevision(board.db))) return { ...found, revision };
|
|
34
|
+
await nextTurn();
|
|
35
|
+
}
|
|
36
|
+
throw unread();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readReceipts(board, wanted, revision) {
|
|
40
|
+
const db = board.db, filed = new Map(), bounced = new Set();
|
|
41
|
+
const ceiling = db.prepare('SELECT MAX(id) AS id FROM task_events').get().id;
|
|
42
|
+
let before = null;
|
|
43
|
+
while (ceiling !== null) {
|
|
44
|
+
if (!same(revision, listRevision(db))) return null;
|
|
45
|
+
// Bound every candidate, including unrelated event kinds. Return bodies only for notice receipts;
|
|
46
|
+
// filtering kinds or JSON in WHERE could scan unbounded history to find one matching page.
|
|
47
|
+
const rows = db.prepare(`SELECT id, task_id, kind, CASE WHEN kind IN ${KINDS} THEN payload END AS payload
|
|
48
|
+
FROM task_events WHERE id <= ?${before === null ? '' : ' AND id < ?'} ORDER BY id DESC LIMIT ${LIST_BATCH}`)
|
|
49
|
+
.all(ceiling, ...(before === null ? [] : [before]));
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
if (!FILED.has(row.kind) && row.kind !== 'notice_bounced') continue;
|
|
52
|
+
let payload;
|
|
53
|
+
try { payload = JSON.parse(row.payload ?? 'null') ?? {}; }
|
|
54
|
+
catch { throw new Error(`Notice receipt payload unread at event ${row.id}; bounced notices will be retried next round.`); }
|
|
55
|
+
const id = payload.message_id;
|
|
56
|
+
if (!wanted.has(id)) continue;
|
|
57
|
+
if (row.kind === 'notice_bounced') bounced.add(noticeKey(row.task_id, id));
|
|
58
|
+
else if (!filed.has(id)) filed.set(id, row); // descending IDs: preserve the latest filing
|
|
59
|
+
}
|
|
60
|
+
if (rows.length < LIST_BATCH) break;
|
|
61
|
+
// All requested messages have both their latest filing and an existing bounce: no older row can undo them.
|
|
62
|
+
if (filed.size === wanted.size && [...filed].every(([id, row]) => bounced.has(noticeKey(row.task_id, id)))) break;
|
|
63
|
+
before = rows.at(-1).id;
|
|
64
|
+
await nextTurn();
|
|
65
|
+
}
|
|
66
|
+
return { filed, bounced, ceiling };
|
|
67
|
+
}
|
package/board/notices.mjs
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** A notice's text or a mail's subject about a card, with the card's number first (`#42 …`; store.mjs layOutNumbers). */
|
|
2
|
+
export const numbered = (number, text) => (number ? `#${number} ${text}` : text);
|
|
3
|
+
/** The number of card `id` in the board's store, or null. */
|
|
4
|
+
export const cardNumber = (db, id) => db.prepare('SELECT number FROM supercode_card_numbers WHERE task_id = ?').get(id)?.number ?? null;
|
|
5
|
+
|
|
1
6
|
export function identifyNotice(event, text) {
|
|
2
7
|
const time = new Date(Number(event.created_at) * 1000).toISOString();
|
|
3
8
|
const kind = String(event.kind).replaceAll('_', ' ');
|