From 871a45eef11aff3e92cd685bce6960318c702730 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 16:18:47 +0200 Subject: [PATCH 01/13] Stabilize PR status lookups and normalize Git remotes - Cache GitHub PR lookups while preserving the last known result on failures - Support older gh repository response fields - Reuse remotes across SSH and HTTPS URL variants - Route refreshes through full VCS status invalidation --- apps/server/src/git/GitManager.test.ts | 45 ++++++++- apps/server/src/git/GitManager.ts | 97 ++++++++++++++++--- .../src/sourceControl/GitHubCli.test.ts | 55 +++++++++++ .../src/sourceControl/gitHubPullRequests.ts | 18 +++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 34 +++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 15 ++- .../src/vcs/VcsStatusBroadcaster.test.ts | 5 + apps/server/src/vcs/VcsStatusBroadcaster.ts | 7 +- 8 files changed, 254 insertions(+), 22 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..0de9bd1da3c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -50,6 +50,8 @@ interface FakeGhScenario { }; repositoryCloneUrls?: Record; failWith?: GitHubCli.GitHubCliError; + /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ + failAfterCalls?: number; } function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { @@ -382,7 +384,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const args = [...input.args]; ghCalls.push(args.join(" ")); - if (scenario.failWith) { + if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { return Effect.fail(scenario.failWith); } @@ -1336,6 +1338,47 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status keeps the last known PR when a later lookup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]); + + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/214", + baseRefName: "main", + headRefName: "feature/pr-sticky", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + // An explicit invalidation (user refresh, git action) bypasses the PR + // cache and forces a live lookup — which now fails. The badge must keep + // the last known PR instead of blanking out. + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(214); + }), + ); + it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f1fb03e7e45..6aa920bca1f 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -95,6 +95,9 @@ const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; +const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); +const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); +const PR_LOOKUP_CACHE_CAPACITY = 2_048; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; @@ -768,6 +771,82 @@ export const make = Effect.gen(function* () { normalizeStatusCacheKey(cwd).pipe( Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), ); + // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh + // on their own, slower cadence: ahead/behind counts stay fresh on every + // status poll while the PR association is re-fetched at most once per + // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump + // the epoch (invalidateStatus) to bypass the cache immediately. + const prLookupEpochByCwd = new Map(); + const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; + const bumpPrLookupEpoch = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.map((cacheKey) => { + prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); + }), + ); + // Cache keys are NUL-joined [cwd, branch, upstreamRef, epoch] — none of the + // segments can contain a NUL byte, and refs are never empty, so "" decodes + // back to a null upstreamRef. + const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => + [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); + const prLookupCache = yield* Cache.makeWith( + (key: string) => { + const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); + return findLatestPr(cwd, { + branch, + upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, + }); + }, + { + capacity: PR_LOOKUP_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), + }, + ); + // A transient lookup failure (rate limit, network blip) must not clear an + // already-known PR badge, so the last successful answer per branch sticks + // around as the fallback. + const lastKnownPrByBranchKey = new Map | null>(); + const rememberLastKnownPr = (branchKey: string, pr: ReturnType | null) => { + if ( + !lastKnownPrByBranchKey.has(branchKey) && + lastKnownPrByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY + ) { + const oldestKey = lastKnownPrByBranchKey.keys().next().value; + if (oldestKey !== undefined) { + lastKnownPrByBranchKey.delete(oldestKey); + } + } + lastKnownPrByBranchKey.set(branchKey, pr); + }; + const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( + cwd: string, + details: { branch: string; upstreamRef: string | null; isDefaultBranch: boolean }, + ) { + const branchKey = `${cwd}\u0000${details.branch}\u0000${details.upstreamRef ?? ""}`; + return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( + Effect.map((latest) => { + if (!latest) return null; + // On the default branch, only surface open PRs. + // Merged/closed matches are usually reverse-merge history, not the thread's PR context. + if (details.isDefaultBranch && latest.state !== "open") return null; + return toStatusPr(latest); + }), + Effect.tap((pr) => Effect.sync(() => rememberLastKnownPr(branchKey, pr))), + Effect.catch((error) => + Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( + Effect.annotateLogs({ + operation: "lookupStatusPr", + branch: details.branch, + errorTag: + typeof error === "object" && error !== null && "_tag" in error + ? String(error._tag) + : typeof error, + }), + Effect.as(lastKnownPrByBranchKey.get(branchKey) ?? null), + ), + ), + ); + }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, options?: GitVcsDriver.GitRemoteStatusOptions, @@ -781,19 +860,11 @@ export const make = Effect.gen(function* () { const pr = details.branch !== null - ? yield* findLatestPr(cwd, { + ? yield* lookupStatusPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => { - if (!latest) return null; - // On the default branch, only surface open PRs. - // Merged/closed matches are usually reverse-merge history, not the thread's PR context. - if (details.isDefaultBranch && latest.state !== "open") return null; - return toStatusPr(latest); - }), - Effect.orElseSucceed(() => null), - ) + isDefaultBranch: details.isDefaultBranch, + }) : null; return { @@ -1442,6 +1513,10 @@ export const make = Effect.gen(function* () { function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); yield* invalidateRemoteStatusResultCache(cwd); + // Full invalidation is the explicit-freshness path (git actions, user + // refresh); it also bypasses the slow PR-lookup cache. The periodic + // status poll only invalidates local/remote and keeps the PR cache warm. + yield* bumpPrLookupEpoch(cwd); }, ); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..5daf7676d60 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -208,6 +208,61 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () => + // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as + // {id, name} only. These entries must decode instead of being dropped, + // with nameWithOwner rebuilt from the owner login. + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 2829, + title: "Codex turn mapping", + url: "https://github.com/pingdotgg/codething-mvp/pull/2829", + baseRefName: "main", + headRefName: "t3code/codex-turn-mapping", + state: "OPEN", + mergedAt: null, + isCrossRepository: false, + headRepository: { + id: "R_kgDORLtfbQ", + name: "codething-mvp", + }, + headRepositoryOwner: { + id: "MDEyOk9yZ2FuaXphdGlvbjg5MTkxNzI3", + login: "pingdotgg", + }, + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "t3code/codex-turn-mapping", + }); + + assert.deepStrictEqual(result, [ + { + number: 2829, + title: "Codex turn mapping", + url: "https://github.com/pingdotgg/codething-mvp/pull/2829", + baseRefName: "main", + headRefName: "t3code/codex-turn-mapping", + state: "open", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index d9dcb7f9ad1..ded3c0a90b0 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -30,17 +30,21 @@ const GitHubPullRequestSchema = Schema.Struct({ mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), + // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was + // added later. Both fields stay optional so a version-drifted gh CLI can + // never fail the decode and silently drop the PR from the list. headRepository: Schema.optional( Schema.NullOr( Schema.Struct({ - nameWithOwner: Schema.String, + nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), headRepositoryOwner: Schema.optional( Schema.NullOr( Schema.Struct({ - login: Schema.String, + login: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), @@ -71,11 +75,15 @@ function normalizeGitHubPullRequestState(input: { function normalizeGitHubPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedGitHubPullRequestRecord { - const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const headRepositoryName = trimOptionalString(raw.headRepository?.name); const headRepositoryOwnerLogin = trimOptionalString(raw.headRepositoryOwner?.login) ?? - (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") - ? (headRepositoryNameWithOwner.split("/")[0] ?? null) + (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); + const headRepositoryNameWithOwner = + explicitNameWithOwner ?? + (headRepositoryOwnerLogin && headRepositoryName + ? `${headRepositoryOwnerLogin}/${headRepositoryName}` : null); return { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9ffd3ed696d..29e113eb895 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -690,6 +690,40 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }); + describe("remote operations", () => { + it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); + + const reusedForSsh = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "git@github.com:pingdotgg/t3code.git", + }); + assert.equal(reusedForSsh, "origin"); + + const reusedForSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com/pingdotgg/t3code", + }); + assert.equal(reusedForSshScheme, "origin"); + + const addedForFork = yield* driver.ensureRemote({ + cwd, + preferredName: "octocat", + url: "git@github.com:octocat/t3code.git", + }); + assert.equal(addedForFork, "octocat"); + assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin"); + }), + ); + }); + describe("commit context", () => { it.effect("stages selected files and commits only those files", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 471ec10b566..3021b3bc3d3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -236,11 +236,24 @@ function sanitizeRemoteName(value: string): string { } function normalizeRemoteUrl(value: string): string { - return value + const normalized = value .trim() .replace(/\/+$/g, "") .replace(/\.git$/i, "") .toLowerCase(); + // Reduce transport variants of the same repository to `host/path` so + // ensureRemote reuses an existing remote instead of adding an ssh/https + // duplicate (git@host:owner/repo, ssh://git@host/owner/repo, + // https://host/owner/repo all compare equal). + const scpLike = /^(?:ssh:\/\/)?(?:[^@/:]+@)([^:/]+)[:/](.+)$/.exec(normalized); + if (scpLike) { + return `${scpLike[1]}/${scpLike[2]}`; + } + const urlLike = /^(?:https?|git):\/\/(?:[^@/]+@)?(.+)$/.exec(normalized); + if (urlLike) { + return urlLike[1] ?? normalized; + } + return normalized; } function parseRemoteFetchUrls(stdout: string): Map { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 032e48e4612..4235240fd28 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -94,6 +94,11 @@ function makeTestLayer(state: { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), }), ), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index c238154f58c..b02ca9deb66 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -366,10 +366,9 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], { - concurrency: "unbounded", - discard: true, - }); + // invalidateStatus (not the two partial invalidations) so an explicit + // refresh also bypasses GitManager's slow PR-lookup cache. + yield* workflow.invalidateStatus(cwd); const [local, remote] = yield* Effect.all( [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], { concurrency: "unbounded" }, From 9de74d3fd21febf25ef5def17a81d1a53f990611 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 17:40:13 +0200 Subject: [PATCH 02/13] Enable remote server self-updates - Install and preflight pinned npm runtimes before restarting - Expose server update RPC and client update controls --- apps/server/src/cloud/bootService.ts | 81 ++--- apps/server/src/cloud/pinnedRuntime.ts | 139 ++++++++ apps/server/src/cloud/selfUpdate.test.ts | 337 ++++++++++++++++++ apps/server/src/cloud/selfUpdate.ts | 276 ++++++++++++++ .../src/environment/ServerEnvironment.ts | 3 + apps/server/src/ws.ts | 8 + apps/web/src/components/ChatView.tsx | 27 +- .../web/src/components/ServerUpdateAction.tsx | 137 +++++++ .../settings/ConnectionsSettings.tsx | 31 +- apps/web/src/versionSkew.ts | 15 +- packages/client-runtime/src/state/server.ts | 6 + packages/contracts/src/environment.ts | 10 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/server.ts | 29 +- 14 files changed, 1050 insertions(+), 60 deletions(-) create mode 100644 apps/server/src/cloud/pinnedRuntime.ts create mode 100644 apps/server/src/cloud/selfUpdate.test.ts create mode 100644 apps/server/src/cloud/selfUpdate.ts create mode 100644 apps/web/src/components/ServerUpdateAction.tsx diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0662b367049..ff37de574bd 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../processRunner.ts"; +import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; /** * Installs T3 Code as a per-user boot service so a connected machine stays @@ -27,10 +28,8 @@ import * as ProcessRunner from "../processRunner.ts"; */ const BOOT_SERVICE_NAME = "t3code"; -const BOOT_RUNTIME_DIR = "runtime"; -const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; -const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; const EPHEMERAL_CACHE_SEGMENTS = [ "/_npx/", // npx @@ -206,14 +205,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const unitDir = path.join(homeDir, ".config", "systemd", "user"); const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); const logPath = path.join(input.logsDir, "boot-service.log"); - const runtimeVersionDir = path.join( - input.baseDir, - BOOT_RUNTIME_DIR, - "versions", - input.cliVersion, - ); - const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); - const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete"); + const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); const requireSystemdLinux = Effect.gen(function* () { if (platform !== "linux" || homeDir === "") { @@ -263,52 +255,41 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!isEphemeralCacheEntry(host.cliEntryPath)) { return; } - // The sentinel is written only after npm exits 0. Checking the entry - // file alone is not enough: npm extracts files before running native - // builds (node-pty), so a killed install leaves a plausible-looking but - // broken tree behind. - const alreadyPinned = yield* Effect.all([ - fs.exists(runtimeSentinelPath), - fs.exists(runtimeEntryPath), - ]).pipe( - Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - if (alreadyPinned) { - return; - } - yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe( - Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - yield* runStep( - "installing the pinned t3 runtime (this can take a few minutes)", - "npm", - [ - "install", - "--prefix", - runtimeVersionDir, - "--no-fund", - "--no-audit", - `t3@${input.cliVersion}`, - ], - // Native deps (node-pty) can compile from source on slow boxes; the - // ProcessRunner default of 60s would kill a healthy install. - { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT }, - ).pipe( - Effect.tapError(() => - fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore), + yield* ensurePinnedRuntimeInstalled({ + baseDir: input.baseDir, + version: input.cliVersion, + fs, + path, + runner, + }).pipe( + Effect.mapError((error) => + error.step.startsWith("installing") + ? new BootServiceCommandError({ + step: error.step, + exitCode: error.exitCode, + stdoutLength: error.stdoutLength, + stderrLength: error.stderrLength, + cause: error.cause, + }) + : new BootServiceInstallError({ cause: error }), + ), + Effect.tapError((error) => + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { + flag: "a", + }), + ), + Effect.ignore, + ), ), ); - yield* fs - .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); }); // Where the unit will point: derivable without touching the network, so // status can compare units purely; install materializes it first. const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) - ? runtimeEntryPath + ? runtimePaths.entryPath : host.cliEntryPath; const plan: BootServicePlan = { nodePath: host.execPath, diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts new file mode 100644 index 00000000000..466eae6db13 --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -0,0 +1,139 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * A pinned runtime is an exact `t3@` npm-installed into + * /runtime/versions/. The boot service points its systemd + * unit here, and server self-update installs the target version here before + * switching over — never `npx t3`, whose cache is ephemeral and whose + * registry fetch at boot would make startup depend on the network. + */ + +const PINNED_RUNTIME_DIR = "runtime"; +const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); + +export interface PinnedRuntimePaths { + readonly versionDir: string; + readonly entryPath: string; + readonly sentinelPath: string; +} + +export function pinnedRuntimePaths( + path: Path.Path, + baseDir: string, + version: string, +): PinnedRuntimePaths { + const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + return { + versionDir, + entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + sentinelPath: path.join(versionDir, ".install-complete"), + }; +} + +export class PinnedRuntimeInstallError extends Schema.TaggedErrorClass()( + "PinnedRuntimeInstallError", + { + step: Schema.String, + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.exitCode === undefined + ? `Pinned runtime install failed while ${this.step}.` + : `Pinned runtime install failed while ${this.step} (exit code ${this.exitCode}).`; + } +} + +/** + * Installs `t3@` into the pinned runtime directory unless a complete + * install is already there, and returns its paths. The sentinel is written + * only after npm exits 0; checking the entry file alone is not enough — npm + * extracts files before running native builds (node-pty), so a killed + * install leaves a plausible-looking but broken tree behind. + */ +export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensure_installed")( + function* (input: { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly runner: ProcessRunner.ProcessRunner["Service"]; + }) { + const { fs, runner } = input; + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + + const alreadyPinned = yield* Effect.all([ + fs.exists(paths.sentinelPath), + fs.exists(paths.entryPath), + ]).pipe( + Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), + Effect.mapError( + (cause) => new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), + ), + ); + if (alreadyPinned) { + return paths; + } + + yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "preparing the pinned runtime directory", cause }), + ), + ); + + const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + yield* runner + .run({ + command: "npm", + args: [ + "install", + "--prefix", + paths.versionDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ], + // Native deps (node-pty) can compile from source on slow boxes; the + // ProcessRunner default of 60s would kill a healthy install. + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: installStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + Effect.tapError(() => + fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + + yield* fs + .writeFileString(paths.sentinelPath, `${input.version}\n`) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), + ), + ); + + return paths; + }, +); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts new file mode 100644 index 00000000000..acc1eb60ec7 --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -0,0 +1,337 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { renderBootServiceUnit } from "./bootService.ts"; +import * as SelfUpdate from "./selfUpdate.ts"; + +const NODE_PATH = "/usr/local/bin/node"; + +interface RecordedCommand { + readonly command: string; + readonly args: ReadonlyArray; +} + +const makeRecordingRunnerLayer = ( + commands: Array, + options?: { readonly failWhen?: ((command: string) => boolean) | undefined }, +) => + Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.sync(() => { + commands.push({ command: input.command, args: input.args }); + const failed = options?.failWhen?.(input.command) === true; + return { + stdout: "", + stderr: failed ? `${input.command} exploded` : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ); + +const provideHostRefs = (input: { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; + readonly entryPath: string; +}) => + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed(HostProcessEnvironment, input.env), + Layer.succeed(HostProcessExecutablePath, NODE_PATH), + Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]), + ), + ); + +it("recognizes published npm artifacts as swappable entry points", () => { + assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isTrue( + SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + ); + assert.isTrue( + SelfUpdate.isPublishedCliEntry( + "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + ), + ); + // Dev checkouts and the desktop bundle run apps/server/dist directly. + assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs")); + assert.isFalse(SelfUpdate.isPublishedCliEntry("")); +}); + +it.layer(NodeServices.layer)("resolveServerSelfUpdateMethod", (it) => { + const makeHome = Effect.fn("test.makeHome")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + return { fs, path, home }; + }); + + const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* ( + home: string, + entryPath: string, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir: path.join(home, ".t3"), + logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + }); + + it.effect("reports boot-service for the systemd-spawned unit process", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( + provideHostRefs({ + platform: "linux", + env: { HOME: home, INVOCATION_ID: "abc123" }, + entryPath, + }), + ); + assert.equal(method, "boot-service"); + }), + ); + + it.effect("reports respawn for a manual run of the pinned artifact", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + // Same unit on disk, but no INVOCATION_ID: restarting the unit would + // not replace this process, so it must respawn itself instead. + const method = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( + provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath }), + ); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports respawn for a foreground npx artifact on darwin", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const method = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`, + }), + ); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports no method for dev checkouts and Windows", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const devMethod = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`, + }), + ); + assert.isNull(devMethod); + const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( + provideHostRefs({ + platform: "win32", + env: { HOME: home }, + entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + }), + ); + assert.isNull(windowsMethod); + }), + ); +}); + +it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { + interface RecordedSpawn { + readonly command: string; + readonly args: ReadonlyArray; + } + + const makeContext = Effect.fn("test.makeContext")(function* (options?: { + readonly platform?: NodeJS.Platform; + readonly bootService?: boolean; + readonly entryPath?: string; + readonly failWhen?: (command: string) => boolean; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + const baseDir = path.join(home, ".t3"); + const entryPath = + options?.entryPath ?? + path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + const env: NodeJS.ProcessEnv = + options?.bootService === true ? { HOME: home, INVOCATION_ID: "abc123" } : { HOME: home }; + if (options?.bootService === true) { + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir, + logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + } + + const commands: Array = []; + const spawns: Array = []; + let exited = 0; + const service = yield* SelfUpdate.make({ + host: { + spawnDetached: (command, args) => { + spawns.push({ command, args }); + }, + exitProcess: () => { + exited += 1; + }, + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeRecordingRunnerLayer(commands, { failWhen: options?.failWhen }), + ServerConfig.layerTest(home, baseDir), + ), + ), + provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), + ); + return { + fs, + path, + home, + baseDir, + entryPath, + commands, + spawns, + exitCount: () => exited, + service, + }; + }); + + it.effect("rejects dist-tags and other non-exact versions", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip); + assert.include(error.reason, "not an exact t3 version"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("fails without touching anything when no update method applies", () => + Effect.gen(function* () { + const context = yield* makeContext({ + entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs", + }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "cannot update itself"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("surfaces a failed npm install and never schedules a restart", () => + Effect.gen(function* () { + const context = yield* makeContext({ failWhen: (command) => command === "npm" }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "installing the pinned t3 runtime"); + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("installs, preflights, and respawns a foreground server", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + assert.deepEqual( + context.commands.map((entry) => [entry.command, ...entry.args].join(" ")), + [ + `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`, + `${NODE_PATH} ${pinnedEntry} --version`, + ], + ); + + // The restart is deferred so the RPC acknowledgement flushes first. + assert.lengthOf(context.spawns, 0); + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 1); + const spawn = context.spawns[0]; + assert.equal(spawn?.command, "/bin/sh"); + assert.include(spawn?.args ?? [], pinnedEntry); + // The replacement replays the original CLI arguments. + assert.include(spawn?.args ?? [], "serve"); + assert.equal(context.exitCount(), 1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rewrites the systemd unit and restarts the boot service", () => + Effect.gen(function* () { + const context = yield* makeContext({ bootService: true }); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" }); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + const unit = yield* context.fs.readFileString( + context.path.join(context.home, ".config", "systemd", "user", "t3code.service"), + ); + assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl"], + ); + assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]); + + yield* TestClock.adjust(Duration.seconds(10)); + assert.deepEqual(context.spawns, [ + { command: "systemctl", args: ["--user", "restart", "t3code.service"] }, + ]); + // systemd replaces the process; the server must not exit itself. + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts new file mode 100644 index 00000000000..b1e0443018d --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.ts @@ -0,0 +1,276 @@ +// @effect-diagnostics nodeBuiltinImport:off +// node:child_process directly: the restart spawns must be detached +// fire-and-forget children that outlive this process, while Effect's +// ChildProcessSpawner ties every child to a scope that kills it. +import { + ServerSelfUpdateError, + type ServerSelfUpdateInput, + type ServerSelfUpdateResult, + type ServerSelfUpdateMethod, +} from "@t3tools/contracts"; +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as NodeChildProcess from "node:child_process"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { BOOT_SERVICE_UNIT_FILE, quoteSystemdValue, renderBootServiceUnit } from "./bootService.ts"; +import { ensurePinnedRuntimeInstalled } from "./pinnedRuntime.ts"; + +/** + * Lets a connected client replace this server with another published `t3` + * version over RPC — the only update path that works when the user is not at + * the machine (phone against a home server, relay-managed box). The target + * version is npm-installed into the pinned runtime and verified before + * anything restarts, so a failed install leaves the running server untouched. + */ + +const PREFLIGHT_TIMEOUT = Duration.seconds(30); +/** Grace between acknowledging the RPC and killing the process, so the + response (and its relay hop) flushes before the socket drops. */ +const RESTART_DELAY = Duration.seconds(2); + +/** Exact npm versions only — never dist-tags — so the acknowledgement names + the version that was actually installed. Also keeps the value safe to + pass to npm and embed in filesystem paths. */ +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export interface ServerSelfUpdateHost { + readonly execPath: string; + readonly cliEntryPath: string; + /** Original CLI arguments after the entry path, replayed on respawn. */ + readonly cliArgs: ReadonlyArray; + /** Fire-and-forget spawn that survives this process exiting. */ + readonly spawnDetached: (command: string, args: ReadonlyArray) => void; + readonly exitProcess: () => void; +} + +function normalizeEntryPath(entryPath: string): string { + return entryPath.replaceAll("\\", "/"); +} + +/** + * Only a published npm artifact can be swapped for another version: dev + * checkouts (apps/server/dist) and the desktop app's bundled backend have no + * npm identity, and the desktop manages its own updates. + */ +export function isPublishedCliEntry(entryPath: string): boolean { + return normalizeEntryPath(entryPath).includes("/node_modules/t3/dist/"); +} + +/** + * How this process can be restarted into another version, or null when it + * cannot. "boot-service" — this is the systemd-supervised process from + * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a + * foreground POSIX process running a published artifact: replace it with a + * detached child. Windows foreground runs are unsupported for now (no + * equivalent of the detach-and-exec handoff below). + */ +export const resolveServerSelfUpdateMethod: Effect.Effect< + ServerSelfUpdateMethod | null, + never, + FileSystem.FileSystem | Path.Path +> = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; + const hostArguments = yield* HostProcessArguments; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const entryPath = hostArguments[1] ?? ""; + if (entryPath === "") { + return null; + } + + const homeDir = env.HOME ?? ""; + if (platform === "linux" && homeDir !== "") { + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe( + Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))), + Effect.orElseSucceed(() => false), + ); + // INVOCATION_ID is set by systemd for the processes it spawns; requiring + // it distinguishes the unit's own process (restarting the unit replaces + // us) from a manual foreground run of the same pinned artifact (it would + // not). + if (unitReferencesEntry && (env.INVOCATION_ID ?? "") !== "") { + return "boot-service"; + } + } + + if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) { + return "respawn"; + } + + return null; +}).pipe(Effect.withSpan("cloud.server_self_update.resolve_method")); + +export class ServerSelfUpdate extends Context.Service< + ServerSelfUpdate, + { + readonly update: ( + input: ServerSelfUpdateInput, + ) => Effect.Effect; + } +>()("t3/cloud/selfUpdate/ServerSelfUpdate") {} + +export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: { + readonly host?: Partial; +}) { + const serverConfig = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const env = yield* HostProcessEnvironment; + const hostExecPath = yield* HostProcessExecutablePath; + const hostArguments = yield* HostProcessArguments; + const method = yield* resolveServerSelfUpdateMethod; + + const host: ServerSelfUpdateHost = { + execPath: options?.host?.execPath ?? hostExecPath, + cliEntryPath: options?.host?.cliEntryPath ?? hostArguments[1] ?? "", + cliArgs: options?.host?.cliArgs ?? hostArguments.slice(2), + spawnDetached: + options?.host?.spawnDetached ?? + ((command, args) => { + NodeChildProcess.spawn(command, [...args], { detached: true, stdio: "ignore" }).unref(); + }), + exitProcess: options?.host?.exitProcess ?? (() => process.exit(0)), + }; + + const inFlight = yield* Ref.make(false); + + const failWith = (reason: string, cause?: unknown) => + cause === undefined + ? new ServerSelfUpdateError({ reason }) + : new ServerSelfUpdateError({ reason, cause }); + + /** Deferred so the RPC acknowledgement flushes before the process dies. + Detached from the request scope: the triggering connection is exactly + what the restart tears down. */ + const scheduleRestart = (restart: Effect.Effect) => + Effect.sleep(RESTART_DELAY).pipe( + Effect.andThen(restart), + Effect.forkDetach({ startImmediately: true }), + ); + + const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( + "cloud.server_self_update.update", + )(function* (input) { + if (method === null) { + return yield* failWith( + "This server cannot update itself; relaunch it manually with the new version.", + ); + } + const activeMethod = method; + const targetVersion = input.targetVersion.trim(); + if (!EXACT_VERSION_PATTERN.test(targetVersion)) { + return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); + } + + const alreadyRunning = yield* Ref.getAndSet(inFlight, true); + if (alreadyRunning) { + return yield* failWith("A server update is already in progress."); + } + + return yield* Effect.gen(function* () { + const runtimePaths = yield* ensurePinnedRuntimeInstalled({ + baseDir: serverConfig.baseDir, + version: targetVersion, + fs, + path, + runner, + }).pipe(Effect.mapError((error) => failWith(error.message, error))); + + // A broken artifact (failed native build, incompatible node) must be + // caught while the current server is still alive to report it. + const preflight = yield* runner + .run({ + command: host.execPath, + args: [runtimePaths.entryPath, "--version"], + timeout: PREFLIGHT_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => + failWith(`Could not verify the installed t3@${targetVersion}.`, cause), + ), + ); + if (preflight.code !== 0) { + return yield* failWith( + `The installed t3@${targetVersion} failed its version check (exit code ${String(preflight.code)}).`, + ); + } + + if (activeMethod === "boot-service") { + const homeDir = env.HOME ?? ""; + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + // Same shape bootService.install writes, so the next `t3 connect` + // still recognizes the unit as current. + const unit = renderBootServiceUnit({ + nodePath: host.execPath, + t3EntryPath: runtimePaths.entryPath, + baseDir: serverConfig.baseDir, + logPath: path.join(serverConfig.logsDir, "boot-service.log"), + unitPath, + }); + yield* fs + .writeFileString(unitPath, unit) + .pipe(Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause))); + const reload = yield* runner + .run({ command: "systemctl", args: ["--user", "daemon-reload"] }) + .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause))); + if (reload.code !== 0) { + return yield* failWith( + `Reloading systemd units failed (exit code ${String(reload.code)}).`, + ); + } + yield* Effect.logInfo("Server self-update installed; restarting boot service.", { + targetVersion, + }); + // systemd stops this process and starts the rewritten unit. + yield* scheduleRestart( + Effect.sync(() => + host.spawnDetached("systemctl", ["--user", "restart", BOOT_SERVICE_UNIT_FILE]), + ), + ); + } else { + yield* Effect.logInfo("Server self-update installed; respawning.", { targetVersion }); + // The shim sleeps so this process has released its listeners before + // the replacement binds them, then execs the new version with the + // original CLI arguments. + yield* scheduleRestart( + Effect.sync(() => { + host.spawnDetached("/bin/sh", [ + "-c", + 'sleep 1; exec "$@"', + "t3-self-update", + host.execPath, + runtimePaths.entryPath, + ...host.cliArgs, + ]); + host.exitProcess(); + }), + ); + } + + return { targetVersion, method: activeMethod }; + }).pipe(Effect.ensuring(Ref.set(inFlight, false))); + }); + + return ServerSelfUpdate.of({ update }); +}); + +export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 01567b98d32..1caf24eb2ca 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -9,6 +9,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; +import { resolveServerSelfUpdateMethod } from "../cloud/selfUpdate.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; @@ -124,6 +125,7 @@ export const make = Effect.gen(function* () { const environmentId = EnvironmentId.make(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + const serverSelfUpdate = yield* resolveServerSelfUpdateMethod; const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -137,6 +139,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 08b1770a0a2..928072119a9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -78,6 +78,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -296,6 +297,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], + [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], @@ -418,6 +420,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -1476,6 +1479,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverUpdateServer]: (input) => + observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, @@ -2101,6 +2108,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(ServerSelfUpdate.layer), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f3145df4500..bd96b49bb0b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -253,11 +253,13 @@ import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; +import { ServerUpdateAction } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, + resolveServerSelfUpdateMethod, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; @@ -1786,6 +1788,9 @@ function ChatViewContent(props: ChatViewProps) { hasMultipleRegisteredEnvironments && activeThread ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` : "server"; + const versionMismatchEnvironmentId = + versionMismatch && activeThread ? activeThread.environmentId : null; + const versionMismatchSelfUpdateMethod = resolveServerSelfUpdateMethod(serverConfig); const composerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { @@ -1824,7 +1829,12 @@ function ChatViewContent(props: ChatViewProps) { ), }); } - if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { + if ( + showVersionMismatchBanner && + versionMismatch && + versionMismatchDismissKey && + versionMismatchEnvironmentId + ) { items.push({ id: `version-mismatch:${versionMismatchDismissKey}`, variant: "warning", @@ -1833,9 +1843,20 @@ function ChatViewContent(props: ChatViewProps) { description: ( <> Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} - {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. + {versionMismatch.serverVersion}.{" "} + {versionMismatchSelfUpdateMethod + ? `Update the ${versionMismatchServerLabel} so they stay in sync.` + : `Relaunch the ${versionMismatchServerLabel} with the copied command to sync them.`} ), + actions: ( + + ), dismissLabel: "Dismiss version mismatch warning", onDismiss: () => { dismissVersionMismatch(versionMismatchDismissKey); @@ -1851,6 +1872,8 @@ function ChatViewContent(props: ChatViewProps) { showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, + versionMismatchEnvironmentId, + versionMismatchSelfUpdateMethod, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx new file mode 100644 index 00000000000..14f6eaec8d9 --- /dev/null +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -0,0 +1,137 @@ +import { useCallback, useRef, useState } from "react"; +import type { EnvironmentId, ServerSelfUpdateMethod } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { manualServerUpdateCommand } from "~/versionSkew"; +import { Button } from "./ui/button"; +import { Spinner } from "./ui/spinner"; +import { toastManager } from "./ui/toast"; + +/** + * The npm install on the server side is capped at 10 minutes; expire the + * spinner a bit beyond that so a dead transport never strands a disabled + * button, while a legitimately slow install is never cut off. + */ +const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000; + +function updateFailureMessage(error: unknown): string { + return error instanceof Error ? error.message : "Server update failed."; +} + +/** + * One-click sync for a version-skewed server: tells it to install + * `t3@targetVersion` and restart into it. When the server cannot update + * itself (older server, dev checkout, desktop-managed) it degrades to + * copying the manual relaunch command, so the skew warning always offers a + * way out. + */ +export function ServerUpdateAction({ + environmentId, + serverLabel, + selfUpdateMethod, + targetVersion, +}: { + readonly environmentId: EnvironmentId; + readonly serverLabel: string; + readonly selfUpdateMethod: ServerSelfUpdateMethod | null; + readonly targetVersion: string; +}) { + const updateServer = useAtomCommand(serverEnvironment.updateServer, { + reportFailure: false, + }); + const [pending, setPending] = useState(false); + const inFlightRef = useRef(false); + const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ + target: "update command", + onCopy: ({ command }) => { + toastManager.add({ + type: "success", + title: "Update command copied", + description: `Run \`${command}\` on ${serverLabel} to update it.`, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Could not copy update command", + description: error.message, + }); + }, + }); + + const handleUpdate = useCallback(async () => { + // Synchronous re-entry guard: setPending is async, so a rapid + // double-click would otherwise dispatch two updates. + if (inFlightRef.current) { + return; + } + inFlightRef.current = true; + setPending(true); + const expiry = setTimeout(() => { + inFlightRef.current = false; + setPending(false); + toastManager.add({ + type: "error", + title: "Server update timed out", + description: "The update may still be running on the server — check again in a minute.", + }); + }, UPDATE_PENDING_EXPIRY_MS); + try { + const result = await updateServer({ + environmentId, + input: { targetVersion }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add({ + type: "error", + title: "Server update failed", + description: updateFailureMessage(squashAtomCommandFailure(result)), + }); + } + return; + } + toastManager.add({ + type: "success", + title: `Updating ${serverLabel}`, + description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Server update failed", + description: updateFailureMessage(error), + }); + } finally { + clearTimeout(expiry); + inFlightRef.current = false; + setPending(false); + } + }, [environmentId, serverLabel, targetVersion, updateServer]); + + if (selfUpdateMethod === null) { + const command = manualServerUpdateCommand(targetVersion); + return ( + + ); + } + + return pending ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 385b244577e..baaec4a3a44 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -106,7 +106,7 @@ import { } from "~/environments/primary"; import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { useUiStateStore } from "~/uiStateStore"; -import { resolveServerConfigVersionMismatch } from "~/versionSkew"; +import { resolveServerConfigVersionMismatch, resolveServerSelfUpdateMethod } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { authEnvironment } from "~/state/auth"; @@ -129,6 +129,7 @@ import { } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { ServerUpdateAction } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; @@ -1425,11 +1426,19 @@ function SavedBackendListRow({

