diff --git a/apps/desktop/src/app/DesktopStartupRecovery.test.ts b/apps/desktop/src/app/DesktopStartupRecovery.test.ts index 12d5289af67c7..029bcaac2b487 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.test.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.test.ts @@ -74,6 +74,19 @@ describe("resolveStartupRecoveryOffer", () => { ).toEqual({ _tag: "Reinstall", version: "0.1.2" }); }); + it("blames the managed policy unless a newer version can read it", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ status: "available", availableVersion: "0.1.4" }), + "0.1.2", + true, + ), + ).toEqual({ _tag: "Update", version: "0.1.4" }); + expect( + resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), "0.1.2", true), + ).toEqual({ _tag: "ManagedPolicy" }); + }); + it("falls back to the releases page when this version never started", () => { expect(resolveStartupRecoveryOffer(updateState({ status: "disabled" }), "0.1.3")).toEqual({ _tag: "Releases", @@ -168,6 +181,7 @@ it.effect.each([ yield* SubscriptionRef.set(state.primaryBackendStartup, { _tag: "Failing", reason: "Server failed before readiness", + managedPolicy: false, output: "Failure details", }); yield* Deferred.await(checked); diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts index ae94fd5b4c135..c8228183aad91 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -32,7 +32,8 @@ export type StartupRecoveryOffer = | { readonly _tag: "Update"; readonly version: string } | { readonly _tag: "Downloading"; readonly version: string; readonly percent: number | null } | { readonly _tag: "Reinstall"; readonly version: string } - | { readonly _tag: "Releases" }; + | { readonly _tag: "Releases" } + | { readonly _tag: "ManagedPolicy" }; const STARTUP_RECOVERY_ACTIONS = ["update", "reinstall", "releases", "copy", "quit"] as const; type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number]; @@ -41,6 +42,7 @@ type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number]; export function resolveStartupRecoveryOffer( update: DesktopUpdateState, lastStartedVersion: string | null, + managedPolicy = false, ): StartupRecoveryOffer { if (update.status === "downloading" && update.availableVersion !== null) { return { @@ -55,6 +57,8 @@ export function resolveStartupRecoveryOffer( if (updateVersion !== null && updateVersion !== update.notAppliedVersion) { return { _tag: "Update", version: updateVersion }; } + // A newer release can know a key this one refuses; an older one or the releases page cannot help. + if (managedPolicy) return { _tag: "ManagedPolicy" }; if (update.enabled && (update.status === "idle" || update.status === "checking")) { return { _tag: "Checking" }; } @@ -98,6 +102,11 @@ function describeOffer(offer: StartupRecoveryOffer): { text: string; action: str text: "Get a different version from the releases page.", action: actionLink("releases", "Open releases", true), }; + case "ManagedPolicy": + return { + text: "Your organization's managed policy stopped T3 Code from starting. Send the details below to your IT administrator.", + action: "", + }; } } @@ -171,8 +180,14 @@ export const listen: Effect.Effect< const { latest, changes: updateChanges } = yield* updates.subscribe; const updateStateRef = yield* Ref.make(latest); - const offer = Ref.get(updateStateRef).pipe( - Effect.map((state) => resolveStartupRecoveryOffer(state, lastStartedVersion)), + const offer = Effect.all([Ref.get(updateStateRef), Ref.get(failureRef)]).pipe( + Effect.map(([state, failure]) => + resolveStartupRecoveryOffer( + state, + lastStartedVersion, + Option.exists(failure, ({ managedPolicy }) => managedPolicy), + ), + ), ); const render = Effect.gen(function* () { @@ -332,7 +347,8 @@ export const listen: Effect.Effect< } if (startup._tag !== "Failing") return; if (Option.isSome(yield* Ref.get(failureRef))) return; - yield* Ref.set(failureRef, Option.some({ reason: startup.reason, output: startup.output })); + const { _tag, ...failure } = startup; + yield* Ref.set(failureRef, Option.some(failure)); // The backend manager already logs each failure's reason. yield* logWarning("primary backend failing to start"); yield* show(); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index a5c12043d9aaa..e4714673777d9 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -1,4 +1,5 @@ import { + SERVER_EXIT_CODE_MANAGED_POLICY, SERVER_EXIT_CODE_STATE_DIR_OWNED, DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, @@ -623,56 +624,63 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("reports a crash loop with the server output on the third restart", () => - Effect.scoped( - Effect.gen(function* () { - const message = "Error: Cannot find module 'zod/v4' in /Users/René"; - let exitCount = 0; - const firstExit = yield* Deferred.make(); - const reports = yield* Queue.unbounded<{ - readonly exitCount: number; - readonly failure: DesktopBackendManager.BackendStartupFailure; - }>(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut - // lands inside the emoji's surrogate pair. - stderr: Stream.make( - new TextEncoder().encode( - `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, + // A policy refusal is reported on the first exit, since retrying cannot fix it. + it.effect.each([ + { code: 1, exits: 3, managedPolicy: false }, + { code: SERVER_EXIT_CODE_MANAGED_POLICY, exits: 1, managedPolicy: true }, + ])( + "reports exit $code with the server output after $exits exits", + ({ code, exits, managedPolicy }) => + Effect.scoped( + Effect.gen(function* () { + const message = "Error: Cannot find module 'zod/v4' in /Users/René"; + let exitCount = 0; + const firstExit = yield* Deferred.make(); + const reports = yield* Queue.unbounded<{ + readonly exitCount: number; + readonly failure: DesktopBackendManager.BackendStartupFailure; + }>(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut + // lands inside the emoji's surrogate pair. + stderr: Stream.make( + new TextEncoder().encode( + `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, + ), + new Uint8Array([0xc3]), + new Uint8Array([0xa9]), ), - new Uint8Array([0xc3]), - new Uint8Array([0xa9]), - ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - }), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), + }), + ), ), ), - ), - httpClientLayer: layerHttpClient(() => Effect.never), - backendOutputLog: { - persistFailure: () => { - exitCount += 1; - return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); + httpClientLayer: layerHttpClient(() => Effect.never), + backendOutputLog: { + persistFailure: () => { + exitCount += 1; + return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); + }, }, - }, - onStartupFailure: (failure) => - Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* Deferred.await(firstExit); - yield* TestClock.adjust(Duration.minutes(1)); - const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); - assert.equal(exitsWhenReported, 3); - assert.equal(failure.reason, "The server could not start (code=1)."); - assert.isTrue(failure.output.endsWith(message)); - assert.isTrue(failure.output.isWellFormed()); - }).pipe(Effect.provide(TestClock.layer())), - ), + onStartupFailure: (failure) => + Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(firstExit); + yield* TestClock.adjust(Duration.minutes(1)); + const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); + assert.equal(exitsWhenReported, exits); + assert.equal(failure.reason, `The server could not start (code=${code}).`); + assert.equal(failure.managedPolicy, managedPolicy); + assert.isTrue(failure.output.endsWith(message)); + assert.isTrue(failure.output.isWellFormed()); + }).pipe(Effect.provide(TestClock.layer())), + ), ); it.effect("waits out a slow startup and reports one that never becomes ready", () => diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index aa3b4d59ef1bf..2f5b57c4e3c95 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -17,6 +17,7 @@ import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { + SERVER_EXIT_CODE_MANAGED_POLICY, SERVER_EXIT_CODE_STATE_DIR_OWNED, DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, @@ -223,6 +224,8 @@ export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( export interface BackendStartupFailure { readonly reason: string; + /** The server refused to start under this machine's managed policy. */ + readonly managedPolicy: boolean; /** Tail of the last run's stdout and stderr. */ readonly output: string; } @@ -635,9 +638,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( // One streaming decoder per stream keeps UTF-8 characters split across chunks intact. const makeOutputDecoders = () => ({ stdout: new TextDecoder(), stderr: new TextDecoder() }); let outputDecoders = makeOutputDecoders(); - const reportStartupFailure = (reason: string) => + const reportStartupFailure = (reason: string, managedPolicy = false) => Ref.get(outputTail).pipe( - Effect.flatMap((output) => spec.onStartupFailure?.({ reason, output }) ?? Effect.void), + Effect.flatMap( + (output) => spec.onStartupFailure?.({ reason, managedPolicy, output }) ?? Effect.void, + ), ); const { logWarning: logInstanceWarning, logError: logInstanceError } = @@ -880,7 +885,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore); return; } - yield* scheduleRestart(reason); + yield* scheduleRestart(reason, exitCode); } }), ); @@ -980,6 +985,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* ( reason: string, + exitCode?: number, ) { const scheduled = yield* SubscriptionRef.modify(state, (latest) => { if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { @@ -1003,8 +1009,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( reason, delayMs: Duration.toMillis(delay), }); - if (attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) { - yield* reportStartupFailure(`The server could not start (${reason}).`); + // Retrying cannot fix a policy refusal, so say so at once; retries still pick up a fix. + const managedPolicy = exitCode === SERVER_EXIT_CODE_MANAGED_POLICY; + if (managedPolicy || attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) { + yield* reportStartupFailure(`The server could not start (${reason}).`, managedPolicy); } const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index a77e14b555420..6bec048a56b4e 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -4,6 +4,8 @@ import { 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; +// A server the managed policy refused, so the desktop app blames the policy rather than the release. +export const SERVER_EXIT_CODE_MANAGED_POLICY = 77; export const DesktopBackendBootstrap = Schema.Struct({ mode: Schema.Literal("desktop"), diff --git a/packages/shared/src/managedPolicyFile.ts b/packages/shared/src/managedPolicyFile.ts index e7e44608d838c..12fb5a666a04a 100644 --- a/packages/shared/src/managedPolicyFile.ts +++ b/packages/shared/src/managedPolicyFile.ts @@ -1,4 +1,4 @@ -import { ManagedPolicyDocument } from "@t3tools/contracts"; +import { ManagedPolicyDocument, SERVER_EXIT_CODE_MANAGED_POLICY } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Runtime from "effect/Runtime"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -20,6 +21,8 @@ export class ManagedPolicyFileError extends Schema.TaggedError