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", + }; +};