@volter/supercode-orchestrator 0.5.200 → 0.5.201
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 +21 -0
- package/board/ci.mjs +79 -42
- package/board/cli.mjs +2 -1
- package/board/client.mjs +10 -0
- package/board/dispatch.mjs +10 -6
- package/board/github-rest.mjs +119 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,27 @@ 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 discovery caches advisory reads while waiting. Review requests, verdicts
|
|
55
|
+
and acceptance refresh the complete source set, including previously draft or
|
|
56
|
+
closed declared sources, and validate its current heads. An unread set defers
|
|
57
|
+
the action. Unread commit dates also defer eligibility and are never cached as
|
|
58
|
+
old work. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
|
|
59
|
+
reserves its own attempt. All processes and credentials sharing this supercode
|
|
60
|
+
home share a rolling-hour ceiling of 1,200 attempts, including at most 600
|
|
61
|
+
discovery attempts. Endpoint exhaustion/Retry-After also defers subsequent work.
|
|
62
|
+
A deferred source remains unknown, with its reason; it is not an empty success.
|
|
63
|
+
Other homes and external GitHub clients are outside this ledger and ceiling.
|
|
64
|
+
See [F104 evidence and qualification limits](../../docs/evidence/f104-github-2026-10-14.md).
|
|
65
|
+
|
|
45
66
|
## Local native session recovery
|
|
46
67
|
|
|
47
68
|
`supercode sessions recover --once --retry-after 300` runs a bounded opt-in
|
package/board/ci.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { dirname, join, relative } from 'node:path';
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace, plainWorkspace } from './workspace.mjs';
|
|
8
8
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
9
|
+
import { githubFetch } from './github-rest.mjs';
|
|
9
10
|
|
|
10
11
|
// GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
|
|
11
12
|
// waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
|
|
@@ -20,12 +21,12 @@ async function githubToken() {
|
|
|
20
21
|
}
|
|
21
22
|
/** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
|
|
22
23
|
* read of the login is not kept, and a login token GitHub refuses (401, rotated or revoked) is read again once. */
|
|
23
|
-
async function github(path, all = false) {
|
|
24
|
+
async function github(path, all = false, { purpose = 'validation' } = {}) {
|
|
24
25
|
const read = async () => {
|
|
25
26
|
const token = await githubToken();
|
|
26
27
|
let url = githubBase() + path, out = [];
|
|
27
28
|
for (let page = 0; url && page < 10; page += 1) {
|
|
28
|
-
const r = await
|
|
29
|
+
const r = await githubFetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) }, { purpose });
|
|
29
30
|
if (!r.ok) return { status: r.status, error: `GitHub ${r.status} for ${path}: ${(await r.text()).slice(0, 300)}` };
|
|
30
31
|
const body = await r.json();
|
|
31
32
|
if (!all) return { body };
|
|
@@ -36,10 +37,10 @@ async function github(path, all = false) {
|
|
|
36
37
|
};
|
|
37
38
|
const started = performance.now();
|
|
38
39
|
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) }; } }
|
|
40
|
+
try { result = await read(); } catch (error) { result = { error: String(error.message), cause: error }; }
|
|
41
|
+
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
42
|
noteStep('github read', performance.now() - started);
|
|
42
|
-
if (result.error) throw new Error(`GitHub: ${result.error.trim().slice(-500)}
|
|
43
|
+
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
44
|
return result.body;
|
|
44
45
|
}
|
|
45
46
|
const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
@@ -48,6 +49,22 @@ const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
|
48
49
|
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
50
|
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
51
|
const readPullRequest = async (url) => pullRequest(repositoryOf(url), await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
|
|
52
|
+
// Discovery is advisory. Already-merged sources are retained for an hour, others for a minute. Every transition
|
|
53
|
+
// still uses readPullRequest/verifyPullRequest above/below, which bypass this cache and reads its current head.
|
|
54
|
+
const discoveredPullRequests = new Map();
|
|
55
|
+
export async function discoverPullRequest(url) {
|
|
56
|
+
const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${url}`;
|
|
57
|
+
let held = discoveredPullRequests.get(key);
|
|
58
|
+
if (held && Date.now() < held.until) return held.pending;
|
|
59
|
+
if (discoveredPullRequests.size >= 4096 && !held) discoveredPullRequests.delete(discoveredPullRequests.keys().next().value);
|
|
60
|
+
held = { until: Date.now() + 60_000 };
|
|
61
|
+
held.pending = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: 'discovery' })
|
|
62
|
+
.then(body => { const pr = pullRequest(repositoryOf(url), body); held.until = Date.now() + (pr.state === 'MERGED' ? 3600_000 : 60_000); return pr; });
|
|
63
|
+
// Failed reads stay failed for a minute; no cached empty source or invented successful head.
|
|
64
|
+
void held.pending.catch(() => {});
|
|
65
|
+
discoveredPullRequests.set(key, held);
|
|
66
|
+
return held.pending;
|
|
67
|
+
}
|
|
51
68
|
|
|
52
69
|
/**
|
|
53
70
|
* 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,7 +80,7 @@ export async function mergeReviewedPullRequest(url, reviewed) {
|
|
|
63
80
|
const token = await githubToken();
|
|
64
81
|
let answer;
|
|
65
82
|
try {
|
|
66
|
-
const r = await
|
|
83
|
+
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' });
|
|
67
84
|
const text = await r.text();
|
|
68
85
|
answer = { status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() };
|
|
69
86
|
} catch (error) { throw new Error(`GitHub merge: ${String(error.message).slice(-300)}`); }
|
|
@@ -165,20 +182,29 @@ async function recreatedWorktree(workspace, resolved) {
|
|
|
165
182
|
|
|
166
183
|
const pullRequestLists = new Map();
|
|
167
184
|
const mergedPullRequestLists = new Map();
|
|
185
|
+
// Keep in-flight and failed list reads too, with room for the registered branch set. History changes rarely;
|
|
186
|
+
// ordinary open work is discovered by the repository's open-PR listing.
|
|
187
|
+
async function discoveryList(cache, key, ttl, read) {
|
|
188
|
+
key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
|
|
189
|
+
let held = cache.get(key);
|
|
190
|
+
if (held && Date.now() < held.until) return held.pending;
|
|
191
|
+
if (cache.size >= 4096 && !held) cache.delete(cache.keys().next().value);
|
|
192
|
+
held = { until: Date.now() + ttl };
|
|
193
|
+
held.pending = Promise.resolve().then(read).catch(error => { held.until = Date.now() + 60_000; throw error; });
|
|
194
|
+
void held.pending.catch(() => {}); cache.set(key, held);
|
|
195
|
+
return held.pending;
|
|
196
|
+
}
|
|
168
197
|
|
|
169
198
|
/** Historical work can already be merged when an outage prevented the board's
|
|
170
199
|
* review request. Read only the arc's declared branch, never all closed PRs. */
|
|
171
|
-
async function mergedBranchPullRequests(repository, branch) {
|
|
200
|
+
async function mergedBranchPullRequests(repository, branch, { fresh = false } = {}) {
|
|
172
201
|
const key = `${repository}:${branch}`;
|
|
173
|
-
|
|
174
|
-
if (!list || Date.now() - list.at >= 60_000) {
|
|
202
|
+
const read = async () => {
|
|
175
203
|
const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
|
|
176
|
-
|
|
204
|
+
return (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' }))
|
|
177
205
|
.filter(p => p.merged_at).map(p => pullRequest(repository, p));
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
return list.items;
|
|
206
|
+
};
|
|
207
|
+
return fresh ? read() : discoveryList(mergedPullRequestLists, key, 900_000, read);
|
|
182
208
|
}
|
|
183
209
|
|
|
184
210
|
async function branchCheckout(board, id, repository, branch) {
|
|
@@ -230,7 +256,7 @@ export function declaredWorkBranches(db, id) {
|
|
|
230
256
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
231
257
|
* 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
258
|
* answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
|
|
233
|
-
export async function openReviewPullRequests(board, id) {
|
|
259
|
+
export async function openReviewPullRequests(board, id, { fresh = false } = {}) {
|
|
234
260
|
const own = await repositoryWorkspace(board, id);
|
|
235
261
|
const registered = registeredBranches(board, id);
|
|
236
262
|
if (!own && !registered.length) return null;
|
|
@@ -242,19 +268,19 @@ export async function openReviewPullRequests(board, id) {
|
|
|
242
268
|
const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
|
|
243
269
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
244
270
|
const prs = [];
|
|
245
|
-
for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories())].filter(Boolean))) {
|
|
246
|
-
//
|
|
247
|
-
let list
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
271
|
+
for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))) {
|
|
272
|
+
// Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
|
|
273
|
+
let list;
|
|
274
|
+
try {
|
|
275
|
+
const read = async () => {
|
|
276
|
+
const items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map((p) => pullRequest(repository, p));
|
|
277
|
+
const defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch ?? (await github(`/repos/${repository}`, false, { purpose: fresh ? 'validation' : 'discovery' })).default_branch;
|
|
278
|
+
return { items, defaultBranch };
|
|
279
|
+
};
|
|
280
|
+
list = fresh ? await read() : await discoveryList(pullRequestLists, repository, 300_000, read);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (fresh || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
|
|
283
|
+
continue; // An unread sibling stays unread: do not cache an empty successful result.
|
|
258
284
|
}
|
|
259
285
|
// The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
|
|
260
286
|
if (repository === ownRepository && branch) {
|
|
@@ -273,7 +299,7 @@ export async function openReviewPullRequests(board, id) {
|
|
|
273
299
|
for (const headBranch of historical) {
|
|
274
300
|
if (headBranch === list.defaultBranch) continue;
|
|
275
301
|
if (list.items.some(pr => pr.headRefName === headBranch)) continue;
|
|
276
|
-
for (const pr of await mergedBranchPullRequests(repository, headBranch)) {
|
|
302
|
+
for (const pr of await mergedBranchPullRequests(repository, headBranch, { fresh })) {
|
|
277
303
|
if (!pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
|
|
278
304
|
&& pr.baseRefName === pr.defaultBranch && !judgedHead(board, id, pr.headRefOid)
|
|
279
305
|
&& !prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
@@ -288,7 +314,7 @@ export async function openReviewPullRequests(board, id) {
|
|
|
288
314
|
const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
|
|
289
315
|
for (const source of declaration ? JSON.parse(declaration.payload).sources : []) {
|
|
290
316
|
if (!repositories.has(repositoryOf(source.url).toLowerCase()) || prs.some((known) => known.url === source.url)) continue;
|
|
291
|
-
const pr = await readPullRequest(source.url);
|
|
317
|
+
const pr = await (fresh ? readPullRequest(source.url) : discoverPullRequest(source.url));
|
|
292
318
|
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
293
319
|
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
294
320
|
}
|
|
@@ -325,14 +351,16 @@ export async function noCodeChange(board, id) {
|
|
|
325
351
|
const remote = (await run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own)).split(/\s+/)[0];
|
|
326
352
|
if (remote) await run('git', ['merge-base', '--is-ancestor', remote, base], own);
|
|
327
353
|
const owner = repository.split('/')[0];
|
|
354
|
+
// An empty PR history can authorize a no-code review; keep this validation fresh too.
|
|
328
355
|
if ((await github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`)).length) return null;
|
|
329
356
|
return { pr: [], pr_head: [], branch, no_code: { repository, branch, base, branch_head: remote || null } };
|
|
330
|
-
} catch { return null; }
|
|
357
|
+
} catch (error) { if (error.code === 'GITHUB_BUDGET') throw error; return null; }
|
|
331
358
|
}
|
|
332
359
|
|
|
333
360
|
/** The repositories this machine keeps a checkout of. */
|
|
334
|
-
async function knownRepositories() {
|
|
335
|
-
try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; }
|
|
361
|
+
async function knownRepositories({ fresh = false } = {}) {
|
|
362
|
+
try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; }
|
|
363
|
+
catch (error) { if (fresh) throw error; return []; }
|
|
336
364
|
}
|
|
337
365
|
|
|
338
366
|
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
|
|
@@ -358,22 +386,28 @@ export async function reviewDue(board, id, prs) {
|
|
|
358
386
|
const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
|
|
359
387
|
if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
|
|
360
388
|
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 ((
|
|
389
|
+
for (const p of unjudged) if ((await commitTime(p)) > Number(last.verdict_at)) return true;
|
|
362
390
|
return false;
|
|
363
391
|
}
|
|
364
392
|
|
|
365
393
|
const commitTimes = new Map();
|
|
366
|
-
/**
|
|
394
|
+
/** Cache successful immutable commit dates only. An unread date is unknown, never evidence of old work. */
|
|
367
395
|
async function commitTime({ url, head }) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
commitTimes.set(head, at);
|
|
396
|
+
const key = `${githubBase()}\0${repositoryOf(url)}\0${head}`;
|
|
397
|
+
if (commitTimes.has(key)) return commitTimes.get(key);
|
|
398
|
+
const at = Math.floor(Date.parse((await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit?.committer?.date) / 1000);
|
|
399
|
+
if (!Number.isFinite(at)) throw new Error(`GitHub commit date for ${url} at ${head} is unreadable; review eligibility is unknown`);
|
|
400
|
+
commitTimes.set(key, at);
|
|
374
401
|
return at;
|
|
375
402
|
}
|
|
376
403
|
|
|
404
|
+
/** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
|
|
405
|
+
async function requireCurrentReviewSet(board, id, urls) {
|
|
406
|
+
const current = await openReviewPullRequests(board, id, { fresh: true });
|
|
407
|
+
const omitted = (current?.pr ?? []).filter(url => !urls.includes(url));
|
|
408
|
+
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`);
|
|
409
|
+
}
|
|
410
|
+
|
|
377
411
|
/** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
|
|
378
412
|
* a review request naming that head, before the next request. A head is reviewed once. */
|
|
379
413
|
export function judgedHead(board, id, head) {
|
|
@@ -400,6 +434,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
400
434
|
}
|
|
401
435
|
const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
|
|
402
436
|
if (candidate) {
|
|
437
|
+
await requireCurrentReviewSet(board, id, candidatePullRequests(candidate).map(p => p.pr.url));
|
|
403
438
|
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
439
|
if (!row) throw new Error('The current candidate needs its independent verdict before acceptance.');
|
|
405
440
|
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);
|
|
@@ -417,6 +452,7 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
417
452
|
}
|
|
418
453
|
const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
|
|
419
454
|
const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
|
|
455
|
+
if (stage === 'request_review' || stage === 'review_verdict') await requireCurrentReviewSet(board, id, urls);
|
|
420
456
|
if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
|
|
421
457
|
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
458
|
// Merged source is immutable and needs no surviving workspace. Resolve an
|
|
@@ -459,7 +495,8 @@ export async function inspectCompletionEvidence(board, id, entry, payload = {},
|
|
|
459
495
|
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
496
|
}
|
|
461
497
|
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
|
|
498
|
+
} catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message,
|
|
499
|
+
...(error.code ? { code: error.code, retryable: error.code === 'GITHUB_BUDGET' } : {}) }); }
|
|
463
500
|
return state;
|
|
464
501
|
}
|
|
465
502
|
|
package/board/cli.mjs
CHANGED
|
@@ -109,6 +109,7 @@ The board's verbs, with their flags:
|
|
|
109
109
|
specify <id> [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
110
110
|
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
111
111
|
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
112
|
+
github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
|
|
112
113
|
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
113
114
|
capacity (each cap's count against its max, and why each card waits; docs/adr/0009-board-capacity.md)
|
|
114
115
|
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 +1425,7 @@ function ownCards(db, sessionId) {
|
|
|
1424
1425
|
return { worked, held };
|
|
1425
1426
|
}
|
|
1426
1427
|
|
|
1427
|
-
const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
|
|
1428
|
+
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
1429
|
|
|
1429
1430
|
/** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of this machine's
|
|
1430
1431
|
* daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
|
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
|
@@ -3172,10 +3172,8 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3172
3172
|
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
3173
|
continue;
|
|
3174
3174
|
}
|
|
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.
|
|
3175
|
+
// A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
|
|
3176
|
+
// (`review_held`) so a goal turn can be told to have started after that work was first observed.
|
|
3179
3177
|
const heldRun = currentRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
|
|
3180
3178
|
const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
|
|
3181
3179
|
const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
|
|
@@ -3184,9 +3182,15 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3184
3182
|
report.queued.push([id, `review waits: ${inTurn.why}`]);
|
|
3185
3183
|
continue;
|
|
3186
3184
|
}
|
|
3187
|
-
|
|
3185
|
+
if (!dry) await refreshCardInputs(board, id, { commits: true });
|
|
3186
|
+
// Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
|
|
3187
|
+
payload = await openReviewPullRequests(board, id, { fresh: true });
|
|
3188
|
+
if (!payload && noCodeReviewDue(board, id)) payload = await noCodeChange(board, id);
|
|
3189
|
+
if (!payload || payload.waits) { report.queued.push([id, `review waits: ${payload?.waits ?? 'the current source set has no review candidate'}`]); payload = null; continue; }
|
|
3190
|
+
const evidence = payload.no_code ? { required: false, ready: true, reason: null, phase: 'source', no_code: payload.no_code } : await inspectCompletionEvidence(board, id, entry, payload, 'request_review');
|
|
3191
|
+
if (!evidence.ready) throw new Error(evidence.reason);
|
|
3192
|
+
// A dry run names the complete validated review a tick would request and writes nothing.
|
|
3188
3193
|
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
3194
|
const moved = tx(board, db => {
|
|
3191
3195
|
const task = getTask(db, id);
|
|
3192
3196
|
if (eligible(db) !== true) return null;
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.201",
|
|
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": {
|