From 04d71a7593d434227089260c5457bda49a5b9f0e Mon Sep 17 00:00:00 2001 From: paulcatamio Date: Fri, 21 Aug 2026 23:14:21 +1000 Subject: [PATCH 1/2] feat(server): add safe service runtime pruning --- apps/server/src/cli/service.test.ts | 23 ++++++- apps/server/src/cli/service.ts | 36 +++++++++- apps/server/src/cloud/bootService.test.ts | 61 ++++++++++++++++- apps/server/src/cloud/bootService.ts | 62 ++++++++++++++++- apps/server/src/cloud/pinnedRuntime.test.ts | 68 ++++++++++++++++++ apps/server/src/cloud/pinnedRuntime.ts | 76 +++++++++++++++++++++ docs/internals/server-updates.md | 12 ++++ docs/user/background-service.md | 14 ++++ 8 files changed, 348 insertions(+), 4 deletions(-) diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 08980faf790e..9f183b0250e9 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -1,6 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { formatServiceStatus } from "./service.ts"; +import { formatServicePruneResult, formatServiceStatus } from "./service.ts"; const status = { supported: true, @@ -35,3 +35,24 @@ it("explains where the service is supported", () => { "Supported on: Linux with systemd, macOS with launchd", ); }); + +it("formats a service runtime prune preview", () => { + assert.equal( + formatServicePruneResult({ dryRun: true, versions: ["0.0.31", "0.0.32"] }), + ["Would prune 2 old T3 Code service runtimes:", " t3@0.0.31", " t3@0.0.32"].join("\n"), + ); +}); + +it("formats a completed service runtime prune", () => { + assert.equal( + formatServicePruneResult({ dryRun: false, versions: ["0.0.31"] }), + ["Pruned 1 old T3 Code service runtime:", " t3@0.0.31"].join("\n"), + ); +}); + +it("reports when there are no old service runtimes", () => { + assert.equal( + formatServicePruneResult({ dryRun: false, versions: [] }), + "No old T3 Code service runtimes found.", + ); +}); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 74d220610eff..15ebd8a13138 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -3,7 +3,7 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Terminal from "effect/Terminal"; -import { Command, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; @@ -63,6 +63,18 @@ export function formatServiceStatus( ].join("\n"); } +export function formatServicePruneResult(result: BootService.BootServicePruneResult): string { + if (result.versions.length === 0) { + return "No old T3 Code service runtimes found."; + } + const action = result.dryRun ? "Would prune" : "Pruned"; + const noun = result.versions.length === 1 ? "runtime" : "runtimes"; + return [ + `${action} ${result.versions.length} old T3 Code service ${noun}:`, + ...result.versions.map((version) => ` t3@${version}`), + ].join("\n"); +} + const runServiceCommand = Effect.fn("cli.service.run")(function* ( flags: { readonly baseDir: Parameters[0]["baseDir"] }, run: Effect.Effect, @@ -143,6 +155,27 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( ), ); +const dryRunFlag = Flag.boolean("dry-run").pipe( + Flag.withDescription("Show which runtimes would be removed without changing anything."), + Flag.withDefault(false), +); + +const servicePruneCommand = Command.make("prune", { + ...projectLocationFlags, + dryRun: dryRunFlag, +}).pipe( + Command.withDescription("Remove old T3 Code service runtimes that are safe to discard."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + yield* Console.log(formatServicePruneResult(yield* service.prune(flags))); + }), + ), + ), +); + export const offerServiceDuringOnboarding = Effect.gen(function* () { const service = yield* BootService.BootService; const { supported, installed, current } = yield* service.status; @@ -203,6 +236,7 @@ export const serviceCommand = Command.make("service").pipe( serviceInstallCommand, serviceUninstallCommand, serviceUpdateCommand, + servicePruneCommand, serviceStatusCommand, ]), ); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 1314ccfb9361..439c02efee02 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -155,7 +155,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ), ), ); - return { service, fs, statePath, commands, timeouts, control }; + return { service, fs, baseDir, statePath, commands, timeouts, control }; }); it.layer(NodeServices.layer)("boot service install", (it) => { @@ -250,6 +250,65 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("prunes old runtimes without restarting the service", () => + Effect.gen(function* () { + const { service, fs, baseDir, commands } = yield* makeHarness(); + yield* service.install; + const path = yield* Path.Path; + const oldRuntime = pinnedRuntimePaths(path, baseDir, "1.2.2"); + yield* fs.makeDirectory(path.dirname(oldRuntime.entryPath), { recursive: true }); + yield* fs.writeFileString(oldRuntime.entryPath, "export {};\n"); + yield* fs.writeFileString(oldRuntime.sentinelPath, "1.2.2\n"); + commands.length = 0; + + expect(yield* service.prune({ dryRun: true })).toEqual({ + dryRun: true, + versions: ["1.2.2"], + }); + expect(yield* fs.exists(oldRuntime.versionDir)).toBe(true); + expect(yield* service.prune({ dryRun: false })).toEqual({ + dryRun: false, + versions: ["1.2.2"], + }); + expect(yield* fs.exists(oldRuntime.versionDir)).toBe(false); + expect(commands).toEqual([]); + }), + ); + + it.effect("refuses to prune while a remote update is pending", () => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(); + yield* service.install; + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. + const pendingState = JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + update: { + id: "remote-update", + fromVersion: "1.2.3", + targetVersion: "1.2.4", + dbPath: "/tmp/state.sqlite", + status: "pending", + }, + }); + yield* fs.writeFileString(statePath, pendingState); + + expect((yield* service.prune({ dryRun: false }).pipe(Effect.flip))._tag).toBe( + "BootServiceUpdatePendingError", + ); + }), + ); + + it.effect("fails closed when service state is missing", () => + Effect.gen(function* () { + const { service } = yield* makeHarness(); + + expect((yield* service.prune({ dryRun: false }).pipe(Effect.flip))._tag).toBe( + "BootServicePruneStateError", + ); + }), + ); + it.effect("fails closed on Windows", () => Effect.gen(function* () { const { service } = yield* makeHarness("win32"); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 795bf38e979d..d81950334e91 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -19,6 +19,8 @@ import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths, PinnedRuntimeInstallError, + type PinnedRuntimePruneResult, + prunePinnedRuntimes, } from "./pinnedRuntime.ts"; import { SERVICE_LAUNCHER_FILE, @@ -403,6 +405,24 @@ export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass()( + "BootServicePruneStateError", + {}, +) { + override get message(): string { + return "The T3 Code service state is missing or invalid. Run `npx t3@latest service update` before pruning runtimes."; + } +} + +export class BootServicePruneError extends Schema.TaggedErrorClass()( + "BootServicePruneError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not prune T3 Code service runtimes."; + } +} + export type BootServiceError = | BootServiceUnsupportedError | BootServiceCommandError @@ -417,12 +437,24 @@ export interface BootServiceStatus { readonly logPath: string; } +export interface BootServicePruneOptions { + readonly dryRun: boolean; +} + +export type BootServicePruneResult = PinnedRuntimePruneResult; + export class BootService extends Context.Service< BootService, { readonly install: Effect.Effect; readonly uninstall: Effect.Effect; readonly status: Effect.Effect; + readonly prune: ( + options: BootServicePruneOptions, + ) => Effect.Effect< + BootServicePruneResult, + BootServicePruneStateError | BootServicePruneError | BootServiceUpdatePendingError + >; } >()("t3/cloud/bootService") {} @@ -683,7 +715,35 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { Effect.withSpan("cloud.boot_service.status"), ); - return BootService.of({ install, uninstall, status }); + const prune: BootService["Service"]["prune"] = Effect.fn("cloud.boot_service.prune")( + function* (options) { + const stateExists = yield* fs + .exists(statePath) + .pipe(Effect.mapError((cause) => new BootServicePruneError({ cause }))); + if (!stateExists) { + return yield* new BootServicePruneStateError(); + } + const stateText = yield* fs + .readFileString(statePath) + .pipe(Effect.mapError((cause) => new BootServicePruneError({ cause }))); + const state = parseServiceState(stateText); + if (state === undefined) { + return yield* new BootServicePruneStateError(); + } + if (state.update?.status === "pending") { + return yield* new BootServiceUpdatePendingError(); + } + return yield* prunePinnedRuntimes({ + baseDir: input.baseDir, + state, + dryRun: options.dryRun, + fs, + path, + }).pipe(Effect.mapError((cause) => new BootServicePruneError({ cause }))); + }, + ); + + return BootService.of({ install, uninstall, status, prune }); }); export const layer = (input: { diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index f34f0f5cf4d7..e9f044cbeb19 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -12,7 +12,9 @@ import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths, PinnedRuntimeInstallError, + prunePinnedRuntimes, } from "./pinnedRuntime.ts"; +import { SERVICE_LAUNCHER_PROTOCOL, type ServiceState } from "./serviceProtocol.ts"; const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => ProcessRunner.ProcessRunner.of({ @@ -37,6 +39,19 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => }), }); +const writeCompletedRuntime = Effect.fn("test.write_completed_runtime")(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string, + version: string, +) { + const runtime = pinnedRuntimePaths(path, baseDir, version); + yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); + yield* fs.writeFileString(runtime.entryPath, "export {};\n"); + yield* fs.writeFileString(runtime.sentinelPath, `${version}\n`); + return runtime; +}); + it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { it.effect("validates a staging tree before atomically publishing it", () => Effect.gen(function* () { @@ -174,3 +189,56 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); }); + +it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { + it.effect("removes only completed, unreferenced runtimes older than the active version", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-prune-" }); + const removable = yield* writeCompletedRuntime(fs, path, baseDir, "1.8.0"); + const rollback = yield* writeCompletedRuntime(fs, path, baseDir, "1.9.0"); + const active = yield* writeCompletedRuntime(fs, path, baseDir, "2.0.0"); + const newer = yield* writeCompletedRuntime(fs, path, baseDir, "2.1.0"); + const incomplete = pinnedRuntimePaths(path, baseDir, "1.7.0"); + yield* fs.makeDirectory(incomplete.versionDir, { recursive: true }); + const wrongSentinel = yield* writeCompletedRuntime(fs, path, baseDir, "1.6.0"); + yield* fs.writeFileString(wrongSentinel.sentinelPath, "wrong-version\n"); + const staging = path.join(path.dirname(active.versionDir), ".staging-install"); + yield* fs.makeDirectory(staging); + const linkedTarget = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runtime-link-target-" }); + const linkedRuntime = pinnedRuntimePaths(path, baseDir, "1.5.0"); + yield* fs.symlink(linkedTarget, linkedRuntime.versionDir); + + const state = { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "2.0.0", + update: { + id: "committed-update", + fromVersion: "1.9.0", + targetVersion: "2.0.0", + status: "committed", + }, + } satisfies ServiceState; + + const preview = yield* prunePinnedRuntimes({ baseDir, state, dryRun: true, fs, path }); + assert.deepEqual(preview, { dryRun: true, versions: ["1.8.0"] }); + assert.isTrue(yield* fs.exists(removable.versionDir)); + + const pruned = yield* prunePinnedRuntimes({ baseDir, state, dryRun: false, fs, path }); + assert.deepEqual(pruned, { dryRun: false, versions: ["1.8.0"] }); + assert.isFalse(yield* fs.exists(removable.versionDir)); + for (const preserved of [ + rollback.versionDir, + active.versionDir, + newer.versionDir, + incomplete.versionDir, + wrongSentinel.versionDir, + staging, + linkedRuntime.versionDir, + ]) { + assert.isTrue(yield* fs.exists(preserved)); + } + }), + ); +}); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 06628d5cc12f..36e5e122df59 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -7,6 +7,11 @@ import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; +import { + compareExactServiceVersions, + isExactServiceVersion, + type ServiceState, +} from "./serviceProtocol.ts"; /** * A pinned runtime is an exact `t3@` npm-installed into @@ -28,6 +33,11 @@ export interface PinnedRuntimePaths { readonly sentinelPath: string; } +export interface PinnedRuntimePruneResult { + readonly dryRun: boolean; + readonly versions: ReadonlyArray; +} + export function pinnedRuntimePaths( path: Path.Path, baseDir: string, @@ -220,3 +230,69 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( export const ensurePinnedRuntimeInstalled = (input: PinnedRuntimeInstallInput) => pinnedRuntimeInstallLock.withPermit(installPinnedRuntime(input)); + +export const prunePinnedRuntimes = Effect.fn("cloud.pinned_runtime.prune")(function* (input: { + readonly baseDir: string; + readonly state: ServiceState; + readonly dryRun: boolean; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; +}) { + const versionsDir = input.path.join(input.baseDir, PINNED_RUNTIME_DIR, "versions"); + if (!(yield* input.fs.exists(versionsDir))) { + return { dryRun: input.dryRun, versions: [] } satisfies PinnedRuntimePruneResult; + } + + const protectedVersions = new Set([ + input.state.activeVersion, + ...(input.state.update === undefined + ? [] + : [input.state.update.fromVersion, input.state.update.targetVersion]), + ]); + const realVersionsDir = yield* input.fs.realPath(versionsDir); + const entries = yield* input.fs.readDirectory(versionsDir); + const versions = yield* Effect.filter(entries, (version) => + Effect.gen(function* () { + if ( + !isExactServiceVersion(version) || + protectedVersions.has(version) || + compareExactServiceVersions(version, input.state.activeVersion) >= 0 + ) { + return false; + } + + const paths = pinnedRuntimePaths(input.path, input.baseDir, version); + const realVersionDir = yield* input.fs.realPath(paths.versionDir).pipe(Effect.option); + if ( + Option.isNone(realVersionDir) || + realVersionDir.value !== input.path.join(realVersionsDir, version) + ) { + return false; + } + + const [entryExists, sentinel] = yield* Effect.all([ + input.fs.exists(paths.entryPath), + input.fs.readFileString(paths.sentinelPath).pipe(Effect.option), + ]); + return entryExists && Option.isSome(sentinel) && sentinel.value.trim() === version; + }), + ); + versions.sort((left, right) => { + const precedence = compareExactServiceVersions(left, right); + return precedence === 0 ? left.localeCompare(right) : precedence; + }); + + if (!input.dryRun) { + yield* Effect.forEach( + versions, + (version) => + input.fs.remove(pinnedRuntimePaths(input.path, input.baseDir, version).versionDir, { + recursive: true, + force: true, + }), + { discard: true }, + ); + } + + return { dryRun: input.dryRun, versions } satisfies PinnedRuntimePruneResult; +}); diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 4648fa1fd257..ecdb261fecc8 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -54,6 +54,18 @@ snapshot, records rollback, and starts A. A durable restore marker makes an inte resume before either version can boot. After commit, B is active and the service manager's normal restart policy applies. +## Runtime Pruning + +`t3 service prune` removes completed exact-version installs that are older than the active version +and are not named by the latest update record. It ignores incomplete installs, staging directories, +symlinks, unexpected directory names, and newer versions. `--dry-run` reports the same candidates +without removing them. + +The command parses launcher-owned state before selecting candidates and refuses to run while an +update is pending. It does not stop or restart the service. Restricting candidates to versions older +than the active runtime also keeps a concurrently staged forward-update target outside the prune +set. + ## Database Rollback The launcher snapshots `state.sqlite`, `state.sqlite-wal`, and `state.sqlite-shm` after the old diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 17de5777ba0b..2b6964ab9c7f 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -23,6 +23,18 @@ Update or repair it: npx t3@latest service update ``` +Preview old service runtimes that can be removed: + +```sh +npx t3@latest service prune --dry-run +``` + +Remove them: + +```sh +npx t3@latest service prune +``` + Stop it and remove it from startup: ```sh @@ -31,6 +43,8 @@ npx t3@latest service uninstall Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. If a remote update is already in progress, wait for it to finish before retrying a local update. +Pruning does not restart the service. It keeps the active runtime and both versions named by the +latest update record, and it refuses to run while an update is pending. The service runs a small stable launcher. Exact T3 Code versions are installed separately, so a failed remote candidate can return to the previous version without rewriting the service From 2a2ac12e6dc4e943c435278325ea22d1cafff228 Mon Sep 17 00:00:00 2001 From: paulcatamio Date: Fri, 21 Aug 2026 23:33:53 +1000 Subject: [PATCH 2/2] fix(server): preserve prune failure context --- apps/server/src/cloud/bootService.test.ts | 9 ++-- apps/server/src/cloud/bootService.ts | 53 +++++++++++++++++------ 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 439c02efee02..4538b791ced6 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -301,11 +301,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("fails closed when service state is missing", () => Effect.gen(function* () { - const { service } = yield* makeHarness(); + const { service, statePath } = yield* makeHarness(); - expect((yield* service.prune({ dryRun: false }).pipe(Effect.flip))._tag).toBe( - "BootServicePruneStateError", - ); + expect(yield* service.prune({ dryRun: false }).pipe(Effect.flip)).toMatchObject({ + _tag: "BootServicePruneStateError", + statePath, + }); }), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index d81950334e91..6500217b1bf9 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -407,19 +407,23 @@ export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass()( "BootServicePruneStateError", - {}, + { statePath: Schema.String }, ) { override get message(): string { - return "The T3 Code service state is missing or invalid. Run `npx t3@latest service update` before pruning runtimes."; + return `The T3 Code service state at '${this.statePath}' is missing or invalid. Run \`npx t3@latest service update\` before pruning runtimes.`; } } export class BootServicePruneError extends Schema.TaggedErrorClass()( "BootServicePruneError", - { cause: Schema.Defect() }, + { + stage: Schema.Literals(["checking service state", "reading service state", "pruning runtimes"]), + path: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Could not prune T3 Code service runtimes."; + return `Could not prune T3 Code service runtimes while ${this.stage} at '${this.path}'.`; } } @@ -717,18 +721,32 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const prune: BootService["Service"]["prune"] = Effect.fn("cloud.boot_service.prune")( function* (options) { - const stateExists = yield* fs - .exists(statePath) - .pipe(Effect.mapError((cause) => new BootServicePruneError({ cause }))); + const stateExists = yield* fs.exists(statePath).pipe( + Effect.mapError( + (cause) => + new BootServicePruneError({ + stage: "checking service state", + path: statePath, + cause, + }), + ), + ); if (!stateExists) { - return yield* new BootServicePruneStateError(); + return yield* new BootServicePruneStateError({ statePath }); } - const stateText = yield* fs - .readFileString(statePath) - .pipe(Effect.mapError((cause) => new BootServicePruneError({ cause }))); + const stateText = yield* fs.readFileString(statePath).pipe( + Effect.mapError( + (cause) => + new BootServicePruneError({ + stage: "reading service state", + path: statePath, + cause, + }), + ), + ); const state = parseServiceState(stateText); if (state === undefined) { - return yield* new BootServicePruneStateError(); + return yield* new BootServicePruneStateError({ statePath }); } if (state.update?.status === "pending") { return yield* new BootServiceUpdatePendingError(); @@ -739,7 +757,16 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { dryRun: options.dryRun, fs, path, - }).pipe(Effect.mapError((cause) => new BootServicePruneError({ cause }))); + }).pipe( + Effect.mapError( + (cause) => + new BootServicePruneError({ + stage: "pruning runtimes", + path: path.dirname(runtimePaths.versionDir), + cause, + }), + ), + ); }, );