diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index e67ff13311..34e2b25589 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -5,6 +5,7 @@ import open from "open" import { AltimateApi } from "../api/client" // altimate_change — onboarding telemetry for the gateway sign-in funnel import * as OnboardingTelemetry from "../telemetry/onboarding" +import { Telemetry } from "../telemetry" // altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) import { getOrCreateMachineId } from "../util/machine-id" import { Config } from "@/config/config" @@ -89,17 +90,21 @@ export async function buildCliContext(machineIdPath?: string): Promise { } catch (err) { // Config was unreadable — NOT the normal path. Server routes run inside // Instance.provide() (AsyncLocalStorage-propagated across awaits), so - // Config.get() resolves during an ordinary browser authorize(). The known - // exception is `altimate auth login `, which deliberately skips - // instance bootstrap (ProvidersLoginCommand `instance: (args) => !isAuthProviderUrl(args.target)`); - // on that path this fires. Fail CLOSED — omit the durable machine_id rather - // than transmit it for a user who may have opted out via config; a missed - // correlation beats leaking a stable identifier. Log so a low correlation - // rate is traceable here instead of being mistaken for a lost reply. - log.warn("cli_context: config unreadable, omitting machine_id (fail-closed)", { + // Config.get() resolves during an ordinary browser authorize(). It fires on + // the paths that skip instance bootstrap: `altimate auth login ` and the + // create-and-link hand-off of `altimate-code link`. Follow the telemetry + // module's own decision for this process: it evaluates the same gates in + // doInit() and, with the config unreadable, keeps sending the machine id + // itself — so this context carries the id exactly when telemetry does, and + // omits it whenever telemetry is off (opted out, no sink, test runner). + // Failing closed here instead hid the id from every create-and-link + // hand-off, which made the link step unattributable on the funnel. + await Telemetry.init().catch(() => {}) + disabled = !Telemetry.isEnabled() + log.info("cli_context: config unreadable, following telemetry state", { code: (err as NodeJS.ErrnoException)?.code, + enabled: !disabled, }) - disabled = true } } let machineId = "" diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index f350d9783c..9fc4ee78d5 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -538,6 +538,65 @@ export namespace Telemetry { recovered: boolean duration_ms: number } + // altimate_change start — workspace link lifecycle (shared event contract: one name per fact, + // the backend's workspace id on every row, fired on the API success response, a `client` + // property stamped on every envelope). The extension emits the same names for its own moments. + | { + type: "workspace_linked" + timestamp: number + session_id: string + workspace_id: number + how: "bind_existing" | "create_and_bind" | "rebind_remote" | "rebind_path" + } + | { + type: "workspace_created" + timestamp: number + session_id: string + workspace_id: number + how: "create_and_bind" | "create_unbound" + } + | { + type: "workspace_link_failed" + timestamp: number + session_id: string + how: "bind_existing" | "create_and_bind" | "create_unbound" | "rebind_remote" | "rebind_path" + reason: "auth" | "not_found" | "network" | "conflict" | "other" + workspace_id?: number + } + | { + type: "workspace_unlinked" + timestamp: number + session_id: string + workspace_id?: number + } + | { + type: "workspace_switched" + timestamp: number + session_id: string + to_workspace_id: number + from_workspace_id?: number + how: "rebind_remote" | "rebind_path" + } + | { + type: "workspace_auth_expired" + timestamp: number + session_id: string + /** Method and path with ids replaced by ":id", e.g. "POST /datamate-project-bindings/bind". */ + endpoint: string + status: number + workspace_id?: number + } + | { + type: "workspace_api_error" + timestamp: number + session_id: string + endpoint: string + /** `response`: the server answered 2xx with an empty, unparseable or oversized body. */ + status_class: "4xx" | "5xx" | "network" | "timeout" | "response" + status?: number + workspace_id?: number + } + // altimate_change end | { type: "mcp_server_census" timestamp: number @@ -1935,6 +1994,9 @@ export namespace Telemetry { const properties: Record = { cli_version: InstallationVersion, + // altimate_change — the emitting client, per the shared event contract; `source` below + // still says how this CLI was launched (cli, tui, serve, datamates). + client: "altimate-code", source: clientSource, project_id: fields.project_id ?? projectId, ...(machineId && { machine_id: machineId }), @@ -1949,7 +2011,9 @@ export namespace Telemetry { for (const [k, v] of Object.entries(fields)) { if (k === "session_id" || k === "project_id" || k === "_retried" || k === "_operation_id") continue - if (typeof v === "number") { + // altimate_change — an id is a dimension, not a quantity: numeric *_id fields (workspace ids) + // go to properties as strings so the dashboards join on customDimensions like every other key. + if (typeof v === "number" && !k.endsWith("_id")) { measurements[k] = v } else if (v !== undefined && v !== null) { properties[k] = typeof v === "object" ? JSON.stringify(v) : String(v) diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 47c4a9a676..6de32e0e2d 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -11,6 +11,7 @@ // bodies come out as `{"detail": }` — we parse the object form // for 409/412 and surface it as a typed error rather than a bare status code. import { AltimateApi } from "@/altimate/api/client" +import { Telemetry } from "@/altimate/telemetry" const REQUEST_TIMEOUT_MS = 15_000 @@ -153,6 +154,9 @@ export class WorkspaceApiError extends Error { constructor( msg: string, public readonly status?: number, + /** Where it failed: on the wire before any answer, or in an answer the server gave (its + * status, when known, is `status`). A statusless error is transport unless said otherwise. */ + public readonly failure: "transport" | "response" = status === undefined ? "transport" : "response", ) { super(msg) this.name = "WorkspaceApiError" @@ -173,6 +177,145 @@ async function creds(): Promise<{ url: string; instance: string; apiKey: string return { url: c.altimateUrl, instance: c.altimateInstanceName, apiKey: c.altimateApiKey } } +// altimate_change start — workspace telemetry helpers. Every failed workspace call reports +// `workspace_api_error` (or `workspace_auth_expired` on 401/403) with the endpoint masked of +// ids; the link lifecycle functions below report linked / created / switched / unlinked on the +// success response and `workspace_link_failed` with a reason class otherwise. +/** Endpoint labels come from a ROUTE TABLE, never from how a segment is spelled: an identifier, + * a memory id or a file path equal to a route word must not reach telemetry. Each router the + * workspace clients call lists the shapes of its subpaths as regular expressions with the label + * they map to; a subpath that matches no shape, or a router not listed, is labelled with every + * segment masked. Identifiers and file paths therefore cannot leak whatever they contain; an + * unlisted new route only costs detail until it is added here. */ +const ROUTES: Record> = { + "/datamate-project-bindings": [ + [/^\/?$/, "/"], + [/^\/bind$/, "/bind"], + [/^\/by-remote$/, "/by-remote"], + [/^\/by-path$/, "/by-path"], + ], + "/datamates": [[/^\/?$/, "/"]], + "/users": [[/^\/me$/, "/me"]], + "/skills": [ + [/^\/[^/]+$/, "/:id"], + [/^\/[^/]+\/datamates$/, "/:id/datamates"], + [/^\/[^/]+\/files(\/.*)?$/, "/:id/files/:path"], + ], + "/datamates/memory": [ + [/^\/?$/, "/"], + [/^\/list$/, "/list"], + [/^\/[^/]+$/, "/:id"], + ], +} + +/** "METHOD /base/path" with every dynamic segment replaced from the route table above. */ +export function endpointOf(method: string, basePath: string, subpath: string): string { + const shapes = ROUTES[basePath] + const maskAll = (p: string) => + p + .split("/") + .map((seg) => (seg === "" ? seg : ":id")) + .join("/") + if (!shapes) return `${method} ${maskAll(basePath)}${maskAll(subpath)}` + const query = subpath.split("?")[0] + for (const [shape, label] of shapes) if (shape.test(query)) return `${method} ${basePath}${label}` + return `${method} ${basePath}${maskAll(query)}` +} + +function workspaceIdIn(body: unknown): number | undefined { + if (!body || typeof body !== "object") return undefined + const b = body as Record + const id = b.datamate_id ?? b.target_datamate_id + return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : undefined +} + +function reportApiError(input: { + endpoint: string + status?: number + kind: "4xx" | "5xx" | "network" | "timeout" | "response" + workspaceId?: number +}) { + const session_id = Telemetry.getContext().sessionId + const ws = input.workspaceId !== undefined ? { workspace_id: input.workspaceId } : {} + if (input.status === 401 || input.status === 403) { + Telemetry.track({ + type: "workspace_auth_expired", + timestamp: Date.now(), + session_id, + endpoint: input.endpoint, + status: input.status, + ...ws, + }) + return + } + Telemetry.track({ + type: "workspace_api_error", + timestamp: Date.now(), + session_id, + endpoint: input.endpoint, + status_class: input.kind, + ...(input.status !== undefined ? { status: input.status } : {}), + ...ws, + }) +} + +/** The reason class of a failed link call, for `workspace_link_failed`. */ +export function linkFailureReason(err: unknown): "auth" | "not_found" | "network" | "conflict" | "other" { + if (err instanceof ForbiddenError) return "auth" + if (err instanceof NotFoundError) return "not_found" + if (err instanceof ConflictError || err instanceof PreconditionFailedError) return "conflict" + if (err instanceof WorkspaceApiError) { + // An answer whose body failed carries its status here rather than as a typed error: the same + // status classes as a readable answer. + if (err.status === 401 || err.status === 403) return "auth" + if (err.status === 404) return "not_found" + if (err.status === 409 || err.status === 412) return "conflict" + if (err.status === undefined && err.failure === "transport") return "network" // unreachable or timed out + } + return "other" +} + +type LinkHow = "bind_existing" | "create_and_bind" | "create_unbound" | "rebind_remote" | "rebind_path" + +async function linkAttempt(how: LinkHow, workspaceId: number | undefined, call: () => Promise): Promise { + try { + return await call() + } catch (err) { + Telemetry.track({ + type: "workspace_link_failed", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + how, + reason: linkFailureReason(err), + ...(workspaceId !== undefined ? { workspace_id: workspaceId } : {}), + }) + throw err + } +} + +function trackSwitched(to: number, from: number | undefined, how: "rebind_remote" | "rebind_path") { + Telemetry.track({ + type: "workspace_switched", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + to_workspace_id: to, + ...(from !== undefined ? { from_workspace_id: from } : {}), + how, + }) + trackLinked(to, how) +} + +function trackLinked(workspaceId: number, how: "bind_existing" | "create_and_bind" | "rebind_remote" | "rebind_path") { + Telemetry.track({ + type: "workspace_linked", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: workspaceId, + how, + }) +} +// altimate_change end + async function req( method: string, subpath: string, @@ -222,10 +365,13 @@ async function req( const qs = opts.query ? "?" + new URLSearchParams(opts.query).toString() : "" const basePath = opts.base ?? "/datamate-project-bindings" const target = `${url}${basePath}${subpath}${qs}` + const endpoint = endpointOf(method, basePath, subpath) + const bodyWorkspaceId = workspaceIdIn(opts.body) const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) let res: Response let text: string + let answered: number | undefined // the status once the server answered, for the catch below try { res = await fetch(target, { method, @@ -237,6 +383,7 @@ async function req( signal: controller.signal, ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), }) + answered = res.status // Keep the AbortController timeout ACTIVE while we read the response body. // ``fetch()`` resolves after headers arrive; a server can send headers then // stall the body stream indefinitely, so pulling the body inside the same @@ -266,11 +413,34 @@ async function req( // The abort fires equally when it kills the fetch OR the body read. (m8) const name = (err as { name?: string } | undefined)?.name if (name === "AbortError") { - throw new WorkspaceApiError( - `Request to ${target} timed out after ${Math.round(timeoutMs / 1000)}s`, - ) + // A timeout after the server answered (the body stalled) keeps the answer's status: + // a 500 whose body never arrives is still a 5xx row; a 2xx whose body never arrives + // stays a timeout, with the status attached. + reportApiError({ + endpoint, + ...(answered !== undefined ? { status: answered } : {}), + kind: answered === undefined ? "timeout" : answered >= 500 ? "5xx" : answered >= 400 ? "4xx" : "timeout", + workspaceId: bodyWorkspaceId, + }) + // The answer's status rides on the error too, so a link failure after an answer is not + // classified as a network failure. + throw new WorkspaceApiError(`Request to ${target} timed out after ${Math.round(timeoutMs / 1000)}s`, answered) + } + // The server answered and the body failed afterwards (readBounded's cap, a cut stream): + // classify by the status it answered with, so an oversized 500 is still a 5xx row; only a + // usable status with a bad body is "response". Before any answer it is transport. + if (answered !== undefined) { + reportApiError({ + endpoint, + status: answered, + kind: answered >= 500 ? "5xx" : answered >= 400 ? "4xx" : "response", + workspaceId: bodyWorkspaceId, + }) + } else { + reportApiError({ endpoint, kind: "network", workspaceId: bodyWorkspaceId }) } const msg = err instanceof Error ? err.message : String(err) + if (answered !== undefined) throw new WorkspaceApiError(`Response from ${target} failed: ${msg}`, answered) throw new WorkspaceApiError(`Cannot reach ${target}: ${msg}`) } finally { clearTimeout(timeout) @@ -284,6 +454,14 @@ async function req( } } const detail = (json as { detail?: unknown } | undefined)?.detail + if (!res.ok) { + reportApiError({ + endpoint, + status: res.status, + kind: res.status >= 500 ? "5xx" : "4xx", + workspaceId: bodyWorkspaceId, + }) + } if (res.status === 404) throw new NotFoundError(typeof detail === "string" ? detail : "Not found") if (res.status === 403) throw new ForbiddenError(typeof detail === "string" ? detail : "Forbidden") if (res.status === 409) { @@ -316,10 +494,8 @@ async function req( // is treated as an empty body too — otherwise ``null as T`` reaches callers // and .foo throws in a way the typed switches can't classify. (m7 + CR) if (json == null && !opts.allowEmptyBody) { - throw new WorkspaceApiError( - `Empty ${res.status} body from ${target} — expected JSON payload`, - res.status, - ) + reportApiError({ endpoint, status: res.status, kind: "response", workspaceId: bodyWorkspaceId }) + throw new WorkspaceApiError(`Empty ${res.status} body from ${target} — expected JSON payload`, res.status) } return json as T } @@ -346,9 +522,7 @@ async function readBounded(res: Response, target: string): Promise { if (!res.body) { const whole = await res.text() if (Buffer.byteLength(whole, "utf8") > MAX_RESPONSE_BYTES) { - throw new WorkspaceApiError( - `Response from ${target} exceeded the ${MAX_RESPONSE_BYTES} byte limit`, - ) + throw new WorkspaceApiError(`Response from ${target} exceeded the ${MAX_RESPONSE_BYTES} byte limit`) } return whole } @@ -362,9 +536,7 @@ async function readBounded(res: Response, target: string): Promise { if (!value) continue total += value.byteLength if (total > MAX_RESPONSE_BYTES) { - throw new WorkspaceApiError( - `Response from ${target} exceeded the ${MAX_RESPONSE_BYTES} byte limit`, - ) + throw new WorkspaceApiError(`Response from ${target} exceeded the ${MAX_RESPONSE_BYTES} byte limit`) } chunks.push(value) } @@ -380,7 +552,10 @@ export namespace WorkspaceApi { /** Server-authoritative pre-check by git remote. Returns null on 404. */ export async function getBindingForRemote(remote: string, actAs?: ActAs): Promise { try { - return await req("GET", "/by-remote", { query: { repo_remote: remote }, ...(actAs ? { actAs } : {}) }) + return await req("GET", "/by-remote", { + query: { repo_remote: remote }, + ...(actAs ? { actAs } : {}), + }) } catch (err) { if (err instanceof NotFoundError) return null throw err @@ -391,7 +566,10 @@ export namespace WorkspaceApi { * without a git remote). Returns null on 404. */ export async function getBindingForPath(projectPath: string, actAs?: ActAs): Promise { try { - return await req("GET", "/by-path", { query: { project_path: projectPath }, ...(actAs ? { actAs } : {}) }) + return await req("GET", "/by-path", { + query: { project_path: projectPath }, + ...(actAs ? { actAs } : {}), + }) } catch (err) { if (err instanceof NotFoundError) return null throw err @@ -403,7 +581,10 @@ export namespace WorkspaceApi { * picks the right endpoint even if the current identifier's remote has * changed since the binding was created (M3). Both fields on the * identifier are optional but at least one must be present. */ - export async function getBindingForProject(id: ProjectIdentifier, actAs?: ActAs): Promise { + export async function getBindingForProject( + id: ProjectIdentifier, + actAs?: ActAs, + ): Promise { if (id.repoRemote) { const hit = await getBindingForRemote(id.repoRemote, actAs) if (hit) return { ...hit, matchedBy: "remote" } @@ -425,7 +606,9 @@ export namespace WorkspaceApi { * A local-only unlink is not possible: ``lookupBinding`` re-asks the server * whenever the cache misses, so a row dropped only on disk comes straight back * on the next resolve. */ - export async function unbindProject(id: ProjectIdentifier): Promise { + /** `knownDatamateId`: the workspace the caller knows it is unlinking, for telemetry; the + * endpoint identifies the binding by project, not by workspace, and answers nothing. */ + export async function unbindProject(id: ProjectIdentifier, knownDatamateId?: number): Promise { const query: Record = {} // Send exactly one identifier. The endpoint answers 409 when both are given // and they name different bindings, and preferring the remote matches how @@ -436,6 +619,12 @@ export namespace WorkspaceApi { else return false try { await req("DELETE", "/", { query, allowEmptyBody: true }) + Telemetry.track({ + type: "workspace_unlinked", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + ...(knownDatamateId !== undefined ? { workspace_id: knownDatamateId } : {}), + }) return true } catch (err) { if (err instanceof NotFoundError) return false @@ -448,14 +637,28 @@ export namespace WorkspaceApi { identifier: ProjectIdentifier description?: string }): Promise { - return req("POST", "/", { - body: { - name: input.name, - repo_remote: input.identifier.repoRemote ?? null, - project_path: input.identifier.projectPath ?? null, - description: input.description ?? null, - }, - }) + const res = await linkAttempt("create_and_bind", undefined, () => + req("POST", "/", { + body: { + name: input.name, + repo_remote: input.identifier.repoRemote ?? null, + project_path: input.identifier.projectPath ?? null, + description: input.description ?? null, + }, + }), + ) + const id = res?.binding?.datamate_id ?? res?.datamate?.id + if (typeof id === "number") { + Telemetry.track({ + type: "workspace_created", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: id, + how: "create_and_bind", + }) + trackLinked(id, "create_and_bind") + } + return res } /** Create a workspace WITHOUT binding anything to it. @@ -502,30 +705,46 @@ export namespace WorkspaceApi { name: string description?: string }): Promise<{ id: number; name: string }> { - const data = await req<{ id: number }>("POST", "/", { - base: "/datamates", - body: { - name: input.name, - description: input.description ?? null, - integrations: [], - memory_enabled: true, - knowledge_engine_enabled: true, - privacy: "private", - }, + // The id check sits inside the reported attempt: a create whose answer is unusable is a + // failed link step, not a created workspace. + const id = await linkAttempt("create_unbound", undefined, async () => { + const data = await req<{ id: number }>("POST", "/", { + base: "/datamates", + body: { + name: input.name, + description: input.description ?? null, + integrations: [], + memory_enabled: true, + knowledge_engine_enabled: true, + privacy: "private", + }, + }) + // `typeof` FIRST, before any arithmetic. `Number()` coerces, so the + // previous `Number.isSafeInteger(Number(data?.id))` accepted `true` as 1, + // `"7"` as 7 and `[5]` as 5 — a malformed body would have rebound the + // project to whatever those coerced to (workspace 1, in the boolean case) + // instead of failing. The server's `CreateDatamateResponse` is `{id: int}` + // and FastAPI enforces it, so anything else here is a contract break worth + // refusing loudly rather than guessing at. + const id: unknown = (data as { id?: unknown } | null | undefined)?.id + if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) { + throw new WorkspaceApiError( + `Workspace was created but the server returned no usable id (${JSON.stringify(id) ?? "undefined"}).`, + undefined, + "response", + ) + } + return id + }) + // The create-then-rebind path for an already-linked project: the workspace exists + // from here; the rebind that follows reports the switch and the link. + Telemetry.track({ + type: "workspace_created", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: id, + how: "create_unbound", }) - // `typeof` FIRST, before any arithmetic. `Number()` coerces, so the - // previous `Number.isSafeInteger(Number(data?.id))` accepted `true` as 1, - // `"7"` as 7 and `[5]` as 5 — a malformed body would have rebound the - // project to whatever those coerced to (workspace 1, in the boolean case) - // instead of failing. The server's `CreateDatamateResponse` is `{id: int}` - // and FastAPI enforces it, so anything else here is a contract break worth - // refusing loudly rather than guessing at. - const id: unknown = (data as { id?: unknown } | null | undefined)?.id - if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) { - throw new WorkspaceApiError( - `Workspace was created but the server returned no usable id (${JSON.stringify(id) ?? "undefined"}).`, - ) - } return { id, name: input.name } } @@ -539,14 +758,18 @@ export namespace WorkspaceApi { identifier: ProjectIdentifier, actAs?: ActAs, ): Promise { - return req("POST", "/bind", { - body: { - datamate_id: datamateId, - repo_remote: identifier.repoRemote ?? null, - project_path: identifier.projectPath ?? null, - }, - ...(actAs ? { actAs } : {}), - }) + const res = await linkAttempt("bind_existing", datamateId, () => + req("POST", "/bind", { + body: { + datamate_id: datamateId, + repo_remote: identifier.repoRemote ?? null, + project_path: identifier.projectPath ?? null, + }, + ...(actAs ? { actAs } : {}), + }), + ) + trackLinked(res?.binding?.datamate_id ?? datamateId, "bind_existing") + return res } export async function rebindByRemote(input: { @@ -555,16 +778,20 @@ export namespace WorkspaceApi { expectedCurrentDatamateId?: number actAs?: ActAs }): Promise { - return req("PUT", "/by-remote", { - body: { - repo_remote: input.remote, - target_datamate_id: input.targetDatamateId, - ...(input.expectedCurrentDatamateId !== undefined - ? { expected_current_datamate_id: input.expectedCurrentDatamateId } - : {}), - }, - ...(input.actAs ? { actAs: input.actAs } : {}), - }) + const res = await linkAttempt("rebind_remote", input.targetDatamateId, () => + req("PUT", "/by-remote", { + body: { + repo_remote: input.remote, + target_datamate_id: input.targetDatamateId, + ...(input.expectedCurrentDatamateId !== undefined + ? { expected_current_datamate_id: input.expectedCurrentDatamateId } + : {}), + }, + ...(input.actAs ? { actAs: input.actAs } : {}), + }), + ) + trackSwitched(res?.binding?.datamate_id ?? input.targetDatamateId, input.expectedCurrentDatamateId, "rebind_remote") + return res } /** Path-identified rebind — symmetric to ``rebindByRemote`` for projects @@ -575,16 +802,20 @@ export namespace WorkspaceApi { expectedCurrentDatamateId?: number actAs?: ActAs }): Promise { - return req("PUT", "/by-path", { - body: { - project_path: input.projectPath, - target_datamate_id: input.targetDatamateId, - ...(input.expectedCurrentDatamateId !== undefined - ? { expected_current_datamate_id: input.expectedCurrentDatamateId } - : {}), - }, - ...(input.actAs ? { actAs: input.actAs } : {}), - }) + const res = await linkAttempt("rebind_path", input.targetDatamateId, () => + req("PUT", "/by-path", { + body: { + project_path: input.projectPath, + target_datamate_id: input.targetDatamateId, + ...(input.expectedCurrentDatamateId !== undefined + ? { expected_current_datamate_id: input.expectedCurrentDatamateId } + : {}), + }, + ...(input.actAs ? { actAs: input.actAs } : {}), + }), + ) + trackSwitched(res?.binding?.datamate_id ?? input.targetDatamateId, input.expectedCurrentDatamateId, "rebind_path") + return res } /** Populates the "link to existing workspace" picker. Reuses the existing @@ -618,11 +849,7 @@ export namespace WorkspaceApi { // ``datamates`` or ``data`` value (object / string / null) would // otherwise slip through and throw on ``.map`` below, taking the // picker down before it renders. (cubic round 4.) - rows = Array.isArray(body.datamates) - ? body.datamates - : Array.isArray(body.data) - ? body.data - : [] + rows = Array.isArray(body.datamates) ? body.datamates : Array.isArray(body.data) ? body.data : [] } else { rows = [] } diff --git a/packages/opencode/src/altimate/workspace/manage.ts b/packages/opencode/src/altimate/workspace/manage.ts index da24513695..9ec8a3947c 100644 --- a/packages/opencode/src/altimate/workspace/manage.ts +++ b/packages/opencode/src/altimate/workspace/manage.ts @@ -411,6 +411,9 @@ export async function unlink(directory: string): Promise { // a blocking git call — is reached only for a project with no local row, which // is the case unlink exists to repair. let identifier: ProjectIdentifier + // The workspace being unlinked, for telemetry: the local row's id, else the one the + // server lookup below finds (the no-local-row recovery path). + let unlinkingId: number | undefined = was?.datamateId if (was?.repoRemote) identifier = { repoRemote: was.repoRemote } else if (was?.projectPath) identifier = { projectPath: was.projectPath } else { @@ -431,8 +434,9 @@ export async function unlink(directory: string): Promise { if (hit?.matchedBy === "path" && detected.projectPath) { identifier = { projectPath: detected.projectPath } } + unlinkingId ??= hit?.binding?.datamate_id } - const removedServerSide = await WorkspaceApi.unbindProject(identifier) + const removedServerSide = await WorkspaceApi.unbindProject(identifier, unlinkingId) // Only the row unlink started from. A relink that completed while the DELETE // was in flight recorded a new row, and removing that — then memoizing the diff --git a/packages/opencode/test/altimate/altimate-plugin.test.ts b/packages/opencode/test/altimate/altimate-plugin.test.ts index c6dbb791ce..36b003d5d2 100644 --- a/packages/opencode/test/altimate/altimate-plugin.test.ts +++ b/packages/opencode/test/altimate/altimate-plugin.test.ts @@ -5,6 +5,8 @@ import path from "path" import { buildAuthorizeUrl, buildCliContext } from "../../src/altimate/plugin/altimate" import { getOrCreateMachineId } from "../../src/altimate/util/machine-id" import { Config } from "../../src/config/config" +import { Telemetry } from "../../src/altimate/telemetry" +import os from "os" import { tmpdir } from "../fixture/fixture" const VALID_UUID = "550e8400-e29b-41d4-a716-446655440000" @@ -112,7 +114,7 @@ describe("buildCliContext", () => { expect(Object.prototype.hasOwnProperty.call(ctx, "machine_id")).toBe(false) }) - test("omits machine_id when Config.get() throws (fails CLOSED in the worker)", async () => { + test("omits machine_id when Config.get() throws and telemetry is off", async () => { delete process.env.ALTIMATE_TELEMETRY_DISABLED cfg.mockImplementation((async () => { throw new Error("InstanceRef not provided") @@ -120,14 +122,50 @@ describe("buildCliContext", () => { await using dir = await tmpdir() const idPath = path.join(dir.path, "machine-id") fs.writeFileSync(idPath, VALID_UUID, "utf8") + await Telemetry.shutdown() // under the runner the production sink is withheld: telemetry stays off const encoded = await buildCliContext(idPath) const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record - // Config unreadable → must NOT transmit the durable id. + // Config unreadable → the context follows the telemetry module, which is off here. + expect(Telemetry.isEnabled()).toBe(false) expect(Object.prototype.hasOwnProperty.call(ctx, "machine_id")).toBe(false) }) + test("includes machine_id when Config.get() throws but telemetry is on", async () => { + delete process.env.ALTIMATE_TELEMETRY_DISABLED + cfg.mockImplementation((async () => { + throw new Error("InstanceRef not provided") + }) as never) + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, VALID_UUID, "utf8") + const savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + const savedOpencodeOptOut = process.env.OPENCODE_DISABLE_TELEMETRY + delete process.env.OPENCODE_DISABLE_TELEMETRY + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + const home = spyOn(os, "homedir").mockImplementation(() => dir.path) + const net = spyOn(globalThis, "fetch").mockImplementation((async () => new Response("", { status: 200 })) as any) + try { + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.isEnabled()).toBe(true) + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + expect(ctx["machine_id"]).toBe(VALID_UUID) + } finally { + await Telemetry.shutdown() + net.mockRestore() + home.mockRestore() + if (savedCs === undefined) delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + else process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = savedCs + if (savedOpencodeOptOut === undefined) delete process.env.OPENCODE_DISABLE_TELEMETRY + else process.env.OPENCODE_DISABLE_TELEMETRY = savedOpencodeOptOut + } + }) + test("includes machine_id when telemetry is enabled", async () => { delete process.env.ALTIMATE_TELEMETRY_DISABLED await using dir = await tmpdir() diff --git a/packages/opencode/test/altimate/telemetry/id-properties.test.ts b/packages/opencode/test/altimate/telemetry/id-properties.test.ts new file mode 100644 index 0000000000..f0ee99f944 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/id-properties.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdtempSync, rmSync } from "fs" +import os from "os" +import path from "path" +import { Telemetry } from "../../../src/altimate/telemetry" + +describe("numeric ids are envelope properties, not measurements", () => { + let home: string + let posts: string[] + let fetchSpy: ReturnType + let homedirSpy: ReturnType + let savedCs: string | undefined + // Either disable flag wins over the connection string, so a developer or CI environment that + // sets one would fail the envelope assertions; cleared here and restored after. + const DISABLE_FLAGS = ["ALTIMATE_TELEMETRY_DISABLED", "OPENCODE_DISABLE_TELEMETRY"] as const + let savedFlags: Record + + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "telemetry-ids-")) + savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + savedFlags = {} + for (const flag of DISABLE_FLAGS) { + savedFlags[flag] = process.env[flag] + delete process.env[flag] + } + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) + posts = [] + fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + if (url.includes("/v2/track")) posts.push(String(init?.body ?? "")) + return new Response("", { status: 200 }) + }) as any) + }) + + afterEach(async () => { + await Telemetry.shutdown() + fetchSpy.mockRestore() + homedirSpy.mockRestore() + if (savedCs === undefined) delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + else process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = savedCs + for (const flag of DISABLE_FLAGS) { + if (savedFlags[flag] === undefined) delete process.env[flag] + else process.env[flag] = savedFlags[flag] + } + rmSync(home, { recursive: true, force: true }) + }) + + test("workspace ids ride in properties as strings; quantities stay measurements", async () => { + await Telemetry.shutdown() + await Telemetry.init() + Telemetry.track({ + type: "workspace_switched", + timestamp: Date.now(), + session_id: "s", + to_workspace_id: 9, + from_workspace_id: 4, + how: "rebind_remote", + }) + Telemetry.track({ + type: "workspace_api_error", + timestamp: Date.now(), + session_id: "s", + endpoint: "POST /datamate-project-bindings/bind", + status_class: "4xx", + status: 404, + workspace_id: 42, + }) + await Telemetry.flush() + const envelopes = posts.flatMap((b) => JSON.parse(b) as any[]) + const switched = envelopes.find((e) => e.data.baseData.name === "workspace_switched").data.baseData + expect(switched.properties.to_workspace_id).toBe("9") + expect(switched.properties.from_workspace_id).toBe("4") + expect(switched.properties.client).toBe("altimate-code") + expect("to_workspace_id" in switched.measurements).toBe(false) + const apiError = envelopes.find((e) => e.data.baseData.name === "workspace_api_error").data.baseData + expect(apiError.properties.workspace_id).toBe("42") + expect(apiError.measurements.status).toBe(404) // a quantity, still a measurement + expect("workspace_id" in apiError.measurements).toBe(false) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts new file mode 100644 index 0000000000..0809f1797c --- /dev/null +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -0,0 +1,321 @@ +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import os from "os" +import path from "path" + +// Set before the modules under test load: AltimateApi reads Global.Path.home at call time, +// but the sandbox must exist before the first credential read. +const ORIGINAL_TEST_HOME = process.env.OPENCODE_TEST_HOME +const SANDBOX = mkdtempSync(path.join(os.tmpdir(), "link-telemetry-")) +process.env.OPENCODE_TEST_HOME = path.join(SANDBOX, "home") +mkdirSync(path.join(SANDBOX, "home", ".altimate"), { recursive: true }) +const API_URL = "https://api.example.test" +writeFileSync( + path.join(SANDBOX, "home", ".altimate", "altimate.json"), + JSON.stringify({ altimateUrl: API_URL, altimateInstanceName: "acme", altimateApiKey: "test-key" }), +) + +const { + WorkspaceApi, + linkFailureReason, + endpointOf, + altimateRequest, + WorkspaceApiError, + NotFoundError, + ForbiddenError, + ConflictError, +} = await import("../../../src/altimate/workspace/api-client") +const { Telemetry } = await import("../../../src/altimate/telemetry") + +type Reply = { status: number; body?: unknown } | { throw: Error } | { hang: true } +type Event = import("../../../src/altimate/telemetry").Telemetry.Event + +describe("workspace link telemetry", () => { + const ORIGINAL_FETCH = globalThis.fetch + let events: Event[] + let requests: { method: string; url: string; body: unknown }[] + let replies: Reply[] + let trackSpy: ReturnType + + const binding = (id: number) => ({ + binding: { id: 1, datamate_id: id, datamate_name: `ws-${id}`, repo_remote: "git@x:y.git", project_path: null }, + }) + const of = (type: string) => events.filter((e) => e.type === type) as any[] + + beforeEach(() => { + events = [] + requests = [] + replies = [] + trackSpy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + requests.push({ + method: init?.method ?? "GET", + url: String(input), + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }) + const r = replies.shift() ?? { status: 200, body: binding(1) } + if ("throw" in r) throw r.throw + if ("hang" in r) { + await new Promise((_, reject) => + init?.signal?.addEventListener("abort", () => + reject(Object.assign(new Error("aborted"), { name: "AbortError" })), + ), + ) + } + const rr = r as { status: number; body?: unknown } + return new Response(rr.body === undefined ? "" : JSON.stringify(rr.body), { + status: rr.status, + headers: { "content-type": "application/json" }, + }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + trackSpy.mockRestore() + globalThis.fetch = ORIGINAL_FETCH + }) + + afterAll(() => { + if (ORIGINAL_TEST_HOME === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = ORIGINAL_TEST_HOME + rmSync(SANDBOX, { recursive: true, force: true }) + }) + + test("bind to an existing workspace reports workspace_linked on the success response", async () => { + replies = [{ status: 200, body: binding(42) }] + await WorkspaceApi.bindExisting(42, { repoRemote: "git@x:y.git" }) + expect(of("workspace_linked")).toEqual([expect.objectContaining({ workspace_id: 42, how: "bind_existing" })]) + expect(of("workspace_link_failed")).toHaveLength(0) + expect(of("workspace_api_error")).toHaveLength(0) + }) + + test("create-and-bind reports workspace_created then workspace_linked", async () => { + replies = [ + { status: 200, body: { ...binding(7), datamate: { id: 7, name: "ws-7" }, manage_url: "https://app/ws/7" } }, + ] + await WorkspaceApi.createAndBind({ name: "ws-7", identifier: { projectPath: "/p" } }) + expect(of("workspace_created")).toEqual([expect.objectContaining({ workspace_id: 7, how: "create_and_bind" })]) + expect(of("workspace_linked")).toEqual([expect.objectContaining({ workspace_id: 7, how: "create_and_bind" })]) + expect(events.findIndex((e) => e.type === "workspace_created")).toBeLessThan( + events.findIndex((e) => e.type === "workspace_linked"), + ) + }) + + test("an unbound create (the create-then-rebind path) reports workspace_created with how create_unbound", async () => { + replies = [{ status: 200, body: { id: 13 } }] + await WorkspaceApi.createWorkspaceUnbound({ name: "ws-13" }) + expect(of("workspace_created")).toEqual([expect.objectContaining({ workspace_id: 13, how: "create_unbound" })]) + expect(of("workspace_linked")).toHaveLength(0) // the rebind that follows reports the link + + events = [] + replies = [{ status: 200, body: { id: "7" } }] // malformed id: refused, and not reported as created + await expect(WorkspaceApi.createWorkspaceUnbound({ name: "x" })).rejects.toThrow(/no usable id/) + expect(of("workspace_created")).toHaveLength(0) + // ...but it is a failed link step: the answer was unusable, which is not a network failure. + expect(of("workspace_link_failed")).toEqual([expect.objectContaining({ how: "create_unbound", reason: "other" })]) + + events = [] + replies = [{ status: 409, body: { detail: { message: "name taken" } } }] + await expect(WorkspaceApi.createWorkspaceUnbound({ name: "x" })).rejects.toBeInstanceOf(ConflictError) + expect(of("workspace_link_failed")).toEqual([ + expect.objectContaining({ how: "create_unbound", reason: "conflict" }), + ]) + }) + + test("a rebind reports workspace_switched with both ids, then workspace_linked", async () => { + replies = [{ status: 200, body: binding(9) }] + await WorkspaceApi.rebindByRemote({ remote: "git@x:y.git", targetDatamateId: 9, expectedCurrentDatamateId: 4 }) + expect(of("workspace_switched")).toEqual([ + expect.objectContaining({ to_workspace_id: 9, from_workspace_id: 4, how: "rebind_remote" }), + ]) + expect(of("workspace_linked")).toEqual([expect.objectContaining({ workspace_id: 9, how: "rebind_remote" })]) + + events = [] + replies = [{ status: 200, body: binding(11) }] + await WorkspaceApi.rebindByPath({ projectPath: "/p", targetDatamateId: 11 }) + const sw = of("workspace_switched")[0] + expect(sw.to_workspace_id).toBe(11) + expect(sw.how).toBe("rebind_path") + expect("from_workspace_id" in sw).toBe(false) + }) + + test("unlink reports workspace_unlinked with the id the caller knew", async () => { + replies = [{ status: 204 }] + expect(await WorkspaceApi.unbindProject({ repoRemote: "git@x:y.git" }, 42)).toBe(true) + expect(of("workspace_unlinked")).toEqual([expect.objectContaining({ workspace_id: 42 })]) + + events = [] + replies = [{ status: 404, body: { detail: "no binding" } }] + expect(await WorkspaceApi.unbindProject({ repoRemote: "git@x:y.git" })).toBe(false) + expect(of("workspace_unlinked")).toHaveLength(0) + expect(of("workspace_api_error")).toEqual([ + expect.objectContaining({ endpoint: "DELETE /datamate-project-bindings/", status: 404, status_class: "4xx" }), + ]) + }) + + test("failed links report workspace_link_failed with a reason class and the API error", async () => { + replies = [{ status: 404, body: { detail: "gone" } }] + await expect(WorkspaceApi.bindExisting(42, { projectPath: "/p" })).rejects.toBeInstanceOf(NotFoundError) + expect(of("workspace_link_failed")).toEqual([ + expect.objectContaining({ how: "bind_existing", reason: "not_found", workspace_id: 42 }), + ]) + expect(of("workspace_api_error")).toEqual([ + expect.objectContaining({ endpoint: "POST /datamate-project-bindings/bind", status: 404, workspace_id: 42 }), + ]) + expect(of("workspace_linked")).toHaveLength(0) + + events = [] + replies = [{ status: 403, body: { detail: "nope" } }] + await expect(WorkspaceApi.bindExisting(42, { projectPath: "/p" })).rejects.toBeInstanceOf(ForbiddenError) + expect(of("workspace_link_failed")[0].reason).toBe("auth") + expect(of("workspace_auth_expired")).toEqual([expect.objectContaining({ status: 403, workspace_id: 42 })]) + expect(of("workspace_api_error")).toHaveLength(0) + + events = [] + replies = [{ status: 409, body: { detail: { message: "already linked", existing_datamate_id: 3 } } }] + await expect(WorkspaceApi.bindExisting(42, { projectPath: "/p" })).rejects.toBeInstanceOf(ConflictError) + expect(of("workspace_link_failed")[0].reason).toBe("conflict") + + events = [] + replies = [{ throw: new Error("ECONNREFUSED") }] + await expect(WorkspaceApi.rebindByPath({ projectPath: "/p", targetDatamateId: 5 })).rejects.toBeInstanceOf( + WorkspaceApiError, + ) + expect(of("workspace_link_failed")[0]).toEqual(expect.objectContaining({ how: "rebind_path", reason: "network" })) + expect(of("workspace_api_error")[0]).toEqual( + expect.objectContaining({ + endpoint: "PUT /datamate-project-bindings/by-path", + status_class: "network", + workspace_id: 5, + }), + ) + + events = [] + replies = [{ status: 500, body: { detail: "boom" } }] + await expect(WorkspaceApi.createAndBind({ name: "x", identifier: { projectPath: "/p" } })).rejects.toBeInstanceOf( + WorkspaceApiError, + ) + expect(of("workspace_link_failed")[0]).toEqual(expect.objectContaining({ how: "create_and_bind", reason: "other" })) + expect(of("workspace_api_error")[0].status_class).toBe("5xx") + }) + + test("endpoints are masked of ids and a timeout is its own class", async () => { + replies = [{ status: 404, body: { detail: "x" } }] + await expect(WorkspaceApi.getBindingForRemote("git@x:y.git")).resolves.toBeNull() + // 404 on a lookup is an answer, not a failure of the link flow: no link_failed, but the API error row stays. + expect(of("workspace_link_failed")).toHaveLength(0) + expect(of("workspace_api_error")[0].endpoint).toMatch(/^GET \/datamate-project-bindings\//) + + events = [] + replies = [{ hang: true }] + const r = WorkspaceApi.listDatamates() + await expect(r).rejects.toThrow(/timed out/) + expect(of("workspace_api_error")[0]).toEqual(expect.objectContaining({ status_class: "timeout" })) + }, 40_000) + + test("endpoints mask every dynamic segment, and a bad 2xx body is its own class", async () => { + expect(endpointOf("GET", "/datamate-project-bindings", "/by-remote")).toBe( + "GET /datamate-project-bindings/by-remote", + ) + // The datamates router only lists its root here: any other shape is masked whole. + expect(endpointOf("GET", "/datamates", "/123/memory/8f2c0a1e-9b7d-4e11-a0c2-5d6e7f8a9b0c")).toBe( + "GET /datamates/:id/:id/:id", + ) + expect(endpointOf("POST", "/datamates", "/42/skills/pub_Ab12Cd/attach")).toBe( + "POST /datamates/:id/:id/:id/:id", + ) + // A route table, not spelling: ids and file paths are masked whatever they contain, including + // a public id or a file named after a route word. + expect(endpointOf("GET", "/skills", "/pub-many")).toBe("GET /skills/:id") + expect(endpointOf("GET", "/skills", "/status")).toBe("GET /skills/:id") + expect(endpointOf("PUT", "/skills", "/status/datamates")).toBe("PUT /skills/:id/datamates") + expect(endpointOf("GET", "/skills", "/pub_x/files/status")).toBe("GET /skills/:id/files/:path") + expect(endpointOf("GET", "/skills", "/pub_x/files/docs/readme.md")).toBe("GET /skills/:id/files/:path") + expect(endpointOf("GET", "/skills", "/pub_x/unexpected/route")).toBe("GET /skills/:id/:id/:id") + expect(endpointOf("PATCH", "/datamates/memory", "/list")).toBe("PATCH /datamates/memory/list") + expect(endpointOf("PATCH", "/datamates/memory", "/mem_status")).toBe("PATCH /datamates/memory/:id") + expect(endpointOf("POST", "/datamate-project-bindings", "/bind")).toBe("POST /datamate-project-bindings/bind") + expect(endpointOf("DELETE", "/datamate-project-bindings", "/")).toBe("DELETE /datamate-project-bindings/") + expect(endpointOf("GET", "/users", "/me")).toBe("GET /users/me") + expect(endpointOf("GET", "/unknown-router", "/anything/else")).toBe("GET /:id/:id/:id") + expect(endpointOf("GET", "/datamates", "/42/skills/pub_Ab12Cd/files/readme.md")).toBe( + "GET /datamates/:id/:id/:id/:id/:id", + ) + + replies = [{ status: 200 }] // 2xx with an empty body where JSON was required + await expect(WorkspaceApi.listDatamates()).rejects.toThrow(/Empty 200 body/) + expect(of("workspace_api_error")).toEqual([expect.objectContaining({ status_class: "response", status: 200 })]) + }) + + test("a body that stalls after the server answered keeps the answer's status class", async () => { + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => + new Response( + new ReadableStream({ + start(controller) { + init?.signal?.addEventListener("abort", () => + controller.error(Object.assign(new Error("aborted"), { name: "AbortError" })), + ) + }, + }), + { status: 500 }, + )) as typeof globalThis.fetch + const stalled: any = await altimateRequest("GET", "/x", { base: "/datamates", timeoutMs: 200 }).catch((e) => e) + expect(stalled).toBeInstanceOf(WorkspaceApiError) + expect(stalled.message).toMatch(/timed out/) + expect(stalled.status).toBe(500) // the answer's status rides on the error + expect(linkFailureReason(stalled)).toBe("other") // answered: not a network failure + // A known status whose body failed classifies like a readable answer with that status. + expect(linkFailureReason(new WorkspaceApiError("stalled", 404))).toBe("not_found") + expect(linkFailureReason(new WorkspaceApiError("stalled", 403))).toBe("auth") + expect(linkFailureReason(new WorkspaceApiError("stalled", 409))).toBe("conflict") + expect(linkFailureReason(new WorkspaceApiError("stalled", 412))).toBe("conflict") + expect(linkFailureReason(new WorkspaceApiError("cannot reach"))).toBe("network") + expect(linkFailureReason(new WorkspaceApiError("no usable id", undefined, "response"))).toBe("other") + expect(of("workspace_api_error")).toEqual([expect.objectContaining({ status_class: "5xx", status: 500 })]) + + events = [] + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => + new Response( + new ReadableStream({ + start(controller) { + init?.signal?.addEventListener("abort", () => + controller.error(Object.assign(new Error("aborted"), { name: "AbortError" })), + ) + }, + }), + { status: 200 }, + )) as typeof globalThis.fetch + await expect(altimateRequest("GET", "/x", { base: "/datamates", timeoutMs: 200 })).rejects.toThrow(/timed out/) + expect(of("workspace_api_error")).toEqual([expect.objectContaining({ status_class: "timeout", status: 200 })]) + }) + + test("a body that fails after the server answered keeps the answer's status class", async () => { + // Oversized 500: readBounded() refuses the body before the status branch; the row is still a 5xx. + globalThis.fetch = (async (_input: string | URL | Request, _init?: RequestInit) => + new Response("x", { status: 500, headers: { "content-length": "100000000" } })) as typeof globalThis.fetch + await expect(altimateRequest("GET", "/x", { base: "/datamates", boundResponse: true })).rejects.toBeInstanceOf( + WorkspaceApiError, + ) + expect(of("workspace_api_error")).toEqual([expect.objectContaining({ status_class: "5xx", status: 500 })]) + + events = [] + globalThis.fetch = (async (_input: string | URL | Request, _init?: RequestInit) => + new Response("x", { status: 200, headers: { "content-length": "100000000" } })) as typeof globalThis.fetch + await expect(altimateRequest("GET", "/x", { base: "/datamates", boundResponse: true })).rejects.toBeInstanceOf( + WorkspaceApiError, + ) + expect(of("workspace_api_error")).toEqual([expect.objectContaining({ status_class: "response", status: 200 })]) + }) + + test("linkFailureReason classifies every error the client throws", () => { + expect(linkFailureReason(new ForbiddenError("f"))).toBe("auth") + expect(linkFailureReason(new WorkspaceApiError("unauthorized", 401))).toBe("auth") + expect(linkFailureReason(new NotFoundError("n"))).toBe("not_found") + expect(linkFailureReason(new ConflictError({ message: "c" }))).toBe("conflict") + expect(linkFailureReason(new WorkspaceApiError("Cannot reach x: ECONNRESET"))).toBe("network") + expect(linkFailureReason(new WorkspaceApiError("bad", 500))).toBe("other") + expect(linkFailureReason(new Error("whatever"))).toBe("other") + }) +})