toolroll 0.9.76 → 0.9.78

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 (144) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +46 -10
  5. package/dist/assignment.js +3 -0
  6. package/dist/backup-ui.d.ts +9 -1
  7. package/dist/backup-ui.js +28 -3
  8. package/dist/backup.js +4 -3
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  10. package/dist/browser/workspace.css +2 -2
  11. package/dist/browser/workspace.js +31 -31
  12. package/dist/browser-workspace.d.ts +2 -0
  13. package/dist/builder.d.ts +18 -3
  14. package/dist/builder.js +152 -47
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +295 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +67 -6
  21. package/dist/check-report.js +254 -9
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/claim.d.ts +9 -0
  24. package/dist/claim.js +9 -1
  25. package/dist/cli.js +8 -5
  26. package/dist/coding-handoff.d.ts +1 -0
  27. package/dist/coding-handoff.js +20 -11
  28. package/dist/commit-extract.d.ts +6 -0
  29. package/dist/commit-extract.js +63 -8
  30. package/dist/console-chrome.d.ts +41 -0
  31. package/dist/console-chrome.js +166 -0
  32. package/dist/contracts/console-api.d.ts +1 -1
  33. package/dist/contracts/console-api.js +1 -1
  34. package/dist/contracts/release-check.d.ts +12 -0
  35. package/dist/contracts/release-check.js +17 -0
  36. package/dist/contracts/release-terms.d.ts +41 -0
  37. package/dist/contracts/release-terms.js +53 -0
  38. package/dist/contracts/verification-receipt.d.ts +20 -2
  39. package/dist/contracts/verification-receipt.js +15 -3
  40. package/dist/contracts/verification-view.d.ts +8 -0
  41. package/dist/controller-service.js +4 -1
  42. package/dist/controller-supervisor.d.ts +8 -0
  43. package/dist/controller-supervisor.js +33 -1
  44. package/dist/demo.js +3 -2
  45. package/dist/deploy-gate.js +5 -0
  46. package/dist/deploy-rescue.d.ts +41 -0
  47. package/dist/deploy-rescue.js +94 -0
  48. package/dist/desktop-update.d.ts +1 -1
  49. package/dist/dispose.d.ts +3 -0
  50. package/dist/dispose.js +9 -5
  51. package/dist/doctor.d.ts +7 -0
  52. package/dist/doctor.js +10 -2
  53. package/dist/envelope.js +2 -1
  54. package/dist/evidence.d.ts +9 -0
  55. package/dist/evidence.js +19 -4
  56. package/dist/exec.d.ts +9 -0
  57. package/dist/exec.js +22 -2
  58. package/dist/git-floor.d.ts +16 -0
  59. package/dist/git-floor.js +36 -0
  60. package/dist/grant.js +5 -1
  61. package/dist/guides.js +22 -11
  62. package/dist/latency-timeline.d.ts +46 -0
  63. package/dist/latency-timeline.js +120 -0
  64. package/dist/live-backup.d.ts +39 -0
  65. package/dist/live-backup.js +271 -0
  66. package/dist/operate.d.ts +13 -0
  67. package/dist/operate.js +969 -328
  68. package/dist/planner.js +2 -2
  69. package/dist/prepared-evidence.d.ts +2 -1
  70. package/dist/prepared-evidence.js +8 -3
  71. package/dist/project-knowledge.js +6 -2
  72. package/dist/project-learning.js +2 -2
  73. package/dist/proof.js +6 -0
  74. package/dist/read-executor-worker.js +8 -4
  75. package/dist/read-executor.d.ts +18 -1
  76. package/dist/read-executor.js +8 -0
  77. package/dist/release-adapters.d.ts +80 -2
  78. package/dist/release-adapters.js +441 -16
  79. package/dist/release-authority.js +6 -2
  80. package/dist/release-auto.d.ts +38 -7
  81. package/dist/release-auto.js +131 -15
  82. package/dist/release-card.d.ts +15 -3
  83. package/dist/release-card.js +25 -9
  84. package/dist/release-carry.d.ts +21 -1
  85. package/dist/release-carry.js +22 -0
  86. package/dist/release-ci-hold.d.ts +41 -0
  87. package/dist/release-ci-hold.js +53 -0
  88. package/dist/release-cli.d.ts +3 -0
  89. package/dist/release-cli.js +95 -17
  90. package/dist/release-coverage.js +5 -2
  91. package/dist/release-gate.d.ts +5 -0
  92. package/dist/release-gate.js +1 -1
  93. package/dist/release-recheck.d.ts +1 -1
  94. package/dist/release-recheck.js +8 -2
  95. package/dist/release.d.ts +144 -14
  96. package/dist/release.js +421 -81
  97. package/dist/review-context.js +8 -6
  98. package/dist/runner-process.d.ts +67 -0
  99. package/dist/runner-process.js +219 -0
  100. package/dist/runner.d.ts +48 -6
  101. package/dist/runner.js +73 -9
  102. package/dist/sandbox-runtime.d.ts +8 -0
  103. package/dist/sandbox-runtime.js +14 -2
  104. package/dist/scope.d.ts +19 -1
  105. package/dist/scope.js +26 -3
  106. package/dist/scout.js +2 -2
  107. package/dist/secret-mint.d.ts +1 -0
  108. package/dist/secret-mint.js +14 -0
  109. package/dist/serve.js +112 -51
  110. package/dist/server/chat.js +5 -5
  111. package/dist/server/http.d.ts +2 -0
  112. package/dist/server/pages.js +7 -3
  113. package/dist/server/remote-hooks.d.ts +1 -1
  114. package/dist/server/remote-hooks.js +5 -3
  115. package/dist/server/render-results.d.ts +2 -0
  116. package/dist/server/render-results.js +1 -0
  117. package/dist/server/render-tasks.d.ts +2 -0
  118. package/dist/server/render-tasks.js +2 -1
  119. package/dist/server/request-context.d.ts +8 -0
  120. package/dist/server/runtime.d.ts +2 -0
  121. package/dist/server/settings.js +22 -2
  122. package/dist/server/task-views.js +7 -2
  123. package/dist/server/tasks.js +4 -1
  124. package/dist/setup-guide.d.ts +2 -2
  125. package/dist/storage-sweep.js +1 -1
  126. package/dist/storage.d.ts +5 -0
  127. package/dist/storage.js +28 -1
  128. package/dist/store.d.ts +135 -2
  129. package/dist/store.js +275 -13
  130. package/dist/surface.js +3 -1
  131. package/dist/task-base.d.ts +4 -0
  132. package/dist/task-base.js +22 -2
  133. package/dist/teardown-git.d.ts +52 -3
  134. package/dist/teardown-git.js +145 -33
  135. package/dist/test-infra.js +1 -1
  136. package/dist/toolroll-update.js +2 -2
  137. package/dist/up-processes.d.ts +137 -0
  138. package/dist/up-processes.js +165 -0
  139. package/dist/up-role-child.d.ts +1 -0
  140. package/dist/up-role-child.js +81 -0
  141. package/dist/verification-evidence.d.ts +1 -0
  142. package/dist/verification-evidence.js +13 -1
  143. package/dist/workspace-revision.js +3 -1
  144. package/package.json +2 -1
