authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-22 23:49:21+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 23:49:21+02:00
logab4a88386d5227706fadc4feb045dfb3e7fe6e1d
tree6a1caa8114a08ba8a5751d34be650f7378f7b0b7
parent78a0ea55c1d9edce8bcd2b3caff9510b4093e6d3
signature Signed by PGP key B5690EEEBB952194

Add remote server updates and standalone service management (#4286)

Co-authored-by: codex <codex@users.noreply.github.com>

36 files changed, 2566 insertions(+), 149 deletions(-)

README.md+2
......@@ -54,6 +54,8 @@ There's no public docs site yet, checkout the miscellaneous markdown files in [d
5454## Documentation
5555
5656- [Getting started](./docs/getting-started/quick-start.md)
57- [Remote access](./docs/user/remote-access.md)
58- [Keeping T3 Code in sync](./docs/user/server-updates.md)
5759- [Architecture overview](./docs/architecture/overview.md)
5860- [Provider guides](./docs/providers/codex.md)
5961- [Operations](./docs/operations/ci.md)
apps/server/src/bin.test.ts+16-1
......@@ -202,6 +202,18 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
202202 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))),
203203 );
204204
205 it.effect("exposes service lifecycle commands without T3 Connect configuration", () =>
206 Effect.gen(function* () {
207 const { output } = yield* captureStdout(runCli(["service", "--help"], noConnectCli));
208
209 assert.include(output, "Manage the T3 Code background service.");
210 assert.include(output, "install");
211 assert.include(output, "uninstall");
212 assert.include(output, "update");
213 assert.include(output, "status");
214 }),
215 );
216
205217 it.effect("reports fresh headless connect state without requiring local configuration", () =>
206218 Effect.gen(function* () {
207219 const baseDir = NodeFS.mkdtempSync(
......@@ -305,7 +317,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
305317 runConnectCli(["connect", "logout", "--base-dir", baseDir]),
306318 );
307319
308 assert.equal(output, "Signed out of T3 Connect locally.");
320 assert.equal(
321 output,
322 "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.",
323 );
309324 assert.isFalse(NodeFS.existsSync(tokenPath));
310325 }),
311326 );
apps/server/src/bin.ts+2
......@@ -13,6 +13,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
1313import { sharedServerCommandFlags } from "./cli/config.ts";
1414import { projectCommand } from "./cli/project.ts";
1515import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
16import { serviceCommand } from "./cli/service.ts";
1617
1718const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1819
......@@ -47,6 +48,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
4748 serveCommand,
4849 authCommand,
4950 projectCommand,
51 serviceCommand,
5052 cloudEnabled ? connectCommand : connectUnavailableCommand,
5153 ]),
5254 );
apps/server/src/cli/connect.test.ts+2-2
......@@ -16,9 +16,9 @@ import {
1616 formatRelayClientReady,
1717 headlessSessionConfig,
1818 isPublishAgentActivityEnabledValue,
19 recoverBootServiceOffer,
2019 reportCloudDisconnectResults,
2120} from "./connect.ts";
21import { recoverServiceOnboardingOffer } from "./service.ts";
2222
2323it("explains how to complete headless authorization", () => {
2424 assert.equal(
......@@ -58,7 +58,7 @@ it.effect("detects headless operation from individual SSH config values", () =>
5858
5959it.effect("treats cancelling optional background setup as a successful skip", () =>
6060 Effect.gen(function* () {
61 const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({})));
61 const result = yield* recoverServiceOnboardingOffer(Effect.fail(new Terminal.QuitError({})));
6262 assert.isFalse(result);
6363 }),
6464);
apps/server/src/cli/connect.ts+11-69
......@@ -6,7 +6,6 @@ import {
66} from "@t3tools/contracts";
77import { RelayOkResponse } from "@t3tools/contracts/relay";
88import * as RelayClient from "@t3tools/shared/relayClient";
9import * as Terminal from "effect/Terminal";
109import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1110import * as Cause from "effect/Cause";
1211import * as Config from "effect/Config";
......@@ -20,6 +19,7 @@ import * as Layer from "effect/Layer";
2019import * as Option from "effect/Option";
2120import * as References from "effect/References";
2221import * as Schema from "effect/Schema";
22import * as Terminal from "effect/Terminal";
2323import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
2424import {
2525 FetchHttpClient,
......@@ -29,7 +29,6 @@ import {
2929} from "effect/unstable/http";
3030import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
3131
32import packageJson from "../../package.json" with { type: "json" };
3332import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
3433import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
3534import * as BootService from "../cloud/bootService.ts";
......@@ -45,9 +44,13 @@ import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
4544import * as ServerConfig from "../config.ts";
4645import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
4746import * as ExternalLauncher from "../process/externalLauncher.ts";
48import * as ProcessRunner from "../processRunner.ts";
4947import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
5048import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
49import {
50 bootServiceLayer,
51 offerServiceDuringOnboarding,
52 recoverServiceOnboardingOffer,
53} from "./service.ts";
5154
5255const jsonFlag = Flag.boolean("json").pipe(
5356 Flag.withDescription("Emit JSON instead of human-readable output."),
......@@ -396,21 +399,6 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: {
396399 if (options.clearAuthorization) {
397400 const tokens = yield* CliTokenManager.CloudCliTokenManager;
398401 yield* tokens.clear;
399
400 // uninstall itself no-ops when nothing is installed (and on non-Linux),
401 // so no status pre-check that could mask a real removal failure.
402 const bootService = yield* BootService.BootService;
403 yield* bootService.uninstall.pipe(
404 Effect.tap((removed) =>
405 removed ? Console.log("Removed the T3 Code background service.") : Effect.void,
406 ),
407 Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)),
408 Effect.catch((error) =>
409 Console.warn(`Could not remove the background service: ${error.message}`).pipe(
410 Effect.as(false),
411 ),
412 ),
413 );
414402 }
415403
416404 yield* reportCloudDisconnectResults({
......@@ -420,7 +408,9 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: {
420408 });
421409
422410 if (options.clearAuthorization) {
423 yield* Console.log("Signed out of T3 Connect locally.");
411 yield* Console.log(
412 "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.",
413 );
424414 }
425415});
426416
......@@ -457,11 +447,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
457447 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
458448 EnvironmentAuth.runtimeLayer,
459449 ServerEnvironment.layer,
460 BootService.layer({
461 baseDir: config.baseDir,
462 logsDir: config.logsDir,
463 cliVersion: packageJson.version,
464 }).pipe(Layer.provide(ProcessRunner.layer)),
450 bootServiceLayer(config),
465451 headlessRelayClientTracingLayer,
466452 ).pipe(
467453 Layer.provideMerge(FetchHttpClient.layer),
......@@ -683,50 +669,6 @@ const connectLogoutCommand = Command.make("logout", {
683669 ),
684670);
685671
686const offerBootService = Effect.gen(function* () {
687 const bootService = yield* BootService.BootService;
688 const { supported, installed, current } = yield* bootService.status;
689 if (!supported) {
690 // Don't prompt for something that can only fail; background setup is
691 // Linux/systemd-only for now.
692 return false;
693 }
694 if (installed && current) {
695 yield* Console.log("T3 Code is already set up to run in the background on this machine.");
696 return true;
697 }
698 const wanted = yield* Prompt.run(
699 Prompt.confirm({
700 message: installed
701 ? "The installed T3 Code background service is from an older setup. Update it now?"
702 : "Run T3 Code in the background whenever this machine boots? " +
703 "It stays reachable through T3 Connect even after you log out.",
704 initial: true,
705 }),
706 );
707 if (!wanted) {
708 return false;
709 }
710 const plan = yield* bootService.install;
711 yield* Console.log(`Background service installed. Logs: ${plan.logPath}`);
712 return true;
713});
714
715export const recoverBootServiceOffer = <R>(
716 offer: Effect.Effect<boolean, BootService.BootServiceError | Terminal.QuitError, R>,
717) =>
718 offer.pipe(
719 Effect.catchTags({
720 QuitError: () => Effect.succeed(false),
721 BootServiceUnsupportedError: (error) =>
722 Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
723 BootServiceCommandError: (error) =>
724 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
725 BootServiceInstallError: (error) =>
726 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
727 }),
728 );
729
730672export const connectCommand = Command.make("connect", {
731673 ...projectLocationFlags,
732674 headless: headlessFlag,
......@@ -748,7 +690,7 @@ export const connectCommand = Command.make("connect", {
748690
749691 // Connect itself already succeeded; a boot-service failure must not
750692 // fail the command, just tell the user what happened and move on.
751 const background = yield* recoverBootServiceOffer(offerBootService);
693 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
752694 yield* Console.log(
753695 background
754696 ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out."
apps/server/src/cli/service.test.ts created+37
......@@ -0,0 +1,37 @@
1import { assert, it } from "@effect/vitest";
2
3import { formatServiceStatus } from "./service.ts";
4
5const status = {
6 supported: true,
7 installed: true,
8 current: true,
9 unitPath: "/home/me/.config/systemd/user/t3code.service",
10 logPath: "/home/me/.t3/userdata/logs/boot-service.log",
11} as const;
12
13it("reports the installed service version and host paths", () => {
14 assert.equal(
15 formatServiceStatus(status, "0.0.29"),
16 [
17 "T3 Code service",
18 " Status: installed · t3@0.0.29",
19 " Unit: /home/me/.config/systemd/user/t3code.service",
20 " Logs: /home/me/.t3/userdata/logs/boot-service.log",
21 ].join("\n"),
22 );
23});
24
25it("gives a direct repair command for a stale service", () => {
26 assert.include(
27 formatServiceStatus({ ...status, current: false }, "0.0.29"),
28 "Next: Run `npx t3@latest service update`.",
29 );
30});
31
32it("explains service availability without systemd", () => {
33 assert.include(
34 formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),
35 "Supported on: Linux with systemd",
36 );
37});
apps/server/src/cli/service.ts created+199
......@@ -0,0 +1,199 @@
1import * as Console from "effect/Console";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Terminal from "effect/Terminal";
5import { Command, GlobalFlag, Prompt } from "effect/unstable/cli";
6
7import packageJson from "../../package.json" with { type: "json" };
8import * as BootService from "../cloud/bootService.ts";
9import type * as ServerConfig from "../config.ts";
10import * as ProcessRunner from "../processRunner.ts";
11import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
12
13export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) =>
14 BootService.layer({
15 baseDir: config.baseDir,
16 logsDir: config.logsDir,
17 cliVersion: packageJson.version,
18 }).pipe(Layer.provide(ProcessRunner.layer));
19
20export type ServiceReconcileResult =
21 | {
22 readonly changed: false;
23 readonly status: BootService.BootServiceStatus;
24 }
25 | {
26 readonly changed: true;
27 readonly previouslyInstalled: boolean;
28 readonly plan: BootService.BootServicePlan;
29 };
30
31/** Install, update, or repair the service using the CLI version running this command. */
32export const reconcileService = Effect.fn("cli.service.reconcile")(function* () {
33 const service = yield* BootService.BootService;
34 const status = yield* service.status;
35 if (status.installed && status.current) {
36 return { changed: false, status } satisfies ServiceReconcileResult;
37 }
38 const plan = yield* service.install;
39 return {
40 changed: true,
41 previouslyInstalled: status.installed,
42 plan,
43 } satisfies ServiceReconcileResult;
44});
45
46export function formatServiceStatus(
47 status: BootService.BootServiceStatus,
48 cliVersion: string,
49): string {
50 if (!status.supported) {
51 return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd";
52 }
53 if (!status.installed) {
54 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
55 }
56 return [
57 "T3 Code service",
58 ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`,
59 ` Unit: ${status.unitPath}`,
60 ` Logs: ${status.logPath}`,
61 ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]),
62 ].join("\n");
63}
64
65const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E>(
66 flags: { readonly baseDir: Parameters<typeof resolveCliAuthConfig>[0]["baseDir"] },
67 run: Effect.Effect<A, E, BootService.BootService>,
68) {
69 const logLevel = yield* GlobalFlag.LogLevel;
70 const config = yield* resolveCliAuthConfig(flags, logLevel);
71 return yield* run.pipe(Effect.provide(bootServiceLayer(config)));
72});
73
74const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe(
75 Command.withDescription("Install T3 Code as a background service for this user."),
76 Command.withHandler((flags) =>
77 runServiceCommand(
78 flags,
79 Effect.gen(function* () {
80 const result = yield* reconcileService();
81 if (!result.changed) {
82 yield* Console.log(
83 `T3 Code service is already installed with t3@${packageJson.version}.`,
84 );
85 return;
86 }
87 yield* Console.log(
88 `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`,
89 );
90 }),
91 ),
92 ),
93);
94
95const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(
96 Command.withDescription(
97 "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.",
98 ),
99 Command.withHandler((flags) =>
100 runServiceCommand(
101 flags,
102 Effect.gen(function* () {
103 const result = yield* reconcileService();
104 if (!result.changed) {
105 yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);
106 return;
107 }
108 yield* Console.log(
109 `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`,
110 );
111 }),
112 ),
113 ),
114);
115
116const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe(
117 Command.withDescription("Stop and remove the T3 Code background service."),
118 Command.withHandler((flags) =>
119 runServiceCommand(
120 flags,
121 Effect.gen(function* () {
122 const service = yield* BootService.BootService;
123 const removed = yield* service.uninstall;
124 yield* Console.log(
125 removed ? "Removed the T3 Code service." : "T3 Code service is not installed.",
126 );
127 }),
128 ),
129 ),
130);
131
132const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(
133 Command.withDescription("Show whether the T3 Code background service is installed."),
134 Command.withHandler((flags) =>
135 runServiceCommand(
136 flags,
137 Effect.gen(function* () {
138 const service = yield* BootService.BootService;
139 yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version));
140 }),
141 ),
142 ),
143);
144
145export const offerServiceDuringOnboarding = Effect.gen(function* () {
146 const service = yield* BootService.BootService;
147 const { supported, installed, current } = yield* service.status;
148 if (!supported) {
149 return false;
150 }
151 if (installed && current) {
152 yield* Console.log("T3 Code is already set up to run in the background on this machine.");
153 return true;
154 }
155 const wanted = yield* Prompt.run(
156 Prompt.confirm({
157 message: installed
158 ? "The installed T3 Code service needs an update or repair. Update it now?"
159 : "Run T3 Code in the background whenever this machine boots? " +
160 "It stays reachable through T3 Connect even after you log out.",
161 initial: true,
162 }),
163 );
164 if (!wanted) {
165 return false;
166 }
167 const result = yield* reconcileService();
168 if (result.changed) {
169 yield* Console.log(
170 `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`,
171 );
172 }
173 return true;
174});
175
176export const recoverServiceOnboardingOffer = <R>(
177 offer: Effect.Effect<boolean, BootService.BootServiceError | Terminal.QuitError, R>,
178) =>
179 offer.pipe(
180 Effect.catchTags({
181 QuitError: () => Effect.succeed(false),
182 BootServiceUnsupportedError: (error) =>
183 Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
184 BootServiceCommandError: (error) =>
185 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
186 BootServiceInstallError: (error) =>
187 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
188 }),
189 );
190
191export const serviceCommand = Command.make("service").pipe(
192 Command.withDescription("Manage the T3 Code background service."),
193 Command.withSubcommands([
194 serviceInstallCommand,
195 serviceUninstallCommand,
196 serviceUpdateCommand,
197 serviceStatusCommand,
198 ]),
199);
apps/server/src/cloud/bootService.test.ts+33-4
......@@ -14,6 +14,7 @@ import {
1414 HostProcessPlatform,
1515} from "@t3tools/shared/hostProcess";
1616
17import { reconcileService } from "../cli/service.ts";
1718import * as ProcessRunner from "../processRunner.ts";
1819import * as BootService from "./bootService.ts";
1920
......@@ -100,7 +101,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => {
100101 unit,
101102 [
102103 "[Unit]",
103 "Description=T3 Code server (T3 Connect)",
104 "Description=T3 Code server",
104105 "StartLimitIntervalSec=300",
105106 "StartLimitBurst=5",
106107 "",
......@@ -108,6 +109,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => {
108109 "Type=simple",
109110 "WorkingDirectory=%h",
110111 "Environment=T3CODE_HOME=/home/theo/.t3",
112 "Environment=T3_BOOT_SERVICE_UNIT=t3code.service",
111113 "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve",
112114 "Restart=always",
113115 "RestartSec=5",
......@@ -170,6 +172,33 @@ it("flags package-manager cache entry points as ephemeral", () => {
170172});
171173
172174it.layer(NodeServices.layer)("BootService", (it) => {
175 it.effect("reconciles the standalone service once and is then idempotent", () =>
176 Effect.gen(function* () {
177 const { dirs } = yield* makeTestContext();
178 const commands: Array<RecordedCommand> = [];
179 const service = yield* BootService.make({
180 baseDir: dirs.baseDir,
181 logsDir: dirs.logsDir,
182 cliVersion: "0.0.27",
183 host: makeHost(dirs.stableEntry),
184 }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
185
186 const first = yield* reconcileService().pipe(
187 Effect.provideService(BootService.BootService, service),
188 );
189 assert.isTrue(first.changed);
190 if (!first.changed) return;
191 assert.isFalse(first.previouslyInstalled);
192
193 const commandCount = commands.length;
194 const second = yield* reconcileService().pipe(
195 Effect.provideService(BootService.BootService, service),
196 );
197 assert.isFalse(second.changed);
198 assert.lengthOf(commands, commandCount);
199 }),
200 );
201
173202 it.effect("installs the unit, enables the service, and enables linger", () =>
174203 Effect.gen(function* () {
175204 const { dirs, fs, path } = yield* makeTestContext();
......@@ -311,7 +340,7 @@ it.layer(NodeServices.layer)("BootService", (it) => {
311340 }),
312341 );
313342
314 it.effect("reports an installed-but-stale unit so connect can offer a repair", () =>
343 it.effect("reports an installed-but-stale unit so the lifecycle can offer a repair", () =>
315344 Effect.gen(function* () {
316345 const { dirs, fs, path } = yield* makeTestContext();
317346 const commands: Array<RecordedCommand> = [];
......@@ -402,8 +431,8 @@ it.layer(NodeServices.layer)("BootService", (it) => {
402431
403432 const error = yield* service.install.pipe(Effect.flip);
404433 assert.isTrue(isCommandError(error));
405 // A leftover unit would make the next connect report "already set up"
406 // even though linger never happened.
434 // A leftover unit would make status report "installed" even though
435 // linger never happened.
407436 assert.isFalse(
408437 yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
409438 );
apps/server/src/cloud/bootService.ts+41-60
......@@ -16,21 +16,19 @@ import {
1616} from "@t3tools/shared/hostProcess";
1717
1818import * as ProcessRunner from "../processRunner.ts";
19import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts";
1920
2021/**
21 * Installs T3 Code as a per-user boot service so a connected machine stays
22 * reachable through T3 Connect after the SSH session ends. Linux-only for
23 * now: systemd user unit + loginctl enable-linger. The service runs a pinned
24 * runtime installed under <baseDir>/runtime — never `npx t3`, whose cache is
25 * ephemeral and whose registry fetch at boot would make startup depend on
26 * the network.
22 * Installs T3 Code as a per-user boot service. Linux-only for now: systemd
23 * user unit + loginctl enable-linger. The service runs a stable or pinned
24 * runtime — never an ephemeral `npx t3` cache whose eviction could break
25 * startup.
2726 */
2827
2928const BOOT_SERVICE_NAME = "t3code";
30const BOOT_RUNTIME_DIR = "runtime";
3129
32const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
33const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
30export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
31export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";
3432
3533const EPHEMERAL_CACHE_SEGMENTS = [
3634 "/_npx/", // npx
......@@ -92,7 +90,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
9290 // relay connection, and Restart=always covers early-boot failures.
9391 return [
9492 "[Unit]",
95 "Description=T3 Code server (T3 Connect)",
93 "Description=T3 Code server",
9694 // Give up after 5 crashes in 5 minutes so a persistently broken install
9795 // (deleted runtime, broken workspace) stops instead of restarting every
9896 // 5s forever and growing the unrotated append log without bound.
......@@ -103,6 +101,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
103101 "Type=simple",
104102 "WorkingDirectory=%h",
105103 `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
104 `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
106105 `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`,
107106 "Restart=always",
108107 "RestartSec=5",
......@@ -206,14 +205,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
206205 const unitDir = path.join(homeDir, ".config", "systemd", "user");
207206 const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);
208207 const logPath = path.join(input.logsDir, "boot-service.log");
209 const runtimeVersionDir = path.join(
210 input.baseDir,
211 BOOT_RUNTIME_DIR,
212 "versions",
213 input.cliVersion,
214 );
215 const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs");
216 const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete");
208 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion);
217209
218210 const requireSystemdLinux = Effect.gen(function* () {
219211 if (platform !== "linux" || homeDir === "") {
......@@ -263,52 +255,41 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
263255 if (!isEphemeralCacheEntry(host.cliEntryPath)) {
264256 return;
265257 }
266 // The sentinel is written only after npm exits 0. Checking the entry
267 // file alone is not enough: npm extracts files before running native
268 // builds (node-pty), so a killed install leaves a plausible-looking but
269 // broken tree behind.
270 const alreadyPinned = yield* Effect.all([
271 fs.exists(runtimeSentinelPath),
272 fs.exists(runtimeEntryPath),
273 ]).pipe(
274 Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists),
275 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
276 );
277 if (alreadyPinned) {
278 return;
279 }
280 yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(
281 Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })),
282 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
283 );
284 yield* runStep(
285 "installing the pinned t3 runtime (this can take a few minutes)",
286 "npm",
287 [
288 "install",
289 "--prefix",
290 runtimeVersionDir,
291 "--no-fund",
292 "--no-audit",
293 `t3@${input.cliVersion}`,
294 ],
295 // Native deps (node-pty) can compile from source on slow boxes; the
296 // ProcessRunner default of 60s would kill a healthy install.
297 { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT },
298 ).pipe(
299 Effect.tapError(() =>
300 fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore),
258 yield* ensurePinnedRuntimeInstalled({
259 baseDir: input.baseDir,
260 version: input.cliVersion,
261 fs,
262 path,
263 runner,
264 }).pipe(
265 Effect.mapError((error) =>
266 error.step.startsWith("installing")
267 ? new BootServiceCommandError({
268 step: error.step,
269 exitCode: error.exitCode,
270 stdoutLength: error.stdoutLength,
271 stderrLength: error.stderrLength,
272 cause: error.cause,
273 })
274 : new BootServiceInstallError({ cause: error }),
275 ),
276 Effect.tapError((error) =>
277 DateTime.now.pipe(
278 Effect.flatMap((now) =>
279 fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, {
280 flag: "a",
281 }),
282 ),
283 Effect.ignore,
284 ),
301285 ),
302286 );
303 yield* fs
304 .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`)
305 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
306287 });
307288
308289 // Where the unit will point: derivable without touching the network, so
309290 // status can compare units purely; install materializes it first.
310291 const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath)
311 ? runtimeEntryPath
292 ? runtimePaths.entryPath
312293 : host.cliEntryPath;
313294 const plan: BootServicePlan = {
314295 nodePath: host.execPath,
......@@ -341,8 +322,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
341322 );
342323
343324 // If any activation step fails, remove the unit again: a leftover file
344 // would make the next `t3 connect` report the service as already set up
345 // even though it was never enabled or lingered.
325 // would make service status report it as installed even though it was
326 // never enabled or lingered.
346327 yield* Effect.gen(function* () {
347328 yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);
348329 yield* runStep("enabling the service", "systemctl", [
......@@ -372,7 +353,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
372353 // fails), leave nothing behind: disable removes the enable symlink, remove
373354 // deletes the file, daemon-reload clears the stale definition — otherwise a
374355 // dangling wants/ symlink logs "Failed to load unit" at every boot and the
375 // next connect misreports the state.
356 // next lifecycle command misreports the state.
376357 const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* (
377358 previousUnit: Option.Option<string>,
378359 ) {
apps/server/src/cloud/pinnedRuntime.test.ts created+129
......@@ -0,0 +1,129 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
9
10import * as ProcessRunner from "../processRunner.ts";
11import {
12 ensurePinnedRuntimeInstalled,
13 pinnedRuntimePaths,
14 removePinnedRuntimeInstallation,
15} from "./pinnedRuntime.ts";
16
17it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
18 it.effect("serializes concurrent installs of the same runtime", () =>
19 Effect.gen(function* () {
20 const fs = yield* FileSystem.FileSystem;
21 const path = yield* Path.Path;
22 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
23 const installStarted = yield* Deferred.make<void>();
24 const allowInstallToFinish = yield* Deferred.make<void>();
25 const paths = pinnedRuntimePaths(path, baseDir, "0.0.29");
26 let npmRuns = 0;
27
28 const runner = ProcessRunner.ProcessRunner.of({
29 run: (_input) =>
30 Effect.gen(function* () {
31 npmRuns += 1;
32 yield* Deferred.succeed(installStarted, undefined);
33 yield* Deferred.await(allowInstallToFinish);
34 yield* fs
35 .makeDirectory(path.dirname(paths.entryPath), { recursive: true })
36 .pipe(Effect.orDie);
37 yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie);
38 return {
39 stdout: "",
40 stderr: "",
41 code: ChildProcessSpawner.ExitCode(0),
42 timedOut: false,
43 stdoutTruncated: false,
44 stderrTruncated: false,
45 };
46 }),
47 });
48 const install = ensurePinnedRuntimeInstalled({
49 baseDir,
50 version: "0.0.29",
51 fs,
52 path,
53 runner,
54 });
55
56 const first = yield* Effect.forkChild(install, { startImmediately: true });
57 yield* Deferred.await(installStarted);
58 const second = yield* Effect.forkChild(install, { startImmediately: true });
59 yield* Effect.yieldNow;
60 assert.equal(npmRuns, 1);
61
62 yield* Deferred.succeed(allowInstallToFinish, undefined);
63 yield* Fiber.join(first);
64 yield* Fiber.join(second);
65
66 assert.equal(npmRuns, 1);
67 assert.isTrue(yield* fs.exists(paths.sentinelPath));
68 assert.isTrue(yield* fs.exists(paths.entryPath));
69 }),
70 );
71
72 it.effect("waits for an active install before removing its runtime", () =>
73 Effect.gen(function* () {
74 const fs = yield* FileSystem.FileSystem;
75 const path = yield* Path.Path;
76 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
77 const installStarted = yield* Deferred.make<void>();
78 const allowInstallToFinish = yield* Deferred.make<void>();
79 const paths = pinnedRuntimePaths(path, baseDir, "0.0.30");
80 const runner = ProcessRunner.ProcessRunner.of({
81 run: (_input) =>
82 Effect.gen(function* () {
83 yield* Deferred.succeed(installStarted, undefined);
84 yield* Deferred.await(allowInstallToFinish);
85 yield* fs
86 .makeDirectory(path.dirname(paths.entryPath), { recursive: true })
87 .pipe(Effect.orDie);
88 yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie);
89 return {
90 stdout: "",
91 stderr: "",
92 code: ChildProcessSpawner.ExitCode(0),
93 timedOut: false,
94 stdoutTruncated: false,
95 stderrTruncated: false,
96 };
97 }),
98 });
99
100 const installFiber = yield* Effect.forkChild(
101 ensurePinnedRuntimeInstalled({
102 baseDir,
103 version: "0.0.30",
104 fs,
105 path,
106 runner,
107 }),
108 { startImmediately: true },
109 );
110 yield* Deferred.await(installStarted);
111 const removeFiber = yield* Effect.forkChild(
112 removePinnedRuntimeInstallation({
113 baseDir,
114 version: "0.0.30",
115 fs,
116 path,
117 }),
118 { startImmediately: true },
119 );
120 yield* Effect.yieldNow;
121 assert.isTrue(yield* fs.exists(paths.versionDir));
122
123 yield* Deferred.succeed(allowInstallToFinish, undefined);
124 yield* Fiber.join(installFiber);
125 yield* Fiber.join(removeFiber);
126 assert.isFalse(yield* fs.exists(paths.versionDir));
127 }),
128 );
129});
apps/server/src/cloud/pinnedRuntime.ts created+176
......@@ -0,0 +1,176 @@
1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Schema from "effect/Schema";
6import * as Semaphore from "effect/Semaphore";
7
8import * as ProcessRunner from "../processRunner.ts";
9
10/**
11 * A pinned runtime is an exact `t3@<version>` npm-installed into
12 * <baseDir>/runtime/versions/<version>. The boot service points its systemd
13 * unit here, and server self-update installs the target version here before
14 * switching over — never `npx t3`, whose cache is ephemeral and whose
15 * registry fetch at boot would make startup depend on the network.
16 */
17
18const PINNED_RUNTIME_DIR = "runtime";
19const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
20// Boot-service setup and remote self-update share this module but can be
21// constructed in separate layers. Serialize the complete check/install/
22// sentinel transaction across all callers in this process.
23const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1);
24
25export interface PinnedRuntimePaths {
26 readonly versionDir: string;
27 readonly entryPath: string;
28 readonly sentinelPath: string;
29}
30
31export function pinnedRuntimePaths(
32 path: Path.Path,
33 baseDir: string,
34 version: string,
35): PinnedRuntimePaths {
36 const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version);
37 return {
38 versionDir,
39 entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),
40 sentinelPath: path.join(versionDir, ".install-complete"),
41 };
42}
43
44export class PinnedRuntimeInstallError extends Schema.TaggedErrorClass<PinnedRuntimeInstallError>()(
45 "PinnedRuntimeInstallError",
46 {
47 step: Schema.String,
48 exitCode: Schema.optional(Schema.Number),
49 stdoutLength: Schema.optional(Schema.Number),
50 stderrLength: Schema.optional(Schema.Number),
51 cause: Schema.optional(Schema.Defect()),
52 },
53) {
54 override get message(): string {
55 return this.exitCode === undefined
56 ? `Pinned runtime install failed while ${this.step}.`
57 : `Pinned runtime install failed while ${this.step} (exit code ${this.exitCode}).`;
58 }
59}
60
61/**
62 * Installs `t3@<version>` into the pinned runtime directory unless a complete
63 * install is already there, and returns its paths. The sentinel is written
64 * only after npm exits 0; checking the entry file alone is not enough — npm
65 * extracts files before running native builds (node-pty), so a killed
66 * install leaves a plausible-looking but broken tree behind.
67 */
68export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensure_installed")(
69 function* (input: {
70 readonly baseDir: string;
71 readonly version: string;
72 readonly fs: FileSystem.FileSystem;
73 readonly path: Path.Path;
74 readonly runner: ProcessRunner.ProcessRunner["Service"];
75 }) {
76 const { fs, runner } = input;
77 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version);
78
79 return yield* pinnedRuntimeInstallLock.withPermit(
80 Effect.gen(function* () {
81 const alreadyPinned = yield* Effect.all([
82 fs.exists(paths.sentinelPath),
83 fs.exists(paths.entryPath),
84 ]).pipe(
85 Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists),
86 Effect.mapError(
87 (cause) =>
88 new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }),
89 ),
90 );
91 if (alreadyPinned) {
92 return paths;
93 }
94
95 yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(
96 Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })),
97 Effect.mapError(
98 (cause) =>
99 new PinnedRuntimeInstallError({
100 step: "preparing the pinned runtime directory",
101 cause,
102 }),
103 ),
104 );
105
106 const installStep = "installing the pinned t3 runtime (this can take a few minutes)";
107 yield* runner
108 .run({
109 command: "npm",
110 args: [
111 "install",
112 "--prefix",
113 paths.versionDir,
114 "--no-fund",
115 "--no-audit",
116 `t3@${input.version}`,
117 ],
118 // Native deps (node-pty) can compile from source on slow boxes; the
119 // ProcessRunner default of 60s would kill a healthy install.
120 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
121 })
122 .pipe(
123 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })),
124 Effect.filterOrFail(
125 (result) => result.code === 0,
126 (result) =>
127 new PinnedRuntimeInstallError({
128 step: installStep,
129 exitCode: Number(result.code),
130 stdoutLength: result.stdout.length,
131 stderrLength: result.stderr.length,
132 }),
133 ),
134 Effect.tapError(() =>
135 fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore),
136 ),
137 );
138
139 yield* fs
140 .writeFileString(paths.sentinelPath, `${input.version}\n`)
141 .pipe(
142 Effect.mapError(
143 (cause) =>
144 new PinnedRuntimeInstallError({ step: "recording the completed install", cause }),
145 ),
146 );
147
148 return paths;
149 }),
150 );
151 },
152);
153
154/** Removes one pinned runtime while holding the same process-wide lock used
155 * by install/check/sentinel work, so cleanup cannot race another caller that
156 * is materializing or reusing the runtime tree. */
157export const removePinnedRuntimeInstallation = Effect.fn("cloud.pinned_runtime.remove")(
158 function* (input: {
159 readonly baseDir: string;
160 readonly version: string;
161 readonly fs: FileSystem.FileSystem;
162 readonly path: Path.Path;
163 }) {
164 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version);
165 yield* pinnedRuntimeInstallLock.withPermit(
166 input.fs
167 .remove(paths.versionDir, { recursive: true, force: true })
168 .pipe(
169 Effect.mapError(
170 (cause) =>
171 new PinnedRuntimeInstallError({ step: "removing the pinned runtime", cause }),
172 ),
173 ),
174 );
175 },
176);
apps/server/src/cloud/selfUpdate.test.ts created+589
......@@ -0,0 +1,589 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as TestClock from "effect/testing/TestClock";
9import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
10
11import {
12 HostProcessArguments,
13 HostProcessEnvironment,
14 HostProcessExecutablePath,
15 HostProcessPlatform,
16} from "@t3tools/shared/hostProcess";
17
18import * as ServerConfig from "../config.ts";
19import * as ProcessRunner from "../processRunner.ts";
20import {
21 BOOT_SERVICE_UNIT_ENV,
22 BOOT_SERVICE_UNIT_FILE,
23 renderBootServiceUnit,
24} from "./bootService.ts";
25import * as SelfUpdate from "./selfUpdate.ts";
26
27const NODE_PATH = "/usr/local/bin/node";
28
29interface RecordedCommand {
30 readonly command: string;
31 readonly args: ReadonlyArray<string>;
32}
33
34const makeRecordingRunnerLayer = (
35 commands: Array<RecordedCommand>,
36 options?: {
37 readonly failWhen?: ((command: string, args: ReadonlyArray<string>) => boolean) | undefined;
38 readonly stdoutFor?:
39 | ((command: string, args: ReadonlyArray<string>) => string | undefined)
40 | undefined;
41 },
42) =>
43 Layer.succeed(
44 ProcessRunner.ProcessRunner,
45 ProcessRunner.ProcessRunner.of({
46 run: (input) =>
47 Effect.sync(() => {
48 commands.push({ command: input.command, args: input.args });
49 const failed = options?.failWhen?.(input.command, input.args) === true;
50 const versionFromPath =
51 input.command === NODE_PATH && input.args[1] === "--version"
52 ? /[/\\]runtime[/\\]versions[/\\]([^/\\]+)/.exec(input.args[0] ?? "")?.[1]
53 : undefined;
54 return {
55 stdout:
56 options?.stdoutFor?.(input.command, input.args) ??
57 (versionFromPath === undefined ? "" : `${versionFromPath}\n`),
58 stderr: failed ? `${input.command} exploded` : "",
59 code: ChildProcessSpawner.ExitCode(failed ? 1 : 0),
60 timedOut: false,
61 stdoutTruncated: false,
62 stderrTruncated: false,
63 };
64 }),
65 }),
66 );
67
68const provideHostRefs = (input: {
69 readonly platform: NodeJS.Platform;
70 readonly env: NodeJS.ProcessEnv;
71 readonly entryPath: string;
72}) =>
73 Effect.provide(
74 Layer.mergeAll(
75 Layer.succeed(HostProcessPlatform, input.platform),
76 Layer.succeed(HostProcessEnvironment, input.env),
77 Layer.succeed(HostProcessExecutablePath, NODE_PATH),
78 Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]),
79 ),
80 );
81
82it("recognizes published npm artifacts as swappable entry points", () => {
83 assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs"));
84 assert.isTrue(
85 SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"),
86 );
87 assert.isTrue(
88 SelfUpdate.isPublishedCliEntry(
89 "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs",
90 ),
91 );
92 // Dev checkouts and the desktop bundle run apps/server/dist directly.
93 assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs"));
94 assert.isFalse(SelfUpdate.isPublishedCliEntry(""));
95});
96
97it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => {
98 const makeHome = Effect.fn("test.makeHome")(function* () {
99 const fs = yield* FileSystem.FileSystem;
100 const path = yield* Path.Path;
101 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
102 return { fs, path, home };
103 });
104
105 const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* (
106 home: string,
107 entryPath: string,
108 ) {
109 const fs = yield* FileSystem.FileSystem;
110 const path = yield* Path.Path;
111 const unitDir = path.join(home, ".config", "systemd", "user");
112 yield* fs.makeDirectory(unitDir, { recursive: true });
113 yield* fs.writeFileString(
114 path.join(unitDir, "t3code.service"),
115 renderBootServiceUnit({
116 nodePath: NODE_PATH,
117 t3EntryPath: entryPath,
118 baseDir: path.join(home, ".t3"),
119 logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"),
120 unitPath: path.join(unitDir, "t3code.service"),
121 }),
122 );
123 });
124
125 it.effect("reports boot-service for the systemd-spawned unit process", () =>
126 Effect.gen(function* () {
127 const { home, path } = yield* makeHome();
128 const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
129 yield* writeUnitReferencing(home, entryPath);
130 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
131 desktopManaged: false,
132 }).pipe(
133 provideHostRefs({
134 platform: "linux",
135 env: {
136 HOME: home,
137 INVOCATION_ID: "abc123",
138 [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE,
139 },
140 entryPath,
141 }),
142 );
143 assert.equal(method, "boot-service");
144 }),
145 );
146
147 it.effect("does not claim a systemd process owned by another unit", () =>
148 Effect.gen(function* () {
149 const { home, path } = yield* makeHome();
150 const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
151 yield* writeUnitReferencing(home, entryPath);
152 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
153 desktopManaged: false,
154 }).pipe(
155 provideHostRefs({
156 platform: "linux",
157 env: { HOME: home, INVOCATION_ID: "abc123" },
158 entryPath,
159 }),
160 );
161 assert.isNull(method);
162 }),
163 );
164
165 it.effect("reports respawn for a manual run of the pinned artifact", () =>
166 Effect.gen(function* () {
167 const { home, path } = yield* makeHome();
168 const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
169 yield* writeUnitReferencing(home, entryPath);
170 // Same unit on disk, but no INVOCATION_ID: restarting the unit would
171 // not replace this process, so it must respawn itself instead.
172 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
173 desktopManaged: false,
174 }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath }));
175 assert.equal(method, "respawn");
176 }),
177 );
178
179 it.effect("reports respawn for a foreground npx artifact on darwin", () =>
180 Effect.gen(function* () {
181 const { home } = yield* makeHome();
182 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
183 desktopManaged: false,
184 }).pipe(
185 provideHostRefs({
186 platform: "darwin",
187 env: { HOME: home },
188 entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`,
189 }),
190 );
191 assert.equal(method, "respawn");
192 }),
193 );
194
195 it.effect("reports desktop-managed for desktop-supervised backends", () =>
196 Effect.gen(function* () {
197 const { home, path } = yield* makeHome();
198 // Desktop ownership wins over every process-shape heuristic: even a
199 // systemd-looking pinned artifact belongs to the app that spawned it.
200 const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
201 yield* writeUnitReferencing(home, entryPath);
202 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
203 desktopManaged: true,
204 }).pipe(
205 provideHostRefs({
206 platform: "linux",
207 env: {
208 HOME: home,
209 INVOCATION_ID: "abc123",
210 [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE,
211 },
212 entryPath,
213 }),
214 );
215 assert.equal(method, "desktop-managed");
216 }),
217 );
218
219 it.effect("reports no method for dev checkouts and Windows", () =>
220 Effect.gen(function* () {
221 const { home } = yield* makeHome();
222 const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({
223 desktopManaged: false,
224 }).pipe(
225 provideHostRefs({
226 platform: "darwin",
227 env: { HOME: home },
228 entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`,
229 }),
230 );
231 assert.isNull(devMethod);
232 const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({
233 desktopManaged: false,
234 }).pipe(
235 provideHostRefs({
236 platform: "win32",
237 env: { HOME: home },
238 entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs",
239 }),
240 );
241 assert.isNull(windowsMethod);
242 }),
243 );
244});
245
246it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
247 interface RecordedSpawn {
248 readonly command: string;
249 readonly args: ReadonlyArray<string>;
250 }
251
252 const makeContext = Effect.fn("test.makeContext")(function* (options?: {
253 readonly platform?: NodeJS.Platform;
254 readonly bootService?: boolean;
255 readonly desktopManaged?: boolean;
256 readonly entryPath?: string;
257 readonly failWhen?: (command: string, args: ReadonlyArray<string>) => boolean;
258 readonly stdoutFor?: (command: string, args: ReadonlyArray<string>) => string | undefined;
259 readonly failSpawn?: boolean;
260 }) {
261 const fs = yield* FileSystem.FileSystem;
262 const path = yield* Path.Path;
263 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
264 const baseDir = path.join(home, ".t3");
265 const entryPath =
266 options?.entryPath ??
267 path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
268 const env: NodeJS.ProcessEnv =
269 options?.bootService === true
270 ? {
271 HOME: home,
272 INVOCATION_ID: "abc123",
273 [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE,
274 }
275 : { HOME: home };
276 if (options?.bootService === true) {
277 const unitDir = path.join(home, ".config", "systemd", "user");
278 yield* fs.makeDirectory(unitDir, { recursive: true });
279 yield* fs.writeFileString(
280 path.join(unitDir, "t3code.service"),
281 renderBootServiceUnit({
282 nodePath: NODE_PATH,
283 t3EntryPath: entryPath,
284 baseDir,
285 logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"),
286 unitPath: path.join(unitDir, "t3code.service"),
287 }),
288 );
289 }
290
291 const commands: Array<RecordedCommand> = [];
292 const spawns: Array<RecordedSpawn> = [];
293 let exited = 0;
294 // layerTest always reports mode "web"; desktop-managed contexts overlay
295 // the mode the desktop app's bootstrap envelope would set.
296 const configLayer =
297 options?.desktopManaged === true
298 ? Layer.effect(
299 ServerConfig.ServerConfig,
300 Effect.gen(function* () {
301 const config = yield* ServerConfig.ServerConfig;
302 return { ...config, mode: "desktop" as const };
303 }),
304 ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir)))
305 : ServerConfig.layerTest(home, baseDir);
306 const service = yield* SelfUpdate.make({
307 host: {
308 spawnDetached: (command, args) =>
309 Effect.sync(() => spawns.push({ command, args })).pipe(
310 Effect.andThen(
311 options?.failSpawn === true
312 ? Effect.fail(
313 new ProcessRunner.ProcessSpawnError({
314 command,
315 argumentCount: args.length,
316 cause: new Error("detached spawn failed"),
317 }),
318 )
319 : Effect.void,
320 ),
321 ),
322 exitProcess: () => {
323 exited += 1;
324 },
325 },
326 }).pipe(
327 Effect.provide(
328 Layer.mergeAll(
329 makeRecordingRunnerLayer(commands, {
330 failWhen: options?.failWhen,
331 stdoutFor: options?.stdoutFor,
332 }),
333 configLayer,
334 ),
335 ),
336 provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }),
337 );
338 return {
339 fs,
340 path,
341 home,
342 baseDir,
343 entryPath,
344 commands,
345 spawns,
346 exitCount: () => exited,
347 service,
348 };
349 });
350
351 it.effect("rejects dist-tags and other non-exact versions", () =>
352 Effect.gen(function* () {
353 const context = yield* makeContext();
354 const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip);
355 assert.include(error.reason, "not an exact t3 version");
356 assert.lengthOf(context.commands, 0);
357 }),
358 );
359
360 it.effect("refuses to update a desktop-managed backend and points at the app", () =>
361 Effect.gen(function* () {
362 const context = yield* makeContext({ desktopManaged: true, bootService: true });
363 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
364 assert.include(error.reason, "desktop app");
365 assert.lengthOf(context.commands, 0);
366 assert.lengthOf(context.spawns, 0);
367 }),
368 );
369
370 it.effect("fails without touching anything when no update method applies", () =>
371 Effect.gen(function* () {
372 const context = yield* makeContext({
373 entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs",
374 });
375 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
376 assert.include(error.reason, "cannot update itself");
377 assert.lengthOf(context.commands, 0);
378 }),
379 );
380
381 it.effect("surfaces a failed npm install and never schedules a restart", () =>
382 Effect.gen(function* () {
383 const context = yield* makeContext({ failWhen: (command) => command === "npm" });
384 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
385 assert.equal(error.reason, "Could not install the requested t3 version.");
386 yield* TestClock.adjust(Duration.seconds(10));
387 assert.lengthOf(context.spawns, 0);
388 assert.equal(context.exitCount(), 0);
389 }).pipe(Effect.provide(TestClock.layer())),
390 );
391
392 it.effect("reinstalls the same version after a failed preflight", () =>
393 Effect.gen(function* () {
394 let preflightAttempts = 0;
395 const context = yield* makeContext({
396 failWhen: (command) => {
397 if (command !== NODE_PATH) return false;
398 preflightAttempts += 1;
399 return preflightAttempts === 1;
400 },
401 });
402 const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29");
403 const entryPath = context.path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
404 yield* context.fs.makeDirectory(context.path.dirname(entryPath), { recursive: true });
405 yield* context.fs.writeFileString(entryPath, "export {};\n");
406 yield* context.fs.writeFileString(
407 context.path.join(versionDir, ".install-complete"),
408 "0.0.29\n",
409 );
410
411 const firstError = yield* context.service
412 .update({ targetVersion: "0.0.29" })
413 .pipe(Effect.flip);
414 assert.include(firstError.reason, "failed its version check");
415 assert.isFalse(yield* context.fs.exists(versionDir));
416
417 const result = yield* context.service.update({ targetVersion: "0.0.29" });
418 assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" });
419 assert.deepEqual(
420 context.commands.map((entry) => entry.command),
421 [NODE_PATH, "npm", NODE_PATH],
422 );
423 }).pipe(Effect.provide(TestClock.layer())),
424 );
425
426 it.effect("rejects and removes an installed runtime that reports the wrong version", () =>
427 Effect.gen(function* () {
428 const context = yield* makeContext({
429 stdoutFor: (command, args) =>
430 command === NODE_PATH && args[1] === "--version" ? "0.0.28\n" : undefined,
431 });
432 const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29");
433
434 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
435
436 assert.include(error.reason, "did not report the requested");
437 assert.isFalse(yield* context.fs.exists(versionDir));
438 assert.lengthOf(context.spawns, 0);
439 }),
440 );
441
442 it.effect("reports a detached replacement spawn failure and leaves updates retryable", () =>
443 Effect.gen(function* () {
444 const context = yield* makeContext({ failSpawn: true });
445
446 const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
447 assert.include(first.reason, "Could not start the replacement");
448
449 const second = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
450 assert.include(second.reason, "Could not start the replacement");
451 assert.notInclude(second.reason, "already in progress");
452 assert.lengthOf(context.spawns, 2);
453 assert.equal(context.exitCount(), 0);
454 }),
455 );
456
457 it.effect("installs, preflights, and respawns a foreground server", () =>
458 Effect.gen(function* () {
459 const context = yield* makeContext();
460 const result = yield* context.service.update({ targetVersion: "0.0.29" });
461 assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" });
462 assert.lengthOf(context.spawns, 1);
463
464 const concurrentError = yield* context.service
465 .update({ targetVersion: "0.0.30" })
466 .pipe(Effect.flip);
467 assert.include(concurrentError.reason, "already in progress");
468
469 const pinnedEntry = context.path.join(
470 context.baseDir,
471 "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs",
472 );
473 assert.deepEqual(
474 context.commands.map((entry) => [entry.command, ...entry.args].join(" ")),
475 [
476 `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`,
477 `${NODE_PATH} ${pinnedEntry} --version`,
478 ],
479 );
480
481 // The restart is deferred so the RPC acknowledgement flushes first.
482 yield* TestClock.adjust(Duration.seconds(10));
483 assert.lengthOf(context.spawns, 1);
484 const spawn = context.spawns[0];
485 assert.equal(spawn?.command, "/bin/sh");
486 assert.include(spawn?.args ?? [], pinnedEntry);
487 // The replacement replays the original CLI arguments.
488 assert.include(spawn?.args ?? [], "serve");
489 assert.equal(context.exitCount(), 1);
490 }).pipe(Effect.provide(TestClock.layer())),
491 );
492
493 it.effect("rewrites the systemd unit and restarts the boot service", () =>
494 Effect.gen(function* () {
495 const context = yield* makeContext({ bootService: true });
496 const result = yield* context.service.update({ targetVersion: "0.0.29" });
497 assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" });
498
499 const pinnedEntry = context.path.join(
500 context.baseDir,
501 "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs",
502 );
503 const unit = yield* context.fs.readFileString(
504 context.path.join(context.home, ".config", "systemd", "user", "t3code.service"),
505 );
506 assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`);
507 assert.deepEqual(
508 context.commands.map((entry) => entry.command),
509 ["npm", NODE_PATH, "systemctl", "systemctl"],
510 );
511 assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]);
512
513 assert.deepEqual(context.commands[3], {
514 command: "systemctl",
515 args: ["--user", "restart", "t3code.service"],
516 });
517 assert.lengthOf(context.spawns, 0);
518 // systemd replaces the process; the server must not exit itself.
519 assert.equal(context.exitCount(), 0);
520 }).pipe(Effect.provide(TestClock.layer())),
521 );
522
523 it.effect("restores the previous unit and permits a retry when systemd restart fails", () =>
524 Effect.gen(function* () {
525 let failRestart = true;
526 const context = yield* makeContext({
527 bootService: true,
528 failWhen: (command, args) => {
529 if (command !== "systemctl" || args[1] !== "restart" || !failRestart) {
530 return false;
531 }
532 failRestart = false;
533 return true;
534 },
535 });
536 const unitPath = context.path.join(
537 context.home,
538 ".config",
539 "systemd",
540 "user",
541 BOOT_SERVICE_UNIT_FILE,
542 );
543 const previousUnit = yield* context.fs.readFileString(unitPath);
544
545 const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
546 assert.include(first.reason, "Restarting the systemd boot service failed");
547 assert.equal(yield* context.fs.readFileString(unitPath), previousUnit);
548 assert.deepEqual(
549 context.commands.slice(-2).map((entry) => entry.args),
550 [
551 ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
552 ["--user", "daemon-reload"],
553 ],
554 );
555
556 const retry = yield* context.service.update({ targetVersion: "0.0.30" });
557 assert.deepEqual(retry, { targetVersion: "0.0.30", method: "boot-service" });
558 }).pipe(Effect.provide(TestClock.layer())),
559 );
560
561 it.effect("restores the previous systemd unit when daemon-reload fails", () =>
562 Effect.gen(function* () {
563 const context = yield* makeContext({
564 bootService: true,
565 failWhen: (command) => command === "systemctl",
566 });
567 const unitPath = context.path.join(
568 context.home,
569 ".config",
570 "systemd",
571 "user",
572 BOOT_SERVICE_UNIT_FILE,
573 );
574 const previousUnit = yield* context.fs.readFileString(unitPath);
575
576 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
577 assert.include(error.reason, "Reloading systemd units failed");
578 assert.equal(yield* context.fs.readFileString(unitPath), previousUnit);
579 assert.deepEqual(
580 context.commands.map((entry) => entry.command),
581 ["npm", NODE_PATH, "systemctl", "systemctl"],
582 );
583
584 yield* TestClock.adjust(Duration.seconds(10));
585 assert.lengthOf(context.spawns, 0);
586 assert.equal(context.exitCount(), 0);
587 }).pipe(Effect.provide(TestClock.layer())),
588 );
589});
apps/server/src/cloud/selfUpdate.ts created+428
......@@ -0,0 +1,428 @@
1// @effect-diagnostics nodeBuiltinImport:off
2// node:child_process directly: the foreground-server replacement must be a
3// detached fire-and-forget child that outlives this process, while Effect's
4// ChildProcessSpawner ties every child to a scope that kills it.
5import {
6 ServerSelfUpdateError,
7 type ServerSelfUpdateCapability,
8 type ServerSelfUpdateInput,
9 type ServerSelfUpdateResult,
10} from "@t3tools/contracts";
11import {
12 HostProcessArguments,
13 HostProcessEnvironment,
14 HostProcessExecutablePath,
15 HostProcessPlatform,
16} from "@t3tools/shared/hostProcess";
17import * as NodeChildProcess from "node:child_process";
18import * as Context from "effect/Context";
19import * as Duration from "effect/Duration";
20import * as Effect from "effect/Effect";
21import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";
23import * as Path from "effect/Path";
24import * as Ref from "effect/Ref";
25
26import * as ServerConfig from "../config.ts";
27import { writeFileStringAtomically } from "../atomicWrite.ts";
28import * as ProcessRunner from "../processRunner.ts";
29import {
30 BOOT_SERVICE_UNIT_ENV,
31 BOOT_SERVICE_UNIT_FILE,
32 quoteSystemdValue,
33 renderBootServiceUnit,
34} from "./bootService.ts";
35import { ensurePinnedRuntimeInstalled, removePinnedRuntimeInstallation } from "./pinnedRuntime.ts";
36
37/**
38 * Lets a connected client replace this server with another published `t3`
39 * version over RPC — the only update path that works when the user is not at
40 * the machine (phone against a home server, relay-managed box). The target
41 * version is npm-installed into the pinned runtime and verified before
42 * anything restarts, so a failed install leaves the running server untouched.
43 */
44
45const PREFLIGHT_TIMEOUT = Duration.seconds(30);
46/** Grace between acknowledging the RPC and killing the process, so the
47 response (and its relay hop) flushes before the socket drops. */
48const RESTART_DELAY = Duration.seconds(2);
49
50/** Exact npm versions only — never dist-tags — so the acknowledgement names
51 the version that was actually installed. Also keeps the value safe to
52 pass to npm and embed in filesystem paths. */
53const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
54
55export interface ServerSelfUpdateHost {
56 readonly execPath: string;
57 readonly cliEntryPath: string;
58 /** Original CLI arguments after the entry path, replayed on respawn. */
59 readonly cliArgs: ReadonlyArray<string>;
60 /** Resolves once the foreground replacement process has actually spawned. */
61 readonly spawnDetached: (
62 command: string,
63 args: ReadonlyArray<string>,
64 ) => Effect.Effect<void, ProcessRunner.ProcessSpawnError>;
65 readonly exitProcess: () => void;
66}
67
68function normalizeEntryPath(entryPath: string): string {
69 return entryPath.replaceAll("\\", "/");
70}
71
72/**
73 * Only a published npm artifact can be swapped for another version: dev
74 * checkouts (apps/server/dist) and the desktop app's bundled backend have no
75 * npm identity, and the desktop manages its own updates.
76 */
77export function isPublishedCliEntry(entryPath: string): boolean {
78 return normalizeEntryPath(entryPath).includes("/node_modules/t3/dist/");
79}
80
81/**
82 * The update path this process can offer, or null when only a manual
83 * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this
84 * backend and owns its version; only updating the app updates it.
85 * "boot-service" — this is the systemd-supervised process from
86 * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a
87 * foreground POSIX process running a published artifact: replace it with a
88 * detached child. Windows foreground runs are unsupported for now (no
89 * equivalent of the detach-and-exec handoff below).
90 */
91export const resolveServerSelfUpdateCapability = Effect.fn(
92 "cloud.server_self_update.resolve_capability",
93)(function* (input: {
94 /** True when the desktop app supervises this backend (mode "desktop"). */
95 readonly desktopManaged: boolean;
96}) {
97 if (input.desktopManaged) {
98 return "desktop-managed" as const;
99 }
100
101 const platform = yield* HostProcessPlatform;
102 const env = yield* HostProcessEnvironment;
103 const hostArguments = yield* HostProcessArguments;
104 const fs = yield* FileSystem.FileSystem;
105 const path = yield* Path.Path;
106
107 const entryPath = hostArguments[1] ?? "";
108 if (entryPath === "") {
109 return null;
110 }
111
112 const homeDir = env.HOME ?? "";
113 if (platform === "linux" && homeDir !== "") {
114 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
115 const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe(
116 Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))),
117 Effect.orElseSucceed(() => false),
118 );
119 // INVOCATION_ID only proves that some systemd unit launched us. The
120 // explicit marker written into t3code.service identifies this unit as the
121 // supervisor that will replace the current process when restarted.
122 if (
123 unitReferencesEntry &&
124 (env.INVOCATION_ID ?? "") !== "" &&
125 env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_UNIT_FILE
126 ) {
127 return "boot-service" as const;
128 }
129
130 // A process owned by another (or a legacy unmarked) systemd unit must not
131 // use the foreground respawn path: Restart=always could otherwise launch
132 // the old unit beside the detached replacement.
133 if ((env.INVOCATION_ID ?? "") !== "") {
134 return null;
135 }
136 }
137
138 if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) {
139 return "respawn" as const;
140 }
141
142 return null;
143});
144
145export class ServerSelfUpdate extends Context.Service<
146 ServerSelfUpdate,
147 {
148 readonly update: (
149 input: ServerSelfUpdateInput,
150 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
151 }
152>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
153
154export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: {
155 readonly host?: Partial<ServerSelfUpdateHost>;
156}) {
157 const serverConfig = yield* ServerConfig.ServerConfig;
158 const fs = yield* FileSystem.FileSystem;
159 const path = yield* Path.Path;
160 const runner = yield* ProcessRunner.ProcessRunner;
161 const env = yield* HostProcessEnvironment;
162 const hostExecPath = yield* HostProcessExecutablePath;
163 const hostArguments = yield* HostProcessArguments;
164 const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({
165 desktopManaged: serverConfig.mode === "desktop",
166 });
167
168 const host: ServerSelfUpdateHost = {
169 execPath: options?.host?.execPath ?? hostExecPath,
170 cliEntryPath: options?.host?.cliEntryPath ?? hostArguments[1] ?? "",
171 cliArgs: options?.host?.cliArgs ?? hostArguments.slice(2),
172 spawnDetached:
173 options?.host?.spawnDetached ??
174 ((command, args) =>
175 Effect.callback<void, ProcessRunner.ProcessSpawnError>((resume) => {
176 const spawnError = (cause: unknown) =>
177 new ProcessRunner.ProcessSpawnError({
178 command,
179 argumentCount: args.length,
180 cause,
181 });
182 let child: NodeChildProcess.ChildProcess;
183 try {
184 child = NodeChildProcess.spawn(command, [...args], {
185 detached: true,
186 stdio: "ignore",
187 });
188 } catch (cause) {
189 resume(Effect.fail(spawnError(cause)));
190 return;
191 }
192
193 const onSpawnError = (cause: Error) => resume(Effect.fail(spawnError(cause)));
194 child.once("error", onSpawnError);
195 child.once("spawn", () => {
196 child.removeListener("error", onSpawnError);
197 // Keep asynchronous child errors from becoming uncaught after the
198 // successful spawn handoff has already been acknowledged.
199 child.on("error", () => undefined);
200 child.unref();
201 resume(Effect.void);
202 });
203 })),
204 exitProcess: options?.host?.exitProcess ?? (() => process.exit(0)),
205 };
206
207 const inFlight = yield* Ref.make(false);
208
209 const failWith = (reason: string, cause?: unknown) =>
210 cause === undefined
211 ? new ServerSelfUpdateError({ reason })
212 : new ServerSelfUpdateError({ reason, cause });
213
214 /** Deferred so the RPC acknowledgement flushes before the process dies.
215 Detached from the request scope: the triggering connection is exactly
216 what the restart tears down. */
217 const scheduleRestart = (restart: Effect.Effect<void>) =>
218 Effect.sleep(RESTART_DELAY).pipe(
219 Effect.andThen(restart),
220 Effect.forkDetach({ startImmediately: true }),
221 );
222 const writeUnitAtomically = (filePath: string, contents: string) =>
223 writeFileStringAtomically({ filePath, contents }).pipe(
224 Effect.provideService(FileSystem.FileSystem, fs),
225 Effect.provideService(Path.Path, path),
226 );
227
228 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
229 "cloud.server_self_update.update",
230 )(function* (input) {
231 if (capability === "desktop-managed") {
232 return yield* failWith(
233 "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",
234 );
235 }
236 if (capability === null) {
237 return yield* failWith(
238 "This server cannot update itself; relaunch it manually with the new version.",
239 );
240 }
241 const activeMethod = capability;
242 const targetVersion = input.targetVersion.trim();
243 if (!EXACT_VERSION_PATTERN.test(targetVersion)) {
244 return yield* failWith(`'${targetVersion}' is not an exact t3 version.`);
245 }
246
247 const alreadyRunning = yield* Ref.getAndSet(inFlight, true);
248 if (alreadyRunning) {
249 return yield* failWith("A server update is already in progress.");
250 }
251
252 return yield* Effect.gen(function* () {
253 const runtimePaths = yield* ensurePinnedRuntimeInstalled({
254 baseDir: serverConfig.baseDir,
255 version: targetVersion,
256 fs,
257 path,
258 runner,
259 }).pipe(
260 Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)),
261 );
262
263 // A broken artifact (failed native build, incompatible node) must be
264 // caught while the current server is still alive to report it.
265 const preflight = yield* runner
266 .run({
267 command: host.execPath,
268 args: [runtimePaths.entryPath, "--version"],
269 timeout: PREFLIGHT_TIMEOUT,
270 })
271 .pipe(
272 Effect.mapError((cause) =>
273 failWith(`Could not verify the installed t3@${targetVersion}.`, cause),
274 ),
275 );
276 const preflightVersion = preflight.stdout.trim();
277 if (preflight.code !== 0 || preflightVersion !== targetVersion) {
278 // A completed npm install can still be unusable under this Node or on
279 // this machine. Remove its sentinel and tree so a retry of the same
280 // version performs a clean install instead of reusing a known-bad one.
281 yield* removePinnedRuntimeInstallation({
282 baseDir: serverConfig.baseDir,
283 version: targetVersion,
284 fs,
285 path,
286 }).pipe(
287 Effect.mapError((error) =>
288 failWith(`Could not remove the failed t3@${targetVersion} installation.`, error),
289 ),
290 );
291 return yield* failWith(
292 preflight.code !== 0
293 ? `The installed t3@${targetVersion} failed its version check (exit code ${String(preflight.code)}).`
294 : `The installed runtime did not report the requested t3@${targetVersion} version.`,
295 );
296 }
297
298 if (activeMethod === "boot-service") {
299 const homeDir = env.HOME ?? "";
300 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
301 const previousUnit = yield* fs
302 .readFileString(unitPath)
303 .pipe(
304 Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)),
305 );
306 // Same shape bootService.install writes, so host lifecycle commands
307 // still recognize the unit as current.
308 const unit = renderBootServiceUnit({
309 nodePath: host.execPath,
310 t3EntryPath: runtimePaths.entryPath,
311 baseDir: serverConfig.baseDir,
312 logPath: path.join(serverConfig.logsDir, "boot-service.log"),
313 unitPath,
314 });
315 yield* writeUnitAtomically(unitPath, unit).pipe(
316 Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)),
317 );
318
319 const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () {
320 const reload = yield* runner
321 .run({ command: "systemctl", args: ["--user", "daemon-reload"] })
322 .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause)));
323 if (reload.code !== 0) {
324 return yield* failWith(
325 `Reloading systemd units failed (exit code ${String(reload.code)}).`,
326 );
327 }
328 });
329
330 yield* reloadSystemd().pipe(
331 Effect.catch((reloadError) =>
332 writeUnitAtomically(unitPath, previousUnit).pipe(
333 Effect.mapError((rollbackCause) =>
334 failWith("Could not restore the previous systemd unit.", {
335 reloadError,
336 rollbackCause,
337 }),
338 ),
339 // Systemd should still have the old unit in memory after the
340 // failed reload, but retry after restoring in case it applied a
341 // partial update before returning an error.
342 Effect.andThen(reloadSystemd().pipe(Effect.ignore)),
343 Effect.andThen(Effect.fail(reloadError)),
344 ),
345 ),
346 );
347 yield* Effect.logInfo("Server self-update installed; restarting boot service.", {
348 targetVersion,
349 });
350 // A successful systemd restart stops this process, so the RPC is
351 // interrupted and the reconnecting client observes the new version.
352 // A rejected restart returns while the old process is still alive;
353 // restore the previous unit and report that failure through the RPC.
354 yield* Effect.gen(function* () {
355 const restart = yield* runner
356 .run({
357 command: "systemctl",
358 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
359 })
360 .pipe(
361 Effect.mapError((cause) =>
362 failWith("Could not restart the systemd boot service.", cause),
363 ),
364 );
365 if (restart.code !== 0) {
366 return yield* failWith(
367 `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`,
368 );
369 }
370 }).pipe(
371 Effect.catch((restartError) =>
372 writeUnitAtomically(unitPath, previousUnit).pipe(
373 Effect.andThen(reloadSystemd()),
374 Effect.mapError((rollbackError) =>
375 failWith("Could not restore the previous systemd unit.", {
376 restartError,
377 rollbackError,
378 }),
379 ),
380 Effect.andThen(Effect.fail(restartError)),
381 ),
382 ),
383 );
384 } else {
385 // Spawn the shim before acknowledging the RPC so ENOENT/EACCES and
386 // other launch failures leave this server alive and return a useful
387 // error. The shim itself waits until after the acknowledgement and
388 // deferred exit before binding the replacement server.
389 yield* host
390 .spawnDetached("/bin/sh", [
391 "-c",
392 'sleep 3; exec "$@"',
393 "t3-self-update",
394 host.execPath,
395 runtimePaths.entryPath,
396 ...host.cliArgs,
397 ])
398 .pipe(
399 Effect.mapError((cause) =>
400 failWith("Could not start the replacement t3 process.", cause),
401 ),
402 );
403 yield* Effect.logInfo("Server self-update installed; respawning.", { targetVersion });
404 yield* scheduleRestart(
405 Effect.try({
406 try: () => host.exitProcess(),
407 catch: (cause) => failWith("Could not exit the replaced t3 process.", cause),
408 }).pipe(
409 Effect.catch((error) =>
410 Effect.logError("Server self-update could not exit the replaced process.").pipe(
411 Effect.annotateLogs({ targetVersion, error: error.reason }),
412 Effect.ensuring(Ref.set(inFlight, false)),
413 ),
414 ),
415 ),
416 );
417 }
418
419 return { targetVersion, method: activeMethod };
420 }).pipe(Effect.onError(() => Ref.set(inFlight, false)));
421 });
422
423 return ServerSelfUpdate.of({ update });
424});
425
426export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(
427 Layer.provide(ProcessRunner.layer),
428);
apps/server/src/environment/ServerEnvironment.ts+5
......@@ -9,6 +9,7 @@ import * as Path from "effect/Path";
99import * as Schema from "effect/Schema";
1010
1111import packageJson from "../../package.json" with { type: "json" };
12import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
1213import * as ServerConfig from "../config.ts";
1314import * as ProcessRunner from "../processRunner.ts";
1415import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
......@@ -124,6 +125,9 @@ export const make = Effect.gen(function* () {
124125 const environmentId = EnvironmentId.make(environmentIdRaw);
125126 const cwdBaseName = path.basename(serverConfig.cwd).trim();
126127 const label = yield* resolveServerEnvironmentLabel({ cwdBaseName });
128 const serverSelfUpdate = yield* resolveServerSelfUpdateCapability({
129 desktopManaged: serverConfig.mode === "desktop",
130 });
127131
128132 const descriptor: ExecutionEnvironmentDescriptor = {
129133 environmentId,
......@@ -137,6 +141,7 @@ export const make = Effect.gen(function* () {
137141 repositoryIdentity: true,
138142 connectionProbe: true,
139143 threadSettlement: true,
144 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
140145 },
141146 };
142147
apps/server/src/server.ts+6-1
......@@ -79,6 +79,7 @@ import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts";
7979import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
8080import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
8181import * as CloudCliState from "./cloud/CliState.ts";
82import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
8283import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
8384import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
8485import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
......@@ -361,7 +362,11 @@ export const makeRoutesLayer = Layer.mergeAll(
361362 websocketRpcRouteLayer,
362363 ),
363364 McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),
364).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer));
365).pipe(
366 Layer.provide(PreviewAutomationBroker.layer),
367 Layer.provide(ServerSelfUpdate.layer),
368 Layer.provide(browserApiCorsLayer),
369);
365370
366371export const makeServerLayer = Layer.unwrap(
367372 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriverCore.test.ts+14
......@@ -713,6 +713,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
713713 });
714714 assert.equal(reusedForSshScheme, "origin");
715715
716 const reusedForBareSshScheme = yield* driver.ensureRemote({
717 cwd,
718 preferredName: "pingdotgg",
719 url: "ssh://github.com/pingdotgg/t3code",
720 });
721 assert.equal(reusedForBareSshScheme, "origin");
722
723 const reusedForSshPort = yield* driver.ensureRemote({
724 cwd,
725 preferredName: "pingdotgg",
726 url: "ssh://git@github.com:22/pingdotgg/t3code",
727 });
728 assert.equal(reusedForSshPort, "origin");
729
716730 const reusedForSshWithPort = yield* driver.ensureRemote({
717731 cwd,
718732 preferredName: "pingdotgg",
apps/server/src/ws.ts+9
......@@ -78,6 +78,7 @@ import {
7878} from "./observability/RpcInstrumentation.ts";
7979import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
8080import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
81import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
8182import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
8283import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
8384import * as ServerSettings from "./serverSettings.ts";
......@@ -296,6 +297,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
296297 [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],
297298 [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],
298299 [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],
300 [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope],
299301 [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope],
300302 [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope],
301303 [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope],
......@@ -418,6 +420,7 @@ const makeWsRpcLayer = (
418420 const portDiscovery = yield* PortScanner.PortDiscovery;
419421 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
420422 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
423 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
421424 const config = yield* ServerConfig.ServerConfig;
422425 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
423426 const serverSettings = yield* ServerSettings.ServerSettingsService;
......@@ -1476,6 +1479,10 @@ const makeWsRpcLayer = (
14761479 "rpc.aggregate": "server",
14771480 },
14781481 ),
1482 [WS_METHODS.serverUpdateServer]: (input) =>
1483 observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), {
1484 "rpc.aggregate": "server",
1485 }),
14791486 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
14801487 observeRpcEffect(
14811488 WS_METHODS.serverUpsertKeybinding,
......@@ -2079,6 +2086,7 @@ const makeWsRpcLayer = (
20792086export const websocketRpcRouteLayer = Layer.unwrap(
20802087 Effect.gen(function* () {
20812088 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
2089 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
20822090 return HttpRouter.add(
20832091 "GET",
20842092 "/ws",
......@@ -2101,6 +2109,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
21012109 makeWsRpcLayer(session, previewAutomationBroker).pipe(
21022110 Layer.provideMerge(RpcSerialization.layerJson),
21032111 Layer.provide(ProviderMaintenanceRunner.layer),
2112 Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),
21042113 Layer.provide(
21052114 SourceControlDiscovery.layer.pipe(
21062115 Layer.provide(
apps/web/src/components/ChatView.tsx+28-2
......@@ -254,11 +254,14 @@ import { RightPanelSheet } from "./RightPanelSheet";
254254import { previewEnvironment } from "../state/preview";
255255import { useAtomCommand } from "../state/use-atom-command";
256256import { Button } from "./ui/button";
257import { ServerUpdateAction } from "./ServerUpdateAction";
257258import {
258259 buildVersionMismatchDismissalKey,
259260 dismissVersionMismatch,
260261 isVersionMismatchDismissed,
261262 resolveServerConfigVersionMismatch,
263 resolveServerSelfUpdateCapability,
264 serverUpdateGuidance,
262265} from "../versionSkew";
263266import { useAssetUrls } from "../assets/assetUrls";
264267
......@@ -1784,6 +1787,9 @@ function ChatViewContent(props: ChatViewProps) {
17841787 hasMultipleRegisteredEnvironments && activeThread
17851788 ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`
17861789 : "server";
1790 const versionMismatchEnvironmentId =
1791 versionMismatch && activeThread ? activeThread.environmentId : null;
1792 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
17871793 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
17881794 const items: ComposerBannerStackItem[] = [];
17891795 if (activeEnvironmentUnavailableState) {
......@@ -1822,7 +1828,12 @@ function ChatViewContent(props: ChatViewProps) {
18221828 ),
18231829 });
18241830 }
1825 if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) {
1831 if (
1832 showVersionMismatchBanner &&
1833 versionMismatch &&
1834 versionMismatchDismissKey &&
1835 versionMismatchEnvironmentId
1836 ) {
18261837 items.push({
18271838 id: `version-mismatch:${versionMismatchDismissKey}`,
18281839 variant: "warning",
......@@ -1831,9 +1842,21 @@ function ChatViewContent(props: ChatViewProps) {
18311842 description: (
18321843 <>
18331844 Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "}
1834 {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail.
1845 {versionMismatch.serverVersion}.{" "}
1846 {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)}
18351847 </>
18361848 ),
1849 // The desktop-managed guidance is already the description; the action
1850 // slot would only repeat it.
1851 actions:
1852 versionMismatchSelfUpdate === "desktop-managed" ? undefined : (
1853 <ServerUpdateAction
1854 environmentId={versionMismatchEnvironmentId}
1855 serverLabel={versionMismatchServerLabel}
1856 selfUpdate={versionMismatchSelfUpdate}
1857 targetVersion={versionMismatch.clientVersion}
1858 />
1859 ),
18371860 dismissLabel: "Dismiss version mismatch warning",
18381861 onDismiss: () => {
18391862 dismissVersionMismatch(versionMismatchDismissKey);
......@@ -1846,9 +1869,12 @@ function ChatViewContent(props: ChatViewProps) {
18461869 activeEnvironmentUnavailableState,
18471870 handleReconnectActiveEnvironment,
18481871 navigate,
1872 setDismissedVersionMismatchKey,
18491873 showVersionMismatchBanner,
18501874 versionMismatch,
18511875 versionMismatchDismissKey,
1876 versionMismatchEnvironmentId,
1877 versionMismatchSelfUpdate,
18521878 versionMismatchServerLabel,
18531879 ]);
18541880 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
apps/web/src/components/ServerUpdateAction.test.tsx created+198
......@@ -0,0 +1,198 @@
1import type { Dispatch, ReactElement, SetStateAction } from "react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/unstable/reactivity";
5import type { EnvironmentId } from "@t3tools/contracts";
6
7const testState = vi.hoisted(() => ({
8 updateServer: vi.fn(),
9 toast: vi.fn(),
10}));
11
12const hooks = vi.hoisted(() => {
13 let cursor = 0;
14 let slots: unknown[] = [];
15 const nextIndex = () => cursor++;
16
17 return {
18 beginRender() {
19 cursor = 0;
20 },
21 reset() {
22 cursor = 0;
23 slots = [];
24 },
25 useEffect() {
26 nextIndex();
27 },
28 useMemoCache(size: number): unknown[] {
29 const index = nextIndex();
30 if (!slots[index]) {
31 slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
32 }
33 return slots[index] as unknown[];
34 },
35 useRef<T>(initialValue: T): { current: T } {
36 const index = nextIndex();
37 if (!slots[index]) {
38 slots[index] = { current: initialValue };
39 }
40 return slots[index] as { current: T };
41 },
42 useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
43 const index = nextIndex();
44 if (index >= slots.length) {
45 slots[index] =
46 typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
47 }
48 const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
49 const previous = slots[index] as T;
50 slots[index] =
51 typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
52 };
53 return [slots[index] as T, setValue];
54 },
55 };
56});
57
58vi.mock("react", async (importOriginal) => {
59 const actual = await importOriginal<typeof import("react")>();
60 return {
61 ...actual,
62 useEffect: hooks.useEffect,
63 useRef: hooks.useRef,
64 useState: hooks.useState,
65 };
66});
67
68vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
69vi.mock("~/hooks/useCopyToClipboard", () => ({
70 useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }),
71}));
72vi.mock("~/state/server", () => ({
73 serverEnvironment: { updateServer: Symbol("updateServer") },
74}));
75vi.mock("~/state/use-atom-command", () => ({
76 useAtomCommand: () => testState.updateServer,
77}));
78vi.mock("./ui/toast", () => ({
79 toastManager: { add: testState.toast },
80}));
81
82import { ServerUpdateAction } from "./ServerUpdateAction";
83
84type ActionElement = ReactElement<{
85 readonly disabled?: boolean;
86 readonly onClick?: () => void;
87}>;
88
89function renderAction(): ActionElement {
90 hooks.beginRender();
91 return ServerUpdateAction({
92 environmentId: "env-test" as EnvironmentId,
93 serverLabel: "Test server",
94 selfUpdate: "boot-service",
95 targetVersion: "0.0.29",
96 }) as ActionElement;
97}
98
99function deferred<T>() {
100 let resolve!: (value: T) => void;
101 const promise = new Promise<T>((complete) => {
102 resolve = complete;
103 });
104 return { promise, resolve };
105}
106
107async function flushPromises(): Promise<void> {
108 await Promise.resolve();
109 await Promise.resolve();
110 await Promise.resolve();
111 await Promise.resolve();
112 await Promise.resolve();
113}
114
115describe("ServerUpdateAction", () => {
116 beforeEach(() => {
117 vi.useFakeTimers();
118 hooks.reset();
119 testState.updateServer.mockReset();
120 testState.toast.mockReset();
121 });
122
123 afterEach(() => {
124 vi.useRealTimers();
125 });
126
127 it("starts a fresh reconnect timeout after a long install succeeds", async () => {
128 const update =
129 deferred<
130 ReturnType<typeof AsyncResult.success<{ targetVersion: string; method: "boot-service" }>>
131 >();
132 testState.updateServer.mockReturnValue(update.promise);
133
134 renderAction().props.onClick?.();
135 expect(renderAction().props.disabled).toBe(true);
136
137 await vi.advanceTimersByTimeAsync(11 * 60_000);
138 update.resolve(
139 AsyncResult.success({
140 targetVersion: "0.0.29",
141 method: "boot-service",
142 }),
143 );
144 await flushPromises();
145
146 // The click-based deadline would have fired by now. Success gets a fresh
147 // twelve-minute reconnect window, so the action remains disabled.
148 await vi.advanceTimersByTimeAsync(2 * 60_000);
149 expect(renderAction().props.disabled).toBe(true);
150 expect(testState.toast).not.toHaveBeenCalledWith(
151 expect.objectContaining({ title: "Server update timed out" }),
152 );
153
154 await vi.advanceTimersByTimeAsync(10 * 60_000);
155 expect(renderAction().props.disabled).not.toBe(true);
156 expect(testState.toast).toHaveBeenCalledWith(
157 expect.objectContaining({ title: "Server update timed out" }),
158 );
159 });
160
161 it("does not let an expired request clear a newer retry", async () => {
162 const first = deferred<ReturnType<typeof AsyncResult.failure>>();
163 const retry =
164 deferred<
165 ReturnType<typeof AsyncResult.success<{ targetVersion: string; method: "boot-service" }>>
166 >();
167 testState.updateServer.mockReturnValueOnce(first.promise).mockReturnValueOnce(retry.promise);
168
169 renderAction().props.onClick?.();
170 await vi.advanceTimersByTimeAsync(12 * 60_000);
171 expect(renderAction().props.disabled).not.toBe(true);
172
173 renderAction().props.onClick?.();
174 expect(renderAction().props.disabled).toBe(true);
175
176 first.resolve(AsyncResult.failure(Cause.fail(new Error("first request failed late"))));
177 await flushPromises();
178
179 expect(renderAction().props.disabled).toBe(true);
180 expect(testState.updateServer).toHaveBeenCalledTimes(2);
181
182 retry.resolve(AsyncResult.success({ targetVersion: "0.0.29", method: "boot-service" }));
183 await flushPromises();
184 expect(renderAction().props.disabled).toBe(true);
185 });
186
187 it("quietly releases the action when a restart RPC is interrupted", async () => {
188 testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt()));
189
190 renderAction().props.onClick?.();
191 await flushPromises();
192
193 expect(renderAction().props.disabled).not.toBe(true);
194 expect(testState.toast).not.toHaveBeenCalledWith(
195 expect.objectContaining({ title: "Server update failed" }),
196 );
197 });
198});
apps/web/src/components/ServerUpdateAction.tsx created+201
......@@ -0,0 +1,201 @@
1import { useEffect, useRef, useState } from "react";
2import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7
8import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
9import { serverEnvironment } from "~/state/server";
10import { useAtomCommand } from "~/state/use-atom-command";
11import { manualServerUpdateCommand } from "~/versionSkew";
12import { Button } from "./ui/button";
13import { Spinner } from "./ui/spinner";
14import { toastManager } from "./ui/toast";
15
16/**
17 * The npm install on the server side is capped at 10 minutes; expire the
18 * spinner a bit beyond that so a dead transport never strands a disabled
19 * button, while a legitimately slow install is never cut off.
20 */
21const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000;
22
23function updateFailureMessage(error: unknown): string {
24 return error instanceof Error ? error.message : "Server update failed.";
25}
26
27/**
28 * The call-to-action for a version-skewed server, matched to the update path
29 * it advertises: a one-click install-and-restart for servers that can update
30 * themselves, an update-the-desktop-app hint for desktop-managed backends
31 * (running `npx t3` there would start a second server, not update this one),
32 * and copying the manual relaunch command for everything else — so the skew
33 * warning always offers a way out.
34 */
35export function ServerUpdateAction({
36 environmentId,
37 serverLabel,
38 selfUpdate,
39 targetVersion,
40}: {
41 readonly environmentId: EnvironmentId;
42 readonly serverLabel: string;
43 readonly selfUpdate: ServerSelfUpdateCapability | null;
44 readonly targetVersion: string;
45}) {
46 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
47 reportFailure: false,
48 });
49 const [pending, setPending] = useState(false);
50 const inFlightRef = useRef(false);
51 const attemptRef = useRef(0);
52 const expiryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({
54 target: "update command",
55 onCopy: ({ command }) => {
56 toastManager.add({
57 type: "success",
58 title: "Update command copied",
59 description: `Run \`${command}\` on ${serverLabel} to update it.`,
60 });
61 },
62 onError: (error) => {
63 toastManager.add({
64 type: "error",
65 title: "Could not copy update command",
66 description: error.message,
67 });
68 },
69 });
70
71 useEffect(
72 () => () => {
73 if (expiryRef.current !== null) {
74 clearTimeout(expiryRef.current);
75 expiryRef.current = null;
76 }
77 attemptRef.current += 1;
78 inFlightRef.current = false;
79 },
80 [],
81 );
82
83 const handleUpdate = () => {
84 // Synchronous re-entry guard: setPending is async, so a rapid
85 // double-click would otherwise dispatch two updates.
86 if (inFlightRef.current) {
87 return;
88 }
89 inFlightRef.current = true;
90 const attempt = attemptRef.current + 1;
91 attemptRef.current = attempt;
92 const ownsAttempt = () => attemptRef.current === attempt;
93 setPending(true);
94 const armExpiry = () => {
95 const expiry = setTimeout(() => {
96 if (!ownsAttempt()) return;
97 expiryRef.current = null;
98 attemptRef.current += 1;
99 inFlightRef.current = false;
100 setPending(false);
101 toastManager.add({
102 type: "error",
103 title: "Server update timed out",
104 description: "The update may still be running on the server — check again in a minute.",
105 });
106 }, UPDATE_PENDING_EXPIRY_MS);
107 expiryRef.current = expiry;
108 return expiry;
109 };
110 let expiry = armExpiry();
111 let restartAccepted = false;
112 const keepPendingForRestart = () => {
113 restartAccepted = true;
114 if (expiryRef.current === expiry) {
115 clearTimeout(expiry);
116 expiry = armExpiry();
117 }
118 };
119 void Promise.resolve()
120 .then(() =>
121 updateServer({
122 environmentId,
123 input: { targetVersion },
124 }),
125 )
126 .then((result) => {
127 if (!ownsAttempt()) return;
128 if (result._tag === "Failure") {
129 // An interrupt may be the expected boot-service disconnect, but it
130 // can also be client-side cancellation before restart was accepted.
131 // Release the action quietly; version sync will remove it when a
132 // successful replacement reconnects.
133 if (isAtomCommandInterrupted(result)) {
134 return;
135 }
136 toastManager.add({
137 type: "error",
138 title: "Server update failed",
139 description: updateFailureMessage(squashAtomCommandFailure(result)),
140 });
141 return;
142 }
143 keepPendingForRestart();
144 // Installation can legitimately consume most of the request window.
145 // Give restart/reconnect a fresh full window after the server accepts
146 // the handoff instead of expiring based on the original click time.
147 toastManager.add({
148 type: "success",
149 title: `Updating ${serverLabel}`,
150 description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`,
151 });
152 })
153 .catch((error: unknown) => {
154 if (!ownsAttempt()) return;
155 toastManager.add({
156 type: "error",
157 title: "Server update failed",
158 description: updateFailureMessage(error),
159 });
160 })
161 .finally(() => {
162 // A successful RPC only acknowledges that restart is scheduled. Keep
163 // the action disabled until version sync unmounts it, or until the
164 // safety expiry reports that reconnection never arrived.
165 if (restartAccepted || !ownsAttempt() || expiryRef.current !== expiry) return;
166 expiryRef.current = null;
167 clearTimeout(expiry);
168 attemptRef.current += 1;
169 inFlightRef.current = false;
170 setPending(false);
171 });
172 };
173
174 if (selfUpdate === "desktop-managed") {
175 return (
176 <span className="text-muted-foreground text-xs">
177 Update the desktop app on that machine to update this server.
178 </span>
179 );
180 }
181
182 if (selfUpdate === null) {
183 const command = manualServerUpdateCommand(targetVersion);
184 return (
185 <Button size="xs" variant="outline" onClick={() => copyToClipboard(command, { command })}>
186 Copy update command
187 </Button>
188 );
189 }
190
191 return pending ? (
192 <Button size="xs" disabled>
193 <Spinner className="size-3.5" />
194 Updating...
195 </Button>
196 ) : (
197 <Button size="xs" onClick={() => void handleUpdate()}>
198 Update server
199 </Button>
200 );
201}
apps/web/src/components/settings/ConnectionsSettings.tsx+28-6
......@@ -106,7 +106,10 @@ import {
106106} from "~/environments/primary";
107107import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
108108import { useUiStateStore } from "~/uiStateStore";
109import { resolveServerConfigVersionMismatch } from "~/versionSkew";
109import {
110 resolveServerConfigVersionMismatch,
111 resolveServerSelfUpdateCapability,
112} from "~/versionSkew";
110113import { hasCloudPublicConfig } from "~/cloud/publicConfig";
111114import { useCloudLinkController } from "~/cloud/useCloudLinkController";
112115import { authEnvironment } from "~/state/auth";
......@@ -129,6 +132,7 @@ import {
129132} from "~/state/environments";
130133import { useAtomCommand } from "../../state/use-atom-command";
131134import { ConnectionStatusDot } from "../ConnectionStatusDot";
135import { ServerUpdateAction } from "../ServerUpdateAction";
132136import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
133137import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
134138
......@@ -1425,11 +1429,19 @@ function SavedBackendListRow({
14251429 <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
14261430 ) : null}
14271431 {versionMismatch ? (
1428 <p className="flex items-center gap-1 text-warning text-xs">
1429 <TriangleAlertIcon className="size-3.5 shrink-0" />
1430 Version drift: client {versionMismatch.clientVersion}, server{" "}
1431 {versionMismatch.serverVersion}.
1432 </p>
1432 <div className="flex flex-wrap items-center gap-2">
1433 <p className="flex items-center gap-1 text-warning text-xs">
1434 <TriangleAlertIcon className="size-3.5 shrink-0" />
1435 Version drift: client {versionMismatch.clientVersion}, server{" "}
1436 {versionMismatch.serverVersion}.
1437 </p>
1438 <ServerUpdateAction
1439 environmentId={environmentId}
1440 serverLabel={`${environment.label} server`}
1441 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1442 targetVersion={versionMismatch.clientVersion}
1443 />
1444 </div>
14331445 ) : null}
14341446 {environment.connection.error ? (
14351447 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
......@@ -2982,6 +2994,16 @@ export function ConnectionsSettings() {
29822994 fail.
29832995 </span>
29842996 }
2997 control={
2998 primaryEnvironmentId !== null ? (
2999 <ServerUpdateAction
3000 environmentId={primaryEnvironmentId}
3001 serverLabel={primaryEnvironment?.label ?? "this server"}
3002 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3003 targetVersion={primaryVersionMismatch.clientVersion}
3004 />
3005 ) : undefined
3006 }
29853007 />
29863008 ) : null}
29873009 {desktopBridge ? (
apps/web/src/versionSkew.test.ts+32
......@@ -8,7 +8,9 @@ import {
88 dismissVersionMismatch,
99 isVersionMismatchDismissed,
1010 resolveServerConfigVersionMismatch,
11 resolveServerSelfUpdateCapability,
1112 resolveVersionMismatch,
13 serverUpdateGuidance,
1214} from "./versionSkew";
1315
1416describe("versionSkew", () => {
......@@ -75,4 +77,34 @@ describe("versionSkew", () => {
7577 "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.",
7678 );
7779 });
80
81 it("reads desktop-managed update capabilities from config descriptors", () => {
82 expect(
83 resolveServerSelfUpdateCapability({
84 environment: {
85 environmentId: EnvironmentId.make("environment-desktop"),
86 label: "Desktop",
87 platform: { os: "darwin", arch: "arm64" },
88 serverVersion: "9.9.9",
89 capabilities: {
90 repositoryIdentity: true,
91 serverSelfUpdate: "desktop-managed",
92 },
93 },
94 }),
95 ).toBe("desktop-managed");
96 expect(resolveServerSelfUpdateCapability(null)).toBeNull();
97 });
98
99 it("matches version-drift guidance to the advertised update path", () => {
100 expect(serverUpdateGuidance("respawn", "Remote server")).toBe(
101 "Update the Remote server so they stay in sync.",
102 );
103 expect(serverUpdateGuidance("desktop-managed", "Desktop server")).toBe(
104 "The Desktop server is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.",
105 );
106 expect(serverUpdateGuidance(null, "Local server")).toBe(
107 "Relaunch the Local server with the copied command to sync them.",
108 );
109 });
78110});
apps/web/src/versionSkew.ts+31-1
......@@ -1,4 +1,4 @@
1import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
1import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";
22import * as Schema from "effect/Schema";
33
44import { APP_VERSION } from "./branding";
......@@ -49,6 +49,36 @@ export function resolveServerConfigVersionMismatch(
4949 return resolveVersionMismatch(serverConfig?.environment.serverVersion);
5050}
5151
52/** The update path the connected server offers, or null when it only
53 supports a manual relaunch (older servers, dev checkouts, Windows). */
54export function resolveServerSelfUpdateCapability(
55 serverConfig: Pick<ServerConfig, "environment"> | null | undefined,
56): ServerSelfUpdateCapability | null {
57 return serverConfig?.environment.capabilities.serverSelfUpdate ?? null;
58}
59
60/** The command to hand users whose server cannot update itself. */
61export function manualServerUpdateCommand(targetVersion: string): string {
62 return `npx t3@${targetVersion}`;
63}
64
65/** One sentence telling the user how to resolve version skew for a server,
66 matched to the update path it offers. */
67export function serverUpdateGuidance(
68 capability: ServerSelfUpdateCapability | null,
69 serverLabel: string,
70): string {
71 switch (capability) {
72 case "boot-service":
73 case "respawn":
74 return `Update the ${serverLabel} so they stay in sync.`;
75 case "desktop-managed":
76 return `The ${serverLabel} is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.`;
77 default:
78 return `Relaunch the ${serverLabel} with the copied command to sync them.`;
79 }
80}
81
5282export function buildVersionMismatchDismissalKey(
5383 environmentId: EnvironmentId,
5484 mismatch: Pick<VersionMismatch, "clientVersion" | "serverVersion">,
docs/README.md+10-2
......@@ -1,11 +1,19 @@
11# Documentation
22
33- [Getting started](./getting-started/quick-start.md)
4- [Architecture](./architecture/overview.md)
4- Architecture
5 - [Overview](./architecture/overview.md)
6 - [Connection runtime](./architecture/connection-runtime.md)
7 - [Remote environments](./architecture/remote.md)
8 - [Server updates](./architecture/server-updates.md)
9- User guides
10 - [Background service](./user/background-service.md)
11 - [Remote access](./user/remote-access.md)
12 - [Keeping T3 Code in sync](./user/server-updates.md)
13 - [Keybindings](./user/keybindings.md)
514- [T3 Connect](./cloud/t3-connect-clerk.md)
615- [Integrations](./integrations/source-control-providers.md)
716- [Mobile](./mobile/app.md)
817- [Operations](./operations/ci.md)
918- [Providers](./providers/codex.md)
1019- [Reference](./reference/encyclopedia.md)
11- [User guides](./user/keybindings.md)
docs/architecture/overview.md+2
......@@ -37,6 +37,8 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS
3737
3838- **Runtime signals**: The server emits lightweight typed receipts when important async milestones finish, such as checkpoint capture, diff finalization, or a turn becoming fully quiescent. Tests and orchestration code wait on these signals instead of polling internal state.
3939
40- **Server updates**: A connected environment advertises whether its server can replace itself. When client and server versions differ, the browser selects an automatic, desktop-managed, or manual update path without changing connection ownership. See [Server Update Architecture](./server-updates.md).
41
4042## Event Lifecycle
4143
4244### Startup and client connect
docs/architecture/remote.md+15
......@@ -331,6 +331,21 @@ In all of those cases, the `ExecutionEnvironment` is the same kind of thing.
331331
332332Only the launch and access paths differ.
333333
334## Version Coordination
335
336Remote environments may stay online while web, desktop, or mobile clients move to a newer release.
337The environment descriptor therefore carries the running server version and may advertise a safe
338replacement path. The web and desktop UI use that information to show the appropriate action
339without making the connection transport responsible for process management.
340
341Published CLI servers on supported hosts can install and hand off to the client's exact version. A
342desktop-managed backend instead points the user to the desktop app on that machine, while older or
343unsupported servers fall back to a manual relaunch. The existing connection supervisor owns the
344disconnect and reconnect just as it would for any other involuntary socket close.
345
346See [Server Update Architecture](./server-updates.md) for capability detection, installation safety,
347and restart sequencing.
348
334349## Security model
335350
336351Remote support must assume that some environments will be reachable over untrusted networks.
docs/architecture/server-updates.md created+121
......@@ -0,0 +1,121 @@
1# Server Update Architecture
2
3T3 Code can update a connected server to the exact version of the client that detected version
4drift. This path exists primarily for remote environments, where the user may not have a terminal
5open on the server machine.
6
7The feature has three boundaries:
8
9- the server advertises whether and how it can be replaced;
10- the client chooses the matching user action;
11- the server installs and verifies the replacement before handing off the process.
12
13## Detection and Presentation
14
15`ExecutionEnvironmentDescriptor` includes the server version and an optional
16`capabilities.serverSelfUpdate` value. The client compares that version with `APP_VERSION` after
17loading server config.
18
19The optional capability is intentionally backward compatible. An older server does not know about
20the field, so a missing value means the client must offer a manual relaunch instead of sending an
21unknown RPC.
22
23The shared `ServerUpdateAction` is rendered in both user-facing version-drift surfaces:
24
25- the conversation banner in `ChatView`;
26- primary and saved environment rows in **Settings** → **Connections**.
27
28Both surfaces target the client's exact version. When the reconnected server reports that version,
29the mismatch and action disappear.
30
31## Capability Selection
32
33The server resolves its capability once at startup and publishes it in the environment descriptor.
34
35| Advertised value | Process shape | Client behavior |
36| ----------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
37| `boot-service` | Linux server running under the T3-managed systemd user service | Call the update RPC; the service unit is replaced and restarted. |
38| `respawn` | Published npm CLI running in the foreground on macOS or Linux | Call the update RPC; the process hands off to a detached replacement. |
39| `desktop-managed` | Backend supervised by the desktop app | Tell the user to update the desktop app on the server machine. |
40| absent | Older server, development checkout, Windows foreground process, or an unrecognized supervisor | Offer the exact manual relaunch command. |
41
42Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must
43never spawn a second CLI server beside the app-owned process. Likewise, a process launched by an
44unrecognized systemd unit does not claim the foreground respawn path because its supervisor could
45bring the old version back.
46
47## Update Flow
48
49```mermaid
50flowchart TD
51 A[Client detects different versions] --> B{Advertised update path}
52 B -->|desktop-managed| C[Update desktop app on server machine]
53 B -->|missing| D[Copy exact manual relaunch command]
54 B -->|boot-service or respawn| E[server.updateServer]
55 E --> F[Install exact t3 version in pinned runtime]
56 F --> G[Run version preflight]
57 G -->|fails| H[Remove failed runtime and keep current server]
58 G -->|passes| I{Handoff method}
59 I -->|boot-service| J[Rewrite and restart T3 systemd unit]
60 I -->|respawn| K[Start delayed replacement and exit current process]
61 J --> L[Client reconnects]
62 K --> L
63```
64
65`server.updateServer` requires the environment's `orchestration:operate` authorization scope. Its
66payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as
67`latest` and `nightly` are rejected.
68
69The update service permits one update at a time. It installs `t3@<version>` under
70`<baseDir>/runtime/versions/<version>` and writes an install-complete sentinel only after npm exits
71successfully. Boot-service setup and self-update share the same process-wide installation lock, so
72they cannot mutate a pinned runtime concurrently.
73
74Before any restart, the current Node executable runs the replacement with `--version`. A failed
75install, failed preflight, or wrong reported version leaves the current server running. A failed
76preflight also removes the candidate runtime so retrying the same version performs a clean install.
77
78## Host Service Lifecycle
79
80The systemd user service is a host lifecycle concern, not a T3 Connect resource. The standalone
81`t3 service install`, `uninstall`, `update`, and `status` commands own it. Install and update both
82reconcile the unit through `BootService`; running `npx t3@latest service update` therefore pins and
83activates the latest CLI release without requiring a connected client.
84
85The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation
86operation as `t3 service install`. Connect logout only disables cloud access and clears its
87authorization; it does not uninstall the host service.
88
89## Process Handoff
90
91For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified
92runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous
93unit before returning an error.
94
95For `respawn`, the server starts a detached, delayed replacement that replays the original CLI
96arguments. It then acknowledges the request and schedules the current process to exit. The delays
97give the acknowledgement time to cross direct or relayed connections before the socket closes.
98
99There is no separate progress stream. The update request remains pending while npm installs and the
100client shows a disabled update action. A restart can interrupt the request normally; the connection
101runtime keeps the environment registered and reconnects through its usual retry path. After an
102acknowledged foreground handoff, the UI keeps the action pending until version sync removes it or a
103safety timeout releases it. If a boot-service restart closes the connection before acknowledgement,
104the UI releases the interrupted action without reporting a false update failure and lets reconnect
105and the next version check determine the result.
106
107## Release Invariant
108
109The exact client version must exist as the `t3` npm package before a client carrying that version is
110published. The release workflow therefore makes the GitHub release depend on CLI publication, and
111the hosted web deployment depends on that release. See [Release Checklist](../operations/release.md#server-self-update-release-invariant).
112
113## Source Map
114
115- Capability contract: `packages/contracts/src/environment.ts`
116- Update RPC contract: `packages/contracts/src/server.ts` and `packages/contracts/src/rpc.ts`
117- Capability detection and handoff: `apps/server/src/cloud/selfUpdate.ts`
118- Host service commands: `apps/server/src/cli/service.ts`
119- Pinned runtime installation: `apps/server/src/cloud/pinnedRuntime.ts`
120- Client version comparison: `apps/web/src/versionSkew.ts`
121- Shared update action: `apps/web/src/components/ServerUpdateAction.tsx`
docs/cloud/t3-connect-clerk.md+3
......@@ -92,6 +92,9 @@ connector, and attempts to revoke the relay-side environment record. It retains
9292authorization so `t3 connect link` can re-enable exposure without another browser flow. `t3 connect
9393logout` performs the same cleanup and removes the stored CLI authorization.
9494
95The background service has an independent lifecycle. Connect setup may offer to install it, but
96logout leaves it running; manage it with `t3 service status`, `install`, `update`, and `uninstall`.
97
9598The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH,
9699forward that port before running `t3 connect login` or `t3 connect link`:
97100
docs/operations/release.md+21
......@@ -156,6 +156,26 @@ One-time Vercel dashboard setup:
156156- Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version.
157157- Does not commit version bumps back to `main`.
158158
159## Server self-update release invariant
160
161Connected servers update to the client's exact version, not to an npm dist-tag. Every released
162desktop or hosted client version must therefore have a matching `t3@<version>` package available on
163npm before users can receive that client.
164
165The workflow enforces this ordering:
166
1671. `publish_cli` publishes the exact stable or nightly version to npm.
1682. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases.
1693. `deploy_web` depends on `release` before moving the hosted channel to the new client.
170
171Preserve these dependencies when changing the release graph. Publishing a client first would leave
172the **Update server** action targeting a package version that does not exist yet.
173
174For a release smoke test, confirm `npm view t3@<version> version` returns the expected version, then
175connect the new client to a server on the previous version and verify that the update action
176reconnects to the matching server. Test one automatic path and the manual or desktop-managed
177guidance when those environments are available.
178
159179## Desktop auto-update notes
160180
161181- Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`.
......@@ -293,6 +313,7 @@ Checklist:
2933135. Verify workflow steps:
294314 - preflight passes
295315 - all matrix builds pass
316 - `publish_cli` publishes the exact release version before the release job
296317 - release job uploads expected files
2973186. Smoke test downloaded artifacts.
298319
docs/user/background-service.md created+42
......@@ -0,0 +1,42 @@
1# Running T3 Code in the Background
2
3On a Linux host, T3 Code can run as a background service for your user. It starts when the machine
4boots and keeps running after you log out.
5
6## Manage the Service
7
8Install it with the latest T3 Code release:
9
10```sh
11npx t3@latest service install
12```
13
14Check whether it is installed:
15
16```sh
17npx t3@latest service status
18```
19
20Update or repair it:
21
22```sh
23npx t3@latest service update
24```
25
26Stop it and remove it from startup:
27
28```sh
29npx t3@latest service uninstall
30```
31
32Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first.
33
34## Using It with T3 Connect
35
36T3 Connect may offer to install the service during setup so the host stays reachable after you log
37out. This is only an onboarding shortcut: the service and T3 Connect are managed separately.
38
39Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer
40want T3 Code to start in the background.
41
42The background service currently requires Linux with systemd.
docs/user/remote-access.md+13
......@@ -152,6 +152,19 @@ With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and
152152
153153If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually.
154154
155## Updating a Remote Server
156
157When the T3 Code web or desktop app and a remote server use different versions, a warning appears in
158the conversation and in **Settings** → **Connections**. Follow the action shown there: T3 Code may
159be able to update and reconnect the server for you, or it may ask you to update the desktop app or
160run a copied command on the server machine.
161
162Finish active work before updating because the server restarts briefly. For step-by-step guidance,
163see [Keeping T3 Code in Sync](./server-updates.md).
164
165On a Linux host, you can keep the server running after logout and manage it independently of the
166connection method. See [Running T3 Code in the Background](./background-service.md).
167
155168## How Pairing Works
156169
157170The remote device does not need a long-lived secret up front.
docs/user/server-updates.md created+56
......@@ -0,0 +1,56 @@
1# Keeping T3 Code in Sync
2
3The T3 Code web or desktop app and the server it connects to work best when they use the same
4version. If they do not match, T3 Code shows a warning with the right update option for that server.
5
6## Where to Find the Update
7
8You may see the warning in either of these places:
9
10- above the message box in the current conversation
11- **Settings** → **Connections**, beside the affected connection
12
13Dismissing the conversation warning only hides that reminder for those two versions. It does not
14update the server, and the version difference remains visible in Connections.
15
16## Before You Update
17
18Let active agent work and terminal commands finish first. Updating restarts the server, so the
19connection will disappear briefly and work that is still running may be interrupted.
20
21The update does not remove saved threads, settings, or project files.
22
23## Choose the Action You See
24
25| Action | What to do |
26| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27| **Update server** | Select the button and leave T3 Code open. It prepares the matching version, restarts the server, and reconnects automatically. This can take several minutes. |
28| **Update the desktop app** | Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. |
29| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. |
30
31The available action depends on how that server was started. T3 Code does not update connected
32servers silently in the background.
33
34If the server uses the T3 Code background service, you can also update it directly on the host:
35
36```sh
37npx t3@latest service update
38```
39
40See [Running T3 Code in the Background](./background-service.md) for install, status, and removal
41commands.
42
43## After the Update
44
45Keep the web or desktop app open while the server restarts. When it reconnects with the matching
46version, the warning and update action disappear.
47
48If the client reports a timeout, the server may still be finishing the update. Wait a minute, then
49reconnect or open **Settings** → **Connections** again. If the warning remains:
50
511. Retry the offered action once.
522. Make sure you updated the machine named in the warning, not only the device you are using.
533. For a command-line server, relaunch it with `npx t3@<client-version>`, replacing
54 `<client-version>` with the client version shown in the warning.
55
56For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md).
packages/client-runtime/src/state/server.ts+6
......@@ -321,6 +321,12 @@ export function createServerEnvironmentAtoms<R, E>(
321321 scheduler: configScheduler,
322322 concurrency: configConcurrency,
323323 }),
324 updateServer: createEnvironmentRpcCommand(runtime, {
325 label: "environment-data:server:update-server",
326 tag: WS_METHODS.serverUpdateServer,
327 scheduler: configScheduler,
328 concurrency: configConcurrency,
329 }),
324330 upsertKeybinding: createEnvironmentRpcCommand(runtime, {
325331 label: "environment-data:server:upsert-keybinding",
326332 tag: WS_METHODS.serverUpsertKeybinding,
packages/contracts/src/environment.ts+21
......@@ -20,6 +20,23 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({
2020});
2121export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;
2222
23/** How a server can replace itself with another version when asked over RPC:
24 "boot-service" rewrites the systemd user unit and restarts it; "respawn"
25 installs the target version and respawns the foreground process. */
26export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]);
27export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;
28
29/** What update path a client should offer for a server: one of the RPC
30 self-update methods above, or "desktop-managed" when the backend's
31 version belongs to the T3 Code desktop app supervising it — updating the
32 app on that machine is the only way to update the server. */
33export const ServerSelfUpdateCapability = Schema.Literals([
34 "boot-service",
35 "respawn",
36 "desktop-managed",
37]);
38export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type;
39
2340export const ExecutionEnvironmentCapabilities = Schema.Struct({
2441 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
2542 connectionProbe: Schema.optionalKey(Schema.Boolean),
......@@ -27,6 +44,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
2744 pre-settlement servers, so clients treat missing as unsupported and
2845 never send the commands under version skew. */
2946 threadSettlement: Schema.optionalKey(Schema.Boolean),
47 /** The update path clients should offer for this server. Absent on
48 servers that must be relaunched manually (dev checkouts, Windows
49 foreground runs, pre-update servers). */
50 serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability),
3051});
3152export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
3253
packages/contracts/src/rpc.ts+11
......@@ -122,6 +122,9 @@ import {
122122 ServerRemoveKeybindingInput,
123123 ServerRemoveKeybindingResult,
124124 ServerProviderUpdatedPayload,
125 ServerSelfUpdateError,
126 ServerSelfUpdateInput,
127 ServerSelfUpdateResult,
125128 ServerTraceDiagnosticsResult,
126129 ServerProcessDiagnosticsResult,
127130 ServerProcessResourceHistoryInput,
......@@ -205,6 +208,7 @@ export const WS_METHODS = {
205208 serverGetConfig: "server.getConfig",
206209 serverRefreshProviders: "server.refreshProviders",
207210 serverUpdateProvider: "server.updateProvider",
211 serverUpdateServer: "server.updateServer",
208212 serverUpsertKeybinding: "server.upsertKeybinding",
209213 serverRemoveKeybinding: "server.removeKeybinding",
210214 serverGetSettings: "server.getSettings",
......@@ -279,6 +283,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide
279283 error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]),
280284});
281285
286export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, {
287 payload: ServerSelfUpdateInput,
288 success: ServerSelfUpdateResult,
289 error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]),
290});
291
282292export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
283293 payload: Schema.Struct({}),
284294 success: ServerSettings,
......@@ -693,6 +703,7 @@ export const WsRpcGroup = RpcGroup.make(
693703 WsServerGetConfigRpc,
694704 WsServerRefreshProvidersRpc,
695705 WsServerUpdateProviderRpc,
706 WsServerUpdateServerRpc,
696707 WsServerUpsertKeybindingRpc,
697708 WsServerRemoveKeybindingRpc,
698709 WsServerGetSettingsRpc,
packages/contracts/src/server.ts+28-1
......@@ -1,6 +1,6 @@
11import * as Effect from "effect/Effect";
22import * as Schema from "effect/Schema";
3import { ExecutionEnvironmentDescriptor } from "./environment.ts";
3import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts";
44import { ServerAuthDescriptor } from "./auth.ts";
55import {
66 IsoDateTime,
......@@ -574,3 +574,30 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass<ServerPro
574574 return `Provider update failed for ${this.provider}: ${this.reason}`;
575575 }
576576}
577
578export const ServerSelfUpdateInput = Schema.Struct({
579 /** Exact npm version of the `t3` package to install (never a dist-tag, so
580 the server and the acknowledging client agree on what was requested). */
581 targetVersion: TrimmedNonEmptyString,
582});
583export type ServerSelfUpdateInput = typeof ServerSelfUpdateInput.Type;
584
585/** Acknowledgement that the update artifact is installed and the server is
586 about to restart into it — the connection will drop moments later. */
587export const ServerSelfUpdateResult = Schema.Struct({
588 targetVersion: TrimmedNonEmptyString,
589 method: ServerSelfUpdateMethod,
590});
591export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type;
592
593export class ServerSelfUpdateError extends Schema.TaggedErrorClass<ServerSelfUpdateError>()(
594 "ServerSelfUpdateError",
595 {
596 reason: TrimmedNonEmptyString,
597 cause: Schema.optional(Schema.Defect()),
598 },
599) {
600 override get message(): string {
601 return `Server update failed: ${this.reason}`;
602 }
603}