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.
- package/CHANGELOG.md +81 -0
- package/dist/agent-fence.js +18 -2
- package/dist/backup-ui.d.ts +9 -1
- package/dist/backup-ui.js +28 -3
- package/dist/backup.js +4 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/builder.d.ts +4 -3
- package/dist/builder.js +14 -8
- package/dist/check-failure.js +31 -3
- package/dist/check-report.d.ts +18 -3
- package/dist/check-report.js +67 -7
- package/dist/claim.d.ts +9 -0
- package/dist/claim.js +9 -1
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/contracts/release-terms.d.ts +41 -0
- package/dist/contracts/release-terms.js +53 -0
- package/dist/controller-service.js +4 -1
- package/dist/deploy-rescue.d.ts +41 -0
- package/dist/deploy-rescue.js +94 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.js +4 -3
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +15 -3
- package/dist/exec.d.ts +2 -0
- package/dist/exec.js +15 -1
- package/dist/guides.js +18 -8
- package/dist/latency-timeline.d.ts +46 -0
- package/dist/latency-timeline.js +120 -0
- package/dist/live-backup.d.ts +39 -0
- package/dist/live-backup.js +271 -0
- package/dist/operate.js +203 -16
- package/dist/planner.js +2 -2
- package/dist/release-adapters.d.ts +32 -1
- package/dist/release-adapters.js +99 -7
- package/dist/release-carry.d.ts +21 -1
- package/dist/release-carry.js +22 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +74 -5
- package/dist/release-gate.d.ts +5 -0
- package/dist/release-gate.js +1 -1
- package/dist/release-recheck.js +3 -2
- package/dist/release.d.ts +37 -4
- package/dist/release.js +128 -43
- package/dist/runner-process.d.ts +67 -0
- package/dist/runner-process.js +219 -0
- package/dist/runner.d.ts +48 -6
- package/dist/runner.js +71 -8
- package/dist/scope.d.ts +19 -1
- package/dist/scope.js +19 -2
- package/dist/scout.js +2 -2
- package/dist/serve.js +11 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/pages.js +7 -3
- package/dist/server/remote-hooks.d.ts +1 -1
- package/dist/server/remote-hooks.js +5 -3
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/settings.js +22 -2
- package/dist/server/tasks.js +4 -1
- package/dist/storage-sweep.js +1 -1
- package/dist/store.d.ts +125 -2
- package/dist/store.js +250 -12
- package/dist/surface.js +3 -1
- package/dist/teardown-git.js +5 -5
- package/dist/toolroll-update.js +2 -2
- package/dist/up-role-child.js +6 -2
- package/dist/workspace-revision.js +3 -1
- 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
|
+
}
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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` };
|
package/dist/evidence.d.ts
CHANGED
|
@@ -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
|
|
402
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
488
|
-
|
|
489
|
-
13. **homebrew**:
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
512
|
-
|
|
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>;
|