From c2399b7d48be886528546cce86fda8df6f84376f Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Sun, 27 Sep 2026 00:28:47 -0400 Subject: [PATCH 1/3] feat(service): publish versioned native service status --json Add schemaVersion/manager/enabled/running/observation to BootServiceStatus and a --json mode to 't3 service status', reusing BootService and keeping human output compatible. Manager observation is bounded and read-only: systemd --user show and launchctl print/print-disabled, with distinct missing-domain, not-loaded, permission, timeout and malformed outcomes. Unknown stays unknown; a state file or current identity is never health. WIP: focused tests pass; typecheck/lint and review hardening pending. --- apps/server/src/cli/service.test.ts | 57 +++ apps/server/src/cli/service.ts | 76 +++- apps/server/src/cloud/bootService.test.ts | 411 ++++++++++++++++++- apps/server/src/cloud/bootService.ts | 477 +++++++++++++++++++++- docs/internals/service-status.md | 80 ++++ docs/user/background-service.md | 8 +- 6 files changed, 1098 insertions(+), 11 deletions(-) create mode 100644 docs/internals/service-status.md diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 74c2489e7b12..64c70e2bc03d 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -23,11 +23,16 @@ 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, 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 +100,58 @@ 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", + runningVersion: "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.manager).toBe("systemd"); + expect(parsed.running).toBe("running"); + expect(parsed.cliVersion).toBe("0.0.29"); + expect(parsed.unitPath).toBe(status.unitPath); + expect((parsed.observation as Record).runningVersion).toBe("0.0.29"); +}); + +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 · t3@0.0.29"); + 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:"); +}); + +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"); +}); + 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..afa7307a1c12 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,76 @@ 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 === "not-loaded" + ? "not loaded" + : "unknown"; + const identity = + observation.runningVersion === undefined ? "" : ` · t3@${observation.runningVersion}`; + return [ + ` Manager: ${observation.manager} · running ${running}${identity}`, + ` Enabled: ${observation.enabled}`, + ...(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 +266,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..b5153ca66c26 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -150,15 +150,33 @@ 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; + systemdExecStartPath: string | undefined; + launchdDomainPresent: boolean; + launchdDomainStderr: string; + launchdJob: "running" | "stopped" | "not-loaded" | "malformed" | "permission"; + launchdJobStderr: string; + launchdProgramPath: string | undefined; + launchdDisabled: boolean; } = { failCommand: undefined, + timedOutCommand: undefined, linger: "yes", enabled: true, active: true, + systemdShow: undefined, + systemdExecStartPath: undefined, + launchdDomainPresent: true, + launchdDomainStderr: "", + launchdJob: "running", + launchdJobStderr: "Operation not permitted", + launchdProgramPath: undefined, + launchdDisabled: false, }; const runner = ProcessRunner.ProcessRunner.of({ run: Effect.fn("test.run_boot_service_command")(function* ( @@ -167,11 +185,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 +200,94 @@ 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.active ? "active" : "inactive"}`, + `SubState=${control.active ? "running" : "dead"}`, + `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=0", + "Result=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); + if (control.launchdJob === "permission") return status("", 1, control.launchdJobStderr); + if (control.launchdJob === "malformed") return ok("this is not launchctl output\n"); + const state = + control.launchdJob === "running" + ? "state = running\n\n\tpid = 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 +356,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 +944,295 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); }); + +it("parses only well-formed manager output and exact versions", () => { + expect(BootService.parseSystemdShow("no anchors here")).toBeUndefined(); + expect(BootService.parseSystemdShow("LoadState=loaded\nActiveState=active")).toMatchObject({ + loadState: "loaded", + activeState: "active", + }); + 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.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.bootServiceProgramInBaseDir( + "/home/x/.t3/runtime/versions/1.2.3/t3", + "/home/x/.t3", + "/", + ), + ).toBe(true); + expect( + BootService.bootServiceProgramInBaseDir( + "/other/.t3/runtime/versions/1.2.3/t3", + "/home/x/.t3", + "/", + ), + ).toBe(false); +}); + +const SYSTEMD_SHOW_PROPERTY = + "systemctl --user show t3code.service --property=LoadState,ActiveState,SubState,UnitFileState,ExecStart,NRestarts,Result"; + +it.layer(NodeServices.layer)("boot service status observations", (it) => { + it.effect("separates 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", + runningVersion: "1.2.3", + }); + expect(status.problems).toContain("service-stopped"); + expect(status.observation?.restartCount).toBe(0); + expect(status.observation?.source).toBe("systemctl --user show t3code.service"); + }), + ); + + 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.runningVersion).toBe("1.2.3"); + }), + ); + + 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 running artifact", () => + 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 manager reports the installed + // runtime path at 1.2.3: installed/current identity and observed running + // identity are different claims. + expect(status.installedVersion).toBe("1.2.4"); + expect(status.runningVersion).toBe("1.2.3"); + 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("does not publish a running version 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.runningVersion).toBeUndefined(); + expect(status.observation).toMatchObject({ + reachable: true, + running: "running", + detail: "running-from-different-home", + }); + }), + ); + + it.effect("does not publish a running version when systemd points at a different 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.runningVersion).toBeUndefined(); + expect(status.observation?.detail).toBe("running-from-different-home"); + }), + ); +}); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0a57ed822af4..61b048cacf8d 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -30,6 +30,7 @@ import { SERVICE_RESTART_PENDING_FILE, SERVICE_STATE_FILE, compareExactServiceVersions, + isExactServiceVersion, parseServiceState, serviceStateActiveVersion, serviceStateHasPendingUpdate, @@ -505,9 +506,72 @@ 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 = 1; + +export type BootServiceManagerKind = "systemd" | "launchd" | "unsupported"; + +/** `unknown` is the honest answer whenever the manager did not answer. */ +export type BootServiceEnabledState = "enabled" | "disabled" | "unknown"; + +export type BootServiceRunningState = "running" | "stopped" | "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; + /** + * Version parsed from the artifact the manager reports as launched. Only set + * when the manager itself exposes the launched program path (launchd + * `program`, systemd `ExecStart`). Reading the launcher state file never + * sets this: that file records intent, not the running process. + */ + readonly runningVersion?: 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 +581,118 @@ export interface BootServiceStatus { * server of the machine it ran on. */ readonly installedBaseDir?: string; + /** Version parsed from the manager's launched program path, when exposed. */ + readonly runningVersion?: 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; +} + +/** + * A manager-reported program only identifies *this* installation when it lives + * under the selected T3 home's runtime tree. A stale unit, or a home other than + * the one this CLI is bound to, is divergence to report, not a version of this + * service. + */ +export function bootServiceProgramInBaseDir( + programPath: string, + baseDir: string, + separator: string, +): boolean { + return programPath.startsWith(`${baseDir}${separator}runtime${separator}versions${separator}`); +} + +/** + * `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); +} + +export interface BootServiceSystemdProperties { + readonly loadState: string; + readonly activeState: string; + readonly subState: string; + readonly unitFileState: string; + readonly execStart: string; + readonly nRestarts?: number; + readonly result?: string; +} + +/** + * 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. + */ +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 restartText = values.get("NRestarts"); + const nRestarts = restartText === undefined ? undefined : Number.parseInt(restartText, 10); + const result = values.get("Result"); + return { + loadState, + activeState, + subState: values.get("SubState") ?? "", + unitFileState: values.get("UnitFileState") ?? "", + execStart: values.get("ExecStart") ?? "", + ...(nRestarts !== undefined && Number.isFinite(nRestarts) ? { 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. + */ +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]*(\d+)/.exec(stdout)?.[1]; + const program = /(?:^|\n)[ \t]*program[ \t]*=[ \t]*([^\n]*)/.exec(stdout)?.[1]?.trim(); + const lastExitText = /(?:^|\n)[ \t]*last exit code[ \t]*=[ \t]*(-?\d+)/.exec(stdout)?.[1]; + const pid = pidText === undefined ? undefined : Number.parseInt(pidText, 10); + const lastExitCode = lastExitText === undefined ? undefined : Number.parseInt(lastExitText, 10); + 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 +899,268 @@ 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,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 === "active" || parsed.activeState === "reloading" + ? "running" + : parsed.activeState === "inactive" || + parsed.activeState === "failed" || + parsed.activeState === "deactivating" + ? "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 programInBaseDir = + programPath === undefined || + bootServiceProgramInBaseDir(programPath, input.baseDir, path.sep); + const runningVersion = + programPath === undefined || !programInBaseDir + ? undefined + : bootServiceVersionFromProgramPath(programPath); + const detail = + programPath !== undefined && !programInBaseDir + ? "running-from-different-home" + : running === "unknown" + ? "manager-state-unknown" + : undefined; + return { + manager: "systemd", + source, + observedAt, + reachable: true, + enabled, + running, + ...(runningVersion === undefined ? {} : { runningVersion }), + ...(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() === "") { + const permission = launchdPermissionDenied(domain.value.stderr); + return unknownObservation({ + manager: "launchd", + source: domainSource, + observedAt, + detail: permission ? "manager-permission-denied" : "gui-login-domain-unavailable", + reachable: permission, + }); + } + 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, + }); + } + return { + manager: "launchd", + source: jobSource, + observedAt, + reachable: true, + enabled, + running: "not-loaded", + detail: "launch-agent-not-loaded", + } satisfies BootServiceManagerObservation; + } + 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 programInBaseDir = + parsed.program === undefined || + bootServiceProgramInBaseDir(parsed.program, input.baseDir, path.sep); + const runningVersion = + parsed.program === undefined || !programInBaseDir + ? undefined + : bootServiceVersionFromProgramPath(parsed.program); + const detail = + parsed.program !== undefined && !programInBaseDir + ? "running-from-different-home" + : running === "unknown" + ? "manager-state-unknown" + : undefined; + return { + manager: "launchd", + source: jobSource, + observedAt, + reachable: true, + enabled, + running, + ...(runningVersion === undefined ? {} : { runningVersion }), + ...(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 +1377,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 +1424,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.runningVersion === undefined + ? {} + : { runningVersion: observation.runningVersion }), + observation, problems, current: problems.length === 0 && @@ -982,6 +1454,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..8108852977ed --- /dev/null +++ b/docs/internals/service-status.md @@ -0,0 +1,80 @@ +# 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`, `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. | +| `runningVersion` | Version parsed from the program path the manager reports as launched (launchd `program`, systemd `ExecStart`), only when that path is inside the selected base directory. | That the server answers, is authenticated, or that a foreign-home path is this service. | + +A launcher state file records intent; the manager records what it launched; the +server answering is a third, separate gate. `current: true` with +`running: "not-loaded"` is a truthful combination, not a contradiction. + +`observation` 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, and a different-home program never produces a `runningVersion`. 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 and be non-empty, 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//