diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 7464d8fbe9e2f..3d53c8a63e373 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; import * as Clock from "effect/Clock"; @@ -328,6 +329,12 @@ describe("DesktopBackendConfiguration", () => { const observedDistros: Array = []; const config = yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(environment.stateDir, "environment-id"), + "windows-primary\n", + ); return yield* configuration.resolveWsl({ port: 5000, distro: null }); }).pipe( Effect.provide( @@ -368,6 +375,9 @@ describe("DesktopBackendConfiguration", () => { ); assert.equal(config.runningDistro, "Ubuntu"); + assert.deepEqual(config.bootstrap.desktopHost, { + environmentId: EnvironmentId.make("windows-primary"), + }); assert.deepEqual(config.args.slice(0, 2), ["-d", "Ubuntu"]); assert.deepEqual(observedDistros, ["Ubuntu", "Ubuntu", "Ubuntu"]); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -761,6 +771,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.bootstrap.host, "0.0.0.0"); assert.equal(config.bootstrap.tailscaleServeEnabled, false); assert.notProperty(config.bootstrap, "desktopTelemetryFd"); + assert.deepEqual(config.bootstrap.desktopHost, {}); assert.notProperty(config.bootstrap, "resourceMonitorPath"); assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 492903139547b..a472c87bb2dc6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,5 +1,6 @@ import * as NodeOS from "node:os"; +import { EnvironmentId } from "@t3tools/contracts"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; import * as Clock from "effect/Clock"; @@ -548,6 +549,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl input: SharedBootstrapInput & { readonly port: number; readonly distro: string | null; + readonly hostEnvironmentId?: EnvironmentId | undefined; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -575,6 +577,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // PortSchema rejects 0 even when Tailscale Serve is disabled. tailscaleServeEnabled: false, tailscaleServePort: 443, + desktopHost: + input.hostEnvironmentId === undefined ? {} : { environmentId: input.hostEnvironmentId }, ...buildObservabilityFragment(input.observabilitySettings), }; @@ -833,7 +837,15 @@ export const make = Effect.gen(function* () { resolveWsl: Effect.fnUntraced( function* (input) { const shared = yield* sharedInputs; - return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( + // Ownership is optional; WSL must start even before the primary creates its identity. + const hostEnvironmentId = yield* fileSystem + .readFileString(environment.path.join(environment.stateDir, "environment-id")) + .pipe( + Effect.map((value) => value.trim()), + Effect.map((value) => (value ? EnvironmentId.make(value) : undefined)), + Effect.orElseSucceed(() => undefined), + ); + return yield* resolveWslStartConfig({ ...shared, ...input, hostEnvironmentId }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 8468a9538630c..af2b849bf607a 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -88,11 +88,19 @@ describe("DesktopWslBackend", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; let registeredWsl: DesktopBackendPool.DesktopBackendInstance | undefined; let startCount = 0; - const primary = makeStubInstance({ - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: "Windows", - snapshot: primarySnapshot, - }); + let primaryReadyChecked = false; + const primary = { + ...makeStubInstance({ + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: "Windows", + snapshot: primarySnapshot, + }), + waitForReady: () => + Effect.sync(() => { + primaryReadyChecked = true; + return true; + }), + }; const layerPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { get: (id) => Effect.succeed( @@ -104,6 +112,7 @@ describe("DesktopWslBackend", () => { primary: Effect.succeed(primary), register: (spec) => Effect.sync(() => { + assert.isTrue(primaryReadyChecked); registeredSpec = spec; registeredWsl = makeStubInstance({ id: spec.id, diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index e77d896be2469..23da5d294c9f0 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Duration from "effect/Duration"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -92,6 +93,9 @@ export const layer = Layer.effect( const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: { readonly distro: string | null; }) { + const primary = yield* pool.primary; + // Readiness creates the owner identity; keep WSL available if the primary cannot start. + yield* primary.waitForReady(Duration.seconds(60)); const primaryConfig = yield* serverExposure.backendConfig; const port = yield* scanForWslPort(primaryConfig.port + 1).pipe( Effect.provideService(NetService.NetService, net), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 720569b6137d8..443a9d4714ca7 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -372,6 +372,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct const desktopBrowserFd = bootstrap?.desktopBrowserFd; const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd; const resourceMonitorPath = bootstrap?.resourceMonitorPath; + const desktopHost = bootstrap?.desktopHost; const autoBootstrapProjectFromCwd = Option.getOrElse( resolveOptionPrecedence( Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), @@ -502,6 +503,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct desktopBrowserFd, desktopBrowserControlFd, resourceMonitorPath, + ...(desktopHost === undefined ? {} : { desktopHost }), autoBootstrapProjectFromCwd, logWebSocketEvents, tailscaleServeEnabled, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 468cc89ad01e6..8186641a08ad1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -8,7 +8,7 @@ import * as Path from "effect/Path"; import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import type { OtlpEndpointSource } from "@t3tools/contracts"; +import type { DesktopHost, OtlpEndpointSource } from "@t3tools/contracts"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; import { DEFAULT_SIGNAL_EXPORT, @@ -102,6 +102,7 @@ export class ServerConfig extends Context.Service< readonly desktopBrowserFd?: number | undefined; readonly desktopBrowserControlFd?: number | undefined; readonly resourceMonitorPath?: string | undefined; + readonly desktopHost?: DesktopHost | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..3a49d13909139 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; +import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -122,6 +122,32 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { } }), ); + it.effect("publishes the desktop host environment only for a hosted backend", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped(); + const config = yield* makeServerConfig(baseDir); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + for (const desktopHost of [ + { environmentId: EnvironmentId.make("windows-primary") }, + {}, + undefined, + ]) { + const descriptor = yield* Effect.gen(function* () { + const environment = yield* ServerEnvironment.ServerEnvironment; + return yield* environment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(layerEmptySecretStore), + Layer.provide(ServerConfig.layer({ ...config, mode: "desktop", desktopHost })), + ), + ), + ); + expect(descriptor.desktopHost).toEqual(desktopHost); + } + }), + ); it.effect.each([ { name: "missing", content: undefined }, { name: "empty", content: "" }, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b36fa64dc22a5..dbad66eb34e7a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -215,6 +215,7 @@ export const make = Effect.gen(function* () { }, serverVersion: packageJson.version, orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, + ...(serverConfig.desktopHost === undefined ? {} : { desktopHost: serverConfig.desktopHost }), ...(databaseMigration === undefined ? {} : { databaseMigration }), capabilities: { repositoryIdentity: true, diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 4c09823d40538..66284d8a50c5c 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -1,4 +1,8 @@ -import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contracts"; +import { + EnvironmentId, + type ServerConfig, + type ServerSelfUpdateCapability, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -45,6 +49,47 @@ function machine( }; } +function withCapabilities( + base: UpdateEverythingMachine, + capabilities: Partial, +): UpdateEverythingMachine { + return { + ...base, + serverConfig: { + ...base.serverConfig!, + environment: { + ...base.serverConfig!.environment, + capabilities: { ...base.serverConfig!.environment.capabilities, ...capabilities }, + }, + }, + }; +} + +const secondary = ( + id: string, + overrides: Partial = {}, + desktopHost: { readonly environmentId?: EnvironmentId } = { + environmentId: EnvironmentId.make("windows-primary"), + }, +) => { + const base = withCapabilities(machine(id, "0.0.40", "desktop-managed", overrides), { + desktopAppUpdate: false, + }); + return { + ...base, + serverConfig: { + ...base.serverConfig!, + environment: { + ...base.serverConfig!.environment, + desktopHost, + }, + }, + }; +}; + +const oldDesktop = (id: string) => + withCapabilities(machine(id, "0.0.40", "desktop-managed"), { desktopAppUpdate: false }); + describe("collectServerUpdateTargets", () => { it("updates remote servers that can move to the target and names every skipped one", () => { const { targets, skipped } = collectServerUpdateTargets( @@ -53,6 +98,10 @@ describe("collectServerUpdateTargets", () => { machine("service", "0.0.40", "boot-service"), machine("nightly-service", "0.0.49-nightly.20261001.3", "boot-service"), machine("other-desktop", "0.0.49-nightly.20261001.3", "desktop-managed"), + secondary("other-wsl"), + secondary("offline-wsl", { connected: false }), + secondary("unattributed-wsl", {}, {}), + oldDesktop("old-desktop"), machine("offline", "0.0.40", "boot-service", { connected: false }), machine("never-connected", "0.0.40", "boot-service", { serverConfig: null }), machine("no-scope", "0.0.40", "boot-service", { canMaintain: false }), @@ -77,6 +126,10 @@ describe("collectServerUpdateTargets", () => { ]); expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ ["nightly-service", "on the nightly channel"], + ["other-wsl", "update its owning desktop app"], + ["offline-wsl", "not connected"], + ["unattributed-wsl", "update its owning desktop app"], + ["old-desktop", "needs a newer desktop app"], ["offline", "not connected"], ["never-connected", "not connected"], ["no-scope", "no permission"], @@ -84,6 +137,46 @@ describe("collectServerUpdateTargets", () => { ["manual", "needs a manual update"], ]); }); + + it.each([ + { + name: "updating", + owner: machine("windows-primary", "0.0.40", "desktop-managed"), + covered: true, + }, + { name: "absent", owner: undefined, covered: false }, + { + name: "offline", + owner: machine("windows-primary", "0.0.40", "desktop-managed", { connected: false }), + covered: false, + }, + { + name: "denied", + owner: machine("windows-primary", "0.0.40", "desktop-managed", { canMaintain: false }), + covered: false, + }, + { + name: "current", + owner: machine("windows-primary", "0.0.50", "desktop-managed"), + covered: false, + }, + { + name: "not a desktop", + owner: machine("windows-primary", "0.0.40", "boot-service"), + covered: false, + }, + ])("only omits a secondary when its owner is updating: $name", ({ owner, covered }) => { + const { targets, skipped } = collectServerUpdateTargets( + [secondary("wsl"), ...(owner ? [owner] : [])], + "0.0.50", + ); + expect(targets.some((target) => target.environmentId === "wsl")).toBe(false); + expect(skipped.find((entry) => entry.environmentId === "wsl")).toEqual( + covered + ? undefined + : { environmentId: "wsl", label: "wsl", reason: "update its owning desktop app" }, + ); + }); }); describe("collectChannelSwitchTargets", () => { @@ -238,8 +331,9 @@ describe("collectRestartingMachines", () => { withContinuation("opted-out", {}, false), machine("old", "0.0.40", "boot-service"), withContinuation("providers-only"), + secondary("wsl"), ]; - const servers = ["alvin", "opted-out", "old"].map((id) => ({ + const servers = ["alvin", "opted-out", "old", "windows-primary"].map((id) => ({ environmentId: EnvironmentId.make(id), })); @@ -248,10 +342,11 @@ describe("collectRestartingMachines", () => { { environmentId: "alvin", continuesThreads: true }, { environmentId: "opted-out", continuesThreads: false }, { environmentId: "old", continuesThreads: false }, + { environmentId: "wsl", continuesThreads: false }, ]); expect( collectRestartingMachines(machines, servers, false).map((entry) => entry.environmentId), - ).toEqual(["alvin", "opted-out", "old"]); + ).toEqual(["alvin", "opted-out", "old", "wsl"]); }); }); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index cfeecfd5e31ce..78a0bb80392c4 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -41,10 +41,15 @@ export function collectRestartingMachines( const updated = new Set(servers.map((server) => server.environmentId)); return machines.flatMap((machine) => { const config = machine.serverConfig; + const owner = config?.environment.desktopHost?.environmentId; if ( config === null || !machine.connected || - !(updated.has(machine.environmentId) || (installsLocal && machine.hostedByThisApp)) + !( + updated.has(machine.environmentId) || + (owner !== undefined && updated.has(owner)) || + (installsLocal && machine.hostedByThisApp) + ) ) { return []; } @@ -91,6 +96,10 @@ export function collectServerUpdateTargets( skip(unavailable ?? "not connected"); continue; } + if (config.environment.desktopHost !== undefined) { + skip("update its owning desktop app"); + continue; + } const selfUpdate = resolveServerSelfUpdateCapability(config); const desktopAppUpdate = supportsDesktopAppUpdate(config); if (selfUpdate === null) { @@ -119,7 +128,23 @@ export function collectServerUpdateTargets( }; targets.push(target); } - return { targets, skipped }; + const updatingDesktops = new Set( + targets + .filter((target) => target.selfUpdate === "desktop-managed") + .map((target) => target.environmentId), + ); + const coveredSecondaries = new Set( + machines + .filter((machine) => { + const owner = machine.serverConfig?.environment.desktopHost?.environmentId; + return owner !== undefined && updatingDesktops.has(owner); + }) + .map((machine) => machine.environmentId), + ); + return { + targets, + skipped: skipped.filter((machine) => !coveredSecondaries.has(machine.environmentId)), + }; } function unavailableReason(machine: UpdateEverythingMachine): string | null { diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index a77e14b555420..214ebe716e6de 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -1,10 +1,17 @@ import * as Schema from "effect/Schema"; -import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; // A supervisor must not restart a server refused by the state-directory owner. export const SERVER_EXIT_CODE_STATE_DIR_OWNED = 78; +/** Marks a desktop app's secondary backend, such as WSL, which updates only with that app. */ +export const DesktopHost = Schema.Struct({ + // Best effort: absent when the primary's id could not be read before this backend started. + environmentId: Schema.optionalKey(EnvironmentId), +}); +export type DesktopHost = typeof DesktopHost.Type; + export const DesktopBackendBootstrap = Schema.Struct({ mode: Schema.Literal("desktop"), noBrowser: Schema.Boolean, @@ -27,6 +34,7 @@ export const DesktopBackendBootstrap = Schema.Struct({ desktopBrowserFd: Schema.optionalKey(PositiveInt), desktopBrowserControlFd: Schema.optionalKey(PositiveInt), resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), + desktopHost: Schema.optionalKey(DesktopHost), }); export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 326f2f675aff2..45fe7d26db4c7 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -8,6 +8,7 @@ import { ThreadId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; +import { DesktopHost } from "./desktopBootstrap.ts"; import { SourceControlProviderKind } from "./sourceControl.ts"; export const ORCHESTRATION_PROTOCOL_VERSION = 2; @@ -169,6 +170,7 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({ /** Highest migration this server's database holds; absent on older servers. */ databaseMigration: Schema.optionalKey(Schema.Int), capabilities: ExecutionEnvironmentCapabilities, + desktopHost: Schema.optionalKey(DesktopHost), }); export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type;