@volter/supercode-orchestrator 0.5.226 → 0.5.227

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.
@@ -1,5 +1,6 @@
1
1
  // Fleet facts are mail obligations, independent of card transitions and dispatch ticks. Intent comes only from the
2
2
  // canonical workflow IR. The sender supplies observations from its admitted sources; this router grants nothing.
3
+ import { GENERATION_STOPPED } from './generation.mjs';
3
4
  import { DatabaseSync } from 'node:sqlite';
4
5
  import { chmodSync, mkdirSync, existsSync } from 'node:fs';
5
6
  import { join } from 'node:path';
@@ -213,7 +214,8 @@ export class FleetListenerRouter {
213
214
  }
214
215
  }
215
216
 
216
- wake() { if (!this.closed) void this.flush().catch(error => this.log(`fleet listener filing: ${String(error.message).slice(0, 300)}`)); }
217
+ // A flush its stopping board generation refused never ran (GENERATION_STOPPED, generation.mjs): said as that.
218
+ wake() { if (!this.closed) void this.flush().catch(error => this.log(error?.code === GENERATION_STOPPED ? `fleet listener filing not run: ${error.message}` : `fleet listener filing: ${String(error.message).slice(0, 300)}`)); }
217
219
 
218
220
  /** A lost filing answer leaves the obligation pending. Retrying its stable id goes through the mailbox's own
219
221
  * idempotent filing. This receipt proves filing only; native turn delivery remains the mailbox's responsibility. */
