@volter/supercode-orchestrator 0.5.225 → 0.5.227
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -18
- package/adapters/rh2.mjs +6 -5
- package/bin/connection-runner.mjs +46 -12
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +6 -1
- package/board/agent-declarations.mjs +1 -1
- package/board/args.mjs +8 -1
- package/board/call.mjs +2 -2
- package/board/ci.mjs +158 -54
- package/board/cli.mjs +175 -53
- package/board/custody.mjs +5 -1
- package/board/dispatch.mjs +969 -256
- package/board/doors.mjs +7 -2
- package/board/engine.mjs +264 -48
- package/board/files.mjs +4 -1
- package/board/fleet-listeners.mjs +3 -1
- package/board/fleet-observer.mjs +3 -2
- package/board/generation-owner.mjs +40 -10
- package/board/generation.mjs +17 -4
- package/board/machine-fleet.mjs +33 -9
- package/board/notices.mjs +10 -2
- package/board/published.mjs +107 -2
- package/board/readable-id.mjs +25 -2
- package/board/runtime-door.mjs +2 -2
- package/board/session-work.mjs +15 -1
- package/board/storage-contract.mjs +41 -3
- package/board/store.mjs +49 -7
- package/board/tools.mjs +1 -1
- package/board/workflow.mjs +197 -70
- package/board/workspace.mjs +99 -13
- package/board/ztrack.mjs +42 -14
- package/connection.mjs +252 -32
- package/index.d.ts +5 -1
- package/installed-apps.mjs +11 -5
- package/loop.mjs +6 -0
- package/mcp/hermes-kanban-tools.json +10 -1
- package/package.json +6 -6
- package/reducer.mjs +31 -2
package/board/ci.mjs
CHANGED
|
@@ -5,10 +5,9 @@ import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from
|
|
|
5
5
|
import { dirname, join, relative } from 'node:path';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
8
|
-
import { branchCheckouts, checkoutOf, isLocalWorkspace, isWorkspacePath, resolveWorkspace, plainWorkspace } from './workspace.mjs';
|
|
8
|
+
import { branchCheckouts, checkoutOf, isLocalWorkspace, isWorkspacePath, resolveWorkspace, plainWorkspace, workspaceTask } from './workspace.mjs';
|
|
9
9
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
10
10
|
import { githubFetch, githubDiscovery, githubDiscoveryHeld, githubDiscoveryUsed, GITHUB_DISCOVERY_ATTEMPTS } from './github-rest.mjs';
|
|
11
|
-
import { cardHandleOf } from './store.mjs';
|
|
12
11
|
|
|
13
12
|
// GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
|
|
14
13
|
// waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
|
|
@@ -273,6 +272,9 @@ export function completionEvidence(board, id) {
|
|
|
273
272
|
|
|
274
273
|
export function completionPhase(board, id, entry, verb, payload, roles = []) {
|
|
275
274
|
if (verb === 'request_review') return 'request_review';
|
|
275
|
+
// A person's completion of their own card (person-done) is judged by no review and carries no source: its evidence is
|
|
276
|
+
// the person's act the Workplace reported, which the workflow checks names the card's person.
|
|
277
|
+
if (verb === 'complete' && payload.person && roles.includes('platform')) return 'person';
|
|
276
278
|
if ((verb === 'request_state' && payload.state === 'done' || verb === 'complete') && roles.includes('reviewer')) return 'review_verdict';
|
|
277
279
|
return verb === 'complete' ? 'acceptance' : 'completion';
|
|
278
280
|
}
|
|
@@ -298,7 +300,7 @@ export function withCompletionEvidence(board, id, state, fn) {
|
|
|
298
300
|
export function reviewCandidate(board, id) {
|
|
299
301
|
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);
|
|
300
302
|
const payload = row && JSON.parse(row.payload);
|
|
301
|
-
return payload?.ci?.pr || payload?.ci?.no_code ? payload.ci : null;
|
|
303
|
+
return payload?.ci?.pr || payload?.ci?.no_code || payload?.ci?.pr_reviewed?.length ? payload.ci : null;
|
|
302
304
|
}
|
|
303
305
|
|
|
304
306
|
/** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
|
|
@@ -320,7 +322,7 @@ async function repositoryWorkspace(board, id) {
|
|
|
320
322
|
const owner = workspaceOwner(board, id);
|
|
321
323
|
if (!owner) return null;
|
|
322
324
|
// the worktree's folder, when it is still to be made, is named for its card (workspace.mjs worktreeFolder)
|
|
323
|
-
const workspace =
|
|
325
|
+
const workspace = workspaceTask(board, owner);
|
|
324
326
|
const here = isLocalWorkspace(workspace);
|
|
325
327
|
if (!here) {
|
|
326
328
|
const identity = workspace.workspace_anchor ?? workspace.workspace_path;
|
|
@@ -328,10 +330,14 @@ async function repositoryWorkspace(board, id) {
|
|
|
328
330
|
// A portable remote declaration supplies the source identity. Legacy paths
|
|
329
331
|
// are resolved by their own machine, never read as paths on the board host.
|
|
330
332
|
const resolved = plainWorkspace(workspace.workspace_kind, identity, workspace.branch_name) ?? await resolveWorkspace(workspace, false);
|
|
333
|
+
// a plain folder holding the arc's repositories (workspace.mjs plainFolder) has none of its own: the review reads
|
|
334
|
+
// the arc's recorded ones, as a scratch arc's does
|
|
335
|
+
if (resolved.folder) return null;
|
|
331
336
|
return { ...resolved, branch: workspace.branch_name ?? resolved.branch, here: false };
|
|
332
337
|
}
|
|
333
338
|
if (workspace.workspace_anchor) {
|
|
334
339
|
const resolved = await resolveWorkspace(workspace, false);
|
|
340
|
+
if (resolved.folder) return null;
|
|
335
341
|
// Resolution without create names a solo worktree even before it exists on
|
|
336
342
|
// this machine. Its portable repository and branch still identify the
|
|
337
343
|
// pushed PR. Only an actual directory can supply local source evidence.
|
|
@@ -469,8 +475,8 @@ function earlierDeclaredBranches(db, id) {
|
|
|
469
475
|
|
|
470
476
|
/** The PRs the arc declared (`comment --pr URL --pr-head SHA`), each at its latest declaration: every declaration since
|
|
471
477
|
* the latest `--pr none` adds to the ones before it, in any of the arc's repositories, on any run. Discovery reads each
|
|
472
|
-
* at its current head and leaves out only a closed one
|
|
473
|
-
*
|
|
478
|
+
* at its current head and leaves out only a closed one and an open draft: a merged one is in at its merged head
|
|
479
|
+
* (discoverReviewSources). */
|
|
474
480
|
export function declaredPullRequests(db, id) {
|
|
475
481
|
const latest = new Map();
|
|
476
482
|
for (const declaration of declarationsOf(db, id, 'sources')) for (const source of declaration.items) {
|
|
@@ -522,11 +528,14 @@ export function declaredWorkBranches(db, id) {
|
|
|
522
528
|
return [...new Set([...declared, ...reviewed].filter(Boolean))];
|
|
523
529
|
}
|
|
524
530
|
|
|
525
|
-
/** Every
|
|
531
|
+
/** Every PR of the arc, open (not a draft) or merged, at its current head, each proposed to its repository's default
|
|
532
|
+
* branch, but for one merged at a head its PR review passed (`pr_reviewed`, settled): the arc's final review request
|
|
533
|
+
* names them all, and whether a review is due depends only on the heads no verdict judged (reviewDue). A PR review
|
|
534
|
+
* names one ready PR of them (requireCurrentReviewSet). A PR of the arc is one it declared (declaredPullRequests) or one whose head is the card's
|
|
526
535
|
* work branch in the card's own repository, a `wt/<card id>` branch in any repository this machine keeps a checkout
|
|
527
536
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
528
537
|
* 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
|
|
529
|
-
* answers `{ waits }` saying so.
|
|
538
|
+
* answers `{ waits }` saying so. The final review covers them all; the caller decides the transition. */
|
|
530
539
|
export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, refuseStacked = false, repositories = [] } = {}) {
|
|
531
540
|
if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, refuseStacked, repositories });
|
|
532
541
|
// Every authorization entry acquires the broad advisory scope, not only
|
|
@@ -572,7 +581,6 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
572
581
|
...repositories, ...siblings].filter(Boolean).map(r => r.toLowerCase()))];
|
|
573
582
|
const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
|
|
574
583
|
.filter(Boolean).map(r => r.toLowerCase()));
|
|
575
|
-
const answered = answeredChanges(board, id);
|
|
576
584
|
// A branch an earlier `--source` declaration named, and no task or the latest declaration names, keeps its merged
|
|
577
585
|
// history on a long cache: it is read, never skipped, but a lane card's weeks of branches stay a small share of the
|
|
578
586
|
// home's discovery budget. A task's branch and the latest declaration's are read as before.
|
|
@@ -640,7 +648,8 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
640
648
|
...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
|
|
641
649
|
...registered.filter(r => r.repository === repository).map(r => r.branch),
|
|
642
650
|
])];
|
|
643
|
-
// A
|
|
651
|
+
// A merged PR stays in the set at its merged head, judged or not: the review reads the arc's whole diff in every
|
|
652
|
+
// round, and only an unjudged head makes that review due (reviewDue).
|
|
644
653
|
const histories = await readTogether(historical, async headBranch => {
|
|
645
654
|
if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
|
|
646
655
|
const older = earlier.has(`${repository}:${headBranch}`) && !(repository === ownRepository && headBranch === branch);
|
|
@@ -649,7 +658,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
649
658
|
if (older && !held && !await admit(`history:${repository}:${headBranch}`)) return [];
|
|
650
659
|
return (held ?? await mergedBranchPullRequests(repository, headBranch, { fresh, ...(older ? { ttl: spread(EARLIER_BRANCH_TTL, `history:${repository}:${headBranch}`) } : {}) })).filter(pr =>
|
|
651
660
|
!pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
|
|
652
|
-
&& pr.baseRefName === pr.defaultBranch
|
|
661
|
+
&& pr.baseRefName === pr.defaultBranch);
|
|
653
662
|
});
|
|
654
663
|
for (const history of histories) for (const pr of history) {
|
|
655
664
|
if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
@@ -660,9 +669,8 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
660
669
|
// Each declared PR discovery did not find is followed at its current head (GitHub's), never pinned to the declared
|
|
661
670
|
// one, so a push after the declaration is reviewed instead of failing every tick. One open in a repository listed above
|
|
662
671
|
// is taken from that list, with no read of its own; any other is read (a merged answer already held is reused, in a
|
|
663
|
-
// fresh scan too: a merged PR never changes again). One from a fork, closed, or an open draft is left out
|
|
664
|
-
//
|
|
665
|
-
// after its pass and merged unreviewed is still in). One outside the arc's repositories is in scope only when it is
|
|
672
|
+
// fresh scan too: a merged PR never changes again). One from a fork, closed, or an open draft is left out; a merged one
|
|
673
|
+
// is in at its merged head, whatever verdicts it has. One outside the arc's repositories is in scope only when it is
|
|
666
674
|
// itself on the arc's work branch in its own repository. One GitHub says is absent or denied (404, 403, 410) stays
|
|
667
675
|
// outside and is refused with the rest, unless another declared PR brings its repository into scope, where its read
|
|
668
676
|
// error is thrown as it is. Any other read failure (5xx, reset, timeout) is thrown as a transient failure, never
|
|
@@ -699,17 +707,23 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
699
707
|
if (deferred) throw Object.assign(new Error(`${deferred} of ${id}'s earlier-declared PRs and branches are not read yet (${spent ? `the home has spent ${Math.round(FILL_SHARE * 100)}% of its hour's ${GITHUB_DISCOVERY_ATTEMPTS} discovery reads, so an arc starts one new read a round` : `an advisory round starts at most ${NEW_READS_PER_ROUND} new reads for an arc`}); the rest are read on the next rounds`), { code: 'github_read_admission_full', deferred: true });
|
|
700
708
|
for (const pr of read) {
|
|
701
709
|
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
702
|
-
if (pr.state === 'MERGED' && !answered && passedHead(board, id, pr.headRefOid)) continue;
|
|
703
710
|
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
704
711
|
}
|
|
712
|
+
// A PR merged at a head a review passed (its PR review, or an earlier final review: passedHead) is settled: the arc's
|
|
713
|
+
// final review neither names nor owes it. It is kept apart as `pr_reviewed`, so an arc whose every PR is settled still
|
|
714
|
+
// gets its final review, of its Acceptance against them (VO-3766: PRs merged at the heads a final review passed, then
|
|
715
|
+
// PR-reviewed PRs merged, left no PR the final review could name, and its open Acceptance waited forever).
|
|
716
|
+
const settled = prs.filter((p) => p.observed?.state === 'MERGED' && passedHead(board, id, p.head));
|
|
717
|
+
for (const p of settled) prs.splice(prs.indexOf(p), 1);
|
|
718
|
+
const reviewed = settled.length ? { pr_reviewed: settled.map((p) => ({ url: p.url, head: p.head })) } : {};
|
|
705
719
|
// Retain scope even when all matching PRs are excluded drafts/other bases,
|
|
706
|
-
//
|
|
720
|
+
// so a fresh scan cannot inherit that exclusion.
|
|
707
721
|
if (scope) for (const repository of reviewRepositories) scope.add(repository);
|
|
708
722
|
stacked.sort((a, b) => a.url.localeCompare(b.url));
|
|
709
723
|
// Advisory discovery can be up to five minutes old: the dispatcher tells a stacked refusal only once a fresh read
|
|
710
724
|
// (refuseStacked) confirms it.
|
|
711
|
-
if (stacked.length && (checkWorkspaces || refuseStacked)) throw refusalOf(stacked.map((pr) => refused('base', pr.url, `${pr.url} proposes ${pr.headRefName} to ${pr.baseRefName}, not ${pr.defaultBranch}: retarget it to ${pr.defaultBranch} so the arc's
|
|
712
|
-
if (!prs.length && stacked.length) return { pr: [], pr_head: [], branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url) };
|
|
725
|
+
if (stacked.length && (checkWorkspaces || refuseStacked)) throw refusalOf(stacked.map((pr) => refused('base', pr.url, `${pr.url} proposes ${pr.headRefName} to ${pr.baseRefName}, not ${pr.defaultBranch}: retarget it to ${pr.defaultBranch} so the arc's review includes it.`)));
|
|
726
|
+
if (!prs.length && (stacked.length || settled.length)) return { pr: [], pr_head: [], branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url), ...reviewed };
|
|
713
727
|
if (!prs.length) return branch || !ownRepository ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own.path} is on ${current}, its repository's default` : `its checkout ${own.path} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
|
|
714
728
|
// Advisory facts can refuse an attempt before its authoritative REST scan. Check every PR's properties before
|
|
715
729
|
// doing checkout work: a wrong base cannot be repaired by revalidating the whole arc every round. Discovery
|
|
@@ -720,7 +734,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
|
|
|
720
734
|
await refuseTogether(prs, pr => requireReviewPullRequestProperties(pr.url, pr.head, pr.observed));
|
|
721
735
|
await refuseTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
|
|
722
736
|
}
|
|
723
|
-
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url) };
|
|
737
|
+
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url), ...reviewed };
|
|
724
738
|
}
|
|
725
739
|
|
|
726
740
|
/** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
|
|
@@ -792,6 +806,9 @@ async function knownRepositories({ strict = false } = {}) {
|
|
|
792
806
|
* when the request was made. */
|
|
793
807
|
export async function reviewDue(board, id, prs) {
|
|
794
808
|
if (prs.length && answeredChanges(board, id)) return true;
|
|
809
|
+
// A review the manager returned with no verdict is due again only for new work: a PR head its request did not name.
|
|
810
|
+
const returned = returnedReview(board, id);
|
|
811
|
+
if (returned) return prs.some((p) => !returned.heads.some((h) => h.pr.url === p.url && h.commit === p.head));
|
|
795
812
|
const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
|
|
796
813
|
if (!unjudged.length) return false;
|
|
797
814
|
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);
|
|
@@ -800,7 +817,7 @@ export async function reviewDue(board, id, prs) {
|
|
|
800
817
|
AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id) AS verdict_at,
|
|
801
818
|
(SELECT v.kind FROM task_events v WHERE v.task_id = r.task_id
|
|
802
819
|
AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id ORDER BY v.id LIMIT 1) AS verdict
|
|
803
|
-
FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
|
|
820
|
+
FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND (${requestKindSql('r')}) != 'plan'
|
|
804
821
|
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)
|
|
805
822
|
ORDER BY r.id DESC LIMIT 1`).get(id);
|
|
806
823
|
if (!last) return true;
|
|
@@ -817,6 +834,37 @@ export async function reviewDue(board, id, prs) {
|
|
|
817
834
|
return false;
|
|
818
835
|
}
|
|
819
836
|
|
|
837
|
+
/** Whether a PR review is due for one ready PR at `head` (`[{ url, head }]` as reviewDue takes them): no PR review
|
|
838
|
+
* passed at that head, and no verdict judged it or the last verdict's findings are answered (answeredChanges). The
|
|
839
|
+
* arc's other PRs neither hold it nor make it due: each is reviewed when its coder marks it ready. */
|
|
840
|
+
export function prReviewDue(board, id, { head }) {
|
|
841
|
+
if (prReviewedHead(board, id, head)) return false;
|
|
842
|
+
// a PR review's or the final review's verdict on this head stands in its way; a plan review's never does (requestKindSql)
|
|
843
|
+
return !judgedHead(board, id, head) || answeredChanges(board, id);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Whether an arc whose every PR is settled (merged at the head its PR review passed: `pr_reviewed`) is due its final
|
|
847
|
+
* review, of its Acceptance against them: its last review event is a PR review's request (that review is over, and the
|
|
848
|
+
* arc is back with its implementer), a voided verdict, or changes requested whose tasks are all closed. After a pass,
|
|
849
|
+
* or a final review the manager returned, nothing is new until another PR review is requested. */
|
|
850
|
+
export function settledReviewDue(board, id) {
|
|
851
|
+
const last = board.db.prepare("SELECT id, kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided', 'review_returned') ORDER BY id DESC LIMIT 1").get(id);
|
|
852
|
+
if (!last || last.kind === 'review_voided') return true;
|
|
853
|
+
if (last.kind === 'changes_requested') return answeredChanges(board, id);
|
|
854
|
+
return last.kind === 'review_requested' && Boolean(board.db.prepare("SELECT 1 FROM task_events WHERE task_id = ? AND kind = 'pr_review_requested' AND id > ? LIMIT 1").get(id, last.id));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Whether the review in progress on the arc is a PR review: its `pr_review_requested` is newer than its last
|
|
858
|
+
* `review_requested` (the PR review's request records both, in that order; the final review's records only the latter). */
|
|
859
|
+
export function prReviewInProgress(board, id) {
|
|
860
|
+
const at = (kind) => board.db.prepare('SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = ?').get(id, kind)?.id ?? 0;
|
|
861
|
+
return at('pr_review_requested') > at('review_requested');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** A PR review's request: a session's request-review naming its PR (engine.requestReview's `review`), as the
|
|
865
|
+
* workflow's PR review clause selects it. The board's own final review request never carries one. */
|
|
866
|
+
const asksPrReview = (payload) => payload?.review === 'pr';
|
|
867
|
+
|
|
820
868
|
/** Whether the arc's last review event is changes requested and every task that verdict owns is closed now. A verdict
|
|
821
869
|
* owns the tasks filed or reopened between the request it answered and itself (`request-changes --finding` files each
|
|
822
870
|
* finding as a task just before the verdict; a reviewer may reopen one instead). A verdict that owns no task is never
|
|
@@ -832,6 +880,18 @@ export function answeredChanges(board, id) {
|
|
|
832
880
|
return owned.length > 0 && owned.every((t) => ['done', 'cancelled', 'archived'].includes(t.status));
|
|
833
881
|
}
|
|
834
882
|
|
|
883
|
+
/** The manager's return of the arc's review (`review_returned`, no verdict) while it is the arc's latest review event, with
|
|
884
|
+
* the PR heads its review request named (`heads`, as candidatePullRequests answers them); else null. */
|
|
885
|
+
export function returnedReview(board, id) {
|
|
886
|
+
const db = board.db;
|
|
887
|
+
const last = db.prepare("SELECT id, kind, payload FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided', 'review_returned') ORDER BY id DESC LIMIT 1").get(id);
|
|
888
|
+
if (last?.kind !== 'review_returned') return null;
|
|
889
|
+
const asked = JSON.parse(last.payload ?? '{}')?.review_request;
|
|
890
|
+
const request = asked ? db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND id = ? AND kind = 'review_requested'").get(id, asked)
|
|
891
|
+
: db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' AND id < ? ORDER BY id DESC LIMIT 1").get(id, last.id);
|
|
892
|
+
return { id: last.id, heads: candidatePullRequests(request ? JSON.parse(request.payload)?.ci : null) };
|
|
893
|
+
}
|
|
894
|
+
|
|
835
895
|
/** A commit's date and source tree, read once: both are immutable. Only a successful read is cached; an unread commit
|
|
836
896
|
* is unknown, never evidence of old work or of a changed tree. */
|
|
837
897
|
async function commitRead({ url, head }) {
|
|
@@ -846,40 +906,71 @@ const commitTime = async (p) => (await commitRead(p)).at;
|
|
|
846
906
|
/** Whether the PR's head carries the same source tree as `commit` of the same PR. */
|
|
847
907
|
const sameTree = async (p, commit) => (await commitRead(p)).tree === (await commitRead({ url: p.url, head: commit })).tree;
|
|
848
908
|
|
|
849
|
-
/** A
|
|
850
|
-
|
|
909
|
+
/** A PR read for a committed review or acceptance: a merged one already held is its answer (its head, base and state
|
|
910
|
+
* never change again), any other is read fresh. */
|
|
911
|
+
const readSource = async (url) => {
|
|
912
|
+
const held = await heldRead(pullRequestKey(url));
|
|
913
|
+
return held?.state === 'MERGED' ? held : readPullRequest(url);
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
/** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. The final
|
|
917
|
+
* review's request names the whole current set, which leaves out each PR merged at the head its PR review passed
|
|
918
|
+
* (openReviewPullRequests: `pr_reviewed`). A verdict or acceptance of a request also stands beside a PR the request
|
|
919
|
+
* did not name when that PR is merged at a head an earlier verdict of the arc judged (`judged`): it was reviewed at the
|
|
920
|
+
* head it merged at, and no review is due for it (reviewDue). A PR review (`single`) names one ready PR of the set, and
|
|
921
|
+
* its request and verdict stand beside every other PR of the arc, open or merged. */
|
|
922
|
+
async function requireCurrentReviewSet(board, id, urls, repositories = [], { judged = false, single = false } = {}) {
|
|
851
923
|
const current = await openReviewPullRequests(board, id, { fresh: true,
|
|
852
924
|
repositories: [...repositories, ...urls.map(repositoryOf)] });
|
|
853
|
-
|
|
925
|
+
if (single) return current?.review_repositories ?? repositories;
|
|
926
|
+
const unnamed = (current?.pr ?? []).map((url, i) => ({ url, head: current.pr_head[i] })).filter(p => !urls.includes(p.url));
|
|
927
|
+
const settled = judged ? await readTogether(unnamed, async p => judgedHead(board, id, p.head) !== null
|
|
928
|
+
&& (await readSource(p.url)).state === 'MERGED') : unnamed.map(() => false);
|
|
929
|
+
const omitted = unnamed.filter((_, i) => !settled[i]).map(p => p.url);
|
|
854
930
|
if (omitted.length) throw new Error(`The current review source set also includes ${omitted.join(', ')}; refresh the complete candidate before requesting review, recording its verdict or accepting it`);
|
|
855
931
|
return current?.review_repositories ?? repositories;
|
|
856
932
|
}
|
|
857
933
|
|
|
858
|
-
/** The verdict already recorded on `head` of the card's PR: a changes-requested
|
|
859
|
-
* a review request naming that head, before the next request. A head is reviewed once. */
|
|
860
|
-
export function judgedHead(board, id, head) {
|
|
934
|
+
/** The verdict already recorded on `head` of the card's PR: a changes-requested, a pass or a PR review's pass
|
|
935
|
+
* (`pr_review_pass`) that answered a review request naming that head, before the next request. A head is reviewed once. */
|
|
936
|
+
export function judgedHead(board, id, head, of = ['pr', 'final']) {
|
|
937
|
+
return verdictOn(board, id, head, ['changes_requested', 'review_pass', 'pr_review_pass'], of);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** The PR review's pass recorded on `head` (`pr_review_pass`), or null. A PR merged at such a head is settled: the
|
|
941
|
+
* arc's final review neither names nor owes it (openReviewPullRequests). */
|
|
942
|
+
export function prReviewedHead(board, id, head) {
|
|
943
|
+
return verdictOn(board, id, head, ['pr_review_pass'], ['pr']);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** A pass recorded on `head` by its PR review or by the arc's final review, or null: a PR merged at such a head is
|
|
947
|
+
* settled (openReviewPullRequests). */
|
|
948
|
+
export function passedHead(board, id, head) {
|
|
949
|
+
return verdictOn(board, id, head, ['pr_review_pass', 'review_pass'], ['pr', 'final']);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** A review request's kind, as SQL over its `review_requested` row `r`: a PR review's and a plan review's request each
|
|
953
|
+
* record their own marker right after it (pr_review_requested, plan_review_requested), before the next request; the
|
|
954
|
+
* board's final review records none. Each kind keeps its own verdicts: a plan review, which grades no code head, never
|
|
955
|
+
* judges a head for a PR review or the final review. */
|
|
956
|
+
const requestKindSql = (r) => `CASE
|
|
957
|
+
WHEN EXISTS (SELECT 1 FROM task_events k WHERE k.task_id = ${r}.task_id AND k.kind = 'plan_review_requested' AND k.id > ${r}.id
|
|
958
|
+
AND k.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)) THEN 'plan'
|
|
959
|
+
WHEN EXISTS (SELECT 1 FROM task_events k WHERE k.task_id = ${r}.task_id AND k.kind = 'pr_review_requested' AND k.id > ${r}.id
|
|
960
|
+
AND k.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)) THEN 'pr'
|
|
961
|
+
ELSE 'final' END`;
|
|
962
|
+
|
|
963
|
+
function verdictOn(board, id, head, kinds, of) {
|
|
861
964
|
if (!head) return null;
|
|
862
965
|
// A request's JSON head list is checked once before visiting its verdicts. Without CROSS JOIN SQLite starts
|
|
863
966
|
// from every verdict and reparses the same older requests for each of them (including heads never requested).
|
|
864
967
|
return board.db.prepare(`SELECT v.id, v.kind FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
|
|
865
|
-
AND v.kind IN ('
|
|
866
|
-
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)
|
|
867
|
-
AND NOT EXISTS (SELECT 1 FROM task_events x WHERE x.task_id=r.task_id AND x.kind='review_voided' AND x.id>v.id AND json_extract(x.payload,'$.review_request')=r.id)
|
|
868
|
-
WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
|
|
869
|
-
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;
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/** Whether a passed review judged `head` of the card's PR: a pass that answered a review request naming that head,
|
|
873
|
-
* before the next request, and no void of that request since. Unlike judgedHead (the first verdict on a head), a head
|
|
874
|
-
* first sent back and passed in a later round counts. */
|
|
875
|
-
export function passedHead(board, id, head) {
|
|
876
|
-
if (!head) return false;
|
|
877
|
-
return Boolean(board.db.prepare(`SELECT 1 FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
|
|
878
|
-
AND v.kind = 'review_pass' AND v.id > r.id
|
|
968
|
+
AND v.kind IN (${kinds.map((kind) => `'${kind}'`).join(', ')}) AND v.id > r.id
|
|
879
969
|
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)
|
|
880
970
|
AND NOT EXISTS (SELECT 1 FROM task_events x WHERE x.task_id=r.task_id AND x.kind='review_voided' AND x.id>v.id AND json_extract(x.payload,'$.review_request')=r.id)
|
|
881
971
|
WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
|
|
882
|
-
OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?))
|
|
972
|
+
OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?))
|
|
973
|
+
AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')}) ORDER BY v.id LIMIT 1`).get(id, head, head) ?? null;
|
|
883
974
|
}
|
|
884
975
|
|
|
885
976
|
export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
|
|
@@ -889,6 +980,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
889
980
|
const state = { required: false, ready: true, reason: null, phase: 'source' };
|
|
890
981
|
try {
|
|
891
982
|
assertSourceReceipt(payload);
|
|
983
|
+
if (stage === 'person') return { ...state, phase: 'person' };
|
|
892
984
|
if (stage === 'completion' || stage === 'acceptance') {
|
|
893
985
|
const supplied = payload.commit ?? payload.metadata?.commit;
|
|
894
986
|
if (supplied) {
|
|
@@ -896,7 +988,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
896
988
|
}
|
|
897
989
|
const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
|
|
898
990
|
if (candidate) {
|
|
899
|
-
await requireCurrentReviewSet(board, id, candidatePullRequests(candidate).map(p => p.pr.url));
|
|
991
|
+
await requireCurrentReviewSet(board, id, candidatePullRequests(candidate).map(p => p.pr.url), [], { judged: true });
|
|
900
992
|
const row = board.db.prepare("SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
|
|
901
993
|
if (!row) throw new Error('The current candidate needs its independent verdict before acceptance.');
|
|
902
994
|
const voided = board.db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='review_voided' ORDER BY id DESC LIMIT 1").get(id);
|
|
@@ -905,7 +997,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
905
997
|
const wanted = candidatePullRequests(candidate);
|
|
906
998
|
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.');
|
|
907
999
|
await readTogether(wanted, async w => {
|
|
908
|
-
const pr = await
|
|
1000
|
+
const pr = await readSource(w.pr.url);
|
|
909
1001
|
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.`);
|
|
910
1002
|
});
|
|
911
1003
|
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
@@ -918,26 +1010,38 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
918
1010
|
// Advisory heads can refuse a redundant request, never authorize one. Do
|
|
919
1011
|
// this before a fresh source scan, including for callers outside dispatch.
|
|
920
1012
|
const proposed = [].concat(payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
|
|
1013
|
+
// A PR review (its request names its PR; its verdict answers the PR review in progress) names one
|
|
1014
|
+
// ready PR, whatever other PRs the arc has, open or merged.
|
|
1015
|
+
const single = stage === 'request_review' ? asksPrReview(payload) : stage === 'review_verdict' && prReviewInProgress(board, id);
|
|
1016
|
+
if (single && stage === 'request_review' && urls.length !== 1) throw new Error('A PR review names one PR: --pr URL --pr-head SHA.');
|
|
1017
|
+
const due = (prs) => single ? prs.every((p) => prReviewDue(board, id, p)) : reviewDue(board, id, prs);
|
|
921
1018
|
if (stage === 'request_review' && urls.length && proposed.length === urls.length && proposed.every(head => typeof head === 'string' && /^[a-f0-9]{40}$/.test(head))
|
|
922
|
-
&& !await
|
|
923
|
-
throw new Error('Every proposed PR head already has a verdict or carries no new source; no review is due.');
|
|
1019
|
+
&& !await due(urls.map((url, i) => ({ url, head: proposed[i] })))) {
|
|
1020
|
+
throw new Error(single ? 'The proposed PR head already has a verdict (a pass, or findings not yet answered); no PR review is due.' : 'Every proposed PR head already has a verdict or carries no new source; no review is due.');
|
|
924
1021
|
}
|
|
925
|
-
if (stage === 'request_review' || stage === 'review_verdict') state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? []);
|
|
1022
|
+
if (stage === 'request_review' || stage === 'review_verdict') state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? [], { judged: stage === 'review_verdict', single });
|
|
926
1023
|
if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
|
|
1024
|
+
// The final review of an arc whose every PR is settled by its PR review names no PR: it reviews the arc's Acceptance
|
|
1025
|
+
// against them (settledReviewDue).
|
|
1026
|
+
const settled = stage === 'request_review' ? payload.pr_reviewed : asked?.pr_reviewed;
|
|
1027
|
+
if (!urls.length && settled?.length && !single) return { ...state, pr_reviewed: settled };
|
|
927
1028
|
// Merged source is immutable and needs no surviving workspace. Resolve an
|
|
928
1029
|
// unmerged candidate's checkout as before; a broken legacy path never
|
|
929
1030
|
// prevents a verdict on already merged commits.
|
|
930
|
-
const read = await mapGithubSources(urls,
|
|
1031
|
+
const read = await mapGithubSources(urls, readSource);
|
|
931
1032
|
const mergedOnly = urls.length && read.every(pr => pr.state === 'MERGED');
|
|
932
1033
|
const own = mergedOnly ? null : await repositoryWorkspace(board, id);
|
|
933
1034
|
if (!own && !urls.length) {
|
|
934
1035
|
return state;
|
|
935
1036
|
}
|
|
936
|
-
// A card with no repository of its own (scratch
|
|
937
|
-
//
|
|
1037
|
+
// A card with no repository of its own (scratch, or a plain folder holding the arc's repositories: workspace.mjs plainFolder)
|
|
1038
|
+
// is reviewed through the branches it registered and the PRs it declared (declaredPullRequests, each already bounded
|
|
1039
|
+
// to the arc's repositories): each unmerged PR on one of them (or on its wt/<card> branch) is read at its pushed
|
|
1040
|
+
// head, and needs no checkout to exist here.
|
|
938
1041
|
if (!own && !mergedOnly) {
|
|
939
|
-
const registered = registeredBranches(board, id)
|
|
940
|
-
const
|
|
1042
|
+
const registered = registeredBranches(board, id), key = (url) => `${repositoryOf(url).toLowerCase()}#${numberOf(url)}`;
|
|
1043
|
+
const recorded = new Set(declaredPullRequests(board.db, id).map((source) => key(source.url)));
|
|
1044
|
+
const declared = (pr) => recorded.has(key(pr.url)) || namedWorkBranch(id, pr.headRefName) || registered.some((r) => r.branch === pr.headRefName && r.repository.toLowerCase() === repositoryOf(pr.url).toLowerCase());
|
|
941
1045
|
const strays = read.filter((pr) => pr.state !== 'MERGED' && !declared(pr));
|
|
942
1046
|
if (strays.length) throw refusalOf(strays.map((stray) => refused('undeclared', stray.url, `An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; declare it on the arc: comment ${id} "<why>" --source ${repositoryOf(stray.url)}:${stray.headRefName}`)));
|
|
943
1047
|
}
|
|
@@ -956,8 +1060,8 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
956
1060
|
const prs = await refuseTogether(urls, (url, index) => requireReviewPullRequestProperties(url, stage === 'request_review' ? named[index] : null, read[index]));
|
|
957
1061
|
if (stage === 'request_review') await refuseTogether(prs, (source, index) => requireReviewWorkspaceHead(board, id, own, read[index], source.commit, { pushed: true }));
|
|
958
1062
|
if (stage === 'request_review') {
|
|
959
|
-
// A review is asked for only while some PR has a head no verdict has judged.
|
|
960
|
-
if (!await
|
|
1063
|
+
// A review is asked for only while some PR has a head no verdict has judged; a PR review, while its one PR's is.
|
|
1064
|
+
if (!await due(prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error(single ? 'That PR head already has a verdict (a pass, or findings not yet answered): push a commit that changes the source, or close the tasks that own the last verdict\'s findings, before requesting its PR review.' : 'Every PR head has a verdict, carries the source tree its verdict judged, or predates it: push a commit that changes the source, or close the tasks that own the last verdict\'s findings, before requesting review.');
|
|
961
1065
|
} else if (asked) {
|
|
962
1066
|
const requested = candidatePullRequests(asked);
|
|
963
1067
|
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.');
|
|
@@ -966,7 +1070,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
966
1070
|
const bound = candidatePullRequests(latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate);
|
|
967
1071
|
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.');
|
|
968
1072
|
}
|
|
969
|
-
Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}) });
|
|
1073
|
+
Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}), ...(settled?.length && !single ? { pr_reviewed: settled } : {}) });
|
|
970
1074
|
} catch (error) {
|
|
971
1075
|
Object.assign(state, { required: true, ready: false, reason: String(error?.message ?? error) });
|
|
972
1076
|
if (error?.code) Object.assign(state, { code: error.code, retryable: error.code === 'GITHUB_BUDGET' });
|