toolroll 0.9.5 → 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.
Files changed (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +1 -0
  5. package/dist/assignment-presentation.d.ts +13 -2
  6. package/dist/assignment-presentation.js +22 -9
  7. package/dist/assignment-ui.d.ts +10 -15
  8. package/dist/assignment-ui.js +19 -18
  9. package/dist/assignment.d.ts +9 -0
  10. package/dist/assignment.js +62 -13
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +29 -24
  13. package/dist/browser-workspace.d.ts +120 -12
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-acceptance.js +2 -2
  17. package/dist/chat-actions.js +2 -1
  18. package/dist/chat-delivery.js +2 -0
  19. package/dist/chat-quiet.js +30 -19
  20. package/dist/chat-task-actions.d.ts +13 -0
  21. package/dist/chat-task-actions.js +19 -1
  22. package/dist/check-levels-ui.d.ts +21 -0
  23. package/dist/check-levels-ui.js +70 -0
  24. package/dist/check-levels.d.ts +67 -0
  25. package/dist/check-levels.js +171 -0
  26. package/dist/claim.d.ts +22 -0
  27. package/dist/claim.js +43 -0
  28. package/dist/cli.d.ts +1 -1
  29. package/dist/cli.js +4 -0
  30. package/dist/demo-chat.d.ts +0 -5
  31. package/dist/demo-chat.js +14 -3
  32. package/dist/dispatch.js +7 -2
  33. package/dist/dispose.d.ts +8 -0
  34. package/dist/dispose.js +7 -1
  35. package/dist/export.js +3 -2
  36. package/dist/first-run.d.ts +34 -0
  37. package/dist/first-run.js +37 -1
  38. package/dist/flow-gallery-ui.d.ts +30 -0
  39. package/dist/flow-gallery-ui.js +85 -0
  40. package/dist/flow-gallery.d.ts +111 -0
  41. package/dist/flow-gallery.js +445 -0
  42. package/dist/flow-share.d.ts +102 -0
  43. package/dist/flow-share.js +614 -0
  44. package/dist/flow-starters.d.ts +2 -0
  45. package/dist/flow-starters.js +1 -1
  46. package/dist/flow-steps.js +7 -0
  47. package/dist/flow-triggers.js +2 -0
  48. package/dist/flows-cli.d.ts +56 -0
  49. package/dist/flows-cli.js +109 -3
  50. package/dist/flows-ui.d.ts +15 -1
  51. package/dist/flows-ui.js +31 -6
  52. package/dist/guides.js +27 -2
  53. package/dist/lead-context.js +6 -2
  54. package/dist/lead-status.d.ts +7 -0
  55. package/dist/lead-status.js +11 -2
  56. package/dist/mate-doors.d.ts +0 -15
  57. package/dist/mate-doors.js +5 -1
  58. package/dist/mate-tools.js +9 -4
  59. package/dist/models-ui.js +1 -1
  60. package/dist/operate.d.ts +4 -1
  61. package/dist/operate.js +601 -85
  62. package/dist/project-concurrency.d.ts +62 -0
  63. package/dist/project-concurrency.js +116 -0
  64. package/dist/project-delete-ui.d.ts +7 -0
  65. package/dist/project-delete-ui.js +19 -2
  66. package/dist/project-delete.js +4 -0
  67. package/dist/pull-request-flow.d.ts +9 -0
  68. package/dist/pull-request-flow.js +21 -3
  69. package/dist/result-follow-ups.d.ts +101 -0
  70. package/dist/result-follow-ups.js +333 -0
  71. package/dist/scope.js +11 -0
  72. package/dist/serve.d.ts +211 -2
  73. package/dist/serve.js +1070 -204
  74. package/dist/store.d.ts +76 -3
  75. package/dist/store.js +311 -22
  76. package/dist/surface.js +31 -8
  77. package/dist/tailnet.d.ts +11 -0
  78. package/dist/tailnet.js +47 -0
  79. package/dist/task-outcome-cli.js +2 -2
  80. package/dist/task-status.d.ts +186 -0
  81. package/dist/task-status.js +335 -0
  82. package/dist/team-cli.js +3 -0
  83. package/dist/telegram-progress.js +62 -100
  84. package/dist/telegram-status.js +21 -8
  85. package/dist/verification-evidence.js +21 -1
  86. package/dist/webhooks.js +5 -0
  87. package/dist/work-index.js +60 -11
  88. package/dist/work-summary.d.ts +0 -4
  89. package/dist/work-summary.js +2 -0
  90. package/dist/workspace-ui.d.ts +10 -0
  91. package/dist/workspace-ui.js +33 -2
  92. 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
@@ -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 base = { runId, prNumber: publication.prNumber, prUrl: publication.prUrl, mergeCommit: follow.mergeCommit || null, mergeMethod: method, canMerge: false, revisionTask: follow.revisionTask };
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 {};