@volter/supercode-orchestrator 0.5.75 → 0.5.76

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/board/cli.mjs CHANGED
@@ -19,13 +19,13 @@ import { holdLock } from './lock-holder.mjs';
19
19
  import { takeEpoch } from './fence.mjs';
20
20
  import { dispatcherNotice, holdOwnerHealth, holdRound, holdSwitch } from './owner-health.mjs';
21
21
  import { boardTool } from './tools.mjs';
22
- import { recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
22
+ import { noteStep, recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
23
23
  // read by name, so an older harness SDK without them still loads: the owner then keeps no per-verb or loop lines
24
24
  import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
25
25
  import { basename, dirname, join, resolve } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { homedir, tmpdir, userInfo } from 'node:os';
28
- import { actorOf, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
28
+ import { actorOf, heldConfigWatched, heldDocumentsChanged, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
29
29
  import { isLocalWorkspace } from './workspace.mjs';
30
30
  import { openZtrackBoard } from './ztrack.mjs';
31
31
  import * as engine from './engine.mjs';
@@ -1648,7 +1648,33 @@ async function serve(root, args = {}) {
1648
1648
  // a held event loop is every caller's wait at once: each hold over a second is a slow line naming what held it
1649
1649
  const unwatchLoop = slowLog.watchEventLoop?.({ name: 'board owner event loop blocked' });
1650
1650
  log(`owning the boards of ${root} on ${localMachine()} (orchestrator ${running ?? 'unknown'}); dispatching where a board's dispatching is on (\`supercode workflow dispatching\`)`);
1651
- events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
1651
+ // a document event reaches the held boards as it arrives: their next read takes the documents' new stamps
1652
+ // a document event, or the watch saying it may have lost events, reaches the held boards as it arrives: their next
1653
+ // read takes the documents' new stamps; while a document's folder is unwatched they read the stamps on every read
1654
+ // Trust in what the owner holds is per board, per source and per configuration generation. A board's configuration
1655
+ // event (or the home's registry) retires that board's held generation, and the watch set is refreshed at once so a
1656
+ // replacement's documents are watched before they are loaded; a lost configuration watch keeps the board unheld
1657
+ // until it is watched again. A board's own backing source changing, or its watch saying it may have lost events,
1658
+ // makes that board read its files again; while one of its sources is unwatched, it reads them on every use.
1659
+ const sourceOf = (key) => key.startsWith('document:') || key === 'board-storage';
1660
+ // a refresh's own reports (a configuration it could not read) never start another refresh
1661
+ let refreshing = false;
1662
+ const refreshNow = () => { if (refreshing) return; refreshing = true; try { events?.refresh(); } finally { refreshing = false; } };
1663
+ events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log, onEvent: (key, { uncertain = false, watched, id, board = null } = {}) => {
1664
+ if (key === 'board-registry') { retireHeldBoards(); refreshNow(); return; }
1665
+ if (key === 'board-config') {
1666
+ if (watched !== undefined && board) heldConfigWatched(board, id ?? key, watched);
1667
+ retireHeldBoards(board); refreshNow(); return;
1668
+ }
1669
+ if (!sourceOf(key)) return;
1670
+ heldDocumentsChanged(board);
1671
+ if (watched !== undefined && board) heldDocumentsWatched(board, id ?? key, watched);
1672
+ else if (uncertain && !board) heldDocumentsChanged();
1673
+ } });
1674
+ // the owner holds its boards only now, its watch installed (watch before load): opened once, changed by events
1675
+ holdBoards(root);
1676
+ // a pause of the collector holds the loop like any synchronous step: each is a step, so a block line names it
1677
+ try { const { PerformanceObserver } = await import('node:perf_hooks'); new PerformanceObserver((list) => { for (const entry of list.getEntries()) noteStep('gc', entry.duration); }).observe({ entryTypes: ['gc'] }); } catch { /* no gc entries here */ }
1652
1678
  // The board's events are published by this machine's connector, not by the dispatcher (D137, D138 step 4): the home is
1653
1679
  // named to the daemon once, and the connector follows its `workflow events` from then on, with or without this serve.
1654
1680
  { let named = null, refusal = null;
@@ -1676,13 +1702,14 @@ async function serve(root, args = {}) {
1676
1702
  const began = Date.now();
1677
1703
  if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
1678
1704
  wakeReason = null;
1705
+ const loopStep = async (name, fn) => { const at = performance.now(); try { return await fn(); } finally { noteStep(`owner loop: ${name}`, performance.now() - at); } };
1679
1706
  try {
1680
- const state = await ownCall(() => laneState(root));
1707
+ const state = await loopStep('lane state', () => ownCall(() => laneState(root)));
1681
1708
  const settings = { ...state.settings, ...override };
1682
1709
  interval = settings.interval;
1683
1710
  // Publishing is the owner's, dispatching or not: what a writer outside this process marked changed is published
1684
1711
  // on the wake its write caused, and the retained history kept bounded (store.mjs publishMarked).
1685
- const published = await ownCall(async () => publishMarked(root));
1712
+ const published = await loopStep('publish marked', () => ownCall(async () => publishMarked(root)));
1686
1713
  if (published && args.verbose) log(`published ${published} card(s) a writer outside the owner changed`);
1687
1714
  // Dispatching is a controller over the boards whose own state says it is on; the owner answers verbs either way.
1688
1715
  const dispatching = listBoards(root).filter((slug) => dispatchingOf(root, slug).on);
@@ -1722,7 +1749,7 @@ async function serve(root, args = {}) {
1722
1749
  const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
1723
1750
  let moved = newer();
1724
1751
  // the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
1725
- events.refresh();
1752
+ await loopStep('wake refresh', () => refreshNow());
1726
1753
  if (!stopped && !moved) {
1727
1754
  wakeReason = await events.wait(Math.max(0, Math.min(interval * 1000 - (Date.now() - began), nextMailAt - Date.now())));
1728
1755
  // A busy event stream is coalesced to at most one tick per second.
@@ -1735,7 +1762,7 @@ async function serve(root, args = {}) {
1735
1762
  } finally {
1736
1763
  // a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
1737
1764
  try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
1738
- unwatchLoop?.(); closeVerbs();
1765
+ unwatchLoop?.(); releaseHeldBoards(); closeVerbs();
1739
1766
  unfollowHome();
1740
1767
  await closeDoors();
1741
1768
  events?.close();
package/board/files.mjs CHANGED
@@ -33,14 +33,18 @@ function writeAtomic(file, text) {
33
33
  * Open the files board in `dir`. `init(db, { fresh })` lays the board's schema over a loaded database (the store's:
34
34
  * Hermes's tables on a fresh one, supercode's own table and columns on every one). Answers `{ db, tx, close }`.
35
35
  */
36
- export function openFilesBoard(dir, { create = false, init }) {
36
+ export function openFilesBoard(dir, { create = false, init, held = false }) {
37
37
  if (!isFilesBoard(dir)) {
38
38
  if (!create) throw new Error(`${dir} is not a board`);
39
39
  mkdirSync(dir, { recursive: true });
40
40
  }
41
41
  const board = new FilesBoard(dir, init);
42
+ // held by the board owner: its files are read again only when the owner's watch says they changed or may have lost
43
+ // events, and on every read while it keeps no watch on them; a write still compares the files it read (persist)
44
+ board.held = held;
42
45
  if (!isFilesBoard(dir)) board.tx(() => {}, { force: true });
43
- return { db: board.db, tx: (fn) => board.tx(fn), close: () => board.close() };
46
+ return { db: board.db, tx: (fn) => board.tx(fn), close: () => board.close(),
47
+ documentsChanged: () => { board.changed = true; }, documentsUnwatched: (unwatched) => { board.unwatched = unwatched; } };
44
48
  }
45
49
 
46
50
  class FilesBoard {
@@ -217,7 +221,10 @@ class FilesBoard {
217
221
  }
218
222
 
219
223
  read(fn) {
220
- if (!this.inTx && this.stampOf() !== this.stamp) this.load();
224
+ // a held board knows its files until an event says otherwise; one opened per use reads them on each query
225
+ const known = this.held && !this.changed && !this.unwatched && this.mem && this.stamp !== null;
226
+ // the mark clears only once the files were read: a read that fails leaves it, and the next read tries again
227
+ if (!this.inTx && !known) { if (this.stampOf() !== this.stamp) this.load(); if (this.held) this.changed = false; }
221
228
  return fn();
222
229
  }
223
230
 
@@ -132,6 +132,7 @@ export class GitDocument {
132
132
  this.integrate(branch, 'board');
133
133
  }
134
134
  if (this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
135
+ this.refreshLateness();
135
136
  }
136
137
 
137
138
  // After a write: commit the board documents as their doer, then push, taking anything pushed meanwhile.
@@ -145,6 +146,7 @@ export class GitDocument {
145
146
  if (!git(this.root, ['diff', '--cached', '--name-only', '--', ...files]).trim()) return;
146
147
  const who = doer || 'board';
147
148
  indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--author', `${who} <board@supercode.invalid>`, '-m', message, '--', ...files]);
149
+ this.refreshLateness();
148
150
  const branch = this.branch();
149
151
  if (!branch) return this.detached();
150
152
  // The push leaves the write's path: the commit is the write, and a push the remote refuses is taken up by the next
@@ -166,6 +168,7 @@ export class GitDocument {
166
168
  const done = () => {
167
169
  if (this.pushing !== pushing) return;
168
170
  this.pushing = null;
171
+ this.refreshLateness();
169
172
  if (this.again) this.pushInBackground(branch);
170
173
  };
171
174
  pushing.on('error', done); pushing.on('exit', done);
@@ -178,6 +181,29 @@ export class GitDocument {
178
181
 
179
182
  /** The board documents' publish when it is behind: commits the remote lacks, the oldest one's time and the last push's
180
183
  * error; null when the remote has everything (or this checkout pushes nowhere). Local reads only, no network. */
184
+ /** A held board's lateness: read once beside the owner's loop, then again after each of its own commits, pushes and
185
+ * takes (refreshLateness), never by spawning git on an open. `lateness` is that reading (null while none is owed). */
186
+ holdLateness() { this.heldLate = true; this.lateness = null; this.refreshLateness(); }
187
+ refreshLateness() {
188
+ if (!this.heldLate || !this.usable) return;
189
+ if (this.lateReading) { this.lateAgain = true; return; }
190
+ const root = this.root;
191
+ const run = (args) => new Promise((resolve) => execFile('git', args, { cwd: root, timeout: 30_000 }, (error, out) => resolve(error ? null : String(out))));
192
+ this.lateReading = (async () => {
193
+ const name = (await run(['rev-parse', '--abbrev-ref', 'HEAD']))?.trim();
194
+ if (!name || name === 'HEAD') return null;
195
+ const times = ((await run(['log', '--format=%ct', `origin/${name}..HEAD`])) ?? '').trim().split('\n').filter(Boolean).map(Number);
196
+ if (!times.length) return null;
197
+ const failure = (await run(['rev-parse', '--git-path', 'supercode-board-push.err']))?.trim().replace(/^(?!\/)/, `${root}/`);
198
+ let error = null;
199
+ try { error = failure ? readFileSync(failure, 'utf8').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? null : null; } catch { /* none recorded */ }
200
+ return { commits: times.length, since: Math.min(...times) * 1000, error };
201
+ })().then((late) => { this.lateness = late; }, () => {}).finally(() => {
202
+ this.lateReading = null;
203
+ if (this.lateAgain) { this.lateAgain = false; this.refreshLateness(); }
204
+ });
205
+ }
206
+
181
207
  behind() {
182
208
  if (!this.usable) return null;
183
209
  const branch = this.branch();
package/board/store.mjs CHANGED
@@ -172,7 +172,87 @@ function noteOrigin(root) {
172
172
  copies.set(root, origin === here ? null : origin);
173
173
  }
174
174
 
175
- export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null, declared = null } = {}) {
175
+ /**
176
+ * The boards the owner holds (overview.md, the board owner: a held home view, changed by events). In the process that
177
+ * owns a home, every board is opened once and held: each verb, wake and round gets a handle of its own over the held
178
+ * board (its own `label`; its `close` releases nothing), so none re-opens the store, re-reads its schema, re-hashes
179
+ * its documents or asks git again for what the owner already holds. What changes a held board is an event: its own
180
+ * writes, and the document changes the owner's watch reports (heldDocumentsChanged). Elsewhere (a CLI process, a
181
+ * worker) a board is opened per use, as before.
182
+ */
183
+ let holding = null;
184
+ const heldBoards = new Map(); // slug → { board, uses: handles open on it, retired }: one per configuration generation
185
+ const unwatchedSources = new Map(); // slug → its backing sources (watched folders) the owner's watch does not keep
186
+ const untrustedConfig = new Map(); // slug → its configuration's watches the owner does not keep
187
+ const realRoot = (root) => { try { return realpathSync(root); } catch { return root; } };
188
+ /** The owner holds its boards from here on: called once its watch is installed (watch before load, D187). */
189
+ export function holdBoards(root) { holding = realRoot(root); }
190
+ export function releaseHeldBoards() { retireHeldBoards(); holding = null; }
191
+ /**
192
+ * A board's configuration changed, or the home's registry did (their own events): the held boards are retired, so the
193
+ * next use opens what the configuration now says, as a new generation. A retired generation refuses every write (a
194
+ * call that held it across an await writes nothing under the old configuration) and closes with its last handle.
195
+ */
196
+ export function retireHeldBoards(slug = null) {
197
+ for (const [held, entry] of [...heldBoards]) {
198
+ if (slug !== null && held !== slug) continue;
199
+ heldBoards.delete(held); entry.retired = true;
200
+ if (!entry.uses) try { entry.board.close(); } catch { /* closed */ }
201
+ }
202
+ }
203
+ /** A board's backing source changed, or its watch may have lost events: that board reads its files again on its next
204
+ * read, and only then (all boards, with no slug). */
205
+ export function heldDocumentsChanged(slug = null) {
206
+ for (const [held, { board }] of heldBoards) if (slug === null || held === slug) board.documentsChanged?.();
207
+ }
208
+ /** Whether the owner's watch keeps one of a board's backing sources: while any is unwatched, that board reads its files
209
+ * on every use. */
210
+ export function heldDocumentsWatched(slug, source, watched) {
211
+ const set = unwatchedSources.get(slug) ?? new Set();
212
+ if (watched) set.delete(source); else set.add(source);
213
+ unwatchedSources.set(slug, set);
214
+ heldBoards.get(slug)?.board.documentsUnwatched?.(set.size > 0);
215
+ }
216
+ /** Whether the owner's watch keeps a board's configuration: while it does not, the board is not held (each use reads
217
+ * its configuration), and a held generation is retired. */
218
+ export function heldConfigWatched(slug, source, watched) {
219
+ const set = untrustedConfig.get(slug) ?? new Set();
220
+ if (watched) set.delete(source); else set.add(source);
221
+ untrustedConfig.set(slug, set);
222
+ if (set.size) retireHeldBoards(slug);
223
+ }
224
+
225
+ export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
226
+ if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
227
+ let entry = heldBoards.get(slug);
228
+ if (!entry) {
229
+ const began = performance.now();
230
+ let board;
231
+ try { board = openBoardNow(root, slug, { ...options, held: true }); } finally { noteStep('board open (held, once)', performance.now() - began); }
232
+ entry = { board, uses: 0, retired: false };
233
+ heldBoards.set(slug, entry);
234
+ board.documentsUnwatched?.((unwatchedSources.get(slug)?.size ?? 0) > 0);
235
+ }
236
+ entry.uses += 1;
237
+ entry.board.saidOnOpen?.();
238
+ return heldHandle(entry, slug);
239
+ }
240
+ return openBoardNow(root, slug, options);
241
+ }
242
+
243
+ /** A call's handle on a held board: its own label, a close that releases its use, and every write refused once the
244
+ * generation it was opened on is retired. */
245
+ function heldHandle(entry, slug) {
246
+ const guard = () => { if (entry.retired) throw Object.assign(new Error(`board ${slug}'s configuration changed while this call held it; nothing was written (read it again)`), { code: 'board_retired' }); };
247
+ const bound = (target, key) => { const value = target[key]; return typeof value === 'function' ? value.bind(target) : value; };
248
+ const statement = (st) => new Proxy(st, { get: (target, key) => key === 'run' ? (...args) => { guard(); return target.run(...args); } : bound(target, key) });
249
+ const db = new Proxy(entry.board.db, { get: (target, key) => key === 'prepare' ? (sql) => statement(target.prepare(sql)) : key === 'exec' ? (sql) => { guard(); return target.exec(sql); } : bound(target, key) });
250
+ let closed = false;
251
+ return { ...entry.board, db, tx: (fn) => { guard(); return entry.board.tx(fn); },
252
+ close() { if (closed) return; closed = true; entry.uses -= 1; if (entry.retired && !entry.uses) try { entry.board.close(); } catch { /* closed */ } } };
253
+ }
254
+
255
+ function openBoardNow(root, slug, { create = false, backing = null, document = null, declared = null, held = false } = {}) {
176
256
  noteOrigin(root);
177
257
  const kept = backingOf(root, slug);
178
258
  if (!kept && !create) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
@@ -180,8 +260,8 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
180
260
  const use = kept ?? backing ?? 'sqlite';
181
261
  if (!BACKINGS.includes(use)) throw new Error(`a board's backing is ${BACKINGS.join(' | ')}, not ${use}`);
182
262
  const path = backingPaths(root, slug)[use];
183
- if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, declared, init: layOut, root, slug }), root, slug, path, backing: use };
184
- if (use === 'files') return { ...openFilesBoard(path, { create, init: layOut }), root, slug, path, backing: use };
263
+ if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, declared, init: layOut, root, slug, held }), root, slug, path, backing: use };
264
+ if (use === 'files') return { ...openFilesBoard(path, { create, init: layOut, held }), root, slug, path, backing: use };
185
265
  if (!kept) mkdirSync(dirname(path), { recursive: true });
186
266
  const db = new DatabaseSync(path);
187
267
  db.exec('PRAGMA foreign_keys = OFF');
@@ -231,13 +311,14 @@ export function publishMarked(root) {
231
311
  let published = 0;
232
312
  for (const slug of listBoards(root)) {
233
313
  let board;
314
+ const step = (name, fn) => { const began = performance.now(); try { return fn(); } finally { noteStep(`publish marked: ${name}`, performance.now() - began); } };
234
315
  try {
235
- board = openBoard(root, slug);
316
+ board = step('open', () => openBoard(root, slug));
236
317
  // a mark in the store, or a backing edit not yet imported into its tables (a ztrack document): the transaction
237
318
  // imports the edit, which marks its cards, and publishes them
238
- const marked = board.db.prepare('SELECT 1 FROM supercode_publication_dirty LIMIT 1').get() || board.unimported?.();
239
- if (marked) published += board.tx((db) => recordCardPublications(db, actorOf()));
240
- if (pruneDue(board.db)) board.tx((db) => prunePublications(db));
319
+ const marked = step('marks', () => board.db.prepare('SELECT 1 FROM supercode_publication_dirty LIMIT 1').get()) || step('unimported', () => board.unimported?.());
320
+ if (marked) published += step('publish', () => board.tx((db) => recordCardPublications(db, actorOf())));
321
+ if (step('prune due', () => pruneDue(board.db))) step('prune', () => board.tx((db) => prunePublications(db)));
241
322
  } finally { board?.close(); }
242
323
  }
243
324
  return published;
package/board/wake.mjs CHANGED
@@ -16,35 +16,55 @@ export function signalBoardWrite(root, actor) {
16
16
  }
17
17
 
18
18
  /** One coalescing queue shared by standalone serve and the hosted dispatcher. */
19
- export function watchBoard({ root, binary, env, log = () => {} }) {
19
+ export function watchBoard({ root, binary, env, log = () => {}, onEvent = () => {} }) {
20
20
  const watchers = new Map();
21
21
  let pending = null, waiter = null, closed = false;
22
- const wake = (source, at = Date.now()) => {
22
+ const wake = (source, at = Date.now(), info = {}) => {
23
23
  if (closed) return;
24
+ // each event as it arrives, with the board whose source it is (a held board learns its files changed now)
25
+ try { onEvent(source, info); } catch { /* an observer cannot stop a wake */ }
24
26
  if (!pending) pending = { at, sources: new Set() };
25
27
  pending.at = Math.min(pending.at, at);
26
28
  pending.sources.add(source);
27
29
  waiter?.();
28
30
  };
29
- const directory = (path, key, accept) => {
30
- const id = `${path}\0${key}`;
31
- if (watchers.has(id) || !existsSync(path)) return;
31
+ // The watch says when it may have lost events: a pathless event, a watch that failed, one that could not be made.
32
+ // An observer is told (uncertain), and told again when the watch is kept once more (watched), so what it holds from
33
+ // these events is read again only then, never on a timer.
34
+ const unwatched = new Set();
35
+ const tell = (key, info) => { try { onEvent(key, info); } catch { /* an observer cannot stop a watch */ } };
36
+ // `board` names the board whose backing source a watch is (null for the home's own files): its observer keeps trust
37
+ // per board and per source.
38
+ // `name` tells apart two watches of one folder under one key (an alias and its target side by side): each keeps its
39
+ // own filter, so each source's events reach its board
40
+ const directory = (path, key, accept, board = null, name = '') => {
41
+ const id = `${path}\0${key}\0${name}`;
42
+ if (watchers.has(id)) return;
43
+ // a folder not there yet is unwatched, said (never assumed quiet): the next refresh watches it once it exists
44
+ if (!existsSync(path)) { if (!unwatched.has(id)) { unwatched.add(id); tell(key, { uncertain: true, watched: false, id, board }); } return; }
32
45
  try {
33
46
  const handle = watch(path, (_event, filename) => {
34
- if (filename == null || accept(String(filename))) wake(key);
47
+ if (filename == null) { tell(key, { uncertain: true, board }); wake(key, undefined, { board }); return; }
48
+ if (accept(String(filename))) wake(key, undefined, { board });
35
49
  });
36
50
  handle.on('error', error => {
37
- handle.close(); watchers.delete(id);
51
+ handle.close(); watchers.delete(id); unwatched.add(id);
52
+ tell(key, { uncertain: true, watched: false, id, board });
38
53
  log(`dispatch watch ${key} failed: ${error.message}; safety sweep remains active`);
39
54
  });
40
55
  watchers.set(id, handle);
41
- } catch (error) { log(`dispatch watch ${key} unavailable: ${error.message}; safety sweep remains active`); }
56
+ if (unwatched.delete(id)) tell(key, { uncertain: true, watched: true, id, board });
57
+ } catch (error) {
58
+ if (!unwatched.has(id)) { unwatched.add(id); tell(key, { uncertain: true, watched: false, id, board }); }
59
+ log(`dispatch watch ${key} unavailable: ${error.message}; safety sweep remains active`);
60
+ }
42
61
  };
43
- const file = (path, key) => {
44
- directory(dirname(path), key, name => name === basename(path));
62
+ const file = (path, key, board = null) => {
63
+ directory(dirname(path), key, name => name === basename(path), board, basename(path));
45
64
  try {
65
+ // an alias is followed to its target, each filtered by its own name
46
66
  const target = realpathSync(path);
47
- if (target !== path) directory(dirname(target), key, name => name === basename(target));
67
+ if (target !== path) directory(dirname(target), key, name => name === basename(target), board, `target:${basename(target)}`);
48
68
  } catch { /* the parent watcher sees a newly created file */ }
49
69
  };
50
70
  const refresh = () => {
@@ -58,22 +78,27 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
58
78
  const boards = join(root, 'kanban', 'boards');
59
79
  directory(join(root, 'kanban'), 'board-registry', name => ['boards', 'ztrack.json', 'current'].includes(name));
60
80
  directory(boards, 'board-registry', () => true);
61
- const dirs = [join(root, 'kanban'), ...(existsSync(boards) ? readdirSync(boards).map(name => join(boards, name)) : [])];
62
- file(join(root,'kanban.db'),'board-storage');
63
- file(join(root,'kanban.db-wal'),'board-storage');
64
- for (const dir of dirs) {
65
- file(join(dir,'kanban.db'),'board-storage');
66
- file(join(dir,'kanban.db-wal'),'board-storage');
67
- directory(join(dir,'board'),'board-storage',name=>name.endsWith('.json'));
68
- file(join(dir, 'ztrack.json'), 'board-config');
69
- try {
70
- const config = JSON.parse(readFileSync(join(dir, 'ztrack.json'), 'utf8'));
71
- const document = resolve(dir, config.document);
72
- file(document, `document:${document}`);
73
- // the current archive and each rotated one (`<document>.archive-<date>.md`)
74
- const own = basename(document);
75
- directory(dirname(document), `document:${document}.archive`, name => name === `${own}.archive.md` || (name.startsWith(`${own}.archive-`) && name.endsWith('.md')));
76
- } catch { /* other backing or not yet created */ }
81
+ // each board's own backing sources, and only those: a ztrack board's configuration and documents, a files board's
82
+ // folder, a SQLite board's store (a source a board does not have is not watched, so its absence says nothing)
83
+ const dirs = [[join(root, 'kanban'), 'default'], ...(existsSync(boards) ? readdirSync(boards).map(name => [join(boards, name), name]) : [])];
84
+ for (const [dir, board] of dirs) {
85
+ file(join(dir, 'ztrack.json'), 'board-config', board);
86
+ if (existsSync(join(dir, 'ztrack.json'))) {
87
+ try {
88
+ const config = JSON.parse(readFileSync(join(dir, 'ztrack.json'), 'utf8'));
89
+ const document = resolve(dir, config.document);
90
+ file(document, `document:${document}`, board);
91
+ // the current archive and each rotated one (`<document>.archive-<date>.md`)
92
+ const own = basename(document);
93
+ directory(dirname(document), `document:${document}.archive`, name => name === `${own}.archive.md` || (name.startsWith(`${own}.archive-`) && name.endsWith('.md')), board);
94
+ } catch { tell('board-config', { uncertain: true, board }); }
95
+ } else if (existsSync(join(dir, 'board', '_board.json'))) {
96
+ directory(join(dir, 'board'), 'board-storage', name => name.endsWith('.json'), board);
97
+ } else {
98
+ const store = board === 'default' ? join(root, 'kanban.db') : join(dir, 'kanban.db');
99
+ file(store, 'board-storage', board);
100
+ file(`${store}-wal`, 'board-storage', board);
101
+ }
77
102
  }
78
103
  const config = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'supercode') : join(env.HOME ?? '', '.config', 'supercode'));
79
104
  const mail = join(config, 'mail');
package/board/ztrack.mjs CHANGED
@@ -62,7 +62,7 @@ DROP TABLE IF EXISTS board_export;`;
62
62
  // written to the document). board_tasks: a subtask's key on its arc's Tasks list
63
63
  // (`<arc>:c3`) and its `source:` lines, which live only in the document.
64
64
 
65
- export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug }) {
65
+ export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug, held = false }) {
66
66
  if (!existsSync(marker)) {
67
67
  if (!create) throw new Error(`no ztrack board ${marker}`);
68
68
  mkdirSync(dirname(marker), { recursive: true });
@@ -76,13 +76,18 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
76
76
  atomic(marker, JSON.stringify({ format: 1, document: join(realpathSync(dirname(named)), basename(named)), ...said }) + '\n');
77
77
  }
78
78
  const config = JSON.parse(read(marker));
79
- const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug });
80
- return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path };
79
+ const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held });
80
+ return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
81
+ documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
81
82
  }
82
83
 
83
84
 
84
85
  class ZtrackBoard {
85
- constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {} }) {
86
+ constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {}, held = false }) {
87
+ // held by the board owner (store.mjs holdBoards): opened once, its documents' stamps read again only when the
88
+ // owner's watch says they changed or may have lost events (documentsChanged), and on every read while it keeps no
89
+ // watch on them (unwatched); git's lateness held and refreshed on its own commits
90
+ this.held = held; this.docsChanged = true; this.importDue = true; this.knownStamps = null;
86
91
  this.path = resolve(dirname(marker), document);
87
92
  this.archive = `${this.path}.archive.md`;
88
93
  this.baseDocs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
@@ -98,13 +103,10 @@ class ZtrackBoard {
98
103
  this.git = commit ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
99
104
  // A publish that is behind is said once per call, on whichever workflow verb opened the board: a refused push
100
105
  // surfaces on the next call instead of only in the dispatcher's report, and stops being said once the push lands.
101
- const said = current().said;
102
- if (this.git && !said.has(`behind:${this.path}`)) {
103
- said.add(`behind:${this.path}`);
104
- const late = timedStep('board open git behind', () => this.git.behind());
105
- // A push younger than a minute is still on its way.
106
- if (late && Date.now() - late.since > 60_000) current().err(`board: warning: ${late.commits} board document commit${late.commits === 1 ? '' : 's'} not published since ${new Date(late.since).toISOString().slice(0, 16)}Z${late.error ? ` (last push: ${late.error})` : ''}; the dispatcher's next round retries\n`);
107
- }
106
+ // A held board reads git's state once, beside the owner's loop, and again after its own commits and pushes; an
107
+ // open per use reads it now.
108
+ if (this.git && held) this.git.holdLateness();
109
+ else if (this.git) this.sayLate(timedStep('board open git behind', () => this.git.behind()));
108
110
  this.seed = seed; this.root = root; this.slug = slug; this.init = init;
109
111
  this.file = `${marker}.board.db`;
110
112
  mkdirSync(dirname(this.path), { recursive: true });
@@ -137,6 +139,16 @@ class ZtrackBoard {
137
139
  // a board that is only read, or whose owner runs no round, still finishes an earlier process's commit.
138
140
  if (seed || this.git?.underived) this.tx(() => {});
139
141
  }
142
+ /** The publish's lateness, said once per call (a held board's from its held reading, never by asking git). */
143
+ sayLate(late = this.git?.lateness ?? null) {
144
+ const said = current().said;
145
+ if (!this.git || said.has(`behind:${this.path}`)) return;
146
+ said.add(`behind:${this.path}`);
147
+ // A push younger than a minute is still on its way.
148
+ if (late && Date.now() - late.since > 60_000) current().err(`board: warning: ${late.commits} board document commit${late.commits === 1 ? '' : 's'} not published since ${new Date(late.since).toISOString().slice(0, 16)}Z${late.error ? ` (last push: ${late.error})` : ''}; the dispatcher's next round retries\n`);
149
+ }
150
+ /** The owner's watch saw this board's documents change: their stamps are read on the next read, and only then. */
151
+ documentsChanged() { this.docsChanged = true; this.importDue = true; }
140
152
  // The board's documents: the open one, the current archive, and each rotated archive (recorded in the board's own
141
153
  // state, so a rotation committed but not yet written is still a document of the board).
142
154
  get docs() {
@@ -360,21 +372,30 @@ class ZtrackBoard {
360
372
  // A reader sees a hand edit at once, through connection-local copies of the card tables, and
361
373
  // writes nothing: the next write records the edit.
362
374
  observeForRead() {
363
- const stamp = [...this.docs.map((doc) => this.fileHash(doc.path).stamp), this.stateText('published'), this.stateText('pending_hashes')].join('|');
375
+ // a held board knows its documents' stamps until the owner's watch says they changed; one opened per use reads them
376
+ const stamps = this.held && !this.docsChanged && !this.unwatched && this.knownStamps ? this.knownStamps
377
+ : timedStep('board read: document stamps', () => this.docs.map((doc) => this.fileHash(doc.path).stamp));
378
+ if (this.held) { this.knownStamps = stamps; this.docsChanged = false; }
379
+ const stamp = [...stamps, this.stateText('published'), this.stateText('pending_hashes')].join('|');
364
380
  if (stamp === this.seen) return;
365
381
  this.seen = stamp;
366
382
  this.loadErrors();
367
- const stale = this.staleDocuments({ pending: true });
383
+ const stale = timedStep('board read: stale check', () => this.staleDocuments({ pending: true }));
368
384
  if (!stale.length) { this.dropOverlay(); return; }
369
385
  let plan;
370
- try { plan = this.observe(stale); } catch { this.dropOverlay(); return; } // the next write reports it
371
- this.applyPlan(plan, { overlay: true });
386
+ try { plan = timedStep('board read: observe', () => this.observe(stale)); } catch { this.dropOverlay(); return; } // the next write reports it
387
+ timedStep('board read: overlay', () => this.applyPlan(plan, { overlay: true }));
372
388
  }
373
389
 
374
390
  // Whether a document holds an edit its tables have not imported (a hand edit, a git update): the publication owner
375
391
  // enters a transaction for it, which imports it and so marks its cards. Unreadable reads as yes: the write reports it.
376
392
  unimported() {
377
- try { return this.staleDocuments({ pending: true }).length > 0; } catch { return true; }
393
+ // a held board looks again only after a document event (or while unwatched): a quiet round hashes nothing
394
+ if (this.held && !this.importDue && !this.unwatched) return false;
395
+ let stale;
396
+ try { stale = timedStep('board unimported check', () => this.staleDocuments({ pending: true }).length > 0); } catch { return true; }
397
+ if (!stale) this.importDue = false;
398
+ return stale;
378
399
  }
379
400
 
380
401
  dropOverlay() {
@@ -436,11 +457,14 @@ class ZtrackBoard {
436
457
  // accepted, with every document the board has; a recovery in any later write is committed the same way.
437
458
  if (this.git && (this.git.underived || outcome.recovered)) {
438
459
  this.git.paths = this.docs.map((doc) => doc.path);
460
+ if (outcome.recovered) this.docsChanged = true;
439
461
  try { timedStep('board git derive', () => outcome.recovered && !this.git.underived ? this.git.commitDirty('board: documents another write accepted') : this.git.derive()); }
440
462
  catch (error) { current().err(`board: ${error.message}\n`); }
441
463
  }
442
464
  if (outcome.intent) {
443
465
  this.publish(outcome);
466
+ // its own documents written: an event of its own, read on the next read
467
+ this.docsChanged = true;
444
468
  // Committed as the session (or person) whose act this write was, and pushed.
445
469
  const byManager = this.manager !== null && (actorOf() === this.manager || principalOf() === this.manager);
446
470
  if (this.git) this.git.paths = this.docs.map((doc) => doc.path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.75",
3
+ "version": "0.5.76",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {