@volter/supercode-orchestrator 0.5.200 → 0.5.202
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/README.md +27 -0
- package/board/ci.mjs +141 -62
- package/board/cli.mjs +9 -3
- package/board/client.mjs +10 -0
- package/board/dispatch.mjs +38 -11
- package/board/engine.mjs +4 -1
- package/board/expr.mjs +39 -0
- package/board/github-rest.mjs +119 -0
- package/board/workflow.mjs +24 -9
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -42,6 +42,33 @@ Rules the codec keeps (enforced in Rust, observed here):
|
|
|
42
42
|
since import (UNI-22); it copies the store byte for byte when they did not.
|
|
43
43
|
- Files the IR does not model are never touched by `save` and are carried by path on a migration.
|
|
44
44
|
|
|
45
|
+
## Board GitHub REST receipts
|
|
46
|
+
|
|
47
|
+
`supercode workflow github-usage --since 1h --json` reads this user's local
|
|
48
|
+
`github-rest.sqlite` in `SUPERCODE_HOME` (default: `~/.config/supercode`, respecting
|
|
49
|
+
`XDG_CONFIG_HOME`). It needs neither a running board nor a GitHub request. It
|
|
50
|
+
groups retained wire attempts by caller and endpoint, includes their purposes
|
|
51
|
+
and outcomes, and shows the latest 32 response-header receipts. Unknown transport
|
|
52
|
+
completion remains counted. Tokens and response bodies are not retained.
|
|
53
|
+
|
|
54
|
+
Board actions check their local role, guard and validator rules before GitHub.
|
|
55
|
+
Fresh source observations are reused only within that action, with at most eight
|
|
56
|
+
concurrent REST operations per process. Owner receipts retain the return code and
|
|
57
|
+
redacted refusal even when a failed call is fast. See
|
|
58
|
+
[acceptance latency readings](../../docs/evidence/board-acceptance-latency-2026-10-14.md).
|
|
59
|
+
|
|
60
|
+
Board discovery caches advisory reads while waiting. Review requests, verdicts
|
|
61
|
+
and acceptance refresh the complete source set, including previously draft or
|
|
62
|
+
closed declared sources, and validate its current heads. An unread set defers
|
|
63
|
+
the action. Unread commit dates also defer eligibility and are never cached as
|
|
64
|
+
old work. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
|
|
65
|
+
reserves its own attempt. All processes and credentials sharing this supercode
|
|
66
|
+
home share a rolling-hour ceiling of 1,200 attempts, including at most 600
|
|
67
|
+
discovery attempts. Endpoint exhaustion/Retry-After also defers subsequent work.
|
|
68
|
+
A deferred source remains unknown, with its reason; it is not an empty success.
|
|
69
|
+
Other homes and external GitHub clients are outside this ledger and ceiling.
|
|
70
|
+
See [F104 evidence and qualification limits](../../docs/evidence/f104-github-2026-10-14.md).
|
|
71
|
+
|
|
45
72
|
## Local native session recovery
|
|
46
73
|
|
|
47
74
|
`supercode sessions recover --once --retry-after 300` runs a bounded opt-in
|
package/board/ci.mjs
CHANGED
|
@@ -4,8 +4,10 @@ import { execFile } from 'node:child_process';
|
|
|
4
4
|
import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, relative } from 'node:path';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
7
8
|
import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace, plainWorkspace } from './workspace.mjs';
|
|
8
9
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
10
|
+
import { githubFetch } from './github-rest.mjs';
|
|
9
11
|
|
|
10
12
|
// GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
|
|
11
13
|
// waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
|
|
@@ -20,26 +22,60 @@ async function githubToken() {
|
|
|
20
22
|
}
|
|
21
23
|
/** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
|
|
22
24
|
* read of the login is not kept, and a login token GitHub refuses (401, rotated or revoked) is read again once. */
|
|
23
|
-
|
|
25
|
+
const actionReads = new AsyncLocalStorage();
|
|
26
|
+
/** Fresh observations belong to one action only; callers and later actions never share this snapshot. */
|
|
27
|
+
export const withFreshReviewReads = (fn) => actionReads.getStore() ? fn() : actionReads.run(new Map(), fn);
|
|
28
|
+
async function github(path, all = false, { purpose = 'validation' } = {}) {
|
|
29
|
+
const reads = purpose === 'validation' && actionReads.getStore();
|
|
30
|
+
if (!reads) return githubUncached(path, all, { purpose });
|
|
31
|
+
const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${all}\0${path}`;
|
|
32
|
+
if (!reads.has(key)) reads.set(key, githubUncached(path, all, { purpose }));
|
|
33
|
+
return reads.get(key);
|
|
34
|
+
}
|
|
35
|
+
// Bound actual HTTP/body I/O across all concurrent actions and discovery in this process.
|
|
36
|
+
let activeReads = 0;
|
|
37
|
+
const waitingReads = [];
|
|
38
|
+
async function wireRead(fn) {
|
|
39
|
+
if (activeReads >= 8) await new Promise(resolve => waitingReads.push(resolve)); else activeReads++;
|
|
40
|
+
try { return await fn(); }
|
|
41
|
+
finally { const next = waitingReads.shift(); if (next) next(); else activeReads--; }
|
|
42
|
+
}
|
|
43
|
+
/** Preserve input order; after a failure drain active reads and issue no more work from this batch. */
|
|
44
|
+
async function readTogether(items, read) {
|
|
45
|
+
const out = new Array(items.length); let cursor = 0, failure;
|
|
46
|
+
await Promise.all(Array.from({ length: Math.min(8, items.length) }, async () => {
|
|
47
|
+
while (!failure && cursor < items.length) {
|
|
48
|
+
const i = cursor++;
|
|
49
|
+
try { out[i] = await read(items[i], i); } catch (error) { failure ??= error; }
|
|
50
|
+
}
|
|
51
|
+
}));
|
|
52
|
+
if (failure) throw failure;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
async function githubUncached(path, all = false, { purpose = 'validation' } = {}) {
|
|
24
56
|
const read = async () => {
|
|
25
57
|
const token = await githubToken();
|
|
26
58
|
let url = githubBase() + path, out = [];
|
|
27
59
|
for (let page = 0; url && page < 10; page += 1) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
60
|
+
const pageRead = await wireRead(async () => {
|
|
61
|
+
const r = await githubFetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) }, { purpose });
|
|
62
|
+
if (!r.ok) return { status: r.status, error: `GitHub ${r.status} for ${path}: ${(await r.text()).slice(0, 300)}` };
|
|
63
|
+
return { body: await r.json(), next: /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null };
|
|
64
|
+
});
|
|
65
|
+
if (pageRead.error) return pageRead;
|
|
66
|
+
const body = pageRead.body;
|
|
31
67
|
if (!all) return { body };
|
|
32
68
|
out = out.concat(body);
|
|
33
|
-
url =
|
|
69
|
+
url = pageRead.next;
|
|
34
70
|
}
|
|
35
71
|
return { body: out };
|
|
36
72
|
};
|
|
37
73
|
const started = performance.now();
|
|
38
74
|
let result;
|
|
39
|
-
try { result = await read(); } catch (error) { result = { error: String(error.message) }; }
|
|
40
|
-
if (result.status === 401 && !(process.env.GH_TOKEN || process.env.GITHUB_TOKEN) && loginToken) { loginToken = null; try { result = await read(); } catch (error) { result = { error: String(error.message) }; } }
|
|
75
|
+
try { result = await read(); } catch (error) { result = { error: String(error.message), cause: error }; }
|
|
76
|
+
if (result.status === 401 && !(process.env.GH_TOKEN || process.env.GITHUB_TOKEN) && loginToken) { loginToken = null; try { result = await read(); } catch (error) { result = { error: String(error.message), cause: error }; } }
|
|
41
77
|
noteStep('github read', performance.now() - started);
|
|
42
|
-
if (result.error) throw new Error(`GitHub: ${result.error.trim().slice(-500)}
|
|
78
|
+
if (result.error) throw Object.assign(new Error(`GitHub: ${result.error.trim().slice(-500)}`, { cause: result.cause }), result.cause?.code ? { code: result.cause.code } : {});
|
|
43
79
|
return result.body;
|
|
44
80
|
}
|
|
45
81
|
const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
@@ -48,6 +84,22 @@ const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
|
48
84
|
const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
|
|
49
85
|
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) });
|
|
50
86
|
const readPullRequest = async (url) => pullRequest(repositoryOf(url), await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
|
|
87
|
+
// Discovery is advisory. Already-merged sources are retained for an hour, others for a minute. Every transition
|
|
88
|
+
// still uses readPullRequest/verifyPullRequest above/below, which bypass this cache and reads its current head.
|
|
89
|
+
const discoveredPullRequests = new Map();
|
|
90
|
+
export async function discoverPullRequest(url) {
|
|
91
|
+
const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${url}`;
|
|
92
|
+
let held = discoveredPullRequests.get(key);
|
|
93
|
+
if (held && Date.now() < held.until) return held.pending;
|
|
94
|
+
if (discoveredPullRequests.size >= 4096 && !held) discoveredPullRequests.delete(discoveredPullRequests.keys().next().value);
|
|
95
|
+
held = { until: Date.now() + 60_000 };
|
|
96
|
+
held.pending = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: 'discovery' })
|
|
97
|
+
.then(body => { const pr = pullRequest(repositoryOf(url), body); held.until = Date.now() + (pr.state === 'MERGED' ? 3600_000 : 60_000); return pr; });
|
|
98
|
+
// Failed reads stay failed for a minute; no cached empty source or invented successful head.
|
|
99
|
+
void held.pending.catch(() => {});
|
|
100
|
+
discoveredPullRequests.set(key, held);
|
|
101
|
+
return held.pending;
|
|
102
|
+
}
|
|
51
103
|
|
|
52
104
|
/**
|
|
53
105
|
* Merge a reviewed PR at the head its review judged, and only there: GitHub refuses the merge when the PR's head is no
|
|
@@ -63,9 +115,11 @@ export async function mergeReviewedPullRequest(url, reviewed) {
|
|
|
63
115
|
const token = await githubToken();
|
|
64
116
|
let answer;
|
|
65
117
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
118
|
+
answer = await wireRead(async () => {
|
|
119
|
+
const r = await githubFetch(`${githubBase()}/repos/${repositoryOf(url)}/pulls/${numberOf(url)}/merge`, { method: 'PUT', headers: githubHeaders(token, { 'content-type': 'application/json' }), body: JSON.stringify({ sha: reviewed, merge_method: 'merge' }), signal: AbortSignal.timeout(60_000) }, { purpose: 'mutation' });
|
|
120
|
+
const text = await r.text();
|
|
121
|
+
return { status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() };
|
|
122
|
+
});
|
|
69
123
|
} catch (error) { throw new Error(`GitHub merge: ${String(error.message).slice(-300)}`); }
|
|
70
124
|
if (answer.status === 200 && answer.body?.merged) return { merged: true, sha: answer.body.sha };
|
|
71
125
|
if (answer.status === 409) return { moved: true, head: null, message: answer.body?.message ?? null };
|
|
@@ -165,20 +219,29 @@ async function recreatedWorktree(workspace, resolved) {
|
|
|
165
219
|
|
|
166
220
|
const pullRequestLists = new Map();
|
|
167
221
|
const mergedPullRequestLists = new Map();
|
|
222
|
+
// Keep in-flight and failed list reads too, with room for the registered branch set. History changes rarely;
|
|
223
|
+
// ordinary open work is discovered by the repository's open-PR listing.
|
|
224
|
+
async function discoveryList(cache, key, ttl, read) {
|
|
225
|
+
key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
|
|
226
|
+
let held = cache.get(key);
|
|
227
|
+
if (held && Date.now() < held.until) return held.pending;
|
|
228
|
+
if (cache.size >= 4096 && !held) cache.delete(cache.keys().next().value);
|
|
229
|
+
held = { until: Date.now() + ttl };
|
|
230
|
+
held.pending = Promise.resolve().then(read).catch(error => { held.until = Date.now() + 60_000; throw error; });
|
|
231
|
+
void held.pending.catch(() => {}); cache.set(key, held);
|
|
232
|
+
return held.pending;
|
|
233
|
+
}
|
|
168
234
|
|
|
169
235
|
/** Historical work can already be merged when an outage prevented the board's
|
|
170
236
|
* review request. Read only the arc's declared branch, never all closed PRs. */
|
|
171
|
-
async function mergedBranchPullRequests(repository, branch) {
|
|
237
|
+
async function mergedBranchPullRequests(repository, branch, { fresh = false } = {}) {
|
|
172
238
|
const key = `${repository}:${branch}`;
|
|
173
|
-
|
|
174
|
-
if (!list || Date.now() - list.at >= 60_000) {
|
|
239
|
+
const read = async () => {
|
|
175
240
|
const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
|
|
176
|
-
|
|
241
|
+
return (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' }))
|
|
177
242
|
.filter(p => p.merged_at).map(p => pullRequest(repository, p));
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
return list.items;
|
|
243
|
+
};
|
|
244
|
+
return fresh ? read() : discoveryList(mergedPullRequestLists, key, 900_000, read);
|
|
182
245
|
}
|
|
183
246
|
|
|
184
247
|
async function branchCheckout(board, id, repository, branch) {
|
|
@@ -230,7 +293,7 @@ export function declaredWorkBranches(db, id) {
|
|
|
230
293
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
231
294
|
* 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
|
|
232
295
|
* answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
|
|
233
|
-
export async function openReviewPullRequests(board, id) {
|
|
296
|
+
export async function openReviewPullRequests(board, id, { fresh = false } = {}) {
|
|
234
297
|
const own = await repositoryWorkspace(board, id);
|
|
235
298
|
const registered = registeredBranches(board, id);
|
|
236
299
|
if (!own && !registered.length) return null;
|
|
@@ -241,20 +304,21 @@ export async function openReviewPullRequests(board, id) {
|
|
|
241
304
|
let branch = task?.branch_name ?? (current || null);
|
|
242
305
|
const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
|
|
243
306
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true)).map((p) => pullRequest(repository, p));
|
|
252
|
-
defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch ?? (await github(`/repos/${repository}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
list =
|
|
256
|
-
|
|
257
|
-
|
|
307
|
+
const repositoriesToRead = [...new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))];
|
|
308
|
+
const groups = await readTogether(repositoriesToRead, async repository => {
|
|
309
|
+
const prs = [];
|
|
310
|
+
// Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
|
|
311
|
+
let list;
|
|
312
|
+
try {
|
|
313
|
+
const read = async () => {
|
|
314
|
+
const items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map((p) => pullRequest(repository, p));
|
|
315
|
+
const defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch ?? (await github(`/repos/${repository}`, false, { purpose: fresh ? 'validation' : 'discovery' })).default_branch;
|
|
316
|
+
return { items, defaultBranch };
|
|
317
|
+
};
|
|
318
|
+
list = fresh ? await read() : await discoveryList(pullRequestLists, repository, 300_000, read);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (fresh || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
|
|
321
|
+
return []; // An unread sibling stays unread: do not cache an empty successful result.
|
|
258
322
|
}
|
|
259
323
|
// The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
|
|
260
324
|
if (repository === ownRepository && branch) {
|
|
@@ -270,25 +334,26 @@ export async function openReviewPullRequests(board, id) {
|
|
|
270
334
|
...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
|
|
271
335
|
...registered.filter(r => r.repository === repository).map(r => r.branch),
|
|
272
336
|
])];
|
|
273
|
-
|
|
274
|
-
if (headBranch === list.defaultBranch)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
337
|
+
const histories = await readTogether(historical, async headBranch => {
|
|
338
|
+
if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
|
|
339
|
+
return (await mergedBranchPullRequests(repository, headBranch, { fresh })).filter(pr =>
|
|
340
|
+
!pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
|
|
341
|
+
&& pr.baseRefName === pr.defaultBranch && !judgedHead(board, id, pr.headRefOid));
|
|
342
|
+
});
|
|
343
|
+
for (const history of histories) for (const pr of history) {
|
|
344
|
+
if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
281
345
|
}
|
|
282
|
-
|
|
346
|
+
return prs;
|
|
347
|
+
});
|
|
348
|
+
const prs = groups.flat();
|
|
283
349
|
// A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
|
|
284
350
|
// discovery cannot see; it never hides what discovery found. Only repositories the card's workspace or registered
|
|
285
351
|
// branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
|
|
286
352
|
// declared one, so a push after the declaration is reviewed instead of failing every tick.
|
|
287
353
|
const declaration = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.sources')='array' ORDER BY id DESC LIMIT 1").get(id);
|
|
288
354
|
const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const pr = await readPullRequest(source.url);
|
|
355
|
+
const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => repositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
|
|
356
|
+
for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
|
|
292
357
|
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
293
358
|
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
294
359
|
}
|
|
@@ -325,14 +390,16 @@ export async function noCodeChange(board, id) {
|
|
|
325
390
|
const remote = (await run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own)).split(/\s+/)[0];
|
|
326
391
|
if (remote) await run('git', ['merge-base', '--is-ancestor', remote, base], own);
|
|
327
392
|
const owner = repository.split('/')[0];
|
|
393
|
+
// An empty PR history can authorize a no-code review; keep this validation fresh too.
|
|
328
394
|
if ((await github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`)).length) return null;
|
|
329
395
|
return { pr: [], pr_head: [], branch, no_code: { repository, branch, base, branch_head: remote || null } };
|
|
330
|
-
} catch { return null; }
|
|
396
|
+
} catch (error) { if (error.code === 'GITHUB_BUDGET') throw error; return null; }
|
|
331
397
|
}
|
|
332
398
|
|
|
333
399
|
/** The repositories this machine keeps a checkout of. */
|
|
334
|
-
async function knownRepositories() {
|
|
335
|
-
try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; }
|
|
400
|
+
async function knownRepositories({ fresh = false } = {}) {
|
|
401
|
+
try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; }
|
|
402
|
+
catch (error) { if (fresh) throw error; return []; }
|
|
336
403
|
}
|
|
337
404
|
|
|
338
405
|
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
|
|
@@ -358,22 +425,28 @@ export async function reviewDue(board, id, prs) {
|
|
|
358
425
|
const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
|
|
359
426
|
if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
|
|
360
427
|
if (last.verdict === 'review_pass' && unjudged.some((p) => !judged.some((j) => j.pr.url === p.url))) return true;
|
|
361
|
-
for (const p of unjudged) if ((
|
|
428
|
+
for (const p of unjudged) if ((await commitTime(p)) > Number(last.verdict_at)) return true;
|
|
362
429
|
return false;
|
|
363
430
|
}
|
|
364
431
|
|
|
365
432
|
const commitTimes = new Map();
|
|
366
|
-
/**
|
|
433
|
+
/** Cache successful immutable commit dates only. An unread date is unknown, never evidence of old work. */
|
|
367
434
|
async function commitTime({ url, head }) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
commitTimes.set(head, at);
|
|
435
|
+
const key = `${githubBase()}\0${repositoryOf(url)}\0${head}`;
|
|
436
|
+
if (commitTimes.has(key)) return commitTimes.get(key);
|
|
437
|
+
const at = Math.floor(Date.parse((await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit?.committer?.date) / 1000);
|
|
438
|
+
if (!Number.isFinite(at)) throw new Error(`GitHub commit date for ${url} at ${head} is unreadable; review eligibility is unknown`);
|
|
439
|
+
commitTimes.set(key, at);
|
|
374
440
|
return at;
|
|
375
441
|
}
|
|
376
442
|
|
|
443
|
+
/** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
|
|
444
|
+
async function requireCurrentReviewSet(board, id, urls) {
|
|
445
|
+
const current = await openReviewPullRequests(board, id, { fresh: true });
|
|
446
|
+
const omitted = (current?.pr ?? []).filter(url => !urls.includes(url));
|
|
447
|
+
if (omitted.length) throw new Error(`The current review source set also includes ${omitted.join(', ')}; refresh the complete candidate before requesting review, recording its verdict or accepting it`);
|
|
448
|
+
}
|
|
449
|
+
|
|
377
450
|
/** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
|
|
378
451
|
* a review request naming that head, before the next request. A head is reviewed once. */
|
|
379
452
|
export function judgedHead(board, id, head) {
|
|
@@ -389,6 +462,9 @@ export function judgedHead(board, id, head) {
|
|
|
389
462
|
}
|
|
390
463
|
|
|
391
464
|
export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
|
|
465
|
+
return withFreshReviewReads(() => inspectCurrentCompletionEvidence(board, id, entry, payload, stage));
|
|
466
|
+
}
|
|
467
|
+
async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage) {
|
|
392
468
|
const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
|
|
393
469
|
try {
|
|
394
470
|
if (stage === 'completion' || stage === 'acceptance') {
|
|
@@ -400,6 +476,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
400
476
|
}
|
|
401
477
|
const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
|
|
402
478
|
if (candidate) {
|
|
479
|
+
await requireCurrentReviewSet(board, id, candidatePullRequests(candidate).map(p => p.pr.url));
|
|
403
480
|
const row = board.db.prepare("SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
|
|
404
481
|
if (!row) throw new Error('The current candidate needs its independent verdict before acceptance.');
|
|
405
482
|
const voided = board.db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='review_voided' ORDER BY id DESC LIMIT 1").get(id);
|
|
@@ -407,22 +484,23 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
407
484
|
const passed = candidatePullRequests(row && JSON.parse(row.payload)?.ci);
|
|
408
485
|
const wanted = candidatePullRequests(candidate);
|
|
409
486
|
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.');
|
|
410
|
-
|
|
487
|
+
await readTogether(wanted, async w => {
|
|
411
488
|
const pr = await readPullRequest(w.pr.url);
|
|
412
489
|
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.`);
|
|
413
|
-
}
|
|
490
|
+
});
|
|
414
491
|
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
415
492
|
}
|
|
416
493
|
return state;
|
|
417
494
|
}
|
|
418
495
|
const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
|
|
419
496
|
const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
|
|
497
|
+
if (stage === 'request_review' || stage === 'review_verdict') await requireCurrentReviewSet(board, id, urls);
|
|
420
498
|
if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
|
|
421
499
|
if (urls.some(url => typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url))) throw new Error('Name each GitHub PR URL with --pr.');
|
|
422
500
|
// Merged source is immutable and needs no surviving workspace. Resolve an
|
|
423
501
|
// unmerged candidate's checkout as before; a broken legacy path never
|
|
424
502
|
// prevents a verdict on already merged commits.
|
|
425
|
-
const read = await
|
|
503
|
+
const read = await readTogether(urls, readPullRequest);
|
|
426
504
|
const mergedOnly = urls.length && read.every(pr => pr.state === 'MERGED');
|
|
427
505
|
const own = mergedOnly ? null : await repositoryWorkspace(board, id);
|
|
428
506
|
if (!own && !urls.length) {
|
|
@@ -446,7 +524,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
446
524
|
: payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
|
|
447
525
|
if (stage === 'request_review' && named.length !== urls.length) throw new Error('Name each PR head with --pr-head, one for each --pr, in the same order.');
|
|
448
526
|
if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
|
|
449
|
-
const prs = await
|
|
527
|
+
const prs = await readTogether(urls, (url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage));
|
|
450
528
|
if (stage === 'request_review') {
|
|
451
529
|
// A review is asked for only while some PR has a head no verdict has judged.
|
|
452
530
|
if (!await reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('The PRs the last verdict judged have no new head; push a new head before requesting review.');
|
|
@@ -459,7 +537,8 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
459
537
|
if (bound.length && (bound.length !== requested.length || bound.some((b) => !requested.some((r) => r.pr.url === b.pr.url && r.commit === b.commit)))) throw new Error('This reviewer run was launched for different PR heads.');
|
|
460
538
|
}
|
|
461
539
|
Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}) });
|
|
462
|
-
} catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message
|
|
540
|
+
} catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message,
|
|
541
|
+
...(error.code ? { code: error.code, retryable: error.code === 'GITHUB_BUDGET' } : {}) }); }
|
|
463
542
|
return state;
|
|
464
543
|
}
|
|
465
544
|
|
package/board/cli.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { taskStatusOf } from './published.mjs';
|
|
|
14
14
|
import { executionOf } from './session-work.mjs';
|
|
15
15
|
import { listCardsInTurns, writeCardList } from './listing.mjs';
|
|
16
16
|
import { writeOwnerAnswer } from './reply.mjs';
|
|
17
|
+
import { redactText } from '@volter/teams/redact';
|
|
17
18
|
import { createServer } from 'node:net';
|
|
18
19
|
import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
19
20
|
import { notRuntime, ownHome } from './owner-files.mjs';
|
|
@@ -109,6 +110,7 @@ The board's verbs, with their flags:
|
|
|
109
110
|
specify <id> [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
110
111
|
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
111
112
|
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
113
|
+
github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
|
|
112
114
|
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
113
115
|
capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
|
|
114
116
|
dispatching [on|off] [--reason R] dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
|
|
@@ -1424,7 +1426,7 @@ function ownCards(db, sessionId) {
|
|
|
1424
1426
|
return { worked, held };
|
|
1425
1427
|
}
|
|
1426
1428
|
|
|
1427
|
-
const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
|
|
1429
|
+
const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'github-usage', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
|
|
1428
1430
|
|
|
1429
1431
|
/** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of this machine's
|
|
1430
1432
|
* daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
|
|
@@ -1610,10 +1612,14 @@ async function answerOne(root, line) {
|
|
|
1610
1612
|
const began = performance.now();
|
|
1611
1613
|
const before = slowLog.processCounters?.() ?? null;
|
|
1612
1614
|
let named = null, by = null;
|
|
1613
|
-
|
|
1615
|
+
let answer;
|
|
1616
|
+
try { answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }); return answer; }
|
|
1614
1617
|
finally {
|
|
1615
1618
|
const ms = performance.now() - began;
|
|
1616
|
-
if (named
|
|
1619
|
+
if (named) recordSlow({ kind: 'call', name: `owner verb ${named}`, ms, force: answer?.code !== 0,
|
|
1620
|
+
outcome: answer ? answer.code === 0 ? 'ok' : `exit ${answer.code}` : 'unknown', slowest: null,
|
|
1621
|
+
extra: { caller: by, response: { code: answer?.code ?? null, stderr: redactText([].concat(answer?.stderr ?? []).join('')).slice(0, 2000) },
|
|
1622
|
+
steps: slowLog.stepsWithin?.(began, performance.now()) ?? null, whole_process: before ? slowLog.processUse(before) : null } });
|
|
1617
1623
|
}
|
|
1618
1624
|
}
|
|
1619
1625
|
|
package/board/client.mjs
CHANGED
|
@@ -17,6 +17,16 @@ import { processSession } from './caller-session.mjs';
|
|
|
17
17
|
export async function runWorkflow(argv) {
|
|
18
18
|
const args = parse(argv);
|
|
19
19
|
const [verb] = args._;
|
|
20
|
+
// This home's REST ledger is a local diagnostic, not a card verb. It needs neither a running board owner nor
|
|
21
|
+
// a workflow/identity/network read just to explain which callers already issued requests.
|
|
22
|
+
if (verb === 'github-usage') {
|
|
23
|
+
const value = String(args.since ?? '1h'), relative = /^(\d+)([smhd])$/.exec(value);
|
|
24
|
+
const since = relative ? Date.now() - Number(relative[1]) * { s: 1000, m: 60_000, h: 3600_000, d: 86400_000 }[relative[2]] : Date.parse(value);
|
|
25
|
+
if (!Number.isFinite(since)) throw new Error('github-usage --since takes 30m, 1h or an ISO date');
|
|
26
|
+
const { githubUsage } = await import('./github-rest.mjs');
|
|
27
|
+
process.stdout.write(`${JSON.stringify(githubUsage({ since }), null, 2)}\n`);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
20
30
|
const help = !verb || verb === 'help' || args.help;
|
|
21
31
|
const root = help ? null : homeOf(args);
|
|
22
32
|
if (help || BOARD_OPERATION_VERBS.has(verb)) {
|
package/board/dispatch.mjs
CHANGED
|
@@ -28,7 +28,8 @@ import { localSessionDelegations } from '../agents.mjs';
|
|
|
28
28
|
import { creatorOf, sessionProgress } from './workflow.mjs';
|
|
29
29
|
import { boardLabel, boardName } from '@volter/teams/board-name';
|
|
30
30
|
let fleetRoot = null;
|
|
31
|
-
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
31
|
+
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
32
|
+
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
32
33
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
33
34
|
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,handOffAgents, agentRunForSession } from '../agents.mjs';
|
|
34
35
|
import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
|
|
@@ -1936,6 +1937,7 @@ async function synchronizeAgents(board, report) {
|
|
|
1936
1937
|
}
|
|
1937
1938
|
}
|
|
1938
1939
|
for(const assignment of cardAgentAssignments(board.root)){
|
|
1940
|
+
await yieldToOwner();
|
|
1939
1941
|
const task=getTaskNow(board,assignment.target);
|
|
1940
1942
|
if(task&&(assignment.state==='ended'||task.archived||['done','cancelled'].includes(task.status)))finishCardAgentAssignment(board,assignment,`card ${assignment.target} ${task.archived?'archived':task.status}`);
|
|
1941
1943
|
else if(task)recordCardAgentAssignment(board,assignment);
|
|
@@ -2351,8 +2353,10 @@ const latestAnswer = (db, id) => db.prepare(`SELECT id, kind, payload FROM task_
|
|
|
2351
2353
|
* only while that claim is the card's latest.
|
|
2352
2354
|
*/
|
|
2353
2355
|
export async function completeAdoptions(board, fleet, settings, report, lanes = null) {
|
|
2354
|
-
const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at
|
|
2355
|
-
|
|
2356
|
+
const pending = view(board, db => db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at
|
|
2357
|
+
FROM (SELECT DISTINCT task_id FROM task_events) cards CROSS JOIN task_events e ON e.task_id=cards.task_id
|
|
2358
|
+
WHERE e.kind IN ('dispatch_held','adoption_claimed')
|
|
2359
|
+
AND ((e.kind = 'dispatch_held' AND json_extract(e.payload, '$.reason') = 'adoption requested') OR e.kind = 'adoption_claimed')
|
|
2356
2360
|
AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ${ADOPTION_ANSWERS})`).all());
|
|
2357
2361
|
for (const row of pending) {
|
|
2358
2362
|
// a request another round has claimed is that round's while its claim holds; a claim whose round died is open again
|
|
@@ -2788,6 +2792,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
2788
2792
|
await endWorkerSubscriptions(board, fleet, report);
|
|
2789
2793
|
const record = view(board, (db) => sessionRecord(db));
|
|
2790
2794
|
for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
|
|
2795
|
+
await yieldToOwner();
|
|
2791
2796
|
if (stopping) break;
|
|
2792
2797
|
if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
|
|
2793
2798
|
const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
|
|
@@ -2803,7 +2808,8 @@ async function notify(board, fleet, report, entry) {
|
|
|
2803
2808
|
} else if (ended) continue; // no fleet reading: nothing is filed unread to a session recorded as ended
|
|
2804
2809
|
const kinds = heardBy(entry, role);
|
|
2805
2810
|
if (!kinds.length) continue;
|
|
2806
|
-
|
|
2811
|
+
// The retained cursor resumes the next page; one native read never materializes the whole unread history.
|
|
2812
|
+
const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id LIMIT 64`)
|
|
2807
2813
|
.all(sub.task_id, sub.last_event_id, ...kinds);
|
|
2808
2814
|
if (!events.length) continue;
|
|
2809
2815
|
// the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
|
|
@@ -2857,7 +2863,17 @@ async function notify(board, fleet, report, entry) {
|
|
|
2857
2863
|
// a subscription past its card's last event ends with the card: a worker's when the card is done or archived (its
|
|
2858
2864
|
// work is over, and its session may never run again), anyone's when it is archived
|
|
2859
2865
|
const over = task && (task.stored_as === 'archived' || (role === 'worker' && task.stored_as === 'done'));
|
|
2860
|
-
if (over
|
|
2866
|
+
if (over) tx(board, (db) => {
|
|
2867
|
+
const durable = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
|
|
2868
|
+
if (!durable) return;
|
|
2869
|
+
// A page's final event is not the end of the backlog. Check past the persisted cursor in the same
|
|
2870
|
+
// transaction as retirement, so another page (including the card's completion) is never abandoned.
|
|
2871
|
+
const remaining = db.prepare(`SELECT 1 FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) LIMIT 1`)
|
|
2872
|
+
.get(sub.task_id, durable.last_event_id, ...kinds);
|
|
2873
|
+
const current = getTask(db, sub.task_id);
|
|
2874
|
+
if (!remaining && current && (current.status === 'archived' || (role === 'worker' && current.status === 'done')))
|
|
2875
|
+
db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
|
|
2876
|
+
});
|
|
2861
2877
|
}
|
|
2862
2878
|
}
|
|
2863
2879
|
|
|
@@ -3172,10 +3188,8 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3172
3188
|
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`]);
|
|
3173
3189
|
continue;
|
|
3174
3190
|
}
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
// Due in every other respect: only the session's turn may still hold it. The first round that finds it so is
|
|
3178
|
-
// recorded on the run (`review_held`), with what it was due on, so a goal turn can be told to have started after.
|
|
3191
|
+
// A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
|
|
3192
|
+
// (`review_held`) so a goal turn can be told to have started after that work was first observed.
|
|
3179
3193
|
const heldRun = currentRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
|
|
3180
3194
|
const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
|
|
3181
3195
|
const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
|
|
@@ -3184,9 +3198,22 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3184
3198
|
report.queued.push([id, `review waits: ${inTurn.why}`]);
|
|
3185
3199
|
continue;
|
|
3186
3200
|
}
|
|
3187
|
-
|
|
3201
|
+
if (!dry) await refreshCardInputs(board, id, { commits: true });
|
|
3202
|
+
// Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
|
|
3203
|
+
const observed = await withFreshReviewReads(async () => {
|
|
3204
|
+
const candidate = await openReviewPullRequests(board, id, { fresh: true })
|
|
3205
|
+
?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id) : null);
|
|
3206
|
+
const evidence = !candidate || candidate.waits ? null : candidate.no_code
|
|
3207
|
+
? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
|
|
3208
|
+
: await inspectCompletionEvidence(board, id, entry, candidate, 'request_review');
|
|
3209
|
+
return { candidate, evidence };
|
|
3210
|
+
});
|
|
3211
|
+
payload = observed.candidate;
|
|
3212
|
+
if (!payload || payload.waits) { report.queued.push([id, `review waits: ${payload?.waits ?? 'the current source set has no review candidate'}`]); payload = null; continue; }
|
|
3213
|
+
const evidence = observed.evidence;
|
|
3214
|
+
if (!evidence.ready) throw new Error(evidence.reason);
|
|
3215
|
+
// A dry run names the complete validated review a tick would request and writes nothing.
|
|
3188
3216
|
if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null }); continue; }
|
|
3189
|
-
await refreshCardInputs(board, id, { commits: true });
|
|
3190
3217
|
const moved = tx(board, db => {
|
|
3191
3218
|
const task = getTask(db, id);
|
|
3192
3219
|
if (eligible(db) !== true) return null;
|
package/board/engine.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspac
|
|
|
16
16
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
17
|
import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
18
18
|
import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
19
|
-
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
19
|
+
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
20
20
|
import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
|
|
21
21
|
import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
|
|
22
22
|
|
|
@@ -432,6 +432,8 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
432
432
|
if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
|
|
433
433
|
refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
|
|
434
434
|
await refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
435
|
+
if (['complete', 'request_review', 'request_state'].includes(name)) view(board, db => validateEvent(db, board, entry, id, name,
|
|
436
|
+
{ roles: roles ?? callerRoles(db, id), payload, extra, localOnly: true }));
|
|
435
437
|
const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
|
|
436
438
|
if (evidencePhase) payload = { ...payload, ci: await observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
|
|
437
439
|
if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
|
|
@@ -459,6 +461,7 @@ export async function requestStateMany(board, ids, payload = {}) {
|
|
|
459
461
|
const prepared = new Map(await Promise.all(targets.map(async id => {
|
|
460
462
|
const roles = callerRoles(board.db, id);
|
|
461
463
|
const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
|
|
464
|
+
view(board, db => validateEvent(db, board, entry, id, 'request_state', { roles, payload, localOnly: true }));
|
|
462
465
|
const evidence = await inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
463
466
|
// An ordinary request mails a decision to the manager, not a completion.
|
|
464
467
|
// Keep a failed source observation in that mail. A reviewer recording an
|
package/board/expr.mjs
CHANGED
|
@@ -129,6 +129,45 @@ function compiled(text) {
|
|
|
129
129
|
|
|
130
130
|
const own = (o, k) => o !== null && o !== undefined && (typeof o === 'object' || typeof o === 'function') && Object.prototype.hasOwnProperty.call(o, k);
|
|
131
131
|
|
|
132
|
+
/** Whether an expression may read observed sources, including through derived card fields. Unknown access waits. */
|
|
133
|
+
export function needsSources(text, derived = {}) {
|
|
134
|
+
const visiting = new Set();
|
|
135
|
+
const field = (root, key) => {
|
|
136
|
+
if (key === 'ci') return true;
|
|
137
|
+
if (root !== 'card' || !own(derived, key)) return false;
|
|
138
|
+
if (visiting.has(key)) return true;
|
|
139
|
+
visiting.add(key);
|
|
140
|
+
const dependent = expression(derived[key]);
|
|
141
|
+
visiting.delete(key);
|
|
142
|
+
return dependent;
|
|
143
|
+
};
|
|
144
|
+
const root = e => e.k === 'name' && ['card', 'event'].includes(e.v);
|
|
145
|
+
const visit = e => {
|
|
146
|
+
switch (e.k) {
|
|
147
|
+
case 'lit': return false;
|
|
148
|
+
case 'name': return root(e); // passing the whole object can expose its sources
|
|
149
|
+
case 'get': return root(e.on)
|
|
150
|
+
? e.key.k !== 'lit' || field(e.on.v, e.key.v)
|
|
151
|
+
: visit(e.on) || visit(e.key);
|
|
152
|
+
case 'method': return (root(e.on)
|
|
153
|
+
? e.m === 'size' || field(e.on.v, e.m)
|
|
154
|
+
: visit(e.on)) || e.args.some(visit);
|
|
155
|
+
case 'list': return e.items.some(visit);
|
|
156
|
+
case 'call': return e.args.some(visit);
|
|
157
|
+
case 'macro': return visit(e.on) || visit(e.body);
|
|
158
|
+
case 'not': case 'neg': return visit(e.e);
|
|
159
|
+
case 'bin': return visit(e.l) || visit(e.r);
|
|
160
|
+
case 'if': return visit(e.c) || visit(e.a) || visit(e.b);
|
|
161
|
+
default: return true;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const expression = value => {
|
|
165
|
+
if (value === null || value === undefined || String(value).trim() === '') return false;
|
|
166
|
+
try { return visit(compiled(String(value))); } catch { return true; }
|
|
167
|
+
};
|
|
168
|
+
return expression(text);
|
|
169
|
+
}
|
|
170
|
+
|
|
132
171
|
function run(e, scope) {
|
|
133
172
|
switch (e.k) {
|
|
134
173
|
case 'lit': return e.v;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Every REST wire attempt, including fast calls, belongs to a durable caller/endpoint receipt. Admission is shared
|
|
2
|
+
// by all processes using this supercode home; it never pretends to account for another machine or an external gh.
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { homedir, hostname } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { current } from './call.mjs';
|
|
9
|
+
|
|
10
|
+
export const GITHUB_HOURLY_ATTEMPTS = 1200;
|
|
11
|
+
export const GITHUB_DISCOVERY_ATTEMPTS = 600;
|
|
12
|
+
const HOUR = 3600_000;
|
|
13
|
+
const pathOf = () => join(process.env.SUPERCODE_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode'), 'github-rest.sqlite');
|
|
14
|
+
const connections = new Map();
|
|
15
|
+
function connection() {
|
|
16
|
+
const path = pathOf();
|
|
17
|
+
if (connections.has(path)) return connections.get(path);
|
|
18
|
+
mkdirSync(join(path, '..'), { recursive: true, mode: 0o700 });
|
|
19
|
+
const db = new DatabaseSync(path);
|
|
20
|
+
chmodSync(path, 0o600);
|
|
21
|
+
db.exec(`PRAGMA busy_timeout=50; PRAGMA journal_mode=WAL;
|
|
22
|
+
CREATE TABLE IF NOT EXISTS github_attempts (
|
|
23
|
+
id TEXT PRIMARY KEY, credential TEXT NOT NULL, started_at INTEGER NOT NULL, ended_at INTEGER,
|
|
24
|
+
machine TEXT NOT NULL, pid INTEGER NOT NULL, caller TEXT NOT NULL, purpose TEXT NOT NULL,
|
|
25
|
+
method TEXT NOT NULL, endpoint TEXT NOT NULL, status INTEGER, outcome TEXT,
|
|
26
|
+
resource TEXT, used INTEGER, remaining INTEGER, reset_at INTEGER, retry_after INTEGER);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS github_attempts_window ON github_attempts(credential,started_at,purpose);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS github_attempts_home_window ON github_attempts(started_at,purpose);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS github_cooldown (credential TEXT PRIMARY KEY, until_at INTEGER NOT NULL, reason TEXT NOT NULL);`);
|
|
30
|
+
connections.set(path, db);
|
|
31
|
+
return db;
|
|
32
|
+
}
|
|
33
|
+
const refusal = (message) => Object.assign(new Error(message), { code: 'GITHUB_BUDGET' });
|
|
34
|
+
const integer = (value) => value != null && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
|
|
35
|
+
function endpointOf(url) {
|
|
36
|
+
const safe = new URL(url); safe.username = ''; safe.password = '';
|
|
37
|
+
for (const key of [...safe.searchParams.keys()]) if (!['state', 'head', 'per_page', 'page'].includes(key)) safe.searchParams.set(key, '[redacted]');
|
|
38
|
+
return safe.pathname + safe.search;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A reservation counts even when transport completion is unknown; retries and pagination reserve separately. */
|
|
42
|
+
async function issue(url, options, { purpose }) {
|
|
43
|
+
const db = connection(), at = Date.now(), id = randomUUID();
|
|
44
|
+
const headers = new Headers(options.headers);
|
|
45
|
+
const credential = createHash('sha256').update(new URL(url).origin + '\0' + (headers.get('authorization') ?? '')).digest('hex').slice(0, 24);
|
|
46
|
+
const call = current(), caller = JSON.stringify({ address: call.caller ?? call.facts.SUPERCODE_CALLER ?? null,
|
|
47
|
+
principal: call.principal ?? null, actor: call.actor ?? null, agent: call.facts.SUPERCODE_AGENT_ID ?? null,
|
|
48
|
+
session: call.facts.CLAUDE_CODE_SESSION_ID ? `claude-code:${call.facts.CLAUDE_CODE_SESSION_ID}`
|
|
49
|
+
: call.facts.CODEX_SESSION_ID ? `codex:${call.facts.CODEX_SESSION_ID}` : null,
|
|
50
|
+
command: process.argv[2] ?? null });
|
|
51
|
+
const method = options.method ?? 'GET', endpoint = endpointOf(url);
|
|
52
|
+
db.exec('BEGIN IMMEDIATE');
|
|
53
|
+
try {
|
|
54
|
+
const blocked = db.prepare('SELECT until_at,reason FROM github_cooldown WHERE credential=?').get(credential);
|
|
55
|
+
if (blocked?.until_at > at) throw refusal(`GitHub REST deferred until ${new Date(blocked.until_at).toISOString()}: ${blocked.reason}`);
|
|
56
|
+
const count = db.prepare(`SELECT COUNT(*) total, COALESCE(SUM(purpose='discovery'),0) discovery
|
|
57
|
+
FROM github_attempts WHERE started_at>?`).get(at - HOUR);
|
|
58
|
+
if (count.total >= GITHUB_HOURLY_ATTEMPTS || purpose === 'discovery' && count.discovery >= GITHUB_DISCOVERY_ATTEMPTS) {
|
|
59
|
+
const oldest = db.prepare(`SELECT MIN(started_at) at FROM github_attempts WHERE started_at>?
|
|
60
|
+
AND (?='total' OR purpose='discovery')`).get(at - HOUR, count.total >= GITHUB_HOURLY_ATTEMPTS ? 'total' : 'discovery');
|
|
61
|
+
throw refusal(`GitHub ${purpose} deferred by this home's REST budget (${count.total}/${GITHUB_HOURLY_ATTEMPTS} total, ${count.discovery}/${GITHUB_DISCOVERY_ATTEMPTS} discovery in the last hour); retry after ${new Date(oldest.at + HOUR + 1).toISOString()}`);
|
|
62
|
+
}
|
|
63
|
+
db.prepare('INSERT INTO github_attempts(id,credential,started_at,machine,pid,caller,purpose,method,endpoint) VALUES(?,?,?,?,?,?,?,?,?)')
|
|
64
|
+
.run(id, credential, at, hostname(), process.pid, caller, purpose, method, endpoint);
|
|
65
|
+
db.exec('COMMIT');
|
|
66
|
+
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
67
|
+
let response, outcome = 'unknown';
|
|
68
|
+
try { response = await fetch(url, { ...options, redirect: 'manual' }); outcome = response.ok ? 'ok' : `HTTP_${response.status}`; return response; }
|
|
69
|
+
catch (error) { outcome = error.code ?? error.name ?? 'transport_error'; throw error; }
|
|
70
|
+
finally {
|
|
71
|
+
const resource = response?.headers.get('x-ratelimit-resource') ?? null;
|
|
72
|
+
const used = integer(response?.headers.get('x-ratelimit-used'));
|
|
73
|
+
const remaining = integer(response?.headers.get('x-ratelimit-remaining'));
|
|
74
|
+
const reset = integer(response?.headers.get('x-ratelimit-reset'));
|
|
75
|
+
const retry = integer(response?.headers.get('retry-after'));
|
|
76
|
+
db.prepare('UPDATE github_attempts SET ended_at=?,status=?,outcome=?,resource=?,used=?,remaining=?,reset_at=?,retry_after=? WHERE id=?')
|
|
77
|
+
.run(Date.now(), response?.status ?? null, String(outcome), resource, used, remaining, reset == null ? null : reset * 1000, retry, id);
|
|
78
|
+
// Stop at the actual endpoint's headers, never call rate_limit to guess its account's allowance.
|
|
79
|
+
const until = Math.max(remaining === 0 && reset != null ? reset * 1000 : 0,
|
|
80
|
+
(response?.status === 429 || response?.status === 403) && retry != null ? Date.now() + retry * 1000 : 0,
|
|
81
|
+
remaining === 0 && reset == null ? Date.now() + 60_000 : 0);
|
|
82
|
+
if (until > Date.now()) db.prepare(`INSERT INTO github_cooldown(credential,until_at,reason) VALUES(?,?,?)
|
|
83
|
+
ON CONFLICT(credential) DO UPDATE SET until_at=MAX(until_at,excluded.until_at),reason=excluded.reason`)
|
|
84
|
+
.run(credential, until, remaining === 0 ? `${resource ?? 'REST'} remaining=0 on ${endpoint}` : `HTTP ${response.status} Retry-After on ${endpoint}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function githubFetch(url, options, { purpose = 'validation' } = {}) {
|
|
89
|
+
const origin = new URL(url).origin;
|
|
90
|
+
for (let hop = 0; hop < 10; hop++) {
|
|
91
|
+
const response = await issue(url, options, { purpose });
|
|
92
|
+
if (![301, 302, 303, 307, 308].includes(response.status) || !response.headers.get('location')) return response;
|
|
93
|
+
const next = new URL(response.headers.get('location'), url);
|
|
94
|
+
await response.body?.cancel();
|
|
95
|
+
if (next.origin !== origin) throw new Error('GitHub REST redirect leaves the configured API origin; credential forwarding refused');
|
|
96
|
+
// Match fetch's method conversion, but count every redirected wire attempt separately.
|
|
97
|
+
const method = options.method ?? 'GET';
|
|
98
|
+
if (response.status === 303 && !['GET', 'HEAD'].includes(method) || [301, 302].includes(response.status) && method === 'POST') {
|
|
99
|
+
const { body: _body, ...rest } = options; options = { ...rest, method: 'GET' };
|
|
100
|
+
}
|
|
101
|
+
url = next.href;
|
|
102
|
+
}
|
|
103
|
+
throw new Error('GitHub REST redirect chain exceeded 10 attempts');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Only retained product attempts are counted. No network read and no inference about uninstrumented callers. */
|
|
107
|
+
export function githubUsage({ since = Date.now() - HOUR } = {}) {
|
|
108
|
+
const path = pathOf();
|
|
109
|
+
if (!existsSync(path)) return { path, recorded: false, callers: [], endpoints: [] };
|
|
110
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
111
|
+
try {
|
|
112
|
+
return { path, recorded: true, since, limits: { hourly: GITHUB_HOURLY_ATTEMPTS, discovery: GITHUB_DISCOVERY_ATTEMPTS },
|
|
113
|
+
callers: db.prepare(`SELECT credential,machine,pid,caller,purpose,COUNT(*) attempts,SUM(status IS NULL) unanswered
|
|
114
|
+
FROM github_attempts WHERE started_at>=? GROUP BY credential,machine,pid,caller,purpose`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })),
|
|
115
|
+
endpoints: db.prepare(`SELECT credential,machine,pid,caller,purpose,method,endpoint,status,COUNT(*) attempts
|
|
116
|
+
FROM github_attempts WHERE started_at>=? GROUP BY credential,machine,pid,caller,purpose,method,endpoint,status`).all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })),
|
|
117
|
+
recent: db.prepare('SELECT id,credential,started_at,ended_at,machine,pid,caller,purpose,method,endpoint,status,outcome,resource,used,remaining,reset_at,retry_after FROM github_attempts WHERE started_at>=? ORDER BY started_at DESC,rowid DESC LIMIT 32').all(since).map(r => ({ ...r, caller: JSON.parse(r.caller) })) };
|
|
118
|
+
} finally { db.close(); }
|
|
119
|
+
}
|
package/board/workflow.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
|
|
|
14
14
|
import { randomBytes } from 'node:crypto';
|
|
15
15
|
import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
|
|
16
16
|
import { releaseAllSideSessions } from './side-sessions.mjs';
|
|
17
|
-
import { evaluate, holds, render } from './expr.mjs';
|
|
17
|
+
import { evaluate, holds, render, needsSources } from './expr.mjs';
|
|
18
18
|
import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
|
|
19
19
|
import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
|
|
20
20
|
import { identifyNotice } from './notices.mjs';
|
|
@@ -383,7 +383,7 @@ function timeOf(db, task) {
|
|
|
383
383
|
function askedOf(id) { return mailboxAnswers.get(id)?.askedOf ?? null; }
|
|
384
384
|
|
|
385
385
|
/** The scope an expression is evaluated in: the card (with its relations), the event, params, time, extras. */
|
|
386
|
-
export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy = false } = {}) {
|
|
386
|
+
export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy = false, deferSources = false } = {}) {
|
|
387
387
|
// Only read-only guards opt into demand reads. Mutation scopes materialize before any write,
|
|
388
388
|
// keeping `before` and run-history fields pinned to their original snapshot.
|
|
389
389
|
const once = (read) => { let done = false, value; return () => { if (!done) { value = read(); done = true; } return value; }; };
|
|
@@ -486,8 +486,10 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
486
486
|
Object.defineProperty(card, key, { get: read, enumerable: true, configurable: true });
|
|
487
487
|
if (!lazy) read();
|
|
488
488
|
}
|
|
489
|
-
|
|
490
|
-
|
|
489
|
+
const derived = loaded.get(board.root)?.workflow?.derived ?? {};
|
|
490
|
+
for (const [key, expr] of Object.entries(derived)) {
|
|
491
|
+
// Preflight neither evaluates nor exposes a derived source value from a stale/missing observation.
|
|
492
|
+
const value = deferSources && needsSources(expr, derived) ? undefined : evaluate(expr, { card, event, params, now: now() });
|
|
491
493
|
Object.defineProperty(card, key, { value, writable: true, enumerable: true, configurable: true });
|
|
492
494
|
}
|
|
493
495
|
return {
|
|
@@ -594,7 +596,7 @@ function declaredStatus(db, board, entry, task) {
|
|
|
594
596
|
* takes only a sender in one of its roles; `platform` for the dispatcher's own observations). Answers
|
|
595
597
|
* `{ from, to, effects }`; throws the refusal when no transition applies or a validator fails.
|
|
596
598
|
*/
|
|
597
|
-
export function
|
|
599
|
+
export function validateEvent(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, localOnly = false } = {}) {
|
|
598
600
|
// A card ends with its reason: a cancel or archive that says nothing why is refused, whoever sends it, the manager
|
|
599
601
|
// included. The reason is all the next reader of an ended card has (an empty-reason cancel once ended an arc that had
|
|
600
602
|
// shipped half, and nothing on the card said so).
|
|
@@ -603,14 +605,14 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
603
605
|
// archive: that ends it the way the document already has, and is the one way a manager clears it.
|
|
604
606
|
if (db.boardErrors?.has(id) && !(/cannot change card removal/.test(db.boardErrors.get(id)) && ['cancel', 'archive'].includes(name) && roles.includes('manager'))) throw new Error(`${id}: ${db.boardErrors.get(id)}`);
|
|
605
607
|
const { workflow, params } = entry;
|
|
606
|
-
if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return
|
|
608
|
+
if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return null;
|
|
607
609
|
const task = cardOf(db, id);
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
+
const from = localOnly ? statusOf(db, task) : declaredStatus(db, board, entry, task);
|
|
611
|
+
if (localOnly && !workflow.statuses[from]) return null; // only fire adopts an imported status
|
|
610
612
|
const candidates = transitionsFor(workflow, from, name);
|
|
611
613
|
if (!candidates.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there`);
|
|
612
614
|
// `sender`: the roles the event was sent with (a validator can ask who is asking)
|
|
613
|
-
const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
|
|
615
|
+
const scope = scopeOf(db, board, task, { params, event: payload, lazy: localOnly, deferSources: localOnly, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
|
|
614
616
|
const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
|
|
615
617
|
if (!allowed.length) {
|
|
616
618
|
const state = name === 'complete' ? 'done' : name === 'cancel' ? 'cancelled' : null;
|
|
@@ -618,6 +620,10 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
618
620
|
? ` Ask the manager with workflow request-state ${id} --state ${state} --reason <why> --evidence <verdict-and-pointers>.` : '';
|
|
619
621
|
throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map((t) => t.description).filter(Boolean).join(' ')}${request}`);
|
|
620
622
|
}
|
|
623
|
+
// A source-dependent branch is undecidable before observation; preflight must not choose its fallback.
|
|
624
|
+
const needsSource = expr => needsSources(expr, workflow.derived);
|
|
625
|
+
// Other roles' guards also determine the refusal below; none may decide it using stale sources.
|
|
626
|
+
if (localOnly && candidates.some(t => needsSource(t.guard))) return null;
|
|
621
627
|
const taken = allowed.find((t) => holds(t.guard ?? null, scope));
|
|
622
628
|
// none of the sender's rules holds, but another role's does (one verb with a rule per case, as `set` has one per
|
|
623
629
|
// field): it is that role's to send
|
|
@@ -633,8 +639,17 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
633
639
|
none.noTransition = true; throw none;
|
|
634
640
|
}
|
|
635
641
|
for (const v of taken.validate ?? []) {
|
|
642
|
+
if (localOnly && needsSource(v.expr)) continue;
|
|
636
643
|
if (!holds(v.expr, scope)) { const reason = render(v.message, scope); const refusal = new Error(reason); refusal.refusal = reason; throw refusal; }
|
|
637
644
|
}
|
|
645
|
+
return { task, from, taken, scope, beforeRun: localOnly ? null : noticeRun(db, task) };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
|
|
649
|
+
const checked = validateEvent(db, board, entry, id, name, { roles, payload, extra });
|
|
650
|
+
if (!checked) return fireRun(db, board, entry, id, name, { roles, payload, extra });
|
|
651
|
+
const { task, from, taken, scope, beforeRun } = checked;
|
|
652
|
+
const { params } = entry;
|
|
638
653
|
const effects = [];
|
|
639
654
|
const to = take(db, board, entry, task, from, taken, scope, effects);
|
|
640
655
|
settle(db, board, entry, id, effects);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.202",
|
|
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": {
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@volter/supercode-teams": "^0.3.
|
|
31
|
+
"@volter/supercode-teams": "^0.3.334",
|
|
32
32
|
"@volter/supercode-terminal": "^0.3.8",
|
|
33
|
-
"@volter/supercode-harness-sdk": "^0.3.
|
|
33
|
+
"@volter/supercode-harness-sdk": "^0.3.102",
|
|
34
34
|
"@volter/teams": "0.2.93",
|
|
35
35
|
"@volter/ztrack": "^2.4.23",
|
|
36
36
|
"yaml": "^2.9.0"
|