package/dist/scope.d.ts CHANGED
@@ -412,6 +412,9 @@ export type Scope = SavedScopeTerms & {
412
412
  * carry policy, grant, check access, expiry. Part of the digest when present; a person's seal of them opens the
413
413
  * release's authority record. */
414
414
  releaseTerms?: ReleaseTerms | null;
415
+ /** v138 (docs/plans/pipeline-speed.md, P0): a release check's purpose when the release can't carry (no terms) — this
416
+ * exact candidate is a release's check. Part of the digest when present; never present beside terms. */
417
+ releaseCheck?: ReleaseCheckPurpose | null;
415
418
  };
416
419
  /** Who wrote a scope's text, when not a person (approval-policy plan §1.4). */
417
420
  export type ScopeAuthor = "mate" | "coordinator" | "scout" | "lead";
@@ -464,6 +467,9 @@ export type ScopeInput = {
464
467
  /** v135: release terms (release-carry.ts releaseTermsFor), from the plane's own records. Undefined keeps any terms
465
468
  * the scope already carries; null removes them. */
466
469
  releaseTerms?: ReleaseTerms | null;
470
+ /** v138: a release check's purpose (release-carry.ts releaseCheckPurposeFor), from the plane's own records. Undefined
471
+ * keeps any purpose the scope already carries; null removes it. */
472
+ releaseCheck?: ReleaseCheckPurpose | null;
467
473
  now: Date;
468
474
  mutation?: Mutation;
469
475
  };
@@ -488,6 +494,8 @@ export declare function digestOf(scope: Pick<Scope, "goal" | "outOfScope" | "tou
488
494
  candidate?: string | null;
489
495
  /** v135: release terms, folded in only when present. */
490
496
  releaseTerms?: ReleaseTerms | null;
497
+ /** v138: a release check's purpose, folded in only when present. */
498
+ releaseCheck?: ReleaseCheckPurpose | null;
491
499
  }, target?: ExecutionProfile | null | {
492
500
  chain: readonly ChainEntry[];
493
501
  }, route?: PhaseRoute | null): string;
@@ -864,10 +872,12 @@ export declare function scopeRouteWords(scope: Pick<Scope, "proposedRouteJson">,
864
872
  export declare function describeScope(scope: Scope, readiness?: ReadinessLookup): string[];
865
873
  /** The approval sheet's release lines (release-once.md): the release in one sentence, then what asks again. */
866
874
  export declare function releaseTermsSentence(terms: ReleaseTerms): string;
875
+ /** The approval sheet's line for a release check purpose (pipeline-speed.md, P0): a release that can't carry. */
876
+ export declare function releaseCheckSentence(purpose: ReleaseCheckPurpose): string;
867
877
  /** What asks again, in full: the terms behind the sentence. */
868
878
  export declare function releaseAsksAgainWords(terms: ReleaseTerms): string[];
869
879
  export { personProof } from "./person-proof.js";
870
- import { type ReleaseTerms } from "./contracts/release-terms.js";
880
+ import { type ReleaseCheckPurpose, type ReleaseTerms } from "./contracts/release-terms.js";
871
881
  import { type AutoEvidence, type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
872
882
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
873
883
  export declare const machineActing: () => boolean;
@@ -955,6 +965,14 @@ export declare function mainDebtProblem(store: Store, repo: string, taskId: stri
955
965
  * chat it is approved only through the release card.
956
966
  */
957
967
  export declare function deployGateScope(scope: Pick<Scope, "candidate" | "releaseTerms">): boolean;
968
+ /**
969
+ * Whether a scope's check is a release's check (docs/plans/pipeline-speed.md, P0): it carries signed release terms, or a
970
+ * release check purpose sealed with it. Read from the scope's signed fields only, independent of whether the release can
971
+ * carry. It makes the check stricter (a complete, valid report; CI never stands in for its units) and, through
972
+ * check-failure.ts recheckReleaseTask, lets the plane rerun the identical candidate after confirmed infrastructure; it
973
+ * never carries a later commit or approves anything.
974
+ */
975
+ export declare function releaseCheckScope(scope: Pick<Scope, "releaseTerms" | "releaseCheck"> | null): boolean;
958
976
  /**
959
977
  * The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
960
978
  * belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
package/dist/scope.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // The rubric's vocabulary loads before anything that reaches the plan contract (see contracts/acceptance-terms.ts).
2
2
  export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./contracts/acceptance-terms.js";
3
+ import { mintSecret } from "./secret-mint.js";
3
4
  import { passwordlessOn } from "./passwordless.js";
4
5
  import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from "./sign-in-guard.js";
5
6
  import { validateScopeText } from "./task-text.js";
@@ -47,10 +48,11 @@ import { z } from "zod";
47
48
  import {} from "./contracts/acceptance-terms.js";
48
49
  import { parseContract } from "./contracts/contract.js";
49
50
  import { readAcceptance, readScopeTerms, storedRubricSchema } from "./contracts/scope.js";
51
+ import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
50
52
  import { NO_READINESS, exactModelId, legOf, projectRoute, routeDigestOf, routeFromJson, routeJsonProblem, routeWords, } from "./phase-routing.js";
51
53
  /** Same shape as a runner's credential, for the same reasons. */
52
54
  function mintToken() {
53
- return randomBytes(32).toString("base64url");
55
+ return mintSecret(32);
54
56
  }
55
57
  export function hashToken(token) {
56
58
  return createHash("sha256").update(token, "utf8").digest("hex");
@@ -617,6 +619,7 @@ route) {
617
619
  ...(scope.qualityMode === "strict" ? { qualityMode: "strict" } : {}),
618
620
  ...(scope.candidate ? { candidate: scope.candidate } : {}),
619
621
  ...(scope.releaseTerms == null ? {} : { releaseTerms: releaseTermsDigest(scope.releaseTerms) }),
622
+ ...(scope.releaseCheck == null ? {} : { releaseCheck: releaseCheckPurposeDigest(scope.releaseCheck) }),
620
623
  // The single outer key, whichever target produced its inner value:
621
624
  // absent => the golden and every profileless approval are untouched;
622
625
  // a legacy profile => its exact profileDigestOf; an explicit chain
@@ -670,6 +673,7 @@ function proposeLocked(store, input) {
670
673
  ...(input.qualityMode === undefined ? {} : { qualityMode: input.qualityMode }),
671
674
  ...(posture === undefined ? {} : { posture }),
672
675
  ...(input.releaseTerms === undefined ? {} : { releaseTerms: input.releaseTerms }),
676
+ ...(input.releaseCheck === undefined ? {} : { releaseCheck: input.releaseCheck }),
673
677
  proposedVia,
674
678
  });
675
679
  // The store may have RECOMPUTED the digest to bind the resolved profile
@@ -1098,6 +1102,10 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1098
1102
  const releaseProblem = scope.releaseTerms == null ? null : releaseSealProblem(store, scope.releaseTerms);
1099
1103
  if (releaseProblem !== null)
1100
1104
  return { ok: false, reason: "policy", message: releaseProblem };
1105
+ // Filed while CI still ran: nobody approves it before the plane read exact-head CI passing (round 1, finding 4).
1106
+ const ciHold = releaseCiHoldOf(store, taskId);
1107
+ if (ciHold !== null)
1108
+ return { ok: false, reason: "policy", message: releaseCiHoldWords(ciHold) };
1101
1109
  const gate = store.approvalGate(taskId, by, "person");
1102
1110
  if (gate.verdict === "refuse")
1103
1111
  return { ok: false, reason: gate.reason };
@@ -1259,7 +1267,7 @@ export function scopeAuthorityOf(scope, env = {}) {
1259
1267
  const parity = routeParityProblem(route, profile, { riskLevel: scope.riskLevel ?? "routine", qualityMode: scope.qualityMode ?? "default" });
1260
1268
  if (parity !== null)
1261
1269
  return { ok: false, reason: "parity", problem: parity };
1262
- const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null }, profile, route);
1270
+ const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null, releaseCheck: scope.releaseCheck ?? null }, profile, route);
1263
1271
  if (digest !== scope.digest) {
1264
1272
  return { ok: false, reason: "digest", problem: "the scope's reference does not re-derive from its own terms, agents, and route — file it again" };
1265
1273
  }
