toolroll 0.9.8 → 0.9.10

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 (85) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assignment-brief.d.ts +2 -0
  3. package/dist/assignment-brief.js +2 -1
  4. package/dist/assignment-presentation.js +3 -1
  5. package/dist/assignment-status.d.ts +1 -1
  6. package/dist/assignment-ui.js +5 -0
  7. package/dist/assignment.d.ts +9 -0
  8. package/dist/assignment.js +42 -8
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +18 -18
  11. package/dist/browser-crew.js +4 -0
  12. package/dist/browser-workspace.d.ts +40 -0
  13. package/dist/builder.d.ts +2 -0
  14. package/dist/builder.js +114 -8
  15. package/dist/chat-controls.d.ts +1 -1
  16. package/dist/chat-controls.js +1 -1
  17. package/dist/chat-delivery-state.d.ts +5 -0
  18. package/dist/chat-delivery.js +45 -8
  19. package/dist/chat-quiet.d.ts +10 -4
  20. package/dist/chat-quiet.js +87 -22
  21. package/dist/chat-voice.d.ts +68 -0
  22. package/dist/chat-voice.js +175 -0
  23. package/dist/check-progress.js +4 -3
  24. package/dist/checkout-cleanup.d.ts +40 -2
  25. package/dist/checkout-cleanup.js +129 -9
  26. package/dist/desktop-update.d.ts +52 -0
  27. package/dist/desktop-update.js +147 -5
  28. package/dist/discord-chat.js +1 -0
  29. package/dist/evidence.d.ts +6 -0
  30. package/dist/evidence.js +8 -0
  31. package/dist/first-run.d.ts +1 -1
  32. package/dist/first-run.js +7 -5
  33. package/dist/lead-context.d.ts +1 -1
  34. package/dist/lead-context.js +4 -2
  35. package/dist/lead-status.d.ts +10 -1
  36. package/dist/lead-status.js +21 -24
  37. package/dist/needs-you.d.ts +55 -0
  38. package/dist/needs-you.js +83 -0
  39. package/dist/operate.d.ts +2 -0
  40. package/dist/operate.js +122 -15
  41. package/dist/plane-review.js +4 -0
  42. package/dist/process-recovery-eligibility.d.ts +1 -0
  43. package/dist/project-delete.js +1 -0
  44. package/dist/project-learning.d.ts +3 -3
  45. package/dist/project-learning.js +28 -17
  46. package/dist/proof.d.ts +3 -1
  47. package/dist/proof.js +4 -2
  48. package/dist/releases.d.ts +9 -0
  49. package/dist/releases.js +30 -0
  50. package/dist/result-completion.d.ts +4 -0
  51. package/dist/result-completion.js +11 -0
  52. package/dist/review-context.d.ts +3 -0
  53. package/dist/review-context.js +45 -5
  54. package/dist/run-closeout.d.ts +89 -0
  55. package/dist/run-closeout.js +302 -0
  56. package/dist/serve.d.ts +15 -1
  57. package/dist/serve.js +129 -14
  58. package/dist/setup-guide.d.ts +2 -2
  59. package/dist/shared-deps.d.ts +94 -0
  60. package/dist/shared-deps.js +365 -0
  61. package/dist/slack-chat.js +2 -0
  62. package/dist/storage.js +2 -0
  63. package/dist/store.d.ts +60 -0
  64. package/dist/store.js +207 -30
  65. package/dist/task-status.d.ts +18 -4
  66. package/dist/task-status.js +35 -18
  67. package/dist/teams-chat.js +1 -1
  68. package/dist/telegram-progress.d.ts +6 -1
  69. package/dist/telegram-progress.js +13 -5
  70. package/dist/telegram-status.d.ts +2 -1
  71. package/dist/telegram-status.js +6 -5
  72. package/dist/telegram.d.ts +1 -1
  73. package/dist/telegram.js +93 -43
  74. package/dist/test-temp.d.ts +19 -0
  75. package/dist/test-temp.js +98 -0
  76. package/dist/toolroll-update-ui.js +7 -2
  77. package/dist/toolroll-update.d.ts +21 -1
  78. package/dist/toolroll-update.js +135 -5
  79. package/dist/work-index.d.ts +11 -0
  80. package/dist/work-index.js +103 -16
  81. package/dist/work-summary.d.ts +3 -1
  82. package/dist/workspace-ui.js +1 -1
  83. package/dist/worktree.d.ts +16 -0
  84. package/dist/worktree.js +47 -0
  85. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ /** How chat sounds: a teammate talking. One or two short sentences, outcome
2
+ * first, plain words, one action at most. Every chat (Telegram, Slack,
3
+ * Discord, Teams) takes its titles and its finished-work lines from here, so a
4
+ * task id, a "— revision" suffix or a person's own act never reaches a chat.
5
+ * The console keeps every exact name and fact. */
6
+ import type { TelegramTransport } from "./telegram.js";
7
+ import type { Store } from "./store.js";
8
+ /** The bot's display name in every chat app. */
9
+ export declare const BOT_NAME = "Toolroll";
10
+ /** Updates for one person landing within this window become one message, edited in place as it grows. */
11
+ export declare const BATCH_MS: number;
12
+ export declare const SHORT_TITLE_MAX = 60;
13
+ /** A short human summary of a task's title, at most 60 characters: no "— revision", and no task ids. Only the
14
+ * task's own id and tokens `isTaskId` names as real tasks are dropped; `node-20` and `utf-8` stay. */
15
+ export declare function shortTitle(title: string | null | undefined, taskId?: string | null, isTaskId?: (token: string) => boolean): string;
16
+ /** A task's title as every chat says it: short, with its own id and any other real task's id dropped. */
17
+ export declare function chatTitle(store: Pick<Store, "getTask">, taskId: string): string;
18
+ /** A title inside a sentence or list: "faster tests", but "API keys" and "iOS" keep their capitals. */
19
+ export declare function inSentence(summary: string): string;
20
+ /** The facts one line of a chat update is made of, read from the shared task status. */
21
+ export type FinishedFact = {
22
+ summary: string;
23
+ headline: string;
24
+ checks: "passed" | "failed" | "not-run" | "off" | null;
25
+ report: boolean;
26
+ completedBy: string | null;
27
+ /** An open pull request carries this result: the ask is "Merge it?" rather than "Mark it complete?". */
28
+ pullRequest?: boolean;
29
+ /** The lead did this (built it, or marked it complete), so the lead says "I". */
30
+ lead?: boolean;
31
+ /** Not a finished result but another update about the task: its own words when it stands alone, and a few
32
+ * words for a list ("has a plan to review"). */
33
+ update?: {
34
+ words: string;
35
+ phrase: string;
36
+ } | null;
37
+ };
38
+ /** The two buttons under a result ready for a person: the real next step, then a look first. */
39
+ export declare const READY_ACTIONS: {
40
+ readonly merge: "Merge";
41
+ readonly complete: "Mark complete";
42
+ readonly look: "Look first";
43
+ };
44
+ /** Whether this line asks for the result's next step, so it carries [Merge] or [Mark complete] and [Look first]. */
45
+ export declare function asksToFinish(fact: FinishedFact): boolean;
46
+ /** One update, said plainly: what happened, then what happens next. The lead says "I" for what it did. */
47
+ export declare function finishedLine(fact: FinishedFact): string;
48
+ /** Several updates in one message: "4 tasks finished: faster tests, cleanup, …" (or "3 updates: …" when not all of
49
+ * them are finished work), then what broke and what happens next. */
50
+ export declare function batchLine(facts: readonly FinishedFact[]): string;
51
+ /**
52
+ * Name the Telegram bot Toolroll. On pairing it always becomes Toolroll; on upgrade only a bot still called
53
+ * StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
54
+ * leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
55
+ */
56
+ export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade"): Promise<boolean>;
57
+ /** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
58
+ * "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
59
+ * task's short title when one is given, else "this task". */
60
+ export declare function chatText(text: string, tasks?: readonly (string | null | undefined | {
61
+ id: string;
62
+ title?: string;
63
+ })[]): string;
64
+ /** The one button a plain fact may carry: its label names where its machine-minted console path goes; nothing in
65
+ * it comes from the fact's text. */
66
+ export declare function factLinkLabel(path: string): string;
67
+ /** Whether the words already name this title as a whole phrase (so it need not be said twice). */
68
+ export declare function mentions(text: string, title: string): boolean;
@@ -0,0 +1,175 @@
1
+ import { phoneText } from "./telegram-status.js";
2
+ /** The bot's display name in every chat app. */
3
+ export const BOT_NAME = "Toolroll";
4
+ /** Updates for one person landing within this window become one message, edited in place as it grows. */
5
+ export const BATCH_MS = 2 * 60_000;
6
+ export const SHORT_TITLE_MAX = 60;
7
+ /** A token that could be an id: letters and digits joined by `-` or `_`, or with a digit in it (`release-099b`,
8
+ * `t42`). Words such as `node-20` and `utf-8` look the same, so a token goes only when it names a real task. */
9
+ const ID_SHAPED = /\(?(?<![\p{L}\p{N}._/-])[\p{L}\p{N}]+(?:[-_.][\p{L}\p{N}]+)*(?![\p{L}\p{N}_/-]|\.[\p{L}\p{N}])\)?/gu;
10
+ /** An id that could be mistaken for nothing else (`release-099b`, `fix_tax`, `t42`). A one-word id such as `tidy`
11
+ * is also a word, and prose keeps its words. */
12
+ const idLike = (id) => /[-_\d]/.test(id);
13
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ /** A short human summary of a task's title, at most 60 characters: no "— revision", and no task ids. Only the
15
+ * task's own id and tokens `isTaskId` names as real tasks are dropped; `node-20` and `utf-8` stay. */
16
+ export function shortTitle(title, taskId, isTaskId) {
17
+ let text = phoneText(title ?? "", 400);
18
+ // "— revision", "- revision 2", "(revision)": a revision is the same work to a person.
19
+ text = text.replace(/(?:(?:\s*[—–]+|\s+-+)\s*revision\b(?:\s*#?\d+)?|\s*[([]\s*revision(?:\s*#?\d+)?\s*[)\]])/giu, "");
20
+ text = text.replace(ID_SHAPED, token => {
21
+ const bare = token.replace(/^\(|\)$/g, "");
22
+ return idLike(bare) && (bare === taskId || isTaskId?.(bare) === true) ? " " : token;
23
+ });
24
+ text = text.replace(/\(\s*\)/g, " ").replace(/\s+/g, " ").replace(/^[\s·:,;—–-]+|[\s·:,;—–-]+$/gu, "").trim();
25
+ if (text === "") {
26
+ // A task filed with only its id as a title: the words, never the slug.
27
+ const words = (taskId ?? "").replace(/[-_]+/g, " ").trim();
28
+ return words === "" ? "A task" : shortTitle(words[0].toUpperCase() + words.slice(1));
29
+ }
30
+ if (text.length <= SHORT_TITLE_MAX)
31
+ return text;
32
+ const cut = text.slice(0, SHORT_TITLE_MAX - 1);
33
+ const space = cut.lastIndexOf(" ");
34
+ return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[\s·:,;—–-]+$/u, "")}…`;
35
+ }
36
+ /** A task's title as every chat says it: short, with its own id and any other real task's id dropped. */
37
+ export function chatTitle(store, taskId) {
38
+ return shortTitle(store.getTask(taskId)?.title, taskId, token => token !== taskId && store.getTask(token) !== null);
39
+ }
40
+ /** A title inside a sentence or list: "faster tests", but "API keys" and "iOS" keep their capitals. */
41
+ export function inSentence(summary) {
42
+ return /^\p{Lu}\p{Ll}/u.test(summary) ? summary[0].toLowerCase() + summary.slice(1) : summary;
43
+ }
44
+ /** The two buttons under a result ready for a person: the real next step, then a look first. */
45
+ export const READY_ACTIONS = { merge: "Merge", complete: "Mark complete", look: "Look first" };
46
+ /** Whether this line asks for the result's next step, so it carries [Merge] or [Mark complete] and [Look first]. */
47
+ export function asksToFinish(fact) {
48
+ return fact.update == null && fact.headline === "Ready for review" && !fact.report;
49
+ }
50
+ /** One update, said plainly: what happened, then what happens next. The lead says "I" for what it did. */
51
+ export function finishedLine(fact) {
52
+ if (fact.update != null)
53
+ return fact.update.words;
54
+ const name = fact.summary, lower = inSentence(fact.summary);
55
+ const ready = fact.lead === true ? `I finished ${lower}` : `${name} is ready`;
56
+ switch (fact.headline) {
57
+ case "Ready for review":
58
+ if (fact.report)
59
+ return fact.lead === true ? `I wrote up ${lower}. The report is ready to read.` : `${name}: the report is ready to read.`;
60
+ if (fact.checks === "passed")
61
+ return `${ready}. Your tests passed. ${fact.pullRequest === true ? "Merge it?" : "Mark it complete?"}`;
62
+ if (fact.checks === "off")
63
+ return `${ready}. Checks are off for this project, so look it over first.`;
64
+ return `${ready}, but no tests ran. Look it over first.`;
65
+ case "Failed":
66
+ if (fact.checks === "failed")
67
+ return fact.lead === true
68
+ ? `I built ${lower}, but its tests failed. It waits for you: retry or ask for changes.`
69
+ : `${name} is built, but its tests failed. It waits for you: retry or ask for changes.`;
70
+ return fact.lead === true
71
+ ? `I couldn't finish ${lower}. The work so far is kept; retry when you're ready.`
72
+ : `${name} stopped before it finished. The work so far is kept; retry when you're ready.`;
73
+ case "Complete":
74
+ if (fact.lead === true && fact.completedBy)
75
+ return `I marked ${lower} complete.`;
76
+ return fact.completedBy ? `${name} is done. ${fact.completedBy} marked it complete.` : `${name} is done.`;
77
+ case "Needs you":
78
+ return fact.lead === true ? `I need your decision on ${lower} before I can go on.` : `${name} needs your decision before it can continue.`;
79
+ default:
80
+ return `${name}: ${fact.headline.toLowerCase()}.`;
81
+ }
82
+ }
83
+ /** One task in a list of updates: "faster tests is ready", "cleanup failed". */
84
+ function phraseOf(fact) {
85
+ const name = inSentence(fact.summary);
86
+ if (fact.update != null)
87
+ return `${name} ${fact.update.phrase}`;
88
+ switch (fact.headline) {
89
+ case "Ready for review": return `${name} is ready`;
90
+ case "Failed": return `${name} failed`;
91
+ case "Complete": return `${name} is done`;
92
+ case "Needs you": return `${name} needs you`;
93
+ default: return `${name}: ${fact.headline.toLowerCase()}`;
94
+ }
95
+ }
96
+ /** Several updates in one message: "4 tasks finished: faster tests, cleanup, …" (or "3 updates: …" when not all of
97
+ * them are finished work), then what broke and what happens next. */
98
+ export function batchLine(facts) {
99
+ if (facts.length === 1)
100
+ return finishedLine(facts[0]);
101
+ const finishedOnly = facts.every(one => one.update == null);
102
+ const names = finishedOnly ? facts.map(one => inSentence(one.summary)) : facts.map(phraseOf);
103
+ const shown = names.slice(0, 3).join(", ");
104
+ const first = `${facts.length} ${finishedOnly ? "tasks finished" : "updates"}: ${shown}${names.length > 3 ? ", …" : "."}`;
105
+ // What happens next: anything that broke or needs a decision waits for the person.
106
+ const failed = facts.filter(one => one.headline === "Failed");
107
+ const needs = facts.filter(one => one.headline === "Needs you").length;
108
+ const broke = failed.length !== 1 ? `${failed.length} failed`
109
+ : failed[0].checks === "failed" ? `tests failed on ${inSentence(failed[0].summary)}` : `${inSentence(failed[0].summary)} stopped before it finished`;
110
+ const waits = [
111
+ ...(failed.length === 0 ? [] : [broke]),
112
+ ...(needs === 0 ? [] : [needs === facts.length ? "each needs your decision" : `${needs} ${needs === 1 ? "needs" : "need"} your decision`]),
113
+ ];
114
+ if (waits.length === 0)
115
+ return first;
116
+ const said = waits.join(", and ");
117
+ return `${first}\n${said[0].toUpperCase()}${said.slice(1)}; ${failed.length + needs === 1 ? "it waits" : "they wait"} for you.`;
118
+ }
119
+ /** The old product name a bot may still wear from before the rename. */
120
+ const OLD_NAME = /standing\s*-?\s*orders/i;
121
+ /**
122
+ * Name the Telegram bot Toolroll. On pairing it always becomes Toolroll; on upgrade only a bot still called
123
+ * StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
124
+ * leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
125
+ */
126
+ export async function nameTelegramBot(transport, when) {
127
+ try {
128
+ const current = await transport("getMyName", {});
129
+ const name = current.ok ? current.result?.name : undefined;
130
+ const shown = typeof name === "string" ? name : null;
131
+ if (shown === BOT_NAME)
132
+ return false;
133
+ if (when === "upgrade" && (shown === null || !OLD_NAME.test(shown)))
134
+ return false;
135
+ const set = await transport("setMyName", { name: BOT_NAME });
136
+ return set.ok;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }
142
+ /** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
143
+ * "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
144
+ * task's short title when one is given, else "this task". */
145
+ export function chatText(text, tasks = []) {
146
+ let out = text.replace(/(?:(?:[ \t]*[—–]+|[ \t]+-+)[ \t]*revision\b(?:[ \t]*#?\d+)?|[ \t]*[([][ \t]*revision(?:[ \t]*#?\d+)?[ \t]*[)\]])/giu, "");
147
+ out = out.replace(/\bReplaced by (?!a newer task)[^\s.,;:!?)]+/gu, "Replaced by a newer task");
148
+ for (const task of tasks) {
149
+ const id = typeof task === "string" ? task : task?.id;
150
+ if (!id || !idLike(id))
151
+ continue;
152
+ const title = typeof task === "object" && task !== null ? task.title : undefined;
153
+ out = out.replace(new RegExp(`[ \\t]*\\(${escape(id)}\\)|(?<![\\p{L}\\p{N}_/=-])${escape(id)}(?![\\p{L}\\p{N}_-])`, "gu"), match => match.trimStart().startsWith("(") ? "" : title ?? "this task");
154
+ }
155
+ return out;
156
+ }
157
+ /** The one button a plain fact may carry: its label names where its machine-minted console path goes; nothing in
158
+ * it comes from the fact's text. */
159
+ export function factLinkLabel(path) {
160
+ if (/^\/review\?result=[^&]+&run=\d+&tab=checks$/.test(path))
161
+ return "Inspect result";
162
+ if (/^\/t\/[^?#]+#merge$/.test(path))
163
+ return "Merge";
164
+ if (/^\/chat\?task=[^&]+&result=/.test(path))
165
+ return "Open result";
166
+ if (/^\/(?:chat\?task=|t\/)/.test(path))
167
+ return "Open task";
168
+ if (/^\/d\//.test(path))
169
+ return "Open decision";
170
+ return "Open console";
171
+ }
172
+ /** Whether the words already name this title as a whole phrase (so it need not be said twice). */
173
+ export function mentions(text, title) {
174
+ return new RegExp(`(?<![\\p{L}\\p{N}_-])${escape(title)}(?![\\p{L}\\p{N}_-])`, "u").test(text);
175
+ }
@@ -153,9 +153,10 @@ export class CheckProgressTracker {
153
153
  }
154
154
  return;
155
155
  }
156
- // scripts/e2e-parallel.mjs: "Running 7 groups at once: console, task, …", then every group's
157
- // lines as "[group] [app] …", then "✅ group 3.1 min" per group and "N of M groups passed …".
158
- const starting = /^Running ([0-9]+) groups at once: (.+)$/.exec(line);
156
+ // scripts/e2e-parallel.mjs: "Running 7 groups, at most 4 at once (12.0 GB available, about 400 MB each): console,
157
+ // task, …" (before: "Running 7 groups at once: …"), then every group's lines as "[group] [app] …", then
158
+ // "✅ group 3.1 min" per group and "N of M groups passed …".
159
+ const starting = /^Running ([0-9]+) groups(?:, at most [0-9]+)? at once(?: \([^)]*\))?: (.+)$/.exec(line);
159
160
  if (starting !== null) {
160
161
  const total = safeCount(starting[1]);
161
162
  const names = starting[2].split(",").map(one => one.trim()).filter(one => one !== "");
@@ -1,5 +1,5 @@
1
1
  import { type CheckoutCleanup } from "./storage.js";
2
- import type { Store } from "./store.js";
2
+ import type { Store, WorktreeRow } from "./store.js";
3
3
  import { type WorktreePool } from "./worktree.js";
4
4
  /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
5
5
  export declare const CANDIDATE_KEEP_MS: number;
@@ -36,12 +36,33 @@ type Status = {
36
36
  taskId: string;
37
37
  finishedAt: string | null;
38
38
  dueAt?: string;
39
+ atOnce?: boolean;
39
40
  };
41
+ /** The release a deploy installed last (staged-upgrades/<stage>/deployment.json at phase "deployed"): its commit and
42
+ * builder run. A deploy runs from the gate's checkout, so that checkout stays while it is what runs. */
43
+ export type Deployed = {
44
+ head: string | null;
45
+ run: number | null;
46
+ };
47
+ export declare function deployedRelease(stateDir: string | null): Deployed | null;
48
+ /**
49
+ * Which release a gate belongs to: a failed gate is re-filed as the same name plus a letter (`release-x`, then
50
+ * `release-xb`, `release-xc` …), so a gate whose name is another gate's in the same project plus one letter is that
51
+ * release's.
52
+ */
53
+ export declare function releaseOf(id: string, gates: ReadonlySet<string>): string;
40
54
  /** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
41
- export declare function taskStatuses(store: Store, now: Date): {
55
+ export declare function taskStatuses(store: Store, now: Date, deployed?: Deployed | null): {
42
56
  byRef: Map<number, Status>;
43
57
  byBranch: Map<string, Status>;
44
58
  };
59
+ /** The let-go checkouts whose task is complete or cancelled, and when it finished. Not a release candidate's that
60
+ * stays: a deploy runs from it. */
61
+ export declare function finishedCheckouts(store: Store, now: Date, repo?: string): {
62
+ row: WorktreeRow;
63
+ taskId: string | null;
64
+ finishedAt: string;
65
+ }[];
45
66
  /**
46
67
  * What a clean-up would remove now, how much space it would free, and what stays and why. `manual` is a clean-up by
47
68
  * hand (it doesn't wait for the setting); otherwise it is the worker's pass under the setting. Reads only.
@@ -71,6 +92,23 @@ export declare function cleanCheckouts(store: Store, pool: WorktreePool, now: ()
71
92
  repo?: string;
72
93
  only?: ReadonlySet<string>;
73
94
  }): Promise<CleanResult>;
95
+ export type SlimResult = {
96
+ slimmed: {
97
+ path: string;
98
+ taskId: string | null;
99
+ dropped: string[];
100
+ bytes: number;
101
+ }[];
102
+ };
103
+ /**
104
+ * A kept checkout (its result waiting for review, or with changes) drops its dependencies and build output once its
105
+ * run has ended; the next run's setup restores them (node_modules only goes where the project has a setup to restore
106
+ * it). Never one in use or a release candidate (a deploy installs from it). Each in the ledger.
107
+ */
108
+ export declare function slimKeptCheckouts(store: Store, pool: WorktreePool, now: () => Date, options: {
109
+ actor: string;
110
+ repo?: string;
111
+ }): Promise<SlimResult>;
74
112
  /** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
75
113
  export declare function discardCheckout(store: Store, pool: WorktreePool, path: string, now: Date, actor: string): Promise<{
76
114
  ok: true;
@@ -6,13 +6,18 @@
6
6
  * Only the working copy goes: its branch, and so every commit, stays, and a later lease of the branch makes a new one.
7
7
  *
8
8
  * Always kept: a checkout in use, one whose task isn't finished (on hold, failed, a revision under way), a result
9
- * still waiting for review, a release candidate for a week after it was done (a deploy installs from it), a checkout
10
- * with changes (files Toolroll wrote itself aside), and one with commits on no branch. A checkout no task names
11
- * (adopted after a crash) goes only in a clean-up by hand. Every removal is in the ledger.
9
+ * still waiting for review, a checkout with changes (files Toolroll wrote itself aside), and one with commits on no
10
+ * branch. A release candidate's checkout (a deploy installs from it) is kept only while it is the newest gate for its
11
+ * release (for a week after it was done) or the one deployed; a superseded or cancelled gate goes at once, whatever
12
+ * its task's state (staged releases keep the rollback copy). A checkout no task names (adopted after a crash) goes only
13
+ * in a clean-up by hand. Every removal is in the ledger.
14
+ *
15
+ * A kept checkout waiting for review or with changes drops its dependencies and build output once its run ends
16
+ * (`slimKeptCheckouts`); the next run's setup restores them.
12
17
  */
13
18
  import { createHash } from "node:crypto";
14
- import { existsSync } from "node:fs";
15
- import { basename } from "node:path";
19
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
20
+ import { basename, dirname, join } from "node:path";
16
21
  import { taskBranches } from "./names.js";
17
22
  import { COMPLETION_ACTION } from "./result-completion.js";
18
23
  import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
@@ -24,19 +29,80 @@ export const CANDIDATE_KEEP_MS = 7 * DAY_MS;
24
29
  export const AUTO_MAX = 20;
25
30
  /** Git inspections at once. */
26
31
  const INSPECT_AT_ONCE = 8;
32
+ export function deployedRelease(stateDir) {
33
+ if (stateDir === null)
34
+ return null;
35
+ const root = join(stateDir, "staged-upgrades");
36
+ let newest = null;
37
+ let names = [];
38
+ try {
39
+ names = readdirSync(root);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ for (const name of names) {
45
+ let journal;
46
+ try {
47
+ journal = JSON.parse(readFileSync(join(root, name, "deployment.json"), "utf8"));
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ if (journal["phase"] !== "deployed")
53
+ continue;
54
+ const at = typeof journal["deployedAt"] === "string" ? journal["deployedAt"] : typeof journal["updatedAt"] === "string" ? journal["updatedAt"] : "";
55
+ if (newest !== null && newest.at >= at)
56
+ continue;
57
+ newest = { at, head: typeof journal["candidate"] === "string" ? journal["candidate"] : null, run: Number.isSafeInteger(journal["builder"]) ? Number(journal["builder"]) : null };
58
+ }
59
+ return newest === null ? null : { head: newest.head, run: newest.run };
60
+ }
61
+ /** The folder beside the database (where staged releases live), or null for a database in memory. */
62
+ function stateDirOf(store) {
63
+ const main = store.handle.prepare("PRAGMA database_list").all().find(row => row["name"] === "main");
64
+ return typeof main?.["file"] === "string" && main["file"] !== "" ? dirname(main["file"]) : null;
65
+ }
66
+ /**
67
+ * Which release a gate belongs to: a failed gate is re-filed as the same name plus a letter (`release-x`, then
68
+ * `release-xb`, `release-xc` …), so a gate whose name is another gate's in the same project plus one letter is that
69
+ * release's.
70
+ */
71
+ export function releaseOf(id, gates) {
72
+ return /[b-z]$/.test(id) && gates.has(id.slice(0, -1)) ? id.slice(0, -1) : id;
73
+ }
27
74
  const STRENGTH = { "in use": 5, "task not finished": 4, "waiting for review": 3, "release candidate": 2 };
28
75
  const strength = (one) => one === undefined ? -1 : one.keep === null ? 0 : STRENGTH[one.keep] ?? 1;
29
76
  const stronger = (a, b) => strength(b) > strength(a) || (strength(b) === 0 && strength(a) === 0 && (b.finishedAt ?? "") > (a.finishedAt ?? "")) ? b : a;
30
77
  /** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
31
- export function taskStatuses(store, now) {
78
+ export function taskStatuses(store, now, deployed) {
32
79
  const db = store.handle;
33
80
  const refs = db.prepare(`SELECT r.id AS ref, r.external_id AS id, r.revision_of AS revision_of, t.state AS state, t.updated_at AS updated_at,
34
81
  EXISTS (SELECT 1 FROM hold h WHERE h.task_ref = r.id) AS held,
35
82
  EXISTS (SELECT 1 FROM run live WHERE live.task_ref = r.id AND live.finished_at IS NULL) AS live,
36
- EXISTS (SELECT 1 FROM task_scope s WHERE s.task_id = r.external_id AND s.candidate IS NOT NULL) AS candidate
83
+ (SELECT MAX(s.candidate) FROM task_scope s WHERE s.task_id = r.external_id) AS candidate, r.repo AS repo
37
84
  FROM task_ref r LEFT JOIN task t ON t.id = r.external_id AND r.backend = 'built-in'`).all();
38
85
  const runs = db.prepare("SELECT id, task_ref, branch FROM run").all();
39
86
  const refOfRun = new Map(runs.map(row => [Number(row["id"]), Number(row["task_ref"])]));
87
+ // Release candidates: per project and release, the newest gate not cancelled; and the one deployed.
88
+ const gates = refs.filter(row => row["candidate"] !== null);
89
+ const gateIds = new Map();
90
+ for (const row of gates) {
91
+ const repo = String(row["repo"]);
92
+ gateIds.set(repo, (gateIds.get(repo) ?? new Set()).add(String(row["id"])));
93
+ }
94
+ const releaseKey = (row) => `${String(row["repo"])}\u0000${releaseOf(String(row["id"]), gateIds.get(String(row["repo"])))}`;
95
+ const newestGate = new Map();
96
+ for (const row of gates) {
97
+ if (row["state"] === "cancelled")
98
+ continue;
99
+ const key = releaseKey(row);
100
+ newestGate.set(key, Math.max(newestGate.get(key) ?? -1, Number(row["ref"])));
101
+ }
102
+ const live = deployed === undefined ? deployedRelease(stateDirOf(store)) : deployed;
103
+ const deployedRef = live === null ? undefined : live.run !== null ? refOfRun.get(live.run) : undefined;
104
+ const isDeployed = (row) => live !== null &&
105
+ (Number(row["ref"]) === deployedRef || (live.head !== null && String(row["candidate"]) === live.head));
40
106
  // The completion names its family's root; its run names the result it completes.
41
107
  const completed = new Map();
42
108
  const completedRef = new Map();
@@ -59,16 +125,22 @@ export function taskStatuses(store, now) {
59
125
  const id = String(row["id"]);
60
126
  const state = row["state"] === null ? null : String(row["state"]);
61
127
  const updated = row["updated_at"] === null ? null : String(row["updated_at"]);
128
+ const gate = row["candidate"] !== null;
62
129
  let status;
63
130
  if (Number(row["live"]) === 1)
64
131
  status = { keep: "in use", taskId: id, finishedAt: null };
132
+ else if (gate && isDeployed(row))
133
+ status = { keep: "release candidate", taskId: id, finishedAt: updated };
134
+ // A superseded or cancelled gate goes at once, whatever its task's state: a newer gate (or none) is the release.
135
+ else if (gate && (state === "cancelled" || newestGate.get(releaseKey(row)) !== ref))
136
+ status = { keep: null, taskId: id, finishedAt: updated, atOnce: true };
65
137
  else if (state === null || Number(row["held"]) === 1 || (state !== "done" && state !== "cancelled"))
66
138
  status = { keep: "task not finished", taskId: id, finishedAt: null };
67
139
  else if (state === "cancelled")
68
140
  status = { keep: null, taskId: id, finishedAt: updated };
69
141
  else {
70
142
  const completedAt = [completed.get(id), completedRef.get(ref)].filter((one) => one !== undefined).sort().at(-1) ?? null;
71
- if (Number(row["candidate"]) === 1) {
143
+ if (gate) {
72
144
  const since = completedAt ?? updated ?? now.toISOString();
73
145
  const until = new Date(Date.parse(since) + CANDIDATE_KEEP_MS).toISOString();
74
146
  status = until > now.toISOString() ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since };
@@ -119,6 +191,17 @@ function statusOf(row, statuses) {
119
191
  }
120
192
  return found;
121
193
  }
194
+ /** The let-go checkouts whose task is complete or cancelled, and when it finished. Not a release candidate's that
195
+ * stays: a deploy runs from it. */
196
+ export function finishedCheckouts(store, now, repo) {
197
+ const statuses = taskStatuses(store, now);
198
+ return store.listWorktrees().filter(row => row.releasedAt !== null && row.runner === null && (repo === undefined || row.repo === repo)).flatMap(row => {
199
+ const status = statusOf(row, statuses);
200
+ if (status === undefined || status.finishedAt === null || status.keep !== null)
201
+ return [];
202
+ return [{ row, taskId: status.taskId, finishedAt: status.finishedAt }];
203
+ });
204
+ }
122
205
  /** Why a checkout stays before anyone looks at its files, or null: it may go (as far as its task and the setting say). */
123
206
  function keptFor(pool, row, statuses, now, cleanup) {
124
207
  const status = statusOf(row, statuses);
@@ -136,6 +219,8 @@ function keptFor(pool, row, statuses, now, cleanup) {
136
219
  return { why: null, taskId };
137
220
  if (cleanup === "never")
138
221
  return { why: "cleanup is off", taskId };
222
+ if (status.atOnce === true)
223
+ return { why: null, taskId };
139
224
  const days = CLEANUP_CHOICES.find(one => one.value === cleanup).days;
140
225
  const since = [status.finishedAt, row.releasedAt].filter((one) => one !== null).sort().at(-1);
141
226
  const dueAt = new Date(Date.parse(since) + days * DAY_MS).toISOString();
@@ -215,6 +300,41 @@ export async function cleanCheckouts(store, pool, now, options) {
215
300
  }
216
301
  return { removed, kept: pruned.kept, freed: removed.reduce((sum, one) => sum + one.bytes, 0) };
217
302
  }
303
+ /** Checkouts this process already looked at since they were let go (path and release time): looked at once. */
304
+ const looked = new Set();
305
+ /**
306
+ * A kept checkout (its result waiting for review, or with changes) drops its dependencies and build output once its
307
+ * run has ended; the next run's setup restores them (node_modules only goes where the project has a setup to restore
308
+ * it). Never one in use or a release candidate (a deploy installs from it). Each in the ledger.
309
+ */
310
+ export async function slimKeptCheckouts(store, pool, now, options) {
311
+ const rows = store.listWorktrees().filter(row => row.releasedAt !== null && row.runner === null && (options.repo === undefined || row.repo === options.repo)
312
+ && !looked.has(`${row.path}\u0000${row.releasedAt}`) && existsSync(row.path));
313
+ const slimmed = [];
314
+ if (rows.length === 0)
315
+ return { slimmed };
316
+ const keptAs = (row) => keptFor(pool, row, taskStatuses(store, now()), now(), "manual");
317
+ const statuses = taskStatuses(store, now());
318
+ for (const row of rows) {
319
+ const kept = keptFor(pool, row, statuses, now(), "manual");
320
+ if (kept.why === "in use")
321
+ continue;
322
+ looked.add(`${row.path}\u0000${row.releasedAt}`);
323
+ if (kept.why === "release candidate")
324
+ continue;
325
+ if (kept.why !== "waiting for review" && await pool.inspect(row.path) !== "has changes")
326
+ continue;
327
+ const wanted = (fresh) => { const why = keptAs(fresh).why; return why !== "in use" && why !== "release candidate"; };
328
+ const done = await pool.slim(row, wanted, store.liveWorktreeSetup(row.repo) !== null);
329
+ if (!done.ok || done.dropped.length === 0)
330
+ continue;
331
+ const bytes = done.bytes;
332
+ slimmed.push({ path: row.path, taskId: kept.taskId, dropped: done.dropped, bytes });
333
+ store.recordAction({ at: now().toISOString(), actor: options.actor, repo: row.repo, taskId: kept.taskId, runId: null, action: "checkout slimmed", outcome: "removed", source: "work",
334
+ detail: `${basename(row.path)}: dropped ${done.dropped.join(", ")}${bytes > 0 ? ` (about ${bytesWords(bytes)})` : ""}; the next run's setup restores them` });
335
+ }
336
+ return { slimmed };
337
+ }
218
338
  /** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
219
339
  export async function discardCheckout(store, pool, path, now, actor) {
220
340
  const bytes = existsSync(path) ? diskBytes([path]).get(path) ?? 0 : 0;
@@ -233,7 +353,7 @@ export function whyWords(item) {
233
353
  case "in use": return "in use";
234
354
  case "task not finished": return "its task isn't finished";
235
355
  case "waiting for review": return "its result is waiting for review";
236
- case "release candidate": return `release candidate, kept until ${item.dueAt?.slice(0, 10) ?? "a week after"}`;
356
+ case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept until ${item.dueAt.slice(0, 10)}`;
237
357
  case "has changes": return "has changes";
238
358
  case "has commits": return "has commits on no branch";
239
359
  case "unreadable": return "git couldn't read it";
@@ -27,6 +27,10 @@ export type UpdateJournal = {
27
27
  serviceInterrupted?: boolean;
28
28
  replacementOccurred?: boolean;
29
29
  retryableRecovery?: boolean;
30
+ /** The finished run the update is waiting on (or stopped waiting on). Absent while it waits on ordinary work. */
31
+ waiting?: LingeringRun & {
32
+ since: string;
33
+ };
30
34
  };
31
35
  type ServiceState = {
32
36
  state: string;
@@ -39,6 +43,8 @@ export type UpdateHooks = {
39
43
  healthy?: (bundle: DesktopBundle, journal: UpdateJournal, since: string) => Promise<boolean>;
40
44
  swap?: (journal: UpdateJournal) => Promise<void>;
41
45
  sleep?: (ms: number) => Promise<void>;
46
+ /** The clock the drain's two-minute limit reads (tests). */
47
+ now?: () => Date;
42
48
  healthTimeoutMs?: number;
43
49
  /** Fault injection for state-machine tests, never selectable by a CLI flag. */
44
50
  checkpoint?: (phase: Phase) => void;
@@ -48,7 +54,53 @@ export declare const updateTerminal: (phase: string) => boolean;
48
54
  /** Durable receipts are private and independent of the app being replaced. */
49
55
  export declare function durableJson(file: string, value: unknown): void;
50
56
  export declare function readUpdateJournal(stateDir: string, retainedReceipt?: string): UpdateJournal | null;
57
+ /** A finished run whose end Toolroll cannot show yet: in plain words, with the one command that clears it when a
58
+ * person can. */
59
+ export type LingeringRun = {
60
+ run: number;
61
+ on: string;
62
+ action: string | null;
63
+ };
64
+ /** How long an update waits on a finished run it cannot show has ended before it stops waiting and says why. */
65
+ export declare const LINGERING_LIMIT_MS = 120000;
66
+ export declare function lingeringRun(db: DatabaseSync): LingeringRun | null;
51
67
  export declare function lingeringWork(db: DatabaseSync): string | null;
68
+ export declare const lingeringWords: (l: LingeringRun) => string;
69
+ /** What an update says when it stops waiting on `l`: what is in the way, the command, and that new work resumed. */
70
+ export declare const stoppedWaitingWords: (l: LingeringRun) => string;
71
+ /** The next step once an update has stopped on `l`. */
72
+ export declare const clearWords: (l: LingeringRun) => string;
73
+ /** An update that is waiting or stopped, what on, and the one action that clears it: for `toolroll status` and the
74
+ * console. `app`: the desktop app's update rather than `toolroll update`. */
75
+ export type UpdateWaiting = {
76
+ app: boolean;
77
+ version: string;
78
+ stopped: boolean;
79
+ run: number | null;
80
+ on: string;
81
+ action: string | null;
82
+ words: string;
83
+ short: string;
84
+ };
85
+ export declare function updateWaitingWords(o: {
86
+ app: boolean;
87
+ version: string;
88
+ stopped: boolean;
89
+ lingering: LingeringRun | null;
90
+ }): UpdateWaiting;
91
+ /** An app updater that ended while letting work finish, with no automatic recovery coming to resume it, leaves new
92
+ * work paused with nobody left to lift the pause. Nothing was stopped or swapped yet, so the pause is lifted here and
93
+ * the update says it stopped. Returns what it said, or null. */
94
+ export declare function releaseStalledDesktopUpdate(stateDir: string): string | null;
95
+ /** The desktop app's update, when it is waiting on work in `databaseFile`'s installation, or stopped on a run still in
96
+ * the way. An app updater that ended mid-drain is released first, as `toolroll update`'s is. */
97
+ export declare function desktopUpdateWaitingOf(databaseFile: string, inTheWay: (run: number) => boolean, stateDirs?: readonly string[]): UpdateWaiting | null;
98
+ /** The same settling reconcile the background worker runs: a finished run's leftover record of a process that never
99
+ * started settles once the run's process groups are proven gone. A record nothing can prove either way stays. */
100
+ export declare function settleLeftoverRecords(db: DatabaseSync, now: Date): number;
101
+ /** Store.stopQuiescenceProblem's reasons, said for a person: no internal terms, and `toolroll run settle` only where it
102
+ * can clear the record (it refuses while anything of the run is alive). */
103
+ export declare function plainLingering(fallbackRun: number, problem: string): LingeringRun;
52
104
  export declare function previewDesktopUpdate(stateDir: string, installed: string, candidate: string, label: string, hooks?: UpdateHooks): Promise<{
53
105
  old: DesktopBundle;
54
106
  next: DesktopBundle;