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 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.
@@ -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"] !== "deployed")
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");
@@ -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 declare const FORWARD_ONLY: readonly string[];
3
+ export { FORWARD_ONLY };
3
4
  export type RescueWorld = {
4
5
  /** The live database. */
5
6
  database: string;
@@ -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 "./release-gate.js";
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 const FORWARD_ONLY = Object.freeze(["starting", "started", "healthy"]);
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 (["stopping", "stopped", "migrating", "migrated", "start-failed", "backup-restored"].includes(phase)) {
43
- if (["migrating", "migrated", "start-failed"].includes(phase))
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 || !FINISHED.includes(String(one.journal["phase"])));
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 (FINISHED.includes(phase))
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).` };
@@ -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;
@@ -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, gateOwner } from "./release-gate.js";
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-rescue.ts FORWARD_ONLY): recovery refuses to roll it back, so the release
584
- // resumes it forward with the deployment's own finish (health, both consoles, leases, then the pause lifted).
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 phase = (() => { try {
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
- if (FINISHES_FORWARD.includes(String(phase))) {
590
+ } };
591
+ const phase = journalPhase();
592
+ if (deployActionOf(phase) === "forward") {
593
593
  const proven = proveCandidate(runId);
594
- if (!proven.ok)
595
- return stopped(stage, proven.message);
596
- const finished = await deployScript(worktree, stage, runId, proven, ["--yes", "--phase", "finish"]);
597
- if (finished.code === 0)
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, `${tail(finished)} (the deployment journal is at ${String(phase)}; finishing it forward stopped; ${join(stage, "deployment.json")})`);
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 === "preparing" || phase === "released" || phase === "deployed";
661
+ return stoppedWhereNothingChanged(phase);
646
662
  },
647
663
  async cliLinks() { return findCliLinks(); },
648
664
  async switchLinks(links, bin, version) {
@@ -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-phases.mjs). */
23
- export declare const BEFORE_SWAP: readonly string[];
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 {};
@@ -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
- /** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
27
- export const BEFORE_SWAP = Object.freeze(["preparing", "admission-paused", "frozen", "backup-verified", "rehearsed"]);
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
- const worker = processes.find(one => one.command.includes(`${r.priorRuntime}/cli.js up --db ${world.database}`));
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: "released", updatedAt: world.now().toISOString(),
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 };
@@ -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
- export type DeployAction = "none" | "lift-pause" | "forward" | "roll-back" | "refuse";
33
- /** The one action a deployment's journal calls for. */
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;
@@ -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 { FORWARD_ONLY, rescueDeployment } from "./deploy-rescue.js";
15
- import { BEFORE_SWAP, releaseGate } from "./release-gate.js";
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 function deployActionOf(phase) {
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
- return { ok: false, message: `The deployment journal in ${stage} can't be read. Nothing was changed.` };
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 };
@@ -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"] === "deployed" : update !== null ? typeof update["finishedAt"] === "string" : false;
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.98",
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"