@@ -1319,6 +1327,7 @@ export function describeScope(scope, readiness = NO_READINESS) {
1319
1327
  // Its sandbox terms (project-sandbox.ts), part of what the yes agrees to: what it reads, and any widening.
1320
1328
  ...(scope.profile === null || scope.profile === undefined ? [] : ((terms) => [` reads ${terms.reads}`, ...(terms.mach === null ? [] : [` services ${terms.mach}`])])(sandboxTermsWords(scope.profile))),
1321
1329
  ...releaseTermsWords(scope.releaseTerms ?? null),
1330
+ ...(scope.releaseCheck == null ? [] : [` release ${releaseCheckSentence(scope.releaseCheck)}`]),
1322
1331
  ` reference ${scope.digest}`,
1323
1332
  ` approved ${describeApproval(approval)}`,
1324
1333
  ];
@@ -1330,6 +1339,10 @@ export function releaseTermsSentence(terms) {
1330
1339
  const outside = terms.access === "sandboxed" ? "" : " This project's checks don't run in the sandbox: those later test-only commits will run outside the sandbox without being shown to you first.";
1331
1340
  return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Later commits on this pull request that only change tests, docs or the changelog ship without asking you again, until it merges or for 3 days. You'll see each one in the release summary.${outside}`;
1332
1341
  }
1342
+ /** The approval sheet's line for a release check purpose (pipeline-speed.md, P0): a release that can't carry. */
1343
+ export function releaseCheckSentence(purpose) {
1344
+ return `Release ${purpose.packageVersion} from pull request #${purpose.pr} at ${purpose.approvedSha.slice(0, 7)}. Every later commit asks for your approval again. If Toolroll's own machine fails during the check, it reruns this same commit's check (at most twice).`;
1345
+ }
1333
1346
  /** What asks again, in full: the terms behind the sentence. */
