toolroll 0.9.98 → 0.9.99
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 +5 -0
- package/dist/checkout-cleanup.js +2 -1
- package/dist/deploy-phase-data.json +21 -0
- package/dist/deploy-phase.d.ts +37 -0
- package/dist/deploy-phase.js +64 -0
- package/dist/deploy-rescue.d.ts +2 -1
- package/dist/deploy-rescue.js +6 -7
- package/dist/desktop-update.d.ts +1 -1
- package/dist/release-adapters.js +30 -14
- package/dist/release-gate.d.ts +3 -3
- package/dist/release-gate.js +7 -4
- package/dist/release-next.d.ts +3 -3
- package/dist/release-next.js +13 -16
- package/dist/staged-runtimes.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.99 — 2026-11-09
|
|
4
|
+
|
|
5
|
+
- **A Toolroll update interrupted while the new service was starting no longer gets stuck.** Before, an update killed in that short window left new work paused until someone edited a file by hand. Running the release again now finishes the update when the new service is running, and puts the previous service back once the new one is proved gone. Nothing is stopped on a guess: a new service that is loaded but never answers is left alone, and the message names the one command that stops it.
|
|
6
|
+
- **Update steps are one list.** The 17 steps an update records, and what a stopped update at each step needs, now live in one file that every part of Toolroll reads. A step name this version doesn't know is refused instead of being treated as "roll back".
|
|
7
|
+
|
|
3
8
|
## 0.9.98 — 2026-11-09
|
|
4
9
|
|
|
5
10
|
- **Start a small task from chat in one step.** Ask the lead for one small, clear change ("just fix the typo in the footer") in web chat, Telegram, Slack, Discord or Teams, and it drafts a Start work card. Your own tap (then "Yes, start work") files and approves it under the same rules as the New task form's Build: small changes only, Toolroll's size check must agree, your chat approval setting and limits apply, it must start within 30 minutes, and you still accept the result before it's finished or published. On web chat the card opens the full terms to confirm. On Telegram you can also reply yes to the card, and the reply links the task it started. In Teams, and in Slack when a word in the request would show as an emoji, approval cards open the review page instead of taking a tap, so what you approve is always shown exactly as written. Anything bigger becomes an ordinary task card, and the lead can never start work itself.
|
package/dist/checkout-cleanup.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { createHash } from "node:crypto";
|
|
28
28
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
29
29
|
import { basename, dirname, join } from "node:path";
|
|
30
|
+
import { isDeployed } from "./deploy-phase.js";
|
|
30
31
|
import { taskBranches } from "./names.js";
|
|
31
32
|
import { COMPLETION_ACTION } from "./result-completion.js";
|
|
32
33
|
import { CLEANUP_CHOICES, IDLE_CHOICES, bytesWords, diskBytes, diskBytesAsync } from "./storage.js";
|
|
@@ -59,7 +60,7 @@ export function deployedRelease(stateDir) {
|
|
|
59
60
|
catch {
|
|
60
61
|
continue;
|
|
61
62
|
}
|
|
62
|
-
if (journal["phase"]
|
|
63
|
+
if (!isDeployed(journal["phase"]))
|
|
63
64
|
continue;
|
|
64
65
|
const at = typeof journal["deployedAt"] === "string" ? journal["deployedAt"] : typeof journal["updatedAt"] === "string" ? journal["updatedAt"] : "";
|
|
65
66
|
if (newest !== null && newest.at >= at)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"phases": [
|
|
3
|
+
{ "phase": "preparing", "action": "lift-pause", "flags": ["beforeSwap", "beforePause"] },
|
|
4
|
+
{ "phase": "admission-paused", "action": "lift-pause", "flags": ["beforeSwap"] },
|
|
5
|
+
{ "phase": "frozen", "action": "lift-pause", "flags": ["beforeSwap"] },
|
|
6
|
+
{ "phase": "backup-verified", "action": "lift-pause", "flags": ["beforeSwap"] },
|
|
7
|
+
{ "phase": "rehearsed", "action": "lift-pause", "flags": ["beforeSwap"] },
|
|
8
|
+
{ "phase": "stopping", "action": "roll-back", "flags": [] },
|
|
9
|
+
{ "phase": "stopped", "action": "roll-back", "flags": ["restartable"] },
|
|
10
|
+
{ "phase": "migrating", "action": "roll-back", "flags": ["migrationStarted", "restartable"] },
|
|
11
|
+
{ "phase": "migrated", "action": "roll-back", "flags": ["migrationStarted", "restartable"] },
|
|
12
|
+
{ "phase": "starting", "action": "forward", "flags": [] },
|
|
13
|
+
{ "phase": "started", "action": "forward", "flags": [] },
|
|
14
|
+
{ "phase": "healthy", "action": "forward", "flags": [] },
|
|
15
|
+
{ "phase": "start-failed", "action": "roll-back", "flags": ["migrationStarted", "restartable"] },
|
|
16
|
+
{ "phase": "backup-restored", "action": "roll-back", "flags": ["restartable"] },
|
|
17
|
+
{ "phase": "restored", "action": "roll-back", "flags": [] },
|
|
18
|
+
{ "phase": "released", "action": "none", "flags": [] },
|
|
19
|
+
{ "phase": "deployed", "action": "none", "flags": ["deployed"] }
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** What `toolroll release` does with a deployment that stopped at a phase; "refuse" for a word that is no phase. */
|
|
2
|
+
export type DeployAction = "none" | "lift-pause" | "forward" | "roll-back" | "refuse";
|
|
3
|
+
type RowAction = Exclude<DeployAction, "refuse">;
|
|
4
|
+
type Flag = "beforeSwap" | "beforePause" | "migrationStarted" | "restartable" | "deployed";
|
|
5
|
+
type Row = {
|
|
6
|
+
readonly phase: string;
|
|
7
|
+
readonly action: RowAction;
|
|
8
|
+
readonly flags: readonly Flag[];
|
|
9
|
+
};
|
|
10
|
+
/** The rows of a parsed data file, or an Error naming what is wrong with it. Never a partial table. */
|
|
11
|
+
export declare function deployPhaseRows(data: unknown): readonly Row[];
|
|
12
|
+
/** Every phase, in journal order. */
|
|
13
|
+
export declare const DEPLOY_PHASES: readonly string[];
|
|
14
|
+
export declare const isDeployPhase: (phase: unknown) => phase is string;
|
|
15
|
+
/** The one action a journal's phase calls for; "refuse" for anything that is not a phase. */
|
|
16
|
+
export declare const deployActionOf: (phase: unknown) => DeployAction;
|
|
17
|
+
/** The word itself, or an Error: a journal is never written with a word the table does not hold. */
|
|
18
|
+
export declare function assertDeployPhase(phase: string): string;
|
|
19
|
+
/** The service was never stopped. */
|
|
20
|
+
export declare const BEFORE_SWAP: readonly string[];
|
|
21
|
+
/** Before the swap, with the pause in place. */
|
|
22
|
+
export declare const PAUSED_BEFORE_SWAP: readonly string[];
|
|
23
|
+
/** The candidate's store opened the live database, or the new service ran on it. */
|
|
24
|
+
export declare const MIGRATION_STARTED: readonly string[];
|
|
25
|
+
/** The old service is proved stopped: the previous definition may be started again. */
|
|
26
|
+
export declare const RESTARTABLE: readonly string[];
|
|
27
|
+
/** The new service may be running: never rolled back by a journal alone. */
|
|
28
|
+
export declare const FORWARD_ONLY: readonly string[];
|
|
29
|
+
/** Nothing is left to recover. Not the same as deployed: a released deployment never installed its runtime. */
|
|
30
|
+
export declare const NEEDS_NO_RECOVERY: readonly string[];
|
|
31
|
+
/** The deployment finished and its runtime is the installed one. */
|
|
32
|
+
export declare const isDeployed: (phase: unknown) => boolean;
|
|
33
|
+
/** Stopped where nothing was changed (before its pause), or with nothing left to do. */
|
|
34
|
+
export declare const stoppedWhereNothingChanged: (phase: unknown) => boolean;
|
|
35
|
+
/** The phase a lifted pause leaves behind (release-gate.ts). */
|
|
36
|
+
export declare const RELEASED: string;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The phases of a browser deployment's journal, and what a stopped deployment at each one calls for.
|
|
3
|
+
*
|
|
4
|
+
* The rows are data: deploy-phase-data.json beside this file, the one authored list (the build copies it into dist,
|
|
5
|
+
* scripts/postbuild.mjs). The deploy scripts read the same file through scripts/deploy-phase-table.mjs, where each
|
|
6
|
+
* column is described. Both sides check its shape the same way; deploy-phase.test.ts holds them to each other and to
|
|
7
|
+
* its own hand-written expectations.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
const ACTIONS = ["none", "lift-pause", "forward", "roll-back"];
|
|
12
|
+
const FLAGS = ["beforeSwap", "beforePause", "migrationStarted", "restartable", "deployed"];
|
|
13
|
+
/** The rows of a parsed data file, or an Error naming what is wrong with it. Never a partial table. */
|
|
14
|
+
export function deployPhaseRows(data) {
|
|
15
|
+
const rows = data?.phases;
|
|
16
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
17
|
+
throw new Error("The deployment phase table has no rows.");
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
return Object.freeze(rows.map((row) => {
|
|
20
|
+
const { phase, action, flags } = (row ?? {});
|
|
21
|
+
if (typeof phase !== "string" || !/^[a-z]+(-[a-z]+)*$/.test(phase) || seen.has(phase))
|
|
22
|
+
throw new Error(`The deployment phase table has a bad or repeated phase: ${String(phase)}.`);
|
|
23
|
+
if (typeof action !== "string" || !ACTIONS.includes(action))
|
|
24
|
+
throw new Error(`The deployment phase ${phase} has no known action.`);
|
|
25
|
+
if (!Array.isArray(flags) || flags.some(flag => typeof flag !== "string" || !FLAGS.includes(flag)) || new Set(flags).size !== flags.length)
|
|
26
|
+
throw new Error(`The deployment phase ${phase} has an unknown or repeated flag.`);
|
|
27
|
+
seen.add(phase);
|
|
28
|
+
return Object.freeze({ phase, action: action, flags: Object.freeze([...flags]) });
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
// By path, not by URL: a test environment that stands in for a browser replaces the global URL.
|
|
32
|
+
const ROWS = deployPhaseRows(JSON.parse(readFileSync(join(import.meta.dirname, "deploy-phase-data.json"), "utf8")));
|
|
33
|
+
const rowOf = (phase) => typeof phase === "string" ? ROWS.find(row => row.phase === phase) : undefined;
|
|
34
|
+
const withFlag = (flag) => Object.freeze(ROWS.filter(row => row.flags.includes(flag)).map(row => row.phase));
|
|
35
|
+
const withAction = (action) => Object.freeze(ROWS.filter(row => row.action === action).map(row => row.phase));
|
|
36
|
+
/** Every phase, in journal order. */
|
|
37
|
+
export const DEPLOY_PHASES = Object.freeze(ROWS.map(row => row.phase));
|
|
38
|
+
export const isDeployPhase = (phase) => rowOf(phase) !== undefined;
|
|
39
|
+
/** The one action a journal's phase calls for; "refuse" for anything that is not a phase. */
|
|
40
|
+
export const deployActionOf = (phase) => rowOf(phase)?.action ?? "refuse";
|
|
41
|
+
/** The word itself, or an Error: a journal is never written with a word the table does not hold. */
|
|
42
|
+
export function assertDeployPhase(phase) {
|
|
43
|
+
if (!isDeployPhase(phase))
|
|
44
|
+
throw new Error(`"${String(phase)}" is not a deployment phase; the journal was not written.`);
|
|
45
|
+
return phase;
|
|
46
|
+
}
|
|
47
|
+
/** The service was never stopped. */
|
|
48
|
+
export const BEFORE_SWAP = withFlag("beforeSwap");
|
|
49
|
+
/** Before the swap, with the pause in place. */
|
|
50
|
+
export const PAUSED_BEFORE_SWAP = Object.freeze(ROWS.filter(row => row.flags.includes("beforeSwap") && !row.flags.includes("beforePause")).map(row => row.phase));
|
|
51
|
+
/** The candidate's store opened the live database, or the new service ran on it. */
|
|
52
|
+
export const MIGRATION_STARTED = withFlag("migrationStarted");
|
|
53
|
+
/** The old service is proved stopped: the previous definition may be started again. */
|
|
54
|
+
export const RESTARTABLE = withFlag("restartable");
|
|
55
|
+
/** The new service may be running: never rolled back by a journal alone. */
|
|
56
|
+
export const FORWARD_ONLY = withAction("forward");
|
|
57
|
+
/** Nothing is left to recover. Not the same as deployed: a released deployment never installed its runtime. */
|
|
58
|
+
export const NEEDS_NO_RECOVERY = withAction("none");
|
|
59
|
+
/** The deployment finished and its runtime is the installed one. */
|
|
60
|
+
export const isDeployed = (phase) => rowOf(phase)?.flags.includes("deployed") === true;
|
|
61
|
+
/** Stopped where nothing was changed (before its pause), or with nothing left to do. */
|
|
62
|
+
export const stoppedWhereNothingChanged = (phase) => { const row = rowOf(phase); return row !== undefined && (row.flags.includes("beforePause") || row.action === "none"); };
|
|
63
|
+
/** The phase a lifted pause leaves behind (release-gate.ts). */
|
|
64
|
+
export const RELEASED = assertDeployPhase("released");
|
package/dist/deploy-rescue.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { FORWARD_ONLY } from "./deploy-phase.js";
|
|
1
2
|
/** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
|
|
2
|
-
export
|
|
3
|
+
export { FORWARD_ONLY };
|
|
3
4
|
export type RescueWorld = {
|
|
4
5
|
/** The live database. */
|
|
5
6
|
database: string;
|
package/dist/deploy-rescue.js
CHANGED
|
@@ -18,10 +18,9 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { realpathSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
|
-
import { BEFORE_SWAP } from "./
|
|
21
|
+
import { BEFORE_SWAP, FORWARD_ONLY, MIGRATION_STARTED, NEEDS_NO_RECOVERY, RESTARTABLE } from "./deploy-phase.js";
|
|
22
22
|
/** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
|
|
23
|
-
export
|
|
24
|
-
const FINISHED = Object.freeze(["released", "deployed"]);
|
|
23
|
+
export { FORWARD_ONLY };
|
|
25
24
|
const real = (path) => { try {
|
|
26
25
|
return realpathSync(path);
|
|
27
26
|
}
|
|
@@ -39,8 +38,8 @@ export function rescuePlan(phase, journal) {
|
|
|
39
38
|
if (phase === "stopping") {
|
|
40
39
|
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
40
|
}
|
|
42
|
-
if (
|
|
43
|
-
if (
|
|
41
|
+
if (phase === "stopping" || RESTARTABLE.includes(phase)) {
|
|
42
|
+
if (MIGRATION_STARTED.includes(phase))
|
|
44
43
|
words.push("Puts the verified backup back first when the previous runtime can't read the migrated database.");
|
|
45
44
|
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
45
|
}
|
|
@@ -56,7 +55,7 @@ export async function rescueDeployment(stageArg, yes, world) {
|
|
|
56
55
|
return { ok: false, reason: "usage", message: `${stageArg} is not a deployment stage of this installation (under ${join(world.stateDir, "staged-upgrades")}).` };
|
|
57
56
|
}
|
|
58
57
|
else {
|
|
59
|
-
const open = all.filter(one => one.journal === null || !
|
|
58
|
+
const open = all.filter(one => one.journal === null || !NEEDS_NO_RECOVERY.includes(String(one.journal["phase"])));
|
|
60
59
|
if (open.length === 0)
|
|
61
60
|
return { ok: true, acted: false, stage: null, phase: null, words: ["Nothing to recover: every deployment finished or was already put back."] };
|
|
62
61
|
if (open.length > 1)
|
|
@@ -74,7 +73,7 @@ export async function rescueDeployment(stageArg, yes, world) {
|
|
|
74
73
|
return { ok: false, reason: "conflict", message: `That deployment ran on ${String(journal["database"])}, not ${world.database}; nothing was changed.` };
|
|
75
74
|
if (!Number.isSafeInteger(run) || run < 1)
|
|
76
75
|
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 (
|
|
76
|
+
if (NEEDS_NO_RECOVERY.includes(phase))
|
|
78
77
|
return { ok: true, acted: false, stage, phase, words: [`Nothing to recover: that deployment is ${phase}.`] };
|
|
79
78
|
if (FORWARD_ONLY.includes(phase)) {
|
|
80
79
|
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).` };
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -97,13 +97,13 @@ 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
|
+
error?: never;
|
|
100
101
|
active: boolean;
|
|
101
102
|
phase: string;
|
|
102
103
|
detail: string;
|
|
103
104
|
running: boolean;
|
|
104
105
|
canResume: boolean;
|
|
105
106
|
canCancel: boolean;
|
|
106
|
-
error?: never;
|
|
107
107
|
automaticRecovery?: never;
|
|
108
108
|
recoveryAttempts?: never;
|
|
109
109
|
recoveryRepairs?: never;
|
package/dist/release-adapters.js
CHANGED
|
@@ -18,7 +18,8 @@ import { callLabel, readOnlyCall, retryCall, retryingExec, writeWithRecheck } fr
|
|
|
18
18
|
const sqlite = () => createRequire(import.meta.url)("node:sqlite");
|
|
19
19
|
import { PROTECTION_MINIMUM_CHECKS, PROTECTION_REPORT_ONLY_CHECKS } from "./release.js";
|
|
20
20
|
import { activeUpdateWork } from "./desktop-update-gate.js";
|
|
21
|
-
import { BEFORE_SWAP,
|
|
21
|
+
import { BEFORE_SWAP, deployActionOf, stoppedWhereNothingChanged } from "./deploy-phase.js";
|
|
22
|
+
import { gateOwner } from "./release-gate.js";
|
|
22
23
|
import { findCliLinks, switchCliLinks } from "./release-links.js";
|
|
23
24
|
import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, recordReleaseStep, releaseIssuerGone, takeReleaseLease, } from "./store.js";
|
|
24
25
|
import { acquireDeploymentLock } from "./deploy-lock.js";
|
|
@@ -62,8 +63,6 @@ const ownCli = () => {
|
|
|
62
63
|
const bin = join(dirname(fileURLToPath(import.meta.url)), "bin.js");
|
|
63
64
|
return existsSync(bin) ? [process.execPath, bin] : ["toolroll"];
|
|
64
65
|
};
|
|
65
|
-
/** Journal phases the deployment's finish accepts (scripts/deploy-phases.mjs finish): the new service runs, unproven. */
|
|
66
|
-
const FINISHES_FORWARD = Object.freeze(["started", "healthy"]);
|
|
67
66
|
const cleanEnv = (extra = {}) => {
|
|
68
67
|
const env = { ...process.env, ...extra };
|
|
69
68
|
delete env["NODE_OPTIONS"];
|
|
@@ -580,23 +579,40 @@ export function releaseAdapters(options) {
|
|
|
580
579
|
const running = (options.processes ?? processes)().filter(one => one.command.includes("deploy-browser.mjs") && one.command.includes(stage));
|
|
581
580
|
if (running.length > 0)
|
|
582
581
|
return { ok: false, message: `the deployment is still running (process ${running.map(one => one.pid).join(", ")}); let it finish, then rerun` };
|
|
583
|
-
// The new service started (deploy-
|
|
584
|
-
//
|
|
582
|
+
// The new service was started (the phase table's "forward" rows, deploy-phase.ts): the release resumes it
|
|
583
|
+
// forward with the deployment's own finish (health, both consoles, leases, then the pause lifted).
|
|
585
584
|
// Finishing deploys, so the candidate is proven first, as for a deploy.
|
|
586
|
-
const
|
|
585
|
+
const journalPhase = () => { try {
|
|
587
586
|
return object(JSON.parse(readFileSync(join(stage, "deployment.json"), "utf8")))["phase"];
|
|
588
587
|
}
|
|
589
588
|
catch {
|
|
590
589
|
return undefined;
|
|
591
|
-
} }
|
|
592
|
-
|
|
590
|
+
} };
|
|
591
|
+
const phase = journalPhase();
|
|
592
|
+
if (deployActionOf(phase) === "forward") {
|
|
593
593
|
const proven = proveCandidate(runId);
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
594
|
+
let why;
|
|
595
|
+
if (proven.ok) {
|
|
596
|
+
const finished = await deployScript(worktree, stage, runId, proven, ["--yes", "--phase", "finish"]);
|
|
597
|
+
if (finished.code === 0)
|
|
598
|
+
return { ok: true };
|
|
599
|
+
why = `${tail(finished)} (the deployment journal is at ${String(phase)}; finishing it forward stopped; ${join(stage, "deployment.json")})`;
|
|
600
|
+
}
|
|
601
|
+
else
|
|
602
|
+
why = proven.message;
|
|
603
|
+
// Once the new service was recorded as started, a journal alone never rolls it back: the release stops here.
|
|
604
|
+
// Still at "starting" (killed while waiting for the new service; finish only looks, and it did not find it
|
|
605
|
+
// running): the deployment's own recovery puts the previous service back, and only when it proves the new
|
|
606
|
+
// one gone. That needs no approval of the candidate, exactly as below.
|
|
607
|
+
if (journalPhase() !== "starting")
|
|
608
|
+
return stopped(stage, why);
|
|
609
|
+
const recorded = recordedCommit(runId);
|
|
610
|
+
if (recorded === null)
|
|
611
|
+
return stopped(stage, why);
|
|
612
|
+
const back = await deployScript(worktree, stage, runId, recorded, ["--phase", "recover"]);
|
|
613
|
+
if (back.code === 0)
|
|
598
614
|
return { ok: true };
|
|
599
|
-
return stopped(stage, `${
|
|
615
|
+
return stopped(stage, `${why} The previous service was not put back either: ${tail(back)}`);
|
|
600
616
|
}
|
|
601
617
|
// Recovery needs no approval, but it runs only the recorded commit's own script, extracted by this runtime.
|
|
602
618
|
const recorded = recordedCommit(runId);
|
|
@@ -642,7 +658,7 @@ export function releaseAdapters(options) {
|
|
|
642
658
|
catch {
|
|
643
659
|
return false;
|
|
644
660
|
}
|
|
645
|
-
return phase
|
|
661
|
+
return stoppedWhereNothingChanged(phase);
|
|
646
662
|
},
|
|
647
663
|
async cliLinks() { return findCliLinks(); },
|
|
648
664
|
async switchLinks(links, bin, version) {
|
package/dist/release-gate.d.ts
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
* Anything missing, damaged or contradicting refuses and leaves the pause as it is. Without --yes it only shows the
|
|
19
19
|
* proof; with --yes it lifts that one pause (the owner is checked again inside the same write) and journals it.
|
|
20
20
|
*/
|
|
21
|
+
import { BEFORE_SWAP } from "./deploy-phase.js";
|
|
21
22
|
import type { Database } from "./store.js";
|
|
22
|
-
/** The journal phases a deployment passes before it stops the old service (deploy-
|
|
23
|
-
export
|
|
23
|
+
/** The journal phases a deployment passes before it stops the old service (deploy-phase.ts, the one table). */
|
|
24
|
+
export { BEFORE_SWAP };
|
|
24
25
|
export type GateReleaseWorld = {
|
|
25
26
|
/** The live database file. */
|
|
26
27
|
database: string;
|
|
@@ -81,4 +82,3 @@ export type GateReleaseOutcome = {
|
|
|
81
82
|
};
|
|
82
83
|
/** Prove, then (with `yes`) lift exactly that pause and journal the release in the deployment's own journal. */
|
|
83
84
|
export declare function releaseGate(id: string, world: GateReleaseWorld, yes: boolean): GateReleaseOutcome;
|
|
84
|
-
export {};
|
package/dist/release-gate.js
CHANGED
|
@@ -23,8 +23,9 @@ import { join } from "node:path";
|
|
|
23
23
|
import { durableJson } from "./desktop-update.js";
|
|
24
24
|
import { removeUpdateGate, updateGateOwned } from "./desktop-update-gate.js";
|
|
25
25
|
import { acquireDeploymentLock } from "./deploy-lock.js";
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
import { BEFORE_SWAP, RELEASED } from "./deploy-phase.js";
|
|
27
|
+
/** The journal phases a deployment passes before it stops the old service (deploy-phase.ts, the one table). */
|
|
28
|
+
export { BEFORE_SWAP };
|
|
28
29
|
/** Journal fields that only the swap and later phases write. */
|
|
29
30
|
const SWAP_FIELDS = ["stoppingService", "codingBackupBeforeSwap", "migration", "newService", "deployedAt"];
|
|
30
31
|
const GATE_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
@@ -121,7 +122,9 @@ export function proveBeforeSwap(id, world) {
|
|
|
121
122
|
if (staged.length > 0)
|
|
122
123
|
return refuse("after-swap", `Process ${staged.map(one => one.pid).join(", ")} runs from the staged runtime ${r.nextRuntime}.`);
|
|
123
124
|
const supervisor = processes.find(one => one.command.includes(`${r.priorRuntime}/controller-service.js`));
|
|
124
|
-
|
|
125
|
+
// The database as a whole argument: an `up` on a path that only begins with this database's is another service's.
|
|
126
|
+
const upHere = `${r.priorRuntime}/cli.js up --db ${world.database}`;
|
|
127
|
+
const worker = processes.find(one => one.command.split(upHere).slice(1).some(rest => rest === "" || /^\s/.test(rest)));
|
|
125
128
|
if (supervisor === undefined || worker === undefined)
|
|
126
129
|
return refuse("conflict", `The service is not running from ${r.priorRuntime}, so it can't be shown that the swap never began.`);
|
|
127
130
|
proof.push(`The service runs from ${r.priorRuntime} (process ${supervisor.pid}); nothing runs from the staged runtime, and no deployment is running.`);
|
|
@@ -163,7 +166,7 @@ export function releaseGate(id, world, yes) {
|
|
|
163
166
|
// The owner is checked again within removeUpdateGate's short write transaction.
|
|
164
167
|
removeUpdateGate(db, id);
|
|
165
168
|
durableJson(journalFile, {
|
|
166
|
-
...fresh.journal, phase:
|
|
169
|
+
...fresh.journal, phase: RELEASED, updatedAt: world.now().toISOString(),
|
|
167
170
|
releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: fresh.journal.phase, proof: fresh.proof },
|
|
168
171
|
});
|
|
169
172
|
return { ok: true, released: true, id, stage: fresh.stage, proof: fresh.proof };
|
package/dist/release-next.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deployActionOf, type DeployAction } from "./deploy-phase.js";
|
|
1
2
|
import { type RescueWorld } from "./deploy-rescue.js";
|
|
2
3
|
import { type GateReleaseWorld } from "./release-gate.js";
|
|
3
4
|
import { type ReleaseJournal } from "./release.js";
|
|
@@ -29,9 +30,8 @@ export type SettleWorld = {
|
|
|
29
30
|
rescue: RescueWorld;
|
|
30
31
|
gate: GateReleaseWorld;
|
|
31
32
|
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export declare function deployActionOf(phase: unknown): DeployAction;
|
|
33
|
+
/** The one action a deployment's journal calls for: a row of the one phase table (deploy-phase.ts). */
|
|
34
|
+
export { deployActionOf, type DeployAction };
|
|
35
35
|
/** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
|
|
36
36
|
export declare function settleDeployment(world: SettleWorld, say: (line: string) => void): Promise<{
|
|
37
37
|
ok: true;
|
package/dist/release-next.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
12
12
|
import { hostname } from "node:os";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { deployActionOf } from "./deploy-phase.js";
|
|
15
|
+
import { rescueDeployment } from "./deploy-rescue.js";
|
|
16
|
+
import { releaseGate } from "./release-gate.js";
|
|
16
17
|
import { journalFile, readJournal } from "./release.js";
|
|
17
18
|
import { autoPublishHeld, PUBLISH_ALLOWED_ACTION } from "./release-auto.js";
|
|
18
19
|
import { sameMachineHost } from "./machine-identity.js";
|
|
@@ -78,18 +79,8 @@ export async function takeReleaseRun(stateDir, branch, say, seams = {}) {
|
|
|
78
79
|
await sleep(seams.pollMs ?? 5_000);
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
|
-
/** The one action a deployment's journal calls for. */
|
|
82
|
-
export
|
|
83
|
-
if (typeof phase !== "string")
|
|
84
|
-
return "refuse";
|
|
85
|
-
if (phase === "released" || phase === "deployed")
|
|
86
|
-
return "none";
|
|
87
|
-
if (BEFORE_SWAP.includes(phase))
|
|
88
|
-
return "lift-pause";
|
|
89
|
-
if (FORWARD_ONLY.includes(phase))
|
|
90
|
-
return "forward";
|
|
91
|
-
return "roll-back";
|
|
92
|
-
}
|
|
82
|
+
/** The one action a deployment's journal calls for: a row of the one phase table (deploy-phase.ts). */
|
|
83
|
+
export { deployActionOf };
|
|
93
84
|
/** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
|
|
94
85
|
export async function settleDeployment(world, say) {
|
|
95
86
|
const open = world.rescue.journals().filter(one => deployActionOf(one.journal?.["phase"]) !== "none");
|
|
@@ -99,8 +90,14 @@ export async function settleDeployment(world, say) {
|
|
|
99
90
|
return { ok: false, message: `${open.length} deployments are unfinished: ${open.map(one => one.stage).join(", ")}. Nothing was changed.` };
|
|
100
91
|
const { stage, journal } = open[0];
|
|
101
92
|
const action = deployActionOf(journal?.["phase"]);
|
|
102
|
-
if (action === "refuse")
|
|
103
|
-
|
|
93
|
+
if (action === "refuse") {
|
|
94
|
+
// A word this version's table does not hold (a newer candidate's, or damage) is never acted on by itself: the
|
|
95
|
+
// deployment's own recovery knows its own phases, and the person starts it.
|
|
96
|
+
const phase = journal?.["phase"];
|
|
97
|
+
return typeof phase === "string"
|
|
98
|
+
? { ok: false, message: `The deployment in ${stage} stopped at a step this version doesn't know (${/^[a-z-]{1,40}$/.test(phase) ? phase : "not a plain word"}). Nothing was changed. Its own recovery can decide: \`toolroll release --recover-deploy --yes\`.` }
|
|
99
|
+
: { ok: false, message: `The deployment journal in ${stage} can't be read. Nothing was changed.` };
|
|
100
|
+
}
|
|
104
101
|
if (action === "forward") {
|
|
105
102
|
say("Finishing the deployment that was under way.");
|
|
106
103
|
return { ok: true };
|
package/dist/staged-runtimes.js
CHANGED
|
@@ -12,6 +12,7 @@ import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSyn
|
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
15
|
+
import { isDeployed } from "./deploy-phase.js";
|
|
15
16
|
import { quarantine, quarantineEntries, claimQuarantine, releaseQuarantine, restoreQuarantine, deleteQuarantineSync } from "./sweep-quarantine.js";
|
|
16
17
|
import { processCensus } from "./run-closeout.js";
|
|
17
18
|
const DAY_MS = 86_400_000;
|
|
@@ -84,7 +85,7 @@ function readStage(path, name, fallbackAt) {
|
|
|
84
85
|
at = 0;
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
|
-
const finished = deploy !== null ? deploy["phase"]
|
|
88
|
+
const finished = deploy !== null ? isDeployed(deploy["phase"]) : update !== null ? typeof update["finishedAt"] === "string" : false;
|
|
88
89
|
const from = update?.["from"];
|
|
89
90
|
const prior = typeof deploy?.["priorRuntime"] === "string" ? deploy["priorRuntime"] : typeof from?.dist === "string" ? from.dist : null;
|
|
90
91
|
let keptAside = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.99",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|