{metadataBits.join(" · ")}

) : null} {versionMismatch ? ( -

- - Version drift: client {versionMismatch.clientVersion}, server{" "} - {versionMismatch.serverVersion}. -

+
+

+ + Version drift: client {versionMismatch.clientVersion}, server{" "} + {versionMismatch.serverVersion}. +

+ +
) : null} {environment.connection.error ? (

@@ -2982,6 +2991,16 @@ export function ConnectionsSettings() { fail. } + control={ + primaryEnvironmentId !== null ? ( + + ) : undefined + } /> ) : null} {desktopBridge ? ( diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index 88691cfc25e..1e0403156e4 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import type { EnvironmentId, ServerConfig, ServerSelfUpdateMethod } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { APP_VERSION } from "./branding"; @@ -49,6 +49,19 @@ export function resolveServerConfigVersionMismatch( return resolveVersionMismatch(serverConfig?.environment.serverVersion); } +/** How the connected server can update itself over RPC, or null when it only + supports a manual relaunch (older servers, dev checkouts, desktop). */ +export function resolveServerSelfUpdateMethod( + serverConfig: Pick | null | undefined, +): ServerSelfUpdateMethod | null { + return serverConfig?.environment.capabilities.serverSelfUpdate ?? null; +} + +/** The command to hand users whose server cannot update itself. */ +export function manualServerUpdateCommand(targetVersion: string): string { + return `npx t3@${targetVersion}`; +} + export function buildVersionMismatchDismissalKey( environmentId: EnvironmentId, mismatch: Pick, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7306a0a1071..ea7f5fb6d75 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -321,6 +321,12 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + updateServer: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:update-server", + tag: WS_METHODS.serverUpdateServer, + scheduler: configScheduler, + concurrency: configConcurrency, + }), upsertKeybinding: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:upsert-keybinding", tag: WS_METHODS.serverUpsertKeybinding, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 6fc0c914d8a..c09a9ba4521 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -20,6 +20,12 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ }); export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; +/** How a server can replace itself with another version when asked over RPC: + "boot-service" rewrites the systemd user unit and restarts it; "respawn" + installs the target version and respawns the foreground process. */ +export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]); +export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; + export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), @@ -27,6 +33,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), + /** Server accepts server.updateServer and can restart into the requested + version. Absent on servers that must be relaunched manually (dev + checkouts, desktop-managed backends, pre-update servers). */ + serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateMethod), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0356aa1807b..fa2d23b8ef2 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -122,6 +122,9 @@ import { ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, + ServerSelfUpdateError, + ServerSelfUpdateInput, + ServerSelfUpdateResult, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, @@ -205,6 +208,7 @@ export const WS_METHODS = { serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", + serverUpdateServer: "server.updateServer", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", @@ -279,6 +283,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), }); +export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { + payload: ServerSelfUpdateInput, + success: ServerSelfUpdateResult, + error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), +}); + export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -693,6 +703,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, + WsServerUpdateServerRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3d99b8e95a6..69699c7a839 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { IsoDateTime, @@ -574,3 +574,30 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass()( + "ServerSelfUpdateError", + { + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Server update failed: ${this.reason}`; + } +} From 356445041eb21e960f361b9ce05fafa65c657a36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 18:35:34 +0200 Subject: [PATCH 03/13] Address self-update review feedback Co-authored-by: codex --- apps/server/src/cloud/bootService.test.ts | 1 + apps/server/src/cloud/bootService.ts | 2 + apps/server/src/cloud/pinnedRuntime.test.ts | 67 ++++++++ apps/server/src/cloud/pinnedRuntime.ts | 133 ++++++++-------- apps/server/src/cloud/selfUpdate.test.ts | 145 ++++++++++++++++-- apps/server/src/cloud/selfUpdate.ts | 125 +++++++++++---- .../src/environment/ServerEnvironment.ts | 6 +- apps/server/src/server.ts | 7 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 14 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 6 +- apps/server/src/ws.ts | 3 +- apps/web/src/components/ChatView.tsx | 31 ++-- .../web/src/components/ServerUpdateAction.tsx | 96 +++++++----- .../settings/ConnectionsSettings.tsx | 9 +- apps/web/src/versionSkew.test.ts | 32 ++++ apps/web/src/versionSkew.ts | 27 +++- packages/contracts/src/environment.ts | 19 ++- 17 files changed, 547 insertions(+), 176 deletions(-) create mode 100644 apps/server/src/cloud/pinnedRuntime.test.ts diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index a24d54697d9..1affeca0a2e 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -108,6 +108,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { "Type=simple", "WorkingDirectory=%h", "Environment=T3CODE_HOME=/home/theo/.t3", + "Environment=T3_BOOT_SERVICE_UNIT=t3code.service", "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", "Restart=always", "RestartSec=5", diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index ff37de574bd..9db1577f4ef 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -30,6 +30,7 @@ import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntim const BOOT_SERVICE_NAME = "t3code"; export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; const EPHEMERAL_CACHE_SEGMENTS = [ "/_npx/", // npx @@ -102,6 +103,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, + `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, "Restart=always", "RestartSec=5", diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts new file mode 100644 index 00000000000..78cebdca48d --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -0,0 +1,67 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; + +it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { + it.effect("serializes concurrent installs of the same runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const installStarted = yield* Deferred.make(); + const allowInstallToFinish = yield* Deferred.make(); + const paths = pinnedRuntimePaths(path, baseDir, "0.0.29"); + let npmRuns = 0; + + const runner = ProcessRunner.ProcessRunner.of({ + run: (_input) => + Effect.gen(function* () { + npmRuns += 1; + yield* Deferred.succeed(installStarted, undefined); + yield* Deferred.await(allowInstallToFinish); + yield* fs + .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + const install = ensurePinnedRuntimeInstalled({ + baseDir, + version: "0.0.29", + fs, + path, + runner, + }); + + const first = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Deferred.await(installStarted); + const second = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Effect.yieldNow; + assert.equal(npmRuns, 1); + + yield* Deferred.succeed(allowInstallToFinish, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + assert.equal(npmRuns, 1); + assert.isTrue(yield* fs.exists(paths.sentinelPath)); + assert.isTrue(yield* fs.exists(paths.entryPath)); + }), + ); +}); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 466eae6db13..01ecde80017 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; @@ -16,6 +17,10 @@ import * as ProcessRunner from "../processRunner.ts"; const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +// Boot-service setup and remote self-update share this module but can be +// constructed in separate layers. Serialize the complete check/install/ +// sentinel transaction across all callers in this process. +const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); export interface PinnedRuntimePaths { readonly versionDir: string; @@ -71,69 +76,77 @@ export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensu const { fs, runner } = input; const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); - const alreadyPinned = yield* Effect.all([ - fs.exists(paths.sentinelPath), - fs.exists(paths.entryPath), - ]).pipe( - Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), - Effect.mapError( - (cause) => new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), - ), - ); - if (alreadyPinned) { - return paths; - } + return yield* pinnedRuntimeInstallLock.withPermit( + Effect.gen(function* () { + const alreadyPinned = yield* Effect.all([ + fs.exists(paths.sentinelPath), + fs.exists(paths.entryPath), + ]).pipe( + Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), + ), + ); + if (alreadyPinned) { + return paths; + } - yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( - Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })), - Effect.mapError( - (cause) => - new PinnedRuntimeInstallError({ step: "preparing the pinned runtime directory", cause }), - ), - ); + yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "preparing the pinned runtime directory", + cause, + }), + ), + ); - const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; - yield* runner - .run({ - command: "npm", - args: [ - "install", - "--prefix", - paths.versionDir, - "--no-fund", - "--no-audit", - `t3@${input.version}`, - ], - // Native deps (node-pty) can compile from source on slow boxes; the - // ProcessRunner default of 60s would kill a healthy install. - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - .pipe( - Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), - Effect.filterOrFail( - (result) => result.code === 0, - (result) => - new PinnedRuntimeInstallError({ - step: installStep, - exitCode: Number(result.code), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - Effect.tapError(() => - fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore), - ), - ); + const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + yield* runner + .run({ + command: "npm", + args: [ + "install", + "--prefix", + paths.versionDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ], + // Native deps (node-pty) can compile from source on slow boxes; the + // ProcessRunner default of 60s would kill a healthy install. + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: installStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + Effect.tapError(() => + fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); - yield* fs - .writeFileString(paths.sentinelPath, `${input.version}\n`) - .pipe( - Effect.mapError( - (cause) => - new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), - ), - ); + yield* fs + .writeFileString(paths.sentinelPath, `${input.version}\n`) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), + ), + ); - return paths; + return paths; + }), + ); }, ); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index acc1eb60ec7..c16655d08b1 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -17,7 +17,11 @@ import { import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; -import { renderBootServiceUnit } from "./bootService.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + renderBootServiceUnit, +} from "./bootService.ts"; import * as SelfUpdate from "./selfUpdate.ts"; const NODE_PATH = "/usr/local/bin/node"; @@ -79,7 +83,7 @@ it("recognizes published npm artifacts as swappable entry points", () => { assert.isFalse(SelfUpdate.isPublishedCliEntry("")); }); -it.layer(NodeServices.layer)("resolveServerSelfUpdateMethod", (it) => { +it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { const makeHome = Effect.fn("test.makeHome")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -112,10 +116,16 @@ it.layer(NodeServices.layer)("resolveServerSelfUpdateMethod", (it) => { const { home, path } = yield* makeHome(); const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); yield* writeUnitReferencing(home, entryPath); - const method = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( provideHostRefs({ platform: "linux", - env: { HOME: home, INVOCATION_ID: "abc123" }, + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, entryPath, }), ); @@ -123,6 +133,24 @@ it.layer(NodeServices.layer)("resolveServerSelfUpdateMethod", (it) => { }), ); + it.effect("does not claim a systemd process owned by another unit", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { HOME: home, INVOCATION_ID: "abc123" }, + entryPath, + }), + ); + assert.isNull(method); + }), + ); + it.effect("reports respawn for a manual run of the pinned artifact", () => Effect.gen(function* () { const { home, path } = yield* makeHome(); @@ -130,9 +158,9 @@ it.layer(NodeServices.layer)("resolveServerSelfUpdateMethod", (it) => { yield* writeUnitReferencing(home, entryPath); // Same unit on disk, but no INVOCATION_ID: restarting the unit would // not replace this process, so it must respawn itself instead. - const method = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( - provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath }), - ); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath })); assert.equal(method, "respawn"); }), ); @@ -140,7 +168,9 @@ it.layer(NodeServices.layer)("resolveServerSelfUpdateMethod", (it) => { it.effect("reports respawn for a foreground npx artifact on darwin", () => Effect.gen(function* () { const { home } = yield* makeHome(); - const method = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( provideHostRefs({ platform: "darwin", env: { HOME: home }, @@ -151,10 +181,36 @@ it.layer(NodeServices.layer)("resolveServerSelfUpdateMethod", (it) => { }), ); + it.effect("reports desktop-managed for desktop-supervised backends", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + // Desktop ownership wins over every process-shape heuristic: even a + // systemd-looking pinned artifact belongs to the app that spawned it. + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: true, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, + entryPath, + }), + ); + assert.equal(method, "desktop-managed"); + }), + ); + it.effect("reports no method for dev checkouts and Windows", () => Effect.gen(function* () { const { home } = yield* makeHome(); - const devMethod = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( + const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( provideHostRefs({ platform: "darwin", env: { HOME: home }, @@ -162,7 +218,9 @@ it.layer(NodeServices.layer)("resolveServerSelfUpdateMethod", (it) => { }), ); assert.isNull(devMethod); - const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateMethod.pipe( + const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( provideHostRefs({ platform: "win32", env: { HOME: home }, @@ -183,6 +241,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { const makeContext = Effect.fn("test.makeContext")(function* (options?: { readonly platform?: NodeJS.Platform; readonly bootService?: boolean; + readonly desktopManaged?: boolean; readonly entryPath?: string; readonly failWhen?: (command: string) => boolean; }) { @@ -194,7 +253,13 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { options?.entryPath ?? path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); const env: NodeJS.ProcessEnv = - options?.bootService === true ? { HOME: home, INVOCATION_ID: "abc123" } : { HOME: home }; + options?.bootService === true + ? { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + } + : { HOME: home }; if (options?.bootService === true) { const unitDir = path.join(home, ".config", "systemd", "user"); yield* fs.makeDirectory(unitDir, { recursive: true }); @@ -213,6 +278,18 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { const commands: Array = []; const spawns: Array = []; let exited = 0; + // layerTest always reports mode "web"; desktop-managed contexts overlay + // the mode the desktop app's bootstrap envelope would set. + const configLayer = + options?.desktopManaged === true + ? Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return { ...config, mode: "desktop" as const }; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir))) + : ServerConfig.layerTest(home, baseDir); const service = yield* SelfUpdate.make({ host: { spawnDetached: (command, args) => { @@ -226,7 +303,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { Effect.provide( Layer.mergeAll( makeRecordingRunnerLayer(commands, { failWhen: options?.failWhen }), - ServerConfig.layerTest(home, baseDir), + configLayer, ), ), provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), @@ -253,6 +330,16 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { }), ); + it.effect("refuses to update a desktop-managed backend and points at the app", () => + Effect.gen(function* () { + const context = yield* makeContext({ desktopManaged: true, bootService: true }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "desktop app"); + assert.lengthOf(context.commands, 0); + assert.lengthOf(context.spawns, 0); + }), + ); + it.effect("fails without touching anything when no update method applies", () => Effect.gen(function* () { const context = yield* makeContext({ @@ -281,6 +368,11 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { const result = yield* context.service.update({ targetVersion: "0.0.29" }); assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + const concurrentError = yield* context.service + .update({ targetVersion: "0.0.30" }) + .pipe(Effect.flip); + assert.include(concurrentError.reason, "already in progress"); + const pinnedEntry = context.path.join( context.baseDir, "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", @@ -334,4 +426,33 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { assert.equal(context.exitCount(), 0); }).pipe(Effect.provide(TestClock.layer())), ); + + it.effect("restores the previous systemd unit when daemon-reload fails", () => + Effect.gen(function* () { + const context = yield* makeContext({ + bootService: true, + failWhen: (command) => command === "systemctl", + }); + const unitPath = context.path.join( + context.home, + ".config", + "systemd", + "user", + BOOT_SERVICE_UNIT_FILE, + ); + const previousUnit = yield* context.fs.readFileString(unitPath); + + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "Reloading systemd units failed"); + assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl", "systemctl"], + ); + + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); }); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index b1e0443018d..afa1b341e76 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -4,9 +4,9 @@ // ChildProcessSpawner ties every child to a scope that kills it. import { ServerSelfUpdateError, + type ServerSelfUpdateCapability, type ServerSelfUpdateInput, type ServerSelfUpdateResult, - type ServerSelfUpdateMethod, } from "@t3tools/contracts"; import { HostProcessArguments, @@ -24,8 +24,14 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as ServerConfig from "../config.ts"; +import { writeFileStringAtomically } from "../atomicWrite.ts"; import * as ProcessRunner from "../processRunner.ts"; -import { BOOT_SERVICE_UNIT_FILE, quoteSystemdValue, renderBootServiceUnit } from "./bootService.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + quoteSystemdValue, + renderBootServiceUnit, +} from "./bootService.ts"; import { ensurePinnedRuntimeInstalled } from "./pinnedRuntime.ts"; /** @@ -70,18 +76,25 @@ export function isPublishedCliEntry(entryPath: string): boolean { } /** - * How this process can be restarted into another version, or null when it - * cannot. "boot-service" — this is the systemd-supervised process from + * The update path this process can offer, or null when only a manual + * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this + * backend and owns its version; only updating the app updates it. + * "boot-service" — this is the systemd-supervised process from * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a * foreground POSIX process running a published artifact: replace it with a * detached child. Windows foreground runs are unsupported for now (no * equivalent of the detach-and-exec handoff below). */ -export const resolveServerSelfUpdateMethod: Effect.Effect< - ServerSelfUpdateMethod | null, - never, - FileSystem.FileSystem | Path.Path -> = Effect.gen(function* () { +export const resolveServerSelfUpdateCapability = Effect.fn( + "cloud.server_self_update.resolve_capability", +)(function* (input: { + /** True when the desktop app supervises this backend (mode "desktop"). */ + readonly desktopManaged: boolean; +}) { + if (input.desktopManaged) { + return "desktop-managed" as const; + } + const platform = yield* HostProcessPlatform; const env = yield* HostProcessEnvironment; const hostArguments = yield* HostProcessArguments; @@ -100,21 +113,31 @@ export const resolveServerSelfUpdateMethod: Effect.Effect< Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))), Effect.orElseSucceed(() => false), ); - // INVOCATION_ID is set by systemd for the processes it spawns; requiring - // it distinguishes the unit's own process (restarting the unit replaces - // us) from a manual foreground run of the same pinned artifact (it would - // not). - if (unitReferencesEntry && (env.INVOCATION_ID ?? "") !== "") { - return "boot-service"; + // INVOCATION_ID only proves that some systemd unit launched us. The + // explicit marker written into t3code.service identifies this unit as the + // supervisor that will replace the current process when restarted. + if ( + unitReferencesEntry && + (env.INVOCATION_ID ?? "") !== "" && + env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_UNIT_FILE + ) { + return "boot-service" as const; + } + + // A process owned by another (or a legacy unmarked) systemd unit must not + // use the foreground respawn path: Restart=always could otherwise launch + // the old unit beside the detached replacement. + if ((env.INVOCATION_ID ?? "") !== "") { + return null; } } if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) { - return "respawn"; + return "respawn" as const; } return null; -}).pipe(Effect.withSpan("cloud.server_self_update.resolve_method")); +}); export class ServerSelfUpdate extends Context.Service< ServerSelfUpdate, @@ -135,7 +158,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option const env = yield* HostProcessEnvironment; const hostExecPath = yield* HostProcessExecutablePath; const hostArguments = yield* HostProcessArguments; - const method = yield* resolveServerSelfUpdateMethod; + const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); const host: ServerSelfUpdateHost = { execPath: options?.host?.execPath ?? hostExecPath, @@ -164,16 +189,26 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option Effect.andThen(restart), Effect.forkDetach({ startImmediately: true }), ); + const writeUnitAtomically = (filePath: string, contents: string) => + writeFileStringAtomically({ filePath, contents }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", )(function* (input) { - if (method === null) { + if (capability === "desktop-managed") { + return yield* failWith( + "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", + ); + } + if (capability === null) { return yield* failWith( "This server cannot update itself; relaunch it manually with the new version.", ); } - const activeMethod = method; + const activeMethod = capability; const targetVersion = input.targetVersion.trim(); if (!EXACT_VERSION_PATTERN.test(targetVersion)) { return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); @@ -215,6 +250,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option if (activeMethod === "boot-service") { const homeDir = env.HOME ?? ""; const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const previousUnit = yield* fs + .readFileString(unitPath) + .pipe( + Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)), + ); // Same shape bootService.install writes, so the next `t3 connect` // still recognizes the unit as current. const unit = renderBootServiceUnit({ @@ -224,17 +264,38 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option logPath: path.join(serverConfig.logsDir, "boot-service.log"), unitPath, }); - yield* fs - .writeFileString(unitPath, unit) - .pipe(Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause))); - const reload = yield* runner - .run({ command: "systemctl", args: ["--user", "daemon-reload"] }) - .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause))); - if (reload.code !== 0) { - return yield* failWith( - `Reloading systemd units failed (exit code ${String(reload.code)}).`, - ); - } + yield* writeUnitAtomically(unitPath, unit).pipe( + Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)), + ); + + const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () { + const reload = yield* runner + .run({ command: "systemctl", args: ["--user", "daemon-reload"] }) + .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause))); + if (reload.code !== 0) { + return yield* failWith( + `Reloading systemd units failed (exit code ${String(reload.code)}).`, + ); + } + }); + + yield* reloadSystemd().pipe( + Effect.catch((reloadError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.mapError((rollbackCause) => + failWith("Could not restore the previous systemd unit.", { + reloadError, + rollbackCause, + }), + ), + // Systemd should still have the old unit in memory after the + // failed reload, but retry after restoring in case it applied a + // partial update before returning an error. + Effect.andThen(reloadSystemd().pipe(Effect.ignore)), + Effect.andThen(Effect.fail(reloadError)), + ), + ), + ); yield* Effect.logInfo("Server self-update installed; restarting boot service.", { targetVersion, }); @@ -265,7 +326,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option } return { targetVersion, method: activeMethod }; - }).pipe(Effect.ensuring(Ref.set(inFlight, false))); + }).pipe(Effect.onError(() => Ref.set(inFlight, false))); }); return ServerSelfUpdate.of({ update }); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1caf24eb2ca..181237d76b6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -9,7 +9,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; -import { resolveServerSelfUpdateMethod } from "../cloud/selfUpdate.ts"; +import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; @@ -125,7 +125,9 @@ export const make = Effect.gen(function* () { const environmentId = EnvironmentId.make(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); - const serverSelfUpdate = yield* resolveServerSelfUpdateMethod; + const serverSelfUpdate = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0e6db87b109..21d0ebe5fff 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -79,6 +79,7 @@ import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -361,7 +362,11 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), -).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); +).pipe( + Layer.provide(PreviewAutomationBroker.layer), + Layer.provide(ServerSelfUpdate.layer), + Layer.provide(browserApiCorsLayer), +); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 29e113eb895..52dec9b79bb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -713,6 +713,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); assert.equal(reusedForSshScheme, "origin"); + const reusedForBareSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://github.com/pingdotgg/t3code", + }); + assert.equal(reusedForBareSshScheme, "origin"); + + const reusedForSshPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code", + }); + assert.equal(reusedForSshPort, "origin"); + const addedForFork = yield* driver.ensureRemote({ cwd, preferredName: "octocat", diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3021b3bc3d3..698ebeea0cd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -245,7 +245,11 @@ function normalizeRemoteUrl(value: string): string { // ensureRemote reuses an existing remote instead of adding an ssh/https // duplicate (git@host:owner/repo, ssh://git@host/owner/repo, // https://host/owner/repo all compare equal). - const scpLike = /^(?:ssh:\/\/)?(?:[^@/:]+@)([^:/]+)[:/](.+)$/.exec(normalized); + const sshUrl = /^ssh:\/\/(?:[^@/:]+@)?([^:/]+)(?::\d+)?\/(.+)$/.exec(normalized); + if (sshUrl) { + return `${sshUrl[1]}/${sshUrl[2]}`; + } + const scpLike = /^(?:[^@/:]+@)([^:/]+)[:/](.+)$/.exec(normalized); if (scpLike) { return `${scpLike[1]}/${scpLike[2]}`; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 928072119a9..f6f46d1e76e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2086,6 +2086,7 @@ const makeWsRpcLayer = ( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; return HttpRouter.add( "GET", "/ws", @@ -2108,7 +2109,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), - Layer.provide(ServerSelfUpdate.layer), + Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bd96b49bb0b..6d1e7305085 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -259,7 +259,8 @@ import { dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, - resolveServerSelfUpdateMethod, + resolveServerSelfUpdateCapability, + serverUpdateGuidance, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; @@ -1790,7 +1791,7 @@ function ChatViewContent(props: ChatViewProps) { : "server"; const versionMismatchEnvironmentId = versionMismatch && activeThread ? activeThread.environmentId : null; - const versionMismatchSelfUpdateMethod = resolveServerSelfUpdateMethod(serverConfig); + const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); const composerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { @@ -1844,19 +1845,20 @@ function ChatViewContent(props: ChatViewProps) { <> Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} {versionMismatch.serverVersion}.{" "} - {versionMismatchSelfUpdateMethod - ? `Update the ${versionMismatchServerLabel} so they stay in sync.` - : `Relaunch the ${versionMismatchServerLabel} with the copied command to sync them.`} + {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} ), - actions: ( - - ), + // The desktop-managed guidance is already the description; the action + // slot would only repeat it. + actions: + versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( + + ), dismissLabel: "Dismiss version mismatch warning", onDismiss: () => { dismissVersionMismatch(versionMismatchDismissKey); @@ -1869,11 +1871,12 @@ function ChatViewContent(props: ChatViewProps) { activeEnvironmentUnavailableState, handleReconnectActiveEnvironment, navigate, + setDismissedVersionMismatchKey, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, versionMismatchEnvironmentId, - versionMismatchSelfUpdateMethod, + versionMismatchSelfUpdate, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 14f6eaec8d9..e679bfc1234 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -1,5 +1,5 @@ -import { useCallback, useRef, useState } from "react"; -import type { EnvironmentId, ServerSelfUpdateMethod } from "@t3tools/contracts"; +import { useRef, useState } from "react"; +import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -25,21 +25,22 @@ function updateFailureMessage(error: unknown): string { } /** - * One-click sync for a version-skewed server: tells it to install - * `t3@targetVersion` and restart into it. When the server cannot update - * itself (older server, dev checkout, desktop-managed) it degrades to - * copying the manual relaunch command, so the skew warning always offers a - * way out. + * The call-to-action for a version-skewed server, matched to the update path + * it advertises: a one-click install-and-restart for servers that can update + * themselves, an update-the-desktop-app hint for desktop-managed backends + * (running `npx t3` there would start a second server, not update this one), + * and copying the manual relaunch command for everything else — so the skew + * warning always offers a way out. */ export function ServerUpdateAction({ environmentId, serverLabel, - selfUpdateMethod, + selfUpdate, targetVersion, }: { readonly environmentId: EnvironmentId; readonly serverLabel: string; - readonly selfUpdateMethod: ServerSelfUpdateMethod | null; + readonly selfUpdate: ServerSelfUpdateCapability | null; readonly targetVersion: string; }) { const updateServer = useAtomCommand(serverEnvironment.updateServer, { @@ -65,7 +66,7 @@ export function ServerUpdateAction({ }, }); - const handleUpdate = useCallback(async () => { + const handleUpdate = () => { // Synchronous re-entry guard: setPending is async, so a rapid // double-click would otherwise dispatch two updates. if (inFlightRef.current) { @@ -82,40 +83,53 @@ export function ServerUpdateAction({ description: "The update may still be running on the server — check again in a minute.", }); }, UPDATE_PENDING_EXPIRY_MS); - try { - const result = await updateServer({ - environmentId, - input: { targetVersion }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - toastManager.add({ - type: "error", - title: "Server update failed", - description: updateFailureMessage(squashAtomCommandFailure(result)), - }); + void Promise.resolve() + .then(() => + updateServer({ + environmentId, + input: { targetVersion }, + }), + ) + .then((result) => { + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add({ + type: "error", + title: "Server update failed", + description: updateFailureMessage(squashAtomCommandFailure(result)), + }); + } + return; } - return; - } - toastManager.add({ - type: "success", - title: `Updating ${serverLabel}`, - description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`, + toastManager.add({ + type: "success", + title: `Updating ${serverLabel}`, + description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`, + }); + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Server update failed", + description: updateFailureMessage(error), + }); + }) + .finally(() => { + clearTimeout(expiry); + inFlightRef.current = false; + setPending(false); }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Server update failed", - description: updateFailureMessage(error), - }); - } finally { - clearTimeout(expiry); - inFlightRef.current = false; - setPending(false); - } - }, [environmentId, serverLabel, targetVersion, updateServer]); + }; + + if (selfUpdate === "desktop-managed") { + return ( + + Update the desktop app on that machine to update this server. + + ); + } - if (selfUpdateMethod === null) { + if (selfUpdate === null) { const command = manualServerUpdateCommand(targetVersion); return (