1334
1347
  export function releaseAsksAgainWords(terms) {
1335
1348
  const p = terms.policy;
@@ -1358,7 +1371,7 @@ function describeApproval(approval) {
1358
1371
  // ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
1359
1372
  export { personProof } from "./person-proof.js";
1360
1373
  import { personProof } from "./person-proof.js";
1361
- import { releaseTermsDigest } from "./contracts/release-terms.js";
1374
+ import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
1362
1375
  import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem } from "./release-authority.js";
1363
1376
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
1364
1377
  export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
@@ -1542,6 +1555,16 @@ export function mainDebtProblem(store, repo, taskId) {
1542
1555
  export function deployGateScope(scope) {
1543
1556
  return (scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null;
1544
1557
  }
1558
+ /**
1559
+ * Whether a scope's check is a release's check (docs/plans/pipeline-speed.md, P0): it carries signed release terms, or a
1560
+ * release check purpose sealed with it. Read from the scope's signed fields only, independent of whether the release can
1561
+ * carry. It makes the check stricter (a complete, valid report; CI never stands in for its units) and, through
1562
+ * check-failure.ts recheckReleaseTask, lets the plane rerun the identical candidate after confirmed infrastructure; it
1563
+ * never carries a later commit or approves anything.
1564
+ */
1565
+ export function releaseCheckScope(scope) {
1566
+ return scope !== null && ((scope.releaseTerms ?? null) !== null || (scope.releaseCheck ?? null) !== null);
1567
+ }
1545
1568
  /**
1546
1569
  * The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
1547
1570
  * belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
package/dist/scout.js CHANGED
@@ -21,7 +21,7 @@ import { run } from "./exec.js";
21
21
  import { checkoutGit } from "./teardown-git.js";
22
22
  import { auditOf } from "./provider.js";
23
23
  import { currentClaim, heartbeat } from "./claim.js";
24
- import { heartbeat as runnerHeartbeat } from "./runner.js";
24
+ import { heartbeat as runnerHeartbeat, RUNNER_HEARTBEAT_MS } from "./runner.js";
25
25
  import { parseDecision } from "./decision.js";
26
26
  import { scoutHandbackSchema } from "./contracts/scout-report.js";
27
27
  import { parseReport, REPORT_IMAGE_FILE, REPORT_LIMITS, REPORT_VERSION, SCOUT_OUTPUT_JSON_SCHEMA } from "./scout-report.js";
@@ -43,7 +43,7 @@ const GIT = "git";
43
43
  const AGENT_ENV_DENYLIST = [...TELEGRAM_TOKEN_ENVS];
44
44
  const DEFAULT_SCOUT_TIMEOUT_MS = 20 * 60_000;
45
45
  const DEFAULT_SCOUT_TURNS = CLAUDE_LIMITS.maxTurns;
46
- const DEFAULT_PULSE_MS = 60_000;
46
+ const DEFAULT_PULSE_MS = RUNNER_HEARTBEAT_MS;
47
47
  /** The scout's own headless browser: the pinned Playwright tool, saving its
48
48
  * screenshots straight into the run's image folder. */
49
49
  export const SCOUT_BROWSER = "toolroll-browser";
@@ -0,0 +1 @@
1
+ export declare function mintSecret(bytes: number, draw?: (size: number) => Buffer): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Secrets a person types or pastes on a command line (passwords, invite and approver tokens, runner credentials):
3
+ * random base64url, never starting with "-". A value starting with "--" reads as a flag to many parsers (`--token
4
+ * --abc…` was refused as a missing value: about 1 in 4096 minted passwords), so it is drawn again. The CLI accepts one
5
+ * anyway (an older secret), but a new one never needs that.
6
+ */
7
+ import { randomBytes } from "node:crypto";
8
+ export function mintSecret(bytes, draw = randomBytes) {
9
+ for (;;) {
10
+ const secret = draw(bytes).toString("base64url");
11
+ if (!secret.startsWith("-"))
12
+ return secret;
13
+ }
14
+ }
package/dist/serve.js CHANGED
@@ -14,19 +14,15 @@ import { signInsUnavailableWords } from "./check-access.js";
14
14
  import { LIVE_VIEW_CONSENT, liveCommandsShown, liveViewConsentDigest } from "./live-detail.js";
15
15
  import { html, htmlString, jsonScript, textHtml } from "./html.js";
16
16
  import { browserAssetsAvailable, browserWorkspaceDocument } from './browser-shell.js';
17
- import { NO_COUNTS, projectStates, STATE_WORDS } from './agent-state.js';
17
+ import { accessKey, countsKey, indexKey, projectSummaryOf, RevisionCache, summaryKey } from './console-chrome.js';
18
18
  import { usageChipOf } from './limits-ui.js';
19
- /** Tasks the rail lists under a project; the rest are a count that links to the project's tasks. */
20
- const RAIL_TASKS = 6;
21
19
  /** Any header a proxy adds: a request carrying one is never "from this computer" (password freshness, one-time links). */
22
20
  const PROXY_HEADER = /^(forwarded|via|x-forwarded-.+|x-real-ip|x-client-ip|x-original-.+|x-cluster-client-ip|true-client-ip|cf-connecting-ip|fastly-client-ip|x-proxyuser-ip)$/i;
23
- /** Asks a project names for the chime (its count is complete beyond them). */
24
- const ASKS_PER_PROJECT = 100;
25
21
  /** How often the background checks look at what is due (docs/plans/get-side-effects.md), and how often this
26
22
  * computer's agent sign-ins are asked again. */
27
23
  const BACKGROUND_CHECKS_MS = 30_000;
28
24
  const LOCAL_AGENTS_EVERY_MS = 2 * 60_000;
29
- import { boundProjectSummary, browserCrewFromIndex, browserCrewOf, browserNavigationOf, browserProjectsOf, needsYouLabelOf } from './browser-workspace.js';
25
+ import { browserCrewFromIndex, browserCrewOf, browserNavigationOf, browserProjectsOf, needsYouLabelOf } from './browser-workspace.js';
30
26
  import { sharedActionPayload } from './chat-actions.js';
31
27
  import { prepareCodingContext } from './coding-context.js';
32
28
  import { CodingWorkspace } from './coding-workspace.js';
@@ -104,6 +100,7 @@ import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-r
104
100
  * repo, only runs whose task belongs to that repo (or to no repo yet).
105
101
  */
106
102
  import { createHash, randomBytes } from "node:crypto";
103
+ import { keepSlowRequest, Timeline } from "./latency-timeline.js";
107
104
  import { readFileSync } from "node:fs";
108
105
  import { createServer } from "node:http";
109
106
  import { homedir, hostname } from "node:os";
@@ -255,6 +252,9 @@ export function createDecisionServer(options) {
255
252
  };
256
253
  };
257
254
  const workspaceRevision = prepareWorkspaceRevision(store);
255
+ // The sidebar, the Tasks count and the crew: read at most once per workspace revision per reader's exact access
256
+ // (console-chrome.ts), and filled from a read worker before a page renders when one is attached.
257
+ const chromeReads = new RevisionCache(workspaceRevision, clock);
258
258
  const workspaceIncarnation = randomBytes(16).toString('hex');
259
259
  const workspaceValidators = new WorkspaceValidatorCache();
260
260
  const providerHome = options.connectionHome ?? homedir();
@@ -776,6 +776,9 @@ export function createDecisionServer(options) {
776
776
  // uses the address it reached), reads one row, and says nothing else about the installation.
777
777
  const method = request.method ?? "GET";
778
778
  const matchedRoute = matchRoute(method, hook.pathname);
779
+ // Where a slow answer's time went (latency-timeline.ts): milestones by route id only, kept when it took a second.
780
+ const timeline = new Timeline();
781
+ timeline.mark("route matched");
779
782
  const edgeRoute = matchedRoute?.stage === 'edge' ? matchedRoute : null;
780
783
  const wrongMethodEdge = edgeRoute === null ? ROUTES.find(row => row.stage === 'edge' && new RegExp(row.pattern).test(hook.pathname)) ?? null : null;
781
784
  // D7: a password on a request still signs in this release, announced on every answer and named in every refusal;
@@ -838,6 +841,7 @@ export function createDecisionServer(options) {
838
841
  return;
839
842
  const refusedToken = {};
840
843
  const who = identify(request, touch, edgeRoute?.domain === 'live', refusedToken);
844
+ timeline.mark("signed in");
841
845
  if (refusedToken.principal !== undefined) {
842
846
  if (refuseBudget(requestBudget.admit(refusedToken.principal.tokenId, "api")))
843
847
  return;
@@ -881,8 +885,12 @@ export function createDecisionServer(options) {
881
885
  get fresh() { return freshFacts.getStore() != null && ssoNowAvailable(who.name); } } : undefined,
882
886
  refusal: (answer, status, body) => sendScreen(answer, status, screen(status === 404 ? "Not found" : "Request refused", body, { chrome: chromeFor(who.via === "cookie" ? who.session.project : null, "work") })),
883
887
  workspaceRead,
888
+ freshWork: ["request", "task", "result"].some(name => url.searchParams.has(name)),
889
+ ...(workspaceRead && typeof request.headers['if-none-match'] === 'string' ? { workspaceIfNoneMatch: request.headers['if-none-match'] } : {}),
884
890
  workspaceRequest: url.searchParams.get('request'),
891
+ timeline,
885
892
  };
893
+ response.once("finish", () => keepSlowRequest(store, timeline, matchedRoute?.id ?? "unmatched", response.statusCode, clock()));
886
894
  if (method === "GET" || method === "POST")
887
895
  return freshFacts.run(fresh, () => provenPasswordAccount.run(passwordProof, () => requestContext.run(requestFacts, async () => {
888
896
  if (edgeRoute?.domain === 'live')
@@ -935,6 +943,17 @@ export function createDecisionServer(options) {
935
943
  facts.workspaceValidator = { key, revision, expiresAt,
936
944
  etag: '"' + createHash('sha256').update(`${key}:${revision}:${expiresAt}`).digest('hex') + '"' };
937
945
  }
946
+ await prefetchChrome(route, url, who);
947
+ timeline.mark("chrome and worker reads");
948
+ // The worker wait lets a sign-out, credential/access change or console ceiling change land. Prove the
949
+ // cookie and route again before rendering protected content or answering with a content validator.
950
+ if (who.via === 'cookie') {
951
+ const current = identify(request, false);
952
+ if (current?.via !== 'cookie' || current.session !== who.session)
953
+ return redirect(response, loginHref(url.pathname + url.search));
954
+ if (!projectRequestAllowed(route, route.project, url, who, request, response, null))
955
+ return;
956
+ }
938
957
  return handleGet(route, url, who, request, response);
939
958
  }
940
959
  return handlePost(route, url, who, request, response, body);
@@ -1443,6 +1462,7 @@ export function createDecisionServer(options) {
1443
1462
  * are what sensitivity strips.
1444
1463
  */
1445
1464
  function sendScreen(response, status, s) {
1465
+ requestContext.getStore()?.timeline?.mark("handler done; rendering");
1446
1466
  // Sensitivity is the page as authored (cockpit §2, R14): its own explicit word, else a password field it was written
1447
1467
  // with — judged before an identity-provider sign-in replaces that field, so an SSO step-up page stays sensitive.
1448
1468
  const authored = s.sensitive ?? (s.forceSensitive === true ||
@@ -1475,12 +1495,17 @@ export function createDecisionServer(options) {
1475
1495
  notices.push(s.chrome.modeBanner.words);
1476
1496
  if (s.chrome.updateWaiting)
1477
1497
  notices.push(s.chrome.updateWaiting.words);
1498
+ if (options.draining?.() === true)
1499
+ notices.push('Toolroll is restarting (draining). Saved work is kept; new builds start once it is back.');
1478
1500
  let crew = { crew: [], crewTruncated: false };
1479
1501
  try {
1480
1502
  const project = s.chrome.active === 'chat' ? null : s.chrome.project;
1481
1503
  crew = extras.team?.tasks ? browserCrewFromIndex(extras.team.tasks, extras.team.selected?.id) : requestFacts.workCrew?.project === project ? browserCrewFromIndex(requestFacts.workCrew.page)
1482
1504
  // The reader's own lead's claims read "<name> is on it" here too.
1483
- : browserCrewOf(store, clock(), { principal: 'operator', repos: managedRepos(), includeUnplaced: false, viewer: requestFacts.actor ?? null }, { evidenceRoot, project });
1505
+ : (() => {
1506
+ const access = { principal: 'operator', repos: managedRepos(), includeUnplaced: false, viewer: requestFacts.actor ?? null };
1507
+ return chromeRead(`crew\u0000${accessKey(access)}\u0000${project ?? ''}`, () => browserCrewOf(store, clock(), access, { evidenceRoot, project }));
1508
+ })();
1484
1509
  }
1485
1510
  catch {
1486
1511
  notices.push('Crew updates are unavailable. Open Tasks to inspect saved work.');
@@ -1586,7 +1611,17 @@ export function createDecisionServer(options) {
1586
1611
  workspaceValidators.set(validator.key, validator);
1587
1612
  response.setHeader('ETag', validator.etag);
1588
1613
  }
1589
- return respond(response, status, 'application/json; charset=utf-8', JSON.stringify(workspace));
1614
+ const body = JSON.stringify(workspace);
1615
+ // Every other page's re-read after a live `change` (chat keeps its own validator, and its receipt, task and
1616
+ // result reads none): when what it renders now is exactly what the page holds, it hears 304 and keeps its view
1617
+ // (no transfer, no re-render).
1618
+ if (status === 200 && validator === undefined && path.pathname !== '/chat') {
1619
+ const etag = '"w' + createHash('sha256').update(body).digest('base64url') + '"';
1620
+ response.setHeader('ETag', etag);
1621
+ if (requestFacts.workspaceIfNoneMatch === etag)
1622
+ return respond(response, 304, 'application/json; charset=utf-8', '');
1623
+ }
1624
+ return respond(response, status, 'application/json; charset=utf-8', body);
1590
1625
  }
1591
1626
  const nonce = randomBytes(16).toString('base64');
1592
1627
  // React owns the conversation and its refresh/draft lifecycle. Native
@@ -1623,8 +1658,9 @@ export function createDecisionServer(options) {
1623
1658
  return page(response, status, document, nonce, s.functional?.fetches === true || chromeLayer || sensitiveChrome !== "");
1624
1659
  }
1625
1660
  /** The needs-you count every surface wears, read from this request's own
1626
- * Work counts (one query per request, never a cached earlier one), so a
1627
- * live page's badge and its Needs you tab always agree. */
1661
+ * Work counts (one read per request, kept only while the workspace revision
1662
+ * it was read at stands), so a live page's badge and its Needs you tab
1663
+ * always agree. */
1628
1664
  function needsYouBadge(project) {
1629
1665
  return needsYouCount(project);
1630
1666
  }
@@ -1653,44 +1689,8 @@ export function createDecisionServer(options) {
1653
1689
  function projectSummary() {
1654
1690
  const projects = sidebarProjects();
1655
1691
  const actor = requestContext.getStore()?.actor ?? '';
1656
- const states = new Map(projectStates(store, workAccess(), actor, clock(), evidenceRoot, projects.map(one => one.path)).map(one => [one.repo, one]));
1657
- // A row that needs a person or failed says what the Tasks list says it needs (its own next step); a row at work says
1658
- // what its agent did last; a finished one says Done.
1659
- const asking = [...states.values()].flatMap(one => one.families.slice(0, RAIL_TASKS)).filter(one => one.state === 'needs-you' || one.state === 'failed').map(one => one.rootId);
1660
- const needWords = new Map();
1661
- if (asking.length > 0) {
1662
- try {
1663
- // Every row the rail may list, in pages the index allows.
1664
- for (let at = 0; at < asking.length; at += WORK_INDEX_MAX_LIMIT)
1665
- for (const item of workIndexPage(store, clock(), workAccess(), { roots: asking.slice(at, at + WORK_INDEX_MAX_LIMIT), limit: WORK_INDEX_MAX_LIMIT, root: evidenceRoot }).items)
1666
- needWords.set(item.rootId, item.status.detail !== '' ? item.status.detail : item.status.label);
1667
- }
1668
- catch { /* the rows keep their state's words */ }
1669
- }
1670
- // The chime's subjects: an opaque key per obligation and version, scoped to this reader (no words, no ids in it).
1671
- const keyOf = (...parts) => createHash('sha256').update([actor, ...parts].join('\u0000')).digest('base64url').slice(0, 22);
1672
- // Newest first, so a new ask always has a key however many older ones wait (those were heard when they were new).
1673
- const asksOf = (state) => [
1674
- ...(state?.families ?? []).flatMap(one => one.ask === undefined ? [] : [{ at: one.ask.at, ask: { key: keyOf('task', one.rootId, one.ask.version), task: one.rootId, run: one.ask.run, at: one.ask.at } }]),
1675
- ...(state?.flowAsks ?? []).map(one => ({ at: one.at, ask: { key: keyOf('flow', one.card, one.stage, one.entry), task: null, run: null, at: one.at } })),
1676
- ].sort((a, b) => b.at.localeCompare(a.at)).slice(0, ASKS_PER_PROJECT).map(one => one.ask);
1677
- const railOf = (state) => {
1678
- const families = state?.families ?? [];
1679
- const tasks = families.slice(0, RAIL_TASKS).flatMap((one) => {
1680
- const task = store.getTask(one.rootId);
1681
- const line = one.state === 'needs-you' || one.state === 'failed' ? needWords.get(one.rootId) ?? null : one.line;
1682
- return task === null ? [] : [{ id: one.rootId, title: task.title, href: `/t/${encodeURIComponent(one.rootId)}`, state: one.state, line: line ?? STATE_WORDS[one.state] }];
1683
- });
1684
- return { tasks, more: Math.max(0, families.length - RAIL_TASKS), asks: asksOf(state) };
1685
- };
1686
- const rows = projects.map(one => {
1687
- const state = states.get(one.path);
1688
- return { path: one.path, name: one.name, href: `/work?project=${encodeURIComponent(one.path)}`, state: state?.state ?? 'idle', counts: state?.counts ?? { ...NO_COUNTS }, ...railOf(state) };
1689
- });
1690
- const unplaced = states.get(null);
1691
- if (unplaced !== undefined && unplaced.state !== 'idle')
1692
- rows.push({ path: null, name: 'No project', href: '/work', state: unplaced.state, counts: unplaced.counts, ...railOf(unplaced) });
1693
- return boundProjectSummary(rows);
1692
+ const access = workAccess();
1693
+ return chromeRead(summaryKey(actor, access, projects), () => projectSummaryOf(store, clock(), access, actor, projects, evidenceRoot));
1694
1694
  }
1695
1695
  /** What one account may see now, as the summary room keys it: a narrowed ceiling or grant is a different key, so a
1696
1696
  * snapshot read for the wider scope never reaches it. Null: no such account. */
@@ -1777,13 +1777,15 @@ export function createDecisionServer(options) {
1777
1777
  ...(scope === undefined ? {} : { scope }),
1778
1778
  };
1779
1779
  }
1780
- // All project counts are read together. No task artifacts or processes are
1781
- // inspected to paint a badge or a project switcher.
1780
+ // All project counts are read together, at most once per workspace revision
1781
+ // for this reader's access. No task artifacts or processes are inspected to
1782
+ // paint a badge or a project switcher.
1782
1783
  function projectCounts(now) {
1783
1784
  const facts = requestContext.getStore();
1784
1785
  if (facts?.workCounts !== undefined)
1785
1786
  return facts.workCounts;
1786
- const counts = workCountsByProject(store, now, workAccess());
1787
+ const access = workAccess();
1788
+ const counts = chromeRead(countsKey(access), () => workCountsByProject(store, now, access));
1787
1789
  if (facts !== undefined)
1788
1790
  facts.workCounts = counts;
1789
1791
  return counts;
@@ -1793,6 +1795,64 @@ export function createDecisionServer(options) {
1793
1795
  .reduce((sum, one) => sum + one.totals['needs-you'], 0);
1794
1796
  return { count, saturated: false };
1795
1797
  }
1798
+ /** One work index page for an already-admitted reader, through the chrome cache: a whole view the chat landing reads
1799
+ * on every refresh. */
1800
+ function sharedIndexPage(access, options) {
1801
+ // Receipt reconciliation, task and result reads always read work afresh (disk state included).
1802
+ if (requestContext.getStore()?.freshWork === true)
1803
+ return workIndexPage(store, clock(), access, options);
1804
+ return chromeRead(indexKey(access, options), () => workIndexPage(store, clock(), access, options));
1805
+ }
1806
+ /** A chrome read for this request: what it already read (its prefetch included) stands for the whole request, else
1807
+ * the revision cache's value, else `compute()` now. */
1808
+ function chromeRead(key, compute) {
1809
+ const facts = requestContext.getStore();
1810
+ const held = facts?.chromeReads?.get(key);
1811
+ if (held !== undefined)
1812
+ return held;
1813
+ const value = chromeReads.read(key, compute);
1814
+ if (facts !== undefined)
1815
+ (facts.chromeReads ??= new Map()).set(key, value);
1816
+ return value;
1817
+ }
1818
+ /** The chat landing's three whole views (chat.ts chatHomeOf), as it asks for them. */
1819
+ const chatHomeViews = () => ["all", "needs-you", "completed"]
1820
+ .map(view => ({ view, limit: WORK_INDEX_MAX_LIMIT, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) }));
1821
+ /** Pages that wear the chrome (the sidebar's states and the Tasks count). */
1822
+ const CHROME_PAGES = new Set(["home", "work", "board", "agents", "agents.live", "task.page", "run.page", "chat.page", "review", "tasks", "inbox",
1823
+ "next", "done", "projects.page", "flows.page", "flow.page", "settings.page", "decision.page", "menu"]);
1824
+ /** Before a chromed page renders: its chrome reads (and the chat landing's views) run on a read worker, so the
1825
+ * request thread finds them cached instead of computing them. The reader is the one this request admitted; the keys
1826
+ * carry its exact access (console-chrome.ts). Without a worker, or when one fails, the page computes them as before. */
1827
+ async function prefetchChrome(route, url, who) {
1828
+ if (reads === null || who.via !== "cookie" || !CHROME_PAGES.has(route.id))
1829
+ return;
1830
+ try {
1831
+ const actor = requestContext.getStore()?.actor ?? "";
1832
+ const access = workAccess(), projects = sidebarProjects(), now = clock();
1833
+ // Populate only the revision cache. Each answer must still pass its revision/expiry check when rendering:
1834
+ // another job can remain pending while an earlier answer's approval or Needs-you state changes.
1835
+ const fill = async (key, read) => { await chromeReads.fill(key, read); };
1836
+ const jobs = [
1837
+ fill(countsKey(access), () => reads.workCounts(now, access)),
1838
+ fill(summaryKey(actor, access, projects), () => reads.projectSummary(now, access, actor, projects, evidenceRoot)),
1839
+ ];
1840
+ if (route.id === "chat.page") {
1841
+ if (!["project", "room", "team"].some(name => url.searchParams.has(name)) && requestContext.getStore()?.freshWork !== true)
1842
+ for (const options of chatHomeViews())
1843
+ jobs.push(fill(indexKey(access, options), () => reads.workIndexPage(now, access, options)));
1844
+ // The conversation's briefing (chat.ts: the fleet snapshot's states), read as chat reads it.
1845
+ const repos = managedRepos();
1846
+ if (repos.length > 0 && requestContext.getStore()?.freshWork !== true) {
1847
+ const fleet = { principal: "operator", repos, includeUnplaced: false, viewer: who.name };
1848
+ const options = { limit: 100, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) };
1849
+ jobs.push(fill(indexKey(fleet, options), () => reads.workIndexPage(now, fleet, options)));
1850
+ }
1851
+ }
1852
+ await Promise.all(jobs);
1853
+ }
1854
+ catch { /* the page computes what is missing */ }
1855
+ }
1796
1856
  /** A live page's own beat without a docked chat: 10 s while any admitted task is building, else 30 s. */
1797
1857
  function liveRefreshSeconds() {
1798
1858
  return projectCounts(clock()).some(one => one.totals.running > 0) ? 10 : 30;
@@ -2073,6 +2133,7 @@ export function createDecisionServer(options) {
2073
2133
  get projectFamilyPeek() { return projectFamilyPeek; },
2074
2134
  get needsYouBadge() { return needsYouBadge; },
2075
2135
  get liveRefreshSeconds() { return liveRefreshSeconds; },
2136
+ get sharedIndexPage() { return sharedIndexPage; },
2076
2137
  get chatKeyFor() { return chatKeyFor; },
2077
2138
  get chatCatalog() { return chatCatalog; },
2078
2139
  get chatCatalogKnown() { return chatCatalogKnown; },
@@ -44,7 +44,7 @@ import {} from "./render-pages.js";
44
44
  import { completionForm, hardBlockerOf, homePhaseWords, owedAcceptanceOf, resultPanelHtml } from "./render-results.js";
45
45
  import { browserCaller, oneLineUa } from "./session.js";
46
46
  export function createChatHandlers(runtime) {
47
- const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, leadSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, leadPrincipal, taskChatFocus, chatScopeOf, leadConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, startLeadConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, chatCatalogKnown, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
47
+ const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, leadSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, leadPrincipal, taskChatFocus, chatScopeOf, leadConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, sharedIndexPage, startLeadConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, chatCatalogKnown, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
48
48
  // The read-only refresh (package 2): the same JSON status as before —
49
49
  // session binding, the send receipt, the live turn — plus a version
50
50
  // of the DISPLAYED facts and, only when the caller's version differs,
@@ -273,7 +273,7 @@ export function createChatHandlers(runtime) {
273
273
  if (repos.length > 0) {
274
274
  try {
275
275
  fleetSnapshot = store.chatSnapshot(repos, now);
276
- const summaries = workIndexPage(store, now, { principal: 'operator', repos, includeUnplaced: false, viewer: who.name }, { limit: 100, root: evidenceRoot }).items;
276
+ const summaries = sharedIndexPage({ principal: 'operator', repos, includeUnplaced: false, viewer: who.name }, { limit: 100, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) }).items;
277
277
  fleetSnapshot.assignmentStates = Object.fromEntries(summaries.flatMap(value => [value.rootId, value.activeTaskId].map(id => [id, { state: value.assignmentState, label: value.status.label, detail: value.status.detail }])));
278
278
  fleetSnapshot.attentionCount = needsYouBadge(null);
279
279
  }
@@ -1452,9 +1452,9 @@ export function createChatHandlers(runtime) {
1452
1452
  agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt,
1453
1453
  activity: runActivityOf(store, run, now) };
1454
1454
  });
1455
- const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT, root: evidenceRoot });
1456
- const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT, root: evidenceRoot });
1457
- const done = workIndexPage(store, now, access, { view: "completed", limit: WORK_INDEX_MAX_LIMIT, root: evidenceRoot });
1455
+ // The three whole views, read at most once per workspace revision (and on a read worker first when one is attached).
1456
+ const view = (name) => sharedIndexPage(access, { view: name, limit: WORK_INDEX_MAX_LIMIT, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) });
1457
+ const all = view("all"), needs = view("needs-you"), done = view("completed");
1458
1458
  const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
1459
1459
  const counts = [
1460
1460
  { key: "working", label: "Working now", value: agents.length, href: "/work?view=running" },
@@ -148,6 +148,8 @@ export type ServeOptions = {
148
148
  ssoFetch?: typeof fetch;
149
149
  /** Cockpit B1: this `up` console's instance secret (null: none — the instance route answers 404). */
150
150
  instanceSecret?: () => string | null;
151
+ /** v137: whether this console is draining (its `up` is stopping): /healthz says so at once, and pages say Toolroll is restarting. */
152
+ draining?: () => boolean;
151
153
  /** Cockpit E: how Finder is asked to reveal a proved checkout (tests pass their own; never a launch). True: shown. */
152
154
  revealPath?: (path: string) => Promise<boolean> | boolean;
153
155
  chatEnv?: Record<string, string | undefined>;
@@ -43,6 +43,7 @@ import { healthWords } from "../server-telemetry.js";
43
43
  import { tally } from "../summary.js";
44
44
  import { serveInstallAsset } from "./pages-assets.js";
45
45
  import { healthz } from "./remote-hooks.js";
46
+ import { slowRequestLines, SLOW_REQUEST_MS } from "../latency-timeline.js";
46
47
  import { leadsStillAdmitted, leadTilesFor } from './agents-live-lead.js';
47
48
  import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
48
49
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
@@ -627,9 +628,12 @@ export function createPagesHandlers(runtime) {
627
628
  async function health(ctx) {
628
629
  const { request, response } = ctx;
629
630
  const health = store.telemetry.snapshot();
631
+ // Beside /healthz, for an instance operator: where the slowest recent console answers spent their time (route ids
632
+ // and milestones only).
633
+ const slow = slowRequestLines(store);
630
634
  return request.headers.accept?.includes('application/json')
631
- ? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify(health))
632
- : respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n`);
635
+ ? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify({ ...health, slowRequests: slow }))
636
+ : respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n${slow.length === 0 ? "" : `\nSlow console requests (over ${SLOW_REQUEST_MS} ms), newest first:\n${slow.join("\n")}\n`}`);
633
637
  }
634
638
  // v104: Prometheus metrics, for an instance operator (a scraper sends one's API token).
635
639
  async function metrics(ctx) {
@@ -984,7 +988,7 @@ export function createPagesHandlers(runtime) {
984
988
  }
985
989
  async function healthzEdge(ctx) {
986
990
  const { response, method } = ctx;
987
- return healthz(store, response, method === 'HEAD');
991
+ return healthz(store, response, method === 'HEAD', options.draining?.() === true);
988
992
  }
989
993
  async function browserAsset(ctx) {
990
994
  const { request, response, url } = ctx;
@@ -26,4 +26,4 @@ export declare function telegramHook(ctx: RemoteHookContext, request: IncomingMe
26
26
  export declare function flowHook(ctx: RemoteHookContext, request: IncomingMessage, response: ServerResponse, url: URL): Promise<void>;
27
27
  /** A shared button as a public form (v84): its questions, and one card per submission. No sign-in, no session, nothing about the flow shown. */
28
28
  export declare function flowForm(ctx: RemoteHookContext, request: IncomingMessage, response: ServerResponse, url: URL): Promise<void>;
29
- export declare function healthz(store: Store, response: ServerResponse, head: boolean): void;
29
+ export declare function healthz(store: Store, response: ServerResponse, head: boolean, draining?: boolean): void;
@@ -122,7 +122,7 @@ export async function flowForm(ctx, request, response, url) {
122
122
  return page({ status: 500, html: "" });
123
123
  }
124
124
  }
125
- export function healthz(store, response, head) {
125
+ export function healthz(store, response, head, draining = false) {
126
126
  let healthy = true;
127
127
  try {
128
128
  store.handle.prepare("SELECT 1 FROM schema_version").get();
@@ -130,7 +130,9 @@ export function healthz(store, response, head) {
130
130
  catch {
131
131
  healthy = false;
132
132
  }
133
- const body = JSON.stringify({ status: healthy ? "ok" : "unavailable" });
134
- response.writeHead(healthy ? 200 : 503, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
133
+ // A draining console (its `up` is stopping) still answers pages, but says so here at once: a probe moves on instead
134
+ // of waiting for it to disappear.
135
+ const body = JSON.stringify(draining && healthy ? { status: "draining", message: "Toolroll is restarting (draining)" } : { status: healthy ? "ok" : "unavailable" });
136
+ response.writeHead(healthy && !draining ? 200 : 503, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
135
137
  response.end(head ? undefined : body);
136
138
  }
@@ -185,6 +185,8 @@ export type ReviewCockpitView = {
185
185
  efficiency?: RunEfficiency | null;
186
186
  /** What the run's network was and what its proxy saw (egress.ts): display only. */
187
187
  network?: RunNetworkRecord | null;
188
+ /** Where the time went (latency-timeline.ts): its release check's parts and the task's deploys, display only. */
189
+ timing?: import("../latency-timeline.js").TimingView | null;
188
190
  /** How far its checks reached (check-access.ts): shown only when wider than sandboxed. */
189
191
  checkAccess?: CheckAccess;
190
192
  taskId: string;
@@ -625,6 +625,7 @@ export function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab,
625
625
  notes: view.notes.map(one => ({ author: one.author, at: one.createdAt, note: one.note })),
626
626
  efficiency: view.efficiency ?? null,
627
627
  network: view.network ?? null,
628
+ timing: view.timing ?? null,
628
629
  acts: { primary: null, secondary: null, line: null }, runChecks: null, mismatch: null,
629
630
  // The raw run record lives under Details now (2026-10-02): /r/<id> for this result redirects here.
630
631
  record: run === null ? null : { build: run.id, href: `/r/${run.id}?record=1`, facts: [
@@ -533,6 +533,8 @@ export declare function taskBodyParts(data: {
533
533
  kind: "checking" | "approved";
534
534
  candidate: string;
535
535
  } | null;
536
+ /** A release task filed while its pull request's CI still runs (a person may approve now; nothing deploys before CI passes). */
537
+ releaseCiRunning?: boolean;
536
538
  /** One approval per release (release-once.md): the release this task belongs to, and whether this reader may stop it. */
537
539
  release?: ReleaseView | null;
538
540
  canStopRelease?: boolean;