toolroll 0.9.4 → 0.9.6
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/CHANGELOG.md +28 -0
- package/README.md +5 -4
- package/dist/assignment-presentation.d.ts +13 -2
- package/dist/assignment-presentation.js +26 -11
- package/dist/assignment-ui.d.ts +10 -15
- package/dist/assignment-ui.js +18 -18
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +21 -20
- package/dist/browser-workspace.d.ts +13 -10
- package/dist/chat-acceptance.js +2 -2
- package/dist/chat-quiet.js +20 -19
- package/dist/cli.js +6 -6
- package/dist/containment.d.ts +5 -0
- package/dist/containment.js +9 -0
- package/dist/demo-chat.d.ts +32 -0
- package/dist/demo-chat.js +158 -0
- package/dist/demo.d.ts +98 -0
- package/dist/demo.js +525 -21
- package/dist/flow-pull-request.js +26 -3
- package/dist/lead-status.d.ts +7 -0
- package/dist/lead-status.js +11 -2
- package/dist/operate.js +25 -9
- package/dist/proof.d.ts +9 -0
- package/dist/proof.js +25 -1
- package/dist/publish.d.ts +15 -0
- package/dist/publish.js +41 -1
- package/dist/pull-request-flow.js +3 -3
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +260 -81
- package/dist/store.d.ts +1 -0
- package/dist/store.js +2 -1
- package/dist/task-status.d.ts +179 -0
- package/dist/task-status.js +317 -0
- package/dist/telegram-progress.js +62 -100
- package/dist/telegram-status.js +19 -6
- package/dist/work-index.js +25 -9
- package/dist/workspace-ui.d.ts +7 -0
- package/dist/workspace-ui.js +31 -2
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
16
|
import { assignmentOf } from "./assignment.js";
|
|
17
17
|
import { cardFollowers, notifyPeople } from "./flow-people.js";
|
|
18
|
+
import { adoptPublishedPullRequest } from "./publish.js";
|
|
18
19
|
import { failingCheckOf, mergePullRequest, owePullRequest, pullRequestBlocker, pullRequestFollowOf } from "./pull-request-flow.js";
|
|
19
20
|
import { requestResultChanges } from "./result-actions.js";
|
|
20
21
|
import { revisionSourceOf } from "./result-review.js";
|
|
@@ -56,6 +57,15 @@ export async function pullRequestStep(store, flow, definition, stage, card, now,
|
|
|
56
57
|
body: `${said}${stage.onFail === null ? "" : `\n\nIt's back in ${titleOf(stage.onFail)}${revision === null ? "" : `, as revision ${revision}`}.`}` }, now);
|
|
57
58
|
return true;
|
|
58
59
|
};
|
|
60
|
+
// Publishing that can't proceed is not the build's fault: rebuilding wouldn't change it, so the card waits here,
|
|
61
|
+
// saying what to do, and the people on it hear once. It carries on by itself once the cause is fixed.
|
|
62
|
+
const stuck = (said) => {
|
|
63
|
+
if (!waiting(said))
|
|
64
|
+
return false;
|
|
65
|
+
const people = card.owner === null ? cardFollowers(store, card) : [card.owner];
|
|
66
|
+
notifyPeople(store, card, people, null, { key: `pr-stuck:${card.entry}`, subject: `${stage.title} is waiting on you for “${card.title}”`, attention: true, body: said }, now);
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
59
69
|
// With no failure path, a card that didn't pass waits for a person to move it (a move is a fresh visit).
|
|
60
70
|
if (stage.onFail === null && card.waiting !== null && card.waiting.endsWith(" Fix it, then move the card to try again."))
|
|
61
71
|
return false;
|
|
@@ -68,14 +78,27 @@ export async function pullRequestStep(store, flow, definition, stage, card, now,
|
|
|
68
78
|
if (publication === null || pullRequestFollowOf(store, publication.id) === null) {
|
|
69
79
|
if (publication === null) {
|
|
70
80
|
const blocked = pullRequestBlocker(store, runId);
|
|
81
|
+
// Only a result that can't be published as built goes back to be built again.
|
|
82
|
+
if (blocked !== null && (blocked === "This result has no commit to publish." || blocked.includes("looks like a secret")))
|
|
83
|
+
return failed(blocked);
|
|
84
|
+
if (blocked === "Pull requests aren't set up for this project.")
|
|
85
|
+
return stuck("Pull requests aren't set up for this project. Turn them on in Projects → Pull requests (or run toolroll publish setup); the card carries on by itself.");
|
|
71
86
|
if (blocked !== null)
|
|
72
|
-
return
|
|
87
|
+
return stuck(blocked);
|
|
73
88
|
}
|
|
74
89
|
publication = owePullRequest(store, runId, `flow ${flow.name}`, card.id, now);
|
|
75
90
|
}
|
|
91
|
+
if (publication.state === "failed") {
|
|
92
|
+
const said = `Couldn't open the pull request: ${publication.lastError ?? "GitHub refused it"}. The commit is safe locally. Fix that, then move the card back to Build for a fresh result.`;
|
|
93
|
+
if (card.waiting === said)
|
|
94
|
+
return false;
|
|
95
|
+
// A pull request opened for this commit anyway (another pass, or a person) is the card's pull request.
|
|
96
|
+
if (await adoptPublishedPullRequest(store, publication, io.gh, () => now))
|
|
97
|
+
publication = store.publicationForRun(runId);
|
|
98
|
+
else
|
|
99
|
+
return stuck(said);
|
|
100
|
+
}
|
|
76
101
|
const pr = publication.prNumber === null ? "the pull request" : `PR #${publication.prNumber}`;
|
|
77
|
-
if (publication.state === "failed")
|
|
78
|
-
return failed(`Couldn't open the pull request: ${publication.lastError ?? "GitHub refused it"}. The commit is safe locally.`);
|
|
79
102
|
if (publication.state !== "opened")
|
|
80
103
|
return waiting(publication.lastError === null ? "Opening the pull request…" : `Opening the pull request. Retrying: ${publication.lastError}`);
|
|
81
104
|
const link = publication.prUrl ?? pr;
|
package/dist/lead-status.d.ts
CHANGED
|
@@ -69,6 +69,13 @@ export type InstallationStatus = {
|
|
|
69
69
|
command: string;
|
|
70
70
|
since: string;
|
|
71
71
|
}[];
|
|
72
|
+
/** The most urgent tasks with the shared headline and sentence (task-status.ts), as every other surface words them. */
|
|
73
|
+
tasks: {
|
|
74
|
+
task: string;
|
|
75
|
+
title: string;
|
|
76
|
+
headline: string;
|
|
77
|
+
sentence: string;
|
|
78
|
+
}[];
|
|
72
79
|
};
|
|
73
80
|
/** One indexed observation for `task wait`. `watchedRun` is the attempt the
|
|
74
81
|
* caller first saw. Each poll follows the task's newest attempt from there, so
|
package/dist/lead-status.js
CHANGED
|
@@ -4,6 +4,7 @@ import { COMPLETION_ACTION } from "./result-completion.js";
|
|
|
4
4
|
import { windowLabel } from "./provider-limits.js";
|
|
5
5
|
import { openAuthPauses, signInCommand, signInReason } from "./provider-auth.js";
|
|
6
6
|
import { BUILT_IN, checkSuitesOf } from "./store.js";
|
|
7
|
+
import { workIndexPage } from "./work-index.js";
|
|
7
8
|
const maybeNumber = (value) => value === null || value === undefined || !Number.isInteger(Number(value)) ? null : Number(value);
|
|
8
9
|
const CHECK_COLUMNS = "rc.status AS check_status, rc.exit_code AS check_exit_code, rc.suites_json AS check_suites_json";
|
|
9
10
|
function checkOf(row) {
|
|
@@ -253,6 +254,8 @@ export function installationStatus(store, now) {
|
|
|
253
254
|
observedAt: String(row["observed_at"]),
|
|
254
255
|
})),
|
|
255
256
|
signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
|
|
257
|
+
tasks: workIndexPage(store, now, { principal: "operator", repos: null, includeUnplaced: true }, { limit: 8 }).items
|
|
258
|
+
.map(one => ({ task: one.rootId, title: one.title, headline: one.status.label, sentence: one.status.detail })),
|
|
256
259
|
};
|
|
257
260
|
}
|
|
258
261
|
export function renderInstallationStatus(status) {
|
|
@@ -260,7 +263,13 @@ export function renderInstallationStatus(status) {
|
|
|
260
263
|
// A paused provider comes first: it is the one thing a person must do.
|
|
261
264
|
for (const one of status.signIn)
|
|
262
265
|
lines.push(`${one.reason} — run \`${one.command}\`. Its tasks wait until then.`);
|
|
263
|
-
|
|
266
|
+
// Each task's one headline, the same words as the console and chat cards.
|
|
267
|
+
if (status.tasks.length > 0) {
|
|
268
|
+
lines.push("Tasks:");
|
|
269
|
+
for (const one of status.tasks)
|
|
270
|
+
lines.push(` ${one.headline.padEnd(16)} ${one.title} (${one.task}) — ${one.sentence}`);
|
|
271
|
+
}
|
|
272
|
+
lines.push(status.running.count === 0 ? "Building: none" : `Building: ${status.running.count} — ${status.running.tasks.map(one => `${one.task} (#${one.run}, ${one.phase})`).join(", ")}${status.running.count > status.running.tasks.length ? ", …" : ""}`);
|
|
264
273
|
lines.push(status.queued.count === 0 ? "Queued: none" : `Queued: ${status.queued.count} — ${status.queued.tasks.map(one => `${one.task} (${one.reason})`).join(", ")}${status.queued.count > status.queued.tasks.length ? ", …" : ""}`);
|
|
265
274
|
lines.push(status.waitingForReview.count === 0 ? "Ready for review: none" : `Ready for review: ${status.waitingForReview.count} — ${status.waitingForReview.results.map(one => `${one.task} (#${one.run})`).join(", ")}${status.waitingForReview.count > status.waitingForReview.results.length ? ", …" : ""}`);
|
|
266
275
|
if (status.releaseCheck === null) {
|
|
@@ -289,7 +298,7 @@ export function renderInstallationStatus(status) {
|
|
|
289
298
|
lines.push(`Plan windows: ${provider} — ${detail}`);
|
|
290
299
|
}
|
|
291
300
|
}
|
|
292
|
-
return lines.slice(0, 12 + status.signIn.length);
|
|
301
|
+
return lines.slice(0, 12 + status.signIn.length + (status.tasks.length === 0 ? 0 : status.tasks.length + 1));
|
|
293
302
|
}
|
|
294
303
|
export function renderTaskWait(snapshot, outcome = snapshot.outcome) {
|
|
295
304
|
const check = snapshot.check.status === "unknown" ? "checks unknown" : snapshot.check.status === "not-run" ? "checks not run" : `checks ${snapshot.check.status}${snapshot.check.exitCode === null ? "" : ` (exit ${snapshot.check.exitCode})`}`;
|
package/dist/operate.js
CHANGED
|
@@ -178,7 +178,7 @@ import { scout as scoutTask } from "./scout.js";
|
|
|
178
178
|
import { profileDigestOf, chainDigestOf, entryDigestOf } from "./scope.js";
|
|
179
179
|
import { PROVIDER_KEY_ENV, SUBSCRIPTION_CAPABLE, clearProviderKey, keyStatus, readAuthMode, readAuthModeStrict, readProviderKey, saveProviderKey, setAuthMode, verifyProviderKey, verdictWords } from "./keys.js";
|
|
180
180
|
import { run, terminateLiveProviders, run as execRun } from "./exec.js";
|
|
181
|
-
import { containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
|
|
181
|
+
import { containmentNotice, containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
|
|
182
182
|
import { readPulls } from "./pulls.js";
|
|
183
183
|
import { startMaintenance } from "./maintenance.js";
|
|
184
184
|
import { livePin, modelWatchPass } from "./model-catalog.js";
|
|
@@ -367,7 +367,9 @@ External trackers — build what a tracker nominates, under local approvals
|
|
|
367
367
|
refuses to spawn where none exists
|
|
368
368
|
(macOS) instead of downgrading.
|
|
369
369
|
Also read by watch, tick and
|
|
370
|
-
daemon install.
|
|
370
|
+
daemon install. --verbose prints
|
|
371
|
+
the exact containment status (a
|
|
372
|
+
service log always has it).
|
|
371
373
|
toolroll reconcile --repo <path> the morning sweep: recover dead
|
|
372
374
|
runners, reap expired leases, adopt
|
|
373
375
|
or forget orphaned worktrees. Run it
|
|
@@ -612,6 +614,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
|
612
614
|
"saved",
|
|
613
615
|
// task complete: also open the result's pull request.
|
|
614
616
|
"pull-request",
|
|
617
|
+
// up: the exact start-up details (containment) in the terminal too.
|
|
618
|
+
"verbose",
|
|
615
619
|
]);
|
|
616
620
|
export function parseOperateArgs(argv, ownValues = new Set()) {
|
|
617
621
|
const positional = [];
|
|
@@ -7131,7 +7135,8 @@ async function runWatchLoop(args) {
|
|
|
7131
7135
|
}
|
|
7132
7136
|
// What this runner's spawns actually get, in one honest line: native
|
|
7133
7137
|
// and which backend, observed, or a required policy that will refuse.
|
|
7134
|
-
|
|
7138
|
+
for (const line of args.containmentLines?.(currentContainment()) ?? [`watch: ${describeContainment(currentContainment())}`])
|
|
7139
|
+
progress(line);
|
|
7135
7140
|
// The night is a row, not "the last 24 hours": everything this watch does
|
|
7136
7141
|
// attributes to this episode by runner and window, and `brief
|
|
7137
7142
|
// --latest-watch` bounds itself to exactly it.
|
|
@@ -8157,6 +8162,18 @@ async function upCommand(flags, context) {
|
|
|
8157
8162
|
return copy;
|
|
8158
8163
|
};
|
|
8159
8164
|
const prefix = (repo) => (activeRepos.size > 1 ? `[${projectName(repo)}] ` : "");
|
|
8165
|
+
// Containment at start: one plain sentence, once, for a person at a
|
|
8166
|
+
// terminal; the exact status line with --verbose, in a service log (no
|
|
8167
|
+
// terminal), and always when a required policy will refuse spawns.
|
|
8168
|
+
const containmentDetail = flags.has("verbose") || !(context.upSeams?.terminal ?? process.stdout.isTTY === true);
|
|
8169
|
+
let containmentSaid = false;
|
|
8170
|
+
const containmentLines = (effective) => {
|
|
8171
|
+
const notice = containmentNotice(effective);
|
|
8172
|
+
if (notice !== null && !containmentSaid)
|
|
8173
|
+
progress(notice);
|
|
8174
|
+
containmentSaid = true;
|
|
8175
|
+
return containmentDetail || effective.refusal !== null ? [`watch: ${describeContainment(effective)}`] : [];
|
|
8176
|
+
};
|
|
8160
8177
|
const loopResults = new Map();
|
|
8161
8178
|
const launchRepo = async (repo, bind) => {
|
|
8162
8179
|
if (loopResults.has(repo) || stopping)
|
|
@@ -8184,6 +8201,7 @@ async function upCommand(flags, context) {
|
|
|
8184
8201
|
progress: line => progress(`${prefix(repo)}${line}`),
|
|
8185
8202
|
isStopping: () => stopping,
|
|
8186
8203
|
onFollowController: controller => followControllers.push(controller),
|
|
8204
|
+
containmentLines,
|
|
8187
8205
|
onReady: () => {
|
|
8188
8206
|
markReady();
|
|
8189
8207
|
if (bind)
|
|
@@ -8323,10 +8341,10 @@ async function upCommand(flags, context) {
|
|
|
8323
8341
|
}
|
|
8324
8342
|
write(repos.length === 0
|
|
8325
8343
|
? ` builder ${runnerName} is ready — add a local folder or GitHub repository in Projects`
|
|
8326
|
-
: ` builder
|
|
8344
|
+
: ` builder watching ${repos.length === 1 ? repos[0] : `${repos.length} repositories`}`);
|
|
8327
8345
|
if (projectRoots.length > 0)
|
|
8328
8346
|
write(` projects new repositories under ${projectRoots.length === 1 ? projectRoots[0] : `${projectRoots.length} saved folders`} connect automatically`);
|
|
8329
|
-
write("
|
|
8347
|
+
write(" Chat shows the steps to your first result.");
|
|
8330
8348
|
write(" Ctrl-C stops Toolroll on this machine.");
|
|
8331
8349
|
}
|
|
8332
8350
|
if (!json && !flags.has("no-open") && terminal)
|
|
@@ -8335,7 +8353,7 @@ async function upCommand(flags, context) {
|
|
|
8335
8353
|
// 10. Supervise to the end.
|
|
8336
8354
|
await stopped;
|
|
8337
8355
|
await registrySupervisor;
|
|
8338
|
-
|
|
8356
|
+
await Promise.all([...loopResults.values()]);
|
|
8339
8357
|
clearInterval(runnerHeartbeat);
|
|
8340
8358
|
if (runTimer !== undefined)
|
|
8341
8359
|
clearTimeout(runTimer);
|
|
@@ -8368,9 +8386,7 @@ async function upCommand(flags, context) {
|
|
|
8368
8386
|
process.stderr.write(`up: ${fatal}\n`);
|
|
8369
8387
|
return EXIT.failed;
|
|
8370
8388
|
}
|
|
8371
|
-
|
|
8372
|
-
const built = results.reduce((sum, one) => sum + one.result.built, 0);
|
|
8373
|
-
progress(`up: stopped cleanly — ${ticks} pass(es), ${built} with work. Run \`toolroll up\` anywhere on this machine to reconnect every saved project.`);
|
|
8389
|
+
progress("Toolroll stopped. Run `toolroll up` anywhere on this machine to start it again with every saved project.");
|
|
8374
8390
|
return EXIT.ok;
|
|
8375
8391
|
}
|
|
8376
8392
|
// ---- the telegram bridge ---------------------------------------------------
|
package/dist/proof.d.ts
CHANGED
|
@@ -332,6 +332,15 @@ export declare function manualReviewOnly(proof: {
|
|
|
332
332
|
reasons: readonly string[];
|
|
333
333
|
matrix?: readonly CriterionMatrixRow[];
|
|
334
334
|
} | null): boolean;
|
|
335
|
+
/** The criterion id a stored "needs a person's check" reason names, or null
|
|
336
|
+
* for any other reason. The stored words stay as recorded; pages say them
|
|
337
|
+
* through `personCheckWords`. */
|
|
338
|
+
export declare function manualReviewCriterionOf(reason: string): string | null;
|
|
339
|
+
/** A stored verification reason as a person would say it, for pages and
|
|
340
|
+
* cards. The record keeps its exact words; unknown reasons pass through. */
|
|
341
|
+
export declare function plainReasonWords(reason: string): string;
|
|
342
|
+
/** A requirement only a person can confirm, as a person would say it. */
|
|
343
|
+
export declare function personCheckWords(statement: string | null): string;
|
|
335
344
|
export type CriterionMatrixRow = {
|
|
336
345
|
/** Controller-captured evidence for direct goal assessment. Kept separately
|
|
337
346
|
* from the final row state: available evidence is not a satisfied goal. */
|
package/dist/proof.js
CHANGED
|
@@ -509,7 +509,31 @@ export function manualReviewOnly(proof) {
|
|
|
509
509
|
return proof?.verdict === "short" && (proof.matrix === undefined || (proof.matrix.some(row => row.state === "manual-review")
|
|
510
510
|
&& proof.matrix.every(row => row.state === "pass" || row.state === "manual-review")))
|
|
511
511
|
&& proof.reasons.length > 0
|
|
512
|
-
&& proof.reasons.every(reason =>
|
|
512
|
+
&& proof.reasons.every(reason => manualReviewCriterionOf(reason) !== null);
|
|
513
|
+
}
|
|
514
|
+
/** The criterion id a stored "needs a person's check" reason names, or null
|
|
515
|
+
* for any other reason. The stored words stay as recorded; pages say them
|
|
516
|
+
* through `personCheckWords`. */
|
|
517
|
+
export function manualReviewCriterionOf(reason) {
|
|
518
|
+
return /^criterion "([^"]+)" requires manual-review evidence — an operator must accept it before this can verify$/.exec(reason)?.[1] ?? null;
|
|
519
|
+
}
|
|
520
|
+
/** A stored verification reason as a person would say it, for pages and
|
|
521
|
+
* cards. The record keeps its exact words; unknown reasons pass through. */
|
|
522
|
+
export function plainReasonWords(reason) {
|
|
523
|
+
if (manualReviewCriterionOf(reason) !== null)
|
|
524
|
+
return "A requirement needs your own check.";
|
|
525
|
+
const unassigned = /^caveat [0-9]+ names no criterion — every caveat is an exception to exactly one signed criterion, named by its exact id \(an unrelated idea belongs in the handoff's follow-ups\): ([\s\S]*)$/.exec(reason);
|
|
526
|
+
if (unassigned !== null)
|
|
527
|
+
return `The agent left a note without saying which requirement it affects: ${unassigned[1]}`;
|
|
528
|
+
const reviewed = /^(?:reviewer:)?([^\s"]+) (contradicts|needs more evidence for) criterion "[^"]+": ([\s\S]*)$/i.exec(reason);
|
|
529
|
+
if (reviewed !== null)
|
|
530
|
+
return `${reviewed[1]} ${reviewed[2].toLowerCase() === "contradicts" ? "says a requirement is not met" : "isn't sure a requirement is met"}: ${reviewed[3]}`;
|
|
531
|
+
return reason;
|
|
532
|
+
}
|
|
533
|
+
/** A requirement only a person can confirm, as a person would say it. */
|
|
534
|
+
export function personCheckWords(statement) {
|
|
535
|
+
const plain = statement?.trim() ?? "";
|
|
536
|
+
return plain === "" ? "You check this one yourself." : `You check this one: ${plain}`;
|
|
513
537
|
}
|
|
514
538
|
/** Set equality, exactly — the "must equal the complete sealed git diff
|
|
515
539
|
* exactly" rule (v39) needs both directions, unlike the legacy subset
|
package/dist/publish.d.ts
CHANGED
|
@@ -25,6 +25,15 @@ import type { Publication, PublicationGrant, Store } from "./store.js";
|
|
|
25
25
|
export declare const BODY_TEMPLATE_VERSION = 2;
|
|
26
26
|
/** After this many failed attempts a publication stops retrying and pages instead. */
|
|
27
27
|
export declare const MAX_PUBLISH_ATTEMPTS = 5;
|
|
28
|
+
/**
|
|
29
|
+
* The one grant lookup for a task's publication: its project is the task's repo, and that project's live grant
|
|
30
|
+
* governs it. The publisher, Complete → pull request and a flow's Pull request zone all ask here, so none of them
|
|
31
|
+
* can judge a publication by another project's grant. `fallback`: the repo to assume for a task with none recorded.
|
|
32
|
+
*/
|
|
33
|
+
export declare function publicationGrantOf(store: Store, taskRef: number, fallback?: string | null): {
|
|
34
|
+
repo: string | null;
|
|
35
|
+
grant: PublicationGrant | null;
|
|
36
|
+
};
|
|
28
37
|
export type PublishExec = (file: string, args: readonly string[], options?: RunOptions) => Promise<ExecResult>;
|
|
29
38
|
/** The grant's terms, in the words the approver must be shown before agreeing. */
|
|
30
39
|
export declare function describePublicationGrant(grant: {
|
|
@@ -65,6 +74,12 @@ export declare function permitsPublication(grant: PublicationGrant | null, publi
|
|
|
65
74
|
ok: false;
|
|
66
75
|
message: string;
|
|
67
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* A publication given up on whose pull request exists anyway — a pass that gave up while another opened it, or a
|
|
79
|
+
* person who opened it by hand: the PR on its exact head and base, carrying its exact commit, is adopted as opened
|
|
80
|
+
* (merged or closed ones too; the next observation records which). Whether one was found.
|
|
81
|
+
*/
|
|
82
|
+
export declare function adoptPublishedPullRequest(store: Store, publication: Publication, exec: PublishExec, clock: () => Date): Promise<boolean>;
|
|
68
83
|
export type CheckReport = {
|
|
69
84
|
observed: number;
|
|
70
85
|
failing: number;
|
package/dist/publish.js
CHANGED
|
@@ -31,6 +31,15 @@ export const BODY_TEMPLATE_VERSION = 2;
|
|
|
31
31
|
/** After this many failed attempts a publication stops retrying and pages instead. */
|
|
32
32
|
export const MAX_PUBLISH_ATTEMPTS = 5;
|
|
33
33
|
const EXEC_TIMEOUT_MS = 60_000;
|
|
34
|
+
/**
|
|
35
|
+
* The one grant lookup for a task's publication: its project is the task's repo, and that project's live grant
|
|
36
|
+
* governs it. The publisher, Complete → pull request and a flow's Pull request zone all ask here, so none of them
|
|
37
|
+
* can judge a publication by another project's grant. `fallback`: the repo to assume for a task with none recorded.
|
|
38
|
+
*/
|
|
39
|
+
export function publicationGrantOf(store, taskRef, fallback = null) {
|
|
40
|
+
const repo = store.refById(taskRef)?.repo ?? fallback;
|
|
41
|
+
return { repo, grant: repo === null ? null : store.publicationGrantFor(repo) };
|
|
42
|
+
}
|
|
34
43
|
/** The grant's terms, in the words the approver must be shown before agreeing. */
|
|
35
44
|
export function describePublicationGrant(grant) {
|
|
36
45
|
return [
|
|
@@ -107,9 +116,15 @@ export async function publishPass(store, options) {
|
|
|
107
116
|
concede(store, publication, "the accepted diff carries a detected secret — rewrite the branch before anything publishes", report, clock);
|
|
108
117
|
continue;
|
|
109
118
|
}
|
|
119
|
+
// Each project's watch publishes its own project's work: the push runs in
|
|
120
|
+
// that checkout, under that project's grant. Another project's pass must
|
|
121
|
+
// not touch it — judging it by the wrong grant once recorded "no live
|
|
122
|
+
// grant" errors until it gave up, while its own pass opened the PR.
|
|
110
123
|
// The grant is re-read per publication, live: revocation is immediate,
|
|
111
124
|
// and an intent created under a grant that has since died goes nowhere.
|
|
112
|
-
const grant = store.
|
|
125
|
+
const { repo, grant } = publicationGrantOf(store, publication.taskRef, options.repo);
|
|
126
|
+
if (repo !== options.repo)
|
|
127
|
+
continue;
|
|
113
128
|
const verdict = permitsPublication(grant, publication);
|
|
114
129
|
if (!verdict.ok) {
|
|
115
130
|
report.problems.push(`publication ${publication.id}: ${verdict.message}`);
|
|
@@ -243,6 +258,31 @@ async function openOrAdopt(store, publication, grant, exec, clock) {
|
|
|
243
258
|
rmSync(dir, { recursive: true, force: true });
|
|
244
259
|
}
|
|
245
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* A publication given up on whose pull request exists anyway — a pass that gave up while another opened it, or a
|
|
263
|
+
* person who opened it by hand: the PR on its exact head and base, carrying its exact commit, is adopted as opened
|
|
264
|
+
* (merged or closed ones too; the next observation records which). Whether one was found.
|
|
265
|
+
*/
|
|
266
|
+
export async function adoptPublishedPullRequest(store, publication, exec, clock) {
|
|
267
|
+
if (publication.state !== "failed")
|
|
268
|
+
return false;
|
|
269
|
+
const listed = await exec("gh", ["pr", "list", "--repo", publication.githubRepo, "--head", publication.head, "--base", publication.base, "--state", "all", "--json", "number,url,headRefOid"], { timeoutMs: EXEC_TIMEOUT_MS });
|
|
270
|
+
if (listed.code !== 0)
|
|
271
|
+
return false;
|
|
272
|
+
let found;
|
|
273
|
+
try {
|
|
274
|
+
found = JSON.parse(listed.stdout || "[]");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
const pr = Array.isArray(found) ? found.find(one => one.headRefOid === publication.headSha && typeof one.number === "number" && typeof one.url === "string") : undefined;
|
|
280
|
+
if (pr === undefined)
|
|
281
|
+
return false;
|
|
282
|
+
store.markPublicationOpened(publication.id, pr.number, pr.url, clock());
|
|
283
|
+
enqueueOpened(store, publication, pr.url, clock);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
246
286
|
/**
|
|
247
287
|
* Read CI for every PR this control plane opened — structured rollup and the
|
|
248
288
|
* exact head OID, never a command's exit status (finding 18). Episodes are
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
18
|
import { run as execRun } from "./exec.js";
|
|
19
|
-
import { bodyHashOf, publicationBody } from "./publish.js";
|
|
19
|
+
import { bodyHashOf, publicationBody, publicationGrantOf } from "./publish.js";
|
|
20
20
|
import { failingChecks, summarizeChecks } from "./pulls.js";
|
|
21
21
|
import { assignmentOf, checkAssignmentAsOperator } from "./assignment.js";
|
|
22
22
|
import { requestResultChanges } from "./result-actions.js";
|
|
@@ -137,7 +137,7 @@ export function pullRequestBlocker(store, runId) {
|
|
|
137
137
|
const ref = run === null ? null : store.refById(run.taskRef);
|
|
138
138
|
if (run === null || ref === null)
|
|
139
139
|
return "No such result.";
|
|
140
|
-
const grant =
|
|
140
|
+
const { grant } = publicationGrantOf(store, run.taskRef);
|
|
141
141
|
if (grant === null || !grant.capabilities.includes("push-branch") || !grant.capabilities.includes("open-pr"))
|
|
142
142
|
return "Pull requests aren't set up for this project.";
|
|
143
143
|
if (run.headRevision === null || run.committed === false)
|
|
@@ -184,7 +184,7 @@ export function owePullRequest(store, runId, actor, flowCard, now) {
|
|
|
184
184
|
return store.transact(() => {
|
|
185
185
|
const run = store.getRun(runId);
|
|
186
186
|
const ref = store.refById(run.taskRef);
|
|
187
|
-
const grant = store.
|
|
187
|
+
const grant = publicationGrantOf(store, run.taskRef).grant;
|
|
188
188
|
let publication = store.publicationForRun(run.id);
|
|
189
189
|
if (publication === null) {
|
|
190
190
|
const id = store.createPublicationIntent({
|
package/dist/serve.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ import type { RevisionLineage } from "./store.js";
|
|
|
18
18
|
import type { SubscriptionMateRunner } from "./subscription-chat.js";
|
|
19
19
|
import { type InstallMethod } from "./install-method.js";
|
|
20
20
|
export type ServeOptions = {
|
|
21
|
+
/** `toolroll demo`: the scripted lead that answers Chat instead of a model. */
|
|
22
|
+
demoLead?: import("./demo.js").DemoLead;
|
|
21
23
|
/** Tests: the bin whose real path says how Toolroll was installed (Settings → Updates' command). */
|
|
22
24
|
installBin?: string;
|
|
23
25
|
/** Native coding workspace injection for isolated integration tests. */
|