| author | |
| committer | |
| log | ab4a88386d5227706fadc4feb045dfb3e7fe6e1d |
| tree | 6a1caa8114a08ba8a5751d34be650f7378f7b0b7 |
| parent | 78a0ea55c1d9edce8bcd2b3caff9510b4093e6d3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 |
| 54 | 54 | ## Documentation |
| 55 | 55 | |
| 56 | 56 | - [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) | |
| 57 | 59 | - [Architecture overview](./docs/architecture/overview.md) |
| 58 | 60 | - [Provider guides](./docs/providers/codex.md) |
| 59 | 61 | - [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) => { |
| 202 | 202 | }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))), |
| 203 | 203 | ); |
| 204 | 204 | |
| 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 | ||
| 205 | 217 | it.effect("reports fresh headless connect state without requiring local configuration", () => |
| 206 | 218 | Effect.gen(function* () { |
| 207 | 219 | const baseDir = NodeFS.mkdtempSync( |
| ... | ... | @@ -305,7 +317,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { |
| 305 | 317 | runConnectCli(["connect", "logout", "--base-dir", baseDir]), |
| 306 | 318 | ); |
| 307 | 319 | |
| 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 | ); | |
| 309 | 324 | assert.isFalse(NodeFS.existsSync(tokenPath)); |
| 310 | 325 | }), |
| 311 | 326 | ); |
apps/server/src/bin.ts+2| ... | ... | @@ -13,6 +13,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| 13 | 13 | import { sharedServerCommandFlags } from "./cli/config.ts"; |
| 14 | 14 | import { projectCommand } from "./cli/project.ts"; |
| 15 | 15 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; |
| 16 | import { serviceCommand } from "./cli/service.ts"; | |
| 16 | 17 | |
| 17 | 18 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 18 | 19 | |
| ... | ... | @@ -47,6 +48,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 47 | 48 | serveCommand, |
| 48 | 49 | authCommand, |
| 49 | 50 | projectCommand, |
| 51 | serviceCommand, | |
| 50 | 52 | cloudEnabled ? connectCommand : connectUnavailableCommand, |
| 51 | 53 | ]), |
| 52 | 54 | ); |
apps/server/src/cli/connect.test.ts+2-2| ... | ... | @@ -16,9 +16,9 @@ import { |
| 16 | 16 | formatRelayClientReady, |
| 17 | 17 | headlessSessionConfig, |
| 18 | 18 | isPublishAgentActivityEnabledValue, |
| 19 | recoverBootServiceOffer, | |
| 20 | 19 | reportCloudDisconnectResults, |
| 21 | 20 | } from "./connect.ts"; |
| 21 | import { recoverServiceOnboardingOffer } from "./service.ts"; | |
| 22 | 22 | |
| 23 | 23 | it("explains how to complete headless authorization", () => { |
| 24 | 24 | assert.equal( |
| ... | ... | @@ -58,7 +58,7 @@ it.effect("detects headless operation from individual SSH config values", () => |
| 58 | 58 | |
| 59 | 59 | it.effect("treats cancelling optional background setup as a successful skip", () => |
| 60 | 60 | Effect.gen(function* () { |
| 61 | const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({}))); | |
| 61 | const result = yield* recoverServiceOnboardingOffer(Effect.fail(new Terminal.QuitError({}))); | |
| 62 | 62 | assert.isFalse(result); |
| 63 | 63 | }), |
| 64 | 64 | ); |
apps/server/src/cli/connect.ts+11-69| ... | ... | @@ -6,7 +6,6 @@ import { |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; |
| 8 | 8 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 9 | import * as Terminal from "effect/Terminal"; | |
| 10 | 9 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 11 | 10 | import * as Cause from "effect/Cause"; |
| 12 | 11 | import * as Config from "effect/Config"; |
| ... | ... | @@ -20,6 +19,7 @@ import * as Layer from "effect/Layer"; |
| 20 | 19 | import * as Option from "effect/Option"; |
| 21 | 20 | import * as References from "effect/References"; |
| 22 | 21 | import * as Schema from "effect/Schema"; |
| 22 | import * as Terminal from "effect/Terminal"; | |
| 23 | 23 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; |
| 24 | 24 | import { |
| 25 | 25 | FetchHttpClient, |
| ... | ... | @@ -29,7 +29,6 @@ import { |
| 29 | 29 | } from "effect/unstable/http"; |
| 30 | 30 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; |
| 31 | 31 | |
| 32 | import packageJson from "../../package.json" with { type: "json" }; | |
| 33 | 32 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 34 | 33 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 35 | 34 | import * as BootService from "../cloud/bootService.ts"; |
| ... | ... | @@ -45,9 +44,13 @@ import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; |
| 45 | 44 | import * as ServerConfig from "../config.ts"; |
| 46 | 45 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 47 | 46 | import * as ExternalLauncher from "../process/externalLauncher.ts"; |
| 48 | import * as ProcessRunner from "../processRunner.ts"; | |
| 49 | 47 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; |
| 50 | 48 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 49 | import { | |
| 50 | bootServiceLayer, | |
| 51 | offerServiceDuringOnboarding, | |
| 52 | recoverServiceOnboardingOffer, | |
| 53 | } from "./service.ts"; | |
| 51 | 54 | |
| 52 | 55 | const jsonFlag = Flag.boolean("json").pipe( |
| 53 | 56 | Flag.withDescription("Emit JSON instead of human-readable output."), |
| ... | ... | @@ -396,21 +399,6 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { |
| 396 | 399 | if (options.clearAuthorization) { |
| 397 | 400 | const tokens = yield* CliTokenManager.CloudCliTokenManager; |
| 398 | 401 | 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 | ); | |
| 414 | 402 | } |
| 415 | 403 | |
| 416 | 404 | yield* reportCloudDisconnectResults({ |
| ... | ... | @@ -420,7 +408,9 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { |
| 420 | 408 | }); |
| 421 | 409 | |
| 422 | 410 | 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 | ); | |
| 424 | 414 | } |
| 425 | 415 | }); |
| 426 | 416 | |
| ... | ... | @@ -457,11 +447,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E |
| 457 | 447 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), |
| 458 | 448 | EnvironmentAuth.runtimeLayer, |
| 459 | 449 | 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), | |
| 465 | 451 | headlessRelayClientTracingLayer, |
| 466 | 452 | ).pipe( |
| 467 | 453 | Layer.provideMerge(FetchHttpClient.layer), |
| ... | ... | @@ -683,50 +669,6 @@ const connectLogoutCommand = Command.make("logout", { |
| 683 | 669 | ), |
| 684 | 670 | ); |
| 685 | 671 | |
| 686 | const 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 | ||
| 715 | export 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 | ||
| 730 | 672 | export const connectCommand = Command.make("connect", { |
| 731 | 673 | ...projectLocationFlags, |
| 732 | 674 | headless: headlessFlag, |
| ... | ... | @@ -748,7 +690,7 @@ export const connectCommand = Command.make("connect", { |
| 748 | 690 | |
| 749 | 691 | // Connect itself already succeeded; a boot-service failure must not |
| 750 | 692 | // fail the command, just tell the user what happened and move on. |
| 751 | const background = yield* recoverBootServiceOffer(offerBootService); | |
| 693 | const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); | |
| 752 | 694 | yield* Console.log( |
| 753 | 695 | background |
| 754 | 696 | ? "\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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { formatServiceStatus } from "./service.ts"; | |
| 4 | ||
| 5 | const 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 | ||
| 13 | it("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 | ||
| 25 | it("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 | ||
| 32 | it("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 @@ |
| 1 | import * as Console from "effect/Console"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Terminal from "effect/Terminal"; | |
| 5 | import { Command, GlobalFlag, Prompt } from "effect/unstable/cli"; | |
| 6 | ||
| 7 | import packageJson from "../../package.json" with { type: "json" }; | |
| 8 | import * as BootService from "../cloud/bootService.ts"; | |
| 9 | import type * as ServerConfig from "../config.ts"; | |
| 10 | import * as ProcessRunner from "../processRunner.ts"; | |
| 11 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; | |
| 12 | ||
| 13 | export 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 | ||
| 20 | export 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. */ | |
| 32 | export 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 | ||
| 46 | export 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 | ||
| 65 | const 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 | ||
| 74 | const 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 | ||
| 95 | const 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 | ||
| 116 | const 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 | ||
| 132 | const 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 | ||
| 145 | export 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 | ||
| 176 | export 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 | ||
| 191 | export 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 { |
| 14 | 14 | HostProcessPlatform, |
| 15 | 15 | } from "@t3tools/shared/hostProcess"; |
| 16 | 16 | |
| 17 | import { reconcileService } from "../cli/service.ts"; | |
| 17 | 18 | import * as ProcessRunner from "../processRunner.ts"; |
| 18 | 19 | import * as BootService from "./bootService.ts"; |
| 19 | 20 | |
| ... | ... | @@ -100,7 +101,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { |
| 100 | 101 | unit, |
| 101 | 102 | [ |
| 102 | 103 | "[Unit]", |
| 103 | "Description=T3 Code server (T3 Connect)", | |
| 104 | "Description=T3 Code server", | |
| 104 | 105 | "StartLimitIntervalSec=300", |
| 105 | 106 | "StartLimitBurst=5", |
| 106 | 107 | "", |
| ... | ... | @@ -108,6 +109,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { |
| 108 | 109 | "Type=simple", |
| 109 | 110 | "WorkingDirectory=%h", |
| 110 | 111 | "Environment=T3CODE_HOME=/home/theo/.t3", |
| 112 | "Environment=T3_BOOT_SERVICE_UNIT=t3code.service", | |
| 111 | 113 | "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", |
| 112 | 114 | "Restart=always", |
| 113 | 115 | "RestartSec=5", |
| ... | ... | @@ -170,6 +172,33 @@ it("flags package-manager cache entry points as ephemeral", () => { |
| 170 | 172 | }); |
| 171 | 173 | |
| 172 | 174 | it.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 | ||
| 173 | 202 | it.effect("installs the unit, enables the service, and enables linger", () => |
| 174 | 203 | Effect.gen(function* () { |
| 175 | 204 | const { dirs, fs, path } = yield* makeTestContext(); |
| ... | ... | @@ -311,7 +340,7 @@ it.layer(NodeServices.layer)("BootService", (it) => { |
| 311 | 340 | }), |
| 312 | 341 | ); |
| 313 | 342 | |
| 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", () => | |
| 315 | 344 | Effect.gen(function* () { |
| 316 | 345 | const { dirs, fs, path } = yield* makeTestContext(); |
| 317 | 346 | const commands: Array<RecordedCommand> = []; |
| ... | ... | @@ -402,8 +431,8 @@ it.layer(NodeServices.layer)("BootService", (it) => { |
| 402 | 431 | |
| 403 | 432 | const error = yield* service.install.pipe(Effect.flip); |
| 404 | 433 | 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. | |
| 407 | 436 | assert.isFalse( |
| 408 | 437 | yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), |
| 409 | 438 | ); |
apps/server/src/cloud/bootService.ts+41-60| ... | ... | @@ -16,21 +16,19 @@ import { |
| 16 | 16 | } from "@t3tools/shared/hostProcess"; |
| 17 | 17 | |
| 18 | 18 | import * as ProcessRunner from "../processRunner.ts"; |
| 19 | import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; | |
| 19 | 20 | |
| 20 | 21 | /** |
| 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. | |
| 27 | 26 | */ |
| 28 | 27 | |
| 29 | 28 | const BOOT_SERVICE_NAME = "t3code"; |
| 30 | const BOOT_RUNTIME_DIR = "runtime"; | |
| 31 | 29 | |
| 32 | const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; | |
| 33 | const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); | |
| 30 | export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; | |
| 31 | export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; | |
| 34 | 32 | |
| 35 | 33 | const EPHEMERAL_CACHE_SEGMENTS = [ |
| 36 | 34 | "/_npx/", // npx |
| ... | ... | @@ -92,7 +90,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { |
| 92 | 90 | // relay connection, and Restart=always covers early-boot failures. |
| 93 | 91 | return [ |
| 94 | 92 | "[Unit]", |
| 95 | "Description=T3 Code server (T3 Connect)", | |
| 93 | "Description=T3 Code server", | |
| 96 | 94 | // Give up after 5 crashes in 5 minutes so a persistently broken install |
| 97 | 95 | // (deleted runtime, broken workspace) stops instead of restarting every |
| 98 | 96 | // 5s forever and growing the unrotated append log without bound. |
| ... | ... | @@ -103,6 +101,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { |
| 103 | 101 | "Type=simple", |
| 104 | 102 | "WorkingDirectory=%h", |
| 105 | 103 | `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, |
| 104 | `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, | |
| 106 | 105 | `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, |
| 107 | 106 | "Restart=always", |
| 108 | 107 | "RestartSec=5", |
| ... | ... | @@ -206,14 +205,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 206 | 205 | const unitDir = path.join(homeDir, ".config", "systemd", "user"); |
| 207 | 206 | const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); |
| 208 | 207 | 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); | |
| 217 | 209 | |
| 218 | 210 | const requireSystemdLinux = Effect.gen(function* () { |
| 219 | 211 | if (platform !== "linux" || homeDir === "") { |
| ... | ... | @@ -263,52 +255,41 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 263 | 255 | if (!isEphemeralCacheEntry(host.cliEntryPath)) { |
| 264 | 256 | return; |
| 265 | 257 | } |
| 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 | ), | |
| 301 | 285 | ), |
| 302 | 286 | ); |
| 303 | yield* fs | |
| 304 | .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) | |
| 305 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | |
| 306 | 287 | }); |
| 307 | 288 | |
| 308 | 289 | // Where the unit will point: derivable without touching the network, so |
| 309 | 290 | // status can compare units purely; install materializes it first. |
| 310 | 291 | const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) |
| 311 | ? runtimeEntryPath | |
| 292 | ? runtimePaths.entryPath | |
| 312 | 293 | : host.cliEntryPath; |
| 313 | 294 | const plan: BootServicePlan = { |
| 314 | 295 | nodePath: host.execPath, |
| ... | ... | @@ -341,8 +322,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 341 | 322 | ); |
| 342 | 323 | |
| 343 | 324 | // 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. | |
| 346 | 327 | yield* Effect.gen(function* () { |
| 347 | 328 | yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); |
| 348 | 329 | yield* runStep("enabling the service", "systemctl", [ |
| ... | ... | @@ -372,7 +353,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 372 | 353 | // fails), leave nothing behind: disable removes the enable symlink, remove |
| 373 | 354 | // deletes the file, daemon-reload clears the stale definition — otherwise a |
| 374 | 355 | // 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. | |
| 376 | 357 | const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* ( |
| 377 | 358 | previousUnit: Option.Option<string>, |
| 378 | 359 | ) { |
apps/server/src/cloud/pinnedRuntime.test.ts created+129| ... | ... | @@ -0,0 +1,129 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Deferred from "effect/Deferred"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 9 | ||
| 10 | import * as ProcessRunner from "../processRunner.ts"; | |
| 11 | import { | |
| 12 | ensurePinnedRuntimeInstalled, | |
| 13 | pinnedRuntimePaths, | |
| 14 | removePinnedRuntimeInstallation, | |
| 15 | } from "./pinnedRuntime.ts"; | |
| 16 | ||
| 17 | it.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 @@ |
| 1 | import * as Duration from "effect/Duration"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | import * as Path from "effect/Path"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | import * as Semaphore from "effect/Semaphore"; | |
| 7 | ||
| 8 | import * 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 | ||
| 18 | const PINNED_RUNTIME_DIR = "runtime"; | |
| 19 | const 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. | |
| 23 | const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); | |
| 24 | ||
| 25 | export interface PinnedRuntimePaths { | |
| 26 | readonly versionDir: string; | |
| 27 | readonly entryPath: string; | |
| 28 | readonly sentinelPath: string; | |
| 29 | } | |
| 30 | ||
| 31 | export 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 | ||
| 44 | export 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 | */ | |
| 68 | export 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. */ | |
| 157 | export 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as TestClock from "effect/testing/TestClock"; | |
| 9 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 10 | ||
| 11 | import { | |
| 12 | HostProcessArguments, | |
| 13 | HostProcessEnvironment, | |
| 14 | HostProcessExecutablePath, | |
| 15 | HostProcessPlatform, | |
| 16 | } from "@t3tools/shared/hostProcess"; | |
| 17 | ||
| 18 | import * as ServerConfig from "../config.ts"; | |
| 19 | import * as ProcessRunner from "../processRunner.ts"; | |
| 20 | import { | |
| 21 | BOOT_SERVICE_UNIT_ENV, | |
| 22 | BOOT_SERVICE_UNIT_FILE, | |
| 23 | renderBootServiceUnit, | |
| 24 | } from "./bootService.ts"; | |
| 25 | import * as SelfUpdate from "./selfUpdate.ts"; | |
| 26 | ||
| 27 | const NODE_PATH = "/usr/local/bin/node"; | |
| 28 | ||
| 29 | interface RecordedCommand { | |
| 30 | readonly command: string; | |
| 31 | readonly args: ReadonlyArray<string>; | |
| 32 | } | |
| 33 | ||
| 34 | const 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 | ||
| 68 | const 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 | ||
| 82 | it("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 | ||
| 97 | it.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 | ||
| 246 | it.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. | |
| 5 | import { | |
| 6 | ServerSelfUpdateError, | |
| 7 | type ServerSelfUpdateCapability, | |
| 8 | type ServerSelfUpdateInput, | |
| 9 | type ServerSelfUpdateResult, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { | |
| 12 | HostProcessArguments, | |
| 13 | HostProcessEnvironment, | |
| 14 | HostProcessExecutablePath, | |
| 15 | HostProcessPlatform, | |
| 16 | } from "@t3tools/shared/hostProcess"; | |
| 17 | import * as NodeChildProcess from "node:child_process"; | |
| 18 | import * as Context from "effect/Context"; | |
| 19 | import * as Duration from "effect/Duration"; | |
| 20 | import * as Effect from "effect/Effect"; | |
| 21 | import * as FileSystem from "effect/FileSystem"; | |
| 22 | import * as Layer from "effect/Layer"; | |
| 23 | import * as Path from "effect/Path"; | |
| 24 | import * as Ref from "effect/Ref"; | |
| 25 | ||
| 26 | import * as ServerConfig from "../config.ts"; | |
| 27 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 28 | import * as ProcessRunner from "../processRunner.ts"; | |
| 29 | import { | |
| 30 | BOOT_SERVICE_UNIT_ENV, | |
| 31 | BOOT_SERVICE_UNIT_FILE, | |
| 32 | quoteSystemdValue, | |
| 33 | renderBootServiceUnit, | |
| 34 | } from "./bootService.ts"; | |
| 35 | import { 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 | ||
| 45 | const 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. */ | |
| 48 | const 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. */ | |
| 53 | const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; | |
| 54 | ||
| 55 | export 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 | ||
| 68 | function 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 | */ | |
| 77 | export 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 | */ | |
| 91 | export 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 | ||
| 145 | export 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 | ||
| 154 | export 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 | ||
| 426 | export 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"; |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | |
| 11 | 11 | import packageJson from "../../package.json" with { type: "json" }; |
| 12 | import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; | |
| 12 | 13 | import * as ServerConfig from "../config.ts"; |
| 13 | 14 | import * as ProcessRunner from "../processRunner.ts"; |
| 14 | 15 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; |
| ... | ... | @@ -124,6 +125,9 @@ export const make = Effect.gen(function* () { |
| 124 | 125 | const environmentId = EnvironmentId.make(environmentIdRaw); |
| 125 | 126 | const cwdBaseName = path.basename(serverConfig.cwd).trim(); |
| 126 | 127 | const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); |
| 128 | const serverSelfUpdate = yield* resolveServerSelfUpdateCapability({ | |
| 129 | desktopManaged: serverConfig.mode === "desktop", | |
| 130 | }); | |
| 127 | 131 | |
| 128 | 132 | const descriptor: ExecutionEnvironmentDescriptor = { |
| 129 | 133 | environmentId, |
| ... | ... | @@ -137,6 +141,7 @@ export const make = Effect.gen(function* () { |
| 137 | 141 | repositoryIdentity: true, |
| 138 | 142 | connectionProbe: true, |
| 139 | 143 | threadSettlement: true, |
| 144 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), | |
| 140 | 145 | }, |
| 141 | 146 | }; |
| 142 | 147 |
apps/server/src/server.ts+6-1| ... | ... | @@ -79,6 +79,7 @@ import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; |
| 79 | 79 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; |
| 80 | 80 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; |
| 81 | 81 | import * as CloudCliState from "./cloud/CliState.ts"; |
| 82 | import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; | |
| 82 | 83 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 83 | 84 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 84 | 85 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| ... | ... | @@ -361,7 +362,11 @@ export const makeRoutesLayer = Layer.mergeAll( |
| 361 | 362 | websocketRpcRouteLayer, |
| 362 | 363 | ), |
| 363 | 364 | 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 | ); | |
| 365 | 370 | |
| 366 | 371 | export const makeServerLayer = Layer.unwrap( |
| 367 | 372 | Effect.gen(function* () { |
apps/server/src/vcs/GitVcsDriverCore.test.ts+14| ... | ... | @@ -713,6 +713,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 713 | 713 | }); |
| 714 | 714 | assert.equal(reusedForSshScheme, "origin"); |
| 715 | 715 | |
| 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 | ||
| 716 | 730 | const reusedForSshWithPort = yield* driver.ensureRemote({ |
| 717 | 731 | cwd, |
| 718 | 732 | preferredName: "pingdotgg", |
apps/server/src/ws.ts+9| ... | ... | @@ -78,6 +78,7 @@ import { |
| 78 | 78 | } from "./observability/RpcInstrumentation.ts"; |
| 79 | 79 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; |
| 80 | 80 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
| 81 | import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; | |
| 81 | 82 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 82 | 83 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 83 | 84 | import * as ServerSettings from "./serverSettings.ts"; |
| ... | ... | @@ -296,6 +297,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ |
| 296 | 297 | [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], |
| 297 | 298 | [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], |
| 298 | 299 | [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], |
| 300 | [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], | |
| 299 | 301 | [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], |
| 300 | 302 | [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], |
| 301 | 303 | [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], |
| ... | ... | @@ -418,6 +420,7 @@ const makeWsRpcLayer = ( |
| 418 | 420 | const portDiscovery = yield* PortScanner.PortDiscovery; |
| 419 | 421 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| 420 | 422 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 423 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; | |
| 421 | 424 | const config = yield* ServerConfig.ServerConfig; |
| 422 | 425 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; |
| 423 | 426 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| ... | ... | @@ -1476,6 +1479,10 @@ const makeWsRpcLayer = ( |
| 1476 | 1479 | "rpc.aggregate": "server", |
| 1477 | 1480 | }, |
| 1478 | 1481 | ), |
| 1482 | [WS_METHODS.serverUpdateServer]: (input) => | |
| 1483 | observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { | |
| 1484 | "rpc.aggregate": "server", | |
| 1485 | }), | |
| 1479 | 1486 | [WS_METHODS.serverUpsertKeybinding]: (rule) => |
| 1480 | 1487 | observeRpcEffect( |
| 1481 | 1488 | WS_METHODS.serverUpsertKeybinding, |
| ... | ... | @@ -2079,6 +2086,7 @@ const makeWsRpcLayer = ( |
| 2079 | 2086 | export const websocketRpcRouteLayer = Layer.unwrap( |
| 2080 | 2087 | Effect.gen(function* () { |
| 2081 | 2088 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 2089 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; | |
| 2082 | 2090 | return HttpRouter.add( |
| 2083 | 2091 | "GET", |
| 2084 | 2092 | "/ws", |
| ... | ... | @@ -2101,6 +2109,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2101 | 2109 | makeWsRpcLayer(session, previewAutomationBroker).pipe( |
| 2102 | 2110 | Layer.provideMerge(RpcSerialization.layerJson), |
| 2103 | 2111 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 2112 | Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), | |
| 2104 | 2113 | Layer.provide( |
| 2105 | 2114 | SourceControlDiscovery.layer.pipe( |
| 2106 | 2115 | Layer.provide( |
apps/web/src/components/ChatView.tsx+28-2| ... | ... | @@ -254,11 +254,14 @@ import { RightPanelSheet } from "./RightPanelSheet"; |
| 254 | 254 | import { previewEnvironment } from "../state/preview"; |
| 255 | 255 | import { useAtomCommand } from "../state/use-atom-command"; |
| 256 | 256 | import { Button } from "./ui/button"; |
| 257 | import { ServerUpdateAction } from "./ServerUpdateAction"; | |
| 257 | 258 | import { |
| 258 | 259 | buildVersionMismatchDismissalKey, |
| 259 | 260 | dismissVersionMismatch, |
| 260 | 261 | isVersionMismatchDismissed, |
| 261 | 262 | resolveServerConfigVersionMismatch, |
| 263 | resolveServerSelfUpdateCapability, | |
| 264 | serverUpdateGuidance, | |
| 262 | 265 | } from "../versionSkew"; |
| 263 | 266 | import { useAssetUrls } from "../assets/assetUrls"; |
| 264 | 267 | |
| ... | ... | @@ -1784,6 +1787,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1784 | 1787 | hasMultipleRegisteredEnvironments && activeThread |
| 1785 | 1788 | ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` |
| 1786 | 1789 | : "server"; |
| 1790 | const versionMismatchEnvironmentId = | |
| 1791 | versionMismatch && activeThread ? activeThread.environmentId : null; | |
| 1792 | const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); | |
| 1787 | 1793 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 1788 | 1794 | const items: ComposerBannerStackItem[] = []; |
| 1789 | 1795 | if (activeEnvironmentUnavailableState) { |
| ... | ... | @@ -1822,7 +1828,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 1822 | 1828 | ), |
| 1823 | 1829 | }); |
| 1824 | 1830 | } |
| 1825 | if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { | |
| 1831 | if ( | |
| 1832 | showVersionMismatchBanner && | |
| 1833 | versionMismatch && | |
| 1834 | versionMismatchDismissKey && | |
| 1835 | versionMismatchEnvironmentId | |
| 1836 | ) { | |
| 1826 | 1837 | items.push({ |
| 1827 | 1838 | id: `version-mismatch:${versionMismatchDismissKey}`, |
| 1828 | 1839 | variant: "warning", |
| ... | ... | @@ -1831,9 +1842,21 @@ function ChatViewContent(props: ChatViewProps) { |
| 1831 | 1842 | description: ( |
| 1832 | 1843 | <> |
| 1833 | 1844 | 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)} | |
| 1835 | 1847 | </> |
| 1836 | 1848 | ), |
| 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 | ), | |
| 1837 | 1860 | dismissLabel: "Dismiss version mismatch warning", |
| 1838 | 1861 | onDismiss: () => { |
| 1839 | 1862 | dismissVersionMismatch(versionMismatchDismissKey); |
| ... | ... | @@ -1846,9 +1869,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 1846 | 1869 | activeEnvironmentUnavailableState, |
| 1847 | 1870 | handleReconnectActiveEnvironment, |
| 1848 | 1871 | navigate, |
| 1872 | setDismissedVersionMismatchKey, | |
| 1849 | 1873 | showVersionMismatchBanner, |
| 1850 | 1874 | versionMismatch, |
| 1851 | 1875 | versionMismatchDismissKey, |
| 1876 | versionMismatchEnvironmentId, | |
| 1877 | versionMismatchSelfUpdate, | |
| 1852 | 1878 | versionMismatchServerLabel, |
| 1853 | 1879 | ]); |
| 1854 | 1880 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; |
apps/web/src/components/ServerUpdateAction.test.tsx created+198| ... | ... | @@ -0,0 +1,198 @@ |
| 1 | import type { Dispatch, ReactElement, SetStateAction } from "react"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | const testState = vi.hoisted(() => ({ | |
| 8 | updateServer: vi.fn(), | |
| 9 | toast: vi.fn(), | |
| 10 | })); | |
| 11 | ||
| 12 | const 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 | ||
| 58 | vi.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 | ||
| 68 | vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); | |
| 69 | vi.mock("~/hooks/useCopyToClipboard", () => ({ | |
| 70 | useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), | |
| 71 | })); | |
| 72 | vi.mock("~/state/server", () => ({ | |
| 73 | serverEnvironment: { updateServer: Symbol("updateServer") }, | |
| 74 | })); | |
| 75 | vi.mock("~/state/use-atom-command", () => ({ | |
| 76 | useAtomCommand: () => testState.updateServer, | |
| 77 | })); | |
| 78 | vi.mock("./ui/toast", () => ({ | |
| 79 | toastManager: { add: testState.toast }, | |
| 80 | })); | |
| 81 | ||
| 82 | import { ServerUpdateAction } from "./ServerUpdateAction"; | |
| 83 | ||
| 84 | type ActionElement = ReactElement<{ | |
| 85 | readonly disabled?: boolean; | |
| 86 | readonly onClick?: () => void; | |
| 87 | }>; | |
| 88 | ||
| 89 | function 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 | ||
| 99 | function 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 | ||
| 107 | async 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 | ||
| 115 | describe("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 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | |
| 2 | import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | isAtomCommandInterrupted, | |
| 5 | squashAtomCommandFailure, | |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | |
| 7 | ||
| 8 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | |
| 9 | import { serverEnvironment } from "~/state/server"; | |
| 10 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 11 | import { manualServerUpdateCommand } from "~/versionSkew"; | |
| 12 | import { Button } from "./ui/button"; | |
| 13 | import { Spinner } from "./ui/spinner"; | |
| 14 | import { 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 | */ | |
| 21 | const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000; | |
| 22 | ||
| 23 | function 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 | */ | |
| 35 | export 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 { |
| 106 | 106 | } from "~/environments/primary"; |
| 107 | 107 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; |
| 108 | 108 | import { useUiStateStore } from "~/uiStateStore"; |
| 109 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; | |
| 109 | import { | |
| 110 | resolveServerConfigVersionMismatch, | |
| 111 | resolveServerSelfUpdateCapability, | |
| 112 | } from "~/versionSkew"; | |
| 110 | 113 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 111 | 114 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; |
| 112 | 115 | import { authEnvironment } from "~/state/auth"; |
| ... | ... | @@ -129,6 +132,7 @@ import { |
| 129 | 132 | } from "~/state/environments"; |
| 130 | 133 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 131 | 134 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 135 | import { ServerUpdateAction } from "../ServerUpdateAction"; | |
| 132 | 136 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 133 | 137 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; |
| 134 | 138 | |
| ... | ... | @@ -1425,11 +1429,19 @@ function SavedBackendListRow({ |
| 1425 | 1429 | <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> |
| 1426 | 1430 | ) : null} |
| 1427 | 1431 | {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> | |
| 1433 | 1445 | ) : null} |
| 1434 | 1446 | {environment.connection.error ? ( |
| 1435 | 1447 | <p className="flex min-w-0 items-center gap-2 text-destructive text-xs"> |
| ... | ... | @@ -2982,6 +2994,16 @@ export function ConnectionsSettings() { |
| 2982 | 2994 | fail. |
| 2983 | 2995 | </span> |
| 2984 | 2996 | } |
| 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 | } | |
| 2985 | 3007 | /> |
| 2986 | 3008 | ) : null} |
| 2987 | 3009 | {desktopBridge ? ( |
apps/web/src/versionSkew.test.ts+32| ... | ... | @@ -8,7 +8,9 @@ import { |
| 8 | 8 | dismissVersionMismatch, |
| 9 | 9 | isVersionMismatchDismissed, |
| 10 | 10 | resolveServerConfigVersionMismatch, |
| 11 | resolveServerSelfUpdateCapability, | |
| 11 | 12 | resolveVersionMismatch, |
| 13 | serverUpdateGuidance, | |
| 12 | 14 | } from "./versionSkew"; |
| 13 | 15 | |
| 14 | 16 | describe("versionSkew", () => { |
| ... | ... | @@ -75,4 +77,34 @@ describe("versionSkew", () => { |
| 75 | 77 | "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.", |
| 76 | 78 | ); |
| 77 | 79 | }); |
| 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 | }); | |
| 78 | 110 | }); |
apps/web/src/versionSkew.ts+31-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts"; | |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | 4 | import { APP_VERSION } from "./branding"; |
| ... | ... | @@ -49,6 +49,36 @@ export function resolveServerConfigVersionMismatch( |
| 49 | 49 | return resolveVersionMismatch(serverConfig?.environment.serverVersion); |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | /** The update path the connected server offers, or null when it only | |
| 53 | supports a manual relaunch (older servers, dev checkouts, Windows). */ | |
| 54 | export 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. */ | |
| 61 | export 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. */ | |
| 67 | export 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 | ||
| 52 | 82 | export function buildVersionMismatchDismissalKey( |
| 53 | 83 | environmentId: EnvironmentId, |
| 54 | 84 | mismatch: Pick<VersionMismatch, "clientVersion" | "serverVersion">, |
docs/README.md+10-2| ... | ... | @@ -1,11 +1,19 @@ |
| 1 | 1 | # Documentation |
| 2 | 2 | |
| 3 | 3 | - [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) | |
| 5 | 14 | - [T3 Connect](./cloud/t3-connect-clerk.md) |
| 6 | 15 | - [Integrations](./integrations/source-control-providers.md) |
| 7 | 16 | - [Mobile](./mobile/app.md) |
| 8 | 17 | - [Operations](./operations/ci.md) |
| 9 | 18 | - [Providers](./providers/codex.md) |
| 10 | 19 | - [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 |
| 37 | 37 | |
| 38 | 38 | - **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. |
| 39 | 39 | |
| 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 | ||
| 40 | 42 | ## Event Lifecycle |
| 41 | 43 | |
| 42 | 44 | ### 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. |
| 331 | 331 | |
| 332 | 332 | Only the launch and access paths differ. |
| 333 | 333 | |
| 334 | ## Version Coordination | |
| 335 | ||
| 336 | Remote environments may stay online while web, desktop, or mobile clients move to a newer release. | |
| 337 | The environment descriptor therefore carries the running server version and may advertise a safe | |
| 338 | replacement path. The web and desktop UI use that information to show the appropriate action | |
| 339 | without making the connection transport responsible for process management. | |
| 340 | ||
| 341 | Published CLI servers on supported hosts can install and hand off to the client's exact version. A | |
| 342 | desktop-managed backend instead points the user to the desktop app on that machine, while older or | |
| 343 | unsupported servers fall back to a manual relaunch. The existing connection supervisor owns the | |
| 344 | disconnect and reconnect just as it would for any other involuntary socket close. | |
| 345 | ||
| 346 | See [Server Update Architecture](./server-updates.md) for capability detection, installation safety, | |
| 347 | and restart sequencing. | |
| 348 | ||
| 334 | 349 | ## Security model |
| 335 | 350 | |
| 336 | 351 | Remote 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 | ||
| 3 | T3 Code can update a connected server to the exact version of the client that detected version | |
| 4 | drift. This path exists primarily for remote environments, where the user may not have a terminal | |
| 5 | open on the server machine. | |
| 6 | ||
| 7 | The 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 | |
| 17 | loading server config. | |
| 18 | ||
| 19 | The optional capability is intentionally backward compatible. An older server does not know about | |
| 20 | the field, so a missing value means the client must offer a manual relaunch instead of sending an | |
| 21 | unknown RPC. | |
| 22 | ||
| 23 | The 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 | ||
| 28 | Both surfaces target the client's exact version. When the reconnected server reports that version, | |
| 29 | the mismatch and action disappear. | |
| 30 | ||
| 31 | ## Capability Selection | |
| 32 | ||
| 33 | The 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 | ||
| 42 | Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must | |
| 43 | never spawn a second CLI server beside the app-owned process. Likewise, a process launched by an | |
| 44 | unrecognized systemd unit does not claim the foreground respawn path because its supervisor could | |
| 45 | bring the old version back. | |
| 46 | ||
| 47 | ## Update Flow | |
| 48 | ||
| 49 | ```mermaid | |
| 50 | flowchart 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 | |
| 66 | payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as | |
| 67 | `latest` and `nightly` are rejected. | |
| 68 | ||
| 69 | The 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 | |
| 71 | successfully. Boot-service setup and self-update share the same process-wide installation lock, so | |
| 72 | they cannot mutate a pinned runtime concurrently. | |
| 73 | ||
| 74 | Before any restart, the current Node executable runs the replacement with `--version`. A failed | |
| 75 | install, failed preflight, or wrong reported version leaves the current server running. A failed | |
| 76 | preflight also removes the candidate runtime so retrying the same version performs a clean install. | |
| 77 | ||
| 78 | ## Host Service Lifecycle | |
| 79 | ||
| 80 | The 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 | |
| 82 | reconcile the unit through `BootService`; running `npx t3@latest service update` therefore pins and | |
| 83 | activates the latest CLI release without requiring a connected client. | |
| 84 | ||
| 85 | The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation | |
| 86 | operation as `t3 service install`. Connect logout only disables cloud access and clears its | |
| 87 | authorization; it does not uninstall the host service. | |
| 88 | ||
| 89 | ## Process Handoff | |
| 90 | ||
| 91 | For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified | |
| 92 | runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous | |
| 93 | unit before returning an error. | |
| 94 | ||
| 95 | For `respawn`, the server starts a detached, delayed replacement that replays the original CLI | |
| 96 | arguments. It then acknowledges the request and schedules the current process to exit. The delays | |
| 97 | give the acknowledgement time to cross direct or relayed connections before the socket closes. | |
| 98 | ||
| 99 | There is no separate progress stream. The update request remains pending while npm installs and the | |
| 100 | client shows a disabled update action. A restart can interrupt the request normally; the connection | |
| 101 | runtime keeps the environment registered and reconnects through its usual retry path. After an | |
| 102 | acknowledged foreground handoff, the UI keeps the action pending until version sync removes it or a | |
| 103 | safety timeout releases it. If a boot-service restart closes the connection before acknowledgement, | |
| 104 | the UI releases the interrupted action without reporting a false update failure and lets reconnect | |
| 105 | and the next version check determine the result. | |
| 106 | ||
| 107 | ## Release Invariant | |
| 108 | ||
| 109 | The exact client version must exist as the `t3` npm package before a client carrying that version is | |
| 110 | published. The release workflow therefore makes the GitHub release depend on CLI publication, and | |
| 111 | the 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 |
| 92 | 92 | authorization so `t3 connect link` can re-enable exposure without another browser flow. `t3 connect |
| 93 | 93 | logout` performs the same cleanup and removes the stored CLI authorization. |
| 94 | 94 | |
| 95 | The background service has an independent lifecycle. Connect setup may offer to install it, but | |
| 96 | logout leaves it running; manage it with `t3 service status`, `install`, `update`, and `uninstall`. | |
| 97 | ||
| 95 | 98 | The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH, |
| 96 | 99 | forward that port before running `t3 connect login` or `t3 connect link`: |
| 97 | 100 |
docs/operations/release.md+21| ... | ... | @@ -156,6 +156,26 @@ One-time Vercel dashboard setup: |
| 156 | 156 | - Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. |
| 157 | 157 | - Does not commit version bumps back to `main`. |
| 158 | 158 | |
| 159 | ## Server self-update release invariant | |
| 160 | ||
| 161 | Connected servers update to the client's exact version, not to an npm dist-tag. Every released | |
| 162 | desktop or hosted client version must therefore have a matching `t3@<version>` package available on | |
| 163 | npm before users can receive that client. | |
| 164 | ||
| 165 | The workflow enforces this ordering: | |
| 166 | ||
| 167 | 1. `publish_cli` publishes the exact stable or nightly version to npm. | |
| 168 | 2. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases. | |
| 169 | 3. `deploy_web` depends on `release` before moving the hosted channel to the new client. | |
| 170 | ||
| 171 | Preserve these dependencies when changing the release graph. Publishing a client first would leave | |
| 172 | the **Update server** action targeting a package version that does not exist yet. | |
| 173 | ||
| 174 | For a release smoke test, confirm `npm view t3@<version> version` returns the expected version, then | |
| 175 | connect the new client to a server on the previous version and verify that the update action | |
| 176 | reconnects to the matching server. Test one automatic path and the manual or desktop-managed | |
| 177 | guidance when those environments are available. | |
| 178 | ||
| 159 | 179 | ## Desktop auto-update notes |
| 160 | 180 | |
| 161 | 181 | - Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`. |
| ... | ... | @@ -293,6 +313,7 @@ Checklist: |
| 293 | 313 | 5. Verify workflow steps: |
| 294 | 314 | - preflight passes |
| 295 | 315 | - all matrix builds pass |
| 316 | - `publish_cli` publishes the exact release version before the release job | |
| 296 | 317 | - release job uploads expected files |
| 297 | 318 | 6. Smoke test downloaded artifacts. |
| 298 | 319 |
docs/user/background-service.md created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | # Running T3 Code in the Background | |
| 2 | ||
| 3 | On a Linux host, T3 Code can run as a background service for your user. It starts when the machine | |
| 4 | boots and keeps running after you log out. | |
| 5 | ||
| 6 | ## Manage the Service | |
| 7 | ||
| 8 | Install it with the latest T3 Code release: | |
| 9 | ||
| 10 | ```sh | |
| 11 | npx t3@latest service install | |
| 12 | ``` | |
| 13 | ||
| 14 | Check whether it is installed: | |
| 15 | ||
| 16 | ```sh | |
| 17 | npx t3@latest service status | |
| 18 | ``` | |
| 19 | ||
| 20 | Update or repair it: | |
| 21 | ||
| 22 | ```sh | |
| 23 | npx t3@latest service update | |
| 24 | ``` | |
| 25 | ||
| 26 | Stop it and remove it from startup: | |
| 27 | ||
| 28 | ```sh | |
| 29 | npx t3@latest service uninstall | |
| 30 | ``` | |
| 31 | ||
| 32 | Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. | |
| 33 | ||
| 34 | ## Using It with T3 Connect | |
| 35 | ||
| 36 | T3 Connect may offer to install the service during setup so the host stays reachable after you log | |
| 37 | out. This is only an onboarding shortcut: the service and T3 Connect are managed separately. | |
| 38 | ||
| 39 | Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer | |
| 40 | want T3 Code to start in the background. | |
| 41 | ||
| 42 | The 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 |
| 152 | 152 | |
| 153 | 153 | If 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. |
| 154 | 154 | |
| 155 | ## Updating a Remote Server | |
| 156 | ||
| 157 | When the T3 Code web or desktop app and a remote server use different versions, a warning appears in | |
| 158 | the conversation and in **Settings** → **Connections**. Follow the action shown there: T3 Code may | |
| 159 | be able to update and reconnect the server for you, or it may ask you to update the desktop app or | |
| 160 | run a copied command on the server machine. | |
| 161 | ||
| 162 | Finish active work before updating because the server restarts briefly. For step-by-step guidance, | |
| 163 | see [Keeping T3 Code in Sync](./server-updates.md). | |
| 164 | ||
| 165 | On a Linux host, you can keep the server running after logout and manage it independently of the | |
| 166 | connection method. See [Running T3 Code in the Background](./background-service.md). | |
| 167 | ||
| 155 | 168 | ## How Pairing Works |
| 156 | 169 | |
| 157 | 170 | The 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 | ||
| 3 | The T3 Code web or desktop app and the server it connects to work best when they use the same | |
| 4 | version. 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 | ||
| 8 | You 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 | ||
| 13 | Dismissing the conversation warning only hides that reminder for those two versions. It does not | |
| 14 | update the server, and the version difference remains visible in Connections. | |
| 15 | ||
| 16 | ## Before You Update | |
| 17 | ||
| 18 | Let active agent work and terminal commands finish first. Updating restarts the server, so the | |
| 19 | connection will disappear briefly and work that is still running may be interrupted. | |
| 20 | ||
| 21 | The 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 | ||
| 31 | The available action depends on how that server was started. T3 Code does not update connected | |
| 32 | servers silently in the background. | |
| 33 | ||
| 34 | If the server uses the T3 Code background service, you can also update it directly on the host: | |
| 35 | ||
| 36 | ```sh | |
| 37 | npx t3@latest service update | |
| 38 | ``` | |
| 39 | ||
| 40 | See [Running T3 Code in the Background](./background-service.md) for install, status, and removal | |
| 41 | commands. | |
| 42 | ||
| 43 | ## After the Update | |
| 44 | ||
| 45 | Keep the web or desktop app open while the server restarts. When it reconnects with the matching | |
| 46 | version, the warning and update action disappear. | |
| 47 | ||
| 48 | If the client reports a timeout, the server may still be finishing the update. Wait a minute, then | |
| 49 | reconnect or open **Settings** → **Connections** again. If the warning remains: | |
| 50 | ||
| 51 | 1. Retry the offered action once. | |
| 52 | 2. Make sure you updated the machine named in the warning, not only the device you are using. | |
| 53 | 3. 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 | ||
| 56 | For 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>( |
| 321 | 321 | scheduler: configScheduler, |
| 322 | 322 | concurrency: configConcurrency, |
| 323 | 323 | }), |
| 324 | updateServer: createEnvironmentRpcCommand(runtime, { | |
| 325 | label: "environment-data:server:update-server", | |
| 326 | tag: WS_METHODS.serverUpdateServer, | |
| 327 | scheduler: configScheduler, | |
| 328 | concurrency: configConcurrency, | |
| 329 | }), | |
| 324 | 330 | upsertKeybinding: createEnvironmentRpcCommand(runtime, { |
| 325 | 331 | label: "environment-data:server:upsert-keybinding", |
| 326 | 332 | tag: WS_METHODS.serverUpsertKeybinding, |
packages/contracts/src/environment.ts+21| ... | ... | @@ -20,6 +20,23 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ |
| 20 | 20 | }); |
| 21 | 21 | export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; |
| 22 | 22 | |
| 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. */ | |
| 26 | export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]); | |
| 27 | export 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. */ | |
| 33 | export const ServerSelfUpdateCapability = Schema.Literals([ | |
| 34 | "boot-service", | |
| 35 | "respawn", | |
| 36 | "desktop-managed", | |
| 37 | ]); | |
| 38 | export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; | |
| 39 | ||
| 23 | 40 | export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 24 | 41 | repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 25 | 42 | connectionProbe: Schema.optionalKey(Schema.Boolean), |
| ... | ... | @@ -27,6 +44,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 27 | 44 | pre-settlement servers, so clients treat missing as unsupported and |
| 28 | 45 | never send the commands under version skew. */ |
| 29 | 46 | 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), | |
| 30 | 51 | }); |
| 31 | 52 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; |
| 32 | 53 |
packages/contracts/src/rpc.ts+11| ... | ... | @@ -122,6 +122,9 @@ import { |
| 122 | 122 | ServerRemoveKeybindingInput, |
| 123 | 123 | ServerRemoveKeybindingResult, |
| 124 | 124 | ServerProviderUpdatedPayload, |
| 125 | ServerSelfUpdateError, | |
| 126 | ServerSelfUpdateInput, | |
| 127 | ServerSelfUpdateResult, | |
| 125 | 128 | ServerTraceDiagnosticsResult, |
| 126 | 129 | ServerProcessDiagnosticsResult, |
| 127 | 130 | ServerProcessResourceHistoryInput, |
| ... | ... | @@ -205,6 +208,7 @@ export const WS_METHODS = { |
| 205 | 208 | serverGetConfig: "server.getConfig", |
| 206 | 209 | serverRefreshProviders: "server.refreshProviders", |
| 207 | 210 | serverUpdateProvider: "server.updateProvider", |
| 211 | serverUpdateServer: "server.updateServer", | |
| 208 | 212 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 209 | 213 | serverRemoveKeybinding: "server.removeKeybinding", |
| 210 | 214 | serverGetSettings: "server.getSettings", |
| ... | ... | @@ -279,6 +283,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide |
| 279 | 283 | error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), |
| 280 | 284 | }); |
| 281 | 285 | |
| 286 | export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { | |
| 287 | payload: ServerSelfUpdateInput, | |
| 288 | success: ServerSelfUpdateResult, | |
| 289 | error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), | |
| 290 | }); | |
| 291 | ||
| 282 | 292 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { |
| 283 | 293 | payload: Schema.Struct({}), |
| 284 | 294 | success: ServerSettings, |
| ... | ... | @@ -693,6 +703,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 693 | 703 | WsServerGetConfigRpc, |
| 694 | 704 | WsServerRefreshProvidersRpc, |
| 695 | 705 | WsServerUpdateProviderRpc, |
| 706 | WsServerUpdateServerRpc, | |
| 696 | 707 | WsServerUpsertKeybindingRpc, |
| 697 | 708 | WsServerRemoveKeybindingRpc, |
| 698 | 709 | WsServerGetSettingsRpc, |
packages/contracts/src/server.ts+28-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | |
| 3 | import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; | |
| 4 | 4 | import { ServerAuthDescriptor } from "./auth.ts"; |
| 5 | 5 | import { |
| 6 | 6 | IsoDateTime, |
| ... | ... | @@ -574,3 +574,30 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass<ServerPro |
| 574 | 574 | return `Provider update failed for ${this.provider}: ${this.reason}`; |
| 575 | 575 | } |
| 576 | 576 | } |
| 577 | ||
| 578 | export 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 | }); | |
| 583 | export 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. */ | |
| 587 | export const ServerSelfUpdateResult = Schema.Struct({ | |
| 588 | targetVersion: TrimmedNonEmptyString, | |
| 589 | method: ServerSelfUpdateMethod, | |
| 590 | }); | |
| 591 | export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type; | |
| 592 | ||
| 593 | export 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 | } |