@@ -48,8 +48,9 @@ export function observeRoleFleet(root, router, records, log = () => {}, { waitin
48
48
  }
49
49
  if (!waiting) continue;
50
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
51
+ // unread activity or absent record never clears a wait (a part the index could not read is not a lost index: it
52
+ // keeps its rows, machine-fleet.mjs). Waiting on a live native session is not inferred from idle.
53
+ if (record.unread?.panes || record.unread?.sessions_index
53
54
  || record.unread?.sessions_activity || record.unread?.claude_registry_watch || record.panes?.activities_omitted || record.panes?.sessions_unread) continue;
54
55
  const grouped = new Map();
55
56
  for (const { pid, activity } of record.panes?.activities ?? []) {
@@ -19,6 +19,11 @@ import { parse } from './args.mjs';
19
19
 
20
20
  const unknown = () => ({ code: 1, unanswered: true, stdout: '', stderr: 'the board generation ended after taking this request; its outcome is unknown\n' });
21
21
  const failed = error => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error.message}\n` });
22
+ // The card verbs that only read the board's store, and how long one waits on the serving generation: the bound this
23
+ // owner gives a generation's own readback and drain. A generation answers `show` in well under a second; one that has
24
+ // not answered in 8 s is not running, and its caller is told so instead of being held.
25
+ const READS = new Set(['show', 'list', 'ls', 'context', 'brief', 'runs', 'log', 'stats', 'assignees', 'attachments', 'attachment-read', 'notify-list']);
26
+ const READ_MS = 8000;
22
27
 
23
28
  export async function serveBoardOwner(root, args) {
24
29
  root = boardHome(root);
@@ -30,7 +35,7 @@ export async function serveBoardOwner(root, args) {
30
35
  const record = child => {
31
36
  const temporary = `${recordPath}.${randomUUID()}.tmp`;
32
37
  writeFileSync(temporary, JSON.stringify({ version: 1, owner: process.pid, active: child?.process.pid ?? null,
33
- selected: child?.selected ?? null, generation: child?.id ?? null, previous: previous?.process.pid ?? null, at_ms: Date.now() }) + '\n', { mode: 0o600 });
38
+ selected: child?.selected ?? null, generation: child?.id ?? null, previous: previous && !previous.ended ? previous.process.pid : null, at_ms: Date.now() }) + '\n', { mode: 0o600 });
34
39
  renameSync(temporary, recordPath);
35
40
  };
36
41
  const changed = () => { for (const done of waiting) done(); waiting.clear(); };
@@ -52,7 +57,8 @@ export async function serveBoardOwner(root, args) {
52
57
  stdio: ['ignore', 'inherit', 'inherit', 'ipc'], serialization: 'advanced',
53
58
  env,
54
59
  });
55
- const generation = { process: processChild, selected, id: generationId, calls: new Map(), events: new Map(), ended: false, health: null, workflows: new Map() };
60
+ const generation = { process: processChild, selected, id: generationId, calls: new Map(), events: new Map(), ended: false, health: null, workflows: new Map(),
61
+ heard: Date.now(), silenceSaid: false };
56
62
  generation.exited = new Promise(done => { processChild.once('exit', done); processChild.once('error', done); });
57
63
  generations.add(generation);
58
64
  const tell = frame => { if (processChild.connected) processChild.send(frame); else throw new Error('board generation disconnected'); };
@@ -60,18 +66,24 @@ export async function serveBoardOwner(root, args) {
60
66
  generation.wait = (event, ms = 8000) => new Promise((done, reject) => {
61
67
  if (generation.ended) return reject(new Error('board generation ended'));
62
68
  if (generation.events.has(event)) return done(generation.events.get(event));
63
- const waiter = { done, reject, timer: null }, expires = Date.now() + 90000;
69
+ // Preparation and routing are capped at 90 s overall, progress included. The first round ('serving') runs routed,
70
+ // answering card verbs, and is a full tick that waits on other machines' doors: on the MacBook, first rounds took
71
+ // 49.7 s, 62.8 s and over 65 s with one machine answering discovery in 10-15 s, and a 65 s budget for the whole
72
+ // round rolled back a successor that served (VO-3578, 3e8b6f0eb, 01:59:48Z). Its budget bounds progress instead:
73
+ // each step the round says re-arms it. The five-minute bound only ends a round that never finishes.
74
+ const waiter = { done, reject, timer: null }, expires = Date.now() + (event === 'serving' ? 300000 : 90000);
64
75
  waiter.arm = budget => {
65
76
  clearTimeout(waiter.timer);
66
77
  waiter.timer = setTimeout(() => { generation.events.delete(event); reject(new Error(`board generation did not report ${event}; last preparation step: ${generation.startupStep ?? 'entry loading'}; predecessor retained`)); }, Math.min(budget, Math.max(1, expires - Date.now())));
67
78
  };
68
79
  waiter.arm(ms); generation.events.set(event, waiter);
69
80
  });
70
- generation.ask = (line, timeout = null) => new Promise(done => {
81
+ // `late` is the answer given when `timeout` passes first: unknown, unless the caller says what a late answer means
82
+ generation.ask = (line, timeout = null, late = unknown) => new Promise(done => {
71
83
  const id = randomUUID();
72
84
  let timer;
73
85
  const finish = answer => { clearTimeout(timer); generation.calls.delete(id); done(answer); };
74
- if (timeout) timer = setTimeout(() => finish(unknown()), timeout);
86
+ if (timeout) timer = setTimeout(() => finish(late()), timeout);
75
87
  generation.calls.set(id, finish);
76
88
  try { tell({ type: 'request', id, line }); }
77
89
  catch { generation.calls.delete(id); done(unknown()); }
@@ -106,6 +118,7 @@ export async function serveBoardOwner(root, args) {
106
118
  }
107
119
  };
108
120
  processChild.on('message', frame => {
121
+ generation.heard = Date.now(); generation.silenceSaid = false;
109
122
  if (!frame || typeof frame.type !== 'string') return;
110
123
  if (frame.type === 'startup-step') {
111
124
  generation.startupStep = frame.step;
@@ -199,9 +212,11 @@ export async function serveBoardOwner(root, args) {
199
212
  const candidate = start(selected);
200
213
  try {
201
214
  const proof = await candidate.wait('prepared'), held = active.events.get('prepared');
202
- // Both generations name their format (storage-contract.mjs): the formats are compared. A generation from before
203
- // `format` names only its storage modules' source, which is then compared.
204
- const same = proof.format && held?.format ? proof.format === held.format : Boolean(proof.storage) && proof.storage === held?.storage;
215
+ // Both generations name their format (storage-contract.mjs): the formats are compared, and a candidate whose format
216
+ // differs is admitted only when it names the predecessor's among the formats that read what it writes
217
+ // (`readableBy`), so the predecessor stays its rollback. A generation from before `format` names only its storage
218
+ // modules' source, which is then compared.
219
+ const same = proof.format && held?.format ? proof.format === held.format || (Array.isArray(proof.readableBy) && proof.readableBy.includes(held.format)) : Boolean(proof.storage) && proof.storage === held?.storage;
205
220
  if (!same) throw new Error('board storage contract differs; predecessor retained without opening a writer');
206
221
  pending = { id, candidate, predecessor: active, older: previous, phase: 'prepared' };
207
222
  return status();
@@ -263,7 +278,10 @@ export async function serveBoardOwner(root, args) {
263
278
  catch (error) { await terminate(replacement); throw error; }
264
279
  }
265
280
  if (routing !== pending.candidate || active.ended) throw new Error('board successor is not serving; handover cannot finish');
266
- for (const retained of generations) if (retained !== active && retained !== previous) await terminate(retained);
281
+ // The parked predecessor is no rollback once the transaction is finished: no path resumes a parked generation
282
+ // (a rollback after parking starts a fresh one from its selection), so every generation but the serving one ends
283
+ // here. `previous` keeps naming the selection it served.
284
+ for (const retained of generations) if (retained !== active) await terminate(retained);
267
285
  pending = null; record(active);
268
286
  } else if (!['prepare', 'activate'].includes(action)) throw new Error('unknown board handover action');
269
287
  return status();
@@ -285,7 +303,19 @@ export async function serveBoardOwner(root, args) {
285
303
  if (active?.ended && (!previous || previous.ended) && !transition) return failed(new Error('board generation ended; no rollback generation is available'));
286
304
  await new Promise(done => waiting.add(done));
287
305
  }
288
- return stopping ? failed(new Error('the board owner is stopping')) : routing.ask(line);
306
+ if (stopping) return failed(new Error('the board owner is stopping'));
307
+ let verb = null;
308
+ // the generation answers what cannot be read as a verb here
309
+ try { if (!request?.tool && Array.isArray(request?.argv) && request.argv.every(arg => typeof arg === 'string')) verb = parse(request.argv)._[0]; } catch { /* not a read */ }
310
+ if (!READS.has(verb)) return routing.ask(line);
311
+ // A read waits on the serving generation for READ_MS at most. A generation whose process does not run answers
312
+ // nothing, and every read sent to it would otherwise wait for as long as it stands still; its late answer is dropped.
313
+ const serving = routing;
314
+ return serving.ask(line, READ_MS, () => {
315
+ const quiet = Math.round((Date.now() - serving.heard) / 1000);
316
+ if (!serving.silenceSaid) { serving.silenceSaid = true; log(`generation ${serving.id} (pid ${serving.process.pid}) did not answer a ${verb} within ${READ_MS / 1000} s; its last message came ${quiet} s before`); }
317
+ return failed(new Error(`the board's serving generation (pid ${serving.process.pid}) did not answer this ${verb} within ${READ_MS / 1000} s (its last message to the board owner came ${quiet} s before); nothing was read: ask again`));
318
+ });
289
319
  };
290
320
  let closeDoor;
