@uchibeke/gallivant 0.2.1 → 0.3.0
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 +38 -16
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +33 -10
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +2 -0
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +65 -5
- package/docs/config.md +35 -10
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +18 -8
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +2 -1
- package/docs/prd.md +23 -2
- package/docs/preparation.md +1 -1
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +17 -2
- package/docs/task-delivery.md +4 -4
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -462
- package/lib/commands/init.mjs +72 -30
- package/lib/commands/register.mjs +25 -11
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +23 -18
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +48 -3
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +353 -97
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
package/lib/events.mjs
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
// The rules for what counts as an event, written as pure functions over the data `gh` returns, so every rule in
|
|
2
2
|
// PRD 6.8 has a unit test and the tick only orchestrates.
|
|
3
3
|
//
|
|
4
|
-
// Inputs are
|
|
5
|
-
import { actor, normalizeLogin, ROUND_MARKER, NUDGE_MARKER, RESET_MARKER } from "./
|
|
4
|
+
// Inputs are normalized host records; legacy event aliases remain readable for saved integrations.
|
|
5
|
+
import { actor, normalizeLogin, ROUND_MARKER, NUDGE_MARKER, RESET_MARKER } from "./host_contract.mjs";
|
|
6
6
|
import { hasMarker } from "./public-format.mjs";
|
|
7
7
|
import { isAfter, isAtOrAfter, later, floorSecond } from "./time.mjs";
|
|
8
8
|
|
|
9
|
-
const FAILING = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
|
|
10
|
-
const GREEN = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
11
9
|
const COMMAND = /^\s*(?:\/fix\b|@(?:work|gallivant)\s+fix\b)/i;
|
|
12
10
|
export const isFixCommand = body => COMMAND.test(String(body ?? ""));
|
|
13
11
|
|
|
14
12
|
export function makeTrust(config, ownLogin) {
|
|
15
13
|
if (!ownLogin) throw new Error("trust rules need the daemon's own login");
|
|
16
14
|
const trusted = new Set(config.trusted_authors.map(normalizeLogin));
|
|
17
|
-
const reviewer = config.reviewer ? normalizeLogin(config.reviewer.login) : null;
|
|
15
|
+
const reviewer = config.reviewer ? normalizeLogin(config.reviewer.actor_id ?? config.reviewer.login) : null;
|
|
18
16
|
const own = normalizeLogin(ownLogin);
|
|
19
17
|
return {
|
|
20
18
|
isTrusted: a => trusted.has(a.login) && !a.bot,
|
|
21
|
-
isReviewer: a => reviewer !== null && a.bot && a.login === reviewer,
|
|
19
|
+
isReviewer: a => reviewer !== null && (config.reviewer.actor_id || a.bot) && a.login === reviewer && a.login !== own,
|
|
22
20
|
isOwn: a => a.login === own,
|
|
23
21
|
};
|
|
24
22
|
}
|
|
@@ -42,6 +40,27 @@ export const eventKey = e => `${e.kind}:${e.id}`;
|
|
|
42
40
|
*
|
|
43
41
|
* Editing an item later does not make it new again: the comparison is on `created_at`, never on `updated_at`.
|
|
44
42
|
*/
|
|
43
|
+
const recordId = record => record.id ?? record.eventId;
|
|
44
|
+
const currentEvent = (record, head) => !record.deleted && !record.resolved && (record.headBinding !== "exact" || record.headSha === head);
|
|
45
|
+
|
|
46
|
+
// PR-wide unresolved tasks/change requests stay blocking even after their one
|
|
47
|
+
// fix round. An unbound finding must not be silently mistaken for a clean head.
|
|
48
|
+
export function requiredFeedback({ reviews = [], inline = [], trust, headSha }) {
|
|
49
|
+
return [...reviews, ...inline].filter(r => !r.deleted && !r.resolved && !(r.replyTo && trust.isOwn(actor(r.actor ?? r.user))) &&
|
|
50
|
+
(r.required || trust.isTrusted(actor(r.actor ?? r.user)) || trust.isReviewer(actor(r.actor ?? r.user))) &&
|
|
51
|
+
(r.required || r.headBinding === "exact" && !r.headSha) &&
|
|
52
|
+
(r.headBinding !== "exact" || !r.headSha || r.headSha === headSha));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readFeedback(host, repo, pr, inlineSince, commentSince) {
|
|
56
|
+
if (pr.feedback !== undefined) {
|
|
57
|
+
const f = pr.feedback;
|
|
58
|
+
if (!f || f.complete !== true || f.headSha !== pr.headRefOid || ![f.reviews, f.inline, f.comments].every(Array.isArray)) throw new Error("Incomplete or mismatched feedback snapshot; preserve cursor and retry");
|
|
59
|
+
return [f.reviews, f.inline, f.comments];
|
|
60
|
+
}
|
|
61
|
+
return Promise.all([host.reviews(repo, pr.number), host.inlineComments(repo, pr.number, inlineSince), host.issueComments(repo, pr.number, commentSince)]);
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust }) {
|
|
46
65
|
const since = entry.cursor.since;
|
|
47
66
|
const dispatched = new Set(entry.cursor.dispatched || []);
|
|
@@ -50,33 +69,33 @@ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust
|
|
|
50
69
|
|
|
51
70
|
// Reviews our own login submitted, so an inline note hanging off one is recognised as the harness's own comment on
|
|
52
71
|
// its own work rather than as feedback on it.
|
|
53
|
-
const ownReviews = new Set(reviews.filter(r => trust.isOwn(actor(r.user))).map(r => r
|
|
72
|
+
const ownReviews = new Set(reviews.filter(r => trust.isOwn(actor(r.actor ?? r.user))).map(r => recordId(r)));
|
|
54
73
|
|
|
55
74
|
const inlineByReview = new Map();
|
|
56
75
|
for (const c of inline) {
|
|
57
|
-
const rid = c.pull_request_review_id;
|
|
76
|
+
const rid = (c.reviewId ?? c.pull_request_review_id);
|
|
58
77
|
if (!inlineByReview.has(rid)) inlineByReview.set(rid, []);
|
|
59
78
|
inlineByReview.get(rid).push(c);
|
|
60
79
|
}
|
|
61
80
|
|
|
62
81
|
for (const r of reviews) {
|
|
63
|
-
if (!isNew(r.submitted_at, "review", r
|
|
64
|
-
const a = actor(r.user);
|
|
65
|
-
const attached = (inlineByReview.get(r
|
|
82
|
+
if (!currentEvent(r, pr.headRefOid) || r.headBinding === "unbound" || !isNew((r.at ?? r.submitted_at), "review", recordId(r))) continue;
|
|
83
|
+
const a = actor(r.actor ?? r.user);
|
|
84
|
+
const attached = (inlineByReview.get(recordId(r)) || []).filter(c => currentEvent(c, pr.headRefOid) && (c.headBinding !== "exact" || isNew(c.at ?? c.created_at, "inline", recordId(c))) && !(c.replyTo ?? c.in_reply_to_id));
|
|
66
85
|
const qualifies = (trust.isReviewer(a) && (r.state === "CHANGES_REQUESTED" || (r.state === "COMMENTED" && attached.length > 0)))
|
|
67
86
|
|| (trust.isTrusted(a) && r.state === "CHANGES_REQUESTED");
|
|
68
87
|
if (!qualifies) continue;
|
|
69
|
-
out.push({ kind: "review", id: r
|
|
88
|
+
out.push({ kind: "review", id: recordId(r), reviewId: recordId(r), author: a.login, state: r.state, body: r.body || "", at: (r.at ?? r.submitted_at) });
|
|
70
89
|
for (const c of attached) out.push(inlineEvent(c, a.login));
|
|
71
|
-
inlineByReview.delete(r
|
|
90
|
+
inlineByReview.delete(recordId(r));
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
// Inline comments not attached to a dispatched review: standalone trusted comments, or later comments on a review
|
|
75
94
|
// that was already handled. Replies by our own login are the harness's thread replies.
|
|
76
95
|
for (const list of inlineByReview.values()) {
|
|
77
96
|
for (const c of list) {
|
|
78
|
-
if (!isNew(c.created_at, "inline", c
|
|
79
|
-
const a = actor(c.user);
|
|
97
|
+
if (!currentEvent(c, pr.headRefOid) || !isNew((c.at ?? c.created_at), "inline", recordId(c))) continue;
|
|
98
|
+
const a = actor(c.actor ?? c.user);
|
|
80
99
|
// Our own login writes in two capacities and they have to be told apart, because on a one-operator install the
|
|
81
100
|
// operator's login IS the daemon's: config.md says `trusted_authors` is where your own login belongs. The
|
|
82
101
|
// operator leaving a standalone inline comment is the main feedback channel and must dispatch. The harness
|
|
@@ -84,17 +103,17 @@ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust
|
|
|
84
103
|
// carrying `in_reply_to_id`, and as inline notes hanging off a review of its own, which is what
|
|
85
104
|
// `gh pr review --comment` produces. Both are the daemon's own work explaining itself, and dispatching a round
|
|
86
105
|
// to answer it is the loop feeding itself.
|
|
87
|
-
if (trust.isOwn(a) && (c.in_reply_to_id || ownReviews.has(c.pull_request_review_id))) continue;
|
|
106
|
+
if (trust.isOwn(a) && ((c.replyTo ?? c.in_reply_to_id) || ownReviews.has((c.reviewId ?? c.pull_request_review_id)))) continue;
|
|
88
107
|
if (!(trust.isTrusted(a) || trust.isReviewer(a))) continue;
|
|
89
108
|
out.push(inlineEvent(c, a.login));
|
|
90
109
|
}
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
for (const c of comments) {
|
|
94
|
-
if (!isNew(c.created_at, "command", c
|
|
95
|
-
const a = actor(c.user);
|
|
113
|
+
if (!currentEvent(c, pr.headRefOid) || c.replyTo && trust.isOwn(actor(c.actor ?? c.user)) || !isNew((c.at ?? c.created_at), "command", recordId(c)) || isOwnComment(c.body)) continue;
|
|
114
|
+
const a = actor(c.actor ?? c.user);
|
|
96
115
|
if (!trust.isTrusted(a) || !isFixCommand(c.body)) continue;
|
|
97
|
-
out.push({ kind: "command", id: c
|
|
116
|
+
out.push({ kind: "command", id: recordId(c), author: a.login, body: c.body.replace(COMMAND, "").trim(), at: (c.at ?? c.created_at) });
|
|
98
117
|
}
|
|
99
118
|
|
|
100
119
|
if (pr.headRefOid && pr.headRefOid !== entry.cursor.checks_sha) {
|
|
@@ -106,36 +125,30 @@ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust
|
|
|
106
125
|
}
|
|
107
126
|
}
|
|
108
127
|
|
|
109
|
-
out.
|
|
110
|
-
|
|
128
|
+
const unique = [...new Map(out.map(e => [e.kind === "check" ? `check:${e.name}` : eventKey(e), e])).values()];
|
|
129
|
+
unique.sort((x, y) => Date.parse(x.at) - Date.parse(y.at));
|
|
130
|
+
return { events: unique, newest: unique.length ? unique[unique.length - 1].at : null };
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
const inlineEvent = (c, login) => ({
|
|
114
|
-
kind: "inline", id: c
|
|
115
|
-
path: c.path, line: c.line ?? c.original_line ?? null, body: c.body || "", at: c.created_at,
|
|
134
|
+
kind: "inline", id: recordId(c), reviewId: (c.reviewId ?? c.pull_request_review_id) ?? null, author: login,
|
|
135
|
+
path: c.path, line: c.line ?? c.original_line ?? null, body: c.body || "", at: (c.at ?? c.created_at),
|
|
116
136
|
});
|
|
117
137
|
|
|
118
138
|
/** CheckRun and StatusContext entries that failed, with the Actions run id when the URL carries one. */
|
|
119
139
|
export function failingChecks(rollup = []) {
|
|
120
|
-
|
|
121
|
-
for (const c of rollup || []) {
|
|
122
|
-
if (c.__typename === "StatusContext") {
|
|
123
|
-
if (["FAILURE", "ERROR"].includes(c.state)) out.push({ name: c.context, url: c.targetUrl, runId: runIdFrom(c.targetUrl), at: c.createdAt });
|
|
124
|
-
} else if (c.status === "COMPLETED" && FAILING.has(c.conclusion)) {
|
|
125
|
-
out.push({ name: c.workflowName ? `${c.workflowName} / ${c.name}` : c.name, url: c.detailsUrl, runId: runIdFrom(c.detailsUrl), at: c.completedAt });
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return out;
|
|
140
|
+
return (rollup || []).filter(c => c.outcome === "failed").map(c => ({ name: c.name, url: c.url, runId: c.runId, at: c.updatedAt }));
|
|
129
141
|
}
|
|
130
142
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
export function isGreen(rollup = []) {
|
|
135
|
-
const items = rollup || [];
|
|
136
|
-
return items.every(c => c.__typename === "StatusContext" ? c.state === "SUCCESS" : c.status === "COMPLETED" && GREEN.has(c.conclusion));
|
|
143
|
+
/** Every complete check concluded green. Empty evidence is subject to the shared grace period. */
|
|
144
|
+
export function isGreen(rollup = [], required = []) {
|
|
145
|
+
return Array.isArray(rollup) && required.every(name => rollup.some(c => c.name === name)) && rollup.every(c => c.outcome === "passed");
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
/** Newest verdict first, with stable exact-head sequence numbers breaking timestamp ties. */
|
|
149
|
+
export const compareReviewRecency = (a, b) => Date.parse(b.at ?? b.submitted_at) - Date.parse(a.at ?? a.submitted_at) ||
|
|
150
|
+
(a.headBinding === "exact" && b.headBinding === "exact" && Number.isSafeInteger(a.sequence) && Number.isSafeInteger(b.sequence) ? b.sequence - a.sequence : 0);
|
|
151
|
+
|
|
139
152
|
/**
|
|
140
153
|
* The reviewer's verdict since the last request: "none" when no reviewer is configured, "waiting" when it has not
|
|
141
154
|
* reviewed since `sinceIso`, "blocking" when the newest review requests changes or has a blocking inline comment,
|
|
@@ -148,10 +161,10 @@ export function isGreen(rollup = []) {
|
|
|
148
161
|
* `dispatched` holds the event keys already handled at exactly the cursor's instant, so a review the daemon is
|
|
149
162
|
* already fixing cannot also read as the reviewer clearing this head.
|
|
150
163
|
*/
|
|
151
|
-
export function reviewerVerdict({ reviews, inline, comments = [], config, trust, sinceIso, headSha = null, dispatched = [] }) {
|
|
164
|
+
export function reviewerVerdict({ reviews, inline, comments = [], config, trust, sinceIso, headSha = null, dispatched = [], includeIdentity = false }) {
|
|
152
165
|
if (!config.reviewer) return { verdict: "none", note: "no reviewer configured" };
|
|
153
166
|
const handled = new Set(dispatched);
|
|
154
|
-
const fromReviews = reviews.filter(r => trust.isReviewer(actor(r.user)) && isAtOrAfter(r.submitted_at, sinceIso) && !["PENDING", "DISMISSED"].includes(r.state) && !handled.has(eventKey({ kind: "review", id: r
|
|
167
|
+
const fromReviews = reviews.filter(r => trust.isReviewer(actor(r.actor ?? r.user)) && isAtOrAfter((r.at ?? r.submitted_at), sinceIso) && !["PENDING", "DISMISSED"].includes(r.state) && !handled.has(eventKey({ kind: "review", id: recordId(r) })));
|
|
155
168
|
|
|
156
169
|
// A verdict the reviewer posted as a plain comment rather than as a review, normalised into the shape above so the
|
|
157
170
|
// head guard and the blocking rules below judge it identically. Everything after this line is unchanged.
|
|
@@ -167,28 +180,58 @@ export function reviewerVerdict({ reviews, inline, comments = [], config, trust,
|
|
|
167
180
|
// a commit in its body, which `reviewedCommit` already parses and which the existing guard then checks against the
|
|
168
181
|
// head. Clean is never INFERRED from the absence of findings, because the reviewer says other things in comments and
|
|
169
182
|
// a chatty bot must not be able to clear a pull request by mentioning it. No commit named, no verdict.
|
|
170
|
-
const fromComments = (comments || [])
|
|
171
|
-
|
|
172
|
-
|
|
183
|
+
const fromComments = (comments || []).flatMap(c => {
|
|
184
|
+
if (c.deleted || c.headBinding === "exact" || !trust.isReviewer(actor(c.actor ?? c.user))) return [];
|
|
185
|
+
// The GitHub adapter recognizes authenticated, exact-head structured code
|
|
186
|
+
// review summaries. Required CI and independent review policies remain
|
|
187
|
+
// separate gates; an optional security scan is not a code-review verdict.
|
|
188
|
+
const summary = c.source?.host === "github.com" ? c.reviewSummary : null;
|
|
189
|
+
if (Object.hasOwn(c, "reviewSummary") && !summary) return [];
|
|
190
|
+
const at = summary?.at ?? c.at ?? c.created_at;
|
|
191
|
+
if (!isAtOrAfter(at, sinceIso) || !summary && !reviewedCommit(c.body, config.reviewer.reviewed_commit_pattern)) return [];
|
|
192
|
+
return [{ id: recordId(c), body: c.body, actor: c.actor ?? actor(c.user), submitted_at: at,
|
|
193
|
+
state: "COMMENTED", commit_id: null, fromComment: true, ...summary, fromStructuredSummary: Boolean(summary) }];
|
|
194
|
+
});
|
|
173
195
|
|
|
174
|
-
const mine = [...fromReviews, ...fromComments].sort(
|
|
196
|
+
const mine = [...fromReviews, ...fromComments].sort(compareReviewRecency);
|
|
175
197
|
if (!mine.length) return { verdict: "waiting", note: "waiting on reviewer" };
|
|
176
198
|
const newest = mine[0];
|
|
177
199
|
// Which commit was reviewed, from the reviewer's own body first and its `commit_id` second. A review that names no
|
|
178
200
|
// commit at all is no answer about this head: `commit_id` is null once the reviewed commit is gone from the
|
|
179
201
|
// repository, which is what a force-push plus garbage collection leaves, and a falsy value used to skip the guard
|
|
180
202
|
// entirely and clear whatever the head happened to be. So an unknown reviewed commit is `waiting`, not `clear`.
|
|
181
|
-
const reviewed = reviewedCommit(newest.body, config.reviewer.reviewed_commit_pattern) || newest.commit_id || null;
|
|
203
|
+
const reviewed = newest.headBinding === "exact" ? newest.headSha : reviewedCommit(newest.body, config.reviewer.reviewed_commit_pattern) || newest.headSha || newest.commit_id || null;
|
|
182
204
|
if (headSha && !reviewed) return { verdict: "waiting", note: "reviewer named no commit, so it cannot clear this head" };
|
|
183
|
-
if (headSha && !headSha.startsWith(reviewed) && !reviewed.startsWith(headSha)) return { verdict: "waiting", note: `reviewer looked at ${reviewed.slice(0, 7)}, head is ${headSha.slice(0, 7)}` };
|
|
205
|
+
if (headSha && (newest.headBinding === "exact" ? headSha !== reviewed : !headSha.startsWith(reviewed) && !reviewed.startsWith(headSha))) return { verdict: "waiting", note: `reviewer looked at ${reviewed.slice(0, 7)}, head is ${headSha.slice(0, 7)}` };
|
|
206
|
+
if (newest.headBinding === "exact" && !["clear", "blocking"].includes(newest.verdict)) return { verdict: "waiting", note: "waiting for an explicit current-head verdict" };
|
|
207
|
+
if (newest.verdict === "blocking") return { verdict: "blocking", note: "explicit blocking verdict" };
|
|
184
208
|
if (newest.state === "CHANGES_REQUESTED") return { verdict: "blocking", note: "changes requested" };
|
|
185
209
|
const re = new RegExp(config.reviewer.blocking_pattern);
|
|
186
|
-
|
|
210
|
+
// Authenticated structured summaries already carry the code-review verdict.
|
|
211
|
+
// Their body also contains optional security policy metadata (for example a
|
|
212
|
+
// P0 threshold), which is not a code finding. Plain reviews/receipts still
|
|
213
|
+
// use the configured severity pattern; required CI/security gates stay separate.
|
|
214
|
+
const structuredClear = newest.fromStructuredSummary === true && newest.headBinding === "exact" && newest.verdict === "clear";
|
|
215
|
+
if (!structuredClear && re.test(newest.body || "")) return { verdict: "blocking", note: "blocking finding in reviewer message" };
|
|
187
216
|
// Inline findings belong to a REVIEW. A comment-borne verdict has no review to own them, and `newest.id` is a
|
|
188
217
|
// comment id from a different id space, so matching on it could pair a verdict with another review's findings.
|
|
189
|
-
const hits = newest.fromComment ? [] : inline.filter(c => c.pull_request_review_id === newest
|
|
218
|
+
const hits = newest.fromComment ? [] : inline.filter(c => (c.reviewId ?? c.pull_request_review_id) === recordId(newest) && re.test(c.body || ""));
|
|
190
219
|
if (hits.length) return { verdict: "blocking", note: `${hits.length} blocking finding${hits.length > 1 ? "s" : ""}` };
|
|
191
|
-
|
|
220
|
+
const nativeHead = newest.headSha ?? newest.commit_id;
|
|
221
|
+
return { verdict: "clear", note: "no blocking findings", ...(includeIdentity ? {
|
|
222
|
+
reviewedHead: /^[a-f0-9]{40,64}$/.test(nativeHead || "") && nativeHead.startsWith(reviewed) ? nativeHead : reviewed,
|
|
223
|
+
} : {}) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** New policies resolve abbreviated bot identities through the host; a prefix
|
|
227
|
+
* collision or unavailable lookup cannot establish exact-head clearance. */
|
|
228
|
+
export async function exactReviewerVerdict(input, resolveHead) {
|
|
229
|
+
const result = reviewerVerdict({ ...input, includeIdentity: true });
|
|
230
|
+
if (result.verdict !== "clear" || result.reviewedHead === input.headSha) return result;
|
|
231
|
+
try {
|
|
232
|
+
if (typeof resolveHead === "function" && await resolveHead(result.reviewedHead) === input.headSha) return { ...result, reviewedHead: input.headSha };
|
|
233
|
+
} catch { /* preserve waiting: lookup failure is not clearance */ }
|
|
234
|
+
return { verdict: "waiting", note: "reviewer commit could not be resolved to the exact current head; restore host access or request a full commit identity" };
|
|
192
235
|
}
|
|
193
236
|
|
|
194
237
|
/**
|
|
@@ -236,8 +279,8 @@ export function reviewerClock({ config, pushedAt, previousAsk = null, floorAt })
|
|
|
236
279
|
}
|
|
237
280
|
|
|
238
281
|
const ownComments = (issueComments, ownLogin) => issueComments
|
|
239
|
-
.filter(c => actor(c.user ?? c.author).login === normalizeLogin(ownLogin))
|
|
240
|
-
.sort((a, b) => Date.parse(a.created_at ?? a.createdAt) - Date.parse(b.created_at ?? b.createdAt));
|
|
282
|
+
.filter(c => actor(c.actor ?? c.user ?? c.author).login === normalizeLogin(ownLogin))
|
|
283
|
+
.sort((a, b) => Date.parse(a.at ?? a.created_at ?? a.createdAt) - Date.parse(b.at ?? b.created_at ?? b.createdAt));
|
|
241
284
|
|
|
242
285
|
/**
|
|
243
286
|
* The iteration number: the daemon's own round comments since its last reset comment. Every round posts one
|
|
@@ -251,11 +294,11 @@ export function countRounds(issueComments, ownLogin) {
|
|
|
251
294
|
}
|
|
252
295
|
|
|
253
296
|
export const lastOwnCommentAt = (issueComments, ownLogin) => ownComments(issueComments, ownLogin)
|
|
254
|
-
.filter(c => isOwnComment(c.body)).map(c => c.created_at ?? c.createdAt).at(-1) ?? null;
|
|
297
|
+
.filter(c => isOwnComment(c.body)).map(c => (c.at ?? c.created_at) ?? c.createdAt).at(-1) ?? null;
|
|
255
298
|
|
|
256
299
|
/** Whether the daemon already posted a comment carrying `marker` after `sinceIso`. */
|
|
257
300
|
export const hasOwnComment = (issueComments, ownLogin, marker, sinceIso) => ownComments(issueComments, ownLogin)
|
|
258
|
-
.some(c => hasMarker(c.body, marker) && isAfter(c.created_at ?? c.createdAt, sinceIso));
|
|
301
|
+
.some(c => hasMarker(c.body, marker) && isAfter((c.at ?? c.created_at) ?? c.createdAt, sinceIso));
|
|
259
302
|
|
|
260
303
|
/** Only the lines a diff adds, so the completion gate never judges context or removed text. */
|
|
261
304
|
export const addedLines = diff => String(diff || "").split("\n").filter(l => l.startsWith("+") && !l.startsWith("+++")).map(l => l.slice(1)).join("\n");
|
package/lib/execution.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
4
4
|
import { randomUUID, createHash } from "node:crypto";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
|
|
6
7
|
import { acquireProcessLock } from "./daemon.mjs";
|
|
7
8
|
import { capabilitiesFor, adapterFor, preparationReceipt, isPreparationReceipt } from "./harness.mjs";
|
|
@@ -11,6 +12,7 @@ import { prepareLocalExecution } from "./execution_preparation.mjs";
|
|
|
11
12
|
import { EXECUTION_LIMITS, boundedCall, nextAttempt, executionError, assertExecutionCursor, ownsProviderSession, assertTerminalOutcome, redact, redactResultMetadata } from "./execution_io.mjs";
|
|
12
13
|
import { journalFor } from "./execution_journal.mjs";
|
|
13
14
|
import { requestRunCancellation } from "./run.mjs";
|
|
15
|
+
import { verifyReviewImageCopy } from "./review_image.mjs";
|
|
14
16
|
|
|
15
17
|
export const EXECUTION_VERSION = 1;
|
|
16
18
|
const exactIdentity = id => typeof id === "string" && !!id.trim();
|
|
@@ -29,6 +31,14 @@ const preparationFingerprint = input => {
|
|
|
29
31
|
const { receipt: _receipt, ...prepared } = input.prepared || {};
|
|
30
32
|
return digest({ environment: input.environment, ...(input.spec ? { spec: input.spec } : { prompt: input.prompt, prepared }) });
|
|
31
33
|
};
|
|
34
|
+
function verifyPreparedReviewImage(input, transport) {
|
|
35
|
+
const spec = input.spec, args = spec?.args, image = spec?.imageAttachment;
|
|
36
|
+
if (!Array.isArray(args)) return;
|
|
37
|
+
const flags = args.slice(0, -1).filter(arg => arg === "--image" || arg === "-i" || typeof arg === "string" && (arg.startsWith("--image=") || arg.startsWith("-i=")));
|
|
38
|
+
if (!image && flags.length === 0) return;
|
|
39
|
+
if (transport || input.kind !== "intake" || input.preparation?.boundary !== "codex-draft-sandbox-v1" || flags.length !== 1 || args.at(-4) !== "--image" || args.at(-3) !== image?.file || args.at(-2) !== "--" || !spec.options?.cwd) throw executionError("TASK_PREPARATION_BOUNDARY", "image attachment does not match the restricted prepared input");
|
|
40
|
+
verifyReviewImageCopy(spec.options.cwd, image.file, image.expected);
|
|
41
|
+
}
|
|
32
42
|
export const executionRunner = (ctx, id, provider = ctx.state.get(id)?.harness) => ctx.executionFor && provider ? ctx.executionFor(id, provider) : ctx.runnerFor(id);
|
|
33
43
|
|
|
34
44
|
const epochFile = (root, epoch) => join(root, epoch ? `execution-${epoch}.json` : "execution.json");
|
|
@@ -319,6 +329,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
319
329
|
if (prior.acceptance !== "accepted" || (transport && !ownedRemote(prior.job?.execution))) throw executionError("EXECUTION_UNKNOWN", `submission ${prior.runId} acceptance is unknown; reconcile its recorded idempotency key; no new dispatch`);
|
|
320
330
|
return { ...prior.job };
|
|
321
331
|
}
|
|
332
|
+
if (input.kind === "intake") verifyPreparedReviewImage(input, transport);
|
|
322
333
|
if (s.learningContinuity?.pending && (method !== "submit" || input.sessionId)) throw executionError("EXECUTION_CONTINUATION", "retired advice requires a new provider session, not continuation");
|
|
323
334
|
if (s.stopped) throw executionError("EXECUTION_STOPPED", `dispatch stopped: ${s.stopped}; late results remain quarantined`);
|
|
324
335
|
if (s.current && executionLive(s.current)) throw executionError("BUSY", "previous provider work is not settled");
|
|
@@ -356,6 +367,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
356
367
|
if (transport) project(s);
|
|
357
368
|
let accepted;
|
|
358
369
|
try {
|
|
370
|
+
if (!transport && input.kind === "intake") verifyPreparedReviewImage(input, transport);
|
|
359
371
|
accepted = transport ? await call(method, { ...input, taskId, provider, account, runId: id, idempotencyKey: intent.idempotencyKey })
|
|
360
372
|
: await runner.start(input.name, input.spec.cmd, input.spec.args, { ...input.spec.options, runId: id, privateOutput: { taskId, runId: id, provider } });
|
|
361
373
|
} catch (e) {
|
|
@@ -400,6 +412,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
400
412
|
if (!transport) runner.observe?.();
|
|
401
413
|
if (!s.current) return current();
|
|
402
414
|
if (!transport) {
|
|
415
|
+
const before = structuredClone(s);
|
|
403
416
|
let job = runner.current();
|
|
404
417
|
if (!job || job.id !== s.current.id) return current();
|
|
405
418
|
if (s.current.execution.acceptance === "unknown") {
|
|
@@ -418,11 +431,23 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
418
431
|
catch (error) { s.current.execution.recoveryAction = error.code || "local shutdown remains unverified"; }
|
|
419
432
|
if (!job || job.id !== s.current.id) { save(s); return current(); }
|
|
420
433
|
}
|
|
421
|
-
Object.assign(s.current.execution, { providerState: job.running ? s.stopped || job.killed ? "cancellation-requested" : "running" : s.current.execution.collected ? s.current.execution.providerState : "retrieving-results"
|
|
434
|
+
Object.assign(s.current.execution, { providerState: job.running ? s.stopped || job.killed ? "cancellation-requested" : "running" : s.current.execution.collected ? s.current.execution.providerState : "retrieving-results" });
|
|
422
435
|
if (s.stopped && !job.running) confirmLocalStop(s, job);
|
|
423
436
|
const { running: _running, ...record } = job;
|
|
424
437
|
s.current = { ...record, ...(s.current.execution.quarantined ? { exit: s.current.exit, endedAt: s.current.endedAt } : {}), execution: s.current.execution };
|
|
425
|
-
|
|
438
|
+
// Still observe liveness/output every tick. Persist real changes at
|
|
439
|
+
// once, but coalesce unchanged live observations to one per minute.
|
|
440
|
+
// Settled runs need no heartbeat. Never defer a launch or stop intent.
|
|
441
|
+
const ex = s.current.execution;
|
|
442
|
+
if (!isDeepStrictEqual(before, s) || job.running &&
|
|
443
|
+
(ex.reconciledAt == null || clock.now() - ex.reconciledAt >= 60_000)) {
|
|
444
|
+
Object.assign(ex, { observedAt: clock.now(), reconciledAt: clock.now() });
|
|
445
|
+
save(s);
|
|
446
|
+
}
|
|
447
|
+
// Repair a projection after an interrupted write without rewriting an
|
|
448
|
+
// already matching job.json on every poll.
|
|
449
|
+
if (!isDeepStrictEqual(job.execution, ex)) runner.annotate?.(s.current.id, { execution: ex });
|
|
450
|
+
return current();
|
|
426
451
|
}
|
|
427
452
|
const ex = s.current.execution;
|
|
428
453
|
if (ex.collected && terminal(ex.providerState)) return current();
|
|
@@ -626,6 +651,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
626
651
|
sanitize: (value, id, extraSecrets = []) => redact(value, [...secrets, ...(readJson(join(dirOf(id), "sensitive-redactions.json"))?.secrets || []), ...extraSecrets]),
|
|
627
652
|
prepare: async (input, { build } = {}) => {
|
|
628
653
|
authorize(input);
|
|
654
|
+
if (input.reviewImage != null && (transport || input.kind !== "intake" || provider !== "codex")) throw executionError("TASK_PREPARATION_BOUNDARY", "image review requires the restricted local reviewer; no fallback is available");
|
|
629
655
|
if (input.kind !== "intake") return transport ? call("prepare", input) : input;
|
|
630
656
|
authorizePreparation(read());
|
|
631
657
|
const receipt = preparationReceipt(provider, capabilities, account);
|
|
@@ -701,7 +727,26 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
701
727
|
return job;
|
|
702
728
|
} finally { lock.release(); }
|
|
703
729
|
},
|
|
704
|
-
kill: id => {
|
|
730
|
+
kill: id => {
|
|
731
|
+
if (transport) throw executionError("EXECUTION_CONTROL", "remote cancellation must be requested and confirmed by the coordinator");
|
|
732
|
+
ensureDir(root);
|
|
733
|
+
const lock = acquireProcessLock(join(root, "execution.lock"), "execution");
|
|
734
|
+
try {
|
|
735
|
+
const s = read();
|
|
736
|
+
if (s.current) {
|
|
737
|
+
if (s.current.id !== id) throw executionError("EXECUTION_OWNERSHIP", "interrupt does not own the current local run");
|
|
738
|
+
// First-turn sessions can be discovered in the stream before their
|
|
739
|
+
// terminal result. Retain that verified identity BEFORE stopping and
|
|
740
|
+
// quarantining the run; continuation must not depend on late output.
|
|
741
|
+
const sessionId = localSession(s, id, adapterFor(provider).sessionFrom(runner.lines(id)));
|
|
742
|
+
if (sessionId !== s.current.execution.sessionId) {
|
|
743
|
+
s.current.execution.sessionId = sessionId;
|
|
744
|
+
save(s); runner.annotate?.(id, { execution: s.current.execution });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return runner.kill(id);
|
|
748
|
+
} finally { lock.release(); }
|
|
749
|
+
},
|
|
705
750
|
waitForKill: id => runner?.waitForKill(id), dirOf,
|
|
706
751
|
};
|
|
707
752
|
}
|
|
@@ -8,6 +8,7 @@ import { ensureDir } from "./fs.mjs";
|
|
|
8
8
|
import { MAX_PREPARATION_PROMPT_BYTES } from "./task_contract.mjs";
|
|
9
9
|
import { taskError } from "./task_store.mjs";
|
|
10
10
|
import { boundedCall } from "./execution_io.mjs";
|
|
11
|
+
import { copyReviewImage, REVIEW_IMAGE_LIMITS } from "./review_image.mjs";
|
|
11
12
|
|
|
12
13
|
export async function prepareLocalExecution(provider, capabilities, account, input, build = buildPreparationRun, limits = {}) {
|
|
13
14
|
const receipt = preparationReceipt(provider, capabilities, account);
|
|
@@ -25,5 +26,19 @@ export async function prepareLocalExecution(provider, capabilities, account, inp
|
|
|
25
26
|
if (spec.configure) spec = await boundedCall(signal => spec.configure(spec, { cwd: input.cwd, env, signal }), limits);
|
|
26
27
|
check(); // An adapter's final configuration cannot replace its authority.
|
|
27
28
|
assertSafeEnv(env);
|
|
28
|
-
|
|
29
|
+
let copy, imageAttachment;
|
|
30
|
+
try {
|
|
31
|
+
if (input.reviewImage != null) {
|
|
32
|
+
if (provider !== "codex" || spec.reviewImageBoundary !== "coordinator-copy-v1" || spec.boundary !== "codex-draft-sandbox-v1") throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment requires the restricted reviewer boundary");
|
|
33
|
+
const { content, ...expected } = input.reviewImage;
|
|
34
|
+
if (typeof content !== "string" || !Number.isSafeInteger(expected.bytes) || expected.bytes < 1 || expected.bytes > REVIEW_IMAGE_LIMITS.individualBytes || content.length !== Math.ceil(expected.bytes / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(content)) throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment bytes are incomplete");
|
|
35
|
+
const bytes = Buffer.from(content, "base64");
|
|
36
|
+
if (bytes.length !== expected.bytes || bytes.toString("base64") !== content) throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment bytes differ");
|
|
37
|
+
copy = copyReviewImage(input.cwd, bytes, expected);
|
|
38
|
+
spec.args = [...spec.args.slice(0, -1), "--image", copy.file, "--", spec.args.at(-1)];
|
|
39
|
+
imageAttachment = { file: copy.file, expected };
|
|
40
|
+
check();
|
|
41
|
+
}
|
|
42
|
+
return { receipt, environment: input.environment, spec: { cmd: spec.cmd, args: spec.args, options: { cwd: input.cwd, env, inheritEnv: false }, ...(imageAttachment ? { imageAttachment } : {}) } };
|
|
43
|
+
} catch (error) { copy?.cleanup(); throw error; }
|
|
29
44
|
}
|
package/lib/execution_result.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { publicationPolicyOf, assertPublicationPaths, workArtifactReason } from "./work_artifacts.mjs";
|
|
2
|
+
import { assertAgentsCheckout } from "./openai_agents_sources.mjs";
|
|
3
|
+
import { hostApi } from "./hosts.mjs";
|
|
1
4
|
// Result transport ends here. Git/gh own the one publication path; the existing
|
|
2
5
|
// tick still owns readiness, receipts, review rounds, conflicts and human merge.
|
|
3
6
|
import { createHash } from "node:crypto";
|
|
@@ -7,7 +10,8 @@ import { unlinkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
7
10
|
import { repositoryPath, repositoryRelativePath, assertRepositoryTargets, readRepositoryText, writeRepositoryText } from "./repository_files.mjs";
|
|
8
11
|
import { isOurBranch, runEnv } from "./git.mjs";
|
|
9
12
|
import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
|
|
10
|
-
import { execFileAsync
|
|
13
|
+
import { execFileAsync } from "./process.mjs";
|
|
14
|
+
import { remoteMatches } from "./repository.mjs";
|
|
11
15
|
import { parsePrd, verifyCommands } from "./prd.mjs";
|
|
12
16
|
import { normalizePublicBody } from "./block.mjs";
|
|
13
17
|
import { assertPublicText, assertNoNul } from "./public_output.mjs";
|
|
@@ -103,6 +107,8 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
103
107
|
// input requires an explicit corrective run, with no repeated application.
|
|
104
108
|
const fail = (message, retryable = false) => { throw Object.assign(executionError("EXECUTION_RESULT", message), { publicationValidation: !retryable }); };
|
|
105
109
|
const validate = fn => { try { return fn(); } catch (error) { if (error.code) throw error; fail(error.message); } };
|
|
110
|
+
const approvedBrief = ctx.readPrd(id) || "";
|
|
111
|
+
const publicationPolicy = validate(() => publicationPolicyOf(parsePrd(approvedBrief, `${id}.md`), { sources: entry.sources || [] }));
|
|
106
112
|
const execution = ctx.executionFor?.(id, entry.harness);
|
|
107
113
|
const assertNotStopped = () => {
|
|
108
114
|
const current = ctx.state?.get(id) || entry;
|
|
@@ -149,7 +155,7 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
149
155
|
catch (error) { fail(error.message); }
|
|
150
156
|
};
|
|
151
157
|
if (result.pushedHead) {
|
|
152
|
-
if (result.files?.length || await ctx.
|
|
158
|
+
if (result.files?.length || await hostApi(ctx).refSha(entry.repo, entry.branch) !== result.pushedHead || !await hostApi(ctx).containsCommit(entry.repo, entry.branch, result.inputHead)) fail("pushed result revision or input ancestry cannot be verified");
|
|
153
159
|
if (head !== result.pushedHead) fail("reconcile the exact pushed provider branch into its isolated checkout before collection");
|
|
154
160
|
await validateTargets();
|
|
155
161
|
} else {
|
|
@@ -161,6 +167,7 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
161
167
|
paths.add(path);
|
|
162
168
|
changes.push({ ...file, path });
|
|
163
169
|
}
|
|
170
|
+
validate(() => assertPublicationPaths(changes.filter(file => file.text !== null).map(file => file.path), publicationPolicy));
|
|
164
171
|
await validateTargets(changes.map(file => file.path));
|
|
165
172
|
let modesMatch;
|
|
166
173
|
try { modesMatch = await ctx.git.resultModesMatch(entry.worktree, result.inputHead, [...paths]); }
|
|
@@ -191,6 +198,15 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
191
198
|
}
|
|
192
199
|
}
|
|
193
200
|
const appliedHead = verified ? prior.appliedHead : await ctx.git.head(entry.worktree);
|
|
201
|
+
// A provider may return an already-pushed branch or a resumed publication may
|
|
202
|
+
// contain earlier commits. Check the full branch, not only this turn's manifest.
|
|
203
|
+
let publicationPaths;
|
|
204
|
+
try { publicationPaths = await ctx.git.publicationPaths(entry.worktree, entry.base); }
|
|
205
|
+
catch { fail("complete branch publication paths unavailable; retrieve Git base metadata before publication", true); }
|
|
206
|
+
validate(() => assertPublicationPaths(publicationPaths, publicationPolicy));
|
|
207
|
+
const privateInputPaths = [...new Set([...publicationPolicy.inputFiles, ...(entry.prd ? [entry.prd] : [])])]
|
|
208
|
+
.filter(path => workArtifactReason(path, publicationPolicy));
|
|
209
|
+
const privateInputs = privateInputPaths.map(path => ({ path, text: path === entry.prd ? approvedBrief : ctx.readSource?.(id, path) }));
|
|
194
210
|
const assertVerifiedTree = async () => {
|
|
195
211
|
let revision, tree;
|
|
196
212
|
try {
|
|
@@ -199,7 +215,12 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
199
215
|
} catch { block("verification-state-unavailable", "cannot verify the checkout revision and clean index/worktree", appliedHead); }
|
|
200
216
|
if (revision !== appliedHead) block("verification-head-changed", "verification changed the input head; review that change before publishing", appliedHead);
|
|
201
217
|
if (!tree || ![tree.modified, tree.untracked].every(count => Number.isInteger(count) && count >= 0)) block("verification-state-unavailable", "cannot verify clean index/worktree status", appliedHead);
|
|
202
|
-
if (
|
|
218
|
+
if (privateInputs.length) {
|
|
219
|
+
try {
|
|
220
|
+
await assertAgentsCheckout({ worktree: entry.worktree, overlays: privateInputs, immutableOverlays: true });
|
|
221
|
+
if (await ctx.git.resultModesMatch(entry.worktree, result.inputHead, privateInputPaths) !== true) throw new Error("private input modes changed");
|
|
222
|
+
} catch { block("verification-dirty", "verification requires a clean index/worktree apart from unchanged approved private inputs; preserve uncommitted files before correction", appliedHead); }
|
|
223
|
+
} else if (tree.modified || tree.untracked) block("verification-dirty", "verification requires a clean index/worktree; preserve uncommitted files before correction", appliedHead);
|
|
203
224
|
};
|
|
204
225
|
await assertVerifiedTree();
|
|
205
226
|
const evidenceFile = join(dir, "verification.json");
|
|
@@ -256,10 +277,10 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
256
277
|
try {
|
|
257
278
|
assertNotStopped();
|
|
258
279
|
if (execution?.current && execution.current()?.id !== result.runId) fail("publication root changed; reconcile the exact owned result before publishing", true);
|
|
259
|
-
await ctx.git.pushResult(entry.worktree, entry.branch);
|
|
280
|
+
await ctx.git.pushResult(entry.worktree, entry.branch, entry.repo);
|
|
260
281
|
await assertVerifiedTree();
|
|
261
282
|
assertNotStopped();
|
|
262
|
-
const pr = await ctx.
|
|
283
|
+
const pr = await hostApi(ctx).publishResultPr({ repo: entry.repo, branch: entry.branch, base: entry.base, title: result.pr.title, body });
|
|
263
284
|
writeJsonAtomic(checkpoint, { fingerprint, phase: "published", appliedHead, pr: pr.number, url: pr.url });
|
|
264
285
|
return { head: appliedHead, pr, evidence };
|
|
265
286
|
} finally { exclusion.release(); }
|