@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 +147 -56
- package/board/cli.mjs +68 -61
- package/board/dispatch.mjs +129 -58
- package/board/engine.mjs +69 -25
- package/board/store.mjs +6 -3
- package/board/tools.mjs +6 -3
- package/board/workflow.mjs +30 -45
- package/board/workspace.mjs +32 -0
- package/board/ztrack.mjs +802 -542
- package/cron.mjs +8 -2
- package/hermes-locks.mjs +1 -20
- package/loop.mjs +23 -2
- package/mcp/hermes-kanban-tools.json +47 -18
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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'],
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
106
|
-
if (!
|
|
107
|
-
if (
|
|
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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
if (stage === 'review_verdict' && !asked && !
|
|
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 (
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
127
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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) {
|