toolroll 0.9.7 → 0.9.9

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 (97) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/assignment-adapters.d.ts +1 -0
  3. package/dist/assignment-adapters.js +2 -0
  4. package/dist/assignment-brief.d.ts +2 -0
  5. package/dist/assignment-brief.js +2 -1
  6. package/dist/assignment-presentation.js +3 -1
  7. package/dist/assignment-status.d.ts +1 -1
  8. package/dist/assignment-ui.js +5 -0
  9. package/dist/assignment.d.ts +15 -0
  10. package/dist/assignment.js +56 -9
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +18 -18
  13. package/dist/browser-crew.js +4 -0
  14. package/dist/browser-workspace.d.ts +40 -0
  15. package/dist/build-review.d.ts +38 -0
  16. package/dist/build-review.js +341 -0
  17. package/dist/builder.d.ts +2 -0
  18. package/dist/builder.js +114 -8
  19. package/dist/chat-controls.d.ts +1 -1
  20. package/dist/chat-controls.js +1 -1
  21. package/dist/check-levels-ui.d.ts +5 -0
  22. package/dist/check-levels-ui.js +16 -2
  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/cli.js +2 -0
  27. package/dist/desktop-update.d.ts +52 -0
  28. package/dist/desktop-update.js +147 -5
  29. package/dist/dispatch.js +2 -1
  30. package/dist/dispose.d.ts +4 -3
  31. package/dist/dispose.js +12 -4
  32. package/dist/evidence.d.ts +6 -0
  33. package/dist/evidence.js +8 -0
  34. package/dist/exec.d.ts +5 -0
  35. package/dist/exec.js +30 -4
  36. package/dist/first-run.d.ts +1 -1
  37. package/dist/first-run.js +8 -6
  38. package/dist/invoke.js +5 -0
  39. package/dist/lead-context.d.ts +1 -1
  40. package/dist/lead-context.js +4 -2
  41. package/dist/lead-status.d.ts +10 -1
  42. package/dist/lead-status.js +21 -24
  43. package/dist/modes.d.ts +3 -1
  44. package/dist/modes.js +3 -1
  45. package/dist/needs-you.d.ts +55 -0
  46. package/dist/needs-you.js +83 -0
  47. package/dist/operate.d.ts +2 -0
  48. package/dist/operate.js +231 -36
  49. package/dist/plane-review.js +4 -0
  50. package/dist/process-custody.js +8 -0
  51. package/dist/process-recovery-eligibility.d.ts +1 -0
  52. package/dist/project-learning.d.ts +3 -3
  53. package/dist/project-learning.js +28 -17
  54. package/dist/proof.d.ts +3 -1
  55. package/dist/proof.js +4 -2
  56. package/dist/provider.js +16 -0
  57. package/dist/result-completion.d.ts +4 -0
  58. package/dist/result-completion.js +11 -0
  59. package/dist/retention-ui.d.ts +3 -1
  60. package/dist/retention-ui.js +4 -4
  61. package/dist/retention.d.ts +13 -6
  62. package/dist/retention.js +50 -12
  63. package/dist/review-context.d.ts +3 -0
  64. package/dist/review-context.js +45 -5
  65. package/dist/review-switch.d.ts +41 -0
  66. package/dist/review-switch.js +88 -0
  67. package/dist/reviewer.d.ts +24 -2
  68. package/dist/reviewer.js +49 -1
  69. package/dist/run-closeout.d.ts +89 -0
  70. package/dist/run-closeout.js +302 -0
  71. package/dist/serve.d.ts +15 -1
  72. package/dist/serve.js +158 -18
  73. package/dist/setup-guide.d.ts +2 -2
  74. package/dist/shared-deps.d.ts +94 -0
  75. package/dist/shared-deps.js +365 -0
  76. package/dist/storage.js +2 -0
  77. package/dist/store.d.ts +72 -1
  78. package/dist/store.js +258 -31
  79. package/dist/surface.js +5 -1
  80. package/dist/task-status.d.ts +22 -5
  81. package/dist/task-status.js +45 -24
  82. package/dist/telegram-progress.js +2 -1
  83. package/dist/telegram-status.js +3 -3
  84. package/dist/test-temp.d.ts +19 -0
  85. package/dist/test-temp.js +98 -0
  86. package/dist/toolroll-update-ui.js +7 -2
  87. package/dist/toolroll-update.d.ts +21 -1
  88. package/dist/toolroll-update.js +135 -5
  89. package/dist/work-index.d.ts +11 -0
  90. package/dist/work-index.js +103 -16
  91. package/dist/work-summary.d.ts +3 -1
  92. package/dist/work-summary.js +2 -1
  93. package/dist/workspace-ui.d.ts +3 -1
  94. package/dist/workspace-ui.js +10 -9
  95. package/dist/worktree.d.ts +16 -0
  96. package/dist/worktree.js +47 -0
  97. package/package.json +1 -1
@@ -1,9 +1,11 @@
1
+ import { CODE_NEED, NEEDS, needSentence, waitSentence } from "./needs-you.js";
1
2
  /** A cancelled task with a successor reads "Replaced by <id>" everywhere, never "Cancelled". */
2
3
  export const replacedWords = (successor) => `Replaced by ${successor}`;
3
4
  const REPLACED = /^Replaced by \S+/;