291
321
  const stop = () => {
@@ -7,17 +7,30 @@ export const inBoardGeneration = typeof process.send === 'function'
7
7
  const nested = new AsyncLocalStorage();
8
8
  let admitting = !inBoardGeneration, writing = !inBoardGeneration, running = 0, ended = false;
9
9
  let waiters = [], drained = [];
10
+ // Work refused because its generation stopped never ran: callers say it as that, not as a failure of the work (a
11
+ // parked generation's queued round and delivery pass are refused when the transaction's finish stops it).
12
+ export const GENERATION_STOPPED = 'GENERATION_STOPPED';
13
+ const stoppedError = message => Object.assign(new Error(message), { code: GENERATION_STOPPED });
14
+ // Each admitted unit of work by its name and when it was admitted, so a park that waits names what it waits on.
15
+ const admitted = new Map();
16
+ let nextWork = 0;
10
17
 
11
- export async function generationWork(work) {
18
+ export async function generationWork(work, name = 'work') {
12
19
  if (!inBoardGeneration) return work();
13
20
  while (!admitting && !nested.getStore()?.active) {
14
- if (ended) throw new Error('the board generation stopped before this work began');
21
+ if (ended) throw stoppedError('the board generation stopped before this work began');
15
22
  await new Promise((done, reject) => waiters.push({ done, reject }));
16
23
  }
17
24
  running++;
25
+ const id = ++nextWork; admitted.set(id, { name, at: Date.now() });
18
26
  const scope = { active: true };
19
27
  try { return await nested.run(scope, work); }
20
- finally { scope.active = false; if (--running === 0) { for (const done of drained.splice(0)) done(); } }
28
+ finally { admitted.delete(id); scope.active = false; if (--running === 0) { for (const done of drained.splice(0)) done(); } }
29
+ }
30
+
31
+ /** The admitted work still running, each named with how long ago it was admitted. */
32
+ export function admittedWork(now = Date.now()) {
33
+ return [...admitted.values()].map(work => `${work.name} (admitted ${now - work.at} ms before)`);
21
34
  }
22
35
 
23
36
  export function assertGenerationWriter() {
@@ -32,7 +45,7 @@ export function admitGeneration() {
32
45
  export function grantGenerationWriter() { writing = true; }
33
46
  export function stopGeneration() {
34
47
  ended = true; admitting = false;
35
- for (const { reject } of waiters.splice(0)) reject(new Error('the board generation is stopping'));
48
+ for (const { reject } of waiters.splice(0)) reject(stoppedError('the board generation is stopping'));
36
49
  }
37
50
 
38
51
  export async function parkGeneration(settle) {
@@ -7,6 +7,7 @@
7
7
  // A machine whose record is current is `online`. One whose record is out of date (its stream closed, its panes or the
8
8
  // sessions under them not read) or was never read is `unknown` (its runs are kept, as an unread machine's always were), unless its record says it was unlinked from the
9
9
  // team: then it is offline. This machine's own record not being given is no reading at all.
10
+ import { execFileSync } from 'node:child_process';
10
11
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
11
12
  import { machineCall, machineRecordStream } from './doors.mjs';
12
13
  import { RecordView } from './record-view.mjs';
@@ -56,13 +57,29 @@ function readOne(name, held) {
56
57
  if (held.stream !== 'open' && held.stream !== 'self') return { machine: name, error: `its record may be out of date: its stream is ${held.stream} since ${held.since ? new Date(held.since).toISOString() : 'unknown'} (${held.why ?? 'no reason given'})`, code: 'stale' };
57
58
  const record = held.record;
58
59
  // panes not read, or read without the sessions under them (or while the session index is lost), are no reading of
59
- // what runs there: the machine is unknown, never one whose panes or sessions are absent (refused is not absent)
60
- const notCurrent = record.unread?.panes ?? record.unread?.sessions_index ?? record.unread?.sessions_index_parts ?? record.unread?.claude_registry_watch ?? record.unread?.pane_exits_watch ?? record.panes?.activities_omitted ?? record.panes?.sessions_unread ?? (record.panes ? null : 'its record holds no pane list');
60
+ // what runs there: the machine is unknown, never one whose panes or sessions are absent (refused is not absent). A
61
+ // part the index could not read (`sessions_index_parts`) is not: it keeps the rows its last read committed, and the
62
+ // sessions under the panes are read by their processes, not from it; the record names it.
63
+ const notCurrent = record.unread?.panes ?? record.unread?.sessions_index ?? record.unread?.claude_registry_watch ?? record.unread?.pane_exits_watch ?? record.panes?.activities_omitted ?? record.panes?.sessions_unread ?? (record.panes ? null : 'its record holds no pane list');
61
64
  if (notCurrent) return { machine: name, error: `its panes are not current: ${notCurrent}`, code: 'stale' };
62
65
  const panes = record.panes?.panes ?? [];
63
66
  const capabilities = record.capabilities ?? { guard: [], launches: false };
64
- return { machine: name, panes, activities: record.panes?.activities ?? [], ended: record.panes?.ended ?? [], capabilities: { guard: capabilities.guard ?? [], launches: Boolean(capabilities.launches), launchesRefused: capabilities.launches ? null : 'its record names no keyed launch door' } };
67
+ // Since when its record has been current without a break: this machine's own since it last woke (localWake), another's
68
+ // since its stream last opened (the record holder's `since`), unknown (null) where neither was read.
69
+ const linkedSince = held.stream === 'self' ? localWake() : Number(held.since) > 0 ? Number(held.since) : null;
70
+ return { machine: name, panes, activities: record.panes?.activities ?? [], ended: record.panes?.ended ?? [], linked_since_ms: linkedSince, capabilities: { guard: capabilities.guard ?? [], launches: Boolean(capabilities.launches), launchesRefused: capabilities.launches ? null : 'its record names no keyed launch door' } };
65
71
  }
72
+ /** When this machine last woke from sleep (epoch ms): a sleeping machine runs nothing, so no session on it was watched
73
+ * idle while it slept. macOS keeps it as kern.waketime (0 when it has not slept since boot); a machine with no such
74
+ * record is 0; null when the record could not be read. */
75
+ export function localWake() {
76
+ if (process.platform !== 'darwin') return 0;
77
+ try {
78
+ const found = /sec = (\d+), usec = (\d+)/.exec(execFileSync('/usr/sbin/sysctl', ['-n', 'kern.waketime'], { encoding: 'utf8', timeout: 2_000 }));
79
+ return found ? Number(found[1]) * 1000 + Math.floor(Number(found[2]) / 1000) : null;
80
+ } catch { return null; }
81
+ }
82
+
66
83
  /** One reading of `machines` (normalized names) and this machine, `local`: every record, from this machine's daemon. */
67
84
  export async function readMachineFleet(machines, local) {
68
85
  const names = [...new Set([local, ...machines])];
@@ -82,17 +99,20 @@ export async function readMachineFleet(machines, local) {
82
99
  noteStep(`fleet reading (${names.length} machine${names.length === 1 ? '' : 's'}, held records)`, performance.now() - started);
83
100
  // `panes`: each answering machine's live pane ids, whether or not a session was read in them
84
101
  // `ended`: the panes each machine saw end (its tmux's exit log, or a pane its host keeps dead), with how each ended
85
- const online = new Set(), offline = new Set(), unknown = new Set(), errors = new Map(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map();
86
- let localError = null;
102
+ // `linkedSince`: each online machine's unbroken current reading began (epoch ms; this machine's last wake; null unknown)
103
+ const online = new Set(), offline = new Set(), unknown = new Set(), errors = new Map(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map(), linkedSince = new Map();
104
+ let localError = null, localCode = null;
87
105
  for (const row of rows) {
88
106
  if (row.error) {
89
107
  errors.set(row.machine, row.error);
90
- if (row.machine === local) localError = row.error;
91
- else if (row.code === 'machine_offline') offline.add(row.machine);
108
+ if (row.machine === local) { localError = row.error; localCode = row.code; }
109
+ if (row.machine === local && row.code !== 'stale') continue;
110
+ if (row.code === 'machine_offline') offline.add(row.machine);
92
111
  else unknown.add(row.machine);
93
112
  continue;
94
113
  }
95
114
  online.add(row.machine);
115
+ linkedSince.set(row.machine, row.linked_since_ms ?? null);
96
116
  capabilities.set(row.machine, row.capabilities ?? { guard: [], launches: false });
97
117
  const live = (row.panes ?? []).filter((pane) => !pane.dead && pane.pid);
98
118
  panes.set(row.machine, new Set((row.panes ?? []).filter((pane) => !pane.dead).map((pane) => pane.pane)));
@@ -117,6 +137,10 @@ export async function readMachineFleet(machines, local) {
117
137
  question: activity.evidence?.question ?? null });
118
138
  }
119
139
  }
120
- const ok = online.has(local);
121
- return { ok, presenceKnown: ok, online, offline, unknown, errors, alive, native, sessions, capabilities, panes, ended, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
140
+ // The reading is taken once this machine's daemon gave the records it holds and one of them is this machine's. This
141
+ // machine's record held but not current (its panes not read, its stream out of date) is one unknown machine, with
142
+ // why, as any other is: the machines that answered are current beside it. Only no record of this machine at all
143
+ // leaves the whole reading untaken.
144
+ const ok = online.has(local) || (!heldError && localCode === 'stale');
145
+ return { ok, presenceKnown: ok, online, offline, unknown, errors, alive, native, sessions, capabilities, panes, ended, linkedSince, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
122
146
  }
package/board/notices.mjs CHANGED
@@ -1,7 +1,15 @@
1
- /** A notice's text or a mail's subject about a card, with the card's keyed number first (`VO-42 …`; store.mjs boardKey). */
2
- export const numbered = (key, number, text) => (number ? `${key}-${number} ${text}` : text);
1
+ /** A notice's text or a mail's subject about a card, with the card's readable id first and its keyed number after it
2
+ * (`voice-calls (VO-42) …`; store.mjs boardKey), or its keyed number alone where it has no readable id (`VO-42 …`). */
3
+ export const numbered = (key, number, text, readableId = null) => {
4
+ const ref = number ? `${key}-${number}` : null;
5
+ return readableId ? `${readableId}${ref ? ` (${ref})` : ''} ${text}` : ref ? `${ref} ${text}` : text;
6
+ };
3
7
  /** The number of card `id` in the board's store, or null. */
4
8
  export const cardNumber = (db, id) => db.prepare('SELECT number FROM supercode_card_numbers WHERE task_id = ?').get(id)?.number ?? null;
9
+ /** The readable id a notice names card `task` by (a getTask row); null for a subtask, which no person names. */
10
+ export const noticeName = (task) => (task && !task.subtask_of ? task.readable_id ?? null : null);
11
+ /** The readable id a notice names card `id` by (noticeName), read from the board's store. */
12
+ export const cardName = (db, id) => db.prepare('SELECT t.readable_id FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.id = ? AND c.subtask_of IS NULL').get(id)?.readable_id ?? null;
5
13
 
6
14
  export function identifyNotice(event, text) {
7
15
  const time = new Date(Number(event.created_at) * 1000).toISOString();
@@ -103,6 +103,15 @@ const RETAINED_CHANGES = 1024;
103
103
  // every pruned change in one statement held the owner's event loop for 291 s (2026-10-21).
104
104
  const PRUNE_BATCH = 16;
105
105
  const PRUNE_BYTES = 1024 * 1024;
106
+ // A card's head is its complete snapshot (a `workflow events` reading's snapshot frames are complete); a change a newer
107
+ // one supersedes keeps only the history the connector carries (storedSnapshot): on the MacBook (2026-10-27) changes of
108
+ // busy cards ran to 1.7 MB each (1.1 MB of it comments, 0.6 MB attempts), and 4,951 older changes held 2.5 GB of a
109
+ // 3 GB board, while the 4,153 heads held 43 MB. The older changes stored before are trimmed by the owner in place, this
110
+ // many bytes read per wake (trimPublications); the space is given back by `workflow compact` (reclaimSpace).
111
+ const TRIM_BYTES = 8 * 1024 * 1024;
112
+ const TRIM_STATE = 'publication_trim';
113
+ // Free pages over this many bytes and a quarter of the store are worth giving back.
114
+ const RECLAIM_BYTES = 256 * 1024 * 1024;
106
115
  const hash = value => createHash('sha256').update(value).digest('hex');
107
116
  const parse = (value,fallback=null) => {try{return JSON.parse(value)??fallback;}catch{return fallback;}};
108
117
  const at = value => value == null ? null : new Date(Number(value)*1000).toISOString();
@@ -233,6 +242,32 @@ function fingerprintOf(reads,rows,row,key,options){
233
242
  }
234
243
  /** A card's snapshot; `key` is its board's key (store.mjs boardKey), with which `ref` names the card as a person does
235
244
  * (`VO-856`, store.mjs cardRef), null for a card with no number. */
245
+ /**
246
+ * What an older change keeps of a card (a change a newer head supersedes): the card as cardSnapshot read it, its
247
+ * history cut to what a reader of the board's events carries (the connector's publishableCard): the newest comment (the one a `task.commented` is about),
248
+ * the latest attempt and the latest one that names its session's agent, and the latest review. `omitted` counts what
249
+ * was left out, added to what an earlier cut counted, so cutting a stored snapshot again changes nothing. The whole card
250
+ * stays behind the board's own doors (`workflow show`).
251
+ */
252
+ export function storedSnapshot(card) {
253
+ if (!card || card.removed) return card;
254
+ const comments=Array.isArray(card.comments)?card.comments:[],attempts=Array.isArray(card.attempts)?card.attempts:[],reviews=Array.isArray(card.reviews)?card.reviews:[];
255
+ const agent=attempts.findLast(attempt=>attempt?.session?.agent);
256
+ const keptAttempts=attempts.filter((attempt,at)=>at===attempts.length-1||attempt===agent);
257
+ const prior=card.omitted&&typeof card.omitted==='object'?card.omitted:{};
258
+ return {...card,comments:comments.slice(-1),attempts:keptAttempts,reviews:reviews.slice(-1),
259
+ omitted:{...prior,comments:Number(prior.comments??0)+Math.max(0,comments.length-1),attempts:Number(prior.attempts??0)+attempts.length-keptAttempts.length,reviews:Number(prior.reviews??0)+Math.max(0,reviews.length-1)}};
260
+ }
261
+
262
+ /** Cut the history of the change at `sequence`, which a newer head of its card superseded (storedSnapshot). */
263
+ function supersede(db,sequence){
264
+ const row=db.prepare('SELECT snapshot FROM supercode_publications WHERE sequence=?').get(sequence);
265
+ const card=row?parse(row.snapshot,null):null;
266
+ if(!card||card.removed)return;
267
+ const text=JSON.stringify(storedSnapshot(card));
268
+ if(text!==row.snapshot)db.prepare('UPDATE supercode_publications SET snapshot=?,snapshot_bytes=? WHERE sequence=?').run(text,Buffer.byteLength(text),sequence);
269
+ }
270
+
236
271
  export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
237
272
  const own=rows.own(row.id)??{},number=rows.number(row.id);
238
273
  const comments=rows.comments(row.id).map(comment=>({...comment,created_at:at(comment.created_at)}));
@@ -247,7 +282,8 @@ export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
247
282
  });
248
283
  const lane=own.stage??row.status;
249
284
  const archived=Boolean(own.archived || lane==='archived');
250
- return {id:row.id,readable_id:row.readable_id??null,number,ref:key&&number?`${key}-${number}`:null,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),board_status:cardState({...row,stage:lane,paused:own.paused,blocked_by:own.blocked_by},options).status,lane,
285
+ // a subtask, a row under its card that no person names, is published with no readable id, as the board shows it
286
+ return {id:row.id,readable_id:own.subtask_of?null:row.readable_id??null,number,ref:key&&number?`${key}-${number}`:null,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),board_status:cardState({...row,stage:lane,paused:own.paused,blocked_by:own.blocked_by},options).status,lane,
251
287
  execution:executionOf(runs.find(run=>run.id===row.current_run_id),readingContext(db,row.id,row.current_run_id)),
252
288
  priority:row.priority,created_by:row.created_by,created_at:at(row.created_at),started_at:at(row.started_at),completed_at:at(row.completed_at),result:row.result,skills:parse(row.skills,[]),model:row.model_override,provider:row.provider_override,
253
289
  tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived,archive_reason:archived?rows.archiveReason(row.id):null,
@@ -271,7 +307,7 @@ export function recordCardPublications(db,actor,root,key) {
271
307
  const marked=db.prepare('SELECT DISTINCT task_id FROM supercode_publication_dirty').all().map(row=>row.task_id);
272
308
  if(!marked.length)return 0;
273
309
  db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
274
- const rows=perCard(db),reads=fingerprintReads(db),task=db.prepare('SELECT * FROM tasks WHERE id=?'),head=db.prepare('SELECT resource_id,digest,last_event_id,fingerprint FROM supercode_publication_heads WHERE resource_id=?');
310
+ const rows=perCard(db),reads=fingerprintReads(db),task=db.prepare('SELECT * FROM tasks WHERE id=?'),head=db.prepare('SELECT resource_id,digest,sequence,last_event_id,fingerprint FROM supercode_publication_heads WHERE resource_id=?');
275
311
  const lastEvent=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1');
276
312
  const insert=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at,snapshot_bytes) VALUES (?,?,?,?,?,?,?)');
277
313
  const upsert=db.prepare('INSERT INTO supercode_publication_heads(resource_id,digest,sequence,last_event_id,fingerprint) VALUES (?,?,?,?,?) ON CONFLICT(resource_id) DO UPDATE SET digest=excluded.digest,sequence=excluded.sequence,last_event_id=excluded.last_event_id,fingerprint=excluded.fingerprint');
@@ -290,6 +326,8 @@ export function recordCardPublications(db,actor,root,key) {
290
326
  const kind=`task.${previous&&previous.digest!=='removed'?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
291
327
  const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now(),Buffer.byteLength(snapshot));
292
328
  upsert.run(id,digest,Number(inserted.lastInsertRowid),last?.id??null,`${fingerprint}:${digest}`);changed++;
329
+ // the head it replaces is an older change now: it keeps only the history its readers carry
330
+ if(previous&&previous.digest!=='removed')supersede(db,previous.sequence);
293
331
  }else if(previous&&previous.digest!=='removed'){
294
332
  const snapshot=JSON.stringify({id,removed:true});
295
333
  const inserted=insert.run(`event_${randomUUID()}`,id,'task.changed',snapshot,actor,Date.now(),Buffer.byteLength(snapshot));
@@ -300,6 +338,73 @@ export function recordCardPublications(db,actor,root,key) {
300
338
  return changed;
301
339
  }
302
340
 
341
+ /** The trim's progress (`{through, high}`), or null before it began; a read only. */
342
+ function trimState(db) {
343
+ try { return parse(db.prepare('SELECT value FROM supercode_board_state WHERE key=?').get(TRIM_STATE)?.value,null); } catch { return null; }
344
+ }
345
+ /**
346
+ * Whether stored rows from before storedSnapshot are still to be trimmed, read without writing: true before the trim
347
+ * began on a store that holds publications, then until it reached the newest change stored when it began.
348
+ */
349
+ export function trimDue(db) {
350
+ const state=trimState(db);
351
+ if(!state)return Boolean(db.prepare('SELECT 1 FROM supercode_publications LIMIT 1').get());
352
+ return Number(state.through)<Number(state.high);
353
+ }
354
+
355
+ /**
356
+ * Trim the older changes stored complete before, oldest first, at most TRIM_BYTES read per call, in the caller's
357
+ * transaction. A card's head stays complete and is not touched; each trimmed change keeps its id, kind and sequence, so
358
+ * a reader reads the same events. Answers the rows trimmed.
359
+ */
360
+ export function trimPublications(db) {
361
+ db.exec('CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)');
362
+ // the first trim records the newest change stored then: every row after it was stored trimmed
363
+ if(!trimState(db))db.prepare('INSERT INTO supercode_board_state(key,value) VALUES (?,?) ON CONFLICT(key) DO NOTHING')
364
+ .run(TRIM_STATE,JSON.stringify({through:0,high:Number(db.prepare('SELECT MAX(sequence) AS n FROM supercode_publications').get()?.n??0)}));
365
+ const state=trimState(db);
366
+ if(!state||Number(state.through)>=Number(state.high))return 0;
367
+ // a few rows a call: a row ran to 4.5 MB, and each is read whole before the byte bound can count it
368
+ const rows=db.prepare(`SELECT sequence,snapshot FROM supercode_publications p WHERE sequence>? AND sequence<=? AND ${OLDER} ORDER BY sequence LIMIT 8`).all(Number(state.through),Number(state.high));
369
+ const update=db.prepare('UPDATE supercode_publications SET snapshot=?,snapshot_bytes=? WHERE sequence=?');
370
+ let through=Number(state.through),read=0,trimmed=0;
371
+ for(const row of rows){
372
+ if(read&&read+row.snapshot.length>TRIM_BYTES)break;
373
+ read+=row.snapshot.length;through=Number(row.sequence);
374
+ const card=parse(row.snapshot,null);
375
+ if(!card||card.removed)continue;
376
+ const text=JSON.stringify(storedSnapshot(card));
377
+ if(text===row.snapshot)continue;
378
+ update.run(text,Buffer.byteLength(text),row.sequence);trimmed++;
379
+ }
380
+ if(!rows.length)through=Number(state.high);
381
+ db.prepare('UPDATE supercode_board_state SET value=? WHERE key=?').run(JSON.stringify({...state,through}),TRIM_STATE);
382
+ return trimmed;
383
+ }
384
+
385
+ /** Whether the store holds free pages worth giving back: over RECLAIM_BYTES and a quarter of the store. */
386
+ export function reclaimDue(db) {
387
+ const size=Number(db.prepare('PRAGMA page_size').get().page_size),pages=Number(db.prepare('PRAGMA page_count').get().page_count),free=Number(db.prepare('PRAGMA freelist_count').get().freelist_count);
388
+ return free*size>RECLAIM_BYTES&&free*4>pages;
389
+ }
390
+ /** Whether the store gives back free pages a slice at a time (incremental vacuum, set by reclaimSpace's rebuild). */
391
+ export const incrementalStore = (db) => Number(db.prepare('PRAGMA auto_vacuum').get().auto_vacuum)===2;
392
+ /** Give back a slice of the free pages (64 MB) of a store in incremental vacuum, in the owner's transaction. */
393
+ export function reclaimSlice(db) { db.exec('PRAGMA incremental_vacuum(16384)'); }
394
+
395
+ /**
396
+ * Give the store's free pages back to the volume, once, by an operator's `workflow compact`: the store is rebuilt
397
+ * (VACUUM) with incremental vacuum on, so later frees go back a slice at a time on the owner's wakes (reclaimSlice).
398
+ * The rebuild copies every live page and holds the board's writer while it runs; `vacuum` runs SQL on the store's own
399
+ * connection outside any transaction (a backing's own method where it wraps writes). Answers the bytes it shrank by.
400
+ */
401
+ export function reclaimSpace(db,vacuum=(sql)=>db.exec(sql)) {
402
+ const size=Number(db.prepare('PRAGMA page_size').get().page_size),before=Number(db.prepare('PRAGMA page_count').get().page_count);
403
+ if(incrementalStore(db))vacuum('PRAGMA incremental_vacuum');
404
+ else vacuum('PRAGMA auto_vacuum=INCREMENTAL; VACUUM;');
405
+ return (before-Number(db.prepare('PRAGMA page_count').get().page_count))*size;
406
+ }
407
+
303
408
  // An older change: one no card's head is (the heads' sequence index answers it per change).
304
409
  const OLDER='NOT EXISTS (SELECT 1 FROM supercode_publication_heads h WHERE h.sequence=p.sequence)';
305
410
  /** Whether retention has work: a prune under way, or an upper bound on the older changes kept (sequences above the floor
@@ -1,11 +1,33 @@
1
- // A mutable name for people, separate from the immutable t_* identity and sequential number.
1
+ // A card's name for people (`voice-calls`), separate from its immutable t_* identity and sequential number. It names
2
+ // the card's branch, worktree folder, sessions and agents.
2
3
  export function validateReadableId(value) {
3
4
  if (typeof value !== 'string' || value.length > 32 || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value)) {
4
- throw new Error('readable_id is kebab-case, starts with a letter and has at most 32 characters');
5
+ throw new Error(`readable_id ${JSON.stringify(value)} is not a few lowercase words joined by hyphens, starting with a letter, of at most 32 characters (voice-calls)`);
5
6
  }
6
7
  return value;
7
8
  }
8
9
 
10
+ /** A card's readable id as its creator names it: given and valid, neither a repository's default or publication branch
11
+ * name nor shaped like a card number (`vo-42`, which a verb reads as one). */
12
+ export function requireReadableId(requested) {
13
+ if (requested == null || requested === '') {
14
+ throw new Error("a card is named by its creator: --readable-id <a few lowercase words naming its outcome, such as voice-calls>; it names the card's branch, worktree, sessions and agents");
15
+ }
16
+ const value = validateReadableId(requested);
17
+ if (['main', 'master', 'publish'].includes(value)) throw new Error(`readable_id ${value} names a repository's default or publication branch; name the card's outcome`);
18
+ if (/^[a-z][a-z0-9]+-[1-9]\d*$/.test(value)) throw new Error(`readable_id ${value} reads as a card number; name the card's outcome in words`);
19
+ return value;
20
+ }
21
+
22
+ /** A card's readable id as its creator or editor names it (requireReadableId), held by no other card on the board, open
23
+ * or ended (an ended card's branch and worktree keep its name). */
24
+ export function cardReadableId(db, requested, except = '') {
25
+ const value = requireReadableId(requested);
26
+ const holder = db.prepare('SELECT id,status FROM tasks WHERE readable_id=? AND id<>? LIMIT 1').get(value, except);
27
+ if (holder) throw new Error(`readable_id ${value} belongs to card ${holder.id} (${holder.status}); a card's readable id is unique on its board`);
28
+ return value;
29
+ }
30
+
9
31
  export function readableIdFromTitle(title) {
10
32
  const text = String(title).replace(/^(?:\[[^\]]+\]\s*)+/, '');
11
33
  const named = /^([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\s*:/.exec(text)?.[1];
@@ -14,6 +36,7 @@ export function readableIdFromTitle(title) {
14
36
  return (words.replace(/^[^a-z]+/, '') || 'card').slice(0, 32).replace(/-+$/, '');
15
37
  }
16
38
 
39
+ // A row no person named (a card's subtask, or a row a document gave none) is named from its title.
17
40
  export function allocateReadableId(db, title, requested = null, except = '') {
18
41
  const base = requested == null ? readableIdFromTitle(title) : validateReadableId(requested);
19
42
  const occupied = value => db.prepare("SELECT id FROM tasks WHERE readable_id=? AND id<>? AND status NOT IN ('done','cancelled','archived') LIMIT 1").get(value, except);
@@ -9,14 +9,14 @@ import { machineCall } from './doors.mjs';
9
9
  * daemon registered for its home); to another machine, the board's name as the caller, which that machine takes from
10
10
  * this machine's link (machine.mjs #callerOf).
11
11
  */
12
- export async function runtimeDoor(machine, method, params = {}, { timeout = 60_000, as = null } = {}) {
12
+ export async function runtimeDoor(machine, method, params = {}, { timeout = 60_000, as = null, signal = null } = {}) {
13
13
  try {
14
14
  if (!method.startsWith('harness.v1.')) throw Object.assign(new Error('the runtime door accepts harness.v1 methods'), { code: 'invalid_request' });
15
15
  const acting = as && !method.startsWith('harness.v1.sessions.');
16
16
  const named = !acting ? params
17
17
  : machine ? { ...params, caller: as }
18
18
  : { ...params, claim: { pid: process.pid, ppid: process.ppid, session: null, named: as, owner: String(process.pid) } };
19
- const result = await machineCall(machine, method, named, { timeoutMs: timeout });
19
+ const result = await machineCall(machine, method, named, { timeoutMs: timeout, signal });
20
20
  return { code: 0, stdout: `${JSON.stringify(result ?? null)}\n`, stderr: '' };
21
21
  } catch (error) {
22
22
  return { code: 1, stdout: '', stderr: `${JSON.stringify({ code: error?.code ?? 'unavailable', message: String(error?.message ?? error) })}\n` };
@@ -112,7 +112,7 @@ export function delegatedWork(address, edges, sessions) {
112
112
 
113
113
  /** Idle time belongs to a native idle interval, never to transcript freshness, and only to time the board watched:
114
114
  * it starts no earlier than `session.watch_since`, where the board's unbroken reading of the run began (its machine
115
- * linked again, the board restarted or resumed). Quiet the board could not see is not idleness. */
115
+ * linked again, or where resumedWatch put it after a stretch unread). Quiet the board could not see is not idleness. */
116
116
  export function idleInterval(session, live, observedAt) {
117
117
  const nativeSince = Number(live?.turn_since_ms);
118
118
  const read = nativeSince > 0 && nativeSince <= observedAt * 1000
@@ -121,6 +121,20 @@ export function idleInterval(session, live, observedAt) {
121
121
  return { since, quiet: Math.max(0, observedAt - since) };
122
122
  }
123
123
 
124
+ /** Where the board's watch of a run resumes after a stretch this process did not read it (the board restarted, or read
125
+ * nothing of the run for a while). The harness's own idle-since (its native state, read on the session's machine) is a
126
+ * reading of that stretch, not a guess about it: when the session reads idle with one, the watch resumes from it, but
127
+ * never before the run began, before its machine's record was last current without a break (`linkedSinceMs`: the
128
+ * board's own machine's last wake from sleep, null when unknown), or before a bound the board recorded (`watch_since`: its machine linked
129
+ * again, its card held). With no native idle-since, or its machine's link unknown, it resumes from this reading: quiet
130
+ * the board could not see is not idleness. Epoch seconds. */
131
+ export function resumedWatch(session, live, run, linkedSinceMs, observedAt) {
132
+ const nativeSince = Number(live?.turn_since_ms);
133
+ if (live?.turn !== 'idle' || !(nativeSince > 0) || nativeSince > observedAt * 1000 || linkedSinceMs == null) return observedAt;
134
+ return Math.min(observedAt, Math.max(Math.floor(nativeSince / 1000), Number(run?.started_at ?? 0),
135
+ Math.ceil(Number(linkedSinceMs) / 1000), Number(session?.watch_since ?? 0)));
136
+ }
137
+
124
138
  /** A question a run's session shows on its pane (VO-3395), as one line: its words and each option, numbered. */
125
139
  export function questionText(question) {
126
140
  if (!question) return '';
@@ -8,10 +8,34 @@
8
8
  // writer reads (the property sets, a file name, a number's form) alike.
9
9
  // - `storage` is the earlier contract, the modules' whole source. An owner from before `format` compares it alone, so a
10
10
  // candidate keeps sending it; an owner that knows `format` compares that when both generations send one.
11
+ // - `format` also digests what the documents' codec (@volter/ztrack) writes for a fixed card set: a codec writes the
12
+ // documents every reader parses, and a 2.x codec reads a numbered heading (`## VO-154 t_… — title`) as a card whose id
13
+ // is VO-154, so a codec change is a format change, refused at the handover like any other.
14
+ // - `readableBy` names earlier formats whose generations read what this one writes, so the owner can keep such a
15
+ // predecessor as the rollback of a format change (generation-owner.mjs). The claim is keyed on the format it was
16
+ // reviewed for (READABLE_BY): a writer change moves the format, which then names none until its own claim is reviewed.
17
+ // 2bfde37a… (the card-number release) is read by c23c3ca4… (release/0.5.482-fixes through 7510457a3), which passes
18
+ // over the preamble's kept-numbers line as text and reads every card of its documents (VO-3578 take61: the old code
19
+ // rebuilt from them names no VO-n card).
20
+ // 2964acd1… (the store drops the numbers its tasks were given before only cards were numbered, a bounded batch a transaction) is read by 2bfde37a…:
21
+ // only rows of supercode_card_numbers go, the sequence stays, and 2bfde37a numbers no task and gives none back.
22
+ // 04bc6948… (cards named by their readable id, #2452) is read by 2964acd1…: it adds no table, column or document text.
23
+ // Its new rows are task_events kinds (branch_named, worktree_made) that 2964acd1 stores and reads only by named kinds,
24
+ // so it ignores them; only its `gc` verb deletes them (an ended card's, past the retention), and the card's recorded
25
+ // path, while it has one, still names its folder. A branch_name (`voice-calls`) is text it reads as any branch,
26
+ // making that card's worktree in a folder named for its id; a subtask's snapshot carries the nullable readable_id the
27
+ // snapshot already had. A worktree 2964acd1 makes records only its path, which ends the claim (workspace.mjs branchClaim).
28
+ // f5dca0f9… (#2507: a change a newer head supersedes keeps only the history the connector carries) is read by
29
+ // 04bc6948…: it adds no table or column. Its supersede and trim rewrite older supercode_publications rows' snapshot
30
+ // text to the same JSON card with fewer comments, attempts and reviews and an `omitted` object, which 04bc6948 reads
31
+ // as any snapshot (workflow events yields it; its connector counts omitted from the lists it holds); a card's head,
32
+ // which every snapshot frame reads, stays complete. Its supercode_board_state key (publication_trim) is one 04bc6948
33
+ // never reads. A store `workflow compact` rebuilt is in incremental auto-vacuum, which SQLite opens and writes the
34
+ // same for any release.
11
35
  // `format` is null when a member READS names occurs more than once (the owner then compares `storage`).
12
36
  import { createHash } from 'node:crypto';
13
37
  import fs, { readFileSync } from 'node:fs';
14
- import { syncBuiltinESMExports } from 'node:module';
38
+ import { createRequire, syncBuiltinESMExports } from 'node:module';
15
39
  import { DatabaseSync } from 'node:sqlite';
16
40
  import { layOut } from './store.mjs';
17
41
 
@@ -26,6 +50,13 @@ const READS = {
26
50
  };
27
51
 
28
52
  const source = (file) => readFileSync(new URL(file, import.meta.url), 'utf8');
53
+ const require = createRequire(import.meta.url);
54
+ // What the codec writes for two cards, one open and one ended, each given a number as a numbering codec would show it.
55
+ function renders() {
56
+ const codec = require('@volter/ztrack/board-document');
57
+ const cards = [{ id: 't_00000000', title: 'format', status: 'todo', body: 'A card.' }, { id: 't_00000001', title: 'ended', status: 'done', body: '' }];
58
+ return cards.map((card, i) => codec.renderBoardCard({ ...card, number: i + 1, key: 'KB' }, 2));
59
+ }
29
60
  // A member's lines in its module: from its first line (` name(` in a class, `function name(` or `const name =` at the
30
61
  // top) to the line that closes it at the same indent, or that line alone when it ends the member. None when absent.
31
62
  function without(text, name) {
@@ -72,14 +103,21 @@ function format() {
72
103
  const layout = db.prepare("SELECT type, name, tbl_name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name, tbl_name").all()
73
104
  .map(({ type, name, tbl_name: table, sql }) => [type, name, table, sql]);
74
105
  const modules = MODULES.map((file) => [file, code((READS[file] ?? []).reduce(without, source(file)))]);
75
- return createHash('sha256').update(JSON.stringify({ layout, modules })).digest('hex');
106
+ return createHash('sha256').update(JSON.stringify({ layout, modules, renders: renders() })).digest('hex');
76
107
  } finally { db.close(); }
77
108
  }
78
109
 
110
+ const READABLE_BY = {
111
+ '2bfde37add33506a56e910c49c9643e91a4f96d36378daeb8cdae3018835a089': ['c23c3ca4bd184cd9c6d9312f4c3fd0ab7f8bc5bde4926f01d32951a641ebde7d'],
112
+ '2964acd14bca601e85ffaccc160bf058ee327f4aafb9ee5a0d8559fe509632e1': ['2bfde37add33506a56e910c49c9643e91a4f96d36378daeb8cdae3018835a089'],
113
+ '04bc6948582c8de470a7c5301ede239bcf26730f7a1d725ccde7fb03aecf9ba5': ['2964acd14bca601e85ffaccc160bf058ee327f4aafb9ee5a0d8559fe509632e1'],
114
+ 'f5dca0f9a1337a7f807b8a21c39826bfd5c165bcb6a3d847fb9fb31a9d620afb': ['04bc6948582c8de470a7c5301ede239bcf26730f7a1d725ccde7fb03aecf9ba5'],
115
+ };
116
+
79
117
  export function storageContract() {
80
118
  const storage = createHash('sha256');
81
119
  for (const file of MODULES) storage.update(file).update(readFileSync(new URL(file, import.meta.url)));
82
120
  let formatDigest = null;
83
121
  try { formatDigest = format(); } catch (error) { process.stderr.write(`board storage contract: no format digest (${error.message}); the owner compares storage\n`); }
84
- return { storage: storage.digest('hex'), format: formatDigest };
122
+ return { storage: storage.digest('hex'), format: formatDigest, readableBy: (formatDigest && READABLE_BY[formatDigest]) || [] };
85
123
  }