Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/server/src/cli/service.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.",
);
});
36 changes: 35 additions & 1 deletion apps/server/src/cli/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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* <A, E>(
flags: { readonly baseDir: Parameters<typeof resolveCliAuthConfig>[0]["baseDir"] },
run: Effect.Effect<A, E, BootService.BootService>,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -203,6 +236,7 @@ export const serviceCommand = Command.make("service").pipe(
serviceInstallCommand,
serviceUninstallCommand,
serviceUpdateCommand,
servicePruneCommand,
serviceStatusCommand,
]),
);
62 changes: 61 additions & 1 deletion apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -250,6 +250,66 @@ 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, statePath } = yield* makeHarness();

expect(yield* service.prune({ dryRun: false }).pipe(Effect.flip)).toMatchObject({
_tag: "BootServicePruneStateError",
statePath,
});
}),
);

it.effect("fails closed on Windows", () =>
Effect.gen(function* () {
const { service } = yield* makeHarness("win32");
Expand Down
89 changes: 88 additions & 1 deletion apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
ensurePinnedRuntimeInstalled,
pinnedRuntimePaths,
PinnedRuntimeInstallError,
type PinnedRuntimePruneResult,
prunePinnedRuntimes,
} from "./pinnedRuntime.ts";
import {
SERVICE_LAUNCHER_FILE,
Expand Down Expand Up @@ -403,6 +405,28 @@ export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootS
}
}

export class BootServicePruneStateError extends Schema.TaggedErrorClass<BootServicePruneStateError>()(
"BootServicePruneStateError",
{ statePath: Schema.String },
) {
override get message(): string {
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>()(
"BootServicePruneError",
{
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 while ${this.stage} at '${this.path}'.`;
}
}
Comment thread
paulcatamio marked this conversation as resolved.

export type BootServiceError =
| BootServiceUnsupportedError
| BootServiceCommandError
Expand All @@ -417,12 +441,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<BootServicePlan, BootServiceError>;
readonly uninstall: Effect.Effect<boolean, BootServiceError>;
readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;
readonly prune: (
options: BootServicePruneOptions,
) => Effect.Effect<
BootServicePruneResult,
BootServicePruneStateError | BootServicePruneError | BootServiceUpdatePendingError
>;
}
>()("t3/cloud/bootService") {}

Expand Down Expand Up @@ -683,7 +719,58 @@ 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({
stage: "checking service state",
path: statePath,
cause,
}),
),
);
if (!stateExists) {
return yield* new BootServicePruneStateError({ statePath });
}
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({ statePath });
}
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({
stage: "pruning runtimes",
path: path.dirname(runtimePaths.versionDir),
cause,
}),
),
);
},
);

return BootService.of({ install, uninstall, status, prune });
});

export const layer = (input: {
Expand Down
68 changes: 68 additions & 0 deletions apps/server/src/cloud/pinnedRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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* () {
Expand Down Expand Up @@ -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));
}
}),
);
});
Loading
Loading