From df092c477d4246e5a467e0f78f2f1985cac6a75b Mon Sep 17 00:00:00 2001 From: macodev00 Date: Thu, 24 Sep 2026 08:56:09 +0000 Subject: [PATCH] fix(cli): report the running server version from t3 triage npx t3 triage was labeling the CLI package version as the installed app version. Record that value as the triage CLI version, and when server-runtime.json still names the live server, read serverVersion from the environment descriptor. A reused pid, a corrupt origin, or a body that never finishes stays unavailable or not-running so triage still writes its context. Co-authored-by: maco --- .github/triage/PLAYBOOK.md | 30 +++- apps/server/src/cli/triage.ts | 135 +++++++++++++++-- apps/server/src/cli/triagePrompt.test.ts | 101 ++++++++++++- apps/server/src/cli/triagePrompt.ts | 50 +++++-- .../src/cli/triageServerVersion.test.ts | 141 ++++++++++++++++++ apps/server/src/cli/triageServerVersion.ts | 113 ++++++++++++++ 6 files changed, 537 insertions(+), 33 deletions(-) create mode 100644 apps/server/src/cli/triageServerVersion.test.ts create mode 100644 apps/server/src/cli/triageServerVersion.ts diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 39bf3ea01052..efc3f701ed16 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -18,9 +18,14 @@ valuable thing you can extract from this conversation. ## 2. Read the machine facts -Read the triage context file before investigating. It tells you the installed -version, the OS, whether the server process is currently running, and the exact -paths for state, logs, and the database. +Read the triage context file before investigating. It tells you the OS, whether +the server process is currently running, and the exact paths for state, logs, +and the database. + +`Installed version` (older CLIs) and `Triage CLI version` (newer CLIs) name the +`t3` binary that wrote the context file. That is not the build the bug happened +on. `npx t3 triage` is whichever `t3` npm resolved, and it can be older than the +desktop app or background service. ## 3. Check for a newer playbook @@ -30,8 +35,21 @@ instead of this one. The user may be on an old release with an old copy. ## 4. Get the source -Clone the repo at the tag matching the user's installed version, into the source -cache directory named in the context file, one subdirectory per commit hash: +Before cloning, ask which version, device, and surface the bug happened on. A +version read on this machine is the wrong tree when the bug was on another +device, a remote server, or a build the user has updated since. + +If a server is up, read `serverVersion` from +`GET /.well-known/t3/environment` (unauthenticated). The context file's +`Server process` line has the origin. Newer context files may already record it +as `Local server version`. Desktop and server builds share a version, so that +value is the desktop app when its bundled server is the one answering. Treat it +as a local fact to check against the user's answer. Clone it only when they +confirm the bug happened on that server. + +Clone the repo at the tag matching the version the bug happened on, into the +source cache directory named in the context file, one subdirectory per commit +hash: git clone --depth 1 --filter=blob:none --branch \ https://github.com/pingdotgg/t3code / @@ -81,7 +99,7 @@ comes from this repo's `main` branch. Search existing issues in pingdotgg/t3code (use `gh`, or the public GitHub search API if `gh` is missing or not logged in). Then check whether the problem is already -fixed in a release newer than the user's version: compare versions, read release +fixed in a release newer than the version the bug happened on: compare versions, read release notes and recent commits touching the relevant code. If the user is behind and the fix likely shipped, say so plainly and give them the diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index c621d331c404..e1a35fc4f589 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -11,6 +11,9 @@ */ // @effect-diagnostics nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeHttp from "node:http"; +import * as NodeHttps from "node:https"; import * as NodeOS from "node:os"; import * as NodeReadlinePromises from "node:readline/promises"; @@ -19,6 +22,7 @@ import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Console from "effect/Console"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -29,7 +33,11 @@ import { Command, Flag } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; -import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { + isProcessAlive, + readPersistedServerRuntimeState, + type PersistedServerRuntimeState, +} from "../serverRuntimeState.ts"; import { baseDirFlag } from "./config.ts"; import { resolveCliCommand } from "./invocation.ts"; import { @@ -37,6 +45,13 @@ import { buildTriageLaunchPrompt, buildTriageSeedPrompt, } from "./triagePrompt.ts"; +import { + formatLocalServerVersion, + readLocalServerVersion, + recordedServerStillOwnsPid, + triageReleaseTag, + type ServerVersionProbe, +} from "./triageServerVersion.ts"; interface TriageAgent { readonly id: "claude" | "codex"; @@ -76,21 +91,109 @@ export class TriageAgentSpawnError extends Schema.TaggedError` (directory + * mtime is process start). Other platforms ask the OS. Unreadable means + * unknown, which is not treated as the recorded server. + */ +const readProcessStartedAtMs = (pid: number): number | undefined => { + try { + if (NodeOS.platform() === "linux") { + return NodeFS.statSync(`/proc/${pid}`).mtimeMs; + } + if (NodeOS.platform() === "darwin") { + const stdout = NodeChildProcess.execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + timeout: 2_000, + env: { ...process.env, LC_ALL: "C" }, + }); + const parsed = Date.parse(stdout.trim()); + return Number.isFinite(parsed) ? parsed : undefined; + } + if (NodeOS.platform() === "win32") { + const stdout = NodeChildProcess.execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-Process -Id ${String(pid)}).StartTime.ToUniversalTime().ToString('o')`, + ], + { encoding: "utf8", timeout: 5_000, windowsHide: true }, + ); + const parsed = Date.parse(stdout.trim()); + return Number.isFinite(parsed) ? parsed : undefined; + } + } catch { + return undefined; + } + return undefined; +}; + +const serverVersionProbe = { + isAlive: isProcessAlive, + processStartedAtMs: readProcessStartedAtMs, + readText: (url, timeout) => readEnvironmentBody(url, timeout), +} satisfies ServerVersionProbe; + +/** + * GET the environment descriptor. A socket timeout and fiber interruption + * both destroy the request, so a stalled body settles as a failure. + */ +export const readEnvironmentBody = (url: string, timeout: Duration.Input) => + Effect.callback((resume) => { + const timeoutMs = Duration.toMillis(timeout); + let settled = false; + const finish = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + resume(effect); + }; + const request = (url.startsWith("https:") ? NodeHttps : NodeHttp).get(url, (response) => { + response.setTimeout(timeoutMs, () => { + request.destroy(); + }); + if (response.statusCode !== 200) { + response.resume(); + request.destroy(); + finish(Effect.fail(new Error(`environment status ${String(response.statusCode)}`))); + return; + } + const chunks: Array = []; + response.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + response.on("end", () => { + finish(Effect.succeed(Buffer.concat(chunks).toString("utf8"))); + }); + response.on("error", (cause) => { + finish(Effect.fail(cause)); + }); + }); + request.setTimeout(timeoutMs, () => { + request.destroy(); + }); + request.on("error", (cause) => { + finish(Effect.fail(cause)); + }); + return Effect.sync(() => { + request.destroy(); + }); + }); + /** One human-readable line about the local server, for `context.md`. */ -const describeServerProcess = Effect.fn("triage.describeServerProcess")(function* ( - serverRuntimeStatePath: string, -) { - // readPersistedServerRuntimeState swallows read/decode failures itself and - // returns none, so a corrupt state file reads as "not running" here. - const state = yield* readPersistedServerRuntimeState(serverRuntimeStatePath); +const describeServerProcess = (state: Option.Option): string => { if (Option.isNone(state)) { return "not running (no server-runtime.json; the server may never have started here)"; } if (!isProcessAlive(state.value.pid)) { return `not running (state file is stale: pid ${String(state.value.pid)} is dead; last origin ${state.value.origin})`; } + if (!recordedServerStillOwnsPid(state.value.startedAt, readProcessStartedAtMs(state.value.pid))) { + return `not running (state file is stale: pid ${String(state.value.pid)} is not the server that wrote it; last origin ${state.value.origin})`; + } return `running (pid ${String(state.value.pid)}, ${state.value.origin})`; -}); +}; const pickAgent = (agents: ReadonlyArray) => Effect.promise(async () => { @@ -182,20 +285,24 @@ export const triageCommand = Command.make("triage", { ); yield* fs.makeDirectory(scratchDir, { recursive: true }); - const version = packageJson.version; + const cliVersion = packageJson.version; + // A corrupt state file reads as "not running"; the reader swallows that. + const serverState = yield* readPersistedServerRuntimeState(paths.serverRuntimeStatePath); + const localServer = formatLocalServerVersion( + yield* readLocalServerVersion(serverState, serverVersionProbe), + ); const contextFilePath = path.join(scratchDir, "context.md"); yield* fs.writeFileString( contextFilePath, buildTriageContext({ generatedAt: DateTime.formatIso(now), - version, - releaseTag: /^[^-+]+-(?:nightly|preview)\./.test(version) - ? `v${version} (prerelease build; if this tag does not exist, clone main)` - : `v${version}`, + cliVersion, + cliReleaseTag: triageReleaseTag(cliVersion), + ...localServer, os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`, nodeVersion: process.version, launchedAs: yield* resolveCliCommand("triage"), - server: yield* describeServerProcess(paths.serverRuntimeStatePath), + server: describeServerProcess(serverState), paths: { stateDir: paths.stateDir, dbPath: paths.dbPath, diff --git a/apps/server/src/cli/triagePrompt.test.ts b/apps/server/src/cli/triagePrompt.test.ts index bf1ac5dbbe5e..0dc392db0a74 100644 --- a/apps/server/src/cli/triagePrompt.test.ts +++ b/apps/server/src/cli/triagePrompt.test.ts @@ -1,9 +1,13 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; +import * as NodeHttp from "node:http"; import * as NodePath from "node:path"; import { assert, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import { readEnvironmentBody } from "./triage.ts"; import { buildTriageContext, buildTriageLaunchPrompt, @@ -41,8 +45,11 @@ it("launch prompt stays a single argv-safe line naming the prompt file", () => { it("context file carries every path the playbook depends on", () => { const context = buildTriageContext({ generatedAt: "2026-08-13T00:00:00.000Z", - version: "0.0.33", - releaseTag: "v0.0.33", + cliVersion: "0.0.33", + cliReleaseTag: "v0.0.33", + localServerVersion: "0.0.43-nightly.20260923.2173", + localServerReleaseTag: + "v0.0.43-nightly.20260923.2173 (prerelease build; if this tag does not exist, clone main)", os: "linux x64 (7.0.0)", nodeVersion: "v24.0.0", launchedAs: "npx t3 triage", @@ -67,5 +74,93 @@ it("context file carries every path the playbook depends on", () => { assert.include(context, "/home/u/.t3/userdata/secrets"); assert.include(context, "/home/u/.t3/source"); assert.include(context, "npx t3 triage"); - assert.include(context, "v0.0.33"); + assert.include(context, "Triage CLI version: 0.0.33"); + assert.include(context, "Release tag for the triage CLI: v0.0.33"); + assert.include(context, "Local server version: 0.0.43-nightly.20260923.2173"); + assert.include( + context, + "Release tag for the local server: v0.0.43-nightly.20260923.2173 (prerelease build; if this tag does not exist, clone main)", + ); + assert.notInclude(context, "Installed version:"); + assert.include(TRIAGE_PLAYBOOK, "ask which version, device, and surface the bug happened on"); + assert.include(TRIAGE_PLAYBOOK, "check against the user's answer"); +}); + +it("omits a local-server release tag when no server version was probed", () => { + const context = buildTriageContext({ + generatedAt: "2026-08-13T00:00:00.000Z", + cliVersion: "0.0.42", + cliReleaseTag: "v0.0.42", + localServerVersion: "not running", + os: "linux x64 (7.0.0)", + nodeVersion: "v24.0.0", + launchedAs: "npx t3 triage", + server: "not running (no server-runtime.json; the server may never have started here)", + paths: { + stateDir: "/home/u/.t3/userdata", + dbPath: "/home/u/.t3/userdata/state.sqlite", + settingsPath: "/home/u/.t3/userdata/settings.json", + logsDir: "/home/u/.t3/userdata/logs", + serverLogPath: "/home/u/.t3/userdata/logs/server.log", + serverTracePath: "/home/u/.t3/userdata/logs/server.trace.ndjson", + providerEventLogPath: "/home/u/.t3/userdata/logs/provider/events.log", + terminalLogsDir: "/home/u/.t3/userdata/logs/terminals", + providerStatusCacheDir: "/home/u/.t3/caches", + secretsDir: "/home/u/.t3/userdata/secrets", + sourceCacheDir: "/home/u/.t3/source", + }, + }); + assert.include(context, "Local server version: not running"); + assert.notInclude(context, "Release tag for the local server"); }); + +const withOpenPort = ( + respond: (response: NodeHttp.ServerResponse) => void, + run: (origin: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer((_request, response) => { + respond(response); + }); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => { + const address = server.address(); + if (address === null || typeof address === "string") { + return Effect.die("Expected a TCP address"); + } + return run(`http://127.0.0.1:${String(address.port)}`); + }, + (server) => + Effect.sync(() => { + server.closeAllConnections(); + server.close(); + }), + ); + +it.effect("environment read fails on a closed port instead of hanging", () => + Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis; + const exit = yield* Effect.exit(readEnvironmentBody("http://127.0.0.1:1", "1 second")); + assert.equal(exit._tag, "Failure"); + assert.isBelow((yield* Clock.currentTimeMillis) - started, 2_000); + }), +); + +it.effect("environment read gives up when the response body never finishes", () => + withOpenPort( + (response) => { + response.writeHead(200, { "content-type": "application/json" }); + }, + (origin) => + Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis; + const exit = yield* Effect.exit( + readEnvironmentBody(`${origin}/.well-known/t3/environment`, "200 millis"), + ); + assert.equal(exit._tag, "Failure"); + assert.isBelow((yield* Clock.currentTimeMillis) - started, 2_000); + }), + ), +); diff --git a/apps/server/src/cli/triagePrompt.ts b/apps/server/src/cli/triagePrompt.ts index c2b93a1840a1..ae644fe09b15 100644 --- a/apps/server/src/cli/triagePrompt.ts +++ b/apps/server/src/cli/triagePrompt.ts @@ -30,9 +30,14 @@ valuable thing you can extract from this conversation. ## 2. Read the machine facts -Read the triage context file before investigating. It tells you the installed -version, the OS, whether the server process is currently running, and the exact -paths for state, logs, and the database. +Read the triage context file before investigating. It tells you the OS, whether +the server process is currently running, and the exact paths for state, logs, +and the database. + +\`Installed version\` (older CLIs) and \`Triage CLI version\` (newer CLIs) name the +\`t3\` binary that wrote the context file. That is not the build the bug happened +on. \`npx t3 triage\` is whichever \`t3\` npm resolved, and it can be older than the +desktop app or background service. ## 3. Check for a newer playbook @@ -42,8 +47,21 @@ instead of this one. The user may be on an old release with an old copy. ## 4. Get the source -Clone the repo at the tag matching the user's installed version, into the source -cache directory named in the context file, one subdirectory per commit hash: +Before cloning, ask which version, device, and surface the bug happened on. A +version read on this machine is the wrong tree when the bug was on another +device, a remote server, or a build the user has updated since. + +If a server is up, read \`serverVersion\` from +\`GET /.well-known/t3/environment\` (unauthenticated). The context file's +\`Server process\` line has the origin. Newer context files may already record it +as \`Local server version\`. Desktop and server builds share a version, so that +value is the desktop app when its bundled server is the one answering. Treat it +as a local fact to check against the user's answer. Clone it only when they +confirm the bug happened on that server. + +Clone the repo at the tag matching the version the bug happened on, into the +source cache directory named in the context file, one subdirectory per commit +hash: git clone --depth 1 --filter=blob:none --branch \\ https://github.com/pingdotgg/t3code / @@ -93,7 +111,7 @@ comes from this repo's \`main\` branch. Search existing issues in pingdotgg/t3code (use \`gh\`, or the public GitHub search API if \`gh\` is missing or not logged in). Then check whether the problem is already -fixed in a release newer than the user's version: compare versions, read release +fixed in a release newer than the version the bug happened on: compare versions, read release notes and recent commits touching the relevant code. If the user is behind and the fix likely shipped, say so plainly and give them the @@ -165,8 +183,15 @@ ${TRIAGE_PLAYBOOK}`; /** Machine facts for one triage run, pre-formatted so the template stays plain. */ export interface TriageContextInput { readonly generatedAt: string; - readonly version: string; - readonly releaseTag: string; + /** Version of the `t3` process that wrote this file. Not the product the bug happened on. */ + readonly cliVersion: string; + readonly cliReleaseTag: string; + /** + * `serverVersion` from the live local server, or a short reason none was read. + * Desktop and service installs publish this on `/.well-known/t3/environment`. + */ + readonly localServerVersion: string; + readonly localServerReleaseTag?: string; readonly os: string; readonly nodeVersion: string; readonly launchedAs: string; @@ -191,8 +216,13 @@ export const buildTriageContext = (input: TriageContextInput) => `# T3 Code tria Generated by \`t3 triage\` at ${input.generatedAt}. -- Installed version: ${input.version} -- Release tag for this version: ${input.releaseTag} +- Triage CLI version: ${input.cliVersion} +- Release tag for the triage CLI: ${input.cliReleaseTag} +- Local server version: ${input.localServerVersion}${ + input.localServerReleaseTag === undefined + ? "" + : `\n- Release tag for the local server: ${input.localServerReleaseTag}` +} - OS: ${input.os} - Node: ${input.nodeVersion} - CLI launched as: ${input.launchedAs} diff --git a/apps/server/src/cli/triageServerVersion.test.ts b/apps/server/src/cli/triageServerVersion.test.ts new file mode 100644 index 000000000000..786e184eb79c --- /dev/null +++ b/apps/server/src/cli/triageServerVersion.test.ts @@ -0,0 +1,141 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; + +import type { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { readLocalServerVersion, triageReleaseTag } from "./triageServerVersion.ts"; + +const nightly = "0.0.43-nightly.20260923.2173"; + +const descriptor = { + environmentId: "env_triage_test", + label: "T3 Code", + platform: { os: "linux", arch: "x64" }, + serverVersion: nightly, + capabilities: {}, +}; + +const OWNED_STARTED_AT = "2099-01-01T00:00:00.000Z"; +const OWNED_PROCESS_START_MS = Date.parse(OWNED_STARTED_AT) - 1_000; + +const stateFor = ( + origin: string, + pid: number, + startedAt = OWNED_STARTED_AT, +): PersistedServerRuntimeState => ({ + version: 1, + pid, + port: 1, + origin, + startedAt, +}); + +const ownedProbe = (readText: (url: string) => Effect.Effect) => { + const hits = { count: 0 }; + return { + hits, + probe: { + isAlive: () => true, + processStartedAtMs: () => OWNED_PROCESS_START_MS, + readText: (url: string) => { + hits.count += 1; + return readText(url); + }, + }, + }; +}; + +describe("triage version", () => { + it("formats a prerelease tag with the clone-main caveat", () => { + assert.equal( + triageReleaseTag(nightly), + `v${nightly} (prerelease build; if this tag does not exist, clone main)`, + ); + assert.equal(triageReleaseTag("0.0.42"), "v0.0.42"); + }); + + it.effect("reads serverVersion from the descriptor body", () => + Effect.gen(function* () { + const { probe } = ownedProbe(() => Effect.succeed(JSON.stringify(descriptor))); + const probed = yield* readLocalServerVersion( + Option.some(stateFor("http://127.0.0.1:3773", 42)), + probe, + ); + assert.deepEqual(probed, { status: "probed", version: nightly }); + }), + ); + + it.effect("does not probe a dead pid", () => + Effect.gen(function* () { + const { hits, probe } = ownedProbe(() => Effect.die("should not probe")); + const probed = yield* readLocalServerVersion( + Option.some(stateFor("http://127.0.0.1:9", 2_147_483_646, "2026-09-24T00:08:56.777Z")), + { ...probe, isAlive: () => false }, + ); + assert.deepEqual(probed, { status: "not-running" }); + assert.equal(hits.count, 0); + }), + ); + + it.effect("does not probe a reused pid that is not the recorded server", () => + Effect.gen(function* () { + const { hits, probe } = ownedProbe(() => Effect.die("should not probe")); + const probed = yield* readLocalServerVersion( + Option.some(stateFor("http://127.0.0.1:3773", 42, "2000-01-01T00:00:00.000Z")), + { + ...probe, + processStartedAtMs: () => Date.parse("2026-09-24T00:08:56.777Z"), + }, + ); + assert.deepEqual(probed, { status: "not-running" }); + assert.equal(hits.count, 0); + }), + ); + + it.effect("does not probe when the process start time is unknown", () => + Effect.gen(function* () { + const { hits, probe } = ownedProbe(() => Effect.die("should not probe")); + const probed = yield* readLocalServerVersion( + Option.some(stateFor("http://127.0.0.1:3773", 42)), + { ...probe, processStartedAtMs: () => undefined }, + ); + assert.deepEqual(probed, { status: "not-running" }); + assert.equal(hits.count, 0); + }), + ); + + it.effect("reports unavailable when the origin is not a URL", () => + Effect.gen(function* () { + const { hits, probe } = ownedProbe(() => Effect.die("should not probe")); + const probed = yield* readLocalServerVersion(Option.some(stateFor("not a url", 42)), probe); + assert.deepEqual(probed, { status: "unavailable" }); + assert.equal(hits.count, 0); + }), + ); + + it.effect("reports unavailable when the body never finishes", () => + Effect.gen(function* () { + const { probe } = ownedProbe(() => Effect.never); + const fiber = yield* readLocalServerVersion( + Option.some(stateFor("http://127.0.0.1:3773", 42)), + probe, + "200 millis", + ).pipe(Effect.forkChild); + yield* TestClock.adjust("200 millis"); + assert.deepEqual(yield* Fiber.join(fiber), { status: "unavailable" }); + }), + ); + + it.effect("reports unavailable when the body is not a T3 environment", () => + Effect.gen(function* () { + const { probe } = ownedProbe(() => Effect.succeed(JSON.stringify({ ok: true }))); + const probed = yield* readLocalServerVersion( + Option.some(stateFor("http://127.0.0.1:3773", 42)), + probe, + ); + assert.deepEqual(probed, { status: "unavailable" }); + }), + ); +}); diff --git a/apps/server/src/cli/triageServerVersion.ts b/apps/server/src/cli/triageServerVersion.ts new file mode 100644 index 000000000000..3bc28c43752e --- /dev/null +++ b/apps/server/src/cli/triageServerVersion.ts @@ -0,0 +1,113 @@ +/** + * Local server version for `t3 triage`. + * + * `packageJson.version` is the CLI that wrote the context file. The build the + * user is actually running is `serverVersion` on the unauthenticated + * environment descriptor, and only when the pid in `server-runtime.json` is + * still that server. + */ +import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; + +export const SERVER_VERSION_PROBE_TIMEOUT = "2 seconds"; + +/** State is written just after process start; allow a little clock skew. */ +const PROCESS_START_SKEW_MS = 5_000; + +const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; + +const LOCAL_SERVER_VERSION_UNAVAILABLE = "unavailable"; + +const decodeEnvironmentDescriptor = Schema.decodeUnknownEffect( + Schema.fromJsonString(ExecutionEnvironmentDescriptor), +); + +export const triageReleaseTag = (version: string) => + /^[^-+]+-(?:nightly|preview)\./.test(version) + ? `v${version} (prerelease build; if this tag does not exist, clone main)` + : `v${version}`; + +/** + * True when `processStartedAtMs` is still the process that wrote `startedAt`. + * A reused pid starts later. An unknown start time is not a match. + */ +export const recordedServerStillOwnsPid = ( + startedAt: string, + processStartedAtMs: number | undefined, +): boolean => { + const startedAtMs = Date.parse(startedAt); + if (!Number.isFinite(startedAtMs) || processStartedAtMs === undefined) { + return false; + } + return processStartedAtMs <= startedAtMs + PROCESS_START_SKEW_MS; +}; + +/** http(s) environment URL. A corrupt origin stays here and is not probed. */ +export const environmentDescriptorUrl = (origin: string): string | undefined => { + try { + const base = new URL(origin); + if (base.protocol !== "http:" && base.protocol !== "https:") { + return undefined; + } + return new URL(WELL_KNOWN_ENVIRONMENT_PATH, base).toString(); + } catch { + return undefined; + } +}; + +export type LocalServerVersion = + | { readonly status: "not-running" } + | { readonly status: "unavailable" } + | { readonly status: "probed"; readonly version: string }; + +export interface ServerVersionProbe { + readonly isAlive: (pid: number) => boolean; + readonly processStartedAtMs: (pid: number) => number | undefined; + /** Must settle when `timeout` elapses, including a body that never ends. */ + readonly readText: (url: string, timeout: Duration.Input) => Effect.Effect; +} + +export const readLocalServerVersion = Effect.fn("triage.readLocalServerVersion")(function* ( + state: Option.Option, + probe: ServerVersionProbe, + probeTimeout: Duration.Input = SERVER_VERSION_PROBE_TIMEOUT, +) { + if ( + Option.isNone(state) || + !probe.isAlive(state.value.pid) || + !recordedServerStillOwnsPid(state.value.startedAt, probe.processStartedAtMs(state.value.pid)) + ) { + return { status: "not-running" } as const satisfies LocalServerVersion; + } + const url = environmentDescriptorUrl(state.value.origin); + if (url === undefined) { + return { status: "unavailable" } as const satisfies LocalServerVersion; + } + const version = yield* probe.readText(url, probeTimeout).pipe( + Effect.flatMap(decodeEnvironmentDescriptor), + Effect.map((descriptor) => descriptor.serverVersion), + Effect.timeout(probeTimeout), + Effect.orElseSucceed(() => undefined), + ); + return ( + version === undefined ? { status: "unavailable" } : { status: "probed", version } + ) satisfies LocalServerVersion; +}); + +export const formatLocalServerVersion = (probed: LocalServerVersion) => { + if (probed.status === "probed") { + return { + localServerVersion: probed.version, + localServerReleaseTag: triageReleaseTag(probed.version), + }; + } + return { + localServerVersion: + probed.status === "unavailable" ? LOCAL_SERVER_VERSION_UNAVAILABLE : "not running", + }; +};