diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 39bf3ea01052..d215c515955b 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.test.ts b/apps/server/src/cli/triage.test.ts new file mode 100644 index 000000000000..94aaf9bf307f --- /dev/null +++ b/apps/server/src/cli/triage.test.ts @@ -0,0 +1,153 @@ +// @effect-diagnostics nodeBuiltinImport:off - the probe test stands up a local HTTP server. +import * as NodeHttp from "node:http"; + +import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { readLocalServerVersion, triageReleaseTag } from "./triage.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: {}, +}; + +// Later than this process's start, so the pid still matches the recorded server. +const OWNED_STARTED_AT = "2099-01-01T00:00:00.000Z"; + +const stateFor = ( + origin: string, + pid: number, + startedAt = OWNED_STARTED_AT, +): PersistedServerRuntimeState => ({ + version: 1, + pid, + port: 1, + origin, + startedAt, +}); + +const withServer = ( + respond: (response: NodeHttp.ServerResponse) => void, + run: (origin: string, hits: { count: number }) => Effect.Effect, +) => + Effect.gen(function* () { + const hits = { count: 0 }; + return yield* Effect.acquireUseRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer((_request, response) => { + hits.count += 1; + 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(new Error("Expected a TCP address")); + } + return run(`http://127.0.0.1:${String(address.port)}`, hits); + }, + (server) => + Effect.sync(() => { + server.closeAllConnections(); + server.close(); + }), + ); + }); + +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 live server, not the triage CLI", () => + withServer( + (response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(descriptor)); + }, + (origin) => + Effect.gen(function* () { + const probed = yield* readLocalServerVersion(Option.some(stateFor(origin, process.pid))); + assert.deepEqual(probed, { status: "probed", version: nightly }); + }), + ), + ); + + it.effect("does not probe a dead pid", () => + Effect.gen(function* () { + const probed = yield* readLocalServerVersion( + Option.some(stateFor("http://127.0.0.1:9", 2_147_483_646, "2026-09-24T00:08:56.777Z")), + ); + assert.deepEqual(probed, { status: "not-running" }); + }), + ); + + it.effect("does not probe a reused pid that is not the recorded server", () => + withServer( + (response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(descriptor)); + }, + (origin, hits) => + Effect.gen(function* () { + const probed = yield* readLocalServerVersion( + Option.some(stateFor(origin, process.pid, "2000-01-01T00:00:00.000Z")), + ); + 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 probed = yield* readLocalServerVersion(Option.some(stateFor("not a url", process.pid))); + assert.deepEqual(probed, { status: "unavailable" }); + }), + ); + + it.effect("reports unavailable when the live server never finishes its body", () => + withServer( + (response) => { + response.writeHead(200, { "content-type": "application/json" }); + }, + (origin) => + Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis; + const probed = yield* readLocalServerVersion( + Option.some(stateFor(origin, process.pid)), + "200 millis", + ); + assert.deepEqual(probed, { status: "unavailable" }); + assert.isBelow((yield* Clock.currentTimeMillis) - started, 2_000); + }), + ), + ); + + it.effect("reports unavailable when the live server is not a T3 environment", () => + withServer( + (response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }, + (origin) => + Effect.gen(function* () { + const probed = yield* readLocalServerVersion(Option.some(stateFor(origin, process.pid))); + assert.deepEqual(probed, { status: "unavailable" }); + }), + ), + ); +}); diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index c621d331c404..bcb80d82f5db 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -11,25 +11,39 @@ */ // @effect-diagnostics nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodeReadlinePromises from "node:readline/promises"; +import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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 Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; 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 { @@ -76,22 +90,178 @@ export class TriageAgentSpawnError extends Schema.TaggedError + /^[^-+]+-(?:nightly|preview)\./.test(version) + ? `v${version} (prerelease build; if this tag does not exist, clone main)` + : `v${version}`; + +/** + * Start time of `pid`, in epoch ms. Linux reads `/proc/` (directory mtime + * is process start). Other platforms ask the OS. Unreadable means unknown. + */ +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; +}; + +/** + * True when `pid` is still the process that wrote `startedAt`. The state file + * is written after that process starts, so a reused pid (started later) is not + * a match. Unknown start time is not a match: do not probe a stranger. + */ +const recordedServerStillOwnsPid = (state: PersistedServerRuntimeState): boolean => { + const startedAtMs = Date.parse(state.startedAt); + if (!Number.isFinite(startedAtMs)) { + return false; + } + const processStartedAtMs = readProcessStartedAtMs(state.pid); + if (processStartedAtMs === undefined) { + return false; + } + return processStartedAtMs <= startedAtMs + PROCESS_START_SKEW_MS; +}; + /** 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)) { + 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})`; +}; + +export type LocalServerVersion = + | { readonly status: "not-running" } + | { readonly status: "unavailable" } + | { readonly status: "probed"; readonly version: string }; + +/** http(s) environment URL. `new URL` throws on a corrupt origin; that stays here. */ +const environmentUrl = (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; + } +}; + +/** + * `response.arrayBuffer()` does not observe Effect interruption, so a timeout + * has to abort the fetch itself or a stalled body never settles. + */ +const fetchWithDeadline = (timeout: Duration.Input): typeof fetch => { + const deadline = AbortSignal.timeout(Duration.toMillis(timeout)); + return (input, init) => + // @effect-diagnostics-next-line globalFetch:off + globalThis.fetch(input, { + ...init, + signal: init?.signal == null ? deadline : AbortSignal.any([init.signal, deadline]), + }); +}; + +const probeServerVersion = (url: string, timeout: Duration.Input) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const descriptor = yield* client + .execute(HttpClientRequest.get(url)) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), + ); + return descriptor.serverVersion; + }).pipe( + // Covers the status check and the body read, not only the header response. + Effect.timeout(timeout), + Effect.provide( + FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchWithDeadline(timeout))), + ), + ), + Effect.orElseSucceed(() => undefined), + Effect.catchCause(() => Effect.succeed(undefined)), + ); + +/** + * `serverVersion` of the server recorded in `server-runtime.json`. + * Desktop and service installs publish it on the unauthenticated environment + * descriptor. A dead or reused pid is not probed. + */ +export const readLocalServerVersion = Effect.fn("triage.readLocalServerVersion")(function* ( + state: Option.Option, + probeTimeout: Duration.Input = SERVER_VERSION_PROBE_TIMEOUT, +): Effect.fn.Return { + if ( + Option.isNone(state) || + !isProcessAlive(state.value.pid) || + !recordedServerStillOwnsPid(state.value) + ) { + return { status: "not-running" }; + } + const url = environmentUrl(state.value.origin); + if (url === undefined) { + return { status: "unavailable" }; + } + const version = yield* probeServerVersion(url, probeTimeout); + return version === undefined ? { status: "unavailable" } : { status: "probed", version }; }); +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", + }; +}; + const pickAgent = (agents: ReadonlyArray) => Effect.promise(async () => { const readline = NodeReadlinePromises.createInterface({ @@ -182,20 +352,22 @@ 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)); 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..9a3d11cd624d 100644 --- a/apps/server/src/cli/triagePrompt.test.ts +++ b/apps/server/src/cli/triagePrompt.test.ts @@ -41,8 +41,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 +70,42 @@ 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"); }); diff --git a/apps/server/src/cli/triagePrompt.ts b/apps/server/src/cli/triagePrompt.ts index c2b93a1840a1..7b572dad10b2 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}