4
- export const HEADLINES = ["Queued", "Planning", "Needs you", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
5
+ /** Waiting: a reason no person can act on, worded as what it waits for (needs-you.ts). */
6
+ export const HEADLINES = ["Queued", "Planning", "Needs you", "Waiting", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
5
7
  export const HEADLINE_TONE = {
6
- Queued: "neutral", Planning: "live", "Needs you": "attention", Building: "live",
8
+ Queued: "neutral", Planning: "live", "Needs you": "attention", Waiting: "neutral", Building: "live",
7
9
  "Ready for review": "ready", Complete: "success", Failed: "danger", Stopped: "neutral",
8
10
  };
9
11
  const short = (sha) => sha !== null && /^[a-f0-9]{7,40}$/.test(sha) ? sha.slice(0, 7) : null;
@@ -12,8 +14,10 @@ export function headlineOf(facts) {
12
14
  case "queued": return "Queued";
13
15
  case "planning": return "Planning";
14
16
  case "needs-you": return "Needs you";
17
+ case "waiting": return "Waiting";
15
18
  case "building":
16
- case "checking": return "Building";
19
+ case "checking":
20
+ case "reviewing": return "Building";
17
21
  case "finished":
18
22
  if (facts.report)
19
23
  return "Ready for review";
@@ -32,11 +36,11 @@ function sentenceOf(headline, facts) {
32
36
  switch (headline) {
33
37
  case "Queued": return reason ?? "Waiting for a worker.";
34
38
  case "Planning": return reason ?? "The lead is writing the plan.";
35
- // An approval always reads the same way; the plan itself names the work.
36
- case "Needs you": return facts.need === "approval" ? "Review the plan and approve it to start." : reason ?? (facts.need === "answer" ? "Answer the question so the work can continue."
37
- : facts.need === "sign-in" ? "Sign in again; the task starts on its own after."
38
- : "Something needs your decision before the work can continue.");
39
- case "Building": return facts.stage === "checking" ? "Checks are running on the change." : reason ?? "An agent is working on it.";
39
+ // One plain sentence per need (needs-you.ts); a recorded reason only where the need keeps one.
40
+ case "Needs you": return needSentence(facts.need ?? "other", reason, facts.needContext);
41
+ case "Waiting": return waitSentence(facts.wait, reason, facts.needContext);
42
+ case "Building": return facts.stage === "checking" ? "Checks are running on the change."
43
+ : facts.stage === "reviewing" ? "Reviewing the change before it reaches you." : reason ?? "An agent is working on it.";
40
44
  case "Ready for review":
41
45
  if (facts.report)
42
46
  return "The report is ready to read. Read it, then mark it complete.";
@@ -131,8 +135,12 @@ function detailsOf(headline, facts) {
131
135
  }
132
136
  export function taskStatusOf(facts) {
133
137
  const headline = headlineOf(facts);
138
+ // Every Needs you carries the one action that resolves it, worded the same everywhere.
139
+ const need = headline === "Needs you" ? { key: facts.need ?? "other", action: NEEDS[facts.need ?? "other"].action } : null;
140
+ const ownWords = need !== null && (need.key === "other" || need.key === "review-result") && facts.action != null;
141
+ const primaryAction = need === null || ownWords ? facts.action ?? null : { label: need.action.label, href: facts.action?.href ?? null };
134
142
  return { headline, tone: HEADLINE_TONE[headline], sentence: sentenceOf(headline, facts), details: detailsOf(headline, facts),
135
- primaryAction: facts.action ?? null, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== "") };
143
+ primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need };
136
144
  }
137
145
  // ---- reading the existing projections ----------------------------------------
138
146
  /** Every dispatch or work-index code, as the stage it means. Unknown codes that
@@ -140,10 +148,11 @@ export function taskStatusOf(facts) {
140
148
  const CODE_STAGE = {
141
149
  queued: ["queued"], ready: ["queued"], "worker-at-capacity": ["queued"], "retry-scheduled": ["queued"], updating: ["queued"],
142
150
  "waiting-dependency": ["queued"], "scouting-ready": ["queued"], "planning-ready": ["queued"], "provider-quota": ["queued"], "planner-source": ["queued"],
143
- running: ["building"], reviewing: ["building"], "review-pending": ["building"],
151
+ running: ["building"], reviewing: ["reviewing"], "review-pending": ["building"],
144
152
  "signed-out": ["needs-you", "sign-in"],
145
153
  "needs-approval": ["needs-you", "approval"],
146
154
  "waiting-decision": ["needs-you", "answer"], "decision-queue": ["needs-you", "answer"],
155
+ "build-stopping": ["waiting"], "other-computer": ["waiting"],
147
156
  stopping: ["stopped"], stopped: ["stopped"], paused: ["stopped"], cancelled: ["stopped"],
148
157
  failed: ["failed"], "waiting-incident": ["failed"], "checks-failed": ["failed"],
149
158
  "ready-to-check": ["finished"], "ready-to-review": ["finished"], "report-ready": ["finished"], "no-change": ["finished"],
@@ -154,7 +163,7 @@ const CODE_STAGE = {
154
163
  * building reason is scheduler wording, and the default sentence is plainer. */
155
164
  const QUEUED_REASONS = new Set(["worker-at-capacity", "waiting-dependency", "retry-scheduled", "updating", "planning-ready", "scouting-ready", "provider-quota"]);
156
165
  export function plainReasonOf(stage, code, detail) {
157
- if (stage === "building" || stage === "checking" || stage === "planning")
166
+ if (stage === "building" || stage === "checking" || stage === "reviewing" || stage === "planning")
158
167
  return null;
159
168
  if (stage === "queued")
160
169
  return QUEUED_REASONS.has(code) ? detail ?? null : null;
@@ -164,11 +173,11 @@ export function stageOfCode(code, options = {}) {
164
173
  if (code === "running" && options.planning)
165
174
  return { stage: "planning" };
166
175
  if (code === "held")
167
- return options.operatorHold === false ? { stage: "needs-you", need: "other" } : { stage: "stopped" };
176
+ return options.operatorHold === false ? { stage: "needs-you", need: "hold" } : { stage: "stopped" };
168
177
  const known = CODE_STAGE[code];
169
178
  if (known !== undefined)
170
179
  return known[1] === undefined ? { stage: known[0] } : { stage: known[0], need: known[1] };
171
- return options.needsPerson === false ? { stage: "queued" } : { stage: "needs-you", need: "other" };
180
+ return options.needsPerson === false ? { stage: "queued" } : { stage: "needs-you", need: CODE_NEED[code] ?? "other" };
172
181
  }
173
182
  /** The pull request as the task page's follower reports it, or the bare publication row. */
174
183
  export function pullRequestFactOf(publication) {
@@ -195,11 +204,15 @@ export function pullRequestFactOf(publication) {
195
204
  /** The assignment's stage. The work status, when the caller read one, names
196
205
  * the exact reason (a running planner, a pause, a failed attempt, a sign-in). */
197
206
  export function assignmentStageOf(assignment, work, planning = false) {
207
+ // The assignment's own reading (a build Toolroll can't confirm stopped) comes first.
208
+ const own = assignment.need ?? null;
209
+ if (own !== null && (assignment.state === "working" || assignment.state === "needs-decision"))
210
+ return "wait" in own ? { stage: "waiting", wait: own.wait } : { stage: "needs-you", need: own.key };
198
211
  switch (assignment.state) {
199
212
  case "cancelled": return { stage: "stopped" };
200
213
  case "complete": return { stage: "complete" };
201
214
  case "ready-to-check": return { stage: "finished" };
202
- case "checking": return { stage: "checking" };
215
+ case "checking": return { stage: assignment.review?.state === "pending" ? "reviewing" : "checking" };
203
216
  case "working": {
204
217
  const token = work?.token ?? "running";
205
218
  const read = stageOfCode(token, { needsPerson: work?.views?.includes("needs-you") ?? false, planning });
@@ -209,8 +222,10 @@ export function assignmentStageOf(assignment, work, planning = false) {
209
222
  const action = assignment.primaryAction?.code;
210
223
  if (action === "approve-scope")
211
224
  return { stage: "needs-you", need: "approval" };
212
- if (action === "answer-decision" || action === "inspect-decisions")
225
+ if (action === "answer-decision")
213
226
  return { stage: "needs-you", need: "answer" };
227
+ if (action === "inspect-decisions")
228
+ return { stage: "needs-you", need: "questions" };
214
229
  const token = work?.token ?? "";
215
230
  // A failed attempt is Failed. A saved result that still needs a decision
216
231
  // (its scope, a hold, a process exit) is Needs you, even if its checks
@@ -218,7 +233,10 @@ export function assignmentStageOf(assignment, work, planning = false) {
218
233
  if (token === "failed" || token === "waiting-incident")
219
234
  return { stage: "failed" };
220
235
  const read = stageOfCode(token, { needsPerson: true, operatorHold: action === "unhold" });
221
- return read.stage === "stopped" ? read : { stage: "needs-you", need: read.need ?? "other" };
236
+ if (read.stage === "stopped" || read.stage === "waiting")
237
+ return read;
238
+ // A saved result that isn't ready keeps its own reading; a task's dispatch reason names its need.
239
+ return { stage: "needs-you", need: read.need ?? "other" };
222
240
  }
223
241
  }
224
242
  }
@@ -241,11 +259,12 @@ export function evidenceOf(receipt, problems = receipt?.caveats ?? []) {
241
259
  }
242
260
  /** The status facts of one assignment, as every surface reads them. */
243
261
  export function assignmentStatusFacts(assignment, options = {}) {
244
- const { stage, need } = assignmentStageOf(assignment, options.work ?? null, options.planning ?? false);
262
+ const { stage, need, wait } = assignmentStageOf(assignment, options.work ?? null, options.planning ?? false);
263
+ const build = assignment.need?.build ?? null;
245
264
  const receipt = assignment.receipt;
246
265
  const finished = stage === "finished" || stage === "complete";
247
266
  // A saved result's rows show whenever it is the reason a person is needed, too.
248
- const withResult = finished || stage === "failed" || (stage === "needs-you" && receipt !== null);
267
+ const withResult = finished || stage === "failed" || ((stage === "needs-you" || stage === "waiting") && receipt !== null);
249
268
  const report = receipt?.completionKind === "research-report";
250
269
  const checks = receipt === null || !withResult ? null
251
270
  : { status: receipt.checks.status, exitCode: receipt.checks.exitCode, head: receipt.head,
@@ -255,11 +274,11 @@ export function assignmentStatusFacts(assignment, options = {}) {
255
274
  : publication === null ? (withResult && !report ? { state: "none", number: null, url: null, ci: null, error: null } : null)
256
275
  : pullRequestFactOf(publication);
257
276
  // Ready, Complete and Failed speak for themselves; every other stage keeps its recorded reason.
258
- const reason = stage === "needs-you" || stage === "stopped" || stage === "queued" || (stage === "failed" && checks?.status !== "failed")
277
+ const reason = stage === "needs-you" || stage === "waiting" || stage === "stopped" || stage === "queued" || (stage === "failed" && checks?.status !== "failed")
259
278
  ? stage === "stopped" && assignment.state === "cancelled" ? REPLACED.test(assignment.detail) ? assignment.detail : "Cancelled. Nothing else will run." : plainReasonOf(stage, options.work?.token ?? "", options.work?.detail ?? assignment.detail)
260
279
  : null;
261
280
  return {
262
- stage, ...(need === undefined ? {} : { need }), reason, report,
281
+ stage, ...(need === undefined ? {} : { need }), ...(wait === undefined ? {} : { wait }), ...(build === null ? {} : { needContext: { build } }), reason, report,
263
282
  checks,
264
283
  pullRequest: withResult ? pullRequest : null,
265
284
  requirements: withResult ? requirementsOf(receipt?.proof?.matrix) : null,
@@ -290,7 +309,7 @@ export function statusDetailLines(status) {
290
309
  }
291
310
  /** The headline's emoji for chat cards (colour is never the only signal: the words follow). */
292
311
  export function headlineEmoji(headline) {
293
- return { Queued: "🕓", Planning: "📝", "Needs you": "👋", Building: "⏳", "Ready for review": "✅", Complete: "✅", Failed: "❌", Stopped: "⏹" }[headline];
312
+ return { Queued: "🕓", Planning: "📝", "Needs you": "👋", Waiting: "⏸", Building: "⏳", "Ready for review": "✅", Complete: "✅", Failed: "❌", Stopped: "⏹" }[headline];
294
313
  }
295
314
  const escapeHtml = (value) => value.replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
296
315
  const ICON_PATHS = {
@@ -325,11 +344,13 @@ export const TASK_STATUS_CSS = `.status-headline{display:flex;align-items:center
325
344
  * no assignment. A done task is Complete once a person marked its result;
326
345
  * a refuted result whose project check failed is Failed. */
327
346
  export function stageOfDispatch(d, options = {}) {
328
- if (d.code === "complete" || d.code.startsWith("review-") || d.code === "reviewing")
347
+ if (d.code === "reviewing")
348
+ return { stage: "reviewing" };
349
+ if (d.code === "complete" || d.code.startsWith("review-"))
329
350
  return { stage: options.completed ? "complete" : "finished" };
330
351
  if (d.code === "proof-refuted")
331
- return /approved check failed|checks failed/i.test(d.detail ?? "") ? { stage: "failed" } : { stage: "needs-you", need: "other" };
352
+ return /approved check failed|checks failed/i.test(d.detail ?? "") ? { stage: "failed" } : { stage: "needs-you", need: "review-result" };
332
353
  if (d.code === "needs-verification")
333
- return { stage: "needs-you", need: "other" };
354
+ return { stage: "needs-you", need: "review-result" };
334
355
  return stageOfCode(d.code, { needsPerson: d.condition === "waiting" && d.action !== null, planning: d.condition === "running" && d.role === "planner", operatorHold: d.action === "unhold" });
335
356
  }
@@ -86,6 +86,7 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
86
86
  const failedChecks = status.details.some(one => one.key === "checks" && (one.mark === "failed" || one.mark === "note"));
87
87
  const recovery = operatorHold !== undefined || accessBlocked || (!built && run.outcome !== null);
88
88
  return { text, next, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
89
- link: { label: built ? "Open result" : "Open task", path: recovery
89
+ // Needs you: the button names the action that resolves it.
90
+ link: { label: status.need != null && status.need.key !== "other" && status.need.key !== "review-result" ? status.need.action.label : built ? "Open result" : "Open task", path: recovery
90
91
  ? chatControlHref("recovery", taskId) : built ? chatResultHref(taskId, run.id, failedChecks ? "checks" : "summary") : chatControlHref("task", taskId) } };
91
92
  }
@@ -3,7 +3,7 @@ import { manualReviewOnly } from "./proof.js";
3
3
  /** Read-only phone views over the same dispatch and proof records as chat.
4
4
  * No provider calls, repository access, new workflow state, or inferred success. */
5
5
  import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
6
- import { COMPLETION_ACTION } from "./result-completion.js";
6
+ import { familyCompleted } from "./result-completion.js";
7
7
  import { plainReasonOf, stageOfDispatch, taskStatusOf } from "./task-status.js";
8
8
  import { CHAT_CONTROLS, chatControlHref, chatResultHref } from "./chat-controls.js";
9
9
  export function phoneCommand(text) {
@@ -54,8 +54,8 @@ export function projectLabel(repo) {
54
54
  }
55
55
  /** The shared headline (task-status.ts) for a dispatch diagnosis on the phone. */
56
56
  function headlineFor(store, id, d) {
57
- const completed = d.code === "complete" && store.handle.prepare("SELECT 1 FROM action_ledger WHERE task_id = ? AND action = ? AND source = 'work' LIMIT 1")
58
- .get(store.taskFamilyOf(id, null, true)?.root.id ?? id, COMPLETION_ACTION) !== undefined;
57
+ // Any version of a completed family reads Complete, as taskWaitSnapshot says.
58
+ const completed = d.code === "complete" && familyCompleted(store, id);
59
59
  const read = stageOfDispatch(d, { completed });
60
60
  const finished = read.stage === "finished" || read.stage === "complete" || read.stage === "failed";
61
61
  return taskStatusOf({ stage: read.stage, ...(read.need === undefined ? {} : { need: read.need }), reason: finished ? null : plainReasonOf(read.stage, d.code, d.detail),
@@ -0,0 +1,19 @@
1
+ /** Name prefixes of what tests, journeys and scripts leave in the temp folder. */
2
+ export declare const TEST_TEMP_PREFIXES: readonly string[];
3
+ export declare function isTestTemp(name: string): boolean;
4
+ /** Where they collect: the temp folder (and /tmp, where some scripts write). */
5
+ export declare function tempRoots(): string[];
6
+ export type TempLeftover = {
7
+ path: string;
8
+ touchedAt: number;
9
+ };
10
+ /** Every test temp folder in `roots`, and which of them nothing has touched for a day. */
11
+ export declare function testTempFolders(roots: readonly string[], now: Date): {
12
+ all: TempLeftover[];
13
+ stale: TempLeftover[];
14
+ };
15
+ /** Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes. */
16
+ export declare function removeStaleTestTemp(roots: readonly string[], now: Date): {
17
+ removed: string[];
18
+ failed: string[];
19
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Test temp folders (`toolroll storage`, `storage clean`): what the unit tests, journeys and scripts make in the
3
+ * system temp folder. The tests delete their own (each run gets one temp root that its global teardown removes:
4
+ * test/temp-root.ts); a run that was killed, and older versions, leave folders behind. `toolroll storage` counts them
5
+ * and `storage clean` removes the ones nothing has touched for a day.
6
+ *
7
+ * Every prefix the tests and scripts give tmpdir() is covered here (test-temp.test.ts reads the sources to keep it
8
+ * so), and Playwright's own browser profiles.
9
+ */
10
+ import { lstatSync, readdirSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ const DAY_MS = 86_400_000;
14
+ /** Name prefixes of what tests, journeys and scripts leave in the temp folder. */
15
+ export const TEST_TEMP_PREFIXES = [
16
+ "so-", "standing-orders-", "toolroll-", "no-wt", "epoch", "cancel-floor",
17
+ "attest-", "budgets-", "chat-v66-", "check-levels-", "coding-context-", "console-url-", "console-v2-", "e2-attest-",
18
+ "flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
19
+ "proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
20
+ "task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
21
+ "needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-",
22
+ // Playwright's browser profiles and artifacts.
23
+ "playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
24
+ ];
25
+ export function isTestTemp(name) {
26
+ return TEST_TEMP_PREFIXES.some(prefix => name.startsWith(prefix));
27
+ }
28
+ /** Where they collect: the temp folder (and /tmp, where some scripts write). */
29
+ export function tempRoots() {
30
+ const roots = [tmpdir()];
31
+ if (process.platform !== "win32" && !roots.includes("/tmp"))
32
+ roots.push("/tmp");
33
+ return roots;
34
+ }
35
+ /** When anything last happened in a folder: it, or what is directly in it, last changed. */
36
+ function touchedAt(path) {
37
+ let newest = lstatSync(path).mtimeMs;
38
+ let names = [];
39
+ try {
40
+ names = readdirSync(path);
41
+ }
42
+ catch {
43
+ return newest;
44
+ }
45
+ for (const name of names) {
46
+ try {
47
+ newest = Math.max(newest, lstatSync(join(path, name)).mtimeMs);
48
+ }
49
+ catch { /* gone */ }
50
+ }
51
+ return newest;
52
+ }
53
+ /** Every test temp folder in `roots`, and which of them nothing has touched for a day. */
54
+ export function testTempFolders(roots, now) {
55
+ const all = [];
56
+ const seen = new Set();
57
+ for (const root of roots) {
58
+ let names = [];
59
+ try {
60
+ names = readdirSync(root);
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ for (const name of names) {
66
+ if (!isTestTemp(name))
67
+ continue;
68
+ const path = join(root, name);
69
+ try {
70
+ const stat = lstatSync(path);
71
+ const key = `${stat.dev}:${stat.ino}`;
72
+ if (seen.has(key) || stat.isSymbolicLink())
73
+ continue;
74
+ seen.add(key);
75
+ all.push({ path, touchedAt: stat.isDirectory() ? touchedAt(path) : stat.mtimeMs });
76
+ }
77
+ catch { /* gone */ }
78
+ }
79
+ }
80
+ return { all, stale: all.filter(one => now.getTime() - one.touchedAt > DAY_MS) };
81
+ }
82
+ /** Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes. */
83
+ export function removeStaleTestTemp(roots, now) {
84
+ const removed = [];
85
+ const failed = [];
86
+ for (const one of testTempFolders(roots, now).stale) {
87
+ try {
88
+ if (now.getTime() - (lstatSync(one.path).isDirectory() ? touchedAt(one.path) : lstatSync(one.path).mtimeMs) <= DAY_MS)
89
+ continue;
90
+ rmSync(one.path, { recursive: true, force: true, maxRetries: 3 });
91
+ removed.push(one.path);
92
+ }
93
+ catch {
94
+ failed.push(one.path);
95
+ }
96
+ }
97
+ return { removed, failed };
98
+ }
@@ -12,7 +12,7 @@ export const UPDATES_CSS = `.updates{max-width:640px;min-width:0;overflow-wrap:a
12
12
  `.updates ol.update-steps li[data-state=failed]{color:var(--so-danger);font-weight:600}.updates ol.update-steps li[data-state=failed]::before{background:var(--so-danger);border-color:var(--so-danger)}` +
13
13
  `.updates ol.update-steps .step-detail{grid-column:2;font-weight:400;color:var(--so-muted);font-size:.8125rem;margin-top:2px}` +
14
14
  `.updates .whats-new ul{margin:10px 0 0;padding-left:18px}.updates .whats-new li{margin:4px 0}.updates .whats-new form{margin:14px 0 0}` +
15
- `.updates .update-problem{color:var(--so-danger)}.updates .stamp{margin:10px 0 0}.updates code{white-space:nowrap}` +
15
+ `.updates .update-problem{color:var(--so-danger)}.updates .stamp{margin:10px 0 0}.updates code{white-space:nowrap}.updates [data-update-outcome=stopped] code,.updates .step-detail code{white-space:normal;overflow-wrap:anywhere}` +
16
16
  `@media (prefers-reduced-motion:no-preference){.updates ol.update-steps li[data-state=now]::before{animation:update-pulse 1.6s ease-in-out infinite}}@keyframes update-pulse{50%{box-shadow:0 0 0 7px color-mix(in srgb,var(--so-info) 6%,transparent)}}` +
17
17
  `@media (max-width:600px){.updates .update-actions{display:grid}.updates .update-actions button{width:100%;min-height:44px}.updates .step-up{max-width:none}.updates .step-up input{min-height:44px}}`;
18
18
  /** The steps, each done, current, failed or waiting. Also the live region's fragment. */
@@ -23,7 +23,8 @@ export function updateStepsHtml(j, running) {
23
23
  const items = UPDATE_STEPS.map((step, i) => {
24
24
  const state = j.phase === "complete" || i < last ? "done" : i > last ? "waiting" : failed ? "failed" : finished ? "waiting" : "now";
25
25
  // Only where it adds something: which work it waits for, or what went wrong.
26
- const detail = (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? `<span class="step-detail">${e(j.detail)}</span>` : "";
26
+ const detail = state === "now" && step === "draining" && j.waiting ? `<span class="step-detail">${e(j.waiting.on)}.${j.waiting.action ? ` If nothing of it is running, run <code>${e(j.waiting.action)}</code>.` : ""}</span>`
27
+ : (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? `<span class="step-detail">${e(j.detail)}</span>` : "";
27
28
  return `<li data-step="${step}" data-state="${state}">${e(STEP_WORDS[step])}${detail}</li>`;
28
29
  }).join("");
29
30
  const title = j.phase === "scheduled" ? `Update to ${e(j.to.version)} scheduled for ${e(clock(j.at ?? j.startedAt))}`
@@ -38,6 +39,10 @@ function outcomeHtml(j) {
38
39
  return j.kind === "rollback" ? `<div class="card" data-update-outcome="rolled-back"><h2>Back on ${e(j.to.version)}</h2><p class="meta">${e(j.detail)}</p></div>` : "";
39
40
  if (j.phase === "cancelled")
40
41
  return `<div class="card" data-update-outcome="cancelled"><h2>Update cancelled</h2><p class="meta">Nothing changed.</p></div>`;
42
+ // Stopped on a finished run it can't show has ended: what is in the way and the one command, nothing more.
43
+ if (j.phase === "refused" && j.waiting)
44
+ return `<div class="card" data-update-outcome="stopped"><h2>Update to ${e(j.to.version)} stopped: run #${e(j.waiting.run)} is in the way</h2>` +
45
+ `<p>${e(j.waiting.on)}. New work resumed.</p>` + (j.waiting.action ? `<p class="meta">If nothing of it is running, run <code>${e(j.waiting.action)}</code>, then update again.</p>` : `<p class="meta">Update again once it has stopped.</p>`) + `</div>`;
41
46
  const title = j.phase === "refused" ? `Didn't update to ${e(j.to.version)}` : j.phase === "restored" ? `Update to ${e(j.to.version)} didn't finish` : "The update needs attention";
42
47
  return `<div class="card" data-update-outcome="${e(j.phase)}"><h2>${title}</h2><p class="${j.phase === "needs-attention" ? "update-problem" : ""}" role="alert">${e(j.detail)}</p>` +
43
48
  `<details><summary>Steps</summary>${updateStepsHtml(j, false).replace(/<h2>.*?<\/h2>/, "")}</details></div>`;
@@ -1,4 +1,5 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
+ import { type LingeringRun, type UpdateWaiting } from "./desktop-update.js";
2
3
  import { type SupervisorRunner } from "./daemon.js";
3
4
  export declare const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
4
5
  export declare const PROVENANCE_WORKFLOW = ".github/workflows/publish.yml";
@@ -103,6 +104,11 @@ export type RuntimeUpdateJournal = {
103
104
  * the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
104
105
  restoredDatabase?: boolean;
105
106
  seen?: boolean;
107
+ /** The finished run the update is waiting on (or stopped waiting on): what is in the way and the command that
108
+ * clears it. Absent while it waits on ordinary running work. */
109
+ waiting?: LingeringRun & {
110
+ since: string;
111
+ };
106
112
  };
107
113
  export type PackageRelease = {
108
114
  version: string;
@@ -160,6 +166,8 @@ export type UpdateOutcome = {
160
166
  journal: RuntimeUpdateJournal | null;
161
167
  };
162
168
  export declare const runtimeUpdateTerminal: (phase: RuntimePhase) => boolean;
169
+ /** How long a launched or resuming updater is expected to take to start and hold the lock. */
170
+ export declare const UPDATER_START_MS: number;
163
171
  export declare function readRuntimeUpdate(stateDir: string, file?: string): RuntimeUpdateJournal | null;
164
172
  /** The update `--rollback` returns from: the last one that completed, whatever was attempted since. */
165
173
  export declare function lastCompletedUpdate(stateDir: string): RuntimeUpdateJournal | null;
@@ -245,6 +253,18 @@ export type RuntimeUpdateStatus = {
245
253
  } | null;
246
254
  };
247
255
  export declare function runtimeUpdateStatus(stateDir: string): RuntimeUpdateStatus;
256
+ /** An update that is waiting, what on, and the one action that clears it: for `toolroll status` and the console.
257
+ * `inTheWay` asks whether a finished run still holds it up; a stopped update whose run has since cleared says nothing. */
258
+ export type { UpdateWaiting };
259
+ export declare function updateWaitingOf(stateDir: string, inTheWay: (run: number) => boolean): UpdateWaiting | null;
260
+ /** An updater that ended while letting work finish (a crash, a kill, the Mac restarting) leaves new work paused with
261
+ * nobody left to lift the pause. With no updater holding the update, the pause is lifted here and the update says it
262
+ * stopped. Nothing was switched before the backup, so nothing else needs putting back. The background worker's
263
+ * reconcile, `toolroll status` and the console run this. Returns what it said, or null. */
264
+ export declare function releaseStalledUpdate(stateDir: string, now: Date): string | null;
265
+ /** What `toolroll status` and the console show: a stalled `toolroll update` released first, then this installation's
266
+ * waiting update, `toolroll update`'s or the desktop app's. */
267
+ export declare function waitingUpdate(databaseFile: string, inTheWay: (run: number) => boolean, now: Date, desktopStates?: readonly string[]): UpdateWaiting | null;
248
268
  export declare function markWhatsNewSeen(stateDir: string): void;
249
269
  type Exec = (command: string, args: string[], options?: {
250
270
  cwd?: string;
@@ -282,8 +302,8 @@ export declare function launchRuntimeUpdate(args: {
282
302
  run?: SupervisorRunner;
283
303
  platform?: NodeJS.Platform;
284
304
  npmBin?: () => Promise<string | null>;
305
+ now?: () => Date;
285
306
  }): Promise<void>;
286
307
  /** The job's last act: its definition goes, so nothing can start it again. Only the definition for this id. */
287
308
  export declare function retireUpdateJob(id: string, home?: string): void;
288
309
  export declare const nextScheduledAt: typeof nextAt;
289
- export {};
@@ -29,7 +29,7 @@ import { homedir, userInfo } from "node:os";
29
29
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
30
30
  import { fileURLToPath, pathToFileURL } from "node:url";
31
31
  import { createRequire } from "node:module";
32
- import { durableJson, lingeringWork, sqliteLock, verifiedDatabaseBackup } from "./desktop-update.js";
32
+ import { clearWords, durableJson, LINGERING_LIMIT_MS, lingeringRun, lingeringWords, settleLeftoverRecords, sqliteLock, desktopUpdateWaitingOf, stoppedWaitingWords, updateWaitingWords, verifiedDatabaseBackup } from "./desktop-update.js";
33
33
  import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused, updateGateOwned } from "./desktop-update-gate.js";
34
34
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, removeCodingUpdateGate } from "./coding-update.js";
35
35
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
@@ -61,6 +61,24 @@ const journalFile = (stateDir) => join(stateDir, "toolroll-update.json");
61
61
  /** The last completed update, kept apart from the journal: what `--rollback` returns from. */
62
62
  const lastUpdateFile = (stateDir) => join(stateDir, "toolroll-update.last.json");
63
63
  const cancelFile = (j) => join(j.stageDir, "cancel-request.json");
64
+ /** An updater on its way to this update before it holds the lock: the console's job once launched (launchd may not
65
+ * have started it yet), or a resume about to drive it. */
66
+ const startingFile = (j) => join(j.stageDir, "updater-starting.json");
67
+ /** How long a launched or resuming updater is expected to take to start and hold the lock. */
68
+ export const UPDATER_START_MS = 2 * 60_000;
69
+ function markStarting(j, state, now) {
70
+ durableJson(startingFile(j), { id: j.id, state, at: now.toISOString() });
71
+ }
72
+ function updaterStarting(j, now) {
73
+ try {
74
+ const mark = JSON.parse(readFileSync(startingFile(j), "utf8"));
75
+ const age = now.getTime() - Date.parse(mark.at ?? "");
76
+ return mark.id === j.id && age >= 0 && age < UPDATER_START_MS;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
64
82
  const sha = (bytes, algorithm = "sha256") => createHash(algorithm).update(bytes).digest("hex");
65
83
  const fileHash = (file) => sha(readFileSync(file));
66
84
  const quote = (s) => '"' + s.replaceAll('"', '""') + '"';
@@ -744,6 +762,7 @@ export async function resumeRuntimeUpdate(stateDir, system, id) {
744
762
  return { ok: true, phase: j?.phase ?? "complete", message: "The update this job was started for is no longer the saved one. Nothing was changed.", journal: j };
745
763
  if (!j || runtimeUpdateTerminal(j.phase))
746
764
  return { ok: true, phase: j?.phase ?? "complete", message: "No update is in progress.", journal: j };
765
+ markStarting(j, "resuming", system.now());
747
766
  return driveRuntimeUpdate(j, system);
748
767
  }
749
768
  /** A prepared update whose job could not start. */
@@ -801,6 +820,23 @@ async function driveRuntimeUpdate(j, system) {
801
820
  const lock = sqliteLock(join(j.stageDir, "worker.sqlite"), 1000);
802
821
  if (!lock)
803
822
  return { ok: false, phase: j.phase, message: `An updater is already working on the update to ${j.to.version}.`, journal: j };
823
+ // What was read before the lock may be out of date: a stalled update released, or a cancel, in between.
824
+ let saved = null;
825
+ try {
826
+ saved = readRuntimeUpdate(j.stateDir);
827
+ }
828
+ catch {
829
+ saved = null;
830
+ }
831
+ if (saved?.id === j.id) {
832
+ if (runtimeUpdateTerminal(saved.phase)) {
833
+ lock.close();
834
+ return { ok: saved.phase === "complete" || saved.phase === "cancelled", phase: saved.phase, message: saved.detail, journal: saved };
835
+ }
836
+ Object.assign(j, saved);
837
+ }
838
+ // The lock now says an updater is here; the starting mark has done its job.
839
+ rmSync(startingFile(j), { force: true });
804
840
  const at = (phase) => UPDATE_STEPS.indexOf(phase);
805
841
  const step = (phase, detail) => { save(j, phase, detail, system.now()); system.checkpoint?.(phase); };
806
842
  const noun = j.kind === "update" ? "update" : "rollback";
@@ -1042,9 +1078,15 @@ async function drain(j, system) {
1042
1078
  try {
1043
1079
  db.exec("PRAGMA busy_timeout=5000");
1044
1080
  if (j.when === "now") {
1045
- const running = runningWorkWords(db) ?? lingeringWork(db);
1081
+ const running = runningWorkWords(db);
1046
1082
  if (running)
1047
1083
  throw new Refusal(`Work is running: ${running}. Nothing was changed. Use When idle to wait for it.`);
1084
+ settleLeftoverRecords(db, system.now());
1085
+ const lingering = lingeringRun(db);
1086
+ if (lingering) {
1087
+ j.waiting = { ...lingering, since: system.now().toISOString() };
1088
+ throw new Refusal(`${lingering.on}. ${clearWords(lingering)} Nothing was changed.`);
1089
+ }
1048
1090
  if (updateAdmissionPaused(db) && !updateGateOwned(db, j.id))
1049
1091
  throw new Refusal("Another update owns the admission pause. Nothing was changed.");
1050
1092
  installUpdateGate(db, j.id);
@@ -1058,10 +1100,23 @@ async function drain(j, system) {
1058
1100
  for (;;) {
1059
1101
  if (cancelRequested(j))
1060
1102
  return;
1061
- const lingering = Object.values(activeUpdateWork(db)).every(n => n === 0) ? lingeringWork(db) : null;
1062
- if (!lingering && freezeUpdateGate(db, j.id))
1103
+ const idle = Object.values(activeUpdateWork(db)).every(n => n === 0);
1104
+ // The same settling reconcile the background worker runs, so a leftover record it can prove never holds this up.
1105
+ if (idle)
1106
+ settleLeftoverRecords(db, system.now());
1107
+ const lingering = idle ? lingeringRun(db) : null;
1108
+ if (!lingering && freezeUpdateGate(db, j.id)) {
1109
+ delete j.waiting;
1063
1110
  return;
1064
- save(j, "draining", `New work is paused. Waiting for ${lingering ?? runningWorkWords(db) ?? "running work"} to finish. Nothing is being cancelled.`, system.now());
1111
+ }
1112
+ if (!lingering)
1113
+ delete j.waiting;
1114
+ else if (j.waiting?.run !== lingering.run || j.waiting.on !== lingering.on)
1115
+ j.waiting = { ...lingering, since: system.now().toISOString() };
1116
+ // Waiting longer on a finished run proves nothing more: stop, let new work resume, and say what clears it.
1117
+ if (j.waiting && system.now().getTime() - Date.parse(j.waiting.since) >= LINGERING_LIMIT_MS)
1118
+ throw new Refusal(stoppedWaitingWords(j.waiting));
1119
+ save(j, "draining", lingering ? `New work is paused. Waiting for run #${lingering.run}: ${lingeringWords(lingering)}` : `New work is paused. Waiting for ${runningWorkWords(db) ?? "running work"} to finish. Nothing is being cancelled.`, system.now());
1065
1120
  await system.sleep(2000);
1066
1121
  }
1067
1122
  }
@@ -1121,6 +1176,77 @@ export function runtimeUpdateStatus(stateDir) {
1121
1176
  }
1122
1177
  return { journal: j, running, whatsNew, lastUpdate: last ? { from: last.from.version, to: last.to.version } : null };
1123
1178
  }
1179
+ export function updateWaitingOf(stateDir, inTheWay) {
1180
+ let j;
1181
+ try {
1182
+ j = readRuntimeUpdate(stateDir);
1183
+ }
1184
+ catch {
1185
+ return null;
1186
+ }
1187
+ if (!j || j.kind !== "update")
1188
+ return null;
1189
+ const version = j.to.version;
1190
+ if (j.phase === "draining")
1191
+ return updateWaitingWords({ app: false, version, stopped: false, lingering: j.waiting ?? null });
1192
+ if (j.phase !== "refused" || !j.waiting || !inTheWay(j.waiting.run))
1193
+ return null;
1194
+ return updateWaitingWords({ app: false, version, stopped: true, lingering: j.waiting });
1195
+ }
1196
+ /** An updater that ended while letting work finish (a crash, a kill, the Mac restarting) leaves new work paused with
1197
+ * nobody left to lift the pause. With no updater holding the update, the pause is lifted here and the update says it
1198
+ * stopped. Nothing was switched before the backup, so nothing else needs putting back. The background worker's
1199
+ * reconcile, `toolroll status` and the console run this. Returns what it said, or null. */
1200
+ export function releaseStalledUpdate(stateDir, now) {
1201
+ let j;
1202
+ try {
1203
+ j = readRuntimeUpdate(stateDir);
1204
+ }
1205
+ catch {
1206
+ return null;
1207
+ }
1208
+ if (!j || j.phase !== "draining")
1209
+ return null;
1210
+ const lock = sqliteLock(join(j.stageDir, "worker.sqlite"));
1211
+ if (!lock)
1212
+ return null;
1213
+ try {
1214
+ // An updater may have moved on, or a resumed one replaced it, between the first read and the lock.
1215
+ const held = readRuntimeUpdate(stateDir);
1216
+ if (!held || held.id !== j.id || held.phase !== "draining")
1217
+ return null;
1218
+ // A job launched or a resume under way has not taken the lock yet: it continues this update; nothing is lifted under it.
1219
+ if (updaterStarting(held, now))
1220
+ return null;
1221
+ try {
1222
+ ungate(held);
1223
+ }
1224
+ catch { /* no pause yet, or the database is unreadable: the journal still says it stopped */ }
1225
+ if (held.kind === "update")
1226
+ rmSync(join(held.stageDir, "runtime"), { recursive: true, force: true });
1227
+ const noun = held.kind === "update" ? "update" : "rollback";
1228
+ const words = `The ${noun} to ${held.to.version} stopped: its updater ended before running work finished. Nothing was changed; new work resumed. Run toolroll ${noun === "update" ? "update" : "update --rollback"} to try again.`;
1229
+ save(held, "refused", words, now);
1230
+ try {
1231
+ ledger(held.databaseFile, now, held.actor, `toolroll ${noun} stopped`, "refused", `${held.from.version} → ${held.to.version}: the updater ended while draining`);
1232
+ }
1233
+ catch { /* the journal still says what happened */ }
1234
+ return words;
1235
+ }
1236
+ finally {
1237
+ lock.close();
1238
+ }
1239
+ }
1240
+ /** What `toolroll status` and the console show: a stalled `toolroll update` released first, then this installation's
1241
+ * waiting update, `toolroll update`'s or the desktop app's. */
1242
+ export function waitingUpdate(databaseFile, inTheWay, now, desktopStates) {
1243
+ const stateDir = dirname(databaseFile);
1244
+ try {
1245
+ releaseStalledUpdate(stateDir, now);
1246
+ }
1247
+ catch { /* an unreadable record is shown as it is */ }
1248
+ return updateWaitingOf(stateDir, inTheWay) ?? desktopUpdateWaitingOf(databaseFile, inTheWay, desktopStates);
1249
+ }
1124
1250
  export function markWhatsNewSeen(stateDir) {
1125
1251
  const j = readRuntimeUpdate(stateDir);
1126
1252
  if (!j || j.seen || j.phase !== "complete")
@@ -1362,6 +1488,10 @@ export async function launchRuntimeUpdate(args, seams = {}) {
1362
1488
  const dist = args.dist ?? dirname(fileURLToPath(import.meta.url));
1363
1489
  const command = [process.execPath, join(dist, "bin.js"), "update", "--resume", "--id", args.id, "--db", args.databaseFile];
1364
1490
  const stateDir = dirname(args.databaseFile);
1491
+ // Pending until launchd starts the job and it holds the lock: nothing releases the update meanwhile.
1492
+ const saved = readRuntimeUpdate(stateDir);
1493
+ if (saved?.id === args.id && !runtimeUpdateTerminal(saved.phase))
1494
+ markStarting(saved, "launched", (seams.now ?? (() => new Date()))());
1365
1495
  const log = join(stateDir, "toolroll-update.log");
1366
1496
  if ((seams.platform ?? process.platform) === "darwin") {
1367
1497
  const home = seams.home ?? homedir();