toolroll 0.9.6 → 0.9.7
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 +38 -0
- package/dist/actor.d.ts +21 -0
- package/dist/actor.js +48 -0
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-ui.js +3 -1
- package/dist/assignment.d.ts +9 -0
- package/dist/assignment.js +62 -13
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +28 -24
- package/dist/browser-workspace.d.ts +117 -2
- package/dist/builder.d.ts +1 -0
- package/dist/builder.js +33 -13
- package/dist/chat-actions.js +2 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-quiet.js +11 -1
- package/dist/chat-task-actions.d.ts +13 -0
- package/dist/chat-task-actions.js +19 -1
- package/dist/check-levels-ui.d.ts +21 -0
- package/dist/check-levels-ui.js +70 -0
- package/dist/check-levels.d.ts +67 -0
- package/dist/check-levels.js +171 -0
- package/dist/claim.d.ts +22 -0
- package/dist/claim.js +43 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -0
- package/dist/demo-chat.js +4 -1
- package/dist/dispatch.js +7 -2
- package/dist/dispose.d.ts +8 -0
- package/dist/dispose.js +7 -1
- package/dist/export.js +3 -2
- package/dist/first-run.d.ts +34 -0
- package/dist/first-run.js +37 -1
- package/dist/flow-gallery-ui.d.ts +30 -0
- package/dist/flow-gallery-ui.js +85 -0
- package/dist/flow-gallery.d.ts +111 -0
- package/dist/flow-gallery.js +445 -0
- package/dist/flow-share.d.ts +102 -0
- package/dist/flow-share.js +614 -0
- package/dist/flow-starters.d.ts +2 -0
- package/dist/flow-starters.js +1 -1
- package/dist/flow-steps.js +7 -0
- package/dist/flow-triggers.js +2 -0
- package/dist/flows-cli.d.ts +56 -0
- package/dist/flows-cli.js +109 -3
- package/dist/flows-ui.d.ts +15 -1
- package/dist/flows-ui.js +31 -6
- package/dist/guides.js +27 -2
- package/dist/lead-context.js +6 -2
- package/dist/mate-doors.d.ts +0 -15
- package/dist/mate-doors.js +5 -1
- package/dist/mate-tools.js +9 -4
- package/dist/models-ui.js +1 -1
- package/dist/operate.d.ts +4 -1
- package/dist/operate.js +601 -85
- package/dist/project-concurrency.d.ts +62 -0
- package/dist/project-concurrency.js +116 -0
- package/dist/project-delete-ui.d.ts +7 -0
- package/dist/project-delete-ui.js +19 -2
- package/dist/project-delete.js +4 -0
- package/dist/pull-request-flow.d.ts +9 -0
- package/dist/pull-request-flow.js +21 -3
- package/dist/result-follow-ups.d.ts +101 -0
- package/dist/result-follow-ups.js +333 -0
- package/dist/scope.js +11 -0
- package/dist/serve.d.ts +211 -2
- package/dist/serve.js +1009 -141
- package/dist/store.d.ts +76 -3
- package/dist/store.js +311 -22
- package/dist/surface.js +31 -8
- package/dist/tailnet.d.ts +11 -0
- package/dist/tailnet.js +47 -0
- package/dist/task-outcome-cli.js +2 -2
- package/dist/task-status.d.ts +8 -1
- package/dist/task-status.js +28 -10
- package/dist/team-cli.js +3 -0
- package/dist/telegram-status.js +2 -2
- package/dist/verification-evidence.js +21 -1
- package/dist/webhooks.js +5 -0
- package/dist/work-index.js +39 -6
- package/dist/work-summary.d.ts +0 -4
- package/dist/work-summary.js +2 -0
- package/dist/workspace-ui.d.ts +3 -0
- package/dist/workspace-ui.js +5 -3
- package/package.json +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export declare const PROJECT_CONCURRENCY_DEFAULT = 2;
|
|
2
|
+
export declare const projectConcurrencyPath: (databaseFile: string) => string;
|
|
3
|
+
/** A number as typed: a whole number from 1 to RUNNER_CAPACITY_MAX, or null. */
|
|
4
|
+
export declare function parseProjectConcurrency(value: string): number | null;
|
|
5
|
+
/** Every saved number, by canonical project path. An unreadable file reads as none saved. */
|
|
6
|
+
export declare function savedProjectConcurrency(databaseFile: string): Map<string, number>;
|
|
7
|
+
/** The project's own number: saved, or the default. */
|
|
8
|
+
export declare function projectConcurrency(databaseFile: string, repo: string): number;
|
|
9
|
+
/** Save one project's number atomically; returns the number before and after. */
|
|
10
|
+
export declare function saveProjectConcurrency(databaseFile: string, repo: string, n: number): {
|
|
11
|
+
before: number;
|
|
12
|
+
after: number;
|
|
13
|
+
};
|
|
14
|
+
/** What a project actually gets on one worker: its number, never above the worker's capacity. */
|
|
15
|
+
export declare function effectiveConcurrency(setting: number, capacity: number): number;
|
|
16
|
+
/**
|
|
17
|
+
* The in-process record of when each project's builder last finished a
|
|
18
|
+
* pass, shared by every project loop of one service. A project that yields
|
|
19
|
+
* a free slot to another waits only until that project has had a pass to
|
|
20
|
+
* take it: a waiting project that cannot use the slot (a missing tool, a
|
|
21
|
+
* paused provider) never holds a busy one back for long.
|
|
22
|
+
*/
|
|
23
|
+
export declare class ProjectPasses {
|
|
24
|
+
private readonly finished;
|
|
25
|
+
private tick;
|
|
26
|
+
/** A monotonic stamp, so ordering never depends on the wall clock. */
|
|
27
|
+
stamp(): number;
|
|
28
|
+
passed(repo: string): void;
|
|
29
|
+
lastPass(repo: string): number;
|
|
30
|
+
}
|
|
31
|
+
export type SlotFacts = {
|
|
32
|
+
repo: string;
|
|
33
|
+
/** This worker's capacity. */
|
|
34
|
+
capacity: number;
|
|
35
|
+
/** Builds this worker holds now, by project (the asking lane holds none). */
|
|
36
|
+
running: ReadonlyMap<string, number>;
|
|
37
|
+
/** Each project's own number. */
|
|
38
|
+
limitOf: (repo: string) => number;
|
|
39
|
+
/** Projects with work this worker could start now. */
|
|
40
|
+
waiting: ReadonlySet<string>;
|
|
41
|
+
/** Since when this lane has been yielding, or null. */
|
|
42
|
+
yieldingSince: number | null;
|
|
43
|
+
passes: ProjectPasses;
|
|
44
|
+
};
|
|
45
|
+
export type SlotAnswer = {
|
|
46
|
+
take: true;
|
|
47
|
+
} | {
|
|
48
|
+
take: false;
|
|
49
|
+
why: "limit" | "capacity" | "fair-share";
|
|
50
|
+
to?: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* May a build lane of `repo` start one more task now?
|
|
54
|
+
*
|
|
55
|
+
* - never past the project's own number (capped by the worker's capacity);
|
|
56
|
+
* - never past the worker's capacity;
|
|
57
|
+
* - fair share: a project already building yields a free slot to any other
|
|
58
|
+
* project with ready work that is building fewer, until that project has
|
|
59
|
+
* had a pass to take it. A project building nothing never yields, so every
|
|
60
|
+
* project with work gets its first build ahead of another's second.
|
|
61
|
+
*/
|
|
62
|
+
export declare function maySlotTake(facts: SlotFacts): SlotAnswer;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many tasks of one project the service builds at once, and which
|
|
3
|
+
* project gets a free slot when several are waiting.
|
|
4
|
+
*
|
|
5
|
+
* The number is a per-project setting an approver changes (Settings →
|
|
6
|
+
* Projects, or `toolroll project concurrency <n> --repo <p>`), kept in a
|
|
7
|
+
* small file beside the database and recorded in the ledger as before →
|
|
8
|
+
* after. A project without a saved number builds up to two at once. The
|
|
9
|
+
* worker's own capacity always caps the total: a project's number above
|
|
10
|
+
* it means "as many as the worker has".
|
|
11
|
+
*/
|
|
12
|
+
import { lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { RUNNER_CAPACITY_MAX } from "./runner.js";
|
|
16
|
+
export const PROJECT_CONCURRENCY_DEFAULT = 2;
|
|
17
|
+
export const projectConcurrencyPath = (databaseFile) => join(dirname(databaseFile), "project-concurrency.json");
|
|
18
|
+
/** A number as typed: a whole number from 1 to RUNNER_CAPACITY_MAX, or null. */
|
|
19
|
+
export function parseProjectConcurrency(value) {
|
|
20
|
+
const n = Number(value.trim());
|
|
21
|
+
return value.trim() !== "" && Number.isInteger(n) && n >= 1 && n <= RUNNER_CAPACITY_MAX ? n : null;
|
|
22
|
+
}
|
|
23
|
+
/** Every saved number, by canonical project path. An unreadable file reads as none saved. */
|
|
24
|
+
export function savedProjectConcurrency(databaseFile) {
|
|
25
|
+
const saved = new Map();
|
|
26
|
+
const file = projectConcurrencyPath(databaseFile);
|
|
27
|
+
try {
|
|
28
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
29
|
+
if (stat === undefined || !stat.isFile() || stat.size > 262_144)
|
|
30
|
+
return saved;
|
|
31
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
32
|
+
const projects = value !== null && typeof value === "object" ? value.projects : undefined;
|
|
33
|
+
if (projects === null || typeof projects !== "object")
|
|
34
|
+
return saved;
|
|
35
|
+
for (const [repo, n] of Object.entries(projects)) {
|
|
36
|
+
if (typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= RUNNER_CAPACITY_MAX)
|
|
37
|
+
saved.set(repo, n);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// A damaged file never stops building: every project falls back to the default.
|
|
42
|
+
}
|
|
43
|
+
return saved;
|
|
44
|
+
}
|
|
45
|
+
/** The project's own number: saved, or the default. */
|
|
46
|
+
export function projectConcurrency(databaseFile, repo) {
|
|
47
|
+
return savedProjectConcurrency(databaseFile).get(repo) ?? PROJECT_CONCURRENCY_DEFAULT;
|
|
48
|
+
}
|
|
49
|
+
/** Save one project's number atomically; returns the number before and after. */
|
|
50
|
+
export function saveProjectConcurrency(databaseFile, repo, n) {
|
|
51
|
+
const saved = savedProjectConcurrency(databaseFile);
|
|
52
|
+
const before = saved.get(repo) ?? PROJECT_CONCURRENCY_DEFAULT;
|
|
53
|
+
saved.set(repo, n);
|
|
54
|
+
const file = projectConcurrencyPath(databaseFile);
|
|
55
|
+
const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
56
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
57
|
+
writeFileSync(temp, `${JSON.stringify({ version: 1, projects: Object.fromEntries([...saved].sort(([a], [b]) => a.localeCompare(b))) }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
58
|
+
renameSync(temp, file);
|
|
59
|
+
return { before, after: n };
|
|
60
|
+
}
|
|
61
|
+
/** What a project actually gets on one worker: its number, never above the worker's capacity. */
|
|
62
|
+
export function effectiveConcurrency(setting, capacity) {
|
|
63
|
+
return Math.max(1, Math.min(setting, capacity));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The in-process record of when each project's builder last finished a
|
|
67
|
+
* pass, shared by every project loop of one service. A project that yields
|
|
68
|
+
* a free slot to another waits only until that project has had a pass to
|
|
69
|
+
* take it: a waiting project that cannot use the slot (a missing tool, a
|
|
70
|
+
* paused provider) never holds a busy one back for long.
|
|
71
|
+
*/
|
|
72
|
+
export class ProjectPasses {
|
|
73
|
+
finished = new Map();
|
|
74
|
+
tick = 0;
|
|
75
|
+
/** A monotonic stamp, so ordering never depends on the wall clock. */
|
|
76
|
+
stamp() {
|
|
77
|
+
return ++this.tick;
|
|
78
|
+
}
|
|
79
|
+
passed(repo) {
|
|
80
|
+
this.finished.set(repo, this.stamp());
|
|
81
|
+
}
|
|
82
|
+
lastPass(repo) {
|
|
83
|
+
return this.finished.get(repo) ?? 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* May a build lane of `repo` start one more task now?
|
|
88
|
+
*
|
|
89
|
+
* - never past the project's own number (capped by the worker's capacity);
|
|
90
|
+
* - never past the worker's capacity;
|
|
91
|
+
* - fair share: a project already building yields a free slot to any other
|
|
92
|
+
* project with ready work that is building fewer, until that project has
|
|
93
|
+
* had a pass to take it. A project building nothing never yields, so every
|
|
94
|
+
* project with work gets its first build ahead of another's second.
|
|
95
|
+
*/
|
|
96
|
+
export function maySlotTake(facts) {
|
|
97
|
+
const here = facts.running.get(facts.repo) ?? 0;
|
|
98
|
+
const total = [...facts.running.values()].reduce((sum, n) => sum + n, 0);
|
|
99
|
+
if (here >= effectiveConcurrency(facts.limitOf(facts.repo), facts.capacity))
|
|
100
|
+
return { take: false, why: "limit" };
|
|
101
|
+
if (total >= facts.capacity)
|
|
102
|
+
return { take: false, why: "capacity" };
|
|
103
|
+
if (here === 0)
|
|
104
|
+
return { take: true };
|
|
105
|
+
for (const other of [...facts.waiting].sort()) {
|
|
106
|
+
if (other === facts.repo)
|
|
107
|
+
continue;
|
|
108
|
+
const theirs = facts.running.get(other) ?? 0;
|
|
109
|
+
if (theirs >= here || theirs >= effectiveConcurrency(facts.limitOf(other), facts.capacity))
|
|
110
|
+
continue;
|
|
111
|
+
if (facts.yieldingSince !== null && facts.passes.lastPass(other) > facts.yieldingSince)
|
|
112
|
+
continue;
|
|
113
|
+
return { take: false, why: "fair-share", to: other };
|
|
114
|
+
}
|
|
115
|
+
return { take: true };
|
|
116
|
+
}
|
|
@@ -12,6 +12,13 @@ export type ProjectSettingsView = {
|
|
|
12
12
|
holdings: ProjectHoldings;
|
|
13
13
|
running: string[];
|
|
14
14
|
canDelete: boolean;
|
|
15
|
+
/** How many of its tasks build at once: the saved number, what its worker allows, how many build now. */
|
|
16
|
+
builds?: {
|
|
17
|
+
setting: number;
|
|
18
|
+
capacity: number | null;
|
|
19
|
+
building: number;
|
|
20
|
+
canChange: boolean;
|
|
21
|
+
};
|
|
15
22
|
};
|
|
16
23
|
/** The project's page: its path, what Toolroll holds for it, and (for an instance operator) Delete project. */
|
|
17
24
|
export declare function projectSettingsHtml(view: ProjectSettingsView, csrf: string, notice: {
|
|
@@ -11,12 +11,29 @@ export const PROJECT_DELETE_CSS = `.project-settings,.project-delete{max-width:7
|
|
|
11
11
|
`.project-settings details.danger-zone[open]{padding-bottom:14px}.project-settings form,.project-delete form{display:grid;gap:12px;margin-top:12px}.project-settings label,.project-delete label{display:grid;gap:4px;font-size:.8125rem;font-weight:500}` +
|
|
12
12
|
`.project-settings input,.project-delete input{max-width:100%;min-height:36px}.project-delete .removes{margin:12px 0;padding-left:20px}.project-delete .removes li{margin:2px 0}` +
|
|
13
13
|
`.project-delete .actions{display:flex;flex-wrap:wrap;gap:12px;align-items:center}.project-delete button.danger,.project-settings button.danger{background:var(--danger);color:#fff;border-color:var(--danger);white-space:nowrap}` +
|
|
14
|
-
`.project-settings button,.project-delete button{justify-self:start;min-height:36px;white-space:nowrap}.project-delete .actions a{min-height:36px;display:inline-flex;align-items:center}
|
|
14
|
+
`.project-settings button,.project-delete button{justify-self:start;min-height:36px;white-space:nowrap}.project-delete .actions a{min-height:36px;display:inline-flex;align-items:center}` +
|
|
15
|
+
`.project-settings .builds-at-once{margin-top:20px}.project-settings .builds-at-once h2{font-size:1rem;margin:0 0 4px}.project-settings .builds-at-once form{margin-top:4px;gap:4px}` +
|
|
16
|
+
`.project-settings .builds-at-once .builds-row{display:flex;gap:8px;align-items:stretch}.project-settings .builds-at-once input{width:5.5rem;min-height:44px;margin:0;box-sizing:border-box}.project-settings .builds-at-once button{min-height:44px;margin:0}` +
|
|
17
|
+
`.project-settings .builds-at-once .meta{margin:0}`;
|
|
18
|
+
/** Builds at once: one number, one Save. The worker's cap is said only when it lowers the number. */
|
|
19
|
+
function buildsHtml(view, csrf) {
|
|
20
|
+
const b = view.builds;
|
|
21
|
+
if (b === undefined)
|
|
22
|
+
return "";
|
|
23
|
+
const capped = b.capacity !== null && b.capacity < b.setting ? ` Its worker runs ${b.capacity} at once, so ${b.capacity} for now.` : "";
|
|
24
|
+
const now = `${b.building} building now.${capped}`;
|
|
25
|
+
if (!b.canChange)
|
|
26
|
+
return `<section class="builds-at-once"><h2>Builds at once</h2><p>Up to ${b.setting}. ${e(now)}</p></section>`;
|
|
27
|
+
return `<section class="builds-at-once"><h2>Builds at once</h2><form method="post" action="/settings/project/concurrency">` +
|
|
28
|
+
`<input type="hidden" name="csrf" value="${e(csrf)}"><input type="hidden" name="repo" value="${e(view.repo)}">` +
|
|
29
|
+
`<div class="builds-row"><input type="number" name="concurrency" min="1" max="64" step="1" inputmode="numeric" value="${b.setting}" aria-label="Builds at once" aria-describedby="builds-now"><button type="submit">Save</button></div>` +
|
|
30
|
+
`<p class="meta" id="builds-now">${e(now)}</p></form></section>`;
|
|
31
|
+
}
|
|
15
32
|
const note = (notice) => notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
|
|
16
33
|
/** The project's page: its path, what Toolroll holds for it, and (for an instance operator) Delete project. */
|
|
17
34
|
export function projectSettingsHtml(view, csrf, notice) {
|
|
18
35
|
const held = `<p>Toolroll holds ${e(holdingsWords(view.holdings))} for <strong>${e(view.name)}</strong>.</p>`;
|
|
19
|
-
const head = `<section class="project-settings">${note(notice)}<p class="path">${e(view.repo)}</p>${held}`;
|
|
36
|
+
const head = `<section class="project-settings">${note(notice)}<p class="path">${e(view.repo)}</p>${held}${buildsHtml(view, csrf)}`;
|
|
20
37
|
if (!view.canDelete)
|
|
21
38
|
return `${head}<p class="meta">An instance operator can delete a project.</p></section>`;
|
|
22
39
|
const body = view.running.length > 0
|
package/dist/project-delete.js
CHANGED
|
@@ -233,6 +233,7 @@ function deleteRows(store, repo, d, now) {
|
|
|
233
233
|
del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
|
|
234
234
|
del("chat_card_task", IN("task_ref"), T);
|
|
235
235
|
del("push_delivery", IN("notification"), notifications);
|
|
236
|
+
del("notification_actor", IN("notification"), notifications);
|
|
236
237
|
del("notification_delivery", IN("notification"), notifications);
|
|
237
238
|
del("notification", IN("id"), notifications);
|
|
238
239
|
del("chat_focus", IN("task"), K);
|
|
@@ -287,6 +288,8 @@ function deleteRows(store, repo, d, now) {
|
|
|
287
288
|
del("workflow_preview", "repo = ?", repo);
|
|
288
289
|
del("coding_handoff_scope", `id IN (SELECT id FROM coding_handoff WHERE json_extract(payload, '$.repo') = ?)`, repo);
|
|
289
290
|
del("coding_handoff", "json_extract(payload, '$.repo') = ?", repo);
|
|
291
|
+
for (const table of ["task_act", "task_replacement"])
|
|
292
|
+
del(table, IN("task_ref"), T);
|
|
290
293
|
del("task_ref", IN("id"), T);
|
|
291
294
|
del("task_scope", IN("task_id"), K);
|
|
292
295
|
del("task_edge", `${IN("blocked")} OR ${IN("blocker")}`, K, K);
|
|
@@ -313,6 +316,7 @@ function deleteRows(store, repo, d, now) {
|
|
|
313
316
|
del("routine_fire", "routine_id IN (SELECT id FROM routine WHERE repo = ?)", repo);
|
|
314
317
|
for (const [table, column] of SETTINGS)
|
|
315
318
|
del(table, `${column} = ?`, repo);
|
|
319
|
+
del("project_mute", "repo = ?", repo);
|
|
316
320
|
for (const table of ["knowledge_change", "project_skill_change", "memory_gap", "memory_proposal", "memory_rejection", "memory_session", "memory_search", "mode_rail", "side_spend", "watch_episode", "worktree", "coordinator_proposal"])
|
|
317
321
|
del(table, "repo = ?", repo);
|
|
318
322
|
// A builder watching the project keeps its lease until it stops; an ended one goes.
|
|
@@ -137,6 +137,8 @@ export declare function mergeAsPerson(store: Store, input: {
|
|
|
137
137
|
password: string;
|
|
138
138
|
exec?: PublishExec;
|
|
139
139
|
clock?: () => Date;
|
|
140
|
+
/** A person's choice to merge without waiting for the full check (Quick or Off results). */
|
|
141
|
+
anyway?: boolean;
|
|
140
142
|
}): Promise<Result<{
|
|
141
143
|
commit: string | null;
|
|
142
144
|
}>>;
|
|
@@ -152,6 +154,8 @@ export declare function mergePullRequest(store: Store, input: {
|
|
|
152
154
|
clock?: () => Date;
|
|
153
155
|
/** A flow's Pull request zone merges its own way; otherwise the project's. */
|
|
154
156
|
method?: MergeMethod;
|
|
157
|
+
/** Only a person merging by hand may skip waiting for the full check. */
|
|
158
|
+
anyway?: boolean;
|
|
155
159
|
}): Promise<Result<{
|
|
156
160
|
commit: string | null;
|
|
157
161
|
}>>;
|
|
@@ -166,6 +170,11 @@ export type PullRequestView = {
|
|
|
166
170
|
mergeMethod: MergeMethod;
|
|
167
171
|
canMerge: boolean;
|
|
168
172
|
revisionTask: string | null;
|
|
173
|
+
/** Quick or Off results: the full check Merge waits for, unless a person merges anyway. */
|
|
174
|
+
fullCheck: {
|
|
175
|
+
state: "not-requested" | "waiting" | "failed" | "not-run";
|
|
176
|
+
message: string;
|
|
177
|
+
} | null;
|
|
169
178
|
};
|
|
170
179
|
/** The pull request a result opened through Complete, in plain words; null when it opened none. */
|
|
171
180
|
export declare function pullRequestViewOf(store: Store, runId: number): PullRequestView | null;
|
|
@@ -23,6 +23,7 @@ import { requestResultChanges } from "./result-actions.js";
|
|
|
23
23
|
import { revisionSourceOf } from "./result-review.js";
|
|
24
24
|
import { authenticateApprover } from "./scope.js";
|
|
25
25
|
import { BRANCH_PREFIX, headWithin } from "./names.js";
|
|
26
|
+
import { fullCheckGate, requestFollowUpChecks } from "./result-follow-ups.js";
|
|
26
27
|
export const MERGE_METHODS = ["squash", "merge", "rebase"];
|
|
27
28
|
/** CI revisions one task may file across its revisions before a person is asked instead. */
|
|
28
29
|
export const MAX_CI_REVISIONS = 2;
|
|
@@ -198,6 +199,9 @@ export function owePullRequest(store, runId, actor, flowCard, now) {
|
|
|
198
199
|
}
|
|
199
200
|
store.handle.prepare("INSERT OR IGNORE INTO pull_request_follow (publication, flow_card, revisions, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
200
201
|
.run(publication.id, flowCard, inheritedRevisions(store, run.taskRef), now.toISOString(), now.toISOString());
|
|
202
|
+
// Built with Quick or Off checks: the full check runs on this commit now, and Merge waits for it.
|
|
203
|
+
if (fullCheckGate(store, run.id, now).state === "not-requested")
|
|
204
|
+
requestFollowUpChecks(store, { runId: run.id, level: "full", actor, why: "pull-request" }, now);
|
|
201
205
|
store.bumpWake();
|
|
202
206
|
return publication;
|
|
203
207
|
});
|
|
@@ -327,6 +331,9 @@ export async function followPullRequests(store, seen, options) {
|
|
|
327
331
|
continue;
|
|
328
332
|
}
|
|
329
333
|
if (one.state === "passing" && one.headOid === publication.headSha && follow.readyHead !== one.headOid) {
|
|
334
|
+
// A Quick or Off result is ready only once its full check passed too; this is read again next time.
|
|
335
|
+
if (fullCheckGate(store, publication.run, clock()).state !== "clear")
|
|
336
|
+
continue;
|
|
330
337
|
const grant = store.publicationGrantFor(store.refById(publication.taskRef)?.repo ?? "");
|
|
331
338
|
updateFollow(store, publication.id, { ready_head: one.headOid }, clock());
|
|
332
339
|
if (grant?.mergeWhenGreen === true && grant.publishOn === "complete") {
|
|
@@ -365,7 +372,7 @@ export async function mergeAsPerson(store, input) {
|
|
|
365
372
|
return refuse("password", "Enter your password to merge.");
|
|
366
373
|
if (!authenticateApprover(store, input.name, input.password, repo).ok)
|
|
367
374
|
return refuse("password", "That password didn't work, or you can't merge in this project.");
|
|
368
|
-
return mergePullRequest(store, { runId: input.runId, by: input.name, ...(input.exec === undefined ? {} : { exec: input.exec }), ...(input.clock === undefined ? {} : { clock: input.clock }) });
|
|
375
|
+
return mergePullRequest(store, { runId: input.runId, by: input.name, ...(input.anyway === true ? { anyway: true } : {}), ...(input.exec === undefined ? {} : { exec: input.exec }), ...(input.clock === undefined ? {} : { clock: input.clock }) });
|
|
369
376
|
}
|
|
370
377
|
/**
|
|
371
378
|
* Merge a followed pull request: callers have already established who is merging. A fresh read must show it open,
|
|
@@ -407,7 +414,7 @@ export async function mergePullRequest(store, input) {
|
|
|
407
414
|
store.recordPublicationRemoteState(publication.id, "MERGED", now);
|
|
408
415
|
store.resolveCiEpisodes(publication.githubRepo, publication.prNumber, null, now);
|
|
409
416
|
store.recordAction({ at: now.toISOString(), actor: by, repo: ref.repo, taskId: ref.externalId, runId: publication.run, action: MERGED_ACTION,
|
|
410
|
-
outcome: commit ?? "merged", source: "work", detail: `PR #${pr} by ${method}, branch deleted` });
|
|
417
|
+
outcome: commit ?? "merged", source: "work", detail: `PR #${pr} by ${method}, branch deleted${skippedFullCheck ? ", merged without waiting for the full check" : ""}` });
|
|
411
418
|
store.enqueueNotification({ source: { run: publication.run }, dedupeKey: `pull-request:${publication.id}:merged`, kind: "pull-request-merged",
|
|
412
419
|
subject: `Merged: ${ref.externalId} (PR #${pr})`, body: `${publication.prUrl ?? publication.githubRepo}${commit === null ? "" : ` — ${commit.slice(0, 12)}`}`,
|
|
413
420
|
link: `/t/${encodeURIComponent(ref.externalId)}` }, now);
|
|
@@ -417,6 +424,13 @@ export async function mergePullRequest(store, input) {
|
|
|
417
424
|
updateFollow(store, publication.id, { merge_error: message.slice(0, 300) }, clock());
|
|
418
425
|
return refuse(reason, message);
|
|
419
426
|
};
|
|
427
|
+
// Built with Quick or Off checks: Merge waits for the full check on this commit, unless a person merges anyway.
|
|
428
|
+
const gate = fullCheckGate(store, publication.run, clock());
|
|
429
|
+
if (gate.state === "not-requested")
|
|
430
|
+
requestFollowUpChecks(store, { runId: publication.run, level: "full", actor: input.by, why: "pull-request" }, clock());
|
|
431
|
+
const skippedFullCheck = gate.state !== "clear";
|
|
432
|
+
if (skippedFullCheck && input.anyway !== true)
|
|
433
|
+
return fail("full-checks", gate.message);
|
|
420
434
|
const before = await view();
|
|
421
435
|
if (before === null)
|
|
422
436
|
return fail("unread", "GitHub couldn't be read just now. Try again.");
|
|
@@ -450,7 +464,9 @@ export function pullRequestViewOf(store, runId) {
|
|
|
450
464
|
return null;
|
|
451
465
|
const grant = store.publicationGrantFor(store.refById(publication.taskRef)?.repo ?? "");
|
|
452
466
|
const method = follow.mergeMethod ?? grant?.mergeMethod ?? "squash";
|
|
453
|
-
const
|
|
467
|
+
const gate = fullCheckGate(store, runId, new Date());
|
|
468
|
+
const fullCheck = gate.state === "clear" ? null : gate;
|
|
469
|
+
const base = { runId, prNumber: publication.prNumber, prUrl: publication.prUrl, mergeCommit: follow.mergeCommit || null, mergeMethod: method, canMerge: false, revisionTask: follow.revisionTask, fullCheck };
|
|
454
470
|
const error = follow.mergeError === null ? "" : ` Last merge attempt: ${follow.mergeError}`;
|
|
455
471
|
if (follow.mergeCommit !== null) {
|
|
456
472
|
return { ...base, state: "merged", label: "Merged", detail: follow.mergedBy === null || follow.mergedBy === "GitHub" ? "Merged on GitHub." : `Merged by ${follow.mergedBy}${follow.mergeMethod === null ? "" : ` (${follow.mergeMethod})`}. Branch deleted.` };
|
|
@@ -464,6 +480,8 @@ export function pullRequestViewOf(store, runId) {
|
|
|
464
480
|
return { ...base, state: "closed", label: "Closed", detail: "The pull request was closed on GitHub without merging." };
|
|
465
481
|
switch (publication.lastCheckState) {
|
|
466
482
|
case "passing":
|
|
483
|
+
if (fullCheck !== null)
|
|
484
|
+
return { ...base, state: "waiting", label: fullCheck.state === "failed" ? "Full checks failed" : "Waiting for full checks", detail: `CI passed on GitHub. ${fullCheck.message}${error}`, canMerge: grant !== null };
|
|
467
485
|
return { ...base, state: "ready", label: "Ready to merge", detail: `CI passed on GitHub.${grant?.mergeWhenGreen === true ? " It merges by itself." : ""}${error}`, canMerge: grant !== null };
|
|
468
486
|
case "failing":
|
|
469
487
|
return { ...base, state: "failing", label: "Checks failed", detail: follow.revisionTask !== null && follow.askedHead !== follow.redHead ? `Revision ${follow.revisionTask} was filed with the failure.` : follow.askedHead !== null ? "Toolroll stopped filing revisions. Decide what to do next." : "Reading the failure." };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Runner } from "./backend.js";
|
|
2
|
+
import type { Store, VerifyCommand } from "./store.js";
|
|
3
|
+
import { type CheckLevel } from "./check-levels.js";
|
|
4
|
+
export declare const CHECKS_REQUESTED = "checks requested";
|
|
5
|
+
export declare const CHECKS_STARTED = "checks started";
|
|
6
|
+
export declare const CHECKS_FINISHED = "checks finished";
|
|
7
|
+
export declare const FOLLOW_UP_LOG = "follow-up check log v1";
|
|
8
|
+
export declare const FOLLOW_UP_RECEIPT = "follow-up check receipt v1";
|
|
9
|
+
export declare const ADD_TESTS_ACTION = "tests task filed";
|
|
10
|
+
export type RunLevel = Exclude<CheckLevel, "off">;
|
|
11
|
+
export type FollowUpCheck = {
|
|
12
|
+
request: number;
|
|
13
|
+
runId: number;
|
|
14
|
+
level: RunLevel;
|
|
15
|
+
head: string;
|
|
16
|
+
actor: string;
|
|
17
|
+
at: string;
|
|
18
|
+
why: "person" | "pull-request";
|
|
19
|
+
/** The approved command's digest when checks were asked for: only that exact command runs. */
|
|
20
|
+
digest: string;
|
|
21
|
+
state: "waiting" | "running" | "passed" | "failed" | "not-run";
|
|
22
|
+
exitCode: number | null;
|
|
23
|
+
logArtifactId: number | null;
|
|
24
|
+
note: string | null;
|
|
25
|
+
};
|
|
26
|
+
type Refusal = {
|
|
27
|
+
ok: false;
|
|
28
|
+
reason: string;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
/** Every follow-up check a result has had, oldest first, as the ledger and its sealed receipts say. */
|
|
32
|
+
export declare function followUpChecksOf(store: Store, runId: number, now?: Date, root?: string): FollowUpCheck[];
|
|
33
|
+
/** Ask for checks on a result's exact commit. Asking twice while one is waiting
|
|
34
|
+
* or running returns the same request; a worker runs it once. */
|
|
35
|
+
export declare function requestFollowUpChecks(store: Store, input: {
|
|
36
|
+
runId: number;
|
|
37
|
+
level: RunLevel;
|
|
38
|
+
actor: string;
|
|
39
|
+
why?: "person" | "pull-request";
|
|
40
|
+
}, now: Date): {
|
|
41
|
+
ok: true;
|
|
42
|
+
request: number;
|
|
43
|
+
level: RunLevel;
|
|
44
|
+
existing: boolean;
|
|
45
|
+
} | Refusal;
|
|
46
|
+
/** Requests nobody has started yet, oldest first. */
|
|
47
|
+
export declare function waitingCheckRequests(store: Store, now: Date): FollowUpCheck[];
|
|
48
|
+
export type CheckRunDeps = {
|
|
49
|
+
runner?: Runner;
|
|
50
|
+
git?: Runner;
|
|
51
|
+
now?: () => Date;
|
|
52
|
+
scratch?: string;
|
|
53
|
+
};
|
|
54
|
+
/** Run one waiting request: claim it, check out its commit, run the approved
|
|
55
|
+
* setup (when the project has one) and the check, seal the log and receipt. */
|
|
56
|
+
export declare function runFollowUpCheck(store: Store, root: string, request: number, deps?: CheckRunDeps): Promise<FollowUpCheck | null>;
|
|
57
|
+
/** The worker's pass: run every waiting request, one at a time. */
|
|
58
|
+
export declare function runWaitingChecks(store: Store, root: string, deps?: CheckRunDeps & {
|
|
59
|
+
shouldStop?: () => boolean;
|
|
60
|
+
}): Promise<number>;
|
|
61
|
+
export type CheckReading = {
|
|
62
|
+
status: "passed" | "failed" | "not-run" | "unavailable";
|
|
63
|
+
level: CheckLevel | null;
|
|
64
|
+
exitCode: number | null;
|
|
65
|
+
head: string | null;
|
|
66
|
+
};
|
|
67
|
+
/** The build's own check, then each finished follow-up in order on the same
|
|
68
|
+
* commit: a pass upgrades (a quick pass never replaces a full one), a failure
|
|
69
|
+
* is shown as it is. A follow-up that couldn't run changes nothing. */
|
|
70
|
+
export declare function withFollowUps(original: CheckReading, followUps: readonly Pick<FollowUpCheck, "level" | "state" | "head" | "exitCode">[]): CheckReading & {
|
|
71
|
+
running: RunLevel | null;
|
|
72
|
+
};
|
|
73
|
+
/** The level a result's checks stand at now: the build's own level, raised by
|
|
74
|
+
* a follow-up that passed on the same commit. null for results from before levels. */
|
|
75
|
+
export declare function resultCheckLevel(store: Store, runId: number, now: Date): CheckLevel | null;
|
|
76
|
+
/** Whether this result's commit has passed the project's full check, here or in a follow-up. */
|
|
77
|
+
export declare function fullCheckPassed(reading: CheckReading): boolean;
|
|
78
|
+
/** Before a Quick or Off result merges: the project's full check on its exact
|
|
79
|
+
* commit. Clear when the build already ran the full check, when the project has
|
|
80
|
+
* no full command (its CI covers it), or when a follow-up full check passed. */
|
|
81
|
+
export type FullCheckGate = {
|
|
82
|
+
state: "clear";
|
|
83
|
+
} | {
|
|
84
|
+
state: "not-requested" | "waiting" | "failed" | "not-run";
|
|
85
|
+
message: string;
|
|
86
|
+
};
|
|
87
|
+
export declare function fullCheckGate(store: Store, runId: number, now: Date): FullCheckGate;
|
|
88
|
+
export declare const isQuickCommand: (command: VerifyCommand | null, repo: string) => boolean;
|
|
89
|
+
/** File a small task to write tests for a result's change. It is filed like any
|
|
90
|
+
* other task: nothing runs until a person approves it. */
|
|
91
|
+
export declare function fileAddTestsTask(store: Store, root: string | null, input: {
|
|
92
|
+
runId: number;
|
|
93
|
+
actor: string;
|
|
94
|
+
filedVia: string;
|
|
95
|
+
admittedRepos?: readonly string[];
|
|
96
|
+
}, now: Date): {
|
|
97
|
+
ok: true;
|
|
98
|
+
id: string;
|
|
99
|
+
existing: boolean;
|
|
100
|
+
} | Refusal;
|
|
101
|
+
export {};
|