@volter/supercode-orchestrator 0.4.150 → 0.4.152
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/ci.mjs +82 -18
- package/board/dispatch.mjs +24 -3
- package/board/store.mjs +17 -1
- package/package.json +1 -1
package/board/ci.mjs
CHANGED
|
@@ -6,6 +6,43 @@ import { dirname, join, relative } from 'node:path';
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { branchCheckouts, checkoutOf, resolveWorkspace } from './workspace.mjs';
|
|
8
8
|
|
|
9
|
+
// GitHub is read through its REST API with Node's own fetch, not the gh binary: Node takes a World's trust bundle and
|
|
10
|
+
// injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a review World's GitHub twin answers it, where gh (Go) trusts only
|
|
11
|
+
// the system keychain. The read runs in a child Node so the callers stay synchronous.
|
|
12
|
+
const GITHUB_READ = `
|
|
13
|
+
const [path, all] = process.argv.slice(1);
|
|
14
|
+
const base = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\\/$/, '');
|
|
15
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
16
|
+
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'supercode-board', ...(token ? { authorization: 'Bearer ' + token } : {}) };
|
|
17
|
+
let url = base + path, out = [];
|
|
18
|
+
for (let page = 0; url && page < 10; page += 1) {
|
|
19
|
+
const r = await fetch(url, { headers });
|
|
20
|
+
if (!r.ok) { process.stderr.write('GitHub ' + r.status + ' for ' + path + ': ' + (await r.text()).slice(0, 300)); process.exit(1); }
|
|
21
|
+
const body = await r.json();
|
|
22
|
+
if (all !== '1') { process.stdout.write(JSON.stringify(body)); process.exit(0); }
|
|
23
|
+
out = out.concat(body);
|
|
24
|
+
url = /<([^>]+)>;\\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
|
|
25
|
+
}
|
|
26
|
+
process.stdout.write(JSON.stringify(out));`;
|
|
27
|
+
let githubToken;
|
|
28
|
+
/** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login. */
|
|
29
|
+
function github(path, all = false) {
|
|
30
|
+
if (githubToken === undefined) {
|
|
31
|
+
githubToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
|
|
32
|
+
if (!githubToken) try { githubToken = run('gh', ['auth', 'token']) || null; } catch { githubToken = null; }
|
|
33
|
+
}
|
|
34
|
+
const env = githubToken ? { ...process.env, GH_TOKEN: githubToken } : process.env;
|
|
35
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_READ, path, all ? '1' : '0'], { env, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
36
|
+
if (result.status !== 0) throw new Error(`GitHub: ${(result.stderr || result.error?.message || 'read failed').trim().slice(-500)}`);
|
|
37
|
+
return JSON.parse(result.stdout);
|
|
38
|
+
}
|
|
39
|
+
const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
40
|
+
const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
41
|
+
/** A PR as the board reads it (gh's field names). */
|
|
42
|
+
const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
|
|
43
|
+
headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
|
|
44
|
+
const readPullRequest = (url) => pullRequest(repositoryOf(url), github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
|
|
45
|
+
|
|
9
46
|
const observations = new Map();
|
|
10
47
|
const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
|
|
11
48
|
const run = (program, args, cwd) => {
|
|
@@ -85,18 +122,22 @@ function registeredBranches(board, id) {
|
|
|
85
122
|
return out;
|
|
86
123
|
}
|
|
87
124
|
|
|
88
|
-
/** Every opened PR of the arc
|
|
89
|
-
* card's own repository
|
|
90
|
-
* checkout the arc registered (`registeredBranches`).
|
|
125
|
+
/** Every opened PR of the arc, each proposed to its repository's default branch: an open PR whose head is the card's
|
|
126
|
+
* work branch in the card's own repository, a `wt/<card id>` branch in any repository this machine keeps a checkout
|
|
127
|
+
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
128
|
+
* checked out (or no branch), is not a work branch: such a card's PRs are only its `wt/<card>` and registered ones, and with none it
|
|
129
|
+
* answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
|
|
91
130
|
export function openReviewPullRequests(board, id) {
|
|
92
131
|
const own = repositoryWorkspace(board, id);
|
|
93
132
|
if (!own) return null;
|
|
94
133
|
const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
|
|
95
|
-
const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], own);
|
|
96
134
|
const ownRepository = originOf(own);
|
|
97
135
|
if (!ownRepository) return null;
|
|
136
|
+
const current = task?.branch_name ? null : run('git', ['branch', '--show-current'], own);
|
|
137
|
+
let branch = task?.branch_name ?? (current || null);
|
|
98
138
|
const registered = registeredBranches(board, id);
|
|
99
|
-
const
|
|
139
|
+
const named = (head) => [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
|
|
140
|
+
const mine = (repository, head) => (repository === ownRepository && head === branch) || named(head)
|
|
100
141
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
101
142
|
const prs = [];
|
|
102
143
|
for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
|
|
@@ -104,16 +145,21 @@ export function openReviewPullRequests(board, id) {
|
|
|
104
145
|
let list = pullRequestLists.get(repository);
|
|
105
146
|
if (!list || Date.now() - list.at >= 60_000) {
|
|
106
147
|
let items = [];
|
|
107
|
-
try { items =
|
|
148
|
+
try { items = github(`/repos/${repository}/pulls?state=open&per_page=100`, true).map((p) => pullRequest(repository, p)); }
|
|
108
149
|
catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
|
|
109
150
|
list = { at: Date.now(), items };
|
|
110
151
|
if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
|
|
111
152
|
pullRequestLists.set(repository, list);
|
|
112
153
|
}
|
|
113
|
-
//
|
|
114
|
-
|
|
154
|
+
// The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
|
|
155
|
+
if (repository === ownRepository && branch) {
|
|
156
|
+
const defaultBranch = list.items.find((pr) => pr.defaultBranch)?.defaultBranch ?? (() => { try { return run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own).replace(/^origin\//, ''); } catch { return null; } })();
|
|
157
|
+
if (branch === defaultBranch) branch = null;
|
|
158
|
+
}
|
|
159
|
+
// a draft is not handed over yet: its review starts when it is marked ready; a PR to another base (main→prod) is a release, not the arc's work
|
|
160
|
+
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
115
161
|
}
|
|
116
|
-
if (!prs.length) return null;
|
|
162
|
+
if (!prs.length) return branch ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own} is on ${current}, its repository's default` : `its checkout ${own} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
|
|
117
163
|
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
|
|
118
164
|
}
|
|
119
165
|
|
|
@@ -122,17 +168,35 @@ function knownRepositories() {
|
|
|
122
168
|
try { return [...new Set(branchCheckouts(null, '*').map((row) => row.repository))]; } catch { return []; }
|
|
123
169
|
}
|
|
124
170
|
|
|
125
|
-
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review
|
|
126
|
-
*
|
|
127
|
-
*
|
|
171
|
+
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
|
|
172
|
+
* Before any verdict every unjudged head is. After a verdict, a PR it judged with a new head is, and so is a head
|
|
173
|
+
* committed after that verdict (a new PR carrying the fixes, or any PR after a verdict that named none). A PR that
|
|
174
|
+
* joins with a head committed before the verdict is not: after changes are requested the next run is the
|
|
175
|
+
* implementer's until it pushes. */
|
|
128
176
|
export function reviewDue(board, id, prs) {
|
|
129
|
-
const unjudged = prs.
|
|
130
|
-
|
|
177
|
+
const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
|
|
178
|
+
if (!unjudged.length) return false;
|
|
179
|
+
const last = board.db.prepare(`SELECT r.payload, (SELECT MIN(v.created_at) FROM task_events v WHERE v.task_id = r.task_id
|
|
180
|
+
AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id) AS verdict_at
|
|
181
|
+
FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
|
|
131
182
|
AND EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id)
|
|
132
183
|
ORDER BY r.id DESC LIMIT 1`).get(id);
|
|
133
|
-
if (!last) return
|
|
184
|
+
if (!last) return true;
|
|
134
185
|
const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
|
|
135
|
-
|
|
186
|
+
if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
|
|
187
|
+
return unjudged.some((p) => (commitTime(p) ?? 0) > Number(last.verdict_at));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const commitTimes = new Map();
|
|
191
|
+
/** When a PR's head was committed (seconds), read from GitHub once per head; null when it cannot be read. */
|
|
192
|
+
function commitTime({ url, head }) {
|
|
193
|
+
if (commitTimes.has(head)) return commitTimes.get(head);
|
|
194
|
+
let at = null;
|
|
195
|
+
try {
|
|
196
|
+
at = Math.floor(Date.parse(github(`/repos/${repositoryOf(url)}/commits/${head}`).commit.committer.date) / 1000) || null;
|
|
197
|
+
} catch { /* unreadable: not counted as new */ }
|
|
198
|
+
commitTimes.set(head, at);
|
|
199
|
+
return at;
|
|
136
200
|
}
|
|
137
201
|
|
|
138
202
|
/** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
|
|
@@ -163,7 +227,7 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
|
|
|
163
227
|
const wanted = candidatePullRequests(candidate);
|
|
164
228
|
if (wanted.length !== passed.length || wanted.some((w) => !passed.some((p) => p.pr.url === w.pr.url && p.commit === w.commit))) throw new Error('The current PR heads need their independent verdict before acceptance.');
|
|
165
229
|
for (const w of wanted) {
|
|
166
|
-
const pr =
|
|
230
|
+
const pr = readPullRequest(w.pr.url);
|
|
167
231
|
if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
|
|
168
232
|
}
|
|
169
233
|
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
@@ -211,7 +275,7 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
|
211
275
|
// An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
|
|
212
276
|
// card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
|
|
213
277
|
// with nothing uncommitted.
|
|
214
|
-
const observed =
|
|
278
|
+
const observed = readPullRequest(url);
|
|
215
279
|
const path = originOf(own) === repository && run('git', ['branch', '--show-current'], own) === observed.headRefName ? own : branchCheckout(board, id, repository, observed.headRefName);
|
|
216
280
|
if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error(`Source review needs an open PR from its repository (${url}).`);
|
|
217
281
|
if (observed.isDraft) throw new Error(`${url} is a draft; its review starts when it is marked ready for review.`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { hostname, tmpdir, totalmem } from 'node:os';
|
|
|
23
23
|
import { basename, dirname, join } from 'node:path';
|
|
24
24
|
import { createHash, randomUUID } from 'node:crypto';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
|
-
import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
26
|
+
import { view, boardCopyOf, boardPath, claimer, copyRefusal, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
27
27
|
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
|
|
28
28
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
29
29
|
import { evaluate, holds, render } from './expr.mjs';
|
|
@@ -428,6 +428,7 @@ function limitsOf(db, board, entry, task, session) {
|
|
|
428
428
|
|
|
429
429
|
function stopSession(board, session) {
|
|
430
430
|
if (!session) return;
|
|
431
|
+
if (boardCopyOf()) throw copyRefusal('closes no session');
|
|
431
432
|
if (session.surface === 'headless' && pidAlive(session.pid)) {
|
|
432
433
|
// SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
|
|
433
434
|
const signal = (sig) => { try { process.kill(-session.pid, sig); } catch { try { process.kill(session.pid, sig); } catch { /* gone */ } } };
|
|
@@ -1354,6 +1355,7 @@ function resolveLocalWorkspace(board, task) {
|
|
|
1354
1355
|
*/
|
|
1355
1356
|
export function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
|
|
1356
1357
|
if (!who) throw new Error('name the session to adopt');
|
|
1358
|
+
if (boardCopyOf()) throw copyRefusal('adopts no session');
|
|
1357
1359
|
tx(board, db => {
|
|
1358
1360
|
if (!getTask(db, id)) throw new Error(`no card ${id}`);
|
|
1359
1361
|
event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
|
|
@@ -1442,6 +1444,7 @@ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null,
|
|
|
1442
1444
|
|
|
1443
1445
|
// a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
|
|
1444
1446
|
function deliverNow(board, to, body, key) {
|
|
1447
|
+
if (boardCopyOf()) throw copyRefusal('sends no notice to sessions');
|
|
1445
1448
|
const request = { op: 'send', from: boardAddress(board.label), from_name: `${board.label} board`, to, queue: false, id: key, subject: String(body).split('\n')[0].slice(0, 160),
|
|
1446
1449
|
body: `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)` };
|
|
1447
1450
|
const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
|
|
@@ -1451,6 +1454,7 @@ function deliverNow(board, to, body, key) {
|
|
|
1451
1454
|
}
|
|
1452
1455
|
|
|
1453
1456
|
async function send(from, label, to, body, key, { queue = false, ask = false, subject } = {}) {
|
|
1457
|
+
if (boardCopyOf()) throw copyRefusal('sends no mail to sessions or managers');
|
|
1454
1458
|
const request = {
|
|
1455
1459
|
op: 'send', from, from_name: `${label} board`, to, queue, id: key, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
|
|
1456
1460
|
// a question the board waits on is answered by a reply; a notice is not
|
|
@@ -1563,11 +1567,20 @@ async function notify(board, fleet, report, entry) {
|
|
|
1563
1567
|
// a worker whose run has ended is not woken by a notice (a second session would start working the card beside
|
|
1564
1568
|
// the one the next run gets): the notice is filed in its mailbox, read when it next works
|
|
1565
1569
|
const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, sub.task_id)?.metadata?.supercode) === sub.chat_id);
|
|
1570
|
+
// An idle Claude session cannot hold a notice in its mailbox (it starts a turn for every message it is given), so
|
|
1571
|
+
// a notice that must not wake it waits, unsent, for the session's next work.
|
|
1572
|
+
if (!working && fleet.ok && fleet.sessions.some((s) => s.address === sub.chat_id && s.harness === 'claude-code' && s.status === 'idle')) {
|
|
1573
|
+
report.notices_waiting = (report.notices_waiting ?? 0) + 1;
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1566
1576
|
let cursor = Number(sub.last_event_id);
|
|
1567
1577
|
let failed = null;
|
|
1568
1578
|
try {
|
|
1579
|
+
// an event older than the workflow's notice_max_age_seconds is history, not news: it is passed over, never mailed
|
|
1580
|
+
const maxAge = Number(entry.params.notice_max_age_seconds) || null;
|
|
1569
1581
|
for (const ev of events) {
|
|
1570
1582
|
const p = json(ev.payload, {}) ?? {};
|
|
1583
|
+
if (maxAge && now() - Number(ev.created_at) > maxAge) { cursor = ev.id; (report.stale_notices_passed ??= 0); report.stale_notices_passed += 1; continue; }
|
|
1571
1584
|
// nobody is told of their own act
|
|
1572
1585
|
if (p.by && p.by === sub.chat_id) { cursor = ev.id; continue; }
|
|
1573
1586
|
let text;
|
|
@@ -1593,7 +1606,8 @@ async function notify(board, fleet, report, entry) {
|
|
|
1593
1606
|
const kind = 'notice_failed';
|
|
1594
1607
|
const previous = db.prepare('SELECT payload FROM task_events WHERE task_id=? AND kind=? ORDER BY id DESC LIMIT 1').get(sub.task_id, kind);
|
|
1595
1608
|
const payload = { after_event: cursor, address: sub.chat_id, error: failed, retry: true };
|
|
1596
|
-
|
|
1609
|
+
const { by: _, ...said } = json(previous?.payload, {}) ?? {};
|
|
1610
|
+
if (JSON.stringify(said) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
|
|
1597
1611
|
});
|
|
1598
1612
|
report.undelivered.push([sub.task_id, sub.chat_id, failed]);
|
|
1599
1613
|
}
|
|
@@ -1865,7 +1879,12 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
1865
1879
|
payload = openReviewPullRequests(board, id);
|
|
1866
1880
|
// The arc's open PRs ask for one review only while some head has no verdict: after changes are
|
|
1867
1881
|
// requested the next run is the implementer's, until a new head is pushed.
|
|
1868
|
-
if (!payload
|
|
1882
|
+
if (!payload) { report.queued.push([id, 'review waits: no open PR on its branch, a wt/<card> branch or a registered checkout']); continue; }
|
|
1883
|
+
if (payload.waits) { report.queued.push([id, `review waits: ${payload.waits}`]); payload = null; continue; }
|
|
1884
|
+
if (!reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
1885
|
+
report.queued.push([id, `review waits: every open PR head (${payload.pr.join(', ')}) has a verdict or predates the last one; push a commit that answers it`]);
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1869
1888
|
const evidence = inspectCompletionEvidence(board, id, entry, payload, 'request_review');
|
|
1870
1889
|
if (!evidence.ready) throw new Error(evidence.reason);
|
|
1871
1890
|
refreshCardInputs(board, id, { commits: true });
|
|
@@ -1912,6 +1931,8 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1912
1931
|
const path = boardPath(root, slug);
|
|
1913
1932
|
const marker = `${path}.corrupt-seen`;
|
|
1914
1933
|
if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
|
|
1934
|
+
// a copy of a home dispatches nothing: its runs, sessions and managers are the original's
|
|
1935
|
+
if (boardCopyOf()) { report.error = `skipped: ${copyRefusal('starts, supervises, adopts and mails nothing').message}`; out.push(report); continue; }
|
|
1915
1936
|
let board;
|
|
1916
1937
|
try {
|
|
1917
1938
|
board = openBoard(root, slug);
|
package/board/store.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// are in `engine.mjs`. Hermes's own tools may write a `kanban.db` at the same time: every write there takes SQLite's
|
|
11
11
|
// native transaction boundary and retries an optimistic conflict.
|
|
12
12
|
import { randomBytes } from 'node:crypto';
|
|
13
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { hostname } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -142,7 +142,23 @@ export function layOut(db, { fresh }) {
|
|
|
142
142
|
* Open (creating when `create`, in `backing`) board `slug`: `{ db, root, slug, path, backing, tx, close }`. `db` answers
|
|
143
143
|
* SQLite's statement API (`prepare(sql).get|all|run`, `exec`) whatever the backing.
|
|
144
144
|
*/
|
|
145
|
+
// A home records where it was made (`.board-origin` at its root). A copy of the home carries that record elsewhere and
|
|
146
|
+
// is a copy: it may be read and written, but it addresses no session and no manager, which are the original's.
|
|
147
|
+
let copyOfHome = null;
|
|
148
|
+
/** The original home's path when the board opened last is a copy of it; null for a home of its own. */
|
|
149
|
+
export const boardCopyOf = () => copyOfHome;
|
|
150
|
+
export const copyRefusal = (what) => new Error(`this board is a copy of the home at ${copyOfHome}; it ${what}, which belong to the original`);
|
|
151
|
+
function noteOrigin(root) {
|
|
152
|
+
if (!existsSync(root)) return;
|
|
153
|
+
const here = realpathSync(root), file = join(root, '.board-origin');
|
|
154
|
+
let origin = null;
|
|
155
|
+
try { origin = JSON.parse(readFileSync(file, 'utf8')).root ?? null; } catch { /* none yet */ }
|
|
156
|
+
if (!origin) { writeFileSync(file, `${JSON.stringify({ root: here })}\n`); origin = here; }
|
|
157
|
+
copyOfHome = origin === here ? null : origin;
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null } = {}) {
|
|
161
|
+
noteOrigin(root);
|
|
146
162
|
const kept = backingOf(root, slug);
|
|
147
163
|
if (!kept && !create) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
|
|
148
164
|
if (kept && backing && kept !== backing) throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ${backing}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.152",
|
|
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": {
|