@volter/supercode-orchestrator 0.5.73 → 0.5.75
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/agents.mjs +34 -24
- package/bin/orchestrator.mjs +2 -0
- package/board/agent-declarations.mjs +5 -5
- package/board/ci.mjs +98 -102
- package/board/cli.mjs +44 -57
- package/board/dispatch.mjs +314 -190
- package/board/engine.mjs +50 -28
- package/board/git-document.mjs +68 -20
- package/board/machine-fleet.mjs +27 -14
- package/board/runtime-door.mjs +17 -14
- package/board/store.mjs +1 -1
- package/board/tools.mjs +2 -2
- package/board/workflow.mjs +13 -12
- package/board/workspace.mjs +22 -15
- package/board/ztrack.mjs +22 -12
- package/hermes-cli.mjs +4 -12
- package/loop.mjs +7 -7
- package/package.json +2 -2
- package/bin/machine-fleet.mjs +0 -38
- package/bin/runtime-door.mjs +0 -23
package/board/ci.mjs
CHANGED
|
@@ -1,79 +1,72 @@
|
|
|
1
1
|
// Review candidates are observed before a board mutation. No network or Git
|
|
2
2
|
// process runs inside the optimistic transaction. Deployment is not a completion gate.
|
|
3
|
-
import {
|
|
3
|
+
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
7
|
import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace } from './workspace.mjs';
|
|
8
8
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
9
9
|
|
|
10
|
-
// GitHub is read through its REST API with Node's own fetch,
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const base = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\\/$/, '');
|
|
16
|
-
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
17
|
-
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'supercode-board', ...(token ? { authorization: 'Bearer ' + token } : {}) };
|
|
18
|
-
let url = base + path, out = [];
|
|
19
|
-
for (let page = 0; url && page < 10; page += 1) {
|
|
20
|
-
const r = await fetch(url, { headers });
|
|
21
|
-
if (!r.ok) { process.stderr.write('GitHub ' + r.status + ' for ' + path + ': ' + (await r.text()).slice(0, 300)); process.exit(1); }
|
|
22
|
-
const body = await r.json();
|
|
23
|
-
if (all !== '1') { process.stdout.write(JSON.stringify(body)); process.exit(0); }
|
|
24
|
-
out = out.concat(body);
|
|
25
|
-
url = /<([^>]+)>;\\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
|
|
26
|
-
}
|
|
27
|
-
process.stdout.write(JSON.stringify(out));`;
|
|
10
|
+
// GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
|
|
11
|
+
// waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
|
|
12
|
+
// review World's GitHub twin answers it, where gh (Go) trusts only the system keychain.
|
|
13
|
+
const githubBase = () => (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/$/, '');
|
|
14
|
+
const githubHeaders = (token, extra = {}) => ({ accept: 'application/vnd.github+json', 'user-agent': 'supercode-board', ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra });
|
|
28
15
|
let loginToken = null;
|
|
16
|
+
async function githubToken() {
|
|
17
|
+
const fixed = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
|
|
18
|
+
if (!fixed && !loginToken) try { loginToken = (await run('gh', ['auth', 'token'])) || null; } catch { loginToken = null; }
|
|
19
|
+
return fixed ?? loginToken;
|
|
20
|
+
}
|
|
29
21
|
/** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login: a failed
|
|
30
22
|
* read of the login is not kept, and a login token GitHub refuses (401, rotated or revoked) is read again once. */
|
|
31
|
-
function github(path, all = false) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
23
|
+
async function github(path, all = false) {
|
|
24
|
+
const read = async () => {
|
|
25
|
+
const token = await githubToken();
|
|
26
|
+
let url = githubBase() + path, out = [];
|
|
27
|
+
for (let page = 0; url && page < 10; page += 1) {
|
|
28
|
+
const r = await fetch(url, { headers: githubHeaders(token), signal: AbortSignal.timeout(30_000) });
|
|
29
|
+
if (!r.ok) return { status: r.status, error: `GitHub ${r.status} for ${path}: ${(await r.text()).slice(0, 300)}` };
|
|
30
|
+
const body = await r.json();
|
|
31
|
+
if (!all) return { body };
|
|
32
|
+
out = out.concat(body);
|
|
33
|
+
url = /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
|
|
34
|
+
}
|
|
35
|
+
return { body: out };
|
|
38
36
|
};
|
|
39
37
|
const started = performance.now();
|
|
40
|
-
let result
|
|
38
|
+
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) }; } }
|
|
41
41
|
noteStep('github read', performance.now() - started);
|
|
42
|
-
if (result.
|
|
43
|
-
|
|
44
|
-
return JSON.parse(result.stdout);
|
|
42
|
+
if (result.error) throw new Error(`GitHub: ${result.error.trim().slice(-500)}`);
|
|
43
|
+
return result.body;
|
|
45
44
|
}
|
|
46
45
|
const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
47
46
|
const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
48
47
|
/** A PR as the board reads it (gh's field names). */
|
|
49
48
|
const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
|
|
50
49
|
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) });
|
|
51
|
-
const readPullRequest = (url) => pullRequest(repositoryOf(url), github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
|
|
50
|
+
const readPullRequest = async (url) => pullRequest(repositoryOf(url), await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
|
|
52
51
|
|
|
53
|
-
const GITHUB_MERGE = `
|
|
54
|
-
const [path, sha] = process.argv.slice(1);
|
|
55
|
-
const base = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\\/$/, '');
|
|
56
|
-
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
57
|
-
const r = await fetch(base + path, { method: 'PUT', headers: { accept: 'application/vnd.github+json', 'content-type': 'application/json', 'user-agent': 'supercode-board', ...(token ? { authorization: 'Bearer ' + token } : {}) }, body: JSON.stringify({ sha, merge_method: 'merge' }) });
|
|
58
|
-
const text = await r.text();
|
|
59
|
-
process.stdout.write(JSON.stringify({ status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() }));`;
|
|
60
52
|
/**
|
|
61
53
|
* Merge a reviewed PR at the head its review judged, and only there: GitHub refuses the merge when the PR's head is no
|
|
62
54
|
* longer that commit (409), so a head pushed after the verdict is never merged unreviewed. Answers
|
|
63
55
|
* `{ merged, sha }` (already merged answers its merge commit), `{ moved, head }` when the head is not the reviewed one,
|
|
64
56
|
* or throws on anything GitHub did not decide (the caller tries again).
|
|
65
57
|
*/
|
|
66
|
-
export function mergeReviewedPullRequest(url, reviewed) {
|
|
67
|
-
const pr = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`);
|
|
58
|
+
export async function mergeReviewedPullRequest(url, reviewed) {
|
|
59
|
+
const pr = await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`);
|
|
68
60
|
if (pr.merged_at) return { merged: true, sha: pr.merge_commit_sha, already: true };
|
|
69
61
|
if (pr.head?.sha !== reviewed) return { moved: true, head: pr.head?.sha ?? null };
|
|
70
62
|
if (String(pr.state).toLowerCase() !== 'open') return { closed: true };
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
63
|
+
const token = await githubToken();
|
|
64
|
+
let answer;
|
|
65
|
+
try {
|
|
66
|
+
const r = await fetch(`${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) });
|
|
67
|
+
const text = await r.text();
|
|
68
|
+
answer = { status: r.status, body: (() => { try { return JSON.parse(text); } catch { return { message: text.slice(0, 300) }; } })() };
|
|
69
|
+
} catch (error) { throw new Error(`GitHub merge: ${String(error.message).slice(-300)}`); }
|
|
77
70
|
if (answer.status === 200 && answer.body?.merged) return { merged: true, sha: answer.body.sha };
|
|
78
71
|
if (answer.status === 409) return { moved: true, head: null, message: answer.body?.message ?? null };
|
|
79
72
|
throw new Error(`GitHub merge ${answer.status}: ${answer.body?.message ?? 'not merged'}`);
|
|
@@ -81,15 +74,17 @@ export function mergeReviewedPullRequest(url, reviewed) {
|
|
|
81
74
|
|
|
82
75
|
const observations = new Map();
|
|
83
76
|
const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
|
|
84
|
-
|
|
77
|
+
// A git (or gh) command, awaited: never a wait that holds the event loop.
|
|
78
|
+
const run = async (program, args, cwd) => {
|
|
85
79
|
if (cwd) {
|
|
86
80
|
let directory = false;
|
|
87
81
|
try { directory = statSync(cwd).isDirectory(); } catch { /* reported below */ }
|
|
88
82
|
if (!directory) throw new Error(`Repository checkout ${cwd} is unavailable; resolve the card's declared workspace before verifying its source.`);
|
|
89
83
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
return new Promise((resolve, reject) => execFile(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
85
|
+
if (error) reject(new Error(`${program}: ${(stderr || error.message || 'command failed').trim().slice(-500)}`));
|
|
86
|
+
else resolve(String(stdout).trim());
|
|
87
|
+
}));
|
|
93
88
|
};
|
|
94
89
|
|
|
95
90
|
export function completionEvidence(board, id) {
|
|
@@ -102,8 +97,8 @@ export function completionPhase(board, id, entry, verb, payload, roles = []) {
|
|
|
102
97
|
return verb === 'complete' ? 'acceptance' : 'completion';
|
|
103
98
|
}
|
|
104
99
|
|
|
105
|
-
export function observeCompletionEvidence(board, id, entry, payload = {}, phase = 'completion') {
|
|
106
|
-
const state = inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
100
|
+
export async function observeCompletionEvidence(board, id, entry, payload = {}, phase = 'completion') {
|
|
101
|
+
const state = await inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
107
102
|
observations.set(key(board, id), state);
|
|
108
103
|
return state;
|
|
109
104
|
}
|
|
@@ -129,26 +124,26 @@ export function reviewCandidate(board, id) {
|
|
|
129
124
|
/** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
|
|
130
125
|
export const candidatePullRequests = (ci) => ci?.prs ?? (ci?.pr ? [{ repository: ci.repository, branch: ci.branch, commit: ci.commit, source_sha: ci.source_sha ?? ci.commit, pr: ci.pr }] : []);
|
|
131
126
|
// The repository a checkout declares (remote.origin.url), not where a URL rewrite sends its transport.
|
|
132
|
-
const originOf = (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['config', '--get', 'remote.origin.url'], path))?.[1] ?? null;
|
|
127
|
+
const originOf = async (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(await run('git', ['config', '--get', 'remote.origin.url'], path))?.[1] ?? null;
|
|
133
128
|
|
|
134
129
|
/** The card's checkout as its machine answers for it (path, repository, branch), and whether it is on this machine:
|
|
135
130
|
* git runs only in a checkout here, and another machine's is known by what its door said. */
|
|
136
|
-
function repositoryWorkspace(board, id) {
|
|
131
|
+
async function repositoryWorkspace(board, id) {
|
|
137
132
|
const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
|
|
138
133
|
const parent = task?.subtask_of && board.db.prepare('SELECT t.*,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(task.subtask_of);
|
|
139
134
|
const workspace = task?.workspace_kind === 'scratch' ? parent : task;
|
|
140
135
|
if (!workspace || workspace.workspace_kind === 'scratch') return null;
|
|
141
136
|
const here = isLocalWorkspace(workspace);
|
|
142
|
-
if (workspace.workspace_anchor) return { ...resolveWorkspace(workspace, false), here };
|
|
137
|
+
if (workspace.workspace_anchor) return { ...(await resolveWorkspace(workspace, false)), here };
|
|
143
138
|
return workspace.workspace_path ? { path: workspace.workspace_path, here } : null;
|
|
144
139
|
}
|
|
145
140
|
|
|
146
141
|
const pullRequestLists = new Map();
|
|
147
142
|
|
|
148
|
-
function branchCheckout(board, id, repository, branch) {
|
|
143
|
+
async function branchCheckout(board, id, repository, branch) {
|
|
149
144
|
const task = board.db.prepare('SELECT c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
|
|
150
145
|
if (!isLocalWorkspace(task ?? {})) return null;
|
|
151
|
-
try { return checkoutOf(task?.machine, repository, branch)?.path ?? null; } catch { return null; }
|
|
146
|
+
try { return (await checkoutOf(task?.machine, repository, branch))?.path ?? null; } catch { return null; }
|
|
152
147
|
}
|
|
153
148
|
|
|
154
149
|
/** The sibling checkouts an arc registered: each subtask (and the card itself) that declares a repository
|
|
@@ -184,24 +179,24 @@ export function declaredWorkBranches(db, id) {
|
|
|
184
179
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
185
180
|
* 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
|
|
186
181
|
* answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
|
|
187
|
-
export function openReviewPullRequests(board, id) {
|
|
188
|
-
const own = repositoryWorkspace(board, id);
|
|
182
|
+
export async function openReviewPullRequests(board, id) {
|
|
183
|
+
const own = await repositoryWorkspace(board, id);
|
|
189
184
|
if (!own) return null;
|
|
190
185
|
const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
|
|
191
|
-
const ownRepository = own.repository ?? (own.here ? originOf(own.path) : null);
|
|
186
|
+
const ownRepository = own.repository ?? (own.here ? await originOf(own.path) : null);
|
|
192
187
|
if (!ownRepository) return null;
|
|
193
|
-
const current = task?.branch_name ? null : own.branch ?? (own.here ? run('git', ['branch', '--show-current'], own.path) : null);
|
|
188
|
+
const current = task?.branch_name ? null : own.branch ?? (own.here ? await run('git', ['branch', '--show-current'], own.path) : null);
|
|
194
189
|
let branch = task?.branch_name ?? (current || null);
|
|
195
190
|
const registered = registeredBranches(board, id);
|
|
196
191
|
const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
|
|
197
192
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
198
193
|
const prs = [];
|
|
199
|
-
for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
|
|
194
|
+
for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories())])) {
|
|
200
195
|
// Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
|
|
201
196
|
let list = pullRequestLists.get(repository);
|
|
202
197
|
if (!list || Date.now() - list.at >= 60_000) {
|
|
203
198
|
let items = [];
|
|
204
|
-
try { items = github(`/repos/${repository}/pulls?state=open&per_page=100`, true).map((p) => pullRequest(repository, p)); }
|
|
199
|
+
try { items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true)).map((p) => pullRequest(repository, p)); }
|
|
205
200
|
catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
|
|
206
201
|
list = { at: Date.now(), items };
|
|
207
202
|
if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
|
|
@@ -209,7 +204,7 @@ export function openReviewPullRequests(board, id) {
|
|
|
209
204
|
}
|
|
210
205
|
// The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
|
|
211
206
|
if (repository === ownRepository && branch) {
|
|
212
|
-
const defaultBranch = list.items.find((pr) => pr.defaultBranch)?.defaultBranch ?? (() => { try { return run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own.path).replace(/^origin\//, ''); } catch { return null; } })();
|
|
207
|
+
const defaultBranch = list.items.find((pr) => pr.defaultBranch)?.defaultBranch ?? (await (async () => { try { return (await run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own.path)).replace(/^origin\//, ''); } catch { return null; } })());
|
|
213
208
|
if (branch === defaultBranch) branch = null;
|
|
214
209
|
}
|
|
215
210
|
// a draft is not handed over yet: its review starts when it is marked ready; a PR to another base (main→prod) is a release, not the arc's work
|
|
@@ -222,32 +217,32 @@ export function openReviewPullRequests(board, id) {
|
|
|
222
217
|
/** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
|
|
223
218
|
* beyond its repository's default branch, locally or on origin, its checkout holds no uncommitted work, no PR was
|
|
224
219
|
* ever opened from it, and it registered no other branch. Answers what its review reads instead of a diff, or null when the arc carries (or may carry) code. */
|
|
225
|
-
export function noCodeChange(board, id) {
|
|
226
|
-
const checkout = repositoryWorkspace(board, id);
|
|
220
|
+
export async function noCodeChange(board, id) {
|
|
221
|
+
const checkout = await repositoryWorkspace(board, id);
|
|
227
222
|
if (!checkout?.here) return null;
|
|
228
223
|
const own = checkout.path;
|
|
229
224
|
try {
|
|
230
|
-
const repository = originOf(own);
|
|
225
|
+
const repository = await originOf(own);
|
|
231
226
|
const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
|
|
232
|
-
const branch = task?.branch_name ?? (run('git', ['branch', '--show-current'], own) || null);
|
|
233
|
-
const base = run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own);
|
|
227
|
+
const branch = task?.branch_name ?? (await run('git', ['branch', '--show-current'], own) || null);
|
|
228
|
+
const base = await run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], own);
|
|
234
229
|
if (!repository || !branch || !base || branch === base.replace(/^origin\//, '')) return null;
|
|
235
230
|
if (registeredBranches(board, id).some((r) => r.repository !== repository || r.branch !== branch)) return null;
|
|
236
231
|
// Uncommitted work in the checkout is code not yet committed: a dirty worktree is never no-code.
|
|
237
|
-
if (run('git', ['status', '--porcelain', '--untracked-files=normal'], own)) return null;
|
|
232
|
+
if (await run('git', ['status', '--porcelain', '--untracked-files=normal'], own)) return null;
|
|
238
233
|
// An unknown local or remote commit is code this checkout cannot see: run() throws and the arc waits.
|
|
239
|
-
if (run('git', ['for-each-ref', '--format=%(refname)', `refs/heads/${branch}`], own)) run('git', ['merge-base', '--is-ancestor', `refs/heads/${branch}`, base], own);
|
|
240
|
-
const remote = run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own).split(/\s+/)[0];
|
|
241
|
-
if (remote) run('git', ['merge-base', '--is-ancestor', remote, base], own);
|
|
234
|
+
if (await run('git', ['for-each-ref', '--format=%(refname)', `refs/heads/${branch}`], own)) await run('git', ['merge-base', '--is-ancestor', `refs/heads/${branch}`, base], own);
|
|
235
|
+
const remote = (await run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own)).split(/\s+/)[0];
|
|
236
|
+
if (remote) await run('git', ['merge-base', '--is-ancestor', remote, base], own);
|
|
242
237
|
const owner = repository.split('/')[0];
|
|
243
|
-
if (github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`).length) return null;
|
|
238
|
+
if ((await github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`)).length) return null;
|
|
244
239
|
return { pr: [], pr_head: [], branch, no_code: { repository, branch, base, branch_head: remote || null } };
|
|
245
240
|
} catch { return null; }
|
|
246
241
|
}
|
|
247
242
|
|
|
248
243
|
/** The repositories this machine keeps a checkout of. */
|
|
249
|
-
function knownRepositories() {
|
|
250
|
-
try { return [...new Set(branchCheckouts(null, '*').map((row) => row.repository))]; } catch { return []; }
|
|
244
|
+
async function knownRepositories() {
|
|
245
|
+
try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; } catch { return []; }
|
|
251
246
|
}
|
|
252
247
|
|
|
253
248
|
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
|
|
@@ -255,7 +250,7 @@ function knownRepositories() {
|
|
|
255
250
|
* committed after that verdict (a new PR carrying the fixes, or any PR after a verdict that named none). A PR that
|
|
256
251
|
* joins with a head committed before the verdict is not: after changes are requested the next run is the
|
|
257
252
|
* implementer's until it pushes. */
|
|
258
|
-
export function reviewDue(board, id, prs) {
|
|
253
|
+
export async function reviewDue(board, id, prs) {
|
|
259
254
|
const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
|
|
260
255
|
if (!unjudged.length) return false;
|
|
261
256
|
const voided = board.db.prepare("SELECT r.payload FROM task_events v JOIN task_events r ON r.id=json_extract(v.payload,'$.review_request') AND r.task_id=v.task_id WHERE v.task_id=? AND v.kind='review_voided' ORDER BY v.id DESC LIMIT 1").get(id);
|
|
@@ -268,16 +263,17 @@ export function reviewDue(board, id, prs) {
|
|
|
268
263
|
if (!last) return true;
|
|
269
264
|
const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
|
|
270
265
|
if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
|
|
271
|
-
|
|
266
|
+
for (const p of unjudged) if (((await commitTime(p)) ?? 0) > Number(last.verdict_at)) return true;
|
|
267
|
+
return false;
|
|
272
268
|
}
|
|
273
269
|
|
|
274
270
|
const commitTimes = new Map();
|
|
275
271
|
/** When a PR's head was committed (seconds), read from GitHub once per head; null when it cannot be read. */
|
|
276
|
-
function commitTime({ url, head }) {
|
|
272
|
+
async function commitTime({ url, head }) {
|
|
277
273
|
if (commitTimes.has(head)) return commitTimes.get(head);
|
|
278
274
|
let at = null;
|
|
279
275
|
try {
|
|
280
|
-
at = Math.floor(Date.parse(github(`/repos/${repositoryOf(url)}/commits/${head}`).commit.committer.date) / 1000) || null;
|
|
276
|
+
at = Math.floor(Date.parse((await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit.committer.date) / 1000) || null;
|
|
281
277
|
} catch { /* unreadable: not counted as new */ }
|
|
282
278
|
commitTimes.set(head, at);
|
|
283
279
|
return at;
|
|
@@ -295,15 +291,15 @@ export function judgedHead(board, id, head) {
|
|
|
295
291
|
OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?)) ORDER BY v.id LIMIT 1`).get(id, head, head) ?? null;
|
|
296
292
|
}
|
|
297
293
|
|
|
298
|
-
export function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
|
|
294
|
+
export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
|
|
299
295
|
const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
|
|
300
296
|
try {
|
|
301
297
|
if (stage === 'completion' || stage === 'acceptance') {
|
|
302
298
|
const supplied = payload.commit ?? payload.metadata?.commit;
|
|
303
299
|
if (supplied) {
|
|
304
|
-
const path = repositoryWorkspace(board, id)?.path;
|
|
300
|
+
const path = (await repositoryWorkspace(board, id))?.path;
|
|
305
301
|
if (!path) throw new Error('The named commit needs a resolved repository workspace.');
|
|
306
|
-
Object.assign(state, { repository: originOf(path), commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
|
|
302
|
+
Object.assign(state, { repository: await originOf(path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
|
|
307
303
|
}
|
|
308
304
|
const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
|
|
309
305
|
if (candidate) {
|
|
@@ -314,14 +310,14 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
|
|
|
314
310
|
const wanted = candidatePullRequests(candidate);
|
|
315
311
|
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.');
|
|
316
312
|
for (const w of wanted) {
|
|
317
|
-
const pr = readPullRequest(w.pr.url);
|
|
313
|
+
const pr = await readPullRequest(w.pr.url);
|
|
318
314
|
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.`);
|
|
319
315
|
}
|
|
320
316
|
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
321
317
|
}
|
|
322
318
|
return state;
|
|
323
319
|
}
|
|
324
|
-
const own = repositoryWorkspace(board, id);
|
|
320
|
+
const own = await repositoryWorkspace(board, id);
|
|
325
321
|
const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
|
|
326
322
|
const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
|
|
327
323
|
if (!own) {
|
|
@@ -338,10 +334,10 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
|
|
|
338
334
|
: payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
|
|
339
335
|
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.');
|
|
340
336
|
if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
|
|
341
|
-
const prs = urls.map((url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage));
|
|
337
|
+
const prs = await Promise.all(urls.map((url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage)));
|
|
342
338
|
if (stage === 'request_review') {
|
|
343
339
|
// A review is asked for only while some PR has a head no verdict has judged.
|
|
344
|
-
if (!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.');
|
|
340
|
+
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.');
|
|
345
341
|
} else if (asked) {
|
|
346
342
|
const requested = candidatePullRequests(asked);
|
|
347
343
|
if (requested.length !== prs.length || requested.some((r) => !prs.some((p) => p.pr.url === r.pr.url && p.commit === r.commit))) throw new Error('The verdict covers the requested PRs at their requested heads; a PR whose head moved needs a new review request.');
|
|
@@ -356,14 +352,14 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
|
|
|
356
352
|
}
|
|
357
353
|
|
|
358
354
|
/** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
|
|
359
|
-
function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
355
|
+
async function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
360
356
|
if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
|
|
361
357
|
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
362
358
|
// An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
|
|
363
359
|
// card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
|
|
364
360
|
// with nothing uncommitted. A card on another machine is reviewed at its pushed head: its checkout is not read from here.
|
|
365
|
-
const observed = readPullRequest(url);
|
|
366
|
-
const path = !own.here ? null : originOf(own.path) === repository && run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : branchCheckout(board, id, repository, observed.headRefName);
|
|
361
|
+
const observed = await readPullRequest(url);
|
|
362
|
+
const path = !own.here ? null : await originOf(own.path) === repository && await run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : await branchCheckout(board, id, repository, observed.headRefName);
|
|
367
363
|
// A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
|
|
368
364
|
// verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
|
|
369
365
|
const merged = observed.state === 'MERGED';
|
|
@@ -377,11 +373,11 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
|
377
373
|
// A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
|
|
378
374
|
if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
|
|
379
375
|
if (stage === 'request_review' && path && !merged) {
|
|
380
|
-
run('git', ['check-ref-format', '--branch', branch], path);
|
|
381
|
-
if (run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
|
|
382
|
-
const uncommitted = run('git', ['status', '--porcelain', '--untracked-files=normal'], path).split('\n').filter(Boolean);
|
|
376
|
+
await run('git', ['check-ref-format', '--branch', branch], path);
|
|
377
|
+
if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
|
|
378
|
+
const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
|
|
383
379
|
if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
|
|
384
|
-
const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
|
|
380
|
+
const pushed = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
|
|
385
381
|
if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
|
|
386
382
|
}
|
|
387
383
|
return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
|
|
@@ -389,25 +385,25 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
|
|
|
389
385
|
|
|
390
386
|
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
391
387
|
/** Read the tracked workflow revision the next serve tick will load. */
|
|
392
|
-
export function workflowRevision(root) {
|
|
388
|
+
export async function workflowRevision(root) {
|
|
393
389
|
try {
|
|
394
390
|
const workflow = realpathSync(join(root, 'workflow.yaml'));
|
|
395
|
-
const checkout = run('git', ['rev-parse', '--show-toplevel'], dirname(workflow));
|
|
396
|
-
const head = run('git', ['rev-parse', 'HEAD'], checkout);
|
|
397
|
-
const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['config', '--get', 'remote.origin.url'], checkout))?.[1];
|
|
391
|
+
const checkout = await run('git', ['rev-parse', '--show-toplevel'], dirname(workflow));
|
|
392
|
+
const head = await run('git', ['rev-parse', 'HEAD'], checkout);
|
|
393
|
+
const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(await run('git', ['config', '--get', 'remote.origin.url'], checkout))?.[1];
|
|
398
394
|
const name = relative(checkout, workflow).replaceAll('\\', '/');
|
|
399
395
|
const source = readFileSync(workflow, 'utf8').trimEnd();
|
|
400
|
-
if (run('git', ['show', `${head}:${name}`], checkout) !== source) return null;
|
|
396
|
+
if (await run('git', ['show', `${head}:${name}`], checkout) !== source) return null;
|
|
401
397
|
// Prompts are loaded with the workflow and must belong to the same revision.
|
|
402
|
-
run('git', ['diff', '--quiet', 'HEAD', '--', name, join(dirname(name), 'prompts')], checkout);
|
|
398
|
+
await run('git', ['diff', '--quiet', 'HEAD', '--', name, join(dirname(name), 'prompts')], checkout);
|
|
403
399
|
return { checkout, repository, head, workflow_hash: digest(source), started_at: Date.now(), pid: process.pid };
|
|
404
400
|
} catch { return null; }
|
|
405
401
|
}
|
|
406
402
|
|
|
407
403
|
/** Only the retained serve loop calls this after a successful tick. */
|
|
408
|
-
export function retainWorkflowTick(root, before) {
|
|
404
|
+
export async function retainWorkflowTick(root, before) {
|
|
409
405
|
if (!before) return null;
|
|
410
|
-
const after = workflowRevision(root);
|
|
406
|
+
const after = await workflowRevision(root);
|
|
411
407
|
if (!after || after.head !== before.head || after.workflow_hash !== before.workflow_hash) return null;
|
|
412
408
|
const receipt = { ...before, completed_at: Date.now() };
|
|
413
409
|
const path = join(root, 'runtime-deployment.json'), temporary = `${path}.${process.pid}.tmp`;
|