diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 74c2489e7b12..55a30501711a 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -23,11 +23,17 @@ import { afterEach(() => vi.restoreAllMocks()); const status = { + schemaVersion: BootService.BOOT_SERVICE_STATUS_SCHEMA_VERSION, supported: true, + manager: "systemd", installed: true, + enabled: "enabled", + running: "running", current: true, + configuredVersion: "0.0.29", unitPath: "/home/me/.config/systemd/user/t3code.service", logPath: "/home/me/.t3/userdata/logs/boot-service.log", + observedAt: "2026-09-26T00:00:00.000Z", } as const; it("reports the installed service version and host paths", () => { @@ -95,6 +101,114 @@ it("reports a newer installed service and tells the CLI to catch up to it", () = assert.notInclude(output, "npx"); }); +const observation = { + manager: "systemd", + source: "systemctl --user show t3code.service", + observedAt: "2026-09-26T00:00:00.000Z", + reachable: true, + enabled: "enabled", + running: "running", + state: "active", + subState: "running", + processId: 4321, + configuredProgramPath: "/home/me/.t3/runtime/versions/0.0.29/t3", + configuredVersion: "0.0.29", + restartCount: 0, + lastResult: "success", +} satisfies BootService.BootServiceManagerObservation; + +it("emits the versioned machine-readable status contract with --json", () => { + const parsed = JSON.parse( + formatServiceStatus({ ...status, observation }, "0.0.29", { json: true }), + ) as Record; + + expect(parsed.schemaVersion).toBe(BootService.BOOT_SERVICE_STATUS_SCHEMA_VERSION); + expect(parsed.schemaVersion).toBe(2); + expect(parsed.manager).toBe("systemd"); + expect(parsed.running).toBe("running"); + expect(parsed.cliVersion).toBe("0.0.29"); + expect(parsed.unitPath).toBe(status.unitPath); + // Configured launch metadata is separate and there is no observed running + // server version in this contract. + expect(parsed.configuredVersion).toBe("0.0.29"); + expect("runningVersion" in parsed).toBe(false); + const observationJson = parsed.observation as Record; + expect(observationJson.configuredVersion).toBe("0.0.29"); + expect(observationJson.configuredProgramPath).toBe("/home/me/.t3/runtime/versions/0.0.29/t3"); + expect("runningVersion" in observationJson).toBe(false); +}); + +it("keeps human status output and adds manager observation lines", () => { + const output = formatServiceStatus({ ...status, observation }, "0.0.29"); + + expect(output).toContain("Status: installed · t3@0.0.29"); + expect(output).toContain("Manager: systemd · running running"); + expect(output).toContain("Configured launcher: t3@0.0.29"); + expect(output).toContain("Manager state: active/running"); + expect(output).toContain("Enabled: enabled"); + expect(output).toContain( + "Observed: 2026-09-26T00:00:00.000Z (systemctl --user show t3code.service)", + ); + expect(output).not.toContain("Note:"); + expect(output).not.toContain("t3@0.0.29 (running)"); +}); + +it("does not report a non-running manager observation as healthy", () => { + const output = formatServiceStatus( + { + ...status, + running: "not-loaded", + observation: { ...observation, running: "not-loaded", detail: "launch-agent-not-loaded" }, + }, + "0.0.29", + ); + + expect(output).toContain("Manager: systemd · running not loaded"); + expect(output).toContain("Manager detail: launch-agent-not-loaded"); + expect(output).toContain("that is a manager observation, not application health"); +}); + +it("reports a transitioning manager state without calling it stopped or healthy", () => { + const output = formatServiceStatus( + { + ...status, + running: "transitioning", + observation: { + ...observation, + running: "transitioning", + state: "deactivating", + subState: "stop-sigterm", + }, + }, + "0.0.29", + ); + + expect(output).toContain("Manager: systemd · running changing state"); + expect(output).toContain("Manager state: deactivating/stop-sigterm"); + expect(output).toContain("that is a manager observation, not application health"); + expect(output).not.toContain("running stopped"); +}); + +it("shows the configured program path when it carries no exact version", () => { + const { + configuredVersion: _configuredVersion, + configuredProgramPath: _configuredProgramPath, + ...observationWithoutBinding + } = observation; + const output = formatServiceStatus( + { + ...status, + observation: { + ...observationWithoutBinding, + configuredProgramPath: "/home/me/.t3/runtime/versions/nightly/t3", + }, + }, + "0.0.29", + ); + + expect(output).toContain("Configured launcher: /home/me/.t3/runtime/versions/nightly/t3"); +}); + const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; function makeTestService(serviceStatus: BootService.BootServiceStatus) { diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 692bf4f3a099..7b5da2e54437 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -2,6 +2,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as References from "effect/References"; import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient } from "effect/unstable/http"; @@ -66,7 +67,11 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op export function formatServiceStatus( status: BootService.BootServiceStatus, cliVersion: string, + options?: { readonly json?: boolean }, ): string { + if (options?.json) { + return JSON.stringify({ ...status, cliVersion }, null, 2); + } if (!status.supported) { return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd"; } @@ -77,6 +82,8 @@ export function formatServiceStatus( const problems = (status.problems ?? []).map( (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`, ); + const observationLines = formatServiceObservation(status); + const warning = formatServiceObservationWarning(status); if ( !status.current && status.installedVersion !== undefined && @@ -87,7 +94,9 @@ export function formatServiceStatus( ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, + ...observationLines, ...problems, + ...warning, ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`, ].join("\n"); } @@ -96,20 +105,88 @@ export function formatServiceStatus( ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, + ...observationLines, ...problems, + ...warning, ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]), ].join("\n"); } +/** Manager observation lines. Additive: absent when no live observation exists. */ +function formatServiceObservation(status: BootService.BootServiceStatus): ReadonlyArray { + const observation = status.observation; + if (observation === undefined) return []; + const running = + observation.running === "running" + ? "running" + : observation.running === "stopped" + ? "stopped" + : observation.running === "transitioning" + ? "changing state" + : observation.running === "not-loaded" + ? "not loaded" + : "unknown"; + const configured = + observation.configuredVersion !== undefined + ? `t3@${observation.configuredVersion}` + : observation.configuredProgramPath; + const rawState = + observation.state === undefined + ? undefined + : observation.subState === undefined + ? observation.state + : `${observation.state}/${observation.subState}`; + return [ + ` Manager: ${observation.manager} · running ${running}`, + ` Enabled: ${observation.enabled}`, + ...(configured === undefined ? [] : [` Configured launcher: ${configured}`]), + ...(rawState === undefined ? [] : [` Manager state: ${rawState}`]), + ...(observation.restartCount === undefined + ? [] + : [` Restarts (systemd, monotonic since start): ${observation.restartCount}`]), + ...(observation.lastResult === undefined ? [] : [` Last result: ${observation.lastResult}`]), + ...(observation.detail === undefined ? [] : [` Manager detail: ${observation.detail}`]), + ` Observed: ${observation.observedAt} (${observation.source})`, + ]; +} + +/** + * A non-running manager observation must not read as healthy, but a state file + * and `current` cannot prove the server answers either. Keep both claims + * separate and say which one the note is based on. + */ +function formatServiceObservationWarning( + status: BootService.BootServiceStatus, +): ReadonlyArray { + const observation = status.observation; + if (observation === undefined || observation.running === "running") return []; + return [ + ` Note: the service manager reports the job as '${observation.running}'; that is a manager observation, not application health.`, + ]; +} + const runServiceCommand = Effect.fn("cli.service.run")(function* ( flags: { readonly baseDir: Parameters[0]["baseDir"] }, run: Effect.Effect, + options?: { readonly quietLogs?: boolean }, ) { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveCliAuthConfig(flags, logLevel); - return yield* run.pipe(Effect.provide(bootServiceLayer(config))); + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; + return yield* run.pipe( + Effect.provide( + bootServiceLayer(config).pipe( + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); }); +const jsonFlag = Flag.Boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + const serviceReconcileFlags = { ...projectLocationFlags, allowDowngrade: Flag.Boolean("allow-downgrade").pipe( @@ -201,15 +278,20 @@ const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags). ), ); -const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( +const serviceStatusCommand = Command.make("status", { + ...projectLocationFlags, + json: jsonFlag, +}).pipe( Command.withDescription("Show whether the T3 Code background service is installed."), Command.withHandler((flags) => runServiceCommand( flags, Effect.gen(function* () { const service = yield* BootService.BootService; - yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); + const status = yield* service.status; + yield* Console.log(formatServiceStatus(status, packageJson.version, { json: flags.json })); }), + { quietLogs: flags.json }, ), ), ); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 25609853d2d3..a63ca2c5e54b 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -150,15 +150,48 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const timeouts = new Map(); const control: { failCommand: string | undefined; + timedOutCommand: string | undefined; stateAfterStop?: string; linger: string; enabled: boolean; active: boolean; + systemdShow: string | undefined; + systemdActiveState: string | undefined; + systemdSubState: string | undefined; + systemdMainPid: string | undefined; + systemdNRestarts: string | undefined; + systemdResult: string | undefined; + systemdExecStartPath: string | undefined; + launchdDomainPresent: boolean; + launchdDomainStderr: string; + launchdJob: "running" | "stopped" | "not-loaded" | "malformed" | "permission" | "unexpected"; + launchdJobStderr: string; + launchdNotFoundStderr: string; + launchdProgramPath: string | undefined; + launchdPid: string | undefined; + launchdDisabled: boolean; } = { failCommand: undefined, + timedOutCommand: undefined, linger: "yes", enabled: true, active: true, + systemdShow: undefined, + systemdActiveState: undefined, + systemdSubState: undefined, + systemdMainPid: undefined, + systemdNRestarts: undefined, + systemdResult: undefined, + systemdExecStartPath: undefined, + launchdDomainPresent: true, + launchdDomainStderr: "Could not find domain for user gui: 501", + launchdJob: "running", + launchdJobStderr: "Operation not permitted", + launchdNotFoundStderr: + 'Could not find service "com.t3tools.t3code.service" in domain for user gui: 501', + launchdProgramPath: undefined, + launchdPid: undefined, + launchdDisabled: false, }; const runner = ProcessRunner.ProcessRunner.of({ run: Effect.fn("test.run_boot_service_command")(function* ( @@ -167,11 +200,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const command = `${input.command} ${input.args.join(" ")}`; commands.push(command); timeouts.set(command, input.timeout); - const failed = command === control.failCommand; - if (!failed && command === "loginctl enable-linger --no-ask-password 501") + const timedOut = control.timedOutCommand === command; + const failed = !timedOut && command === control.failCommand; + if (!failed && !timedOut && command === "loginctl enable-linger --no-ask-password 501") control.linger = "yes"; - if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; - if (!failed && command === "systemctl --user restart t3code.service") control.active = true; + if (!failed && !timedOut && command === "systemctl --user enable t3code.service") + control.enabled = true; + if (!failed && !timedOut && command === "systemctl --user restart t3code.service") + control.active = true; if ( control.stateAfterStop !== undefined && (command === "systemctl --user stop t3code.service" || @@ -179,6 +215,100 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ) { yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); } + if (timedOut) { + return { + stdout: "", + stderr: "", + code: null, + timedOut: true, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + } + const ok = (stdout: string) => ({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + const status = (stdout: string, code: number, stderr = "") => ({ + ...ok(stdout), + stderr, + code: ChildProcessSpawner.ExitCode(code), + }); + if (failed) return status("", 1); + // Manager observation probes. These mirror the real commands and let the + // tests exercise running/enabled/identity divergence without a host. + if ( + input.command === "systemctl" && + input.args[1] === "show" && + input.args[2] === "t3code.service" + ) { + return ok( + control.systemdShow ?? + [ + "LoadState=loaded", + `ActiveState=${ + control.systemdActiveState ?? (control.active ? "active" : "inactive") + }`, + `SubState=${control.systemdSubState ?? (control.active ? "running" : "dead")}`, + `MainPID=${control.systemdMainPid ?? (control.active ? "4321" : "0")}`, + `UnitFileState=${control.enabled ? "enabled" : "disabled"}`, + `ExecStart={ path=${control.systemdExecStartPath ?? runtime.entryPath} ; argv[]=${runtime.entryPath} __service-launcher ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(never) ; status=0/0 }`, + `NRestarts=${control.systemdNRestarts ?? "0"}`, + `Result=${control.systemdResult ?? "success"}`, + ].join("\n"), + ); + } + if ( + input.command === "launchctl" && + input.args[0] === "print" && + input.args[1] === "gui/501" + ) { + return control.launchdDomainPresent + ? ok("gui/501 = {\n\ttype = login\n}\n") + : status("", 1, control.launchdDomainStderr); + } + if ( + input.command === "launchctl" && + input.args[0] === "print" && + input.args[1] === "gui/501/com.t3tools.t3code.service" + ) { + if (control.launchdJob === "not-loaded") + return status("", 1, control.launchdNotFoundStderr); + if (control.launchdJob === "permission") return status("", 1, control.launchdJobStderr); + if (control.launchdJob === "unexpected") + return status("", 1, "launchctl: input/output error"); + if (control.launchdJob === "malformed") return ok("this is not launchctl output\n"); + const state = + control.launchdJob === "running" + ? `state = running\n\n\tpid = ${control.launchdPid ?? "4321"}\n` + : "state = not running\n"; + return ok( + `gui/501/com.t3tools.t3code.service = {\n\tactive count = ${ + control.launchdJob === "running" ? "1" : "0" + }\n\ttype = LaunchAgent\n\t${state}\tprogram = ${ + control.launchdProgramPath ?? runtime.entryPath + }\n\tlast exit code = 0\n}\n`, + ); + } + if ( + input.command === "launchctl" && + input.args[0] === "print-disabled" && + input.args[1] === "gui/501" + ) { + return ok( + `disabled services = {\n\t"com.t3tools.t3code.service" => ${ + control.launchdDisabled ? "true" : "false" + }\n}\n`, + ); + } return { stdout: input.args[0] === "--version" @@ -247,7 +377,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ), ); const service = yield* makeService(); - return { service, makeService, fs, statePath, commands, timeouts, control, runtime }; + return { service, makeService, fs, home, statePath, commands, timeouts, control, runtime }; }); it.layer(NodeServices.layer)("boot service install", (it) => { @@ -835,3 +965,564 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); }); + +it("parses only well-formed manager output and exact versions, rejecting bad counters and PIDs", () => { + expect(BootService.parseSystemdShow("no anchors here")).toBeUndefined(); + expect(BootService.parseSystemdShow("LoadState=loaded\nActiveState=active")).toMatchObject({ + loadState: "loaded", + activeState: "active", + }); + expect( + BootService.parseSystemdShow("LoadState=loaded\nActiveState=active\nMainPID=0\nNRestarts=-1") + ?.mainPid, + ).toBeUndefined(); + expect( + BootService.parseSystemdShow("LoadState=loaded\nActiveState=active\nMainPID=abc\nNRestarts=12x") + ?.nRestarts, + ).toBeUndefined(); + expect( + BootService.parseSystemdShow("LoadState=loaded\nActiveState=active\nMainPID=42\nNRestarts=5"), + ).toMatchObject({ mainPid: 42, nRestarts: 5 }); + expect(BootService.parseLaunchdPrint("this is not launchctl output")).toBeUndefined(); + expect(BootService.parseLaunchdPrint("\tstate = running\n\tpid = 12\n")).toMatchObject({ + state: "running", + pid: 12, + }); + expect(BootService.parseLaunchdPrint("\tstate = running\n\tpid = 0\n")).toMatchObject({ + state: "running", + }); + expect(BootService.parseLaunchdPrint("\tstate = running\n\tpid = 0\n")?.pid).toBeUndefined(); + expect( + BootService.parseLaunchdDisabled( + 'disabled services = {\n\t"com.t3tools.t3code.service" => true\n}', + "com.t3tools.t3code.service", + ), + ).toBe(true); + expect( + BootService.parseLaunchdDisabled("disabled services = {}", "com.t3tools.t3code.service"), + ).toBeUndefined(); + expect( + BootService.bootServiceVersionFromProgramPath("/home/x/.t3/runtime/versions/1.2.3/t3"), + ).toBe("1.2.3"); + expect(BootService.bootServiceVersionFromProgramPath("/usr/bin/node")).toBeUndefined(); + expect(BootService.launchdPermissionDenied("Operation not permitted")).toBe(true); + expect(BootService.launchdPermissionDenied("Could not find service")).toBe(false); + expect(BootService.launchdNotFound("Could not find service in domain for user gui: 501")).toBe( + true, + ); + expect(BootService.launchdNotFound("launchctl: input/output error")).toBe(false); +}); + +it("rejects a numeric prefix and unrepresentable integers in manager output", () => { + // `pid = 12junk` must not be truncated to 12. + expect(BootService.parseLaunchdPrint("\tstate = running\n\tpid = 12junk\n")?.pid).toBeUndefined(); + // The last-exit-code field has the same suffix hazard. + expect( + BootService.parseLaunchdPrint("\tstate = running\n\tlast exit code = 7junk\n")?.lastExitCode, + ).toBeUndefined(); + // An unrepresentable pid must not become an unsafe number. + expect( + BootService.parseLaunchdPrint("\tstate = running\n\tpid = 99999999999999999999\n")?.pid, + ).toBeUndefined(); + // Whole fields still parse, including a signed last exit code. + expect( + BootService.parseLaunchdPrint("\tstate = running\n\tpid = 12\n\tlast exit code = -9\n"), + ).toMatchObject({ pid: 12, lastExitCode: -9 }); + // systemd MainPID must be safe before it can imply a live process. + expect( + BootService.parseSystemdShow( + "LoadState=loaded\nActiveState=active\nSubState=running\nMainPID=99999999999999999999", + )?.mainPid, + ).toBeUndefined(); + expect( + BootService.parseSystemdShow( + "LoadState=loaded\nActiveState=active\nSubState=running\nMainPID=4242", + )?.mainPid, + ).toBe(4242); +}); + +it.layer(NodeServices.layer)("program path binding", (it) => { + it.effect("binds only normalized paths inside the selected runtime tree", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const baseDir = "/home/x/.t3"; + const bind = (programPath: string) => + BootService.bindBootServiceProgramPath(programPath, baseDir, path); + + expect(bind("/home/x/.t3/runtime/versions/1.2.3/t3")).toMatchObject({ + contained: true, + version: "1.2.3", + }); + // A sibling directory that only shares the textual prefix is not this home. + expect(bind("/home/x/.t3-escape/runtime/versions/1.2.3/t3").contained).toBe(false); + // `..` beneath the textual prefix escapes the runtime tree. + expect(bind("/home/x/.t3/runtime/versions/../../evil/t3").contained).toBe(false); + expect(bind("/other/.t3/runtime/versions/1.2.3/t3").contained).toBe(false); + // A runtime path with no exact version segment binds without a version. + expect(bind("/home/x/.t3/runtime/versions/nightly/t3")).toMatchObject({ + contained: true, + }); + expect(bind("/home/x/.t3/runtime/versions/nightly/t3").version).toBeUndefined(); + }), + ); +}); + +const SYSTEMD_SHOW_PROPERTY = + "systemctl --user show t3code.service --property=LoadState,ActiveState,SubState,MainPID,UnitFileState,ExecStart,NRestarts,Result"; + +it.layer(NodeServices.layer)("boot service status observations", (it) => { + it.effect("separates configured launcher identity from a stopped Linux manager state", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness(); + yield* service.install(); + control.active = false; + + const status = yield* service.status; + + expect(status).toMatchObject({ + schemaVersion: BootService.BOOT_SERVICE_STATUS_SCHEMA_VERSION, + supported: true, + manager: "systemd", + installed: true, + enabled: "enabled", + running: "stopped", + current: false, + installedVersion: "1.2.3", + configuredVersion: "1.2.3", + }); + // A stopped unit still has a versioned ExecStart. That is configured + // launch metadata, not a running server: no observed running identity. + expect("runningVersion" in status).toBe(false); + expect(status.observation).toMatchObject({ + state: "inactive", + subState: "dead", + configuredVersion: "1.2.3", + }); + expect(status.observation?.processId).toBeUndefined(); + expect(status.problems).toContain("service-stopped"); + expect(status.observation?.restartCount).toBe(0); + expect(status.observation?.source).toBe("systemctl --user show t3code.service"); + }), + ); + + it.effect("does not infer a running identity from a changed configured version", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { service, home, control } = yield* makeHarness(); + yield* service.install(); + control.systemdActiveState = "unknown"; + control.systemdMainPid = "0"; + control.systemdExecStartPath = path.join(home, ".t3", "runtime", "versions", "1.2.4", "t3"); + + const status = yield* service.status; + + // The configured path moved to 1.2.4 with no process proof, so the + // observed running identity stays absent and the service is not running. + expect(status.installedVersion).toBe("1.2.3"); + expect(status.configuredVersion).toBe("1.2.4"); + expect("runningVersion" in status).toBe(false); + expect(status.running).toBe("unknown"); + }), + ); + + it.effect("keeps launcher and server identities separate during a version handoff", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { service, fs, home, statePath, control } = yield* makeHarness(); + yield* service.install(); + // The launcher executable stays on 1.2.3 while the selected server child + // is 1.2.4; the manager only exposes the launcher's configured path. + yield* fs.writeFileString( + statePath, + `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.4"}`, + ); + control.systemdActiveState = "active"; + control.systemdSubState = "running"; + control.systemdMainPid = "4321"; + control.systemdExecStartPath = path.join(home, ".t3", "runtime", "versions", "1.2.3", "t3"); + + const status = yield* service.status; + + expect(status.running).toBe("running"); + expect(status.installedVersion).toBe("1.2.4"); + expect(status.configuredVersion).toBe("1.2.3"); + expect("runningVersion" in status).toBe(false); + }), + ); + + it.effect("reports linger-disabled without hiding the running manager state", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness(); + yield* service.install(); + control.linger = "no"; + + const status = yield* service.status; + + expect(status.problems).toContain("linger-disabled"); + expect(status.running).toBe("running"); + expect(status.enabled).toBe("enabled"); + }), + ); + + it.effect("does not call a Mac job running when the launch agent is not loaded", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.launchdJob = "not-loaded"; + + const status = yield* service.status; + + expect(status).toMatchObject({ + schemaVersion: BootService.BOOT_SERVICE_STATUS_SCHEMA_VERSION, + manager: "launchd", + installed: true, + running: "not-loaded", + enabled: "enabled", + }); + expect(status.observation).toMatchObject({ + reachable: true, + running: "not-loaded", + detail: "launch-agent-not-loaded", + }); + }), + ); + + it.effect("reports an unavailable GUI login domain as unknown, never healthy", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.launchdDomainPresent = false; + + const status = yield* service.status; + + expect(status).toMatchObject({ manager: "launchd", running: "unknown", enabled: "unknown" }); + expect(status.observation).toMatchObject({ + reachable: false, + running: "unknown", + detail: "gui-login-domain-unavailable", + }); + }), + ); + + it.effect("keeps a Mac stopped job distinct from a missing one and observes its version", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.launchdJob = "stopped"; + + const status = yield* service.status; + + expect(status).toMatchObject({ running: "stopped", enabled: "enabled" }); + expect(status.observation?.detail).toBeUndefined(); + expect(status.configuredVersion).toBe("1.2.3"); + expect("runningVersion" in status).toBe(false); + }), + ); + + it.effect("reports command failure, timeout and malformed output as unknown", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness(); + yield* service.install(); + + control.failCommand = SYSTEMD_SHOW_PROPERTY; + expect((yield* service.status).observation).toMatchObject({ + reachable: false, + running: "unknown", + detail: "manager-unreachable", + }); + + control.failCommand = undefined; + control.timedOutCommand = SYSTEMD_SHOW_PROPERTY; + expect((yield* service.status).observation).toMatchObject({ + running: "unknown", + detail: "manager-timeout", + }); + control.timedOutCommand = undefined; + + control.systemdShow = "not key=value output"; + expect((yield* service.status).observation).toMatchObject({ + reachable: true, + running: "unknown", + detail: "manager-output-malformed", + }); + }), + ); + + it.effect("does not let the launcher state file prove the observed running identity", () => + Effect.gen(function* () { + const { service, fs, statePath, runtime } = yield* makeHarness(); + yield* service.install(); + yield* fs.writeFileString( + statePath, + `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.4"}`, + ); + + const status = yield* service.status; + + // The state file claims 1.2.4 and the configured launcher path is 1.2.3: + // installed identity and configured-launcher identity are different + // claims, and neither is a proven running server. + expect(status.installedVersion).toBe("1.2.4"); + expect(status.configuredVersion).toBe("1.2.3"); + expect("runningVersion" in status).toBe(false); + expect(runtime.entryPath).toContain("versions/1.2.3"); + expect(status.current).toBe(false); + }), + ); + + it.effect("binds identity to the selected T3 home and never calls a foreign home current", () => + Effect.gen(function* () { + const { service, fs, home, makeService } = yield* makeHarness(); + const path = yield* Path.Path; + yield* service.install(); + const otherHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-other-home-" }); + + const other = yield* makeService(undefined, "1.2.3", path.join(otherHome, ".t3")); + const status = yield* other.status; + + expect(status.installed).toBe(true); + expect(status.installedBaseDir).toBe(path.join(home, ".t3")); + expect(status.current).toBe(false); + }), + ); + + it.effect("fails closed on Windows with unknown manager observations", () => + Effect.gen(function* () { + const { service } = yield* makeHarness("win32"); + + const status = yield* service.status; + + expect(status).toMatchObject({ + schemaVersion: BootService.BOOT_SERVICE_STATUS_SCHEMA_VERSION, + supported: false, + manager: "unsupported", + installed: false, + running: "unknown", + enabled: "unknown", + current: false, + }); + expect(status.observation).toBeUndefined(); + }), + ); + + it.effect("reports a launchctl permission refusal as unknown, never a missing domain", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.launchdDomainPresent = false; + control.launchdDomainStderr = "launchctl: Operation not permitted"; + + const status = yield* service.status; + + expect(status).toMatchObject({ manager: "launchd", running: "unknown", enabled: "unknown" }); + expect(status.observation).toMatchObject({ + reachable: true, + running: "unknown", + detail: "manager-permission-denied", + }); + }), + ); + + it.effect("reports a launchctl permission refusal on the job as unknown, not not-loaded", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.launchdJob = "permission"; + + const status = yield* service.status; + + expect(status).toMatchObject({ running: "unknown", enabled: "enabled" }); + expect(status.observation).toMatchObject({ + reachable: true, + running: "unknown", + detail: "manager-permission-denied", + }); + }), + ); + + it.effect("never promotes a launchd program from a different T3 home", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.launchdProgramPath = "/Volumes/other/.t3/runtime/versions/9.9.9/t3"; + + const status = yield* service.status; + + expect(status.running).toBe("running"); + expect(status.configuredVersion).toBeUndefined(); + expect("runningVersion" in status).toBe(false); + expect(status.observation).toMatchObject({ + reachable: true, + running: "running", + configuredProgramPath: "/Volumes/other/.t3/runtime/versions/9.9.9/t3", + detail: "configured-from-different-home", + }); + }), + ); + + it.effect("never promotes a systemd program from a different T3 home", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness(); + yield* service.install(); + control.systemdExecStartPath = "/srv/other/.t3/runtime/versions/9.9.9/t3"; + + const status = yield* service.status; + + expect(status.running).toBe("running"); + expect(status.configuredVersion).toBeUndefined(); + expect(status.observation?.detail).toBe("configured-from-different-home"); + }), + ); + + it.effect("does not bind a lexical escape beneath the base dir prefix", () => + Effect.gen(function* () { + const { service, home, control } = yield* makeHarness(); + yield* service.install(); + // Literal `..` segments under the textual prefix: kept unnormalized so the + // configured path is not mistaken for a contained runtime. + control.systemdExecStartPath = `${home}/.t3/runtime/versions/../../evil/t3`; + + const status = yield* service.status; + + expect(status.configuredVersion).toBeUndefined(); + expect(status.observation?.configuredProgramPath).toContain(".."); + expect(status.observation?.detail).toBe("configured-from-different-home"); + }), + ); + + it.effect("does not map a deactivating unit with a live process to stopped", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness(); + yield* service.install(); + control.active = false; + control.systemdActiveState = "deactivating"; + control.systemdSubState = "stop-sigterm"; + control.systemdMainPid = "4321"; + + const status = yield* service.status; + + expect(status.running).toBe("transitioning"); + expect(status.observation).toMatchObject({ + state: "deactivating", + subState: "stop-sigterm", + processId: 4321, + }); + }), + ); + + it.effect("does not call an active unit with no live process running", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness(); + yield* service.install(); + control.systemdActiveState = "active"; + control.systemdSubState = "exited"; + control.systemdMainPid = "0"; + + const status = yield* service.status; + + expect(status.running).toBe("unknown"); + expect(status.observation?.processId).toBeUndefined(); + expect(status.observation?.detail).toBe("manager-state-unknown"); + }), + ); + + it.effect("rejects a zero or malformed PID as process evidence", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness(); + yield* service.install(); + + for (const mainPid of ["0", "abc"]) { + control.systemdActiveState = "active"; + control.systemdSubState = "running"; + control.systemdMainPid = mainPid; + const status = yield* service.status; + expect(status.running).toBe("unknown"); + expect(status.observation?.processId).toBeUndefined(); + } + }), + ); + + it.effect("rejects a zero launchd PID as a live process", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.launchdJob = "running"; + control.launchdPid = "0"; + + const status = yield* service.status; + + expect(status.running).toBe("unknown"); + expect(status.observation?.processId).toBeUndefined(); + }), + ); + + it.effect("drops a malformed restart counter instead of inventing a count", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness(); + yield* service.install(); + + for (const nRestarts of ["-1", "12x", "abc"]) { + control.systemdNRestarts = nRestarts; + const status = yield* service.status; + expect(status.observation?.restartCount).toBeUndefined(); + } + control.systemdNRestarts = "7"; + expect((yield* service.status).observation?.restartCount).toBe(7); + }), + ); + + it.effect("keeps an unexpected launchctl job failure unknown, not not-loaded", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.launchdJob = "unexpected"; + + const status = yield* service.status; + + expect(status.running).toBe("unknown"); + expect(status.observation).toMatchObject({ + reachable: true, + running: "unknown", + detail: "manager-query-failed", + }); + }), + ); + + it.effect("keeps an unexpected launchctl domain failure unknown, not missing", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.launchdDomainPresent = false; + control.launchdDomainStderr = "launchctl: input/output error"; + + const status = yield* service.status; + + expect(status.observation).toMatchObject({ + reachable: true, + running: "unknown", + detail: "manager-query-failed", + }); + }), + ); + + it.effect("preserves timeout and permission behavior on launchd probes", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + + control.timedOutCommand = "launchctl print gui/501"; + expect((yield* service.status).observation).toMatchObject({ + running: "unknown", + detail: "manager-timeout", + }); + control.timedOutCommand = undefined; + + control.launchdJob = "permission"; + expect((yield* service.status).observation).toMatchObject({ + reachable: true, + running: "unknown", + detail: "manager-permission-denied", + }); + }), + ); +}); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0a57ed822af4..5ec13db433ff 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -23,6 +23,7 @@ import { ensurePinnedRuntimeInstalled, pinnedRuntimeCommand, pinnedRuntimePaths, + pinnedRuntimeVersionsDir, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; import { @@ -30,6 +31,7 @@ import { SERVICE_RESTART_PENDING_FILE, SERVICE_STATE_FILE, compareExactServiceVersions, + isExactServiceVersion, parseServiceState, serviceStateActiveVersion, serviceStateHasPendingUpdate, @@ -505,9 +507,96 @@ export type BootServiceError = | BootServiceUpdatePendingError | BootServiceDowngradeRefusedError; +/** + * Version of the additive `t3 service status --json` contract. Bump when an + * existing field changes meaning or is removed; adding optional fields does + * not require a bump. The contract is documented in + * `docs/internals/service-status.md`. + */ +export const BOOT_SERVICE_STATUS_SCHEMA_VERSION = 2; + +export type BootServiceManagerKind = "systemd" | "launchd" | "unsupported"; + +/** `unknown` is the honest answer whenever the manager did not answer. */ +export type BootServiceEnabledState = "enabled" | "disabled" | "unknown"; + +/** + * `transitioning` covers manager states that are neither a live job nor a + * proven stop (systemd `activating`/`deactivating`). A transitional unit is + * never reported as stopped. + */ +export type BootServiceRunningState = + | "running" + | "stopped" + | "transitioning" + | "not-loaded" + | "unknown"; + +/** + * A bounded, read-only observation of the service manager. It is deliberately + * narrower than application health: `running` only means the manager reports + * the job's main process alive. A state file, a launchd `last exit code` of 0 + * or a `current` identity never substitute for a live manager answer. + */ +export interface BootServiceManagerObservation { + readonly manager: "systemd" | "launchd"; + /** The command this observation came from. */ + readonly source: string; + readonly observedAt: string; + /** Whether the manager control plane answered at all. */ + readonly reachable: boolean; + readonly enabled: BootServiceEnabledState; + readonly running: BootServiceRunningState; + /** Raw manager activity token, preserved verbatim (systemd `ActiveState`, launchd `state`). */ + readonly state?: string; + /** Raw manager sub-state when the manager exposes one (systemd `SubState`). */ + readonly subState?: string; + /** The manager's main process id, only when it is a valid positive integer. */ + readonly processId?: number; + /** + * The program path the manager is *configured* to launch (systemd + * `ExecStart`, launchd `program`). This is configuration, not proof of the + * running server: T3 keeps its launcher executable while it swaps the server + * child during an update, so the configured launcher and the running server + * can be different versions. It is reported whether or not it binds to the + * selected base dir; only a bound path yields `configuredVersion`. + */ + readonly configuredProgramPath?: string; + /** + * Version parsed from `configuredProgramPath` when that path is inside the + * selected base dir's runtime tree. This names the configured launcher, not + * the running server; a different-home path never produces it. + */ + readonly configuredVersion?: string; + /** + * `systemd NRestarts`: monotonic since the unit last (re)started. launchd has + * no equivalent, so this is never set on macOS; launchd throttling is not a + * finite restart budget and must not be presented as one. + */ + readonly restartCount?: number; + /** The manager's own last-result token, when it reports one (systemd `Result`, launchd `last exit code`). */ + readonly lastResult?: string; + /** Why a value is unknown. Sanitized: never contains host secrets or process environments. */ + readonly detail?: string; +} + export interface BootServiceStatus { + readonly schemaVersion: number; readonly supported: boolean; + readonly manager: BootServiceManagerKind; readonly installed: boolean; + /** + * Manager-reported registration state. `unknown` whenever the manager could + * not be reached, timed out or returned output this CLI cannot parse. + */ + readonly enabled: BootServiceEnabledState; + /** Manager-observed job state; `unknown` is never healthy. */ + readonly running: BootServiceRunningState; + /** + * Identity only: unit/plist matches this CLI, pinned runtime is present, the + * state file names this version and no update is pending. `current: true` + * says nothing about whether the server answers or is even running. + */ readonly current: boolean; readonly installedVersion?: string; /** @@ -517,9 +606,181 @@ export interface BootServiceStatus { * server of the machine it ran on. */ readonly installedBaseDir?: string; + /** + * Version of the launch program the manager is configured to run, when the + * manager exposes that path and it binds to the selected base dir. This is + * configuration, not proof of the running server: T3 retains its launcher + * executable while replacing the server child, so the configured launcher + * and the running server can differ. There is deliberately no observed + * running-server version here until a bounded probe can prove one. + */ + readonly configuredVersion?: string; + readonly observation?: BootServiceManagerObservation; readonly problems?: ReadonlyArray; readonly unitPath: string; readonly logPath: string; + readonly observedAt: string; +} + +/** Extracts an exact release version from a manager-reported runtime path. */ +export function bootServiceVersionFromProgramPath(programPath: string): string | undefined { + const version = /[\\/]runtime[\\/]versions[\\/]([^\\/]+)[\\/]/.exec(programPath)?.[1]; + return version !== undefined && isExactServiceVersion(version) ? version : undefined; +} + +export interface BootServiceProgramBinding { + /** Whether the normalized program path really lives under the selected base dir's runtime tree. */ + readonly contained: boolean; + /** Version parsed from the contained path's first runtime-tree segment, when exact. */ + readonly version?: string; +} + +/** + * A manager-reported program only identifies *this* installation when it lives + * under the selected T3 home's `runtime/versions` tree. The check normalizes + * both paths through the platform `Path` helpers and rejects anything whose + * relative path escapes that tree, so a lexical prefix or a `..` segment + * under it is not mistaken for containment. A stale unit, or a home other than + * the one this CLI is bound to, stays unbound rather than being promoted to + * this service's identity. + */ +export function bindBootServiceProgramPath( + programPath: string, + baseDir: string, + path: Path.Path, +): BootServiceProgramBinding { + const versionsDir = path.resolve(pinnedRuntimeVersionsDir(path, baseDir)); + const relative = path.relative(versionsDir, path.resolve(programPath)); + const escaped = + relative === "" || + path.isAbsolute(relative) || + relative === ".." || + relative.startsWith(`..${path.sep}`); + if (escaped) return { contained: false }; + const [segment = ""] = relative.split(path.sep); + return { + contained: true, + ...(isExactServiceVersion(segment) ? { version: segment } : {}), + }; +} + +/** + * `launchctl` stderr is not a stable format either, so only a coarse token + * match is used. A permission refusal is a distinct observation: the manager + * control plane exists but this user may not inspect it. It must never be + * folded into "missing domain" or "job not loaded" and must never be healthy. + */ +export function launchdPermissionDenied(stderr: string): boolean { + return /\b(operation not permitted|permission denied|not privileged|eperm)\b/i.test(stderr); +} + +/** + * The established "this job/domain does not exist" outcomes. Any other nonzero + * launchctl failure is an unexpected query error, not evidence of absence. + */ +export function launchdNotFound(stderr: string): boolean { + return /\b(could not find|not find|no such (?:process|service|domain)|service not found|domain not found)\b/i.test( + stderr, + ); +} + +export interface BootServiceSystemdProperties { + readonly loadState: string; + readonly activeState: string; + readonly subState: string; + readonly unitFileState: string; + readonly execStart: string; + readonly mainPid?: number; + readonly nRestarts?: number; + readonly result?: string; +} + +/** + * Reads a whole field as a safe integer. A numeric prefix with trailing junk + * (`12junk`), a non-decimal spelling, or a value outside `Number.MAX_SAFE_INTEGER` + * stays unknown rather than being truncated or rounded into a misleading + * number. Sign and positivity are decided by the caller's domain rules. + */ +function parseWholeSafeInteger(text: string | undefined): number | undefined { + if (text === undefined) return undefined; + const trimmed = text.trim(); + if (!/^-?\d+$/.test(trimmed)) return undefined; + const value = Number.parseInt(trimmed, 10); + return Number.isSafeInteger(value) ? value : undefined; +} + +/** + * Parses `systemctl --user show` key=value output. Missing LoadState or + * ActiveState means the answer is unusable and must stay unknown rather than + * defaulting to a healthy value. A malformed or nonpositive `MainPID` and a + * malformed or negative `NRestarts` are dropped rather than coerced, including + * values too large to represent exactly as a safe integer. + */ +export function parseSystemdShow(stdout: string): BootServiceSystemdProperties | undefined { + const values = new Map(); + for (const line of stdout.split("\n")) { + const separator = line.indexOf("="); + if (separator <= 0) continue; + values.set(line.slice(0, separator), line.slice(separator + 1)); + } + const loadState = values.get("LoadState"); + const activeState = values.get("ActiveState"); + if (loadState === undefined || activeState === undefined) return undefined; + const restartValue = parseWholeSafeInteger(values.get("NRestarts")); + const nRestarts = restartValue !== undefined && restartValue >= 0 ? restartValue : undefined; + const mainPidValue = parseWholeSafeInteger(values.get("MainPID")); + const mainPid = mainPidValue !== undefined && mainPidValue > 0 ? mainPidValue : undefined; + const result = values.get("Result"); + return { + loadState, + activeState, + subState: values.get("SubState") ?? "", + unitFileState: values.get("UnitFileState") ?? "", + execStart: values.get("ExecStart") ?? "", + ...(mainPid === undefined ? {} : { mainPid }), + ...(nRestarts === undefined ? {} : { nRestarts }), + ...(result !== undefined && result !== "" ? { result } : {}), + }; +} + +export interface BootServiceLaunchdPrint { + readonly state?: string; + readonly pid?: number; + readonly program?: string; + readonly lastExitCode?: number; +} + +/** + * `launchctl print` has no stable machine format, so only a few anchored tokens + * are read. A response with none of them is malformed and stays unknown. A + * `pid` is only observed when the whole field is a positive safe integer; a + * zero, a numeric prefix with trailing junk, or an unrepresentable value is not + * a live process. `last exit code` is read as a whole safe signed integer. + */ +export function parseLaunchdPrint(stdout: string): BootServiceLaunchdPrint | undefined { + if (!/(?:^|\n)[ \t]*(?:state|pid|program|last exit code)[ \t]*=/.test(stdout)) return undefined; + const state = /(?:^|\n)[ \t]*state[ \t]*=[ \t]*([^\n]*)/.exec(stdout)?.[1]?.trim(); + const pidText = /(?:^|\n)[ \t]*pid[ \t]*=[ \t]*([^\n]*)/.exec(stdout)?.[1]; + const program = /(?:^|\n)[ \t]*program[ \t]*=[ \t]*([^\n]*)/.exec(stdout)?.[1]?.trim(); + const lastExitText = /(?:^|\n)[ \t]*last exit code[ \t]*=[ \t]*([^\n]*)/.exec(stdout)?.[1]; + const pidValue = parseWholeSafeInteger(pidText); + const pid = pidValue !== undefined && pidValue > 0 ? pidValue : undefined; + const lastExitCode = parseWholeSafeInteger(lastExitText); + return { + ...(state === undefined || state === "" ? {} : { state }), + ...(pid === undefined ? {} : { pid }), + ...(program === undefined || program === "" ? {} : { program }), + ...(lastExitCode === undefined ? {} : { lastExitCode }), + }; +} + +/** Reads enabled/disabled out of `launchctl print-disabled gui/`. */ +export function parseLaunchdDisabled(stdout: string, label: string): boolean | undefined { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`(?:^|\\n)[ \\t]*"?${escaped}"?[ \\t]*=>[ \\t]*(true|false)`).exec( + stdout, + ); + return match === null ? undefined : match[1] === "true"; } export class BootService extends Context.Service< @@ -726,6 +987,294 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { return problems; }); + /** + * A single bounded, read-only manager probe. Timeouts are the caller's to + * interpret: a manager that does not answer within the bound is unknown, not + * stopped and certainly not healthy. + */ + const probeManager = (command: string, args: ReadonlyArray) => + runner + .run({ + command, + args, + timeout: Duration.seconds(5), + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + + const unknownObservation = (input: { + readonly manager: "systemd" | "launchd"; + readonly source: string; + readonly observedAt: string; + readonly detail: string; + readonly enabled?: BootServiceEnabledState; + readonly reachable?: boolean; + }): BootServiceManagerObservation => ({ + manager: input.manager, + source: input.source, + observedAt: input.observedAt, + reachable: input.reachable ?? false, + enabled: input.enabled ?? "unknown", + running: "unknown", + detail: input.detail, + }); + + const observeSystemd = Effect.fn("cloud.boot_service.observe_systemd")(function* () { + const source = `systemctl --user show ${BOOT_SERVICE_UNIT_FILE}`; + const observedAt = DateTime.formatIso(yield* DateTime.now); + const result = yield* probeManager("systemctl", [ + "--user", + "show", + BOOT_SERVICE_UNIT_FILE, + "--property=LoadState,ActiveState,SubState,MainPID,UnitFileState,ExecStart,NRestarts,Result", + ]); + if (Option.isNone(result)) { + return unknownObservation({ + manager: "systemd", + source, + observedAt, + detail: "manager-unreachable", + }); + } + if (result.value.timedOut) { + return unknownObservation({ + manager: "systemd", + source, + observedAt, + detail: "manager-timeout", + }); + } + if (result.value.code !== 0) { + return unknownObservation({ + manager: "systemd", + source, + observedAt, + detail: "manager-unreachable", + }); + } + const parsed = parseSystemdShow(result.value.stdout); + if (parsed === undefined) { + return unknownObservation({ + manager: "systemd", + source, + observedAt, + detail: "manager-output-malformed", + reachable: true, + }); + } + const running: BootServiceRunningState = + parsed.loadState === "not-found" + ? "not-loaded" + : parsed.activeState === "activating" || parsed.activeState === "deactivating" + ? "transitioning" + : parsed.activeState === "active" || parsed.activeState === "reloading" + ? parsed.subState === "running" && parsed.mainPid !== undefined + ? "running" + : "unknown" + : parsed.activeState === "inactive" || parsed.activeState === "failed" + ? "stopped" + : "unknown"; + const enabled: BootServiceEnabledState = + parsed.unitFileState === "enabled" + ? "enabled" + : parsed.unitFileState === "disabled" || + parsed.unitFileState === "masked" || + parsed.unitFileState === "not-found" + ? "disabled" + : "unknown"; + const programPath = /path=([^;]+?)\s*(?:;|})/.exec(parsed.execStart)?.[1]; + const binding = + programPath === undefined + ? undefined + : bindBootServiceProgramPath(programPath, input.baseDir, path); + const detail = + binding !== undefined && !binding.contained + ? "configured-from-different-home" + : running === "unknown" + ? "manager-state-unknown" + : undefined; + return { + manager: "systemd", + source, + observedAt, + reachable: true, + enabled, + running, + ...(parsed.activeState === "" ? {} : { state: parsed.activeState }), + ...(parsed.subState === "" ? {} : { subState: parsed.subState }), + ...(parsed.mainPid === undefined ? {} : { processId: parsed.mainPid }), + ...(programPath === undefined ? {} : { configuredProgramPath: programPath }), + ...(binding?.version === undefined ? {} : { configuredVersion: binding.version }), + ...(parsed.nRestarts === undefined ? {} : { restartCount: parsed.nRestarts }), + ...(parsed.result === undefined ? {} : { lastResult: parsed.result }), + ...(detail === undefined ? {} : { detail }), + } satisfies BootServiceManagerObservation; + }); + + const observeLaunchd = Effect.fn("cloud.boot_service.observe_launchd")(function* () { + const observedAt = DateTime.formatIso(yield* DateTime.now); + if (uid === undefined) { + // The selected user is unknown, so there is no `gui/` domain to bind + // to. Guessing one (e.g. `gui/0`) would observe the wrong user. + return unknownObservation({ + manager: "launchd", + source: `launchctl print gui//${BOOT_SERVICE_LAUNCHD_LABEL}`, + observedAt, + detail: "manager-user-unknown", + }); + } + const domainTarget = `gui/${String(uid)}`; + const domainSource = `launchctl print ${domainTarget}`; + const jobSource = `launchctl print ${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; + const domain = yield* probeManager("launchctl", ["print", domainTarget]); + if (Option.isNone(domain)) { + return unknownObservation({ + manager: "launchd", + source: domainSource, + observedAt, + detail: "manager-unreachable", + }); + } + if (domain.value.timedOut) { + return unknownObservation({ + manager: "launchd", + source: domainSource, + observedAt, + detail: "manager-timeout", + }); + } + if (domain.value.code !== 0 || domain.value.stdout.trim() === "") { + if (launchdPermissionDenied(domain.value.stderr)) { + return unknownObservation({ + manager: "launchd", + source: domainSource, + observedAt, + detail: "manager-permission-denied", + reachable: true, + }); + } + // A zero-code answer, or an explicit "could not find", is an absent domain. + // Any other nonzero result is an unexpected query failure and is not + // evidence that the domain is missing. + const absentDomain = domain.value.code === 0 || launchdNotFound(domain.value.stderr); + return unknownObservation({ + manager: "launchd", + source: domainSource, + observedAt, + detail: absentDomain ? "gui-login-domain-unavailable" : "manager-query-failed", + reachable: !absentDomain, + }); + } + const disabledResult = yield* probeManager("launchctl", ["print-disabled", domainTarget]); + const disabled = + Option.isSome(disabledResult) && + !disabledResult.value.timedOut && + disabledResult.value.code === 0 + ? parseLaunchdDisabled(disabledResult.value.stdout, BOOT_SERVICE_LAUNCHD_LABEL) + : undefined; + const enabled: BootServiceEnabledState = + disabled === undefined ? "unknown" : disabled ? "disabled" : "enabled"; + const job = yield* probeManager("launchctl", [ + "print", + `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`, + ]); + if (Option.isNone(job)) { + return unknownObservation({ + manager: "launchd", + source: jobSource, + observedAt, + detail: "manager-unreachable", + enabled, + reachable: true, + }); + } + if (job.value.timedOut) { + return unknownObservation({ + manager: "launchd", + source: jobSource, + observedAt, + detail: "manager-timeout", + enabled, + reachable: true, + }); + } + if (job.value.code !== 0) { + if (launchdPermissionDenied(job.value.stderr)) { + return unknownObservation({ + manager: "launchd", + source: jobSource, + observedAt, + detail: "manager-permission-denied", + enabled, + reachable: true, + }); + } + // A genuine "could not find service" is a not-loaded job. Any other + // nonzero result is an unexpected query failure: unknown, not absence. + return launchdNotFound(job.value.stderr) + ? ({ + manager: "launchd", + source: jobSource, + observedAt, + reachable: true, + enabled, + running: "not-loaded", + detail: "launch-agent-not-loaded", + } satisfies BootServiceManagerObservation) + : unknownObservation({ + manager: "launchd", + source: jobSource, + observedAt, + detail: "manager-query-failed", + enabled, + reachable: true, + }); + } + const parsed = parseLaunchdPrint(job.value.stdout); + if (parsed === undefined) { + return unknownObservation({ + manager: "launchd", + source: jobSource, + observedAt, + detail: "manager-output-malformed", + enabled, + reachable: true, + }); + } + const running: BootServiceRunningState = + parsed.state === "running" && parsed.pid !== undefined + ? "running" + : parsed.state === "not running" || parsed.state === "waiting" || parsed.state === "exited" + ? "stopped" + : "unknown"; + const binding = + parsed.program === undefined + ? undefined + : bindBootServiceProgramPath(parsed.program, input.baseDir, path); + const detail = + binding !== undefined && !binding.contained + ? "configured-from-different-home" + : running === "unknown" + ? "manager-state-unknown" + : undefined; + return { + manager: "launchd", + source: jobSource, + observedAt, + reachable: true, + enabled, + running, + ...(parsed.state === undefined ? {} : { state: parsed.state }), + ...(parsed.pid === undefined ? {} : { processId: parsed.pid }), + ...(parsed.program === undefined ? {} : { configuredProgramPath: parsed.program }), + ...(binding?.version === undefined ? {} : { configuredVersion: binding.version }), + ...(parsed.lastExitCode === undefined ? {} : { lastResult: String(parsed.lastExitCode) }), + ...(detail === undefined ? {} : { detail }), + } satisfies BootServiceManagerObservation; + }); + + const observeManager = detectedManager?.kind === "systemd" ? observeSystemd : observeLaunchd; + const requireSystemdPrerequisites = Effect.gen(function* () { const problems = yield* readSystemdProblems(false); const unavailable = problems.find((problem) => problem !== "linger-disabled"); @@ -942,11 +1491,37 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); const status: BootService["Service"]["status"] = Effect.gen(function* () { + const observedAt = DateTime.formatIso(yield* DateTime.now); if (detectedManager === undefined) { - return { supported: false, installed: false, current: false, unitPath, logPath }; + return { + schemaVersion: BOOT_SERVICE_STATUS_SCHEMA_VERSION, + supported: false, + manager: "unsupported", + installed: false, + enabled: "unknown", + running: "unknown", + current: false, + unitPath, + logPath, + observedAt, + } satisfies BootServiceStatus; } if (!(yield* fs.exists(unitPath))) { - return { supported: true, installed: false, current: false, unitPath, logPath }; + // No unit file is the only claim made here. The manager is not probed for + // an unregistered service, and an absent file is not evidence about a + // foreign registration that happens to share the fixed unit name. + return { + schemaVersion: BOOT_SERVICE_STATUS_SCHEMA_VERSION, + supported: true, + manager: detectedManager.kind, + installed: false, + enabled: "unknown", + running: "unknown", + current: false, + unitPath, + logPath, + observedAt, + } satisfies BootServiceStatus; } const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ fs.readFileString(unitPath), @@ -963,14 +1538,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { detectedManager.kind === "launchd" ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") : contents; + // Existing problem codes and their `current` effect are preserved; the + // richer manager observation below is additive and never rewrites them. const problems: BootServiceProblem[] = detectedManager.kind === "systemd" ? [...(yield* readSystemdProblems(true))] : []; if (yield* fs.exists(restartPendingPath)) problems.push("restart-pending"); + const observation = yield* observeManager(); return { + schemaVersion: BOOT_SERVICE_STATUS_SCHEMA_VERSION, supported: true, + manager: detectedManager.kind, installed: true, + enabled: observation.enabled, + running: observation.running, ...(installedVersion === undefined ? {} : { installedVersion }), ...(installedBaseDir === undefined ? {} : { installedBaseDir }), + ...(observation.configuredVersion === undefined + ? {} + : { configuredVersion: observation.configuredVersion }), + observation, problems, current: problems.length === 0 && @@ -982,6 +1568,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { state?.update?.status !== "pending", unitPath, logPath, + observedAt, }; }).pipe( Effect.mapError((cause) => new BootServiceInstallError({ cause })), diff --git a/docs/internals/service-status.md b/docs/internals/service-status.md new file mode 100644 index 000000000000..6418f7663c54 --- /dev/null +++ b/docs/internals/service-status.md @@ -0,0 +1,110 @@ +# Background service status contract + +`t3 service status --json` publishes a small, versioned projection of the native +service manager. It exists so the ENV inventory/doctor can consume service +state without scraping human output and without inventing an application +health check. The implementation is +[BootService.status](https://github.com/nullStack65/t3code/blob/main/apps/server/src/cloud/bootService.ts) +and [formatServiceStatus](https://github.com/nullStack65/t3code/blob/main/apps/server/src/cli/service.ts). + +## Identity is not health + +The contract keeps these observations separate, and each one may be `unknown`: + +| Field | Meaning | Not a claim about | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `supported` / `manager` | This host can run a service, and which manager. | Whether one is installed. | +| `installed` | The unit/plist file for this fixed per-user name exists. | Whether the manager has loaded it. | +| `enabled` | Manager's own registration state (`systemd UnitFileState`, launchd `print-disabled`). | Running state. | +| `running` | Manager-observed job state: `running`, `stopped`, `transitioning`, `not-loaded`, `unknown`. | The server answering, or the expected artifact. | +| `current` | Identity only: unit/plist matches this CLI, the pinned runtime is present, the launcher state file names this version and no update is pending. | Running, enabled, or healthy. | +| `installedVersion` | Version recorded in the launcher state file. | The process that is actually running. | +| `configuredVersion` | Version parsed from the program path the manager is _configured_ to launch (launchd `program`, systemd `ExecStart`), only when that path normalizes inside the selected base directory's `runtime/versions` tree. | That the server answers, is authenticated, or that a foreign-home path is this service; that the running server is this version. | + +A launcher state file records intent; the manager records what it is configured +to launch; the server answering is a third, separate gate. `current: true` with +`running: "not-loaded"` is a truthful combination, not a contradiction. + +There is deliberately no observed running-server version. The manager only +exposes the _configured_ launch program, and T3 retains its launcher executable +while replacing the server child during an update, so the configured launcher +and the running server can be different versions. `configuredVersion` therefore +names the configured launcher/installation, never a proven running server; when +a live process is needed but cannot be tied to the target, the identity stays +absent. `observation` carries the raw `state` (systemd `ActiveState`, launchd +`state`), systemd `subState`, and the manager's `processId` when it is a +positive integer, so a consumer can tell a transition or an exited unit from a +live one. + +`observation` also carries provenance for the fields above: `manager`, the exact +`source` command, `observedAt`, `reachable`, and a sanitized `detail` when a +value is `unknown` or diverges. A manager that is missing, inaccessible, +permission-refused, timed out, or returned unparseable output is +`reachable: false` or `running: "unknown"`; none of those is treated as +healthy. An unexpected nonzero `launchctl` failure stays `unknown` +(`manager-query-failed`); only an established "could not find" outcome is +absence. A different-home program never produces a `configuredVersion`. Local +paths are included; credentials, process environments and pairing URLs never +are. + +## Versioning + +`BOOT_SERVICE_STATUS_SCHEMA_VERSION` is in +[bootService.ts](https://github.com/nullStack65/t3code/blob/main/apps/server/src/cloud/bootService.ts). +Adding an optional field does not bump it; changing a field's meaning or +removing one does. Consumers should ignore unknown fields and treat any +`unknown` as unknown rather than a default. + +## macOS launchctl observations + +`launchctl print` is not a stable machine format, so only a few anchored tokens +are read and the result of a malformed answer stays `unknown`. The probe is +bounded and read-only: + +- `launchctl print gui/` must answer with a domain, or there is no GUI + login domain to inspect (`gui-login-domain-unavailable`). A LaunchAgent is a + login-session service; a missing domain is not a missing service. With no + selected user (`uid` unknown) the probe does not guess a domain + (`manager-user-unknown`). +- `launchctl print gui//