@volter/supercode-orchestrator 0.5.75 → 0.5.77
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 +34 -7
- package/board/files.mjs +10 -3
- package/board/git-document.mjs +26 -0
- package/board/store.mjs +88 -7
- package/board/wake.mjs +52 -27
- package/board/ztrack.mjs +40 -16
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/board/git-document.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
for (const dir of dirs) {
|
|
65
|
-
file(join(dir,'
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.77",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.60",
|
|
28
|
-
"@volter/teams": "0.2.
|
|
28
|
+
"@volter/teams": "0.2.26",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|
|
31
31
|
},
|