@volter/supercode-orchestrator 0.4.148 → 0.4.150

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,14 +1,19 @@
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
3
  import { spawnSync } from 'node:child_process';
4
- import { readFileSync, realpathSync, writeFileSync, renameSync } from 'node:fs';
4
+ import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from 'node:fs';
5
5
  import { dirname, join, relative } from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
- import { resolveWorkspace } from './workspace.mjs';
7
+ import { branchCheckouts, checkoutOf, resolveWorkspace } from './workspace.mjs';
8
8
 
9
9
  const observations = new Map();
10
10
  const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
11
11
  const run = (program, args, cwd) => {
12
+ if (cwd) {
13
+ let directory = false;
14
+ try { directory = statSync(cwd).isDirectory(); } catch { /* reported below */ }
15
+ if (!directory) throw new Error(`Repository checkout ${cwd} is unavailable; resolve the card's declared workspace before verifying its source.`);
16
+ }
12
17
  const result = spawnSync(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
13
18
  if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
14
19
  return result.stdout.trim();
@@ -41,13 +46,17 @@ export function withCompletionEvidence(board, id, state, fn) {
41
46
  }
42
47
  }
43
48
 
44
- /** The latest explicitly requested PR is the reviewer's candidate, never the deployment tip. */
49
+ /** The latest explicitly requested PRs are the reviewer's candidate, never the deployment tip. */
45
50
  export function reviewCandidate(board, id) {
46
51
  const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
47
52
  const payload = row && JSON.parse(row.payload);
48
53
  return payload?.ci?.pr ? payload.ci : null;
49
54
  }
50
55
 
56
+ /** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
57
+ 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 }] : []);
58
+ const originOf = (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1] ?? null;
59
+
51
60
  function repositoryWorkspace(board, id) {
52
61
  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);
53
62
  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);
@@ -57,90 +66,172 @@ function repositoryWorkspace(board, id) {
57
66
 
58
67
  const pullRequestLists = new Map();
59
68
 
60
- /** Find an opened PR for this card's declared worktree branch; caller decides the transition. */
61
- export function openReviewPullRequest(board, id) {
62
- const path = repositoryWorkspace(board, id);
63
- if (!path) return null;
69
+ function branchCheckout(board, id, repository, branch) {
70
+ 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);
71
+ try { return checkoutOf(task?.machine, repository, branch)?.path ?? null; } catch { return null; }
72
+ }
73
+
74
+ /** The sibling checkouts an arc registered: each subtask (and the card itself) that declares a repository
75
+ * workspace and a branch, e.g. `create "<what>" --subtask-of <arc> --workspace worktree:<repository> --branch <name>`.
76
+ * Their PRs are the arc's whatever the branch is named. */
77
+ function registeredBranches(board, id) {
78
+ const rows = board.db.prepare(`SELECT t.branch_name, t.workspace_kind, c.workspace_anchor FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
79
+ WHERE (t.id=? OR c.subtask_of=?) AND t.status != 'cancelled' AND t.workspace_kind IN ('worktree','dir') AND t.branch_name IS NOT NULL`).all(id, id);
80
+ const out = [];
81
+ for (const row of rows) {
82
+ if (!row.workspace_anchor || /^(\/|~|[A-Za-z]:[\\/])/.test(row.workspace_anchor)) continue;
83
+ out.push({ repository: row.workspace_anchor.replace(/^github\.com\//i, '').replace(/\.git$/, ''), branch: row.branch_name });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** Every opened PR of the arc: each open PR whose head is the card's branch or a `wt/<card id>` branch, in the
89
+ * card's own repository and in every repository this machine keeps a checkout of, and each open PR on a sibling
90
+ * checkout the arc registered (`registeredBranches`). One review covers them all; the caller decides the transition. */
91
+ export function openReviewPullRequests(board, id) {
92
+ const own = repositoryWorkspace(board, id);
93
+ if (!own) return null;
64
94
  const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
65
- const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], path);
66
- if (!branch) return null;
67
- const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1];
68
- if (!repository) return null;
69
- // Discovery is shared per repository for one minute; the selected PR/head is
70
- // always re-read by inspectCompletionEvidence before it authorizes a move.
71
- let list = pullRequestLists.get(repository);
72
- if (!list || Date.now() - list.at >= 60_000) {
73
- list = { at: Date.now(), items: JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository'], path)) };
74
- if (pullRequestLists.size >= 32) pullRequestLists.delete(pullRequestLists.keys().next().value);
75
- pullRequestLists.set(repository, list);
95
+ const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], own);
96
+ const ownRepository = originOf(own);
97
+ if (!ownRepository) return null;
98
+ const registered = registeredBranches(board, id);
99
+ const mine = (repository, head) => head === branch || [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`))
100
+ || registered.some((r) => r.repository === repository && r.branch === head);
101
+ const prs = [];
102
+ for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
103
+ // Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
104
+ let list = pullRequestLists.get(repository);
105
+ if (!list || Date.now() - list.at >= 60_000) {
106
+ let items = [];
107
+ try { items = JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository,isDraft'])); }
108
+ catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
109
+ list = { at: Date.now(), items };
110
+ if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
111
+ pullRequestLists.set(repository, list);
112
+ }
113
+ // a draft is not handed over yet: its review starts when it is marked ready
114
+ for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
76
115
  }
77
- const prs = list.items.filter(pr => pr.headRefName === branch && !pr.isCrossRepository);
78
- if (prs.length !== 1) return null;
79
- return { pr: prs[0].url, pr_head: prs[0].headRefOid, commit: prs[0].headRefOid, branch };
116
+ if (!prs.length) return null;
117
+ return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
118
+ }
119
+
120
+ /** The repositories this machine keeps a checkout of. */
121
+ function knownRepositories() {
122
+ try { return [...new Set(branchCheckouts(null, '*').map((row) => row.repository))]; } catch { return []; }
123
+ }
124
+
125
+ /** Whether the arc's open PRs (`[{ url, head }]`) call for its review. Before any verdict: while some head has none.
126
+ * After a verdict: only once a PR that verdict judged shows a new head. A PR that joins the arc, or one that merged or
127
+ * closed, does not by itself bring the arc back: after changes are requested the next run is the implementer's. */
128
+ export function reviewDue(board, id, prs) {
129
+ const unjudged = prs.some((p) => !judgedHead(board, id, p.head));
130
+ const last = board.db.prepare(`SELECT r.payload FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
131
+ AND EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id)
132
+ ORDER BY r.id DESC LIMIT 1`).get(id);
133
+ if (!last) return unjudged;
134
+ const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
135
+ return unjudged && judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit));
136
+ }
137
+
138
+ /** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
139
+ * a review request naming that head, before the next request. A head is reviewed once. */
140
+ export function judgedHead(board, id, head) {
141
+ if (!head) return null;
142
+ return board.db.prepare(`SELECT v.id, v.kind FROM task_events r JOIN task_events v ON v.task_id = r.task_id
143
+ AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id
144
+ AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)
145
+ WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
146
+ 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;
80
147
  }
81
148
 
82
149
  export function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
83
150
  const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
84
151
  try {
85
- const path = repositoryWorkspace(board, id);
86
152
  if (stage === 'completion' || stage === 'acceptance') {
87
153
  const supplied = payload.commit ?? payload.metadata?.commit;
88
154
  if (supplied) {
155
+ const path = repositoryWorkspace(board, id);
89
156
  if (!path) throw new Error('The named commit needs a resolved repository workspace.');
90
- const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1];
91
- Object.assign(state, { repository, commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
157
+ Object.assign(state, { repository: originOf(path), commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
92
158
  }
93
159
  const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
94
160
  if (candidate) {
95
161
  const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
96
- const pass = row && JSON.parse(row.payload)?.ci;
97
- if (pass?.commit !== candidate.commit || pass?.pr?.url !== candidate.pr.url) throw new Error('The current PR head needs its independent verdict before acceptance.');
98
- const pr = JSON.parse(run('gh', ['pr', 'view', candidate.pr.url, '--repo', candidate.repository, '--json', 'state,headRefOid'], path));
99
- if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== candidate.commit) throw new Error('The PR head changed after its verdict; request a new review.');
162
+ const passed = candidatePullRequests(row && JSON.parse(row.payload)?.ci);
163
+ const wanted = candidatePullRequests(candidate);
164
+ if (wanted.length !== passed.length || wanted.some((w) => !passed.some((p) => p.pr.url === w.pr.url && p.commit === w.commit))) throw new Error('The current PR heads need their independent verdict before acceptance.');
165
+ for (const w of wanted) {
166
+ const pr = JSON.parse(run('gh', ['pr', 'view', w.pr.url, '--repo', w.repository, '--json', 'state,headRefOid']));
167
+ if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
168
+ }
100
169
  Object.assign(state, { ...candidate, ready: true, reason: null });
101
170
  }
102
171
  return state;
103
172
  }
173
+ const own = repositoryWorkspace(board, id);
104
174
  const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
105
- const pr = payload.pr ?? payload.metadata?.pr ?? asked?.pr?.url;
106
- if (!path) {
107
- if (pr) throw new Error('A PR candidate needs a resolved repository workspace.');
175
+ const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
176
+ if (!own) {
177
+ if (urls.length) throw new Error('A PR candidate needs a resolved repository workspace.');
108
178
  return state;
109
179
  }
110
- // Repository-free review-only jobs keep their own verdict contract. Ordinary
111
- // implementation reviews always name an opened PR, independent of completion_ci.
112
- if (stage === 'review_verdict' && !asked && !pr) return state;
180
+ // A verdict with no requested PR (a scratch arc) keeps its own contract. An arc's review
181
+ // always names every opened PR it changed, independent of completion_ci.
182
+ if (stage === 'review_verdict' && !asked && !urls.length) return state;
113
183
  Object.assign(state, { required: true, ready: false });
114
- if (typeof pr !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(pr)) throw new Error('Name the opened PR URL with --pr.');
115
- const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1];
116
- if (!repository || new URL(pr).pathname.split('/').slice(1, 3).join('/') !== repository) throw new Error('The PR must belong to this workspace repository.');
117
- const observed = JSON.parse(run('gh', ['pr', 'view', pr, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository'], path));
118
- if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error('Source review needs an open PR from this repository.');
119
- const supplied = payload.pr_head ?? payload.reviewed_sha ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit;
120
- if (!supplied || !/^[a-f0-9]{40}$/.test(supplied)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
121
- if (observed.headRefOid !== supplied) throw new Error(`PR head changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
122
- const branch = payload.branch ?? payload.metadata?.branch ?? observed.headRefName;
123
- const mainBranch = entry.params.completion_ci?.main_branches?.[repository] ?? 'main';
124
- if (branch !== observed.headRefName || branch === mainBranch || observed.baseRefName !== mainBranch) throw new Error('The PR must propose its named worktree branch to the repository main branch.');
184
+ if (!urls.length) throw new Error('Name each opened PR with --pr.');
185
+ const named = [].concat(stage === 'review_verdict'
186
+ ? payload.reviewed_sha ?? payload.pr_head ?? payload.commit ?? []
187
+ : payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
188
+ 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.');
189
+ if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
190
+ const prs = urls.map((url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage));
125
191
  if (stage === 'request_review') {
126
- run('git', ['check-ref-format', '--branch', branch], path);
127
- if (run('git', ['rev-parse', 'HEAD'], path) !== supplied) throw new Error('The named PR head must be this worktree head.');
128
- run('git', ['diff', '--quiet', 'HEAD'], path);
129
- if (run('git', ['ls-files', '--others', '--exclude-standard'], path)) throw new Error('Commit the untracked work before requesting review.');
130
- const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
131
- if (pushed !== supplied) throw new Error('Push the exact PR head before requesting review.');
192
+ // A review is asked for only while some PR has a head no verdict has judged.
193
+ 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.');
132
194
  } else if (asked) {
133
- if (asked.commit !== supplied || asked.pr.url !== observed.url) throw new Error('The verdict must name the requested PR and head.');
195
+ const requested = candidatePullRequests(asked);
196
+ 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.');
197
+ if (requested.some((r) => !named.includes(r.commit))) throw new Error('Name every reviewed PR head with --reviewed-sha.');
134
198
  const latest = board.db.prepare('SELECT metadata FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
135
- const bound = latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate;
136
- if (bound && (bound.commit !== supplied || bound.pr?.url !== observed.url)) throw new Error('This reviewer run was launched for a different PR head.');
199
+ const bound = candidatePullRequests(latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate);
200
+ 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.');
137
201
  }
138
- Object.assign(state, { ready: true, repository, branch, commit: supplied, source_sha: supplied,
139
- pr: { url: observed.url, number: observed.number, head: supplied, branch, base: observed.baseRefName }, reason: null });
202
+ Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}) });
140
203
  } catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message }); }
141
204
  return state;
142
205
  }
143
206
 
207
+ /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
208
+ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
209
+ if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
210
+ const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
211
+ // An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
212
+ // card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
213
+ // with nothing uncommitted.
214
+ const observed = JSON.parse(run('gh', ['pr', 'view', url, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository,isDraft']));
215
+ const path = originOf(own) === repository && run('git', ['branch', '--show-current'], own) === observed.headRefName ? own : branchCheckout(board, id, repository, observed.headRefName);
216
+ if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error(`Source review needs an open PR from its repository (${url}).`);
217
+ if (observed.isDraft) throw new Error(`${url} is a draft; its review starts when it is marked ready for review.`);
218
+ const head = supplied ?? observed.headRefOid;
219
+ if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
220
+ if (observed.headRefOid !== head) throw new Error(`The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
221
+ const branch = observed.headRefName;
222
+ const mainBranch = entry.params.completion_ci?.main_branches?.[repository] ?? 'main';
223
+ if (branch === mainBranch || observed.baseRefName !== mainBranch) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
224
+ if (stage === 'request_review' && path) {
225
+ run('git', ['check-ref-format', '--branch', branch], path);
226
+ if (run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
227
+ const uncommitted = run('git', ['status', '--porcelain', '--untracked-files=normal'], path).split('\n').filter(Boolean);
228
+ 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 ? ', …' : ''}`);
229
+ const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
230
+ if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
231
+ }
232
+ return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
233
+ }
234
+
144
235
  const digest = bytes => createHash('sha256').update(bytes).digest('hex');
145
236
  /** Read the tracked workflow revision the next serve tick will load. */
146
237
  export function workflowRevision(root) {