Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions .github/triage/PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <origin>/.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 <release-tag> \
https://github.com/pingdotgg/t3code <source-cache-dir>/<hash>
Expand Down Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions apps/server/src/cli/triage.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <A, E, R>(
respond: (response: NodeHttp.ServerResponse) => void,
run: (origin: string, hits: { count: number }) => Effect.Effect<A, E, R>,
) =>
Effect.gen(function* () {
const hits = { count: 0 };
return yield* Effect.acquireUseRelease(
Effect.callback<NodeHttp.Server>((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" });
}),
),
);
});
Loading
Loading