@volter/supercode-orchestrator 0.5.74 → 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 +52 -8
- package/board/files.mjs +10 -3
- package/board/git-document.mjs +26 -0
- package/board/store.mjs +89 -8
- package/board/wake.mjs +52 -27
- package/board/ztrack.mjs +47 -23
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -19,11 +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
|
+
// read by name, so an older harness SDK without them still loads: the owner then keeps no per-verb or loop lines
|
|
24
|
+
import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
|
|
23
25
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
24
26
|
import { fileURLToPath } from 'node:url';
|
|
25
27
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
26
|
-
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';
|
|
27
29
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
28
30
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
29
31
|
import * as engine from './engine.mjs';
|
|
@@ -1518,8 +1520,20 @@ async function answerVerbs(root, log) {
|
|
|
1518
1520
|
return () => { server.close(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
|
|
1519
1521
|
}
|
|
1520
1522
|
|
|
1521
|
-
/** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer.
|
|
1523
|
+
/** One request to the owner, answered whole: the verb run in the caller's call, its output kept for the answer. A
|
|
1524
|
+
* verb that takes more than a second is a slow line of its own, with the steps that ended while it ran (its own and,
|
|
1525
|
+
* in this one process, any other call's that held it). */
|
|
1522
1526
|
async function answerOne(root, line) {
|
|
1527
|
+
const began = performance.now();
|
|
1528
|
+
let named = null, by = null;
|
|
1529
|
+
try { return await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }); }
|
|
1530
|
+
finally {
|
|
1531
|
+
const ms = performance.now() - began;
|
|
1532
|
+
if (named) recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, slowest: null, extra: { caller: by, steps: slowLog.stepsWithin?.(began, performance.now()) ?? null } });
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async function answerRequest(root, line, naming) {
|
|
1523
1537
|
try {
|
|
1524
1538
|
const request = JSON.parse(line);
|
|
1525
1539
|
const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
|
|
@@ -1529,6 +1543,7 @@ async function answerOne(root, line) {
|
|
|
1529
1543
|
if (BOARD_OPERATION_VERBS.has(verb)) return { code: 1, stdout: '', stderr: `supercode workflow: ${verb} is run on the board's machine as its own act, not sent to the owner\n` };
|
|
1530
1544
|
const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
|
|
1531
1545
|
const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
|
|
1546
|
+
naming(tool ? `tool ${tool.name}` : verb, caller);
|
|
1532
1547
|
const facts = Object.fromEntries(Object.entries(request.facts ?? {}).filter(([key, value]) => CALLER_FACTS.includes(key) && typeof value === 'string'));
|
|
1533
1548
|
// a caller names its Teams context (the CLI and the daemon always do): one that names none is refused before anything
|
|
1534
1549
|
// is done, never answered with the owner's
|
|
@@ -1630,8 +1645,36 @@ async function serve(root, args = {}) {
|
|
|
1630
1645
|
try { (await import('../hermes-cli.mjs')).hermesInstall(); } catch { /* none installed */ }
|
|
1631
1646
|
let closeVerbs;
|
|
1632
1647
|
try { closeVerbs = await answerVerbs(root, log); } catch (error) { unfollowHome(); throw error; }
|
|
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
|
+
const unwatchLoop = slowLog.watchEventLoop?.({ name: 'board owner event loop blocked' });
|
|
1633
1650
|
log(`owning the boards of ${root} on ${localMachine()} (orchestrator ${running ?? 'unknown'}); dispatching where a board's dispatching is on (\`supercode workflow dispatching\`)`);
|
|
1634
|
-
|
|
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 */ }
|
|
1635
1678
|
// The board's events are published by this machine's connector, not by the dispatcher (D137, D138 step 4): the home is
|
|
1636
1679
|
// named to the daemon once, and the connector follows its `workflow events` from then on, with or without this serve.
|
|
1637
1680
|
{ let named = null, refusal = null;
|
|
@@ -1659,13 +1702,14 @@ async function serve(root, args = {}) {
|
|
|
1659
1702
|
const began = Date.now();
|
|
1660
1703
|
if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
1661
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); } };
|
|
1662
1706
|
try {
|
|
1663
|
-
const state = await ownCall(() => laneState(root));
|
|
1707
|
+
const state = await loopStep('lane state', () => ownCall(() => laneState(root)));
|
|
1664
1708
|
const settings = { ...state.settings, ...override };
|
|
1665
1709
|
interval = settings.interval;
|
|
1666
1710
|
// Publishing is the owner's, dispatching or not: what a writer outside this process marked changed is published
|
|
1667
1711
|
// on the wake its write caused, and the retained history kept bounded (store.mjs publishMarked).
|
|
1668
|
-
const published = await ownCall(async () => publishMarked(root));
|
|
1712
|
+
const published = await loopStep('publish marked', () => ownCall(async () => publishMarked(root)));
|
|
1669
1713
|
if (published && args.verbose) log(`published ${published} card(s) a writer outside the owner changed`);
|
|
1670
1714
|
// Dispatching is a controller over the boards whose own state says it is on; the owner answers verbs either way.
|
|
1671
1715
|
const dispatching = listBoards(root).filter((slug) => dispatchingOf(root, slug).on);
|
|
@@ -1705,7 +1749,7 @@ async function serve(root, args = {}) {
|
|
|
1705
1749
|
const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
|
|
1706
1750
|
let moved = newer();
|
|
1707
1751
|
// the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
|
|
1708
|
-
|
|
1752
|
+
await loopStep('wake refresh', () => refreshNow());
|
|
1709
1753
|
if (!stopped && !moved) {
|
|
1710
1754
|
wakeReason = await events.wait(Math.max(0, Math.min(interval * 1000 - (Date.now() - began), nextMailAt - Date.now())));
|
|
1711
1755
|
// A busy event stream is coalesced to at most one tick per second.
|
|
@@ -1718,7 +1762,7 @@ async function serve(root, args = {}) {
|
|
|
1718
1762
|
} finally {
|
|
1719
1763
|
// a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
|
|
1720
1764
|
try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
|
|
1721
|
-
closeVerbs();
|
|
1765
|
+
unwatchLoop?.(); releaseHeldBoards(); closeVerbs();
|
|
1722
1766
|
unfollowHome();
|
|
1723
1767
|
await closeDoors();
|
|
1724
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');
|
|
@@ -217,7 +297,7 @@ export const tx = (board, fn) => {
|
|
|
217
297
|
const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
|
|
218
298
|
const started = performance.now();
|
|
219
299
|
try {
|
|
220
|
-
const result = board.tx(db => {const result=fn(db);recordCardPublications(db,actorOf());return result;});
|
|
300
|
+
const result = board.tx(db => {const result=fn(db);const began=performance.now();try{recordCardPublications(db,actorOf());}finally{noteStep('board tx publications',performance.now()-began);}return result;});
|
|
221
301
|
signalBoardWrite(board.root, actorOf());
|
|
222
302
|
return result;
|
|
223
303
|
} finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
|
|
@@ -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
|
@@ -17,6 +17,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
17
17
|
import { actorOf, contention, event, json, now, principalOf } from './store.mjs';
|
|
18
18
|
import { GitDocument } from './git-document.mjs';
|
|
19
19
|
import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
20
|
+
import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
|
|
20
21
|
|
|
21
22
|
/** Run `fn` as a named step of the running command (the slow log names its slowest step). */
|
|
22
23
|
const timedStep = (step, fn) => { const started = performance.now(); try { return fn(); } finally { noteStep(step, performance.now() - started); } };
|
|
@@ -61,7 +62,7 @@ DROP TABLE IF EXISTS board_export;`;
|
|
|
61
62
|
// written to the document). board_tasks: a subtask's key on its arc's Tasks list
|
|
62
63
|
// (`<arc>:c3`) and its `source:` lines, which live only in the document.
|
|
63
64
|
|
|
64
|
-
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 }) {
|
|
65
66
|
if (!existsSync(marker)) {
|
|
66
67
|
if (!create) throw new Error(`no ztrack board ${marker}`);
|
|
67
68
|
mkdirSync(dirname(marker), { recursive: true });
|
|
@@ -75,13 +76,18 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
|
|
|
75
76
|
atomic(marker, JSON.stringify({ format: 1, document: join(realpathSync(dirname(named)), basename(named)), ...said }) + '\n');
|
|
76
77
|
}
|
|
77
78
|
const config = JSON.parse(read(marker));
|
|
78
|
-
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug });
|
|
79
|
-
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() };
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
|
|
83
85
|
class ZtrackBoard {
|
|
84
|
-
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;
|
|
85
91
|
this.path = resolve(dirname(marker), document);
|
|
86
92
|
this.archive = `${this.path}.archive.md`;
|
|
87
93
|
this.baseDocs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
|
|
@@ -97,13 +103,10 @@ class ZtrackBoard {
|
|
|
97
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;
|
|
98
104
|
// A publish that is behind is said once per call, on whichever workflow verb opened the board: a refused push
|
|
99
105
|
// surfaces on the next call instead of only in the dispatcher's report, and stops being said once the push lands.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
// A push younger than a minute is still on its way.
|
|
105
|
-
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`);
|
|
106
|
-
}
|
|
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()));
|
|
107
110
|
this.seed = seed; this.root = root; this.slug = slug; this.init = init;
|
|
108
111
|
this.file = `${marker}.board.db`;
|
|
109
112
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
@@ -112,12 +115,12 @@ class ZtrackBoard {
|
|
|
112
115
|
// `sqlite3 <store> "select …"` made the empty file the next release then opened instead of importing).
|
|
113
116
|
if (existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
|
|
114
117
|
if (!existsSync(this.file)) this.create(legacy);
|
|
115
|
-
this.conn = new DatabaseSync(this.file);
|
|
118
|
+
this.conn = timedStep('board open connection', () => new DatabaseSync(this.file));
|
|
116
119
|
this.refuseUnimported(legacy);
|
|
117
120
|
this.errors = new Map(); this.statements = new Map(); this.hashes = new Map();
|
|
118
121
|
this.active = false; this.overlay = false; this.seen = null; this.refused = new Set();
|
|
119
|
-
this.prepareConnection();
|
|
120
|
-
this.loadErrors();
|
|
122
|
+
timedStep('board open schema', () => this.prepareConnection());
|
|
123
|
+
timedStep('board open errors', () => this.loadErrors());
|
|
121
124
|
const self = this;
|
|
122
125
|
this.db = {
|
|
123
126
|
boardErrors: self.errors,
|
|
@@ -136,6 +139,16 @@ class ZtrackBoard {
|
|
|
136
139
|
// a board that is only read, or whose owner runs no round, still finishes an earlier process's commit.
|
|
137
140
|
if (seed || this.git?.underived) this.tx(() => {});
|
|
138
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; }
|
|
139
152
|
// The board's documents: the open one, the current archive, and each rotated archive (recorded in the board's own
|
|
140
153
|
// state, so a rotation committed but not yet written is still a document of the board).
|
|
141
154
|
get docs() {
|
|
@@ -359,21 +372,30 @@ class ZtrackBoard {
|
|
|
359
372
|
// A reader sees a hand edit at once, through connection-local copies of the card tables, and
|
|
360
373
|
// writes nothing: the next write records the edit.
|
|
361
374
|
observeForRead() {
|
|
362
|
-
|
|
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('|');
|
|
363
380
|
if (stamp === this.seen) return;
|
|
364
381
|
this.seen = stamp;
|
|
365
382
|
this.loadErrors();
|
|
366
|
-
const stale = this.staleDocuments({ pending: true });
|
|
383
|
+
const stale = timedStep('board read: stale check', () => this.staleDocuments({ pending: true }));
|
|
367
384
|
if (!stale.length) { this.dropOverlay(); return; }
|
|
368
385
|
let plan;
|
|
369
|
-
try { plan = this.observe(stale); } catch { this.dropOverlay(); return; } // the next write reports it
|
|
370
|
-
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 }));
|
|
371
388
|
}
|
|
372
389
|
|
|
373
390
|
// Whether a document holds an edit its tables have not imported (a hand edit, a git update): the publication owner
|
|
374
391
|
// enters a transaction for it, which imports it and so marks its cards. Unreadable reads as yes: the write reports it.
|
|
375
392
|
unimported() {
|
|
376
|
-
|
|
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;
|
|
377
399
|
}
|
|
378
400
|
|
|
379
401
|
dropOverlay() {
|
|
@@ -396,8 +418,7 @@ class ZtrackBoard {
|
|
|
396
418
|
try {
|
|
397
419
|
txStarted = performance.now();
|
|
398
420
|
this.dropOverlay();
|
|
399
|
-
this.conn.exec('BEGIN');
|
|
400
|
-
fenced(this.conn);
|
|
421
|
+
timedStep('board tx begin', () => { this.conn.exec('BEGIN'); fenced(this.conn); });
|
|
401
422
|
this.active = true; this.contended = false;
|
|
402
423
|
this.conn.exec('DELETE FROM temp.board_dirty; DELETE FROM temp.board_touched');
|
|
403
424
|
this.refused = new Set();
|
|
@@ -418,12 +439,12 @@ class ZtrackBoard {
|
|
|
418
439
|
this.put('pending', { intent, files });
|
|
419
440
|
this.put('pending_hashes', Object.fromEntries(files.map((f) => [f.role, { before: f.before, after: f.after }])));
|
|
420
441
|
}
|
|
421
|
-
this.conn.exec('COMMIT');
|
|
442
|
+
timedStep('board tx commit', () => this.conn.exec('COMMIT'));
|
|
422
443
|
// A long transaction holds every other writer of this board (the dispatcher's tick included), so it is a line
|
|
423
444
|
// of its own in the slow log, from whichever process ran it.
|
|
424
445
|
const held = performance.now() - txStarted;
|
|
425
446
|
noteStep('board transaction', held);
|
|
426
|
-
recordSlow({ kind: 'call', name: 'board transaction', ms: held, slowest: null, extra: { board: this.path } });
|
|
447
|
+
recordSlow({ kind: 'call', name: 'board transaction', ms: held, slowest: null, extra: { board: this.path, steps: slowLog.stepsWithin?.(txStarted, performance.now()) ?? null } });
|
|
427
448
|
outcome = { result, files, intent, recovered };
|
|
428
449
|
} catch (error) {
|
|
429
450
|
try { this.conn.exec('ROLLBACK'); } catch { /* not begun */ }
|
|
@@ -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.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": {
|