toolroll 0.9.77 → 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 (72) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/agent-fence.js +18 -2
  3. package/dist/backup-ui.d.ts +9 -1
  4. package/dist/backup-ui.js +28 -3
  5. package/dist/backup.js +4 -3
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +18 -18
  8. package/dist/browser-workspace.d.ts +2 -0
  9. package/dist/builder.d.ts +4 -3
  10. package/dist/builder.js +14 -8
  11. package/dist/check-failure.js +31 -3
  12. package/dist/check-report.d.ts +18 -3
  13. package/dist/check-report.js +67 -7
  14. package/dist/claim.d.ts +9 -0
  15. package/dist/claim.js +9 -1
  16. package/dist/contracts/console-api.d.ts +1 -1
  17. package/dist/contracts/console-api.js +1 -1
  18. package/dist/contracts/release-terms.d.ts +41 -0
  19. package/dist/contracts/release-terms.js +53 -0
  20. package/dist/controller-service.js +4 -1
  21. package/dist/deploy-rescue.d.ts +41 -0
  22. package/dist/deploy-rescue.js +94 -0
  23. package/dist/desktop-update.d.ts +1 -1
  24. package/dist/dispose.js +4 -3
  25. package/dist/evidence.d.ts +9 -0
  26. package/dist/evidence.js +15 -3
  27. package/dist/exec.d.ts +2 -0
  28. package/dist/exec.js +15 -1
  29. package/dist/guides.js +18 -8
  30. package/dist/latency-timeline.d.ts +46 -0
  31. package/dist/latency-timeline.js +120 -0
  32. package/dist/live-backup.d.ts +39 -0
  33. package/dist/live-backup.js +271 -0
  34. package/dist/operate.js +203 -16
  35. package/dist/planner.js +2 -2
  36. package/dist/release-adapters.d.ts +32 -1
  37. package/dist/release-adapters.js +99 -7
  38. package/dist/release-carry.d.ts +21 -1
  39. package/dist/release-carry.js +22 -0
  40. package/dist/release-cli.d.ts +3 -0
  41. package/dist/release-cli.js +74 -5
  42. package/dist/release-gate.d.ts +5 -0
  43. package/dist/release-gate.js +1 -1
  44. package/dist/release-recheck.js +3 -2
  45. package/dist/release.d.ts +37 -4
  46. package/dist/release.js +128 -43
  47. package/dist/runner-process.d.ts +67 -0
  48. package/dist/runner-process.js +219 -0
  49. package/dist/runner.d.ts +48 -6
  50. package/dist/runner.js +71 -8
  51. package/dist/scope.d.ts +19 -1
  52. package/dist/scope.js +19 -2
  53. package/dist/scout.js +2 -2
  54. package/dist/serve.js +11 -0
  55. package/dist/server/http.d.ts +2 -0
  56. package/dist/server/pages.js +7 -3
  57. package/dist/server/remote-hooks.d.ts +1 -1
  58. package/dist/server/remote-hooks.js +5 -3
  59. package/dist/server/render-results.d.ts +2 -0
  60. package/dist/server/render-results.js +1 -0
  61. package/dist/server/request-context.d.ts +2 -0
  62. package/dist/server/settings.js +22 -2
  63. package/dist/server/tasks.js +4 -1
  64. package/dist/storage-sweep.js +1 -1
  65. package/dist/store.d.ts +125 -2
  66. package/dist/store.js +250 -12
  67. package/dist/surface.js +3 -1
  68. package/dist/teardown-git.js +5 -5
  69. package/dist/toolroll-update.js +2 -2
  70. package/dist/up-role-child.js +6 -2
  71. package/dist/workspace-revision.js +3 -1
  72. package/package.json +1 -1
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `toolroll release --recover-deploy [--stage <dir>] [--yes]`: one command for a browser deployment that stopped half
3
+ * way and could not put itself back (the 2026-10-29 deploy stuck at "stopping": an `up` left running under launchd
4
+ * that ignored SIGTERM, a restart refused while its stale heartbeat lapsed, and every resume refusing "not proved
5
+ * stopped"). It does what was done by hand, through the deployment's own recovery (scripts/deploy-browser.mjs
6
+ * --phase recover, run from the recorded commit's pristine extraction exactly as `toolroll release` resumes one):
7
+ *
8
+ * - the journal is verified (one unfinished deployment of this database, its run recorded) under the deployment lock
9
+ * - when the journal says the old service must be stopped, recovery requires proof it already exited; historical
10
+ * PIDs and argv never authorize signals or booting out a currently loaded service. Remaining processes must be
11
+ * stopped through their owner before recovery can resume.
12
+ * - the previous service is started again (the verified backup first when the database was migrated past it) and
13
+ * waited for past a stale worker heartbeat's window, then the deployment's update pause is lifted
14
+ *
15
+ * A journal at or past the new service's start is never touched here: the new service may be running, and the
16
+ * release resumes it forward. Finished journals need nothing. Run again after a refusal or a failure: every step
17
+ * re-proves itself, so a rerun does only what is still left.
18
+ */
19
+ import { realpathSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { BEFORE_SWAP } from "./release-gate.js";
22
+ /** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
23
+ export const FORWARD_ONLY = Object.freeze(["starting", "started", "healthy"]);
24
+ const FINISHED = Object.freeze(["released", "deployed"]);
25
+ const real = (path) => { try {
26
+ return realpathSync(path);
27
+ }
28
+ catch {
29
+ return path;
30
+ } };
31
+ /** What recovery will do from a phase, in a few words. */
32
+ export function rescuePlan(phase, journal) {
33
+ const id = String(journal["id"] ?? "?"), prior = String(journal["priorRuntime"] ?? "?");
34
+ if (BEFORE_SWAP.includes(phase))
35
+ return [`The service was never stopped. Lifts update pause ${id}, if this deployment holds it.`];
36
+ if (phase === "restored")
37
+ return [`The previous service is already running again. Lifts update pause ${id}.`];
38
+ const words = [];
39
+ if (phase === "stopping") {
40
+ words.push("The old service must already be stopped. Recovery checks for remaining processes and refuses while any may still be running. Stop them through the service owner, then rerun recovery.");
41
+ }
42
+ if (["stopping", "stopped", "migrating", "migrated", "start-failed", "backup-restored"].includes(phase)) {
43
+ if (["migrating", "migrated", "start-failed"].includes(phase))
44
+ words.push("Puts the verified backup back first when the previous runtime can't read the migrated database.");
45
+ words.push(`Starts the previous service (${prior}) again and waits for it to answer, past a stopped worker's heartbeat window; then lifts update pause ${id}.`);
46
+ }
47
+ return words.length > 0 ? words : [`Journal phase ${phase}: the deployment's own recovery decides; it refuses anything it can't prove.`];
48
+ }
49
+ /** Find the one unfinished deployment (or the named stage), verify its journal, and recover it (with `yes`). */
50
+ export async function rescueDeployment(stageArg, yes, world) {
51
+ const all = world.journals();
52
+ let chosen;
53
+ if (stageArg !== undefined) {
54
+ chosen = all.find(one => real(one.stage) === real(stageArg));
55
+ if (chosen === undefined)
56
+ return { ok: false, reason: "usage", message: `${stageArg} is not a deployment stage of this installation (under ${join(world.stateDir, "staged-upgrades")}).` };
57
+ }
58
+ else {
59
+ const open = all.filter(one => one.journal === null || !FINISHED.includes(String(one.journal["phase"])));
60
+ if (open.length === 0)
61
+ return { ok: true, acted: false, stage: null, phase: null, words: ["Nothing to recover: every deployment finished or was already put back."] };
62
+ if (open.length > 1)
63
+ return { ok: false, reason: "conflict", message: `${open.length} deployments are unfinished: ${open.map(one => one.stage).join(", ")}. Name one with --stage <dir>.` };
64
+ chosen = open[0];
65
+ }
66
+ const { stage, journal } = chosen;
67
+ if (journal === null)
68
+ return { ok: false, reason: "damaged", message: `${join(stage, "deployment.json")} can't be read; nothing was changed.` };
69
+ const phase = journal["phase"], run = journal["builder"];
70
+ if (typeof phase !== "string" || typeof journal["priorRuntime"] !== "string" || typeof journal["nextRuntime"] !== "string" || typeof journal["id"] !== "string") {
71
+ return { ok: false, reason: "damaged", message: `${join(stage, "deployment.json")} is missing its phase, runtimes or update id; nothing was changed.` };
72
+ }
73
+ if (journal["database"] !== world.database)
74
+ return { ok: false, reason: "conflict", message: `That deployment ran on ${String(journal["database"])}, not ${world.database}; nothing was changed.` };
75
+ if (!Number.isSafeInteger(run) || run < 1)
76
+ return { ok: false, reason: "damaged", message: `${join(stage, "deployment.json")} names no builder run, so its commit's recovery can't be found; nothing was changed.` };
77
+ if (FINISHED.includes(phase))
78
+ return { ok: true, acted: false, stage, phase, words: [`Nothing to recover: that deployment is ${phase}.`] };
79
+ if (FORWARD_ONLY.includes(phase)) {
80
+ return { ok: false, reason: "forward", message: `That deployment is at ${phase}: the new service may be running, so nothing was stopped. Continue it forward with \`toolroll release <branch>\` (it resumes the deployment's finish).` };
81
+ }
82
+ const plan = rescuePlan(phase, journal);
83
+ if (!yes)
84
+ return { ok: true, acted: false, stage, phase, words: [`Deployment ${String(journal["id"])} stopped at ${phase} (${stage}).`, ...plan, "Run it: toolroll release --recover-deploy --yes"] };
85
+ const worktree = world.runWorktree(run);
86
+ if (worktree === null)
87
+ return { ok: false, reason: "damaged", message: `Run ${String(run)} has no recorded worktree, so its commit's recovery can't run; nothing was changed.` };
88
+ const recovered = await world.recover({ run: run, worktree, stage });
89
+ const after = world.journals().find(one => one.stage === stage)?.journal ?? null;
90
+ const now = typeof after?.["phase"] === "string" ? after["phase"] : null;
91
+ if (!recovered.ok)
92
+ return { ok: false, reason: "failed", message: `${recovered.message}${now === null ? "" : ` The journal is at ${now}.`} Fix what it names, then run \`toolroll release --recover-deploy --yes\` again.` };
93
+ return { ok: true, acted: true, stage, phase: now, words: [now === "released" ? "The previous service is running and new work can start again." : `The deployment's recovery finished; its journal is at ${now ?? "an unreadable phase"}.`] };
94
+ }
@@ -97,6 +97,7 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
+ version?: never;
100
101
  active: boolean;
101
102
  phase: string;
102
103
  detail: string;
@@ -110,7 +111,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
110
111
  wasRunning?: never;
111
112
  backupPath?: never;
112
113
  updateId?: never;
113
- version?: never;
114
114
  buildId?: never;
115
115
  workDir?: never;
116
116
  } | {
package/dist/dispose.js CHANGED
@@ -23,7 +23,7 @@ import { nightlyTaskRef, releaseTaskRef } from "./store.js";
23
23
  import {} from "./proof.js";
24
24
  import { verificationEvidence, failedVerificationEvidence } from "./verification-evidence.js";
25
25
  import { classifyGateFailure, describeGateFailure, gateFailureSummary } from "./gate-failure.js";
26
- import { propose, approve, liveReleaseOf } from "./scope.js";
26
+ import { propose, approve, liveReleaseOf, releaseCheckScope } from "./scope.js";
27
27
  import { headWithin } from "./names.js";
28
28
  /** The reasons tick classifies as the attempt itself breaking. */
29
29
  const TICK_FAILURE_REASONS = new Set([
@@ -456,14 +456,15 @@ export function regateTask(store, taskId, now, approval) {
456
456
  return { ok: false, reason: "no-attempt", message: `${taskId} has no scope` };
457
457
  const head = last.headRevision;
458
458
  const actor = approval.kind === "operator" ? approval.name : approval.kind === "release" ? approval.name : `mode ${approval.name}`;
459
- if (approval.kind === "release" && ((scope.candidate ?? null) === null || (scope.releaseTerms ?? null) === null)) {
459
+ // A release task: signed terms or a release check purpose (pipeline-speed.md, P0), each sealed with the approval.
460
+ if (approval.kind === "release" && ((scope.candidate ?? null) === null || !releaseCheckScope(scope))) {
460
461
  return { ok: false, reason: "not-approved", message: `${taskId} isn't a release candidate; only a release reruns under its own approval` };
461
462
  }
462
463
  // A prepared candidate (a release gate, the nightly check) already names the approved commit, and the builder loads
463
464
  // exactly its tree on every attempt. The rerun keeps that scope and its approval as they are: nothing is proposed or
464
465
  // sealed again, so a regate can never swap the approved commit, its release terms or the person's yes for others
465
466
  // (2026-10-27: a regate rewrote release-0.9.73's candidate to its own build commit and the release refused it).
466
- if ((scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null) {
467
+ if ((scope.candidate ?? null) !== null || releaseCheckScope(scope)) {
467
468
  const candidate = scope.candidate ?? null;
468
469
  if (candidate === null || scope.approvedDigest !== scope.digest || last.scopeDigest !== scope.digest) {
469
470
  return { ok: false, reason: "not-approved", message: `${taskId}'s approved commit or scope changed since attempt #${last.id}, or its approval no longer stands; approve its scope again instead` };
@@ -73,6 +73,15 @@ export declare function isBinaryAssetPath(path: string): boolean;
73
73
  export declare const SHORTENED_MARKER = "\n\u2026 output shortened; ending follows \u2026\n";
74
74
  export declare function boundStreamHeadTail(value: string, cap: number, headShare?: number): string;
75
75
  export declare const EVIDENCE_CAPS: Record<Artifact["kind"], number>;
76
+ /**
77
+ * The bound a writer may seal a kind at with storeEvidence's `cap`, above its default: a release check's sealed report and
78
+ * inventory (check-report.ts CHECK_ARTIFACT_CAP, 256 KiB). Readers accept exactly what writers may seal: 0.9.77 sealed
79
+ * reports up to 256 KiB but read structured output back only to 64 KiB, so every full report over that (~85 KiB for a
80
+ * 585-file suite) read as "no longer verifies" and the release's check as unavailable.
81
+ */
82
+ export declare const SEALED_EVIDENCE_CAPS: Partial<Record<Artifact["kind"], number>>;
83
+ /** The most a stored artifact of this kind may hold: its default cap, or the larger bound a sealed payload may use. */
84
+ export declare function evidenceReadCap(kind: Artifact["kind"]): number;
76
85
  export declare function evidenceRoot(home: string): string;
77
86
  /**
78
87
  * Unpredictable per attempt, so nothing on disk before this build can already
package/dist/evidence.js CHANGED
@@ -116,6 +116,17 @@ export const EVIDENCE_CAPS = {
116
116
  // to hold the JSON-escaped form of that bounded content plus provenance.
117
117
  "review-context": 200 * 1024 * 1024,
118
118
  };
119
+ /**
120
+ * The bound a writer may seal a kind at with storeEvidence's `cap`, above its default: a release check's sealed report and
121
+ * inventory (check-report.ts CHECK_ARTIFACT_CAP, 256 KiB). Readers accept exactly what writers may seal: 0.9.77 sealed
122
+ * reports up to 256 KiB but read structured output back only to 64 KiB, so every full report over that (~85 KiB for a
123
+ * 585-file suite) read as "no longer verifies" and the release's check as unavailable.
124
+ */
125
+ export const SEALED_EVIDENCE_CAPS = { "structured-output": 256 * 1024 };
126
+ /** The most a stored artifact of this kind may hold: its default cap, or the larger bound a sealed payload may use. */
127
+ export function evidenceReadCap(kind) {
128
+ return Math.max(EVIDENCE_CAPS[kind], SEALED_EVIDENCE_CAPS[kind] ?? 0);
129
+ }
119
130
  export function evidenceRoot(home) {
120
131
  // Same continuity rule as the database: evidence recorded under an older
121
132
  // name keeps verifying until a new-name root exists.
@@ -398,8 +409,9 @@ export function imageDimensions(bytes, kind) {
398
409
  * store the caller passed — inside whatever transaction the caller holds.
399
410
  */
400
411
  export function storeEvidence(store, root, runId, kind, name, content, capture, now, options = {}) {
401
- // `cap`: a sealed structured payload with its own documented bound (a release check's report: 256 KiB), never above 1 MiB.
402
- const cap = options.cap === undefined ? EVIDENCE_CAPS[kind] : Math.min(options.cap, 1024 * 1024);
412
+ // `cap`: a sealed structured payload with its own documented bound (a release check's report: 256 KiB), never above what
413
+ // a reader of this kind accepts (evidenceReadCap): a record written larger would never verify again.
414
+ const cap = options.cap === undefined ? EVIDENCE_CAPS[kind] : Math.min(options.cap, evidenceReadCap(kind));
403
415
  const stored = content.subarray(0, cap);
404
416
  // Some callers that deliberately summarize/bound a source before storage
405
417
  // may still report its true original size. This keeps `truncated` honest
@@ -469,7 +481,7 @@ function withArtifactFile(root, artifact, use) {
469
481
  !/[\u0000-\u001f\u007f:]/.test(segment));
470
482
  if (!wellFormed)
471
483
  return { ok: false, problem: "the key is not a normalized relative path" };
472
- const cap = EVIDENCE_CAPS[artifact.kind];
484
+ const cap = evidenceReadCap(artifact.kind);
473
485
  if (artifact.bytesStored > cap) {
474
486
  return { ok: false, problem: "the record claims more bytes than its kind may store" };
475
487
  }
package/dist/exec.d.ts CHANGED
@@ -252,6 +252,8 @@ export type SpawnAttempt = {
252
252
  */
253
253
  export declare function retryTransientSpawn(attempt: () => Promise<SpawnAttempt>, delays?: readonly number[], sleep?: (ms: number) => Promise<void>): Promise<ExecResult>;
254
254
  export declare function run(file: string, args: readonly string[], options?: RunOptions): Promise<ExecResult>;
255
+ /** How long a timed-out plain command has to exit after SIGTERM before it is killed outright. */
256
+ export declare const TERM_GRACE_MS = 2000;
255
257
  /**
256
258
  * Provider replies are nested inside their JSONL envelope. A 64 KiB reply
257
259
  * therefore needs appreciably more than 64 KiB on the wire once quotes,
package/dist/exec.js CHANGED
@@ -584,7 +584,10 @@ export function run(file, args, options = {}) {
584
584
  return Promise.resolve({ code: CONTAINMENT_REFUSED_CODE, stdout: "", stderr: error.message, timedOut: false, notFound: false, containment: { refused: error.message } });
585
585
  }
586
586
  return retryTransientSpawn(() => new Promise(resolve => {
587
- execFile(wrapped.file, wrapped.args, {
587
+ // execFile's timeout sends SIGTERM and nothing more: a process that survives it (one that ignores or blocks
588
+ // it) never exits, and its caller would wait forever. SIGKILL follows a short grace later.
589
+ let hardKill;
590
+ const child = execFile(wrapped.file, wrapped.args, {
588
591
  cwd,
589
592
  timeout: timeoutMs,
590
593
  maxBuffer,
@@ -593,6 +596,8 @@ export function run(file, args, options = {}) {
593
596
  windowsHide: true,
594
597
  ...((env => env === undefined ? {} : { env }))(withSandboxEnv(childEnv, wrapped.env, (options.fence?.length ?? 0) > 0)),
595
598
  }, (error, stdout, stderr) => {
599
+ if (hardKill !== undefined)
600
+ clearTimeout(hardKill);
596
601
  if (error === null) {
597
602
  resolve({ result: { code: 0, stdout, stderr, timedOut: false, notFound: false }, transient: false });
598
603
  return;
@@ -602,8 +607,17 @@ export function run(file, args, options = {}) {
602
607
  transient: isTransientSpawnFailure(error),
603
608
  });
604
609
  });
610
+ if (timeoutMs > 0) {
611
+ hardKill = setTimeout(() => { try {
612
+ child.kill("SIGKILL");
613
+ }
614
+ catch { /* gone */ } }, timeoutMs + TERM_GRACE_MS);
615
+ hardKill.unref?.();
616
+ }
605
617
  }));
606
618
  }
619
+ /** How long a timed-out plain command has to exit after SIGTERM before it is killed outright. */
620
+ export const TERM_GRACE_MS = 2_000;
607
621
  /**
608
622
  * The buffered transport for process-group providers (claude): spawn
609
623
  * detached so the harness and everything it launches share one killable
package/dist/guides.js CHANGED
@@ -482,13 +482,17 @@ with a time limit.
482
482
  10. **tree**: main's merge commit must have exactly the gated tree, or
483
483
  nothing is tagged.
484
484
  11. **tag**: pushes \`v<version>\` on that merge commit. In publish.yml,
485
- macOS Node 22 and 24 must both pass before npm and GitHub publication.
485
+ macOS Node 22 and 24 must both pass before npm and GitHub publication
486
+ (the candidate's macOS checks already passed in ci.yml before the merge).
486
487
  macos.yml keeps the nightly matrix.
487
- 12. **publish**: waits for npm, the GitHub release and the tag's macOS
488
- and publish checks.
489
- 13. **homebrew**: updates the tap's formula, waits for checks to register
490
- and every registered check to pass, then merges. No checks means waiting,
491
- within the Homebrew time limit; a failed or skipped check stops the release.
488
+ 12. **publish**: waits for npm, the GitHub release and the tag's publish
489
+ job (which needs the macOS legs); not main's own CI on the merge commit.
490
+ 13. **homebrew**: the release is published once npm and the GitHub release
491
+ are verified. It proposes the tap's formula change (checksum from npm's
492
+ tarball) and looks at its checks once: \`Published; Homebrew pending\`
493
+ (or failed) ends the run, and the same command finishes it later, waiting
494
+ for checks to register and every one to pass, then merging exactly that
495
+ change. A tap already on a newer version is never moved back.
492
496
 
493
497
  ## When it stops
494
498
 
@@ -508,8 +512,10 @@ instead of making them twice.
508
512
  A stop at the gate comes before the task is filed, so nothing was filed
509
513
  either: a fix pushed as a new commit is released with \`--new\`; after
510
514
  rerunning the failed job on GitHub, run the same command again.
511
- - \`publish-failed\` / \`homebrew-failed\`: fix the failed workflow before
512
- resuming. A failed tag check cannot finish publication or update Homebrew.
515
+ - \`publish-failed\`: fix the failed workflow before resuming. A failed tag
516
+ check cannot finish publication or update Homebrew.
517
+ - Homebrew failed: the release is still published. Fix or rerun the tap's
518
+ check, then run the same command to finish Homebrew.
513
519
  - \`base-moved\`: the branch is behind main (it doesn't contain main's
514
520
  head), so the merge wouldn't be the tree CI tested. Checked at the gate
515
521
  (nothing filed) and again right before merging (nothing merged): update
@@ -521,6 +527,10 @@ instead of making them twice.
521
527
  - An interrupted deployment resumes with \`deploy-browser.mjs --stage <saved
522
528
  path> --phase recover\` before any new attempt, including \`--new\`.
523
529
  Failed recovery stops there; the original deployment journal remains available.
530
+ \`toolroll release --recover-deploy\` shows what that recovery will do for
531
+ the one unfinished deployment, and \`--yes\` runs it: when the old service
532
+ isn't proved stopped it stops only processes proved to be that service's
533
+ own, restarts the previous service and lifts the pause. Safe to rerun.
524
534
  - \`link\`: a CLI name didn't answer as the new build, so both were put
525
535
  back. The service already runs the new build; fix the name and rerun.
526
536
  - \`gate-held\`: the deployment stopped before its swap but left new work
@@ -0,0 +1,46 @@
1
+ import type { CheckReport } from "./contracts/release-check.js";
2
+ import { type Milestone, type Store } from "./store.js";
3
+ /** A console request slower than this keeps its timeline. */
4
+ export declare const SLOW_REQUEST_MS = 1000;
5
+ /** One operation's milestones as they happen, bounded (the store bounds them again when kept). */
6
+ export declare class Timeline {
7
+ readonly startedAt: Date;
8
+ private readonly t0;
9
+ readonly marks: Milestone[];
10
+ mark(name: string, detail?: string): void;
11
+ elapsed(): number;
12
+ }
13
+ /** Keep a finished console request's timeline when it was slow. Best effort: a failed write never touches the answer. */
14
+ export declare function keepSlowRequest(store: Store, timeline: Timeline, route: string, status: number, now: Date): void;
15
+ /** The check's own `took:` line (scripts/release-check.mjs): each part's duration, in ms, by label. */
16
+ export declare function partDurations(stdout: string): Map<string, number>;
17
+ /** A release check's parts as milestones: each part that started, and its end where its duration is known. */
18
+ export declare function checkMilestones(report: CheckReport, stdout: string): {
19
+ startedAt: Date;
20
+ totalMs: number;
21
+ milestones: Milestone[];
22
+ } | null;
23
+ /** Keep a release check's timeline beside its run. Best effort: the check's outcome never depends on it. */
24
+ export declare function keepCheckTimeline(store: Store, args: {
25
+ runId: number;
26
+ taskId: string;
27
+ report: CheckReport | null;
28
+ stdout: string;
29
+ now: Date;
30
+ }): void;
31
+ /** "Where the time went" on the task and run page: the run's check and the task's deploys, newest first. */
32
+ export type TimingView = {
33
+ title: string;
34
+ total: string;
35
+ rows: {
36
+ label: string;
37
+ at: string;
38
+ detail: string | null;
39
+ }[];
40
+ }[];
41
+ /** 65 s → "1:05"; 3 723 s → "1:02:03". */
42
+ export declare function clockWords(ms: number): string;
43
+ /** A run's check timelines and its task's deploy timelines, for the page (null: none kept). */
44
+ export declare function timingFor(store: Store, runId: number | null, taskId: string): TimingView | null;
45
+ /** The approver's debug view beside /healthz (/health): the slowest recent console requests, one line each. */
46
+ export declare function slowRequestLines(store: Store, limit?: number): string[];
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Latency timelines (docs/plans/actors-lessons.md, from durable-actors' latency timeline): for one operation, a short
3
+ * list of milestones, each an offset from its start, kept in the plane (latency_timeline, bounded and pruned) for:
4
+ *
5
+ * - console requests slower than SLOW_REQUEST_MS: route matched, signed in, chrome and worker reads, handler, render;
6
+ * - release checks: each part's start and end, from the sealed report's start times and the check's own `took:` line;
7
+ * - deploys: each phase, and each health wait with its reason (scripts/deploy-phases.mjs writes those).
8
+ *
9
+ * Milestones carry route ids, part and phase names and fixed reason words only: never a URL, a query, a body or a
10
+ * secret. A console request's offsets are monotonic (performance.now within one process); a check's and a deploy's
11
+ * are wall-clock offsets, since their parts run in other processes.
12
+ */
13
+ import { performance } from "node:perf_hooks";
14
+ import { latencyTimelines, MAX_MILESTONES, recordLatencyTimeline } from "./store.js";
15
+ /** A console request slower than this keeps its timeline. */
16
+ export const SLOW_REQUEST_MS = 1_000;
17
+ /** One operation's milestones as they happen, bounded (the store bounds them again when kept). */
18
+ export class Timeline {
19
+ startedAt = new Date();
20
+ t0 = performance.now();
21
+ marks = [];
22
+ mark(name, detail) {
23
+ if (this.marks.length >= MAX_MILESTONES * 2)
24
+ return;
25
+ this.marks.push({ name, ms: performance.now() - this.t0, ...(detail === undefined ? {} : { detail }) });
26
+ }
27
+ elapsed() { return performance.now() - this.t0; }
28
+ }
29
+ /** Keep a finished console request's timeline when it was slow. Best effort: a failed write never touches the answer. */
30
+ export function keepSlowRequest(store, timeline, route, status, now) {
31
+ const total = timeline.elapsed();
32
+ if (total < SLOW_REQUEST_MS)
33
+ return;
34
+ try {
35
+ recordLatencyTimeline(store, { kind: "console", subject: route, startedAt: timeline.startedAt, totalMs: total,
36
+ milestones: [...timeline.marks, { name: "answered", ms: total, detail: `status ${status}` }] }, now);
37
+ }
38
+ catch { /* display only */ }
39
+ }
40
+ const TOOK = /^(\d+(?:\.\d+)?) (s|min)$/;
41
+ /** The check's own `took:` line (scripts/release-check.mjs): each part's duration, in ms, by label. */
42
+ export function partDurations(stdout) {
43
+ const line = stdout.split("\n").reverse().find(one => one.startsWith("took: "));
44
+ const durations = new Map();
45
+ if (line === undefined)
46
+ return durations;
47
+ for (const one of line.slice("took: ".length).split(";")[0].split(", ")) {
48
+ const at = one.lastIndexOf(" ", one.lastIndexOf(" ") - 1);
49
+ if (at < 1)
50
+ continue;
51
+ const label = one.slice(0, at), took = TOOK.exec(one.slice(at + 1));
52
+ if (took !== null)
53
+ durations.set(label, Number(took[1]) * (took[2] === "min" ? 60_000 : 1_000));
54
+ }
55
+ return durations;
56
+ }
57
+ /** A release check's parts as milestones: each part that started, and its end where its duration is known. */
58
+ export function checkMilestones(report, stdout) {
59
+ const took = partDurations(stdout);
60
+ const started = report.parts.flatMap(part => {
61
+ const at = part.startedAt == null ? NaN : Date.parse(part.startedAt);
62
+ return Number.isFinite(at) ? [{ part: part.part, at, ms: took.get(part.part) ?? null }] : [];
63
+ });
64
+ if (started.length === 0)
65
+ return null;
66
+ const first = Math.min(...started.map(one => one.at));
67
+ const milestones = started.flatMap(one => [
68
+ { name: `${one.part} started`, ms: one.at - first },
69
+ ...(one.ms === null ? [] : [{ name: `${one.part} ended`, ms: one.at - first + one.ms }]),
70
+ ]).sort((a, b) => a.ms - b.ms);
71
+ return { startedAt: new Date(first), totalMs: Math.max(...milestones.map(one => one.ms)), milestones };
72
+ }
73
+ /** Keep a release check's timeline beside its run. Best effort: the check's outcome never depends on it. */
74
+ export function keepCheckTimeline(store, args) {
75
+ if (args.report === null)
76
+ return;
77
+ try {
78
+ const timeline = checkMilestones(args.report, args.stdout);
79
+ if (timeline === null)
80
+ return;
81
+ recordLatencyTimeline(store, { kind: "release-check", subject: `check ${args.report.mode}`, runId: args.runId, taskId: args.taskId, ...timeline }, args.now);
82
+ }
83
+ catch { /* display only */ }
84
+ }
85
+ /** 65 s → "1:05"; 3 723 s → "1:02:03". */
86
+ export function clockWords(ms) {
87
+ const seconds = Math.round(ms / 1000), h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60;
88
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
89
+ }
90
+ function viewOf(one) {
91
+ const title = one.kind === "deploy" ? `Deploy ${one.subject}` : one.kind === "release-check" ? "Release check" : `Request ${one.subject}`;
92
+ const started = new Map();
93
+ return { title, total: clockWords(one.totalMs), rows: one.milestones.map(mark => {
94
+ const part = / (started|ended)$/.exec(mark.name);
95
+ if (part?.[1] === "started")
96
+ started.set(mark.name.slice(0, -" started".length), mark.ms);
97
+ const began = part?.[1] === "ended" ? started.get(mark.name.slice(0, -" ended".length)) : undefined;
98
+ return { label: mark.name, at: `+${clockWords(mark.ms)}`, detail: began === undefined ? mark.detail ?? null : `took ${clockWords(mark.ms - began)}` };
99
+ }) };
100
+ }
101
+ /** A run's check timelines and its task's deploy timelines, for the page (null: none kept). */
102
+ export function timingFor(store, runId, taskId) {
103
+ try {
104
+ const kept = runId === null ? latencyTimelines(store, { taskId, limit: 5 }) : latencyTimelines(store, { runId, taskId, limit: 5 });
105
+ const shown = kept.filter(one => one.kind === "deploy" || one.runId === runId);
106
+ return shown.length === 0 ? null : shown.map(viewOf);
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ /** The approver's debug view beside /healthz (/health): the slowest recent console requests, one line each. */
113
+ export function slowRequestLines(store, limit = 20) {
114
+ try {
115
+ return latencyTimelines(store, { kind: "console", limit }).map(one => `${one.startedAt} ${one.subject} ${Math.round(one.totalMs)} ms: ${one.milestones.map(mark => `${mark.name} +${Math.round(mark.ms)}${mark.detail === undefined ? "" : ` (${mark.detail})`}`).join(", ")}`);
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ }
@@ -0,0 +1,39 @@
1
+ import { type LiveBackupCopy, type LiveBackupSettings, type Store } from "./store.js";
2
+ /** The choices Settings offers, in minutes (0: off is its own choice). */
3
+ export declare const LIVE_EVERY_MINUTES: readonly [1, 5, 15, 30];
4
+ /** Daily copies are kept this many days besides the newest `keep`. */
5
+ export declare const LIVE_DAILY_DAYS = 7;
6
+ /** `backups/live` beside the database, through its real path (the folder check refuses a path through a link). */
7
+ export declare function defaultLiveFolder(databaseFile: string): string;
8
+ export declare const liveFolderOf: (settings: LiveBackupSettings, databaseFile: string) => string;
9
+ /** Why a folder can't hold the copies, or null. Missing is fine (it is made on first use, then checked again). */
10
+ export declare function liveFolderProblem(folder: string, forbidden: readonly string[], protectedRoot?: string): string | null;
11
+ /** This database's copies in a folder, newest first (plain files named for it only). */
12
+ export declare function liveCopies(folder: string, tag: string): {
13
+ name: string;
14
+ path: string;
15
+ day: string;
16
+ }[];
17
+ /** Keep the newest `keep`, the newest of each of the last LIVE_DAILY_DAYS days, and `protect`; remove the rest (and
18
+ * this database's partial files older than an hour). Never anything not named for this database. */
19
+ export declare function pruneLiveCopies(folder: string, tag: string, keep: number, now: Date, protect: string | null): number;
20
+ /** One copy on a worker thread: copied, checked, hashed and renamed into place, or a problem (nothing left behind). */
21
+ export declare function copyOffThread(source: string, folder: string, tag: string, now: Date): Promise<{
22
+ ok: true;
23
+ copy: Omit<LiveBackupCopy, "madeAt">;
24
+ } | {
25
+ ok: false;
26
+ problem: string;
27
+ }>;
28
+ /** Whether a copy is due: on, and the latest (or the latest try) is at least the schedule old. */
29
+ export declare function liveCopyDue(store: Store, now: Date): boolean;
30
+ /** One pass: hold the lease, and when due check the folder, copy, record and prune. */
31
+ export declare function liveBackupPass(store: Store, databaseFile: string, holder: string, forbidden: () => readonly string[], clock?: () => Date): Promise<{
32
+ ok: true;
33
+ copy: LiveBackupCopy;
34
+ } | {
35
+ ok: false;
36
+ problem: string;
37
+ } | null>;
38
+ /** The loop in the worker: a pass a minute (the first after 30 s). The returned stop waits for a copy under way. */
39
+ export declare function startLiveBackups(store: Store, databaseFile: string, forbidden: () => readonly string[], everyMs?: number): () => Promise<void>;