@volter/supercode-orchestrator 0.5.72 → 0.5.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/board/ci.mjs 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 { spawnSync } from 'node:child_process';
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, not the gh binary: Node takes a World's trust bundle and
11
- // injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a review World's GitHub twin answers it, where gh (Go) trusts only
12
- // the system keychain. The read runs in a child Node so the callers stay synchronous.
13
- const GITHUB_READ = `
14
- const [path, all] = process.argv.slice(1);
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 fixed = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
33
- const read = () => {
34
- if (!fixed && !loginToken) try { loginToken = run('gh', ['auth', 'token']) || null; } catch { loginToken = null; }
35
- const token = fixed ?? loginToken;
36
- const env = token ? { ...process.env, GH_TOKEN: token } : process.env;
37
- return spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_READ, path, all ? '1' : '0'], { env, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
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 = read();
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.status !== 0 && !fixed && loginToken && /^GitHub 401 /.test(result.stderr ?? '')) { loginToken = null; result = read(); }
43
- if (result.status !== 0) throw new Error(`GitHub: ${(result.stderr || result.error?.message || 'read failed').trim().slice(-500)}`);
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 fixed = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
72
- if (!fixed && !loginToken) try { loginToken = run('gh', ['auth', 'token']) || null; } catch { loginToken = null; }
73
- const token = fixed ?? loginToken;
74
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_MERGE, `/repos/${repositoryOf(url)}/pulls/${numberOf(url)}/merge`, reviewed], { env: token ? { ...process.env, GH_TOKEN: token } : process.env, encoding: 'utf8', timeout: 60_000 });
75
- if (result.status !== 0) throw new Error(`GitHub merge: ${(result.stderr || result.error?.message || 'failed').trim().slice(-300)}`);
76
- const answer = JSON.parse(result.stdout);
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
- const run = (program, args, cwd) => {
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
- const result = spawnSync(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
91
- if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
92
- return result.stdout.trim();
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
- return unjudged.some((p) => (commitTime(p) ?? 0) > Number(last.verdict_at));
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`;