From 567d961a1105eaf79f35bdf868faad0e1c6346ab Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 18:06:13 +0800 Subject: [PATCH 01/59] feat: telemetry for the workspace link lifecycle workspace_linked, workspace_created, workspace_switched, workspace_unlinked on the API success response; workspace_link_failed with a reason class; every failed workspace call reports workspace_api_error or workspace_auth_expired; every envelope carries client. The create-and-link hand-off follows the telemetry module's own decision when the config is unreadable. Closes #1508 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/plugin/altimate.ts | 23 +- .../opencode/src/altimate/telemetry/index.ts | 61 +++++ .../src/altimate/workspace/api-client.ts | 219 +++++++++++++++--- .../opencode/src/altimate/workspace/manage.ts | 2 +- .../test/altimate/altimate-plugin.test.ts | 38 ++- .../altimate/workspace/link-telemetry.test.ts | 198 ++++++++++++++++ 6 files changed, 492 insertions(+), 49 deletions(-) create mode 100644 packages/opencode/test/altimate/workspace/link-telemetry.test.ts 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 3bb60e717a..628020cf35 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -538,6 +538,64 @@ 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 + status_class: "4xx" | "5xx" | "network" | "timeout" + status?: number + workspace_id?: number + } + // altimate_change end | { type: "mcp_server_census" timestamp: number @@ -1951,6 +2009,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 }), diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 47c4a9a676..bb8778e45a 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 @@ -173,6 +174,104 @@ 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. +function endpointOf(method: string, basePath: string, subpath: string): string { + return `${method} ${basePath}${subpath}`.replace(/\/\d+(?=\/|$)/g, "/:id") +} + +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" + 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) { + if (err.status === 401) return "auth" + if (err.status === undefined) return "network" // unreachable or timed out: req() sets no status + } + 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,6 +321,8 @@ 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 @@ -266,10 +367,12 @@ 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") { + reportApiError({ endpoint, kind: "timeout", workspaceId: bodyWorkspaceId }) throw new WorkspaceApiError( `Request to ${target} timed out after ${Math.round(timeoutMs / 1000)}s`, ) } + reportApiError({ endpoint, kind: "network", workspaceId: bodyWorkspaceId }) const msg = err instanceof Error ? err.message : String(err) throw new WorkspaceApiError(`Cannot reach ${target}: ${msg}`) } finally { @@ -284,6 +387,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) { @@ -425,7 +536,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 +549,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 +567,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. @@ -539,14 +672,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 +692,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 +716,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 diff --git a/packages/opencode/src/altimate/workspace/manage.ts b/packages/opencode/src/altimate/workspace/manage.ts index da24513695..ed30d911ef 100644 --- a/packages/opencode/src/altimate/workspace/manage.ts +++ b/packages/opencode/src/altimate/workspace/manage.ts @@ -432,7 +432,7 @@ export async function unlink(directory: string): Promise { identifier = { projectPath: detected.projectPath } } } - const removedServerSide = await WorkspaceApi.unbindProject(identifier) + const removedServerSide = await WorkspaceApi.unbindProject(identifier, was?.datamateId) // 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..042dc20a0d 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,46 @@ 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 + 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 + } + }) + 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/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts new file mode 100644 index 0000000000..ec9eeabfef --- /dev/null +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -0,0 +1,198 @@ +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, 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("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("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") + }) +}) From 0c0dd3935c312b18524768e351b52bbe5e2af6fe Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 18:13:05 +0800 Subject: [PATCH 02/59] fix: report workspace_created on the unbound create path too The create-then-rebind flow for an already-linked project creates through createWorkspaceUnbound(); it now tracks workspace_created (how: create_unbound) on the success response and workspace_link_failed otherwise. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/api-client.ts | 33 ++++++++++++------- .../altimate/workspace/link-telemetry.test.ts | 19 +++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index bb8778e45a..c1dc43e7d8 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -635,17 +635,19 @@ 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", - }, - }) + const data = await linkAttempt("create_unbound", undefined, () => + 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 @@ -659,6 +661,15 @@ export namespace WorkspaceApi { `Workspace was created but the server returned no usable id (${JSON.stringify(id) ?? "undefined"}).`, ) } + // 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", + }) return { id, name: input.name } } diff --git a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts index ec9eeabfef..d40f0c1895 100644 --- a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -95,6 +95,25 @@ describe("workspace link telemetry", () => { ) }) + 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) + + 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 }) From b24a1891dead27a554f8fcb060b0510567379f4c Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 18:17:23 +0800 Subject: [PATCH 03/59] fix: attribute recovery-path unlinks, report bad 2xx bodies, mask every id The no-local-row unlink carries the server-found workspace id; an empty, unparseable or oversized 2xx body reports status_class "response"; endpoint masking keeps only route words, so memory and skill public ids never reach telemetry; the plugin test isolates OPENCODE_DISABLE_TELEMETRY. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 3 ++- .../src/altimate/workspace/api-client.ts | 22 +++++++++++++++---- .../test/altimate/altimate-plugin.test.ts | 4 ++++ .../altimate/workspace/link-telemetry.test.ts | 19 +++++++++++++++- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 628020cf35..6feafb19ad 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -591,7 +591,8 @@ export namespace Telemetry { timestamp: number session_id: string endpoint: string - status_class: "4xx" | "5xx" | "network" | "timeout" + /** `response`: the server answered 2xx with an empty, unparseable or oversized body. */ + status_class: "4xx" | "5xx" | "network" | "timeout" | "response" status?: number workspace_id?: number } diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index c1dc43e7d8..a01e82ceb4 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -178,8 +178,15 @@ async function creds(): Promise<{ url: string; instance: string; apiKey: string // `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. -function endpointOf(method: string, basePath: string, subpath: string): string { - return `${method} ${basePath}${subpath}`.replace(/\/\d+(?=\/|$)/g, "/:id") +/** "METHOD /base/path" with every dynamic segment replaced by ":id". Route words are lowercase + * letters with hyphens or underscores (or a version like v3); anything else — numeric ids, + * uuids, memory and skill public ids — is an identifier and must not reach telemetry. */ +export function endpointOf(method: string, basePath: string, subpath: string): string { + const masked = `${basePath}${subpath}` + .split("/") + .map((seg) => (seg === "" || /^(v\d+|[a-z][a-z_-]*)$/.test(seg) ? seg : ":id")) + .join("/") + return `${method} ${masked}` } function workspaceIdIn(body: unknown): number | undefined { @@ -192,7 +199,7 @@ function workspaceIdIn(body: unknown): number | undefined { function reportApiError(input: { endpoint: string status?: number - kind: "4xx" | "5xx" | "network" | "timeout" + kind: "4xx" | "5xx" | "network" | "timeout" | "response" workspaceId?: number }) { const session_id = Telemetry.getContext().sessionId @@ -372,7 +379,13 @@ async function req( `Request to ${target} timed out after ${Math.round(timeoutMs / 1000)}s`, ) } - reportApiError({ endpoint, kind: "network", workspaceId: bodyWorkspaceId }) + // A WorkspaceApiError here came from readBounded(): the server answered, the body was + // over the cap. Everything else is transport. + reportApiError({ + endpoint, + kind: err instanceof WorkspaceApiError ? "response" : "network", + workspaceId: bodyWorkspaceId, + }) const msg = err instanceof Error ? err.message : String(err) throw new WorkspaceApiError(`Cannot reach ${target}: ${msg}`) } finally { @@ -427,6 +440,7 @@ 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) { + reportApiError({ endpoint, status: res.status, kind: "response", workspaceId: bodyWorkspaceId }) throw new WorkspaceApiError( `Empty ${res.status} body from ${target} — expected JSON payload`, res.status, diff --git a/packages/opencode/test/altimate/altimate-plugin.test.ts b/packages/opencode/test/altimate/altimate-plugin.test.ts index 042dc20a0d..36b003d5d2 100644 --- a/packages/opencode/test/altimate/altimate-plugin.test.ts +++ b/packages/opencode/test/altimate/altimate-plugin.test.ts @@ -141,6 +141,8 @@ describe("buildCliContext", () => { 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) @@ -159,6 +161,8 @@ describe("buildCliContext", () => { 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 } }) diff --git a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts index d40f0c1895..4a51341009 100644 --- a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -15,7 +15,7 @@ writeFileSync( JSON.stringify({ altimateUrl: API_URL, altimateInstanceName: "acme", altimateApiKey: "test-key" }), ) -const { WorkspaceApi, linkFailureReason, WorkspaceApiError, NotFoundError, ForbiddenError, ConflictError } = +const { WorkspaceApi, linkFailureReason, endpointOf, WorkspaceApiError, NotFoundError, ForbiddenError, ConflictError } = await import("../../../src/altimate/workspace/api-client") const { Telemetry } = await import("../../../src/altimate/telemetry") @@ -205,6 +205,23 @@ describe("workspace link telemetry", () => { 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", + ) + expect(endpointOf("GET", "/datamates", "/123/memory/8f2c0a1e-9b7d-4e11-a0c2-5d6e7f8a9b0c")).toBe( + "GET /datamates/:id/memory/:id", + ) + expect(endpointOf("POST", "/datamates", "/42/skills/pub_Ab12Cd/attach")).toBe( + "POST /datamates/:id/skills/:id/attach", + ) + expect(endpointOf("GET", "/dbt", "/v3/validate-credentials")).toBe("GET /dbt/v3/validate-credentials") + + 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("linkFailureReason classifies every error the client throws", () => { expect(linkFailureReason(new ForbiddenError("f"))).toBe("auth") expect(linkFailureReason(new WorkspaceApiError("unauthorized", 401))).toBe("auth") From 2e4f6f1b475ed14d25378482ef30b1796e9b39ef Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 18:18:54 +0800 Subject: [PATCH 04/59] fix: carry the server-found workspace id through a recovery-path unlink When no local row exists, manage.ts looks the binding up on the server before unlinking; the id it finds now reaches workspace_unlinked. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/workspace/manage.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/manage.ts b/packages/opencode/src/altimate/workspace/manage.ts index ed30d911ef..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, was?.datamateId) + 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 From 4c8a213af608286994b647c2a40139752249a6a0 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 18:22:45 +0800 Subject: [PATCH 05/59] fix: send workspace ids as envelope properties, not measurements The envelope builder routed every number to measurements, so the new numeric workspace ids landed in customMeasurements; *_id fields now go to properties as strings, where the dashboards join on them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 4 +- .../altimate/telemetry/id-properties.test.ts | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/altimate/telemetry/id-properties.test.ts diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 6feafb19ad..eee7f1c258 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2027,7 +2027,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/test/altimate/telemetry/id-properties.test.ts b/packages/opencode/test/altimate/telemetry/id-properties.test.ts new file mode 100644 index 0000000000..64010a4592 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/id-properties.test.ts @@ -0,0 +1,69 @@ +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 + + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "telemetry-ids-")) + savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + 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 + 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) + }) +}) From db4b1215dccf0394eefc36447762af0e7a1981a5 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 18:27:57 +0800 Subject: [PATCH 06/59] fix: mask endpoints by route vocabulary, keep the status of a body that fails after the answer Only known static route words survive in the endpoint label, so an id spelled like a word is still masked; a body refused after the server answered (readBounded's cap) reports that answer's status class. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/api-client.ts | 38 +++++++++++++------ .../altimate/workspace/link-telemetry.test.ts | 36 ++++++++++++++++-- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index a01e82ceb4..3d54e00bb7 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -178,13 +178,21 @@ async function creds(): Promise<{ url: string; instance: string; apiKey: string // `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. -/** "METHOD /base/path" with every dynamic segment replaced by ":id". Route words are lowercase - * letters with hyphens or underscores (or a version like v3); anything else — numeric ids, - * uuids, memory and skill public ids — is an identifier and must not reach telemetry. */ +/** The static segments of the routes the workspace clients call. Anything not in this list — + * numeric ids, uuids, memory ids, skill public ids however they are spelled — is replaced by + * ":id" in telemetry. A new static word that is missing here is masked too, which only costs + * detail; the list must never be widened with a dynamic value. */ +const ROUTE_WORDS = new Set([ + "datamate-project-bindings", "bind", "by-remote", "by-path", "datamates", "users", "me", + "memory", "blocks", "skills", "files", "list", "attach", "publish", "sync", "status", "search", + "versions", "lessons", "learned-lessons", "content", "download", +]) + +/** "METHOD /base/path" with every dynamic segment replaced by ":id" (see ROUTE_WORDS). */ export function endpointOf(method: string, basePath: string, subpath: string): string { const masked = `${basePath}${subpath}` .split("/") - .map((seg) => (seg === "" || /^(v\d+|[a-z][a-z_-]*)$/.test(seg) ? seg : ":id")) + .map((seg) => (seg === "" || ROUTE_WORDS.has(seg) ? seg : ":id")) .join("/") return `${method} ${masked}` } @@ -334,6 +342,7 @@ async function req( 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, @@ -345,6 +354,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 @@ -379,13 +389,19 @@ async function req( `Request to ${target} timed out after ${Math.round(timeoutMs / 1000)}s`, ) } - // A WorkspaceApiError here came from readBounded(): the server answered, the body was - // over the cap. Everything else is transport. - reportApiError({ - endpoint, - kind: err instanceof WorkspaceApiError ? "response" : "network", - workspaceId: bodyWorkspaceId, - }) + // 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) throw new WorkspaceApiError(`Cannot reach ${target}: ${msg}`) } finally { diff --git a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts index 4a51341009..5134880755 100644 --- a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -15,8 +15,16 @@ writeFileSync( JSON.stringify({ altimateUrl: API_URL, altimateInstanceName: "acme", altimateApiKey: "test-key" }), ) -const { WorkspaceApi, linkFailureReason, endpointOf, WorkspaceApiError, NotFoundError, ForbiddenError, ConflictError } = - await import("../../../src/altimate/workspace/api-client") +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 } @@ -215,13 +223,35 @@ describe("workspace link telemetry", () => { expect(endpointOf("POST", "/datamates", "/42/skills/pub_Ab12Cd/attach")).toBe( "POST /datamates/:id/skills/:id/attach", ) - expect(endpointOf("GET", "/dbt", "/v3/validate-credentials")).toBe("GET /dbt/v3/validate-credentials") + // Spelling is not evidence: a public id that looks like a route word is still an id. + expect(endpointOf("GET", "/skills", "/pub-many")).toBe("GET /skills/:id") + expect(endpointOf("GET", "/datamates", "/42/skills/pub_Ab12Cd/files/readme.md")).toBe( + "GET /datamates/:id/skills/:id/files/: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 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") From d55f1cf84342e172bf344e3a714399a920448cb0 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 18:34:49 +0800 Subject: [PATCH 07/59] fix: mask endpoint ids by route position; keep the status when the body stalls On identifier-first routers (skills, memory) the first subpath segment is masked whatever it is spelled like; a body that times out after the server answered reports that answer's status class with the status. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/api-client.ts | 38 +++++++++++++----- .../altimate/workspace/link-telemetry.test.ts | 40 ++++++++++++++++++- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 3d54e00bb7..752f6afd99 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -178,23 +178,35 @@ async function creds(): Promise<{ url: string; instance: string; apiKey: string // `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. -/** The static segments of the routes the workspace clients call. Anything not in this list — - * numeric ids, uuids, memory ids, skill public ids however they are spelled — is replaced by - * ":id" in telemetry. A new static word that is missing here is masked too, which only costs - * detail; the list must never be widened with a dynamic value. */ +/** Endpoint labels mask by ROUTE POSITION, not by spelling. The routers whose subpaths start + * with a static word are listed here; on every other base (``/skills``, ``/datamates/memory``) + * the first subpath segment is an identifier and is masked whatever it is spelled like — a + * public id equal to a route word must not reach telemetry. Segments after that, and the + * segments of the base itself, are kept only when they are known static words; an unknown + * static word is masked too, which only costs detail. A new router with a static first segment + * is added here; a new identifier-first router needs nothing. */ +const STATIC_FIRST_SEGMENT_BASES = new Set(["/datamate-project-bindings", "/datamates", "/users"]) const ROUTE_WORDS = new Set([ "datamate-project-bindings", "bind", "by-remote", "by-path", "datamates", "users", "me", "memory", "blocks", "skills", "files", "list", "attach", "publish", "sync", "status", "search", "versions", "lessons", "learned-lessons", "content", "download", ]) -/** "METHOD /base/path" with every dynamic segment replaced by ":id" (see ROUTE_WORDS). */ +/** "METHOD /base/path" with every dynamic segment replaced by ":id" (see the note above). */ export function endpointOf(method: string, basePath: string, subpath: string): string { - const masked = `${basePath}${subpath}` + const word = (seg: string) => (seg === "" || ROUTE_WORDS.has(seg) ? seg : ":id") + const base = basePath.split("/").map(word).join("/") + let first = true + const sub = subpath .split("/") - .map((seg) => (seg === "" || ROUTE_WORDS.has(seg) ? seg : ":id")) + .map((seg) => { + if (seg === "") return seg + const idPosition = first && !STATIC_FIRST_SEGMENT_BASES.has(basePath) + first = false + return idPosition ? ":id" : word(seg) + }) .join("/") - return `${method} ${masked}` + return `${method} ${base}${sub}` } function workspaceIdIn(body: unknown): number | undefined { @@ -384,7 +396,15 @@ 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") { - reportApiError({ endpoint, kind: "timeout", workspaceId: bodyWorkspaceId }) + // 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, + }) throw new WorkspaceApiError( `Request to ${target} timed out after ${Math.round(timeoutMs / 1000)}s`, ) diff --git a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts index 5134880755..64fb76c4c5 100644 --- a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -223,8 +223,15 @@ describe("workspace link telemetry", () => { expect(endpointOf("POST", "/datamates", "/42/skills/pub_Ab12Cd/attach")).toBe( "POST /datamates/:id/skills/:id/attach", ) - // Spelling is not evidence: a public id that looks like a route word is still an id. + // Position, not spelling: on an identifier-first router the first segment is an id whatever + // it looks like, including a public id that equals 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("PATCH", "/datamates/memory", "/list")).toBe("PATCH /datamates/memory/:id") + expect(endpointOf("POST", "/datamate-project-bindings", "/bind")).toBe("POST /datamate-project-bindings/bind") + 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/skills/:id/files/:id", ) @@ -234,6 +241,37 @@ describe("workspace link telemetry", () => { 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 + await expect(altimateRequest("GET", "/x", { base: "/datamates", timeoutMs: 200 })).rejects.toThrow(/timed out/) + 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) => From bfd7ecbd44832b8d3cc70c08a565dc386db8d2a7 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:17:14 +0800 Subject: [PATCH 08/59] fix: label endpoints from a route table Each router lists the shapes of its subpaths; ids and file paths are masked whatever they contain, and an unlisted shape or router is masked entirely. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/api-client.ts | 106 +++++++++--------- .../altimate/workspace/link-telemetry.test.ts | 18 ++- 2 files changed, 68 insertions(+), 56 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 752f6afd99..320106e603 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -178,35 +178,45 @@ async function creds(): Promise<{ url: string; instance: string; apiKey: string // `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 mask by ROUTE POSITION, not by spelling. The routers whose subpaths start - * with a static word are listed here; on every other base (``/skills``, ``/datamates/memory``) - * the first subpath segment is an identifier and is masked whatever it is spelled like — a - * public id equal to a route word must not reach telemetry. Segments after that, and the - * segments of the base itself, are kept only when they are known static words; an unknown - * static word is masked too, which only costs detail. A new router with a static first segment - * is added here; a new identifier-first router needs nothing. */ -const STATIC_FIRST_SEGMENT_BASES = new Set(["/datamate-project-bindings", "/datamates", "/users"]) -const ROUTE_WORDS = new Set([ - "datamate-project-bindings", "bind", "by-remote", "by-path", "datamates", "users", "me", - "memory", "blocks", "skills", "files", "list", "attach", "publish", "sync", "status", "search", - "versions", "lessons", "learned-lessons", "content", "download", -]) - -/** "METHOD /base/path" with every dynamic segment replaced by ":id" (see the note above). */ +/** 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 word = (seg: string) => (seg === "" || ROUTE_WORDS.has(seg) ? seg : ":id") - const base = basePath.split("/").map(word).join("/") - let first = true - const sub = subpath - .split("/") - .map((seg) => { - if (seg === "") return seg - const idPosition = first && !STATIC_FIRST_SEGMENT_BASES.has(basePath) - first = false - return idPosition ? ":id" : word(seg) - }) - .join("/") - return `${method} ${base}${sub}` + 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 { @@ -253,7 +263,7 @@ export function linkFailureReason(err: unknown): "auth" | "not_found" | "network if (err instanceof ConflictError || err instanceof PreconditionFailedError) return "conflict" if (err instanceof WorkspaceApiError) { if (err.status === 401) return "auth" - if (err.status === undefined) return "network" // unreachable or timed out: req() sets no status + if (err.status === undefined) return "network" // unreachable or timed out: req() sets no status } return "other" } @@ -405,9 +415,7 @@ async function req( kind: answered === undefined ? "timeout" : answered >= 500 ? "5xx" : answered >= 400 ? "4xx" : "timeout", workspaceId: bodyWorkspaceId, }) - throw new WorkspaceApiError( - `Request to ${target} timed out after ${Math.round(timeoutMs / 1000)}s`, - ) + throw new WorkspaceApiError(`Request to ${target} timed out after ${Math.round(timeoutMs / 1000)}s`) } // 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 @@ -477,10 +485,7 @@ async function req( // and .foo throws in a way the typed switches can't classify. (m7 + CR) if (json == null && !opts.allowEmptyBody) { reportApiError({ endpoint, status: res.status, kind: "response", workspaceId: bodyWorkspaceId }) - throw new WorkspaceApiError( - `Empty ${res.status} body from ${target} — expected JSON payload`, - res.status, - ) + throw new WorkspaceApiError(`Empty ${res.status} body from ${target} — expected JSON payload`, res.status) } return json as T } @@ -507,9 +512,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 } @@ -523,9 +526,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) } @@ -541,7 +542,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 @@ -552,7 +556,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 @@ -564,7 +571,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" } @@ -824,11 +834,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/test/altimate/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts index 64fb76c4c5..097624da6d 100644 --- a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -217,23 +217,29 @@ describe("workspace link telemetry", () => { 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/memory/:id", + "GET /datamates/:id/:id/:id", ) expect(endpointOf("POST", "/datamates", "/42/skills/pub_Ab12Cd/attach")).toBe( - "POST /datamates/:id/skills/:id/attach", + "POST /datamates/:id/:id/:id/:id", ) - // Position, not spelling: on an identifier-first router the first segment is an id whatever - // it looks like, including a public id that equals a route word. + // 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("PATCH", "/datamates/memory", "/list")).toBe("PATCH /datamates/memory/:id") + 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/skills/:id/files/:id", + "GET /datamates/:id/:id/:id/:id/:id", ) replies = [{ status: 200 }] // 2xx with an empty body where JSON was required From e1474e7e250ebcf06e53e23f9d13871bd3c13c4a Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:44:48 +0800 Subject: [PATCH 09/59] test: keep the id-properties test clear of the telemetry disable flags Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../test/altimate/telemetry/id-properties.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/opencode/test/altimate/telemetry/id-properties.test.ts b/packages/opencode/test/altimate/telemetry/id-properties.test.ts index 64010a4592..f0ee99f944 100644 --- a/packages/opencode/test/altimate/telemetry/id-properties.test.ts +++ b/packages/opencode/test/altimate/telemetry/id-properties.test.ts @@ -10,10 +10,19 @@ describe("numeric ids are envelope properties, not measurements", () => { 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) @@ -31,6 +40,10 @@ describe("numeric ids are envelope properties, not measurements", () => { 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 }) }) From f48d0a621070cb8939e7efbe8c5fc8e14e4799d1 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:04:59 +0800 Subject: [PATCH 10/59] fix: keep the answered status on a failed link; check the created id inside the attempt A body that fails after the server answered now throws with that status, so the link failure is not classed as network; a create whose id is unusable is a failed link step, not a created workspace. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/api-client.ts | 49 ++++++++++++------- .../altimate/workspace/link-telemetry.test.ts | 8 ++- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 320106e603..f38bfad57e 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -154,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" @@ -263,7 +266,7 @@ export function linkFailureReason(err: unknown): "auth" | "not_found" | "network if (err instanceof ConflictError || err instanceof PreconditionFailedError) return "conflict" if (err instanceof WorkspaceApiError) { if (err.status === 401) return "auth" - if (err.status === undefined) return "network" // unreachable or timed out: req() sets no status + if (err.status === undefined && err.failure === "transport") return "network" // unreachable or timed out } return "other" } @@ -415,7 +418,9 @@ async function req( kind: answered === undefined ? "timeout" : answered >= 500 ? "5xx" : answered >= 400 ? "4xx" : "timeout", workspaceId: bodyWorkspaceId, }) - throw new WorkspaceApiError(`Request to ${target} timed out after ${Math.round(timeoutMs / 1000)}s`) + // 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 @@ -431,6 +436,7 @@ async function req( 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) @@ -695,8 +701,10 @@ export namespace WorkspaceApi { name: string description?: string }): Promise<{ id: number; name: string }> { - const data = await linkAttempt("create_unbound", undefined, () => - req<{ id: number }>("POST", "/", { + // 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, @@ -706,21 +714,24 @@ export namespace WorkspaceApi { 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"}).`, - ) - } + }) + // `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({ diff --git a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts index 097624da6d..4ea6254d0c 100644 --- a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -113,6 +113,8 @@ describe("workspace link telemetry", () => { 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" } } }] @@ -259,7 +261,11 @@ describe("workspace link telemetry", () => { }), { status: 500 }, )) as typeof globalThis.fetch - await expect(altimateRequest("GET", "/x", { base: "/datamates", timeoutMs: 200 })).rejects.toThrow(/timed out/) + 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 expect(of("workspace_api_error")).toEqual([expect.objectContaining({ status_class: "5xx", status: 500 })]) events = [] From 723915fa47329d5fa53fdfc75480bd6132fa7d06 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:12:23 +0800 Subject: [PATCH 11/59] fix: classify a failed link by the answered status A 403, 404, 409 or 412 whose body then fails is the same class as a readable answer with that status, not other. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/workspace/api-client.ts | 6 +++++- .../test/altimate/workspace/link-telemetry.test.ts | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index f38bfad57e..6de32e0e2d 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -265,7 +265,11 @@ export function linkFailureReason(err: unknown): "auth" | "not_found" | "network if (err instanceof NotFoundError) return "not_found" if (err instanceof ConflictError || err instanceof PreconditionFailedError) return "conflict" if (err instanceof WorkspaceApiError) { - if (err.status === 401) return "auth" + // 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" diff --git a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts index 4ea6254d0c..0809f1797c 100644 --- a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -266,6 +266,13 @@ describe("workspace link telemetry", () => { 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 = [] From d1fc90304df2b467e9bcb266b32e6b9685afd2fd Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 18:23:17 +0800 Subject: [PATCH 12/59] feat: the bound workspace rides on every event of a session Each prompt turn resolves the project's local binding (no network) into the telemetry context, and the envelope builder stamps workspace_id on every row of the session; a row's own id wins. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 14 +++- packages/opencode/src/session/prompt.ts | 13 ++- .../telemetry/workspace-context.test.ts | 81 +++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/altimate/telemetry/workspace-context.test.ts diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index eee7f1c258..cf951a25f0 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1930,6 +1930,11 @@ export namespace Telemetry { let machineId = "" let sessionId = "" let projectId = "" + // altimate_change — the workspace the current session's project is linked to (the backend + // datamate id), set per prompt turn from the local binding cache; stamped on every envelope so + // the session, first-use, skill, memory, tool and outcome rows join the workspace without each + // emit site knowing about it. + let workspaceId: number | undefined let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 let initPromise: Promise | undefined @@ -2014,6 +2019,7 @@ export namespace Telemetry { // still says how this CLI was launched (cli, tui, serve, datamates). client: "altimate-code", source: clientSource, + ...(workspaceId !== undefined && !("workspace_id" in fields) && { workspace_id: String(workspaceId) }), project_id: fields.project_id ?? projectId, ...(machineId && { machine_id: machineId }), // altimate_change — groups every event from one process launch. The onboarding funnel @@ -2193,13 +2199,14 @@ export namespace Telemetry { } } - export function setContext(opts: { sessionId: string; projectId: string }) { + export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }) { sessionId = opts.sessionId projectId = opts.projectId + workspaceId = opts.workspaceId } - export function getContext() { - return { sessionId, projectId } + export function getContext(): { sessionId: string; projectId: string; workspaceId?: number } { + return { sessionId, projectId, ...(workspaceId !== undefined ? { workspaceId } : {}) } } /** Returns true only after init() has completed and telemetry is enabled. */ @@ -2563,6 +2570,7 @@ export namespace Telemetry { droppedEvents = 0 sessionId = "" projectId = "" + workspaceId = undefined machineId = "" // doInit() only assigns userEmail when Account.active() returns one, so without this a // logout followed by a re-init in the same process kept hashing the previous account into diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f6e2c0c0f8..0d0350eef0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -118,6 +118,9 @@ import { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-sourc // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_change — error_fingerprint payload +// altimate_change start — the session's bound workspace rides on every telemetry row +import { readLocalBinding } from "@/altimate/workspace/state" +// altimate_change end import { TraceContext } from "@/altimate/observability/trace-context" // altimate_change — client trace per turn import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // altimate_change — onboarding funnel // altimate_change start — keyless public Zen predicate (flat module) @@ -763,7 +766,15 @@ export namespace SessionPrompt { throw e } // altimate_change end - Telemetry.setContext({ sessionId: sessionID, projectId: Instance.project?.id ?? "" }) + // altimate_change start — the workspace this project is linked to (local binding cache, no + // network) goes on the telemetry context, so every row of the turn carries workspace_id. + const boundWorkspace = await readLocalBinding(Instance.directory).catch(() => null) + Telemetry.setContext({ + sessionId: sessionID, + projectId: Instance.project?.id ?? "", + workspaceId: boundWorkspace?.datamateId, + }) + // altimate_change end // altimate_change start — the learn kill switch disables new delivery, while retaining trusted note cleanup. // An unused project pays only an existence check; do not load stores, migrate, // create state, or even schedule async learning work on that default-off path. diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts new file mode 100644 index 0000000000..297798c451 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -0,0 +1,81 @@ +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("workspace id on the telemetry context", () => { + let home: string + let posts: string[] + let fetchSpy: ReturnType + let homedirSpy: ReturnType + let savedCs: string | undefined + let savedHome: string | undefined + + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "telemetry-ws-")) + savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + savedHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = home + 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 + if (savedHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = savedHome + rmSync(home, { recursive: true, force: true }) + }) + + const envelopes = () => posts.flatMap((b) => JSON.parse(b) as any[]) + const startup = (command: string) => + ({ + type: "startup_ready", + session_id: "", + command, + surface: command, + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) as const + + test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { + await Telemetry.shutdown() + await Telemetry.init() + Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) + expect(Telemetry.getContext().workspaceId).toBe(42) + + Telemetry.track(startup("bound")) + Telemetry.track({ + type: "workspace_linked", + timestamp: Date.now(), + session_id: "s1", + workspace_id: 7, + how: "bind_existing", + }) + await Telemetry.flush() + const bound = envelopes().find((e) => e.data.baseData.properties.command === "bound") + expect(bound.data.baseData.properties.workspace_id).toBe("42") + expect(bound.data.baseData.properties.client).toBe("altimate-code") + const linked = envelopes().find((e) => e.data.baseData.name === "workspace_linked") + expect(linked.data.baseData.properties.workspace_id).toBe("7") // the row's own id, not the context's + + Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) + Telemetry.track(startup("unbound")) + await Telemetry.flush() + const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") + expect("workspace_id" in unbound.data.baseData.properties).toBe(false) + }) +}) From 7a4364af9da51052f04e786e5d1496640e5a5be0 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:00:08 +0800 Subject: [PATCH 13/59] feat: usage rows of a linked workspace Workspace skills, memory and integration calls carry a workspace value and are counted per session for session_end and task_outcome_signal; new starter_shown, workspace_memory_contributed and workspace_sync_failed; skill_published carries the workspace id. Closes #1510 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 63 ++++++- .../src/altimate/workspace/memory-sync.ts | 70 ++++---- .../src/altimate/workspace/starter.ts | 38 ++++- .../src/altimate/workspace/sync-state.ts | 27 +++ packages/opencode/src/cli/cmd/skill.ts | 1 + packages/opencode/src/memory/prompt.ts | 20 +++ packages/opencode/src/server/server.ts | 1 + packages/opencode/src/session/prompt.ts | 20 +++ packages/opencode/src/tool/skill.ts | 9 +- packages/opencode/src/tool/tool.ts | 14 ++ .../telemetry/workspace-context.test.ts | 15 ++ .../altimate/workspace/memory-sync.test.ts | 156 ++++++++++++------ .../test/altimate/workspace/starter.test.ts | 47 +++++- .../altimate/workspace/sync-state.test.ts | 55 +++++- .../test/memory/overlay-merge.test.ts | 43 ++++- .../opencode/test/tool/skill-source.test.ts | 7 + 16 files changed, 483 insertions(+), 103 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index cf951a25f0..d67f8578ce 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -258,6 +258,11 @@ export namespace Telemetry { total_tokens: number tool_call_count: number duration_ms: number + // altimate_change — what of the bound workspace this session used (see noteWorkspaceUse) + workspace_skills_used?: number + workspace_documents_used?: number + workspace_memory_blocks?: number + workspace_integration_calls?: number } | { type: "generation" @@ -316,6 +321,8 @@ export namespace Telemetry { previous_tool: string | null input_signature?: string error?: string + // altimate_change — the call went to the bound workspace (a datamate MCP server) + via_workspace?: boolean } | { type: "native_call" @@ -596,6 +603,34 @@ export namespace Telemetry { status?: number workspace_id?: number } + | { + /** The post-link starter message was served (TUI or extension chat); what the workspace provides. */ + type: "starter_shown" + timestamp: number + session_id: string + workspace_id: number + skills: number + integrations: number + knowledge: string + memory_enabled: boolean + } + | { + /** A memory block was written back to the bound workspace's cloud memory. */ + type: "workspace_memory_contributed" + timestamp: number + session_id: string + workspace_id: number + block_count: number + } + | { + /** A kind's sync status flipped to error. */ + type: "workspace_sync_failed" + timestamp: number + session_id: string + workspace_id: number + kind: "skills" | "memory" + reason: "auth" | "not_found" | "network" | "other" + } // altimate_change end | { type: "mcp_server_census" @@ -633,6 +668,8 @@ export namespace Telemetry { total_chars: number budget: number scopes_used: string[] + /** Blocks that came from the bound workspace's cloud memory (overlay). */ + workspace_blocks?: number } | { // Sent before connecting, so an attempt that hangs or ends the process is still counted. @@ -701,7 +738,7 @@ export namespace Telemetry { session_id: string message_id: string skill_name: string - skill_source: "builtin" | "global" | "project" + skill_source: "builtin" | "global" | "project" | "workspace" duration_ms: number // altimate_change start — skill trigger classification for discovery analytics trigger: "user_command" | "llm_selected" | "auto_suggested" | "unknown" @@ -815,6 +852,8 @@ export namespace Telemetry { file_count: number // "serve": published from the IDE extension over the serve route source: "cli" | "tui" | "serve" + // altimate_change — the workspace the skill was attached to + workspace_id?: number } // altimate_change end // altimate_change start — plan refinement telemetry event @@ -928,6 +967,8 @@ export namespace Telemetry { duration_ms: number /** Last tool category the agent used (or "none") */ last_tool_category: string + // altimate_change — true when the session had used workspace skills, memory, documents or integrations + workspace_context_used?: boolean } // task intent classification for understanding DE problem distribution | { @@ -2199,6 +2240,26 @@ export namespace Telemetry { } } + // altimate_change start — per-session use of the bound workspace. The skill, memory and tool + // emit sites note what they used; session_end reports the counts and task_outcome_signal + // whether any workspace context was in play. Keyed by session so a serve process with several + // sessions keeps them apart; cleared when the session ends. + export type WorkspaceUse = { skills: number; documents: number; memory: number; integrations: number } + const workspaceUse = new Map() + export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1) { + if (!sessionId || n <= 0) return + const use = workspaceUse.get(sessionId) ?? { skills: 0, documents: 0, memory: 0, integrations: 0 } + use[kind] += n + workspaceUse.set(sessionId, use) + } + export function workspaceUseOf(sessionId: string): WorkspaceUse { + return { skills: 0, documents: 0, memory: 0, integrations: 0, ...workspaceUse.get(sessionId) } + } + export function clearWorkspaceUse(sessionId: string) { + workspaceUse.delete(sessionId) + } + // altimate_change end + export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }) { sessionId = opts.sessionId projectId = opts.projectId diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 63cff1314f..2213a60502 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -16,6 +16,7 @@ // Nothing is written to disk: local files are the source of truth, and a cloud // record can have been edited elsewhere by a client that does not preserve this // CLI's metadata. +import { Telemetry } from "@/altimate/telemetry" import { createHash } from "node:crypto" import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { Flag } from "@/flag/flag" @@ -379,11 +380,7 @@ function stripTrainingMeta(content: string): string { /** Fingerprint of everything about a block that reaches the store. Tags and * expiry are included because both are mirrored. */ function contentHash(block: MemoryBlock): string { - const payload = JSON.stringify([ - stripTrainingMeta(block.content), - [...block.tags].sort(), - block.expires ?? "", - ]) + const payload = JSON.stringify([stripTrainingMeta(block.content), [...block.tags].sort(), block.expires ?? ""]) // sha-256, not a 32-bit non-cryptographic hash. This value is the single // gate deciding whether a save is sent at all: on a collision ``push`` // returns "unchanged" and a real edit is silently never mirrored, with no @@ -702,7 +699,9 @@ async function reapArchivedBlocks( try { const gone = syncInternals.removeBlock ? await syncInternals.removeBlock(scope, blockId, directory ?? undefined, block.updated) - : await (await import("@/memory/store")).MemoryStore.remove(scope, blockId, directory ?? undefined, { + : await ( + await import("@/memory/store") + ).MemoryStore.remove(scope, blockId, directory ?? undefined, { expectUpdated: block.updated, }) if (gone) { @@ -770,9 +769,7 @@ async function push( // user recreated under an old id gets a fresh record instead of un-archiving // the old one (`MemoryApi.update` replaces metadata wholesale, which would // drop the archived marker). - const indexed = existing?.memoryId - ? view.records.find((r) => r.id === existing.memoryId) - : undefined + const indexed = existing?.memoryId ? view.records.find((r) => r.id === existing.memoryId) : undefined // A record the index names but a COMPLETE record set does not hold is gone — // deleted in the web app, by another client, or by a wipe. Absence used to // read as "still ours", so `match` stayed pointing at a dead id and every @@ -816,6 +813,7 @@ async function push( const others = copies.filter((r) => r.id !== match).map((r) => r.id) await archiveDuplicates(others, block, metadata, "archiving a second live record of one block") await recordIndexEntry(key, { memoryId: match, contentHash: hash, syncedAt: Date.now() }) + if (binding) trackContributed(binding) return "stored" } @@ -848,6 +846,7 @@ async function push( // leaves the block unindexed, and the next save finds the extras by identity. await archiveDuplicates(extras, block, metadata, "archiving an extra record produced by one create") await recordIndexEntry(key, { memoryId: primary, contentHash: hash, syncedAt: Date.now() }) + if (binding) trackContributed(binding) return "stored" } @@ -866,6 +865,22 @@ async function archiveDuplicates( } } +// altimate_change start — a successful write-back is the workspace getting something back +function trackContributed(binding: CachedBinding) { + try { + Telemetry.track({ + type: "workspace_memory_contributed", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: binding.datamateId, + block_count: 1, + }) + } catch { + // telemetry never blocks a mirror + } +} +// altimate_change end + /** Cloud operations for one logical block, run one at a time. * * Both callers are fire-and-forget from the store's write and delete paths, so @@ -992,11 +1007,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi /** Archive a block's cloud record rather than deleting it, so the workspace * keeps the history. Only this client filters the marker — other readers do * not — so an archived record stays visible elsewhere. */ -export async function archiveBlock( - scope: "global" | "project", - blockId: string, - directory?: string, -): Promise { +export async function archiveBlock(scope: "global" | "project", blockId: string, directory?: string): Promise { if (!isEnabled()) return // Queued behind any in-flight mirror for the same block, so a delete cannot // run before the create it is meant to undo. The binding lookup happens @@ -1015,11 +1026,7 @@ export async function archiveBlock( ) } -async function archiveNow( - scope: "global" | "project", - blockId: string, - binding: CachedBinding, -): Promise { +async function archiveNow(scope: "global" | "project", blockId: string, binding: CachedBinding): Promise { const key = indexKey({ scope, blockId, @@ -1193,9 +1200,7 @@ async function pollMemoKey(binding: CachedBinding): Promise { * * An "unknown" is never memoized: the next tick should ask again rather than * inherit a network blip. */ -export async function memoryEnabledForPoller( - binding: CachedBinding, -): Promise<"enabled" | "disabled" | "unknown"> { +export async function memoryEnabledForPoller(binding: CachedBinding): Promise<"enabled" | "disabled" | "unknown"> { // The scoped memo is the poller's only cache. `memoryEnabledCache` is keyed by // bare workspace id — right for the write path, which is tenant-bound by its // credentials — but consulting it here reopened a 60s cross-tenant window the @@ -1204,8 +1209,7 @@ export async function memoryEnabledForPoller( // minutes per tenant is the price, and `memoryStatus` still warms both. const key = await pollMemoKey(binding) const memo = pollMemo.get(key) - if (memo && Date.now() - memo.at < (memo.status === "unknown" ? POLL_UNKNOWN_TTL_MS : POLL_TTL_MS)) - return memo.status + if (memo && Date.now() - memo.at < (memo.status === "unknown" ? POLL_UNKNOWN_TTL_MS : POLL_TTL_MS)) return memo.status // The in-flight ask is memoized too, not only the settled answer. Two // refreshes overlapping on a cold memo — a remount while a slow one is // still out — both saw it empty and both put a request on the wire. @@ -1340,7 +1344,8 @@ export async function backfill( // ``gated`` says the sweep never ran, as opposed to running and storing // nothing. A caller recording "this binding is seeded" must be able to tell // those apart: memory being off is not a completed seed. - if (!isEnabled()) return { ok: 0, failed: 0, skipped: 0, declined: 0, deferred: 0, gated: true, gateReason: "local-off" } + if (!isEnabled()) + return { ok: 0, failed: 0, skipped: 0, declined: 0, deferred: 0, gated: true, gateReason: "local-off" } // The bind path passes the binding it just recorded; there is no ambient // instance to resolve one from on the `link` subcommand. const binding = explicitBinding ?? (await currentBinding()) @@ -1370,10 +1375,7 @@ export async function backfill( log.info("workspace memory backfill starting", { pending: pending.length, skipped }) const result = await runQueue( pending, - (item) => - serialize(item.block.scope, item.block.id, () => - push(item.block, item.binding, known, sweepDirectory), - ), + (item) => serialize(item.block.scope, item.block.id, () => push(item.block, item.binding, known, sweepDirectory)), BACKFILL_CONCURRENCY, ) // `skipped` combines blocks filtered before the queue (already synced, or @@ -1388,7 +1390,10 @@ export async function backfill( function originLabel(meta: Record): string | undefined { const remote = typeof meta.repo_remote === "string" ? meta.repo_remote : undefined if (remote) { - const trimmed = remote.replace(/[/]+$/, "").replace(/\.git$/, "").replace(/[/]+$/, "") + const trimmed = remote + .replace(/[/]+$/, "") + .replace(/\.git$/, "") + .replace(/[/]+$/, "") const last = trimmed.split(/[/:]/).pop() if (last) return last } @@ -1484,10 +1489,7 @@ export async function hydrate(sessionID: string): Promise { /** Wait for a session's in-flight hydration, capped. Resolves immediately once * the fetch has settled, so only the first injection of a session pays. */ -export async function whenHydrated( - sessionID: string, - timeoutMs: number = HYDRATION_WAIT_MS, -): Promise { +export async function whenHydrated(sessionID: string, timeoutMs: number = HYDRATION_WAIT_MS): Promise { const state = sessions.get(sessionID) const pending = state?.hydration if (!pending || !state) return diff --git a/packages/opencode/src/altimate/workspace/starter.ts b/packages/opencode/src/altimate/workspace/starter.ts index 7532073926..67e3e654b0 100644 --- a/packages/opencode/src/altimate/workspace/starter.ts +++ b/packages/opencode/src/altimate/workspace/starter.ts @@ -5,6 +5,7 @@ // `GET /altimate/workspace/starter`. Fixed in format and built from the same data as the prompt's // "What this Altimate Workspace provides" section, so it costs no model call (none of the user's // tokens) and reads the same every time. Anything not known is left out, never shown as "none". +import { Telemetry } from "@/altimate/telemetry" import { displayWorkspaceName } from "./workspace-name" import { workspaceContents, type WorkspaceContents } from "./contents" @@ -33,10 +34,14 @@ function list(names: string[], max: number): string { } function skillsLine(skills: WorkspaceContents["skills"]): string | null { - if (skills === null) return "Skills: not synced to this project yet; run `altimate-code workspace refresh` to fetch them." + if (skills === null) + return "Skills: not synced to this project yet; run `altimate-code workspace refresh` to fetch them." if (skills === "unknown") return null if (skills.length === 0) return "Skills: none yet. Add them in the workspace and they sync here." - return `Skills (${skills.length}): ${list(skills.map((s) => s.name), MAX_LISTED_SKILLS)}` + return `Skills (${skills.length}): ${list( + skills.map((s) => s.name), + MAX_LISTED_SKILLS, + )}` } function knowledgeLine(k: WorkspaceContents["knowledge"]): string | null { @@ -86,7 +91,9 @@ function summaryOf(contents: WorkspaceContents): string { else if (k?.kind === "selected") parts.push(plural(k.names?.length ?? k.selected, "knowledge document")) else if (k?.kind === "off") parts.push("knowledge off") if (contents.integrations !== null) - parts.push(contents.integrations.length === 0 ? "no integrations" : `integrations: ${contents.integrations.join(", ")}`) + parts.push( + contents.integrations.length === 0 ? "no integrations" : `integrations: ${contents.integrations.join(", ")}`, + ) if (contents.memoryEnabled !== null) parts.push(contents.memoryEnabled ? "memory on" : "memory off") return parts.join(" · ") } @@ -138,10 +145,33 @@ export function wrapStarter(text: string, width: number): string { } /** The starter for a linked project. `directory` is the project key the link was recorded under. */ +// altimate_change start — the starter is the first thing a user sees of the workspace in a tool +/** `starter_shown`, with what the workspace provides as counts. Never throws. */ +export function trackStarterShown(workspaceId: number, contents: WorkspaceContents): void { + try { + Telemetry.track({ + type: "starter_shown", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: workspaceId, + skills: Array.isArray(contents.skills) ? contents.skills.length : 0, + integrations: Array.isArray(contents.integrations) ? contents.integrations.length : 0, + knowledge: contents.knowledge?.kind ?? "unknown", + memory_enabled: contents.memoryEnabled === true, + }) + } catch { + // telemetry never blocks the starter + } +} +// altimate_change end + export async function starterFor( directory: string, binding: { datamateId: number; datamateName: string }, waitMs = STARTER_WAIT_MS, ): Promise { - return renderStarter(binding.datamateName, await workspaceContents(directory, binding.datamateId, waitMs)) + const contents = await workspaceContents(directory, binding.datamateId, waitMs) + const starter = renderStarter(binding.datamateName, contents) + trackStarterShown(binding.datamateId, contents) + return starter } diff --git a/packages/opencode/src/altimate/workspace/sync-state.ts b/packages/opencode/src/altimate/workspace/sync-state.ts index 9e72ebc77b..ac428534d1 100644 --- a/packages/opencode/src/altimate/workspace/sync-state.ts +++ b/packages/opencode/src/altimate/workspace/sync-state.ts @@ -25,6 +25,7 @@ // run in its server worker. A refresh run on the TUI main thread, or by a separate CLI // process, publishes to no `/event` subscriber — the file is still written, and the TUI // sidebar and the status route read it from there. +import { Telemetry } from "@/altimate/telemetry" import { createHash, randomBytes } from "node:crypto" import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs" import path from "node:path" @@ -275,6 +276,15 @@ function chain(file: string, work: () => Promise, kind: SyncKind): Promise return settled } +/** The reason class of a sync error message, for workspace_sync_failed. */ +export function syncFailureReason(error: string | undefined): "auth" | "not_found" | "network" | "other" { + const e = (error ?? "").toLowerCase() + if (/\b(401|403)\b|unauthori[sz]ed|forbidden|sign(ed)? in|credential/.test(e)) return "auth" + if (/\b404\b|not found|no longer exists|unknown workspace/.test(e)) return "not_found" + if (/timed? ?out|cannot reach|unreachable|econn|enotfound|network|socket/.test(e)) return "network" + return "other" +} + async function apply( directory: string, datamateId: number, @@ -330,6 +340,23 @@ async function apply( } const file: StoredFile = { version: 2, directory: canon, scope, datamateId, entity: next } writePrivate(filePath(directory, kind), file) + // altimate_change start — a kind flipping to error is a blocker the dashboard counts; a repeat + // while already in error is not. + if (next.status === "error" && previous?.status !== "error") { + try { + Telemetry.track({ + type: "workspace_sync_failed", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: datamateId, + kind, + reason: syncFailureReason(outcome.error), + }) + } catch { + // telemetry never blocks the record + } + } + // altimate_change end const errorChanged = (previous?.status ?? "ok") !== next.status || previous?.error !== next.error if (!changes && !errorChanged) return diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 53d1d86211..4b8d4ce86e 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -533,6 +533,7 @@ const SkillPublishCommand = cmd({ action: report.action, file_count: report.files, source: "cli", + workspace_id: report.datamateId, }) } catch {} } catch (err) { diff --git a/packages/opencode/src/memory/prompt.ts b/packages/opencode/src/memory/prompt.ts index 89c4cb2208..984a4b6324 100644 --- a/packages/opencode/src/memory/prompt.ts +++ b/packages/opencode/src/memory/prompt.ts @@ -206,6 +206,8 @@ export namespace MemoryPrompt { let injectedCount = 0 const injectedTraining: MemoryBlock[] = [] const scopesSeen = new Set() + // altimate_change — blocks injected from the bound workspace's cloud memory + let workspaceBlocks = 0 // Group training blocks by kind for structured display const byKind = new Map() @@ -244,6 +246,12 @@ export namespace MemoryPrompt { injectedCount++ injectedTraining.push(item.block) scopesSeen.add(item.block.scope) + // altimate_change start — a block from the bound workspace's cloud memory counts as workspace scope + if (isRemote(item.block)) { + scopesSeen.add("workspace") + workspaceBlocks++ + } + // altimate_change end } } @@ -263,6 +271,12 @@ export namespace MemoryPrompt { used += needed injectedCount++ scopesSeen.add(item.block.scope) + // altimate_change start — as above, for memory blocks + if (isRemote(item.block)) { + scopesSeen.add("workspace") + workspaceBlocks++ + } + // altimate_change end } } } @@ -293,7 +307,13 @@ export namespace MemoryPrompt { total_chars: used, budget, scopes_used: [...scopesSeen], + // altimate_change start — workspace memory use, for memory_injection and the session's summary rows + ...(workspaceBlocks > 0 && { workspace_blocks: workspaceBlocks }), }) + if (workspaceBlocks > 0) { + Telemetry.noteWorkspaceUse(ctx?.sessionID ?? Telemetry.getContext().sessionId, "memory", workspaceBlocks) + } + // altimate_change end } return injectedCount > 0 ? result : "" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a917f93d6a..dddd3299ac 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1348,6 +1348,7 @@ export namespace Server { action: report.action, file_count: report.files, source: "serve", + workspace_id: report.datamateId, }) } catch {} return c.json({ ok: true as const, report, message: describePublish(report) }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0d0350eef0..12840ceae6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -120,6 +120,18 @@ import { Telemetry } from "@/telemetry" // altimate_change — session telemetry import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_change — error_fingerprint payload // altimate_change start — the session's bound workspace rides on every telemetry row import { readLocalBinding } from "@/altimate/workspace/state" + +/** The session's workspace use, as session_end fields; counts are cleared with the read. */ +function workspaceUseFields(sessionID: string) { + const use = Telemetry.workspaceUseOf(sessionID) + Telemetry.clearWorkspaceUse(sessionID) + return { + workspace_skills_used: use.skills, + workspace_documents_used: use.documents, + workspace_memory_blocks: use.memory, + workspace_integration_calls: use.integrations, + } +} // altimate_change end import { TraceContext } from "@/altimate/observability/trace-context" // altimate_change — client trace per turn import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // altimate_change — onboarding funnel @@ -844,6 +856,9 @@ export namespace SessionPrompt { total_tokens: sessionTotalTokens, tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, + // altimate_change start — what of the bound workspace this session used + ...workspaceUseFields(sessionID), + // altimate_change end }) } process.once("beforeExit", emergencySessionEnd) @@ -2549,6 +2564,8 @@ export namespace SessionPrompt { step_count: step, duration_ms: Date.now() - sessionStartTime, last_tool_category: lastToolCategory || "none", + // altimate_change — whether workspace skills, memory, documents or integrations were in play + workspace_context_used: Object.values(Telemetry.workspaceUseOf(sessionID)).some((n) => n > 0), }) // Tool chain effectiveness — aggregated tool sequence + outcome if (toolChain.length > 0) { @@ -2626,6 +2643,9 @@ export namespace SessionPrompt { total_tokens: sessionTotalTokens, tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, + // altimate_change start — what of the bound workspace this session used + ...workspaceUseFields(sessionID), + // altimate_change end }) } await Telemetry.shutdown() diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index a33dd49a34..066331aa2a 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -23,7 +23,7 @@ import { isBuiltinSkillLocation } from "../altimate/skill-source" const MAX_DISPLAY_SKILLS = 50 // altimate_change start — classifySkillSource helper for skill telemetry + source badge -export function classifySkillSource(location: string): "builtin" | "global" | "project" { +export function classifySkillSource(location: string): "builtin" | "global" | "project" | "workspace" { // Normalize separators so `.altimate/builtin` / homedir prefix match on Windows too. const normalized = location.replace(/\\/g, "/") // Embedded skills load with a `builtin:/SKILL.md` location and Altimate's @@ -33,6 +33,10 @@ export function classifySkillSource(location: string): "builtin" | "global" | "p // `node_modules/` isn't tagged as Altimate. // altimate_change - the same predicate the skill listing labels with, so the two cannot drift apart if (isBuiltinSkillLocation(location)) return "builtin" + // altimate_change start — skills synced from the bound workspace live under the managed + // `_workspace` folder; they are the workspace's, not the project's. + if (/\/\.altimate-code\/skill\/_workspace\//.test(normalized)) return "workspace" + // altimate_change end if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global" return "project" } @@ -257,6 +261,9 @@ export const SkillTool = Tool.define("skill", async (ctx) => { // altimate_change start — classify origin once, reused for telemetry and the source badge const skillOrigin = classifySkillSource(skill.location) + // altimate_change start — count workspace skill use for the session's summary rows + if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills") + // altimate_change end // altimate_change end // altimate_change start — telemetry instrumentation for skill loading with trigger classification diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c7fa9f7c35..d9139403a2 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -162,6 +162,13 @@ export type InferDef = : never // altimate_change start — telemetry helpers (ported from the fork's pre-1.17 Tool.define wrapper) +// altimate_change start — a tool whose id starts with the datamate prefix is an MCP tool of the bound +// workspace (mirrors ALTIMATE_MCP_PREFIXES in altimate/tool-source.ts: `datamate`, `datamates`, `datamate-…`). +function viaWorkspace(id: string): boolean { + return /^datamates?([_-]|$)/i.test(id) +} +// altimate_change end + function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: number, error: unknown) { try { const errorMsg = error instanceof Error ? error.message : String(error) @@ -175,6 +182,8 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: tool_type: "standard", tool_category: Telemetry.categorizeToolName(id, "standard"), status: "error", + // altimate_change — workspace integration call + ...(viaWorkspace(id) && { via_workspace: true }), duration_ms: Date.now() - startTime, sequence_index: 0, previous_tool: null, @@ -208,6 +217,9 @@ function altimateTrackSuccess( try { const isSoftFailure = hasFailureSignals(id, result) const durationMs = Date.now() - startTime + // altimate_change start — count successful calls to the bound workspace's integrations + if (!isSoftFailure && viaWorkspace(id)) Telemetry.noteWorkspaceUse(ctx.sessionID, "integrations") + // altimate_change end const toolCategory = Telemetry.categorizeToolName(id, "standard") // Skip success tool_call for high-volume/low-signal file tools; failures still tracked. if (isSoftFailure || toolCategory !== "file") { @@ -220,6 +232,8 @@ function altimateTrackSuccess( tool_type: "standard", tool_category: toolCategory, status: isSoftFailure ? "error" : "success", + // altimate_change — workspace integration call + ...(viaWorkspace(id) && { via_workspace: true }), duration_ms: durationMs, sequence_index: 0, previous_tool: null, diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 297798c451..f1386a456f 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -51,6 +51,21 @@ describe("workspace id on the telemetry context", () => { timestamp: Date.now(), }) as const + test("workspace use is counted per session and cleared on read-out", () => { + Telemetry.clearWorkspaceUse("s9") + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + Telemetry.noteWorkspaceUse("s9", "skills") + Telemetry.noteWorkspaceUse("s9", "memory", 3) + Telemetry.noteWorkspaceUse("s9", "integrations") + Telemetry.noteWorkspaceUse("s9", "integrations") + Telemetry.noteWorkspaceUse("", "skills") // no session: ignored + Telemetry.noteWorkspaceUse("s9", "documents", 0) // nothing to count + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 1, documents: 0, memory: 3, integrations: 2 }) + expect(Telemetry.workspaceUseOf("other")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + Telemetry.clearWorkspaceUse("s9") + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + }) + test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { await Telemetry.shutdown() await Telemetry.init() diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index c2a0e3c8d2..0ada7d3cca 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -6,7 +6,7 @@ // the mirror actually issues — method, path, body — rather than a mock's call // log. Cases claiming "nothing was sent" check a zero request count, not merely // the absence of a throw. -import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs" import path from "node:path" import os from "node:os" @@ -54,9 +54,7 @@ const { toBlock, whenHydrated, } = await import("../../../src/altimate/workspace/memory-sync") -const { MIRROR_SOURCE, extractRecordId, LIST_LIMIT } = await import( - "../../../src/altimate/workspace/memory-api" -) +const { MIRROR_SOURCE, extractRecordId, LIST_LIMIT } = await import("../../../src/altimate/workspace/memory-api") import { AltimateApi } from "../../../src/altimate/api/client" @@ -124,7 +122,8 @@ function stubFetch() { if (method === "POST") { const body = init?.body ? JSON.parse(init.body) : {} for (const rec of Array.isArray(createResult) ? createResult : []) { - if (rec?.id) listResponse.push({ id: rec.id, memory: body.messages?.[0]?.content ?? "", metadata: body.metadata }) + if (rec?.id) + listResponse.push({ id: rec.id, memory: body.messages?.[0]?.content ?? "", metadata: body.metadata }) } } return { message: "ok", result: createResult } @@ -132,8 +131,7 @@ function stubFetch() { if (url.includes("/datamates/")) return { datamates: workspaces } return { message: "ok" } })() - const status = - url.includes("/datamate-project-bindings/by-") && !serverBinding ? 404 : 200 + const status = url.includes("/datamate-project-bindings/by-") && !serverBinding ? 404 : 200 return new Response(JSON.stringify(payload), { status, headers: { "Content-Type": "application/json" }, @@ -198,10 +196,8 @@ afterEach(() => { // seam fall through to the real lookup, so a leaked value from a previous // test would decide their outcome. serverBinding = null - ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = - originalIsConfigured - ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = - originalGetCreds + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = originalIsConfigured + ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = originalGetCreds delete process.env.ALTIMATE_DISABLE_WORKSPACE delete syncInternals.resolveBinding delete syncInternals.blockExists @@ -595,7 +591,11 @@ describe("mirrorBlock", () => { createResult = [{ id: "mem-archive-late" }] await mirrorBlock(b) listResponse = [ - { id: "mem-archive-late", memory: b.content, metadata: { source: MIRROR_SOURCE, block_id: "to-archive-late", block_scope: "global" } }, + { + id: "mem-archive-late", + memory: b.content, + metadata: { source: MIRROR_SOURCE, block_id: "to-archive-late", block_scope: "global" }, + }, ] const net = gatedFetch() let settled = false @@ -907,8 +907,7 @@ describe("mirrorBlock", () => { // incrementApplied rewrites a training block on EVERY session start to bump // a counter inside the content body. Mirroring that would cost a request // per training block per session. - const meta = (applied: number) => - `\nAlways alias CTEs.` + const meta = (applied: number) => `\nAlways alias CTEs.` const b = block({ id: "training/rule/aliases", tags: ["training", "rule"], content: meta(1) }) await mirrorBlock(b) captured = [] @@ -955,7 +954,7 @@ describe("memory_enabled", () => { expect(callsTo("/datamates/memory/", "POST").length).toBe(0) workspaces = [{ id: 42, name: "acme", memory_enabled: true }] - serverBinding = null + serverBinding = null captured = [] await mirrorBlock(block({ id: "after-enable" })) expect(callsTo("/datamates/memory/", "POST").length).toBe(1) @@ -1117,10 +1116,7 @@ describe("tag encoding", () => { // The previous comma-joined form split this tag into two on read. const tags = ["warehouse", "owner: data, platform", "prod"] const meta = buildMetadata(block({ tags }), null) - const back = toBlock( - { id: "1", memory: "x", metadata: { ...meta, source: MIRROR_SOURCE } } as any, - undefined, - ) + const back = toBlock({ id: "1", memory: "x", metadata: { ...meta, source: MIRROR_SOURCE } } as any, undefined) expect(back?.tags).toEqual(tags) }) @@ -1180,9 +1176,7 @@ describe("reaping blocks archived elsewhere", () => { const seeThrough = (blocks: Record) => { const removed: { id: string; scope: string; directory?: string; expectUpdated?: string }[] = [] syncInternals.readBlock = async (scope, id) => - blocks[id] - ? ({ id, scope, content: "c", tags: [], created: "", updated: blocks[id].updated } as any) - : undefined + blocks[id] ? ({ id, scope, content: "c", tags: [], created: "", updated: blocks[id].updated } as any) : undefined syncInternals.removeBlock = async (scope, id, directory, expectUpdated) => { removed.push({ id, scope, directory, expectUpdated }) return true @@ -1310,7 +1304,11 @@ describe("reaping blocks archived elsewhere", () => { const removed = seeThrough({ split: { updated: "2026-05-01T00:00:00.000Z" } }) listResponse = [ // The live primary. - { id: "mem-primary", memory: "kept", metadata: { source: MIRROR_SOURCE, block_id: "split", block_scope: "global" } }, + { + id: "mem-primary", + memory: "kept", + metadata: { source: MIRROR_SOURCE, block_id: "split", block_scope: "global" }, + }, // The extra, archived at push time. archived("split", { archived_at: "2026-06-01T00:00:00.000Z" }), ] @@ -1509,7 +1507,11 @@ describe("hydrate", () => { test("drops archived records", async () => { listResponse = [ { id: "1", memory: "live", metadata: { source: MIRROR_SOURCE, block_id: "live", block_scope: "global" } }, - { id: "2", memory: "gone", metadata: { source: MIRROR_SOURCE, block_id: "gone", block_scope: "global", archived: "true" } }, + { + id: "2", + memory: "gone", + metadata: { source: MIRROR_SOURCE, block_id: "gone", block_scope: "global", archived: "true" }, + }, ] await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["live"]) @@ -1518,7 +1520,11 @@ describe("hydrate", () => { test("drops project records belonging to another workspace", async () => { listResponse = [ { id: "1", memory: "g", metadata: { source: MIRROR_SOURCE, block_id: "g", block_scope: "global" } }, - { id: "2", memory: "other", metadata: { source: MIRROR_SOURCE, block_id: "other", block_scope: "project", datamate_id: "999" } }, + { + id: "2", + memory: "other", + metadata: { source: MIRROR_SOURCE, block_id: "other", block_scope: "project", datamate_id: "999" }, + }, ] await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["g"]) @@ -1592,7 +1598,11 @@ describe("archiveBlock", () => { await mirrorBlock(b) captured = [] listResponse = [ - { id: "mem-archive", memory: b.content, metadata: { source: MIRROR_SOURCE, block_id: "to-archive", block_scope: "global" } }, + { + id: "mem-archive", + memory: b.content, + metadata: { source: MIRROR_SOURCE, block_id: "to-archive", block_scope: "global" }, + }, ] await archiveBlock("global", "to-archive") @@ -1730,6 +1740,26 @@ describe("backfill", () => { block({ id: `${prefix}/${i}`, content: `Block ${prefix} ${i} describes a warehouse convention.` }), ) + test("every block stored in the workspace reports workspace_memory_contributed with the workspace id", async () => { + const { Telemetry } = await import("../../../src/altimate/telemetry") + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + createResult = [{ id: "mem-contrib" }] + // Project-scope blocks are the ones bound to the workspace; global memory is the account's. + const bound = blocks("contrib", 2).map((b) => ({ ...b, scope: "project" })) + const result = await backfill(bound, BINDING as any) + expect(result.ok).toBe(2) + const contributed = events.filter((e) => e.type === "workspace_memory_contributed") + expect(contributed).toHaveLength(2) + expect(contributed[0]).toEqual(expect.objectContaining({ workspace_id: 42, block_count: 1 })) + } finally { + spy.mockRestore() + } + }) + test("reads the record set once for the whole sweep, not once per block", async () => { // Every block in a first bind is an index miss, so resolving each through // its own lookup made a bind cost one full fetch per block. @@ -1906,7 +1936,11 @@ describe("binding changes", () => { // The first lookup adopts (and notifies); later ones read the cache, as in production. if (!adopted) { adopted = true - await recordApprovedBinding(dir, { ...BINDING, projectPath: dir, linkedAt: 3 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, projectPath: dir, linkedAt: 3 }, + { awaitBackfill: true, seed: false }, + ) } return BINDING as any } @@ -1937,7 +1971,11 @@ describe("refresh racing a relink", () => { await reachedList const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") const dir = mkdtempSync(path.join(SANDBOX, "race-")) - await recordApprovedBinding(dir, { ...BINDING, datamateId: 44, projectPath: dir, linkedAt: 4 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, datamateId: 44, projectPath: dir, linkedAt: 4 }, + { awaitBackfill: true, seed: false }, + ) release?.() const result = await pending // Superseded: neither its read nor the prior overlay belongs to the new binding. @@ -1961,7 +1999,11 @@ describe("overlay invalidation", () => { await hydrate(SES) expect(overlayBlocks(SES).length).toBe(1) const dir = mkdtempSync(path.join(SANDBOX, "hide-")) - await recordApprovedBinding(dir, { ...BINDING, datamateId: 45, projectPath: dir, linkedAt: 5 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, datamateId: 45, projectPath: dir, linkedAt: 5 }, + { awaitBackfill: true, seed: false }, + ) expect(overlayBlocks(SES)).toEqual([]) }) @@ -2004,7 +2046,11 @@ describe("epoch bracketing and scope", () => { calls++ // First lookup returns A, but the relink to B lands before it returns. if (calls === 1) { - await recordApprovedBinding(dir, { ...BINDING, datamateId: 46, projectPath: dir, linkedAt: 6 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, datamateId: 46, projectPath: dir, linkedAt: 6 }, + { awaitBackfill: true, seed: false }, + ) return BINDING as any } return { ...BINDING, datamateId: 46 } as any @@ -2026,10 +2072,18 @@ describe("epoch bracketing and scope", () => { const other = mkdtempSync(path.join(SANDBOX, "other-")) await refresh(SES, mine) expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["mine"]) - await recordApprovedBinding(other, { ...BINDING, datamateId: 47, projectPath: other, linkedAt: 7 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + other, + { ...BINDING, datamateId: 47, projectPath: other, linkedAt: 7 }, + { awaitBackfill: true, seed: false }, + ) expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["mine"]) // A change to this project's own binding still hides it. - await recordApprovedBinding(mine, { ...BINDING, datamateId: 48, projectPath: mine, linkedAt: 8 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + mine, + { ...BINDING, datamateId: 48, projectPath: mine, linkedAt: 8 }, + { awaitBackfill: true, seed: false }, + ) expect(overlayBlocks(SES)).toEqual([]) }) }) @@ -2088,9 +2142,7 @@ describe("session isolation and turn behaviour", () => { // The caller's enclosing block runs on EVERY user turn, not once per // session. Without idempotence a 20-turn conversation costs 40 requests, // and the first injection of every turn blocks on them. - listResponse = [ - { id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }, - ] + listResponse = [{ id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }] await hydrate(SES) await hydrate(SES) await hydrate(SES) @@ -2128,9 +2180,7 @@ describe("session isolation and turn behaviour", () => { }) test("overlayBlocks returns a copy, so a caller cannot corrupt the cache", async () => { - listResponse = [ - { id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }, - ] + listResponse = [{ id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }] await hydrate(SES) overlayBlocks(SES).length = 0 expect(overlayBlocks(SES).length).toBe(1) @@ -2189,9 +2239,7 @@ describe("binding resolution for the mirror", () => { await mirrorBlock(block(), dir) - const posts = captured.filter( - (c) => c.method === "POST" && c.url.includes("/datamates/memory/"), - ) + const posts = captured.filter((c) => c.method === "POST" && c.url.includes("/datamates/memory/")) expect(posts.length).toBe(1) }) @@ -2205,9 +2253,7 @@ describe("binding resolution for the mirror", () => { await mirrorBlock(block(), dir) - const posts = captured.filter( - (c) => c.method === "POST" && c.url.includes("/datamates/memory/"), - ) + const posts = captured.filter((c) => c.method === "POST" && c.url.includes("/datamates/memory/")) expect(posts.length).toBe(0) }) }) @@ -2627,7 +2673,11 @@ describe("sync state", () => { listResponse = [note("a", "Naming conventions", "2026-02-01T00:00:00.000Z"), note("c", "Cost alerts")] await refresh(`${SES}-state-3`, dir) const entity = await memoryState(dir, (e) => e.changes !== null) - expect(entity?.changes).toEqual({ added: ["Cost alerts"], removed: ["Warehouse sizing"], updated: ["Naming conventions"] }) + expect(entity?.changes).toEqual({ + added: ["Cost alerts"], + removed: ["Warehouse sizing"], + updated: ["Naming conventions"], + }) expect(entity?.count).toBe(2) }) @@ -2786,12 +2836,18 @@ describe("sync state", () => { // project is relinked. let release!: () => void const gate = new Promise((r) => (release = r)) - const holder = SyncState.record(dir, BINDING.datamateId, "memory", {}, { - isCurrent: async () => { - await gate - return false + const holder = SyncState.record( + dir, + BINDING.datamateId, + "memory", + {}, + { + isCurrent: async () => { + await gate + return false + }, }, - }) + ) listResponse = [] await refresh(`${SES}-state-late-2`, dir) // Unlink: every binding epoch moves, and the queued "nothing left" record is now stale. diff --git a/packages/opencode/test/altimate/workspace/starter.test.ts b/packages/opencode/test/altimate/workspace/starter.test.ts index 52a2104d1b..3f00af02bd 100644 --- a/packages/opencode/test/altimate/workspace/starter.test.ts +++ b/packages/opencode/test/altimate/workspace/starter.test.ts @@ -1,6 +1,7 @@ // altimate_change - new file -import { describe, expect, test } from "bun:test" -import { renderStarter, wrapStarter } from "../../../src/altimate/workspace/starter" +import { describe, expect, spyOn, test } from "bun:test" +import { renderStarter, trackStarterShown, wrapStarter } from "../../../src/altimate/workspace/starter" +import { Telemetry } from "../../../src/altimate/telemetry" import type { WorkspaceContents } from "../../../src/altimate/workspace/contents" const full: WorkspaceContents = { @@ -46,7 +47,12 @@ describe("renderStarter", () => { }) test("says what an empty or unsynced workspace means, and suggests only what it can back", () => { - const empty = renderStarter("Acme", { skills: [], integrations: [], memoryEnabled: false, knowledge: { kind: "off" } }) + const empty = renderStarter("Acme", { + skills: [], + integrations: [], + memoryEnabled: false, + knowledge: { kind: "off" }, + }) expect(empty.lines).toEqual([ "Skills: none yet. Add them in the workspace and they sync here.", "Knowledge: off for this workspace.", @@ -110,3 +116,38 @@ describe("wrapStarter", () => { expect(wrapStarter("a\n\nb c", 80)).toBe("a\n\nb c") }) }) + +describe("starter_shown telemetry", () => { + test("reports what the workspace provides as counts, and unknowns as zero", () => { + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + trackStarterShown(5, full) + expect(events).toEqual([ + expect.objectContaining({ + type: "starter_shown", + workspace_id: 5, + skills: Array.isArray(full.skills) ? full.skills.length : 0, + integrations: Array.isArray(full.integrations) ? full.integrations.length : 0, + knowledge: full.knowledge?.kind ?? "unknown", + memory_enabled: full.memoryEnabled === true, + }), + ]) + events.length = 0 + trackStarterShown(6, { skills: "unknown", integrations: null, memoryEnabled: null, knowledge: null }) + expect(events[0]).toEqual( + expect.objectContaining({ + workspace_id: 6, + skills: 0, + integrations: 0, + knowledge: "unknown", + memory_enabled: false, + }), + ) + } finally { + spy.mockRestore() + } + }) +}) diff --git a/packages/opencode/test/altimate/workspace/sync-state.test.ts b/packages/opencode/test/altimate/workspace/sync-state.test.ts index c4e275e6d9..9796884a4d 100644 --- a/packages/opencode/test/altimate/workspace/sync-state.test.ts +++ b/packages/opencode/test/altimate/workspace/sync-state.test.ts @@ -61,7 +61,11 @@ describe("diff", () => { }) test("identical sets have no changes", () => { - expect(SyncState.diff({ a: skill("Alpha") }, { a: skill("Alpha") })).toEqual({ added: [], removed: [], updated: [] }) + expect(SyncState.diff({ a: skill("Alpha") }, { a: skill("Alpha") })).toEqual({ + added: [], + removed: [], + updated: [], + }) }) test("an id named like an inherited property is still an item", () => { @@ -75,7 +79,13 @@ describe("record", () => { await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha"), b: skill("Beta") } }) const state = await SyncState.read(project, 7) - expect(state?.entities.skills).toMatchObject({ kind: "skills", status: "ok", count: 2, lastChangedAt: null, changes: null }) + expect(state?.entities.skills).toMatchObject({ + kind: "skills", + status: "ok", + count: 2, + lastChangedAt: null, + changes: null, + }) expect(state?.entities.skills?.lastCheckedAt).toBeNumber() expect(state?.entities.skills?.lastSucceededAt).toBe(state!.entities.skills!.lastCheckedAt) expect(published).toEqual([]) @@ -196,7 +206,13 @@ describe("record", () => { test("a late result from a previous binding is dropped", async () => { await SyncState.record(project, 8, "memory", { items: { m1: skill("New workspace block") } }) - await SyncState.record(project, 7, "memory", { error: "could not load workspace memory" }, { isCurrent: () => false }) + await SyncState.record( + project, + 7, + "memory", + { error: "could not load workspace memory" }, + { isCurrent: () => false }, + ) const state = await SyncState.read(project, 8) expect(state?.entities.memory).toMatchObject({ status: "ok", count: 1 }) @@ -298,3 +314,36 @@ describe("forget", () => { expect((await SyncState.read(project, 7))?.entities.memory?.count).toBe(1) }) }) + +describe("workspace_sync_failed telemetry", () => { + test("a kind flipping to error reports once, with a reason class; a repeat does not", async () => { + const { Telemetry } = await import("../../../src/altimate/telemetry") + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + await SyncState.record(project, 9, "memory", { error: "401 unauthorized" }) + expect(events).toEqual([ + expect.objectContaining({ type: "workspace_sync_failed", workspace_id: 9, kind: "memory", reason: "auth" }), + ]) + await SyncState.record(project, 9, "memory", { error: "still unauthorized" }) // already in error: no new row + expect(events).toHaveLength(1) + await SyncState.record(project, 9, "memory", { items: { a: skill("Alpha") } }) // back to ok + await SyncState.record(project, 9, "memory", { error: "Cannot reach https://api: ECONNREFUSED" }) + expect(events).toHaveLength(2) + expect(events[1]).toEqual(expect.objectContaining({ kind: "memory", reason: "network" })) + } finally { + spy.mockRestore() + } + }) + + test("syncFailureReason classifies the messages the sync produces", () => { + expect(SyncState.syncFailureReason("Request failed with status 403")).toBe("auth") + expect(SyncState.syncFailureReason("Please sign in again")).toBe("auth") + expect(SyncState.syncFailureReason("Workspace not found (404)")).toBe("not_found") + expect(SyncState.syncFailureReason("Request to x timed out after 15s")).toBe("network") + expect(SyncState.syncFailureReason("The server returned no usable id")).toBe("other") + expect(SyncState.syncFailureReason(undefined)).toBe("other") + }) +}) diff --git a/packages/opencode/test/memory/overlay-merge.test.ts b/packages/opencode/test/memory/overlay-merge.test.ts index 93ecae5eae..10c643904f 100644 --- a/packages/opencode/test/memory/overlay-merge.test.ts +++ b/packages/opencode/test/memory/overlay-merge.test.ts @@ -2,7 +2,7 @@ // Covers the injection-side merge of workspace memory into local memory: // which blocks survive, and the guard that keeps a cloud-sourced training // block from writing to the local store. -import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import path from "node:path" import os from "node:os" @@ -141,6 +141,30 @@ describe("the memory refresh tool", () => { expect(res.output).toContain("2 block(s)") }) + test("a block from the bound workspace's memory is injected under the workspace scope and counted", async () => { + const { Telemetry } = await import("../../src/altimate/telemetry") + listResponse = [remote("ws/one", "WORKSPACE BLOCK")] + await hydrate(SES) + expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["ws/one"]) + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + Telemetry.clearWorkspaceUse(SES) + const injected = await MemoryPrompt.inject(8000, { sessionID: SES }) + expect(injected).toContain("WORKSPACE BLOCK") + const injection = events.find((e) => e.type === "memory_injection") + expect(injection).toBeDefined() + expect(injection.scopes_used).toContain("workspace") + expect(injection.workspace_blocks).toBe(1) + expect(Telemetry.workspaceUseOf(SES).memory).toBe(1) + } finally { + spy.mockRestore() + Telemetry.clearWorkspaceUse(SES) + } + }) + test("says the workspace is unreachable rather than reporting an empty reload", async () => { listResponse = [remote("keep/one", "KEEP")] await hydrate(SES) @@ -280,7 +304,11 @@ describe("on-demand reload", () => { const { count, ok } = await refresh(SES) expect(ok).toBe(true) expect(count).toBe(2) - expect(overlayBlocks(SES).map((b) => b.id).sort()).toEqual(["warehouse/one", "warehouse/two"]) + expect( + overlayBlocks(SES) + .map((b) => b.id) + .sort(), + ).toEqual(["warehouse/one", "warehouse/two"]) }) test("a failed refresh keeps the memory the session already had", async () => { @@ -313,7 +341,7 @@ describe("on-demand reload", () => { await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["keep/me"]) - memoryEnabledCache.clear() // past the 60s positive-cache TTL + memoryEnabledCache.clear() // past the 60s positive-cache TTL const originalFetch = globalThis.fetch globalThis.fetch = (async (_i?: unknown, _n?: unknown) => { throw new Error("workspace unreachable") @@ -351,21 +379,22 @@ describe("on-demand reload", () => { firstCall = false await gate return new Response(JSON.stringify([remote("stale/one", "STALE")]), { - status: 200, headers: { "Content-Type": "application/json" }, + status: 200, + headers: { "Content-Type": "application/json" }, }) } return originalFetch(input, init) }) as typeof fetch try { - void hydrate(SES) // A — stalls on the gate + void hydrate(SES) // A — stalls on the gate await new Promise((r) => setTimeout(r, 20)) listResponse = [remote("fresh/one", "FRESH")] - const res = await refresh(SES) // B — completes first + const res = await refresh(SES) // B — completes first expect(res.status).toBe("loaded") expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["fresh/one"]) - release?.() // A resolves late + release?.() // A resolves late await new Promise((r) => setTimeout(r, 50)) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["fresh/one"]) } finally { diff --git a/packages/opencode/test/tool/skill-source.test.ts b/packages/opencode/test/tool/skill-source.test.ts index 3ede7dcf73..822a2930e8 100644 --- a/packages/opencode/test/tool/skill-source.test.ts +++ b/packages/opencode/test/tool/skill-source.test.ts @@ -18,6 +18,13 @@ describe("classifySkillSource", () => { expect(classifySkillSource("C:\\Users\\x\\.altimate\\builtin\\dbt-analyze\\SKILL.md")).toBe("builtin") }) + test("skills synced from the bound workspace (the managed _workspace folder) → workspace", () => { + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace/dbt-review/SKILL.md")).toBe("workspace") + expect(classifySkillSource("C:\\work\\repo\\.altimate-code\\skill\\_workspace\\x\\SKILL.md")).toBe("workspace") + // a project skill that merely mentions the word is still the project's + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace-notes/SKILL.md")).toBe("project") + }) + test("a third-party skill under some other node_modules is NOT tagged Altimate", () => { expect(classifySkillSource("/work/repo/node_modules/some-other-pkg/skills/x/SKILL.md")).toBe("project") }) From 66590e5ddf1e09737ae39cbbe60c3f206995df8b Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:23:44 +0800 Subject: [PATCH 14/59] fix: track workspace integration calls where MCP tools run MCP tools never pass through Tool.define, so the via_workspace row and the integrations count now come from the MCP execution path, keyed on the client name; the id-prefix test in tool.ts is gone, so datamate_manager no longer counts. The TUI publish event carries workspace_id like the CLI and serve paths. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../altimate/workspace/tool-call-telemetry.ts | 49 +++++++++++++ .../src/plugin/tui/altimate/skill-ops.tsx | 1 + packages/opencode/src/session/prompt.ts | 28 +++++++- packages/opencode/src/tool/tool.ts | 14 ---- .../workspace/tool-call-telemetry.test.ts | 71 +++++++++++++++++++ 5 files changed, 148 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/tool-call-telemetry.ts create mode 100644 packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts new file mode 100644 index 0000000000..dc9351682d --- /dev/null +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -0,0 +1,49 @@ +// altimate_change — tool_call rows for the bound workspace's integration calls. +import { Telemetry } from "@/altimate/telemetry" +import { mcpToolSource } from "@/altimate/tool-source" + +/** True when the MCP client serving a tool is the bound workspace's datamate server. */ +export function isWorkspaceClient(clientName: string): boolean { + return mcpToolSource(clientName) === "altimate" +} + +/** + * Track one MCP call that went through the bound workspace. MCP tools never pass through the + * `Tool.define` wrapper that tracks registry tools, so this is where a workspace integration + * call becomes a `tool_call` row; a successful one also counts as an integration use for the + * session. Third-party MCP servers stay untracked and registry tools (`datamate_manager` + * included) never reach here. Returns whether the call was tracked. + */ +export function trackWorkspaceToolCall(input: { + key: string + clientName: string + sessionID: string + messageID: string + startedAt: number + outcome: "success" | "error" + error?: unknown +}): boolean { + if (!isWorkspaceClient(input.clientName)) return false + try { + if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") + const message = input.error instanceof Error ? input.error.message : String(input.error) + Telemetry.track({ + type: "tool_call", + timestamp: Date.now(), + session_id: input.sessionID, + message_id: input.messageID, + tool_name: input.key, + tool_type: "mcp", + tool_category: Telemetry.categorizeToolName(input.key, "mcp"), + status: input.outcome, + via_workspace: true, + duration_ms: Date.now() - input.startedAt, + sequence_index: 0, + previous_tool: null, + ...(input.error !== undefined && { error: Telemetry.maskString(message).slice(0, 500) }), + }) + } catch { + // telemetry never surfaces into the tool call + } + return true +} diff --git a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx index 3c7b47306d..d5b52efdc9 100644 --- a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx +++ b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx @@ -550,6 +550,7 @@ export async function publishFromPicker( skill_name: skillName, action: report.action, file_count: report.files, + workspace_id: report.datamateId, source: "tui", }) } catch {} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 12840ceae6..0b52470f79 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -114,6 +114,7 @@ import { Config } from "../config/config" import { Tracer } from "../altimate/observability/tracing" // altimate_change start — stamp an authoritative tool source + humanized MCP title import { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-source" +import { trackWorkspaceToolCall } from "../altimate/workspace/tool-call-telemetry" // altimate_change — workspace integration rows // altimate_change end // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry @@ -2942,7 +2943,32 @@ export namespace SessionPrompt { ) // altimate_change end - const result = await execute(args, opts) + // altimate_change start — a workspace integration call is tracked here, where MCP tools run + const startedAt = Date.now() + let result + try { + result = await execute(args, opts) + } catch (err) { + trackWorkspaceToolCall({ + key, + clientName, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + startedAt, + outcome: "error", + error: err, + }) + throw err + } + trackWorkspaceToolCall({ + key, + clientName, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + startedAt, + outcome: (result as { isError?: boolean }).isError === true ? "error" : "success", + }) + // altimate_change end await Plugin.trigger( "tool.execute.after", diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index d9139403a2..c7fa9f7c35 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -162,13 +162,6 @@ export type InferDef = : never // altimate_change start — telemetry helpers (ported from the fork's pre-1.17 Tool.define wrapper) -// altimate_change start — a tool whose id starts with the datamate prefix is an MCP tool of the bound -// workspace (mirrors ALTIMATE_MCP_PREFIXES in altimate/tool-source.ts: `datamate`, `datamates`, `datamate-…`). -function viaWorkspace(id: string): boolean { - return /^datamates?([_-]|$)/i.test(id) -} -// altimate_change end - function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: number, error: unknown) { try { const errorMsg = error instanceof Error ? error.message : String(error) @@ -182,8 +175,6 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: tool_type: "standard", tool_category: Telemetry.categorizeToolName(id, "standard"), status: "error", - // altimate_change — workspace integration call - ...(viaWorkspace(id) && { via_workspace: true }), duration_ms: Date.now() - startTime, sequence_index: 0, previous_tool: null, @@ -217,9 +208,6 @@ function altimateTrackSuccess( try { const isSoftFailure = hasFailureSignals(id, result) const durationMs = Date.now() - startTime - // altimate_change start — count successful calls to the bound workspace's integrations - if (!isSoftFailure && viaWorkspace(id)) Telemetry.noteWorkspaceUse(ctx.sessionID, "integrations") - // altimate_change end const toolCategory = Telemetry.categorizeToolName(id, "standard") // Skip success tool_call for high-volume/low-signal file tools; failures still tracked. if (isSoftFailure || toolCategory !== "file") { @@ -232,8 +220,6 @@ function altimateTrackSuccess( tool_type: "standard", tool_category: toolCategory, status: isSoftFailure ? "error" : "success", - // altimate_change — workspace integration call - ...(viaWorkspace(id) && { via_workspace: true }), duration_ms: durationMs, sequence_index: 0, previous_tool: null, diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts new file mode 100644 index 0000000000..1258e9b24c --- /dev/null +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { Telemetry } from "../../../src/altimate/telemetry" +import { isWorkspaceClient, trackWorkspaceToolCall } from "../../../src/altimate/workspace/tool-call-telemetry" + +describe("workspace integration tool calls", () => { + let rows: any[] + let spy: ReturnType + beforeEach(() => { + rows = [] + spy = spyOn(Telemetry, "track").mockImplementation((e) => { + rows.push(e) + }) + Telemetry.clearWorkspaceUse("s1") + }) + afterEach(() => { + spy.mockRestore() + Telemetry.clearWorkspaceUse("s1") + }) + + const call = (clientName: string, outcome: "success" | "error", error?: unknown) => + trackWorkspaceToolCall({ + key: `${clientName}_jira_get_issue`, + clientName, + sessionID: "s1", + messageID: "m1", + startedAt: Date.now() - 5, + outcome, + error, + }) + + test("the datamate client's calls become tool_call rows with via_workspace; a success counts as an integration use", () => { + expect(call("datamate", "success")).toBe(true) + expect(rows).toHaveLength(1) + expect(rows[0]).toEqual( + expect.objectContaining({ + type: "tool_call", + tool_name: "datamate_jira_get_issue", + tool_type: "mcp", + status: "success", + via_workspace: true, + session_id: "s1", + message_id: "m1", + }), + ) + expect(rows[0].duration_ms).toBeGreaterThanOrEqual(0) + expect("error" in rows[0]).toBe(false) + expect(Telemetry.workspaceUseOf("s1").integrations).toBe(1) + }) + + test("a failed call is a tool_call error row with a masked message and no integration use", () => { + expect(call("datamates", "error", new Error("boom at /home/someone/secret.txt"))).toBe(true) + expect(rows[0]).toEqual(expect.objectContaining({ status: "error", via_workspace: true, tool_type: "mcp" })) + expect(rows[0].error).not.toContain("/home/someone") + expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) + expect(call("datamate-acme", "error", "string failure")).toBe(true) + expect(rows[1].error).toContain("string failure") + }) + + test("other MCP clients are not tracked here, and a registry tool id is not a client", () => { + expect(call("github", "success")).toBe(false) + expect(call("filesystem", "error", new Error("x"))).toBe(false) + expect(rows).toHaveLength(0) + expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) + // The registry tool `datamate_manager` runs through Tool.define, never through this path, and its + // id is not a datamate client name either. + expect(isWorkspaceClient("datamate_manager")).toBe(false) + expect(isWorkspaceClient("datamate")).toBe(true) + expect(isWorkspaceClient("Datamates")).toBe(true) + expect(isWorkspaceClient("datamate-acme")).toBe(true) + }) +}) From 1f775ecd45bdd4b08523d36aa4fe8034965987af Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:39:25 +0800 Subject: [PATCH 15/59] fix: stamp the workspace id at track time; keep the memory failure detail A serve process can move the context to another session before the batch is serialised, and the memory sync record flattened every failure to one text, so its reason class was always other. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 5 +++- .../src/altimate/workspace/memory-sync.ts | 29 ++++++++++++------- .../telemetry/workspace-context.test.ts | 8 ++--- .../altimate/workspace/memory-sync.test.ts | 4 ++- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index d67f8578ce..0088e9b9b2 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2060,7 +2060,6 @@ export namespace Telemetry { // still says how this CLI was launched (cli, tui, serve, datamates). client: "altimate-code", source: clientSource, - ...(workspaceId !== undefined && !("workspace_id" in fields) && { workspace_id: String(workspaceId) }), project_id: fields.project_id ?? projectId, ...(machineId && { machine_id: machineId }), // altimate_change — groups every event from one process launch. The onboarding funnel @@ -2435,6 +2434,10 @@ export namespace Telemetry { const operationId = typeof eventSession === "string" ? TraceContext.activeTraceId(eventSession) : undefined if (operationId) (event as any)._operation_id = operationId // altimate_change end + // altimate_change start — the bound workspace is read now, not at flush: a serve process can start + // another session's turn, and move the context, before this event is serialised. + if (workspaceId !== undefined && !("workspace_id" in event)) (event as any).workspace_id = workspaceId + // altimate_change end buffer.push(event) if (buffer.length > MAX_BUFFER_SIZE) { buffer.shift() diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 2213a60502..a095738d93 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -308,6 +308,9 @@ async function memoryEnabled(binding: CachedBinding): Promise { return (await memoryStatus(binding)) === "enabled" } +/** A failed read says what failed, so the sync record can classify it. */ +type MemoryStatusOutcome = "enabled" | "disabled" | { status: "error"; error: string } + /** Three-way, because a read that cannot reach the service must not be reported * as "this workspace has no memory" — that reads as success while destroying * whatever the session already had. */ @@ -320,7 +323,7 @@ async function memoryStatus( * inherit a positive written under a previous account. */ fresh?: boolean } = {}, -): Promise<"enabled" | "disabled" | "error"> { +): Promise { const cached = opts.fresh ? undefined : memoryEnabledCache.get(binding.datamateId) if (cached && Date.now() - cached.checkedAt < MEMORY_ENABLED_TTL_MS) return "enabled" try { @@ -336,7 +339,7 @@ async function memoryStatus( missingFromListWarned.add(binding.datamateId) log.warn("workspace missing from the workspace list; memory setting unknown", { workspace: binding.datamateId }) } - return "error" + return { status: "error", error: "workspace missing from the workspace list" } } missingFromListWarned.delete(binding.datamateId) if (match.memoryEnabled === undefined && !missingFieldWarned.has(binding.datamateId)) { @@ -363,7 +366,7 @@ async function memoryStatus( log.warn("could not confirm workspace memory setting", { err: String(err) }) // Not cached either way: a transient failure should neither disable the // mirror for a minute nor keep it enabled. - return "error" + return { status: "error", error: String(err) } } } @@ -1218,7 +1221,7 @@ export async function memoryEnabledForPoller(binding: CachedBinding): Promise<"e const ask = (async () => { try { const status = await memoryStatus(binding, { fresh: true }) - const answer = status === "error" ? ("unknown" as const) : status + const answer = typeof status === "string" ? status : ("unknown" as const) pollMemo.set(key, { at: Date.now(), status: answer }) return answer } finally { @@ -1352,8 +1355,10 @@ export async function backfill( if (!binding) return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason: "unbound" } const status = await memoryStatus(binding) - if (status !== "enabled") - return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason: status } + if (status !== "enabled") { + const gateReason = typeof status === "string" ? status : status.status + return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason } + } const index = await readIndex() const { pending, skipped } = partitionPending(blocks, binding, index) @@ -1530,7 +1535,7 @@ type LoadOutcome = ( } | { status: "unlinked"; /** The server confirmed it, rather than a lookup failing. */ confirmed: boolean } | { status: "disabled" } - | { status: "error" } + | { status: "error"; /** What failed, for the sync record and its reason class. */ error: string } ) & { epoch?: string dir?: string | null @@ -1572,12 +1577,12 @@ async function loadWorkspaceMemory(directory?: string): Promise { stable = before === epoch } vouched = epoch - if (!stable) return { status: "error", epoch, dir, ...stamp } + if (!stable) return { status: "error", error: "the workspace link changed while loading", epoch, dir, ...stamp } if (!binding) return { status: "unlinked", confirmed, epoch, dir, ...stamp } const datamateId = binding.datamateId resolved = datamateId const enabled = await memoryStatus(binding) - if (enabled === "error") return { status: "error", epoch, dir, datamateId, ...stamp } + if (typeof enabled !== "string") return { status: "error", error: enabled.error, epoch, dir, datamateId, ...stamp } if (enabled === "disabled") return { status: "disabled", epoch, dir, datamateId, ...stamp } const ownProject = projectIdentityFor(binding) @@ -1632,7 +1637,7 @@ async function loadWorkspaceMemory(directory?: string): Promise { log.warn("workspace memory load failed", { err: String(err) }) // Stamped like any other outcome: without an epoch the session would reload (and make // the prompt wait) on every turn for as long as the service is down. - return { status: "error", epoch: vouched, dir, datamateId: resolved, ...stamp } + return { status: "error", error: String(err), epoch: vouched, dir, datamateId: resolved, ...stamp } } } @@ -1678,7 +1683,9 @@ function recordSyncState(outcome: LoadOutcome): void { if (retire) return SyncState.forget(dir, "memory", { isCurrent, before: outcome.observedAt }) if (outcome.datamateId === undefined) return const result = - outcome.status === "loaded" ? { items: outcome.items } : { error: "could not load workspace memory" } + outcome.status === "loaded" + ? { items: outcome.items } + : { error: `could not load workspace memory: ${"error" in outcome ? outcome.error : outcome.status}` } return SyncState.record(dir, outcome.datamateId, "memory", result, { isCurrent, scope: outcome.scope, diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index f1386a456f..ec7b10b014 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -80,16 +80,16 @@ describe("workspace id on the telemetry context", () => { workspace_id: 7, how: "bind_existing", }) + // Another session's turn moves the context before the batch is serialised (a serve process): + // the rows above keep the workspace they were tracked under. + Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) + Telemetry.track(startup("unbound")) await Telemetry.flush() const bound = envelopes().find((e) => e.data.baseData.properties.command === "bound") expect(bound.data.baseData.properties.workspace_id).toBe("42") expect(bound.data.baseData.properties.client).toBe("altimate-code") const linked = envelopes().find((e) => e.data.baseData.name === "workspace_linked") expect(linked.data.baseData.properties.workspace_id).toBe("7") // the row's own id, not the context's - - Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) - Telemetry.track(startup("unbound")) - await Telemetry.flush() const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") expect("workspace_id" in unbound.data.baseData.properties).toBe(false) }) diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index 0ada7d3cca..3747a18a4a 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -2800,7 +2800,9 @@ describe("sync state", () => { listFails = false } const failed = await memoryState(dir, (e) => e.status === "error") - expect(failed).toMatchObject({ status: "error", count: 1, error: "could not load workspace memory" }) + expect(failed).toMatchObject({ status: "error", count: 1 }) + // The record keeps what failed, so the sync-failure reason class is real rather than "other". + expect(failed?.error).toMatch(/^could not load workspace memory: /) // "memory loaded Xm ago" reads the last success, which the failure did not move. expect(failed?.lastSucceededAt).toBe(loaded!.lastSucceededAt) }) From 403222f5e5d7c11047577d12f12d17a1a61d1b3b Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:52:53 +0800 Subject: [PATCH 16/59] fix: stamp the workspace of the event's own session A serve process runs sessions side by side, so the process-wide context can belong to another session's turn when an event is tracked; the workspace is now kept per session and read by the event's session_id. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 28 +++++++++++++++++-- .../telemetry/workspace-context.test.ts | 28 +++++++++++++++++-- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 0088e9b9b2..13e3b9dea3 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1976,6 +1976,12 @@ export namespace Telemetry { // the session, first-use, skill, memory, tool and outcome rows join the workspace without each // emit site knowing about it. let workspaceId: number | undefined + // altimate_change start — the workspace of every session that set the context, by session: a serve + // process runs sessions side by side, so the singleton above can belong to another session's turn + // when an event is tracked. Insertion-ordered; the oldest entry goes past the cap. + const sessionWorkspaces = new Map() + const MAX_SESSION_WORKSPACES = 256 + // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 let initPromise: Promise | undefined @@ -2263,6 +2269,15 @@ export namespace Telemetry { sessionId = opts.sessionId projectId = opts.projectId workspaceId = opts.workspaceId + // altimate_change start — remembered per session for the stamping in track() + if (opts.sessionId) { + sessionWorkspaces.delete(opts.sessionId) + sessionWorkspaces.set(opts.sessionId, opts.workspaceId) + if (sessionWorkspaces.size > MAX_SESSION_WORKSPACES) { + sessionWorkspaces.delete(sessionWorkspaces.keys().next().value!) + } + } + // altimate_change end } export function getContext(): { sessionId: string; projectId: string; workspaceId?: number } { @@ -2434,9 +2449,15 @@ export namespace Telemetry { const operationId = typeof eventSession === "string" ? TraceContext.activeTraceId(eventSession) : undefined if (operationId) (event as any)._operation_id = operationId // altimate_change end - // altimate_change start — the bound workspace is read now, not at flush: a serve process can start - // another session's turn, and move the context, before this event is serialised. - if (workspaceId !== undefined && !("workspace_id" in event)) (event as any).workspace_id = workspaceId + // altimate_change start — the bound workspace is read now, not at flush, and for the event's own + // session: a serve process runs sessions side by side, so the process-wide context can belong to + // another session's turn. A row with no session takes the current context; a row naming a session + // that never set the context gains no key rather than another session's. + if (!("workspace_id" in event)) { + const own = + typeof eventSession === "string" && eventSession !== "" ? sessionWorkspaces.get(eventSession) : workspaceId + if (own !== undefined) (event as any).workspace_id = own + } // altimate_change end buffer.push(event) if (buffer.length > MAX_BUFFER_SIZE) { @@ -2635,6 +2656,7 @@ export namespace Telemetry { sessionId = "" projectId = "" workspaceId = undefined + sessionWorkspaces.clear() // altimate_change machineId = "" // doInit() only assigns userEmail when Account.active() returns one, so without this a // logout followed by a re-init in the same process kept hashing the previous account into diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index ec7b10b014..5a22c78401 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -11,10 +11,18 @@ describe("workspace id on the telemetry context", () => { let homedirSpy: ReturnType let savedCs: string | undefined let savedHome: string | undefined + // Either disable flag wins over the connection string; cleared for the real init below and restored. + const DISABLE_FLAGS = ["ALTIMATE_TELEMETRY_DISABLED", "OPENCODE_DISABLE_TELEMETRY"] as const + let savedFlags: Record beforeEach(() => { home = mkdtempSync(path.join(os.tmpdir(), "telemetry-ws-")) savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + savedFlags = {} + for (const flag of DISABLE_FLAGS) { + savedFlags[flag] = process.env[flag] + delete process.env[flag] + } savedHome = process.env.OPENCODE_TEST_HOME process.env.OPENCODE_TEST_HOME = home process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = @@ -36,6 +44,10 @@ describe("workspace id on the telemetry context", () => { else process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = savedCs if (savedHome === undefined) delete process.env.OPENCODE_TEST_HOME else process.env.OPENCODE_TEST_HOME = savedHome + 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 }) }) @@ -80,16 +92,28 @@ describe("workspace id on the telemetry context", () => { workspace_id: 7, how: "bind_existing", }) - // Another session's turn moves the context before the batch is serialised (a serve process): - // the rows above keep the workspace they were tracked under. + // Another session's turn moves the context (a serve process) before the batch is serialised, and + // before s1 tracks its next rows: every row still carries the workspace of its own session. Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) Telemetry.track(startup("unbound")) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1" }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s2" }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s3" }) // never set the context + Telemetry.setContext({ sessionId: "s4", projectId: "p1", workspaceId: 9 }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s2" }) await Telemetry.flush() const bound = envelopes().find((e) => e.data.baseData.properties.command === "bound") expect(bound.data.baseData.properties.workspace_id).toBe("42") expect(bound.data.baseData.properties.client).toBe("altimate-code") const linked = envelopes().find((e) => e.data.baseData.name === "workspace_linked") expect(linked.data.baseData.properties.workspace_id).toBe("7") // the row's own id, not the context's + const unlinked = envelopes().filter((e) => e.data.baseData.name === "workspace_unlinked") + expect(unlinked.map((e) => [e.tags["ai.session.id"], e.data.baseData.properties.workspace_id])).toEqual([ + ["s1", "42"], + ["s2", undefined], + ["s3", undefined], + ["s2", undefined], + ]) const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") expect("workspace_id" in unbound.data.baseData.properties).toBe(false) }) From 40807f5ac1b67b4051f57c68ac9c06706b7e6dc0 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:33:48 +0800 Subject: [PATCH 17/59] fix: address cubic's review of the usage rows Session entries end with the turn, not at shutdown; the engine key counts only in a bound turn; only project cloud blocks are workspace memory; skill source scoped to this project; unknown starter contents omitted; documents counter removed; failure text keeps the status and is redacted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 31 +++++++++------ .../src/altimate/workspace/memory-sync.ts | 32 +++++++++++---- .../src/altimate/workspace/starter.ts | 7 ++-- .../src/altimate/workspace/sync-state.ts | 2 +- .../altimate/workspace/tool-call-telemetry.ts | 19 +++++---- packages/opencode/src/memory/prompt.ts | 11 +++--- packages/opencode/src/session/prompt.ts | 7 ++-- packages/opencode/src/tool/skill.ts | 17 ++++++-- .../telemetry/workspace-context.test.ts | 31 ++++++++++++--- .../test/altimate/workspace/starter.test.ts | 17 +++----- .../workspace/tool-call-telemetry.test.ts | 39 ++++++++++++++----- .../test/memory/overlay-merge.test.ts | 15 ++++++- .../opencode/test/tool/skill-source.test.ts | 6 +++ 13 files changed, 160 insertions(+), 74 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 13e3b9dea3..d4b31bc025 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -260,7 +260,6 @@ export namespace Telemetry { duration_ms: number // altimate_change — what of the bound workspace this session used (see noteWorkspaceUse) workspace_skills_used?: number - workspace_documents_used?: number workspace_memory_blocks?: number workspace_integration_calls?: number } @@ -609,10 +608,11 @@ export namespace Telemetry { timestamp: number session_id: string workspace_id: number - skills: number - integrations: number + /** Absent when the starter could not tell (unsynced, unreadable, timed out). */ + skills?: number + integrations?: number knowledge: string - memory_enabled: boolean + memory_enabled?: boolean } | { /** A memory block was written back to the bound workspace's cloud memory. */ @@ -967,7 +967,7 @@ export namespace Telemetry { duration_ms: number /** Last tool category the agent used (or "none") */ last_tool_category: string - // altimate_change — true when the session had used workspace skills, memory, documents or integrations + // altimate_change — true when the session had used workspace skills, memory or integrations workspace_context_used?: boolean } // task intent classification for understanding DE problem distribution @@ -1978,9 +1978,10 @@ export namespace Telemetry { let workspaceId: number | undefined // altimate_change start — the workspace of every session that set the context, by session: a serve // process runs sessions side by side, so the singleton above can belong to another session's turn - // when an event is tracked. Insertion-ordered; the oldest entry goes past the cap. + // when an event is tracked. An entry lives from the session's turn start to its end (endSession); + // the insertion-ordered cap only matters past that many sessions mid-turn at once. const sessionWorkspaces = new Map() - const MAX_SESSION_WORKSPACES = 256 + const MAX_SESSION_WORKSPACES = 1024 // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2249,20 +2250,29 @@ export namespace Telemetry { // emit sites note what they used; session_end reports the counts and task_outcome_signal // whether any workspace context was in play. Keyed by session so a serve process with several // sessions keeps them apart; cleared when the session ends. - export type WorkspaceUse = { skills: number; documents: number; memory: number; integrations: number } + export type WorkspaceUse = { skills: number; memory: number; integrations: number } const workspaceUse = new Map() export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1) { if (!sessionId || n <= 0) return - const use = workspaceUse.get(sessionId) ?? { skills: 0, documents: 0, memory: 0, integrations: 0 } + const use = workspaceUse.get(sessionId) ?? { skills: 0, memory: 0, integrations: 0 } use[kind] += n workspaceUse.set(sessionId, use) } export function workspaceUseOf(sessionId: string): WorkspaceUse { - return { skills: 0, documents: 0, memory: 0, integrations: 0, ...workspaceUse.get(sessionId) } + return { skills: 0, memory: 0, integrations: 0, ...workspaceUse.get(sessionId) } } export function clearWorkspaceUse(sessionId: string) { workspaceUse.delete(sessionId) } + /** The workspace a session's turn bound, or undefined when unbound or unknown. */ + export function workspaceOf(sessionId: string): number | undefined { + return sessionWorkspaces.get(sessionId) + } + /** A session's turn ended: its use counts are read out by then, and its workspace entry goes. */ + export function endSession(sessionId: string) { + workspaceUse.delete(sessionId) + sessionWorkspaces.delete(sessionId) + } // altimate_change end export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }) { @@ -2656,7 +2666,6 @@ export namespace Telemetry { sessionId = "" projectId = "" workspaceId = undefined - sessionWorkspaces.clear() // altimate_change machineId = "" // doInit() only assigns userEmail when Account.active() returns one, so without this a // logout followed by a re-init in the same process kept hashing the previous account into diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index a095738d93..4ac1778e41 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -36,7 +36,8 @@ import { } from "./state" import { indexKey, readIndex, readIndexEntry, recordIndexEntry } from "./memory-index" import { projectIdentity, projectIdentityKey, sameProjectIdentity, type ProjectIdentity } from "./remote-identity" -import { WorkspaceApi } from "./api-client" +import { WorkspaceApi, WorkspaceApiError } from "./api-client" +import { redact } from "@/altimate/debug/report" // altimate_change — failure text in the sync record import { AltimateApi } from "@/altimate/api/client" import { LIST_LIMIT, @@ -311,6 +312,16 @@ async function memoryEnabled(binding: CachedBinding): Promise { /** A failed read says what failed, so the sync record can classify it. */ type MemoryStatusOutcome = "enabled" | "disabled" | { status: "error"; error: string } +// altimate_change start — what failed, for the persisted sync record: the HTTP status when the API +// answered one (so the reason class sees it whatever the detail says), redacted of secrets, URLs +// and the user's identity before it is written. +function failureText(err: unknown): string { + const status = err instanceof WorkspaceApiError ? err.status : undefined + const base = err instanceof Error ? `${err.name}: ${err.message}` : String(err) + return redact(status !== undefined ? `HTTP ${status} ${base}` : base) +} +// altimate_change end + /** Three-way, because a read that cannot reach the service must not be reported * as "this workspace has no memory" — that reads as success while destroying * whatever the session already had. */ @@ -366,7 +377,7 @@ async function memoryStatus( log.warn("could not confirm workspace memory setting", { err: String(err) }) // Not cached either way: a transient failure should neither disable the // mirror for a minute nor keep it enabled. - return { status: "error", error: String(err) } + return { status: "error", error: failureText(err) } } } @@ -723,6 +734,8 @@ async function push( binding: CachedBinding | null, known?: KnownRecords, directory?: string, + /** The session that wrote the block, captured before the queue; the context's when absent. */ + sessionID?: string, ): Promise { const key = indexKey({ scope: block.scope, @@ -816,7 +829,7 @@ async function push( const others = copies.filter((r) => r.id !== match).map((r) => r.id) await archiveDuplicates(others, block, metadata, "archiving a second live record of one block") await recordIndexEntry(key, { memoryId: match, contentHash: hash, syncedAt: Date.now() }) - if (binding) trackContributed(binding) + if (binding) trackContributed(binding, sessionID) return "stored" } @@ -849,7 +862,7 @@ async function push( // leaves the block unindexed, and the next save finds the extras by identity. await archiveDuplicates(extras, block, metadata, "archiving an extra record produced by one create") await recordIndexEntry(key, { memoryId: primary, contentHash: hash, syncedAt: Date.now() }) - if (binding) trackContributed(binding) + if (binding) trackContributed(binding, sessionID) return "stored" } @@ -869,12 +882,12 @@ async function archiveDuplicates( } // altimate_change start — a successful write-back is the workspace getting something back -function trackContributed(binding: CachedBinding) { +function trackContributed(binding: CachedBinding, sessionID?: string) { try { Telemetry.track({ type: "workspace_memory_contributed", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId, + session_id: sessionID ?? Telemetry.getContext().sessionId, workspace_id: binding.datamateId, block_count: 1, }) @@ -978,6 +991,9 @@ export async function flushPendingMirrors(timeoutMs = 30_000): Promise { * disabled. */ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promise { if (!isEnabled()) return + // altimate_change — the writer's session, read before the queue: the context can be another + // session's by the time the mirror lands. + const sessionID = Telemetry.getContext().sessionId // Queued BEFORE the binding lookup, not after. Both are async, so resolving // them first let two operations on one block reach `serialize` in the // opposite order to the writes that triggered them. @@ -1002,7 +1018,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi return } if (memory !== "enabled") return - await push(block, binding, undefined, directory) + await push(block, binding, undefined, directory, sessionID) }) return tracked(task) } @@ -1637,7 +1653,7 @@ async function loadWorkspaceMemory(directory?: string): Promise { log.warn("workspace memory load failed", { err: String(err) }) // Stamped like any other outcome: without an epoch the session would reload (and make // the prompt wait) on every turn for as long as the service is down. - return { status: "error", error: String(err), epoch: vouched, dir, datamateId: resolved, ...stamp } + return { status: "error", error: failureText(err), epoch: vouched, dir, datamateId: resolved, ...stamp } } } diff --git a/packages/opencode/src/altimate/workspace/starter.ts b/packages/opencode/src/altimate/workspace/starter.ts index 67e3e654b0..5b7680426a 100644 --- a/packages/opencode/src/altimate/workspace/starter.ts +++ b/packages/opencode/src/altimate/workspace/starter.ts @@ -154,10 +154,11 @@ export function trackStarterShown(workspaceId: number, contents: WorkspaceConten timestamp: Date.now(), session_id: Telemetry.getContext().sessionId, workspace_id: workspaceId, - skills: Array.isArray(contents.skills) ? contents.skills.length : 0, - integrations: Array.isArray(contents.integrations) ? contents.integrations.length : 0, + // Unknown is left out, not reported as none: the starter's own rule. + ...(Array.isArray(contents.skills) ? { skills: contents.skills.length } : {}), + ...(Array.isArray(contents.integrations) ? { integrations: contents.integrations.length } : {}), knowledge: contents.knowledge?.kind ?? "unknown", - memory_enabled: contents.memoryEnabled === true, + ...(typeof contents.memoryEnabled === "boolean" ? { memory_enabled: contents.memoryEnabled } : {}), }) } catch { // telemetry never blocks the starter diff --git a/packages/opencode/src/altimate/workspace/sync-state.ts b/packages/opencode/src/altimate/workspace/sync-state.ts index ac428534d1..c8dd5819dc 100644 --- a/packages/opencode/src/altimate/workspace/sync-state.ts +++ b/packages/opencode/src/altimate/workspace/sync-state.ts @@ -279,7 +279,7 @@ function chain(file: string, work: () => Promise, kind: SyncKind): Promise /** The reason class of a sync error message, for workspace_sync_failed. */ export function syncFailureReason(error: string | undefined): "auth" | "not_found" | "network" | "other" { const e = (error ?? "").toLowerCase() - if (/\b(401|403)\b|unauthori[sz]ed|forbidden|sign(ed)? in|credential/.test(e)) return "auth" + if (/\b(401|403)\b|unauthori[sz]ed|forbidden|\bsign(ed)? in\b|credential/.test(e)) return "auth" if (/\b404\b|not found|no longer exists|unknown workspace/.test(e)) return "not_found" if (/timed? ?out|cannot reach|unreachable|econn|enotfound|network|socket/.test(e)) return "network" return "other" diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts index dc9351682d..77217f46a6 100644 --- a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -1,18 +1,20 @@ // altimate_change — tool_call rows for the bound workspace's integration calls. import { Telemetry } from "@/altimate/telemetry" -import { mcpToolSource } from "@/altimate/tool-source" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { redact } from "@/altimate/debug/report" -/** True when the MCP client serving a tool is the bound workspace's datamate server. */ +/** True when the MCP client serving a tool is the engine the workspace overlay manages. */ export function isWorkspaceClient(clientName: string): boolean { - return mcpToolSource(clientName) === "altimate" + return clientName === DATAMATE_KEY } /** - * Track one MCP call that went through the bound workspace. MCP tools never pass through the - * `Tool.define` wrapper that tracks registry tools, so this is where a workspace integration + * Track one MCP call that went through the bound workspace's engine. MCP tools never pass through + * the `Tool.define` wrapper that tracks registry tools, so this is where a workspace integration * call becomes a `tool_call` row; a successful one also counts as an integration use for the - * session. Third-party MCP servers stay untracked and registry tools (`datamate_manager` - * included) never reach here. Returns whether the call was tracked. + * session. Only the managed engine client (`datamate`) in a session whose turn is bound counts: + * a standalone `datamate-` server, or the key in an unbound session, is not the workspace's. + * Returns whether the call was tracked. */ export function trackWorkspaceToolCall(input: { key: string @@ -24,6 +26,7 @@ export function trackWorkspaceToolCall(input: { error?: unknown }): boolean { if (!isWorkspaceClient(input.clientName)) return false + if (Telemetry.workspaceOf(input.sessionID) === undefined) return false try { if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") const message = input.error instanceof Error ? input.error.message : String(input.error) @@ -40,7 +43,7 @@ export function trackWorkspaceToolCall(input: { duration_ms: Date.now() - input.startedAt, sequence_index: 0, previous_tool: null, - ...(input.error !== undefined && { error: Telemetry.maskString(message).slice(0, 500) }), + ...(input.error !== undefined && { error: redact(Telemetry.maskString(message)).slice(0, 500) }), }) } catch { // telemetry never surfaces into the tool call diff --git a/packages/opencode/src/memory/prompt.ts b/packages/opencode/src/memory/prompt.ts index 984a4b6324..61590a9033 100644 --- a/packages/opencode/src/memory/prompt.ts +++ b/packages/opencode/src/memory/prompt.ts @@ -246,8 +246,9 @@ export namespace MemoryPrompt { injectedCount++ injectedTraining.push(item.block) scopesSeen.add(item.block.scope) - // altimate_change start — a block from the bound workspace's cloud memory counts as workspace scope - if (isRemote(item.block)) { + // altimate_change start — a project-scope block from the bound workspace's cloud memory counts as + // workspace scope; a global cloud block is the account's and applies everywhere + if (isRemote(item.block) && item.block.scope === "project") { scopesSeen.add("workspace") workspaceBlocks++ } @@ -272,7 +273,7 @@ export namespace MemoryPrompt { injectedCount++ scopesSeen.add(item.block.scope) // altimate_change start — as above, for memory blocks - if (isRemote(item.block)) { + if (isRemote(item.block) && item.block.scope === "project") { scopesSeen.add("workspace") workspaceBlocks++ } @@ -310,9 +311,7 @@ export namespace MemoryPrompt { // altimate_change start — workspace memory use, for memory_injection and the session's summary rows ...(workspaceBlocks > 0 && { workspace_blocks: workspaceBlocks }), }) - if (workspaceBlocks > 0) { - Telemetry.noteWorkspaceUse(ctx?.sessionID ?? Telemetry.getContext().sessionId, "memory", workspaceBlocks) - } + if (workspaceBlocks > 0 && ctx?.sessionID) Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks) // altimate_change end } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0b52470f79..81974c654c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -122,13 +122,12 @@ import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_chan // altimate_change start — the session's bound workspace rides on every telemetry row import { readLocalBinding } from "@/altimate/workspace/state" -/** The session's workspace use, as session_end fields; counts are cleared with the read. */ +/** The session's workspace use, as session_end fields; the read ends the session's telemetry entries. */ function workspaceUseFields(sessionID: string) { const use = Telemetry.workspaceUseOf(sessionID) - Telemetry.clearWorkspaceUse(sessionID) + Telemetry.endSession(sessionID) return { workspace_skills_used: use.skills, - workspace_documents_used: use.documents, workspace_memory_blocks: use.memory, workspace_integration_calls: use.integrations, } @@ -2565,7 +2564,7 @@ export namespace SessionPrompt { step_count: step, duration_ms: Date.now() - sessionStartTime, last_tool_category: lastToolCategory || "none", - // altimate_change — whether workspace skills, memory, documents or integrations were in play + // altimate_change — whether workspace skills, memory or integrations were in play workspace_context_used: Object.values(Telemetry.workspaceUseOf(sessionID)).some((n) => n > 0), }) // Tool chain effectiveness — aggregated tool sequence + outcome diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 066331aa2a..2fa95a83c4 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -19,11 +19,16 @@ import { selectSkillsWithLLM } from "../altimate/skill-selector" import { Telemetry } from "../altimate/telemetry" import os from "os" import { isBuiltinSkillLocation } from "../altimate/skill-source" +import { Instance } from "../project/instance" const MAX_DISPLAY_SKILLS = 50 // altimate_change start — classifySkillSource helper for skill telemetry + source badge -export function classifySkillSource(location: string): "builtin" | "global" | "project" | "workspace" { +export function classifySkillSource( + location: string, + /** This project's root; with it, only its own managed folder is the workspace's. */ + projectRoot?: string, +): "builtin" | "global" | "project" | "workspace" { // Normalize separators so `.altimate/builtin` / homedir prefix match on Windows too. const normalized = location.replace(/\\/g, "/") // Embedded skills load with a `builtin:/SKILL.md` location and Altimate's @@ -34,8 +39,12 @@ export function classifySkillSource(location: string): "builtin" | "global" | "p // altimate_change - the same predicate the skill listing labels with, so the two cannot drift apart if (isBuiltinSkillLocation(location)) return "builtin" // altimate_change start — skills synced from the bound workspace live under the managed - // `_workspace` folder; they are the workspace's, not the project's. - if (/\/\.altimate-code\/skill\/_workspace\//.test(normalized)) return "workspace" + // `_workspace` folder of this project; they are the workspace's, not the project's. Another + // project's managed folder is not this session's workspace. + if (projectRoot !== undefined) { + const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "") + if (normalized.startsWith(`${root}/.altimate-code/skill/_workspace/`)) return "workspace" + } else if (/\/\.altimate-code\/skill\/_workspace\//.test(normalized)) return "workspace" // altimate_change end if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global" return "project" @@ -260,7 +269,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { // altimate_change end // altimate_change start — classify origin once, reused for telemetry and the source badge - const skillOrigin = classifySkillSource(skill.location) + const skillOrigin = classifySkillSource(skill.location, Instance.directory) // altimate_change start — count workspace skill use for the session's summary rows if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills") // altimate_change end diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 5a22c78401..0f8c02e319 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -63,19 +63,19 @@ describe("workspace id on the telemetry context", () => { timestamp: Date.now(), }) as const - test("workspace use is counted per session and cleared on read-out", () => { + test("workspace use is counted per session and cleared explicitly", () => { Telemetry.clearWorkspaceUse("s9") - expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) Telemetry.noteWorkspaceUse("s9", "skills") Telemetry.noteWorkspaceUse("s9", "memory", 3) Telemetry.noteWorkspaceUse("s9", "integrations") Telemetry.noteWorkspaceUse("s9", "integrations") Telemetry.noteWorkspaceUse("", "skills") // no session: ignored - Telemetry.noteWorkspaceUse("s9", "documents", 0) // nothing to count - expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 1, documents: 0, memory: 3, integrations: 2 }) - expect(Telemetry.workspaceUseOf("other")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + Telemetry.noteWorkspaceUse("s9", "skills", 0) // nothing to count + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 1, memory: 3, integrations: 2 }) + expect(Telemetry.workspaceUseOf("other")).toEqual({ skills: 0, memory: 0, integrations: 0 }) Telemetry.clearWorkspaceUse("s9") - expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, documents: 0, memory: 0, integrations: 0 }) + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) }) test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { @@ -114,7 +114,26 @@ describe("workspace id on the telemetry context", () => { ["s3", undefined], ["s2", undefined], ]) + const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") expect("workspace_id" in unbound.data.baseData.properties).toBe(false) + + // One session's turn ending (endSession, and the shutdown the loop runs after it) leaves the + // other sessions' entries alone; the ended session's later rows gain no key. + posts = [] + Telemetry.endSession("s1") + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.workspaceOf("s4")).toBe(9) + expect(Telemetry.workspaceOf("s1")).toBeUndefined() + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s4" }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1" }) + await Telemetry.flush() + const later = envelopes().filter((e) => e.data.baseData.name === "workspace_unlinked") + expect(later.map((e) => [e.tags["ai.session.id"], e.data.baseData.properties.workspace_id])).toEqual([ + ["s4", "9"], + ["s1", undefined], + ]) + Telemetry.endSession("s4") }) }) diff --git a/packages/opencode/test/altimate/workspace/starter.test.ts b/packages/opencode/test/altimate/workspace/starter.test.ts index 3f00af02bd..547393bea3 100644 --- a/packages/opencode/test/altimate/workspace/starter.test.ts +++ b/packages/opencode/test/altimate/workspace/starter.test.ts @@ -118,7 +118,7 @@ describe("wrapStarter", () => { }) describe("starter_shown telemetry", () => { - test("reports what the workspace provides as counts, and unknowns as zero", () => { + test("reports what the workspace provides as counts, and leaves unknowns out", () => { const events: any[] = [] const spy = spyOn(Telemetry, "track").mockImplementation((e) => { events.push(e) @@ -129,23 +129,16 @@ describe("starter_shown telemetry", () => { expect.objectContaining({ type: "starter_shown", workspace_id: 5, - skills: Array.isArray(full.skills) ? full.skills.length : 0, - integrations: Array.isArray(full.integrations) ? full.integrations.length : 0, + skills: Array.isArray(full.skills) ? full.skills.length : undefined, + integrations: Array.isArray(full.integrations) ? full.integrations.length : undefined, knowledge: full.knowledge?.kind ?? "unknown", memory_enabled: full.memoryEnabled === true, }), ]) events.length = 0 trackStarterShown(6, { skills: "unknown", integrations: null, memoryEnabled: null, knowledge: null }) - expect(events[0]).toEqual( - expect.objectContaining({ - workspace_id: 6, - skills: 0, - integrations: 0, - knowledge: "unknown", - memory_enabled: false, - }), - ) + expect(events[0]).toEqual(expect.objectContaining({ workspace_id: 6, knowledge: "unknown" })) + for (const key of ["skills", "integrations", "memory_enabled"]) expect(key in events[0]).toBe(false) } finally { spy.mockRestore() } diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts index 1258e9b24c..45b7342126 100644 --- a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -10,11 +10,13 @@ describe("workspace integration tool calls", () => { spy = spyOn(Telemetry, "track").mockImplementation((e) => { rows.push(e) }) - Telemetry.clearWorkspaceUse("s1") + Telemetry.endSession("s1") + Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) // s1's turn is bound }) afterEach(() => { spy.mockRestore() - Telemetry.clearWorkspaceUse("s1") + Telemetry.endSession("s1") + Telemetry.endSession("s0") }) const call = (clientName: string, outcome: "success" | "error", error?: unknown) => @@ -28,7 +30,7 @@ describe("workspace integration tool calls", () => { error, }) - test("the datamate client's calls become tool_call rows with via_workspace; a success counts as an integration use", () => { + test("the engine client's calls become tool_call rows with via_workspace; a success counts as an integration use", () => { expect(call("datamate", "success")).toBe(true) expect(rows).toHaveLength(1) expect(rows[0]).toEqual( @@ -48,24 +50,43 @@ describe("workspace integration tool calls", () => { }) test("a failed call is a tool_call error row with a masked message and no integration use", () => { - expect(call("datamates", "error", new Error("boom at /home/someone/secret.txt"))).toBe(true) + expect(call("datamate", "error", new Error("boom at /home/someone/secret.txt"))).toBe(true) expect(rows[0]).toEqual(expect.objectContaining({ status: "error", via_workspace: true, tool_type: "mcp" })) expect(rows[0].error).not.toContain("/home/someone") expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) - expect(call("datamate-acme", "error", "string failure")).toBe(true) + expect(call("datamate", "error", "string failure with token=abc123secret and https://u:pw@host/x?q=1")).toBe(true) expect(rows[1].error).toContain("string failure") + expect(rows[1].error).not.toContain("abc123secret") + expect(rows[1].error).not.toContain("u:pw@") }) - test("other MCP clients are not tracked here, and a registry tool id is not a client", () => { + test("the engine key in a session whose turn is not bound is not the workspace's", () => { + Telemetry.setContext({ sessionId: "s0", projectId: "p1" }) // unbound turn; s1 keeps its entry + expect( + trackWorkspaceToolCall({ + key: "datamate_x", + clientName: "datamate", + sessionID: "s0", + messageID: "m", + startedAt: Date.now(), + outcome: "success", + }), + ).toBe(false) + expect(rows).toHaveLength(0) + expect(call("datamate", "success")).toBe(true) // s1 is still bound + }) + + test("other MCP clients, standalone datamate servers and registry tool ids are not the workspace's engine", () => { expect(call("github", "success")).toBe(false) expect(call("filesystem", "error", new Error("x"))).toBe(false) + expect(call("datamate-acme", "success")).toBe(false) // a standalone datamate server the user configured + expect(call("datamates", "success")).toBe(false) expect(rows).toHaveLength(0) expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) // The registry tool `datamate_manager` runs through Tool.define, never through this path, and its - // id is not a datamate client name either. + // id is not the engine's client name either. expect(isWorkspaceClient("datamate_manager")).toBe(false) expect(isWorkspaceClient("datamate")).toBe(true) - expect(isWorkspaceClient("Datamates")).toBe(true) - expect(isWorkspaceClient("datamate-acme")).toBe(true) + expect(isWorkspaceClient("Datamate")).toBe(false) }) }) diff --git a/packages/opencode/test/memory/overlay-merge.test.ts b/packages/opencode/test/memory/overlay-merge.test.ts index 10c643904f..b77fd0970e 100644 --- a/packages/opencode/test/memory/overlay-merge.test.ts +++ b/packages/opencode/test/memory/overlay-merge.test.ts @@ -143,9 +143,18 @@ describe("the memory refresh tool", () => { test("a block from the bound workspace's memory is injected under the workspace scope and counted", async () => { const { Telemetry } = await import("../../src/altimate/telemetry") - listResponse = [remote("ws/one", "WORKSPACE BLOCK")] + // A project-scope block is the workspace's; a global cloud block is the account's and is + // injected too, but not counted as workspace memory. + listResponse = [ + remote("ws/one", "WORKSPACE BLOCK", { block_scope: "project", datamate_id: "7" }), + remote("acct/one", "GLOBAL BLOCK"), + ] await hydrate(SES) - expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["ws/one"]) + expect( + overlayBlocks(SES) + .map((b) => b.id) + .sort(), + ).toEqual(["acct/one", "ws/one"]) const events: any[] = [] const spy = spyOn(Telemetry, "track").mockImplementation((e) => { events.push(e) @@ -154,9 +163,11 @@ describe("the memory refresh tool", () => { Telemetry.clearWorkspaceUse(SES) const injected = await MemoryPrompt.inject(8000, { sessionID: SES }) expect(injected).toContain("WORKSPACE BLOCK") + expect(injected).toContain("GLOBAL BLOCK") const injection = events.find((e) => e.type === "memory_injection") expect(injection).toBeDefined() expect(injection.scopes_used).toContain("workspace") + expect(injection.scopes_used).toContain("global") expect(injection.workspace_blocks).toBe(1) expect(Telemetry.workspaceUseOf(SES).memory).toBe(1) } finally { diff --git a/packages/opencode/test/tool/skill-source.test.ts b/packages/opencode/test/tool/skill-source.test.ts index 822a2930e8..2355c98c53 100644 --- a/packages/opencode/test/tool/skill-source.test.ts +++ b/packages/opencode/test/tool/skill-source.test.ts @@ -23,6 +23,12 @@ describe("classifySkillSource", () => { expect(classifySkillSource("C:\\work\\repo\\.altimate-code\\skill\\_workspace\\x\\SKILL.md")).toBe("workspace") // a project skill that merely mentions the word is still the project's expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace-notes/SKILL.md")).toBe("project") + // With the project root known, only this project's managed folder is the workspace's. + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace/x/SKILL.md", "/work/repo")).toBe("workspace") + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace/x/SKILL.md", "/work/repo/")).toBe( + "workspace", + ) + expect(classifySkillSource("/work/other/.altimate-code/skill/_workspace/x/SKILL.md", "/work/repo")).toBe("project") }) test("a third-party skill under some other node_modules is NOT tagged Altimate", () => { From 217edbd74258d68ac0cbc82efecf5050a66956f6 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:37:38 +0800 Subject: [PATCH 18/59] fix: count injected workspace memory per turn; report only project write-backs Memory is injected on every step, so the count keeps the largest injection instead of summing; a global block's live mirror reports nothing; the injection row names the injecting session. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/telemetry/index.ts | 6 ++++-- .../opencode/src/altimate/workspace/memory-sync.ts | 8 +++++--- .../src/altimate/workspace/tool-call-telemetry.ts | 10 ++++++++-- packages/opencode/src/memory/prompt.ts | 6 ++++-- .../test/altimate/telemetry/workspace-context.test.ts | 5 +++++ 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index d4b31bc025..f757de743c 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2252,10 +2252,12 @@ export namespace Telemetry { // sessions keeps them apart; cleared when the session ends. export type WorkspaceUse = { skills: number; memory: number; integrations: number } const workspaceUse = new Map() - export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1) { + /** `add` accumulates; `max` records the largest value seen in the turn (memory is injected on every + * step of a turn, so a sum would multiply the blocks by the step count). */ + export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1, mode: "add" | "max" = "add") { if (!sessionId || n <= 0) return const use = workspaceUse.get(sessionId) ?? { skills: 0, memory: 0, integrations: 0 } - use[kind] += n + use[kind] = mode === "max" ? Math.max(use[kind], n) : use[kind] + n workspaceUse.set(sessionId, use) } export function workspaceUseOf(sessionId: string): WorkspaceUse { diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 4ac1778e41..7c4a29babd 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -829,7 +829,7 @@ async function push( const others = copies.filter((r) => r.id !== match).map((r) => r.id) await archiveDuplicates(others, block, metadata, "archiving a second live record of one block") await recordIndexEntry(key, { memoryId: match, contentHash: hash, syncedAt: Date.now() }) - if (binding) trackContributed(binding, sessionID) + if (block.scope === "project" && binding) trackContributed(binding, sessionID) return "stored" } @@ -862,7 +862,7 @@ async function push( // leaves the block unindexed, and the next save finds the extras by identity. await archiveDuplicates(extras, block, metadata, "archiving an extra record produced by one create") await recordIndexEntry(key, { memoryId: primary, contentHash: hash, syncedAt: Date.now() }) - if (binding) trackContributed(binding, sessionID) + if (block.scope === "project" && binding) trackContributed(binding, sessionID) return "stored" } @@ -881,7 +881,9 @@ async function archiveDuplicates( } } -// altimate_change start — a successful write-back is the workspace getting something back +// altimate_change start — a successful write-back of a project block is the workspace getting something +// back; a global block is the account's (the live mirror resolves a binding for it too, to consult the +// memory setting), so it is not reported. function trackContributed(binding: CachedBinding, sessionID?: string) { try { Telemetry.track({ diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts index 77217f46a6..06cc572012 100644 --- a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -29,7 +29,13 @@ export function trackWorkspaceToolCall(input: { if (Telemetry.workspaceOf(input.sessionID) === undefined) return false try { if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") - const message = input.error instanceof Error ? input.error.message : String(input.error) + const error = + input.error === undefined + ? undefined + : redact(Telemetry.maskString(input.error instanceof Error ? input.error.message : String(input.error))).slice( + 0, + 500, + ) Telemetry.track({ type: "tool_call", timestamp: Date.now(), @@ -43,7 +49,7 @@ export function trackWorkspaceToolCall(input: { duration_ms: Date.now() - input.startedAt, sequence_index: 0, previous_tool: null, - ...(input.error !== undefined && { error: redact(Telemetry.maskString(message)).slice(0, 500) }), + ...(error !== undefined && { error }), }) } catch { // telemetry never surfaces into the tool call diff --git a/packages/opencode/src/memory/prompt.ts b/packages/opencode/src/memory/prompt.ts index 61590a9033..e1d4a7f289 100644 --- a/packages/opencode/src/memory/prompt.ts +++ b/packages/opencode/src/memory/prompt.ts @@ -303,7 +303,8 @@ export namespace MemoryPrompt { Telemetry.track({ type: "memory_injection", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId, + // altimate_change — the injecting session, not the process context (another turn's in serve) + session_id: ctx?.sessionID ?? Telemetry.getContext().sessionId, block_count: injectedCount, total_chars: used, budget, @@ -311,7 +312,8 @@ export namespace MemoryPrompt { // altimate_change start — workspace memory use, for memory_injection and the session's summary rows ...(workspaceBlocks > 0 && { workspace_blocks: workspaceBlocks }), }) - if (workspaceBlocks > 0 && ctx?.sessionID) Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks) + // The turn injects on every step: the count is the most blocks any step injected, not a sum. + if (workspaceBlocks > 0 && ctx?.sessionID) Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks, "max") // altimate_change end } diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 0f8c02e319..31f09905c9 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -73,6 +73,11 @@ describe("workspace id on the telemetry context", () => { Telemetry.noteWorkspaceUse("", "skills") // no session: ignored Telemetry.noteWorkspaceUse("s9", "skills", 0) // nothing to count expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 1, memory: 3, integrations: 2 }) + // Memory is injected on every step of a turn: "max" keeps the largest injection, not a sum. + Telemetry.noteWorkspaceUse("s9", "memory", 2, "max") + Telemetry.noteWorkspaceUse("s9", "memory", 5, "max") + Telemetry.noteWorkspaceUse("s9", "memory", 4, "max") + expect(Telemetry.workspaceUseOf("s9").memory).toBe(5) expect(Telemetry.workspaceUseOf("other")).toEqual({ skills: 0, memory: 0, integrations: 0 }) Telemetry.clearWorkspaceUse("s9") expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) From cad667a59769c65ceaa6f0e4c6cb0212a5b7952f Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:46:20 +0800 Subject: [PATCH 19/59] test: reset the telemetry context after the tool-call suite Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../test/altimate/workspace/tool-call-telemetry.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts index 45b7342126..80056f97db 100644 --- a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -17,6 +17,9 @@ describe("workspace integration tool calls", () => { spy.mockRestore() Telemetry.endSession("s1") Telemetry.endSession("s0") + // The singleton context is process-wide: left at s1/42 it would stamp a later suite's + // session-less rows. + Telemetry.setContext({ sessionId: "", projectId: "" }) }) const call = (clientName: string, outcome: "success" | "error", error?: unknown) => From 974489ffdad316e991636eeeae8404df3c7cefbf Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:00:19 +0800 Subject: [PATCH 20/59] fix: track session_end before ending the turn's entries; end only the current turn The session_end row was stamped after its own workspace entry was deleted, and a cancelled turn unwinding late could end the replacement's entries. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 30 +++++++++++++------ packages/opencode/src/session/prompt.ts | 9 ++++-- .../telemetry/workspace-context.test.ts | 19 ++++++++++++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index f757de743c..5b7ba5d010 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1982,6 +1982,9 @@ export namespace Telemetry { // the insertion-ordered cap only matters past that many sessions mid-turn at once. const sessionWorkspaces = new Map() const MAX_SESSION_WORKSPACES = 1024 + /** The turn generation per session: `setContext` starts one, `endSession` ends only its own, so a + * cancelled turn unwinding late cannot end the replacement's entries. */ + const sessionTurns = new Map() // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2270,25 +2273,34 @@ export namespace Telemetry { export function workspaceOf(sessionId: string): number | undefined { return sessionWorkspaces.get(sessionId) } - /** A session's turn ended: its use counts are read out by then, and its workspace entry goes. */ - export function endSession(sessionId: string) { + /** A session's turn ended: its use counts are read out and its `session_end` tracked by then, and + * its workspace entry goes. With `turn` (from `setContext`), a turn that is no longer the session's + * current one ends nothing: the replacement's entries are the replacement's. */ + export function endSession(sessionId: string, turn?: number) { + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return workspaceUse.delete(sessionId) sessionWorkspaces.delete(sessionId) + sessionTurns.delete(sessionId) } // altimate_change end - export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }) { + /** Returns the turn generation for the session; pass it to `endSession` so only this turn ends it. */ + export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }): number { sessionId = opts.sessionId projectId = opts.projectId workspaceId = opts.workspaceId // altimate_change start — remembered per session for the stamping in track() - if (opts.sessionId) { - sessionWorkspaces.delete(opts.sessionId) - sessionWorkspaces.set(opts.sessionId, opts.workspaceId) - if (sessionWorkspaces.size > MAX_SESSION_WORKSPACES) { - sessionWorkspaces.delete(sessionWorkspaces.keys().next().value!) - } + if (!opts.sessionId) return 0 + sessionWorkspaces.delete(opts.sessionId) + sessionWorkspaces.set(opts.sessionId, opts.workspaceId) + if (sessionWorkspaces.size > MAX_SESSION_WORKSPACES) { + const oldest = sessionWorkspaces.keys().next().value! + sessionWorkspaces.delete(oldest) + sessionTurns.delete(oldest) } + const turn = (sessionTurns.get(opts.sessionId) ?? 0) + 1 + sessionTurns.set(opts.sessionId, turn) + return turn // altimate_change end } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 81974c654c..a86784d221 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -122,10 +122,11 @@ import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_chan // altimate_change start — the session's bound workspace rides on every telemetry row import { readLocalBinding } from "@/altimate/workspace/state" -/** The session's workspace use, as session_end fields; the read ends the session's telemetry entries. */ +/** The session's workspace use, as session_end fields. Read only: the row is tracked first, then the + * turn ends its telemetry entries (`Telemetry.endSession`), so the row itself still carries the + * workspace. */ function workspaceUseFields(sessionID: string) { const use = Telemetry.workspaceUseOf(sessionID) - Telemetry.endSession(sessionID) return { workspace_skills_used: use.skills, workspace_memory_blocks: use.memory, @@ -781,7 +782,7 @@ export namespace SessionPrompt { // altimate_change start — the workspace this project is linked to (local binding cache, no // network) goes on the telemetry context, so every row of the turn carries workspace_id. const boundWorkspace = await readLocalBinding(Instance.directory).catch(() => null) - Telemetry.setContext({ + const telemetryTurn = Telemetry.setContext({ sessionId: sessionID, projectId: Instance.project?.id ?? "", workspaceId: boundWorkspace?.datamateId, @@ -860,6 +861,7 @@ export namespace SessionPrompt { ...workspaceUseFields(sessionID), // altimate_change end }) + Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only } process.once("beforeExit", emergencySessionEnd) process.once("exit", emergencySessionEnd) @@ -2647,6 +2649,7 @@ export namespace SessionPrompt { ...workspaceUseFields(sessionID), // altimate_change end }) + Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only } await Telemetry.shutdown() // altimate_change end diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 31f09905c9..2f9549e677 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -83,6 +83,25 @@ describe("workspace id on the telemetry context", () => { expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) }) + test("a turn ends only its own entries: a cancelled turn unwinding late leaves the replacement's", () => { + const first = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) + Telemetry.noteWorkspaceUse("s7", "skills") + const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) + expect(second).toBe(first + 1) + Telemetry.noteWorkspaceUse("s7", "memory", 2) + Telemetry.endSession("s7", first) // the old generation, after the replacement started + expect(Telemetry.workspaceOf("s7")).toBe(42) + expect(Telemetry.workspaceUseOf("s7").memory).toBe(2) + Telemetry.endSession("s7", second) + expect(Telemetry.workspaceOf("s7")).toBeUndefined() + expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) + // Without a turn, the end is unconditional (tests and shutdown paths). + Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 9 }) + Telemetry.endSession("s7") + expect(Telemetry.workspaceOf("s7")).toBeUndefined() + Telemetry.setContext({ sessionId: "", projectId: "" }) + }) + test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { await Telemetry.shutdown() await Telemetry.init() From e02ae7f65e7291bfee6134cf99bbd2e4e341200f Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:05:39 +0800 Subject: [PATCH 21/59] fix: issue turn tokens from one counter so an evicted session cannot reuse one Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/telemetry/index.ts | 8 +++++--- .../altimate/telemetry/workspace-context.test.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 5b7ba5d010..7903ec9098 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1982,9 +1982,11 @@ export namespace Telemetry { // the insertion-ordered cap only matters past that many sessions mid-turn at once. const sessionWorkspaces = new Map() const MAX_SESSION_WORKSPACES = 1024 - /** The turn generation per session: `setContext` starts one, `endSession` ends only its own, so a - * cancelled turn unwinding late cannot end the replacement's entries. */ + /** The current turn token per session: `setContext` issues one, `endSession` ends only its own, so a + * cancelled turn unwinding late cannot end the replacement's entries. Tokens come from one + * process-wide counter, so an entry evicted and re-created never reuses a token a stale turn holds. */ const sessionTurns = new Map() + let nextTurn = 0 // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2298,7 +2300,7 @@ export namespace Telemetry { sessionWorkspaces.delete(oldest) sessionTurns.delete(oldest) } - const turn = (sessionTurns.get(opts.sessionId) ?? 0) + 1 + const turn = ++nextTurn sessionTurns.set(opts.sessionId, turn) return turn // altimate_change end diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 2f9549e677..b4e2b380ca 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -87,7 +87,7 @@ describe("workspace id on the telemetry context", () => { const first = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) Telemetry.noteWorkspaceUse("s7", "skills") const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) - expect(second).toBe(first + 1) + expect(second).toBeGreaterThan(first) Telemetry.noteWorkspaceUse("s7", "memory", 2) Telemetry.endSession("s7", first) // the old generation, after the replacement started expect(Telemetry.workspaceOf("s7")).toBe(42) @@ -99,6 +99,16 @@ describe("workspace id on the telemetry context", () => { Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 9 }) Telemetry.endSession("s7") expect(Telemetry.workspaceOf("s7")).toBeUndefined() + // Evicted and re-created entries never reuse a token a stale turn still holds. + const stale = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 5 }) + for (let i = 0; i < 1100; i++) Telemetry.setContext({ sessionId: `evict-${i}`, projectId: "p1" }) + expect(Telemetry.workspaceOf("s8")).toBeUndefined() // evicted + const fresh = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 6 }) + expect(fresh).toBeGreaterThan(stale) + Telemetry.endSession("s8", stale) + expect(Telemetry.workspaceOf("s8")).toBe(6) + Telemetry.endSession("s8", fresh) + for (let i = 0; i < 1100; i++) Telemetry.endSession(`evict-${i}`) Telemetry.setContext({ sessionId: "", projectId: "" }) }) From 06daf9bd5a9a886292aa537a65634311303dd5b3 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:08:00 +0800 Subject: [PATCH 22/59] fix: name the turn's own workspace on the loop's end-of-turn rows A cancelled turn unwinding after its replacement started took the replacement's session entry; the loop now stamps the workspace it resolved. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/error-detail.ts | 2 +- packages/opencode/src/altimate/telemetry/index.ts | 10 ++++++++++ packages/opencode/src/session/prompt.ts | 10 +++++++++- packages/opencode/test/telemetry/error-detail.test.ts | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/error-detail.ts b/packages/opencode/src/altimate/telemetry/error-detail.ts index a424f6c52a..3d2ef5e78d 100644 --- a/packages/opencode/src/altimate/telemetry/error-detail.ts +++ b/packages/opencode/src/altimate/telemetry/error-detail.ts @@ -411,7 +411,7 @@ export function buildErrorFingerprintEvent( recovered: boolean recoveryTool: string }, -): Telemetry.Event { +): Extract { return { type: "error_fingerprint", timestamp: Date.now(), diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 7903ec9098..629a9a9cc3 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -252,6 +252,8 @@ export namespace Telemetry { // altimate_change end | { type: "session_end" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string total_cost: number @@ -519,6 +521,8 @@ export namespace Telemetry { } | { type: "agent_outcome" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string agent: string @@ -955,6 +959,8 @@ export namespace Telemetry { // implicit quality signal for task outcome intelligence | { type: "task_outcome_signal" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string /** Behavioral signal derived from session outcome patterns */ @@ -1032,6 +1038,8 @@ export namespace Telemetry { // error pattern fingerprint — hashed error grouping with recovery data | { type: "error_fingerprint" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string /** SHA256 hash of normalized (masked) error message for grouping */ @@ -1052,6 +1060,8 @@ export namespace Telemetry { // tool chain effectiveness — aggregated tool sequence + outcome at session end | { type: "tool_chain_outcome" + /** altimate_change — the turn's own workspace, set by the prompt loop's end-of-turn rows */ + workspace_id?: number timestamp: number session_id: string /** JSON-encoded ordered tool names (capped at 50) */ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a86784d221..835bd6e64e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -787,6 +787,9 @@ export namespace SessionPrompt { projectId: Instance.project?.id ?? "", workspaceId: boundWorkspace?.datamateId, }) + // The turn's own workspace, named on the rows this loop emits at its end: a cancelled turn + // unwinding after its replacement started must not take the replacement's entry. + const turnWorkspace = boundWorkspace?.datamateId !== undefined ? { workspace_id: boundWorkspace.datamateId } : {} // altimate_change end // altimate_change start — the learn kill switch disables new delivery, while retaining trusted note cleanup. // An unused project pays only an existence check; do not load stores, migrate, @@ -851,6 +854,7 @@ export namespace SessionPrompt { emergencySessionEndFired = true Telemetry.track({ type: "session_end", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, total_cost: sessionTotalCost, @@ -2559,6 +2563,7 @@ export namespace SessionPrompt { // altimate_change start — emit quality signal, tool chain, and error fingerprint events Telemetry.track({ type: "task_outcome_signal", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, signal: Telemetry.deriveQualitySignal(outcome), @@ -2573,6 +2578,7 @@ export namespace SessionPrompt { if (toolChain.length > 0) { Telemetry.track({ type: "tool_chain_outcome", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, chain: JSON.stringify(toolChain), @@ -2590,7 +2596,7 @@ export namespace SessionPrompt { } // Error fingerprints — one event per unique error (capped at 20) for (const err of errorRecords.slice(0, 20)) { - Telemetry.track(ErrorDetail.buildErrorFingerprintEvent(sessionID, err)) + Telemetry.track({ ...ErrorDetail.buildErrorFingerprintEvent(sessionID, err), ...turnWorkspace }) // altimate_change — this turn's workspace, not the session's current entry } // altimate_change end — emit quality signal, tool chain, and error fingerprint events // altimate_change start — populate agent_outcome diagnostic fields @@ -2614,6 +2620,7 @@ export namespace SessionPrompt { // altimate_change end Telemetry.track({ type: "agent_outcome", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, // altimate_change start — route through normalizeAgentName (shared with @@ -2639,6 +2646,7 @@ export namespace SessionPrompt { process.off("exit", emergencySessionEnd) Telemetry.track({ type: "session_end", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, total_cost: sessionTotalCost, diff --git a/packages/opencode/test/telemetry/error-detail.test.ts b/packages/opencode/test/telemetry/error-detail.test.ts index 58daf34d5c..9eb92fb108 100644 --- a/packages/opencode/test/telemetry/error-detail.test.ts +++ b/packages/opencode/test/telemetry/error-detail.test.ts @@ -108,6 +108,6 @@ describe("error_fingerprint wiring", () => { test("prompt.ts emits through these helpers", async () => { const src = await Bun.file(new URL("../../src/session/prompt.ts", import.meta.url)).text() expect(src).toContain("...ErrorDetail.describeToolError(errorMsg)") - expect(src).toContain("Telemetry.track(ErrorDetail.buildErrorFingerprintEvent(sessionID, err))") + expect(src).toContain("Telemetry.track({ ...ErrorDetail.buildErrorFingerprintEvent(sessionID, err), ...turnWorkspace })") }) }) From 15891ec9d795340b939e90edf6222bd606f1cb21 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:18:26 +0800 Subject: [PATCH 23/59] fix: name the turn's workspace on workspace tool-call rows and on an unbound turn's end rows Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 2 ++ .../altimate/workspace/tool-call-telemetry.ts | 12 ++++++---- packages/opencode/src/session/prompt.ts | 9 +++++++- .../workspace/tool-call-telemetry.test.ts | 22 ++++++++++++++----- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 629a9a9cc3..50d442e2fd 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -324,6 +324,8 @@ export namespace Telemetry { error?: string // altimate_change — the call went to the bound workspace (a datamate MCP server) via_workspace?: boolean + // altimate_change — the turn's workspace, named by the MCP path on a workspace call + workspace_id?: number } | { type: "native_call" diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts index 06cc572012..b05287fa29 100644 --- a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -12,21 +12,24 @@ export function isWorkspaceClient(clientName: string): boolean { * Track one MCP call that went through the bound workspace's engine. MCP tools never pass through * the `Tool.define` wrapper that tracks registry tools, so this is where a workspace integration * call becomes a `tool_call` row; a successful one also counts as an integration use for the - * session. Only the managed engine client (`datamate`) in a session whose turn is bound counts: - * a standalone `datamate-` server, or the key in an unbound session, is not the workspace's. - * Returns whether the call was tracked. + * session. Only the managed engine client (`datamate`) in a bound turn counts: a standalone + * `datamate-` server, or the key in an unbound turn, is not the workspace's. The turn's own + * workspace is passed in and named on the row, so a call that finishes after the turn was + * cancelled and replaced keeps the workspace it ran for. Returns whether the call was tracked. */ export function trackWorkspaceToolCall(input: { key: string clientName: string sessionID: string messageID: string + /** The workspace the calling turn resolved; undefined for an unbound turn. */ + workspaceId: number | undefined startedAt: number outcome: "success" | "error" error?: unknown }): boolean { if (!isWorkspaceClient(input.clientName)) return false - if (Telemetry.workspaceOf(input.sessionID) === undefined) return false + if (input.workspaceId === undefined) return false try { if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") const error = @@ -42,6 +45,7 @@ export function trackWorkspaceToolCall(input: { session_id: input.sessionID, message_id: input.messageID, tool_name: input.key, + workspace_id: input.workspaceId, tool_type: "mcp", tool_category: Telemetry.categorizeToolName(input.key, "mcp"), status: input.outcome, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 835bd6e64e..5662ddf643 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -789,7 +789,9 @@ export namespace SessionPrompt { }) // The turn's own workspace, named on the rows this loop emits at its end: a cancelled turn // unwinding after its replacement started must not take the replacement's entry. - const turnWorkspace = boundWorkspace?.datamateId !== undefined ? { workspace_id: boundWorkspace.datamateId } : {} + // Always keyed, undefined for an unbound turn: a row that carries the key is never stamped from + // the session's entry, and the envelope drops an undefined value. + const turnWorkspace = { workspace_id: boundWorkspace?.datamateId } // altimate_change end // altimate_change start — the learn kill switch disables new delivery, while retaining trusted note cleanup. // An unused project pays only an existence check; do not load stores, migrate, @@ -1755,6 +1757,7 @@ export namespace SessionPrompt { messages: msgs, // altimate_change start — file rules append only to this tool result lessons, + workspaceId: boundWorkspace?.datamateId, // altimate_change end }), { step, agent: agent.name }, @@ -2759,6 +2762,8 @@ export namespace SessionPrompt { messages: MessageV2.WithParts[] // altimate_change start — optional for callers outside the main prompt loop lessons?: LessonDelivery + /** The workspace the calling turn resolved, for the rows its MCP calls emit. */ + workspaceId?: number // altimate_change end }) { using _ = log.time("resolveTools") @@ -2964,6 +2969,7 @@ export namespace SessionPrompt { clientName, sessionID: ctx.sessionID, messageID: ctx.messageID, + workspaceId: input.workspaceId, startedAt, outcome: "error", error: err, @@ -2975,6 +2981,7 @@ export namespace SessionPrompt { clientName, sessionID: ctx.sessionID, messageID: ctx.messageID, + workspaceId: input.workspaceId, startedAt, outcome: (result as { isError?: boolean }).isError === true ? "error" : "success", }) diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts index 80056f97db..fc8389f040 100644 --- a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -11,7 +11,6 @@ describe("workspace integration tool calls", () => { rows.push(e) }) Telemetry.endSession("s1") - Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) // s1's turn is bound }) afterEach(() => { spy.mockRestore() @@ -22,12 +21,18 @@ describe("workspace integration tool calls", () => { Telemetry.setContext({ sessionId: "", projectId: "" }) }) - const call = (clientName: string, outcome: "success" | "error", error?: unknown) => + const call = ( + clientName: string, + outcome: "success" | "error", + error?: unknown, + workspaceId: number | undefined = 42, + ) => trackWorkspaceToolCall({ key: `${clientName}_jira_get_issue`, clientName, sessionID: "s1", messageID: "m1", + workspaceId, startedAt: Date.now() - 5, outcome, error, @@ -43,6 +48,7 @@ describe("workspace integration tool calls", () => { tool_type: "mcp", status: "success", via_workspace: true, + workspace_id: 42, session_id: "s1", message_id: "m1", }), @@ -63,20 +69,24 @@ describe("workspace integration tool calls", () => { expect(rows[1].error).not.toContain("u:pw@") }) - test("the engine key in a session whose turn is not bound is not the workspace's", () => { - Telemetry.setContext({ sessionId: "s0", projectId: "p1" }) // unbound turn; s1 keeps its entry + test("the engine key in an unbound turn is not the workspace's; the turn's workspace, not the session's entry, names the row", () => { expect( trackWorkspaceToolCall({ key: "datamate_x", clientName: "datamate", - sessionID: "s0", + sessionID: "s1", messageID: "m", + workspaceId: undefined, startedAt: Date.now(), outcome: "success", }), ).toBe(false) expect(rows).toHaveLength(0) - expect(call("datamate", "success")).toBe(true) // s1 is still bound + // The session's current entry says 7 (a replacement turn), but this call ran for turn 42. + Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 7 }) + expect(call("datamate", "success")).toBe(true) + expect(rows[0].workspace_id).toBe(42) + Telemetry.setContext({ sessionId: "", projectId: "" }) }) test("other MCP clients, standalone datamate servers and registry tool ids are not the workspace's engine", () => { From 6abd47f31db4d2471e464592e953e8573abb3fdf Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:28:48 +0800 Subject: [PATCH 24/59] fix: pass the writer's session to the memory mirror; report the write-back once durable; gate counts by turn Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/telemetry/index.ts | 11 ++++++++++- .../opencode/src/altimate/workspace/memory-sync.ts | 12 +++++++----- .../src/altimate/workspace/tool-call-telemetry.ts | 4 +++- packages/opencode/src/memory/store.ts | 8 ++++++-- packages/opencode/src/memory/tools/memory-extract.ts | 2 +- packages/opencode/src/memory/tools/memory-write.ts | 2 +- packages/opencode/src/session/prompt.ts | 6 +++++- .../altimate/telemetry/workspace-context.test.ts | 5 +++++ 8 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 50d442e2fd..98f1aff661 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2271,8 +2271,17 @@ export namespace Telemetry { const workspaceUse = new Map() /** `add` accumulates; `max` records the largest value seen in the turn (memory is injected on every * step of a turn, so a sum would multiply the blocks by the step count). */ - export function noteWorkspaceUse(sessionId: string, kind: keyof WorkspaceUse, n = 1, mode: "add" | "max" = "add") { + export function noteWorkspaceUse( + sessionId: string, + kind: keyof WorkspaceUse, + n = 1, + mode: "add" | "max" = "add", + /** The turn the use belongs to (from `setContext`): a use landing after its turn was replaced + * does not count toward the replacement. */ + turn?: number, + ) { if (!sessionId || n <= 0) return + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return const use = workspaceUse.get(sessionId) ?? { skills: 0, memory: 0, integrations: 0 } use[kind] = mode === "max" ? Math.max(use[kind], n) : use[kind] + n workspaceUse.set(sessionId, use) diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 7c4a29babd..fe34c0fed0 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -862,6 +862,8 @@ async function push( // leaves the block unindexed, and the next save finds the extras by identity. await archiveDuplicates(extras, block, metadata, "archiving an extra record produced by one create") await recordIndexEntry(key, { memoryId: primary, contentHash: hash, syncedAt: Date.now() }) + // The write-back is durable once indexed: reported here, so a failed archive above leaves the block + // unindexed and the retry that indexes it reports it, once. if (block.scope === "project" && binding) trackContributed(binding, sessionID) return "stored" } @@ -991,11 +993,11 @@ export async function flushPendingMirrors(timeoutMs = 30_000): Promise { /** Mirror one block. Safe to call unconditionally — returns immediately when * workspaces are disabled, the project is unbound, or the workspace has memory * disabled. */ -export async function mirrorBlock(block: MemoryBlock, directory?: string): Promise { +export async function mirrorBlock(block: MemoryBlock, directory?: string, sessionID?: string): Promise { if (!isEnabled()) return - // altimate_change — the writer's session, read before the queue: the context can be another - // session's by the time the mirror lands. - const sessionID = Telemetry.getContext().sessionId + // altimate_change — the writer's session: the one the memory tool passed, else the context's read + // before the queue (the context can be another session's by the time the mirror lands). + const writer = sessionID ?? Telemetry.getContext().sessionId // Queued BEFORE the binding lookup, not after. Both are async, so resolving // them first let two operations on one block reach `serialize` in the // opposite order to the writes that triggered them. @@ -1020,7 +1022,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi return } if (memory !== "enabled") return - await push(block, binding, undefined, directory, sessionID) + await push(block, binding, undefined, directory, writer) }) return tracked(task) } diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts index b05287fa29..7a6ecf66bc 100644 --- a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -24,6 +24,8 @@ export function trackWorkspaceToolCall(input: { messageID: string /** The workspace the calling turn resolved; undefined for an unbound turn. */ workspaceId: number | undefined + /** The calling turn (from `setContext`); a success landing after it was replaced is not counted. */ + turn?: number startedAt: number outcome: "success" | "error" error?: unknown @@ -31,7 +33,7 @@ export function trackWorkspaceToolCall(input: { if (!isWorkspaceClient(input.clientName)) return false if (input.workspaceId === undefined) return false try { - if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations") + if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations", 1, "add", input.turn) const error = input.error === undefined ? undefined diff --git a/packages/opencode/src/memory/store.ts b/packages/opencode/src/memory/store.ts index d6b2b7f46a..11927f0d49 100644 --- a/packages/opencode/src/memory/store.ts +++ b/packages/opencode/src/memory/store.ts @@ -265,7 +265,11 @@ export namespace MemoryStore { }) } - export async function write(block: MemoryBlock): Promise<{ duplicates: MemoryBlock[] }> { + export async function write( + block: MemoryBlock, + // altimate_change — the writing session, for the workspace write-back's row + opts: { sessionID?: string } = {}, + ): Promise<{ duplicates: MemoryBlock[] }> { if (block.content.length > MEMORY_MAX_BLOCK_SIZE) { throw new Error( `Memory block "${block.id}" content exceeds maximum size of ${MEMORY_MAX_BLOCK_SIZE} characters (got ${block.content.length})`, @@ -327,7 +331,7 @@ export namespace MemoryStore { // local-existence check would then look for this block in the wrong tree // and skip it as deleted. const owningDirectory = safeDirectory() - void mirrorBlock(block, owningDirectory).catch((e) => { + void mirrorBlock(block, owningDirectory, opts.sessionID).catch((e) => { mirrorLog.warn("failed to mirror memory block to workspace", { id: block.id, scope: block.scope, diff --git a/packages/opencode/src/memory/tools/memory-extract.ts b/packages/opencode/src/memory/tools/memory-extract.ts index 1a316117bb..46fdb9b34b 100644 --- a/packages/opencode/src/memory/tools/memory-extract.ts +++ b/packages/opencode/src/memory/tools/memory-extract.ts @@ -50,7 +50,7 @@ export const MemoryExtractTool = Tool.define("altimate_memory_extract", { updated: now, citations: fact.citations, content: fact.content, - }) + }, { sessionID: ctx.sessionID }) // altimate_change — the writer's session, for the workspace row const action = existing ? "Updated" : "Created" results.push(` ✓ ${action} "${fact.id}" (${fact.scope})`) diff --git a/packages/opencode/src/memory/tools/memory-write.ts b/packages/opencode/src/memory/tools/memory-write.ts index 4613d9756d..439bc9ec0b 100644 --- a/packages/opencode/src/memory/tools/memory-write.ts +++ b/packages/opencode/src/memory/tools/memory-write.ts @@ -51,7 +51,7 @@ export const MemoryWriteTool = Tool.define("altimate_memory_write", { expires: args.expires, citations: args.citations, content: args.content, - }) + }, { sessionID: ctx.sessionID }) // altimate_change — the writer's session, for the workspace row const action = existing ? "Updated" : "Created" let output = `${action} memory block "${args.id}" in ${args.scope} scope.` diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5662ddf643..f443fd8fec 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1758,6 +1758,7 @@ export namespace SessionPrompt { // altimate_change start — file rules append only to this tool result lessons, workspaceId: boundWorkspace?.datamateId, + turn: telemetryTurn, // altimate_change end }), { step, agent: agent.name }, @@ -2762,8 +2763,10 @@ export namespace SessionPrompt { messages: MessageV2.WithParts[] // altimate_change start — optional for callers outside the main prompt loop lessons?: LessonDelivery - /** The workspace the calling turn resolved, for the rows its MCP calls emit. */ + /** The workspace the calling turn resolved, for the rows its MCP calls emit, and the turn itself, + * so a call landing after the turn was replaced does not count toward the replacement. */ workspaceId?: number + turn?: number // altimate_change end }) { using _ = log.time("resolveTools") @@ -2982,6 +2985,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, messageID: ctx.messageID, workspaceId: input.workspaceId, + turn: input.turn, startedAt, outcome: (result as { isError?: boolean }).isError === true ? "error" : "success", }) diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index b4e2b380ca..fc43474e57 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -92,6 +92,11 @@ describe("workspace id on the telemetry context", () => { Telemetry.endSession("s7", first) // the old generation, after the replacement started expect(Telemetry.workspaceOf("s7")).toBe(42) expect(Telemetry.workspaceUseOf("s7").memory).toBe(2) + // A use landing with a stale turn does not count toward the replacement. + Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", first) + expect(Telemetry.workspaceUseOf("s7").integrations).toBe(0) + Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", second) + expect(Telemetry.workspaceUseOf("s7").integrations).toBe(1) Telemetry.endSession("s7", second) expect(Telemetry.workspaceOf("s7")).toBeUndefined() expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) From 335cd9249d6fbe408def9883c4dbd1cc34487ef4 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:35:41 +0800 Subject: [PATCH 25/59] fix: name the writer's session on the memory_operation row too Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/memory/store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/memory/store.ts b/packages/opencode/src/memory/store.ts index 11927f0d49..2665e033be 100644 --- a/packages/opencode/src/memory/store.ts +++ b/packages/opencode/src/memory/store.ts @@ -312,7 +312,8 @@ export namespace MemoryStore { Telemetry.track({ type: "memory_operation", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId, + // altimate_change — the writing session when the caller passed it, else the context's + session_id: opts.sessionID ?? Telemetry.getContext().sessionId, operation: "write", scope: block.scope, block_id: block.id, From 04d18a5c563eb84d8be5a1bd42461043e995af51 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:39:49 +0800 Subject: [PATCH 26/59] fix: key the workspace-use counters by turn Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 25 +++++++++++++------ packages/opencode/src/session/prompt.ts | 10 ++++---- .../telemetry/workspace-context.test.ts | 4 +++ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 98f1aff661..7b92ec9b59 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -2268,7 +2268,10 @@ export namespace Telemetry { // whether any workspace context was in play. Keyed by session so a serve process with several // sessions keeps them apart; cleared when the session ends. export type WorkspaceUse = { skills: number; memory: number; integrations: number } + /** Keyed by session and turn, so a cancelled turn unwinding late and its replacement never share + * counts; a site that carries no turn counts toward the session's current one. */ const workspaceUse = new Map() + const useKey = (sessionId: string, turn?: number) => `${sessionId}#${turn ?? sessionTurns.get(sessionId) ?? 0}` /** `add` accumulates; `max` records the largest value seen in the turn (memory is injected on every * step of a turn, so a sum would multiply the blocks by the step count). */ export function noteWorkspaceUse( @@ -2282,15 +2285,17 @@ export namespace Telemetry { ) { if (!sessionId || n <= 0) return if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return - const use = workspaceUse.get(sessionId) ?? { skills: 0, memory: 0, integrations: 0 } + const key = useKey(sessionId, turn) + const use = workspaceUse.get(key) ?? { skills: 0, memory: 0, integrations: 0 } use[kind] = mode === "max" ? Math.max(use[kind], n) : use[kind] + n - workspaceUse.set(sessionId, use) + workspaceUse.set(key, use) } - export function workspaceUseOf(sessionId: string): WorkspaceUse { - return { skills: 0, memory: 0, integrations: 0, ...workspaceUse.get(sessionId) } + /** The counts of a turn (the session's current one when no turn is given). */ + export function workspaceUseOf(sessionId: string, turn?: number): WorkspaceUse { + return { skills: 0, memory: 0, integrations: 0, ...workspaceUse.get(useKey(sessionId, turn)) } } - export function clearWorkspaceUse(sessionId: string) { - workspaceUse.delete(sessionId) + export function clearWorkspaceUse(sessionId: string, turn?: number) { + workspaceUse.delete(useKey(sessionId, turn)) } /** The workspace a session's turn bound, or undefined when unbound or unknown. */ export function workspaceOf(sessionId: string): number | undefined { @@ -2300,8 +2305,12 @@ export namespace Telemetry { * its workspace entry goes. With `turn` (from `setContext`), a turn that is no longer the session's * current one ends nothing: the replacement's entries are the replacement's. */ export function endSession(sessionId: string, turn?: number) { - if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return - workspaceUse.delete(sessionId) + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) { + // A turn that was replaced ends only its own counts; the replacement's entries stay. + workspaceUse.delete(useKey(sessionId, turn)) + return + } + workspaceUse.delete(useKey(sessionId, turn)) sessionWorkspaces.delete(sessionId) sessionTurns.delete(sessionId) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f443fd8fec..cfcee71fcf 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -125,8 +125,8 @@ import { readLocalBinding } from "@/altimate/workspace/state" /** The session's workspace use, as session_end fields. Read only: the row is tracked first, then the * turn ends its telemetry entries (`Telemetry.endSession`), so the row itself still carries the * workspace. */ -function workspaceUseFields(sessionID: string) { - const use = Telemetry.workspaceUseOf(sessionID) +function workspaceUseFields(sessionID: string, turn: number) { + const use = Telemetry.workspaceUseOf(sessionID, turn) return { workspace_skills_used: use.skills, workspace_memory_blocks: use.memory, @@ -864,7 +864,7 @@ export namespace SessionPrompt { tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, // altimate_change start — what of the bound workspace this session used - ...workspaceUseFields(sessionID), + ...workspaceUseFields(sessionID, telemetryTurn), // altimate_change end }) Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only @@ -2576,7 +2576,7 @@ export namespace SessionPrompt { duration_ms: Date.now() - sessionStartTime, last_tool_category: lastToolCategory || "none", // altimate_change — whether workspace skills, memory or integrations were in play - workspace_context_used: Object.values(Telemetry.workspaceUseOf(sessionID)).some((n) => n > 0), + workspace_context_used: Object.values(Telemetry.workspaceUseOf(sessionID, telemetryTurn)).some((n) => n > 0), }) // Tool chain effectiveness — aggregated tool sequence + outcome if (toolChain.length > 0) { @@ -2658,7 +2658,7 @@ export namespace SessionPrompt { tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, // altimate_change start — what of the bound workspace this session used - ...workspaceUseFields(sessionID), + ...workspaceUseFields(sessionID, telemetryTurn), // altimate_change end }) Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index fc43474e57..3c3aba8310 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -89,8 +89,12 @@ describe("workspace id on the telemetry context", () => { const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) expect(second).toBeGreaterThan(first) Telemetry.noteWorkspaceUse("s7", "memory", 2) + // Counts are per turn: the cancelled turn's rows read its own, the replacement's its own. + expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 1, memory: 0, integrations: 0 }) + expect(Telemetry.workspaceUseOf("s7", second)).toEqual({ skills: 0, memory: 2, integrations: 0 }) Telemetry.endSession("s7", first) // the old generation, after the replacement started expect(Telemetry.workspaceOf("s7")).toBe(42) + expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 0, memory: 0, integrations: 0 }) expect(Telemetry.workspaceUseOf("s7").memory).toBe(2) // A use landing with a stale turn does not count toward the replacement. Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", first) From bb18d874464e5301992a35a84282e19840e980c3 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:58:13 +0800 Subject: [PATCH 27/59] fix: name the workspace explicitly on every row; no ambient stamping `track()` no longer stamps `workspace_id` from a process-wide or per-session fallback (five rounds of cancelled-turn attribution bugs came from it). `setContext` returns a turn token; loop, tool, skill, memory and first-job rows carry their turn's workspace explicitly; counters key on the turn. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../altimate/plugin/onboarding-telemetry.ts | 8 +- .../opencode/src/altimate/telemetry/index.ts | 86 +++++++++---------- .../opencode/src/altimate/tool-zod-compat.ts | 8 ++ packages/opencode/src/memory/prompt.ts | 5 +- packages/opencode/src/memory/types.ts | 4 + packages/opencode/src/session/prompt.ts | 15 +++- packages/opencode/src/tool/skill.ts | 3 +- packages/opencode/src/tool/tool.ts | 9 ++ .../telemetry/workspace-context.test.ts | 63 +++----------- 9 files changed, 104 insertions(+), 97 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index 4fed174ee1..3a1cad1d90 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -12,6 +12,7 @@ // session loop. import type { Hooks, PluginInput } from "@opencode-ai/plugin" import * as OnboardingTelemetry from "../telemetry/onboarding" +import { Telemetry } from "@/altimate/telemetry" // altimate_change start — AI-8398 workspaces trigger. Reaches into the same // EventV2 bridge the server/routes/tui.ts uses to publish TuiEvent.CommandExecute // so the workspace TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) @@ -261,7 +262,12 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise() - const MAX_SESSION_WORKSPACES = 1024 - /** The current turn token per session: `setContext` issues one, `endSession` ends only its own, so a - * cancelled turn unwinding late cannot end the replacement's entries. Tokens come from one - * process-wide counter, so an entry evicted and re-created never reuses a token a stale turn holds. */ + // altimate_change start — the current turn token per session: `setContext` issues one, `endSession` + // ends only its own, so a cancelled turn unwinding late cannot end the replacement's counters. + // Tokens come from one process-wide counter, so a token is never reused. The map holds sessions + // mid-turn; an entry goes at the turn's end. const sessionTurns = new Map() let nextTurn = 0 + /** The workspace of each session's current turn, for an emitter that runs inside the turn but + * outside the loop (a plugin hook) and asks for it explicitly; nothing is stamped from it. */ + const turnWorkspaces = new Map() // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2297,22 +2312,19 @@ export namespace Telemetry { export function clearWorkspaceUse(sessionId: string, turn?: number) { workspaceUse.delete(useKey(sessionId, turn)) } - /** The workspace a session's turn bound, or undefined when unbound or unknown. */ - export function workspaceOf(sessionId: string): number | undefined { - return sessionWorkspaces.get(sessionId) - } - /** A session's turn ended: its use counts are read out and its `session_end` tracked by then, and - * its workspace entry goes. With `turn` (from `setContext`), a turn that is no longer the session's - * current one ends nothing: the replacement's entries are the replacement's. */ + /** A session's turn ended: its use counts are read out and its `session_end` tracked by then. With + * `turn` (from `setContext`), a turn that is no longer the session's current one ends only its own + * counts: the replacement's are the replacement's. */ export function endSession(sessionId: string, turn?: number) { - if (turn !== undefined && sessionTurns.get(sessionId) !== turn) { - // A turn that was replaced ends only its own counts; the replacement's entries stay. - workspaceUse.delete(useKey(sessionId, turn)) - return - } workspaceUse.delete(useKey(sessionId, turn)) - sessionWorkspaces.delete(sessionId) + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return sessionTurns.delete(sessionId) + turnWorkspaces.delete(sessionId) + } + /** The workspace of the session's current turn, for an emitter inside that turn; undefined when + * unbound or when no turn is running. An explicit lookup, never a stamp. */ + export function turnWorkspaceOf(sessionId: string): number | undefined { + return turnWorkspaces.get(sessionId) } // altimate_change end @@ -2321,15 +2333,9 @@ export namespace Telemetry { sessionId = opts.sessionId projectId = opts.projectId workspaceId = opts.workspaceId - // altimate_change start — remembered per session for the stamping in track() + // altimate_change start — a turn token for the session's counters and its end if (!opts.sessionId) return 0 - sessionWorkspaces.delete(opts.sessionId) - sessionWorkspaces.set(opts.sessionId, opts.workspaceId) - if (sessionWorkspaces.size > MAX_SESSION_WORKSPACES) { - const oldest = sessionWorkspaces.keys().next().value! - sessionWorkspaces.delete(oldest) - sessionTurns.delete(oldest) - } + turnWorkspaces.set(opts.sessionId, opts.workspaceId) const turn = ++nextTurn sessionTurns.set(opts.sessionId, turn) return turn @@ -2505,16 +2511,10 @@ export namespace Telemetry { const operationId = typeof eventSession === "string" ? TraceContext.activeTraceId(eventSession) : undefined if (operationId) (event as any)._operation_id = operationId // altimate_change end - // altimate_change start — the bound workspace is read now, not at flush, and for the event's own - // session: a serve process runs sessions side by side, so the process-wide context can belong to - // another session's turn. A row with no session takes the current context; a row naming a session - // that never set the context gains no key rather than another session's. - if (!("workspace_id" in event)) { - const own = - typeof eventSession === "string" && eventSession !== "" ? sessionWorkspaces.get(eventSession) : workspaceId - if (own !== undefined) (event as any).workspace_id = own - } - // altimate_change end + // altimate_change — no ambient workspace stamping: a row names its workspace itself (the prompt + // loop's rows, tool rows and memory rows carry the turn's; workspace rows carry their own) or it + // carries none. A process-wide or per-session fallback kept attributing cancelled turns' rows to + // their replacements. buffer.push(event) if (buffer.length > MAX_BUFFER_SIZE) { buffer.shift() diff --git a/packages/opencode/src/altimate/tool-zod-compat.ts b/packages/opencode/src/altimate/tool-zod-compat.ts index 25938d1efd..ccab23a3cd 100644 --- a/packages/opencode/src/altimate/tool-zod-compat.ts +++ b/packages/opencode/src/altimate/tool-zod-compat.ts @@ -30,6 +30,10 @@ export type Metadata = { [key: string]: any; error?: string } export interface LegacyContext { sessionID: SessionID messageID: MessageID + // altimate_change start — the calling turn's workspace and token, as on Tool.Context + workspaceId?: number + turn?: number + // altimate_change end agent: string abort: AbortSignal callID?: string @@ -228,6 +232,10 @@ function legacyDefToDef(legacy: LegacyToolDef): DefWithoutID { const legacyCtx: LegacyContext = { sessionID: ctx.sessionID, messageID: ctx.messageID, + // altimate_change start — carried through for the rows a legacy tool emits + workspaceId: ctx.workspaceId, + turn: ctx.turn, + // altimate_change end agent: ctx.agent, abort: ctx.abort, callID: ctx.callID, diff --git a/packages/opencode/src/memory/prompt.ts b/packages/opencode/src/memory/prompt.ts index e1d4a7f289..7cc30c852b 100644 --- a/packages/opencode/src/memory/prompt.ts +++ b/packages/opencode/src/memory/prompt.ts @@ -305,6 +305,7 @@ export namespace MemoryPrompt { timestamp: Date.now(), // altimate_change — the injecting session, not the process context (another turn's in serve) session_id: ctx?.sessionID ?? Telemetry.getContext().sessionId, + workspace_id: ctx?.workspaceId, // altimate_change — the turn's workspace, named explicitly block_count: injectedCount, total_chars: used, budget, @@ -313,7 +314,9 @@ export namespace MemoryPrompt { ...(workspaceBlocks > 0 && { workspace_blocks: workspaceBlocks }), }) // The turn injects on every step: the count is the most blocks any step injected, not a sum. - if (workspaceBlocks > 0 && ctx?.sessionID) Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks, "max") + if (workspaceBlocks > 0 && ctx?.sessionID) { + Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks, "max", ctx.turn) + } // altimate_change end } diff --git a/packages/opencode/src/memory/types.ts b/packages/opencode/src/memory/types.ts index 8f3e34880c..61db9077ea 100644 --- a/packages/opencode/src/memory/types.ts +++ b/packages/opencode/src/memory/types.ts @@ -46,6 +46,10 @@ export const AGENT_TRAINING_RELEVANCE: Record { // altimate_change start — classify origin once, reused for telemetry and the source badge const skillOrigin = classifySkillSource(skill.location, Instance.directory) // altimate_change start — count workspace skill use for the session's summary rows - if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills") + if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills", 1, "add", ctx.turn) // altimate_change end // altimate_change end @@ -281,6 +281,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { type: "skill_used", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace message_id: ctx.messageID, skill_name: skill.name, skill_source: skillOrigin, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c7fa9f7c35..4558050b1d 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -59,6 +59,10 @@ export class InvalidArgumentsError extends Schema.TaggedErrorClass = { sessionID: SessionID messageID: MessageID + // altimate_change start — the calling turn's workspace and token, named on the rows a tool emits + workspaceId?: number + turn?: number + // altimate_change end agent: string abort: AbortSignal callID?: string @@ -170,6 +174,7 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: type: "tool_call", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace message_id: ctx.messageID, tool_name: id, tool_type: "standard", @@ -185,6 +190,7 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: type: "core_failure", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace tool_name: id, tool_category: Telemetry.categorizeToolName(id, "standard"), error_class: Telemetry.classifyError(errorMsg), @@ -215,6 +221,7 @@ function altimateTrackSuccess( type: "tool_call", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace message_id: ctx.messageID, tool_name: id, tool_type: "standard", @@ -252,6 +259,7 @@ function altimateTrackSuccess( type: "core_failure", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace tool_name: id, tool_category: Telemetry.categorizeToolName(id, "standard"), error_class: Telemetry.classifyError(errorMsg), @@ -269,6 +277,7 @@ function altimateTrackSuccess( type: "sql_quality", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace tool_name: id, tool_category: toolCategory, finding_count: findings.length, diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 3c3aba8310..944556bd29 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -83,7 +83,7 @@ describe("workspace id on the telemetry context", () => { expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) }) - test("a turn ends only its own entries: a cancelled turn unwinding late leaves the replacement's", () => { + test("a turn ends only its own counters; a stale turn's use does not count", () => { const first = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) Telemetry.noteWorkspaceUse("s7", "skills") const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) @@ -91,9 +91,9 @@ describe("workspace id on the telemetry context", () => { Telemetry.noteWorkspaceUse("s7", "memory", 2) // Counts are per turn: the cancelled turn's rows read its own, the replacement's its own. expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 1, memory: 0, integrations: 0 }) + expect(Telemetry.turnWorkspaceOf("s7")).toBe(42) // an explicit lookup for in-turn emitters expect(Telemetry.workspaceUseOf("s7", second)).toEqual({ skills: 0, memory: 2, integrations: 0 }) Telemetry.endSession("s7", first) // the old generation, after the replacement started - expect(Telemetry.workspaceOf("s7")).toBe(42) expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 0, memory: 0, integrations: 0 }) expect(Telemetry.workspaceUseOf("s7").memory).toBe(2) // A use landing with a stale turn does not count toward the replacement. @@ -102,32 +102,27 @@ describe("workspace id on the telemetry context", () => { Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", second) expect(Telemetry.workspaceUseOf("s7").integrations).toBe(1) Telemetry.endSession("s7", second) - expect(Telemetry.workspaceOf("s7")).toBeUndefined() expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) - // Without a turn, the end is unconditional (tests and shutdown paths). - Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 9 }) - Telemetry.endSession("s7") - expect(Telemetry.workspaceOf("s7")).toBeUndefined() - // Evicted and re-created entries never reuse a token a stale turn still holds. + expect(Telemetry.turnWorkspaceOf("s7")).toBeUndefined() + // Tokens never repeat, so a stale end can never hit a later turn. const stale = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 5 }) - for (let i = 0; i < 1100; i++) Telemetry.setContext({ sessionId: `evict-${i}`, projectId: "p1" }) - expect(Telemetry.workspaceOf("s8")).toBeUndefined() // evicted + Telemetry.endSession("s8", stale) const fresh = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 6 }) expect(fresh).toBeGreaterThan(stale) + Telemetry.noteWorkspaceUse("s8", "skills") Telemetry.endSession("s8", stale) - expect(Telemetry.workspaceOf("s8")).toBe(6) + expect(Telemetry.workspaceUseOf("s8", fresh).skills).toBe(1) Telemetry.endSession("s8", fresh) - for (let i = 0; i < 1100; i++) Telemetry.endSession(`evict-${i}`) Telemetry.setContext({ sessionId: "", projectId: "" }) }) - test("every event of a bound session carries the workspace id; an event's own id wins; unbound sessions carry none", async () => { + test("a row carries the workspace it names and nothing is stamped from the context", async () => { await Telemetry.shutdown() await Telemetry.init() Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) expect(Telemetry.getContext().workspaceId).toBe(42) - Telemetry.track(startup("bound")) + Telemetry.track(startup("bound")) // names none: carries none, whatever the context holds Telemetry.track({ type: "workspace_linked", timestamp: Date.now(), @@ -135,48 +130,16 @@ describe("workspace id on the telemetry context", () => { workspace_id: 7, how: "bind_existing", }) - // Another session's turn moves the context (a serve process) before the batch is serialised, and - // before s1 tracks its next rows: every row still carries the workspace of its own session. - Telemetry.setContext({ sessionId: "s2", projectId: "p1" }) - Telemetry.track(startup("unbound")) Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1" }) - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s2" }) - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s3" }) // never set the context - Telemetry.setContext({ sessionId: "s4", projectId: "p1", workspaceId: 9 }) - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s2" }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1", workspace_id: undefined }) await Telemetry.flush() const bound = envelopes().find((e) => e.data.baseData.properties.command === "bound") - expect(bound.data.baseData.properties.workspace_id).toBe("42") + expect("workspace_id" in bound.data.baseData.properties).toBe(false) expect(bound.data.baseData.properties.client).toBe("altimate-code") const linked = envelopes().find((e) => e.data.baseData.name === "workspace_linked") - expect(linked.data.baseData.properties.workspace_id).toBe("7") // the row's own id, not the context's + expect(linked.data.baseData.properties.workspace_id).toBe("7") const unlinked = envelopes().filter((e) => e.data.baseData.name === "workspace_unlinked") - expect(unlinked.map((e) => [e.tags["ai.session.id"], e.data.baseData.properties.workspace_id])).toEqual([ - ["s1", "42"], - ["s2", undefined], - ["s3", undefined], - ["s2", undefined], - ]) - - const unbound = envelopes().find((e) => e.data.baseData.properties.command === "unbound") - expect("workspace_id" in unbound.data.baseData.properties).toBe(false) - - // One session's turn ending (endSession, and the shutdown the loop runs after it) leaves the - // other sessions' entries alone; the ended session's later rows gain no key. - posts = [] + expect(unlinked.map((e) => e.data.baseData.properties.workspace_id)).toEqual([undefined, undefined]) Telemetry.endSession("s1") - await Telemetry.shutdown() - await Telemetry.init() - expect(Telemetry.workspaceOf("s4")).toBe(9) - expect(Telemetry.workspaceOf("s1")).toBeUndefined() - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s4" }) - Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1" }) - await Telemetry.flush() - const later = envelopes().filter((e) => e.data.baseData.name === "workspace_unlinked") - expect(later.map((e) => [e.tags["ai.session.id"], e.data.baseData.properties.workspace_id])).toEqual([ - ["s4", "9"], - ["s1", undefined], - ]) - Telemetry.endSession("s4") }) }) From 83e09125bc73ab1ce7014044fa23308fd1436211 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Fri, 16 Oct 2026 00:11:53 +0800 Subject: [PATCH 28/59] fix: the tool hook input carries the turn's workspace; batch and warehouse rows name it `tool.execute.after` gains `workspaceId`, set by the three trigger sites from the turn that ran the tool, so `first_job_completed` names it without asking the session for its current turn. `batch` passes `workspaceId`/`turn` to its inner contexts; `warehouse_add`'s suggestion failure names the turn's workspace. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../altimate/plugin/onboarding-telemetry.ts | 8 ++--- .../opencode/src/altimate/telemetry/index.ts | 10 ------- .../src/altimate/tools/warehouse-add.ts | 1 + packages/opencode/src/session/prompt.ts | 3 ++ packages/opencode/src/tool/batch.ts | 3 ++ .../altimate/telemetry/onboarding.test.ts | 19 ++++++++++++ .../telemetry/workspace-context.test.ts | 2 -- packages/opencode/test/tool/batch.test.ts | 30 +++++++++++++++++++ packages/plugin/src/index.ts | 6 +++- 9 files changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index 3a1cad1d90..b249f7fe79 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -12,7 +12,6 @@ // session loop. import type { Hooks, PluginInput } from "@opencode-ai/plugin" import * as OnboardingTelemetry from "../telemetry/onboarding" -import { Telemetry } from "@/altimate/telemetry" // altimate_change start — AI-8398 workspaces trigger. Reaches into the same // EventV2 bridge the server/routes/tui.ts uses to publish TuiEvent.CommandExecute // so the workspace TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) @@ -262,10 +261,11 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise() let nextTurn = 0 - /** The workspace of each session's current turn, for an emitter that runs inside the turn but - * outside the loop (a plugin hook) and asks for it explicitly; nothing is stamped from it. */ - const turnWorkspaces = new Map() // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 @@ -2319,12 +2316,6 @@ export namespace Telemetry { workspaceUse.delete(useKey(sessionId, turn)) if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return sessionTurns.delete(sessionId) - turnWorkspaces.delete(sessionId) - } - /** The workspace of the session's current turn, for an emitter inside that turn; undefined when - * unbound or when no turn is running. An explicit lookup, never a stamp. */ - export function turnWorkspaceOf(sessionId: string): number | undefined { - return turnWorkspaces.get(sessionId) } // altimate_change end @@ -2335,7 +2326,6 @@ export namespace Telemetry { workspaceId = opts.workspaceId // altimate_change start — a turn token for the session's counters and its end if (!opts.sessionId) return 0 - turnWorkspaces.set(opts.sessionId, opts.workspaceId) const turn = ++nextTurn sessionTurns.set(opts.sessionId, turn) return turn diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index 54adc86657..185f9f3e6b 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -121,6 +121,7 @@ IMPORTANT: For private key file paths, always use "private_key_path" (not "priva type: "core_failure", timestamp: Date.now(), session_id: Telemetry.getContext().sessionId || "unknown-session", + workspace_id: ctx.workspaceId, tool_name: "warehouse_add", tool_category: "warehouse", error_class: "internal", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3539d99fa..e712a113db 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1354,6 +1354,7 @@ export namespace SessionPrompt { sessionID, callID: part.id, args: taskArgs, + workspaceId: boundWorkspace?.datamateId, // altimate_change — the turn's workspace, for plugin rows }, result, ) @@ -2908,6 +2909,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, callID: ctx.callID, args, + workspaceId: input.workspaceId, // altimate_change — the turn's workspace, for plugin rows }, // altimate_change start — plugins observe the source-stamped output stamped, @@ -3011,6 +3013,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, callID: opts.toolCallId, args, + workspaceId: input.workspaceId, // altimate_change — the turn's workspace, for plugin rows }, result, ) diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index 9dc732347a..7f6122b2c9 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -23,6 +23,9 @@ function toEffectContext(ctx: LegacyContext, callID: string): Tool.Context { agent: ctx.agent, abort: ctx.abort, callID, + // the turn's workspace and token, so the inner tools' rows name it + workspaceId: ctx.workspaceId, + turn: ctx.turn, extra: ctx.extra, messages: ctx.messages, // altimate_change start — preserve search result permissions in nested batch tools diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index fd252a6c02..266a394eac 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -215,6 +215,25 @@ describe("activation events", () => { expect((events.find((e) => e.type === "first_job_completed") as any).job).toBe("sample_duck_db") }) + test("the first job's row names the workspace the hook input carries", async () => { + const events = captureEvents() + const hooks = await plugin() + await startOnboarding(hooks, "skip") + + // The trigger site sets `workspaceId` from the turn that ran the tool; the hook never asks the + // session for its current turn (a replacement's, when a cancelled turn's tool returns late). + await runTool( + hooks, + { tool: "sample_setup", sessionID: SESSION, callID: "c3w", args: {}, workspaceId: 7 }, + { title: "", output: "", metadata: { success: true } }, + ) + await settle() + + const completed = events.find((e) => e.type === "first_job_completed") as any + expect(completed.workspace_id).toBe(7) + expect("workspace_id" in (events.find((e) => e.type === "activation_job_selected") as any)).toBe(false) + }) + test("a failed sample setup counts as selected but not completed", async () => { const events = captureEvents() const hooks = await plugin() diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 944556bd29..69016cca41 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -91,7 +91,6 @@ describe("workspace id on the telemetry context", () => { Telemetry.noteWorkspaceUse("s7", "memory", 2) // Counts are per turn: the cancelled turn's rows read its own, the replacement's its own. expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 1, memory: 0, integrations: 0 }) - expect(Telemetry.turnWorkspaceOf("s7")).toBe(42) // an explicit lookup for in-turn emitters expect(Telemetry.workspaceUseOf("s7", second)).toEqual({ skills: 0, memory: 2, integrations: 0 }) Telemetry.endSession("s7", first) // the old generation, after the replacement started expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 0, memory: 0, integrations: 0 }) @@ -103,7 +102,6 @@ describe("workspace id on the telemetry context", () => { expect(Telemetry.workspaceUseOf("s7").integrations).toBe(1) Telemetry.endSession("s7", second) expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) - expect(Telemetry.turnWorkspaceOf("s7")).toBeUndefined() // Tokens never repeat, so a stale end can never hit a later turn. const stale = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 5 }) Telemetry.endSession("s8", stale) diff --git a/packages/opencode/test/tool/batch.test.ts b/packages/opencode/test/tool/batch.test.ts index f59fc9e3c3..5d9b7012bb 100644 --- a/packages/opencode/test/tool/batch.test.ts +++ b/packages/opencode/test/tool/batch.test.ts @@ -157,6 +157,36 @@ describe("BatchTool: file lessons", () => { }) }) +describe("BatchTool: inner tool context", () => { + test("carries the turn's workspace and token to the inner tools", async () => { + const tool = await getToolInfo() + let seen: { workspaceId?: number; turn?: number } | undefined + const registry = spyOn(ToolRegistry, "tools").mockResolvedValue([{ + id: "probe", + registrySource: undefined, + description: "captures its context", + parameters: Schema.Unknown, + execute: (_args: unknown, ctx: { workspaceId?: number; turn?: number }) => { + seen = { workspaceId: ctx.workspaceId, turn: ctx.turn } + return Effect.succeed({ title: "ok", output: "ok", metadata: {} }) + }, + }] as any) + const updatePart = Object.assign(async (part: Parameters[0]) => part, Session.updatePart) + const updates = spyOn(Session, "updatePart").mockImplementation(updatePart) + try { + const result = await tool.execute({ tool_calls: [{ tool: "probe", parameters: {} }] }, { + sessionID: SessionID.make("ses_batch_ctx"), messageID: MessageID.make("msg_batch_ctx"), agent: "build", + abort: AbortSignal.any([]), messages: [], extra: {}, workspaceId: 7, turn: 3, + } as any) + expect(result.metadata.successful).toBe(1) + expect(seen).toEqual({ workspaceId: 7, turn: 3 }) + } finally { + updates.mockRestore() + registry.mockRestore() + } + }) +}) + describe("BatchTool: schema validation", () => { test("rejects empty tool_calls array", async () => { const tool = await getToolInfo() diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139df..42d3e1fdb8 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -271,14 +271,18 @@ export interface Hooks { input: { cwd: string; sessionID?: string; callID?: string }, output: { env: Record }, ) => Promise + // altimate_change start — `workspaceId`: the workspace of the turn the tool ran in, set by the + // trigger site, so a plugin's own rows name it without consulting the session's current turn + // (which may be a replacement by the time a cancelled turn's tool returns) "tool.execute.after"?: ( - input: { tool: string; sessionID: string; callID: string; args: any }, + input: { tool: string; sessionID: string; callID: string; args: any; workspaceId?: number }, output: { title: string output: string metadata: any }, ) => Promise + // altimate_change end "experimental.chat.messages.transform"?: ( input: {}, output: { From d852acfb738637f27fc51d945b8f74346d2aa099 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Fri, 16 Oct 2026 01:54:34 +0800 Subject: [PATCH 29/59] fix: a pending task's context carries the turn's workspace; the warehouse failure row names the tool's session Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/tools/warehouse-add.ts | 2 +- packages/opencode/src/session/prompt.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index 185f9f3e6b..fb4803240a 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -120,7 +120,7 @@ IMPORTANT: For private key file paths, always use "private_key_path" (not "priva Telemetry.track({ type: "core_failure", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId || "unknown-session", + session_id: ctx.sessionID, workspace_id: ctx.workspaceId, tool_name: "warehouse_add", tool_category: "warehouse", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e712a113db..f7c539dc1b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1305,6 +1305,10 @@ export namespace SessionPrompt { sessionID: sessionID, abort, callID: part.callID, + // altimate_change start — the turn's workspace and token, for the task's own rows + workspaceId: boundWorkspace?.datamateId, + turn: telemetryTurn, + // altimate_change end extra: { bypassAgentCheck: true }, // altimate_change start — pass effective permission rules to search result filtering permissions: PermissionNext.merge(taskAgent.permission, session.permission ?? []), From 28779f53bb012394bf8ab7f0642ea711726a70fe Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Fri, 16 Oct 2026 02:17:44 +0800 Subject: [PATCH 30/59] fix: count workspace skills only in a bound turn; attachment reads carry the binding Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/session/prompt.ts | 2 ++ packages/opencode/src/tool/skill.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f7c539dc1b..d6ed9463a3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -3335,6 +3335,8 @@ export namespace SessionPrompt { const model = await Provider.getModel(info.model.providerID, info.model.modelID) const readCtx: Tool.Context = { sessionID: input.sessionID, + // altimate_change — the project's binding, so the attachment read's tool rows name the workspace + workspaceId: (await readLocalBinding(Instance.directory).catch(() => null))?.datamateId, abort: new AbortController().signal, agent: input.agent!, messageID: info.id, diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 44b10d4beb..26271bb804 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -271,7 +271,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => { // altimate_change start — classify origin once, reused for telemetry and the source badge const skillOrigin = classifySkillSource(skill.location, Instance.directory) // altimate_change start — count workspace skill use for the session's summary rows - if (skillOrigin === "workspace") Telemetry.noteWorkspaceUse(ctx.sessionID, "skills", 1, "add", ctx.turn) + // Only a bound turn's use counts: a stale `_workspace` folder in an unbound project is not the workspace's. + if (skillOrigin === "workspace" && ctx.workspaceId !== undefined) { + Telemetry.noteWorkspaceUse(ctx.sessionID, "skills", 1, "add", ctx.turn) + } // altimate_change end // altimate_change end From 8aa0eb579826d3e6b05bdea540cdc66b43a42cc5 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:30:25 +0800 Subject: [PATCH 31/59] feat: telemetry for the workspace engine Attach outcomes, the install prompt and what the user did with it, the precedence snapshot, refusals of datamate_manager and engine errors, each reported once per session when it changes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 58 +++++ .../workspace/datamate-manager-gate.ts | 2 + .../src/altimate/workspace/engine-offer.ts | 18 +- .../src/altimate/workspace/engine-overlay.ts | 7 +- .../altimate/workspace/engine-telemetry.ts | 218 ++++++++++++++++++ .../src/altimate/workspace/precedence.ts | 4 + .../src/plugin/tui/altimate/workspace.tsx | 7 + .../workspace/datamate-manager-gate.test.ts | 26 +++ .../workspace/engine-install-offer.test.ts | 48 ++++ .../altimate/workspace/engine-overlay.test.ts | 53 +++++ .../workspace/engine-telemetry.test.ts | 150 ++++++++++++ .../altimate/workspace/precedence.test.ts | 30 ++- 12 files changed, 616 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/engine-telemetry.ts create mode 100644 packages/opencode/test/altimate/workspace/engine-telemetry.test.ts diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index a9653802be..d041b7a013 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -639,6 +639,64 @@ export namespace Telemetry { kind: "skills" | "memory" reason: "auth" | "not_found" | "network" | "other" } + // Workspace engine (see workspace/engine-telemetry.ts) + | { + /** A session's engine attach settled at a new outcome. */ + type: "workspace_engine_attach" + timestamp: number + session_id: string + outcome: "attached" | "engine_missing" | "engine_too_old" | "connect_failed" | "disabled" | "unbound" + available?: number + declared?: number + missing?: number + extensions?: number + engine_version?: string + found_version?: string + error_class?: "link_unreadable" | "probe_failed" | "not_connected" | "other" + } + | { + type: "workspace_engine_install_prompt" + timestamp: number + session_id: string + action: "shown" | "accepted" | "installed" | "install_failed" | "copied" | "declined" + reason: "engine-missing" | "engine-too-old" + workspace_id?: number + declared?: number + found_version?: string + surface?: "headless" | "dialog" | "bus" | "toast" + node_detected?: boolean + node_major?: number + } + | { + /** A session's precedence snapshot settled at a new state. */ + type: "workspace_engine_precedence" + timestamp: number + session_id: string + enabled: boolean + reason?: string + shadowed_types: number + shadowed_connections: number + extensions: number + escape_hatch: boolean + workspace_id?: number + } + | { + /** A datamate tool call refused because the engine is off for that workspace. */ + type: "workspace_engine_redirect" + timestamp: number + session_id: string + tool: string + operation: string + outcome: "refused" + workspace_id?: number + } + | { + type: "workspace_engine_error" + timestamp: number + session_id: string + stage: "attach" | "install" | "precedence" + message: string + } // altimate_change end | { type: "mcp_server_census" diff --git a/packages/opencode/src/altimate/workspace/datamate-manager-gate.ts b/packages/opencode/src/altimate/workspace/datamate-manager-gate.ts index 1fdfcf28ef..c1c79e2b98 100644 --- a/packages/opencode/src/altimate/workspace/datamate-manager-gate.ts +++ b/packages/opencode/src/altimate/workspace/datamate-manager-gate.ts @@ -19,6 +19,7 @@ // key. Null for an unlinked project, an unreadable binding with no engine // running, workspaces disabled, `altimate serve`, and organisation-managed // config that owns the `datamate` key. +import { trackRedirect } from "./engine-telemetry" import { linkedWorkspaceLoaded, managedWorkspace, settledOutcome, settledWorkspace } from "./engine-overlay" import { installCommand } from "./engine-offer" import { log } from "./engine-seams" @@ -53,6 +54,7 @@ export async function hiddenToolIds(): Promise> { /** What `datamate_manager` answers in a linked project, whatever the operation. * Nothing is looked up or written before it. */ export function refusal(operation: string, workspace: { id: string; name: string }) { + trackRedirect("datamate", operation, Number(workspace.id)) // telemetry: the engine is off for this workspace return { title: `Datamate ${operation}: off in a project linked to a workspace`, metadata: { operation, managedBy: workspace.id }, diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts index 4e282e1471..1f7fe5350e 100644 --- a/packages/opencode/src/altimate/workspace/engine-offer.ts +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -7,6 +7,7 @@ // the detail here from disk and PATH rather than from the overlay's memory. // Offer, never install on the flow's own account — `installEngine` only ever // runs from an explicit "Install now". +import { trackInstallPrompt } from "./engine-telemetry" import { execFile, type ChildProcess } from "node:child_process" import launch from "cross-spawn" import { Process } from "@/util/process" @@ -320,11 +321,24 @@ export function toolsNeed(declared: number | undefined): string { /** Offer via the dialog surface when there is one; otherwise print (headless) * or toast (bus unavailable). Exactly one of these happens. */ export async function offerOrNotify(offer: EngineOffer, toast: Toast, sessionID: string): Promise { + // Telemetry: the offer was shown, on which surface, and whether Node was there to install with. + const shown = (surface: "headless" | "dialog" | "bus" | "toast") => + void nodeMajor() + .catch(() => null) + .then((major) => trackInstallPrompt("shown", offer, { surface, nodeMajor: major }, sessionID)) if (isHeadless()) { printLine(describeOfferLine(offer)) + shown("headless") + return + } + if (offerInstall(offer)) { + shown("dialog") + return + } + if (await publishOffer(sessionID)) { + shown("bus") return } - if (offerInstall(offer)) return - if (await publishOffer(sessionID)) return await notify(toast) + shown("toast") } diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 074f940ae8..c35505549d 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -44,6 +44,7 @@ import { } from "./engine-seams" import { declaredBounded, fingerprint, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" +import { trackAttach, trackEngineError } from "./engine-telemetry" import { ENGINE_BINARY, INSTALL_HELPS, @@ -293,6 +294,7 @@ export async function overlay( log.warn("workspace engine overlay failed; leaving the MCP config as loaded", { err: String(err) }) state.current = null state.failedAt = now() + trackEngineError("attach", String(err)) } } @@ -364,8 +366,9 @@ const declaredCache = new Map() /** Verdict signatures a headless process has already printed to stderr. */ const headlessPrinted = new Set() -function record(sessionID: string, outcome: Outcome): SessionRecord { +function record(sessionID: string, outcome: Outcome, extra: { engineVersion?: string | null } = {}): SessionRecord { const previous = sessions.get(sessionID) + trackAttach(outcome, previous?.outcome, extra, sessionID) // telemetry: only when the outcome changed sessions.delete(sessionID) const next: SessionRecord = { outcome, @@ -873,7 +876,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS ...(unfulfilled === undefined ? {} : { unfulfilled }), ...(declared?.extensions?.length ? { extensions: declared.extensions } : {}), } - const rec = record(sessionID, outcome) + const rec = record(sessionID, outcome, { engineVersion: overlayNow.version }) const snapshot: AttachSnapshot = { workspace: { id: workspace.id, name: workspace.name, key: workspace.key }, engineVersion: overlayNow.version, diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts new file mode 100644 index 0000000000..8ab3481eda --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -0,0 +1,218 @@ +// altimate_change - new file +// +// Telemetry for the workspace engine, per the shared event contract: one name per fact, fired +// on the outcome rather than the attempt, the workspace id on the row where the code has it +// (every row of a bound session also carries it from the telemetry context). Each builder is +// pure so it can be pinned by tests; each `track*` wrapper never throws into its caller. +import { Telemetry } from "@/altimate/telemetry" +import type { Outcome } from "./engine-types" +import type { EngineOffer } from "./engine-offer" +import type { Precedence } from "./precedence" + +export type AttachOutcome = "attached" | "engine_missing" | "engine_too_old" | "connect_failed" | "disabled" | "unbound" +export type InstallPromptAction = "shown" | "accepted" | "installed" | "install_failed" | "copied" | "declined" +export type InstallPromptSurface = "headless" | "dialog" | "bus" | "toast" +export type EngineStage = "attach" | "install" | "precedence" + +const sessionId = () => Telemetry.getContext().sessionId + +function outcomeName(kind: Outcome["kind"]): AttachOutcome { + return kind.replace(/-/g, "_") as AttachOutcome +} + +/** The class of a connect failure, from the fixed messages the overlay produces. */ +export function connectErrorClass(error: string): "link_unreadable" | "probe_failed" | "not_connected" | "other" { + const e = error.toLowerCase() + if (e.includes("link could not be read")) return "link_unreadable" + if (e.includes("could not be checked")) return "probe_failed" + if (e.includes("not connected") || e.includes("did not connect") || e.includes("handshake")) return "not_connected" + return "other" +} + +/** What makes two settled outcomes the same attach for telemetry: the kind and its counts. */ +export function attachSignature(outcome: Outcome): string { + switch (outcome.kind) { + case "attached": + return `attached:${outcome.available}:${outcome.declared ?? ""}:${outcome.missing?.length ?? ""}` + case "engine-missing": + return `engine-missing:${outcome.declared ?? ""}` + case "engine-too-old": + return `engine-too-old:${outcome.found ?? ""}` + case "connect-failed": + return `connect-failed:${connectErrorClass(outcome.error)}` + default: + return outcome.kind + } +} + +export function attachEvent( + outcome: Outcome, + extra: { engineVersion?: string | null } = {}, + sessionID?: string, +): Telemetry.Event { + const base = { type: "workspace_engine_attach" as const, timestamp: Date.now(), session_id: sessionID ?? sessionId() } + switch (outcome.kind) { + case "attached": + return { + ...base, + outcome: "attached", + available: outcome.available, + ...(outcome.declared !== undefined ? { declared: outcome.declared } : {}), + ...(outcome.missing !== undefined ? { missing: outcome.missing.length } : {}), + ...(outcome.extensions?.length ? { extensions: outcome.extensions.length } : {}), + ...(extra.engineVersion ? { engine_version: extra.engineVersion } : {}), + } + case "engine-missing": + return { + ...base, + outcome: "engine_missing", + ...(outcome.declared !== undefined ? { declared: outcome.declared } : {}), + } + case "engine-too-old": + return { ...base, outcome: "engine_too_old", ...(outcome.found ? { found_version: outcome.found } : {}) } + case "connect-failed": + return { ...base, outcome: "connect_failed", error_class: connectErrorClass(outcome.error) } + default: + return { ...base, outcome: outcomeName(outcome.kind) } + } +} + +/** Track the attach when the session's outcome changed (not on every turn boundary). */ +export function trackAttach( + outcome: Outcome, + previous: Outcome | undefined, + extra: { engineVersion?: string | null } = {}, + sessionID?: string, +): boolean { + try { + if (previous && attachSignature(previous) === attachSignature(outcome)) return false + Telemetry.track(attachEvent(outcome, extra, sessionID)) + return true + } catch { + return false + } +} + +export function installPromptEvent( + action: InstallPromptAction, + offer: Pick, + opts: { surface?: InstallPromptSurface; nodeMajor?: number | null } = {}, + sessionID?: string, +): Telemetry.Event { + const id = Number(offer.workspaceId) + return { + type: "workspace_engine_install_prompt", + timestamp: Date.now(), + session_id: sessionID ?? sessionId(), + action, + reason: offer.reason, + ...(Number.isInteger(id) && id > 0 ? { workspace_id: id } : {}), + ...(offer.declared !== undefined ? { declared: offer.declared } : {}), + ...(offer.found ? { found_version: offer.found } : {}), + ...(opts.surface ? { surface: opts.surface } : {}), + ...(opts.nodeMajor !== undefined ? { node_detected: opts.nodeMajor !== null } : {}), + ...(typeof opts.nodeMajor === "number" ? { node_major: opts.nodeMajor } : {}), + } +} + +export function trackInstallPrompt( + action: InstallPromptAction, + offer: Pick, + opts: { surface?: InstallPromptSurface; nodeMajor?: number | null } = {}, + sessionID?: string, +): void { + try { + Telemetry.track(installPromptEvent(action, offer, opts, sessionID)) + } catch { + // never block the offer + } +} + +export function precedenceSignature( + p: Pick, +): string { + const shadowed = [...p.shadowed.entries()] + .map(([type, caps]) => `${type}=${caps.size}`) + .sort() + .join(",") + return `${p.workspaceId ?? ""}|${p.enabled}|${p.disabledReason ?? ""}|${shadowed}|${p.extensions?.length ?? 0}` +} + +export function precedenceEvent( + p: Pick, + escapeHatch: boolean, + sessionID?: string, +): Telemetry.Event { + const id = Number(p.workspaceId) + let connections = 0 + for (const caps of p.shadowed.values()) connections += caps.size + return { + type: "workspace_engine_precedence", + timestamp: Date.now(), + session_id: sessionID ?? sessionId(), + enabled: p.enabled, + ...(p.disabledReason ? { reason: p.disabledReason } : {}), + shadowed_types: p.shadowed.size, + shadowed_connections: connections, + extensions: p.extensions?.length ?? 0, + escape_hatch: escapeHatch, + ...(Number.isInteger(id) && id > 0 ? { workspace_id: id } : {}), + } +} + +const precedenceSeen = new Map() +/** Sessions remembered for the dedupe; the oldest is forgotten past this, and reports again on its next refresh. */ +const MAX_SEEN_SESSIONS = 256 + +/** Track a session's precedence snapshot when it changed since the last refresh. */ +export function trackPrecedence( + sessionID: string, + p: Pick, + escapeHatch: boolean, +): boolean { + try { + const signature = precedenceSignature(p) + if (precedenceSeen.get(sessionID) === signature) return false + precedenceSeen.set(sessionID, signature) + if (precedenceSeen.size > MAX_SEEN_SESSIONS) precedenceSeen.delete(precedenceSeen.keys().next().value!) + Telemetry.track(precedenceEvent(p, escapeHatch, sessionID)) + return true + } catch { + return false + } +} + +export function resetEngineTelemetryForTests(): void { + precedenceSeen.clear() +} + +export function trackEngineError(stage: EngineStage, message: string): void { + try { + Telemetry.track({ + type: "workspace_engine_error", + timestamp: Date.now(), + session_id: sessionId(), + stage, + message: Telemetry.maskString(message).slice(0, 200), + }) + } catch { + // never surface + } +} + +/** A datamate tool call refused because the engine is off for that workspace. */ +export function trackRedirect(tool: string, operation: string, workspaceId: number): void { + try { + Telemetry.track({ + type: "workspace_engine_redirect", + timestamp: Date.now(), + session_id: sessionId(), + tool, + operation, + outcome: "refused", + ...(Number.isInteger(workspaceId) && workspaceId > 0 ? { workspace_id: workspaceId } : {}), + }) + } catch { + // never surface + } +} diff --git a/packages/opencode/src/altimate/workspace/precedence.ts b/packages/opencode/src/altimate/workspace/precedence.ts index ff070b578e..03b5ffac76 100644 --- a/packages/opencode/src/altimate/workspace/precedence.ts +++ b/packages/opencode/src/altimate/workspace/precedence.ts @@ -48,6 +48,7 @@ // that cache sooner, so it makes the next re-derivation see the change earlier — but the // per-turn re-derivation is the mechanism, not the notification. import { Config } from "@/config/config" +import { resetEngineTelemetryForTests, trackEngineError, trackPrecedence } from "./engine-telemetry" import { AppRuntime } from "@/effect/app-runtime" import { EventV2Bridge } from "@/event-v2-bridge" import { TuiEvent } from "@/server/tui-event" @@ -532,10 +533,12 @@ export async function refresh( result = await derive(sessionID, tools) } catch (err) { log.warn("precedence could not be derived; running locally with a notice", { sessionID, err: String(err) }) + trackEngineError("precedence", String(err)) result = EMPTY("derive-failed") } if (ruleset) result.ruleset = ruleset remember(sessionID, result) + trackPrecedence(sessionID, result, escapeHatchOn()) // telemetry: only when the snapshot changed // Mechanism 6 — say once, per session, what is now served where. Silence is the one // thing this design does not allow, but repeating it every turn would be noise. // A session that never had routing is told nothing — there is nothing to say. But a @@ -689,6 +692,7 @@ export function announcedSessionCount(): number { } export function resetForTests(): void { + resetEngineTelemetryForTests() bySession.clear() incarnations.clear() announced.clear() diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 09483deae8..b1181bf067 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -42,6 +42,7 @@ import { type Namesakes, } from "@/altimate/workspace/workspace-name" import { currentAttachSnapshot } from "@/altimate/workspace/attach-snapshot" +import { trackEngineError, trackInstallPrompt } from "@/altimate/workspace/engine-telemetry" import { accountScope, loadStatusView, @@ -1870,6 +1871,8 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { const performInstall = async () => { const result = await installEngine() if (!result.ok) { + trackInstallPrompt("install_failed", props.offer) + trackEngineError("install", result.error) installing = false if (!mounted) { // Dismissed mid-install: the failed-phase rows have nowhere to render, @@ -1885,6 +1888,7 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { setPhase("failed") return } + trackInstallPrompt("installed", props.offer) // Only clear a dialog we still own — by now the user may have opened // another, and clearing the stack would take theirs down instead. if (mounted) props.api.ui.dialog.clear() @@ -1947,6 +1951,7 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { if (option.value === "install") { if (installing) return installing = true + trackInstallPrompt("accepted", props.offer) void runInstall().catch((err) => { const message = err instanceof Error ? err.message : String(err) installing = false @@ -1966,11 +1971,13 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { return } if (option.value === "copy") { + trackInstallPrompt("copied", props.offer) copyCommand() props.api.ui.dialog.clear() return } if (option.value === "skip") { + trackInstallPrompt("declined", props.offer) recordEngineSkip(props.api, props.offer.workspaceId, props.latchScope, Date.now()) } props.api.ui.dialog.clear() diff --git a/packages/opencode/test/altimate/workspace/datamate-manager-gate.test.ts b/packages/opencode/test/altimate/workspace/datamate-manager-gate.test.ts index 4db9c869f9..0ba1fad302 100644 --- a/packages/opencode/test/altimate/workspace/datamate-manager-gate.test.ts +++ b/packages/opencode/test/altimate/workspace/datamate-manager-gate.test.ts @@ -38,8 +38,10 @@ import { DATAMATE_MANAGER_TOOL_ID, engineNotice, hiddenToolIds, + refusal, turnNotice, } from "../../../src/altimate/workspace/datamate-manager-gate" +import { Telemetry } from "../../../src/altimate/telemetry" const ORIGINAL_FLAG = process.env.ALTIMATE_DISABLE_WORKSPACE const ORIGINAL_SPEC = process.env.ALTIMATE_ENGINE_INSTALL_SPEC @@ -815,3 +817,27 @@ describe("older datamate- entries in a linked project", () => { }) } }) + +describe("telemetry — the refusal", () => { + test("a refused datamate_manager call is reported as a redirect naming the operation and workspace", () => { + const rows: Array> = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + rows.push(e as Record) + }) + try { + const out = refusal("status", { id: "42", name: "analytics" }) + expect(out.metadata).toEqual({ operation: "status", managedBy: "42" }) + expect(rows).toEqual([ + expect.objectContaining({ + type: "workspace_engine_redirect", + tool: "datamate", + operation: "status", + outcome: "refused", + workspace_id: 42, + }), + ]) + } finally { + spy.mockRestore() + } + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index d4b75772cd..ad759300c1 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -24,6 +24,7 @@ import { } from "../../../src/altimate/workspace/engine-overlay" import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS } from "../../../src/altimate/workspace/engine-offer" import type { CachedBinding } from "../../../src/altimate/workspace/state" +import { Telemetry } from "../../../src/altimate/telemetry" const DIR = "/tmp/analytics" const ORIGINAL_FLAG = process.env.ALTIMATE_DISABLE_WORKSPACE @@ -460,3 +461,50 @@ describe("install success is verified, not assumed", () => { expect(await installEngine()).toEqual({ ok: false, error: "boom EACCES denied" }) }) }) + +describe("telemetry — the install prompt", () => { + const rows: Array> = [] + let spy: ReturnType + beforeEach(() => { + rows.length = 0 + spy = spyOn(Telemetry, "track").mockImplementation((e) => { + rows.push(e as Record) + }) + }) + afterEach(() => spy.mockRestore()) + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + const prompts = () => rows.filter((r) => r.type === "workspace_engine_install_prompt") + + test("headless: shown once per session, naming the surface and the node that could install", async () => { + install({ headless: true }) + syncInternals.nodeMajor = async () => 22 + await beforeTurn("s1") + await beforeTurn("s1") + await settle() + expect(prompts()).toEqual([ + expect.objectContaining({ + session_id: "s1", + action: "shown", + reason: "engine-missing", + workspace_id: 42, + declared: 2, + surface: "headless", + node_detected: true, + node_major: 22, + }), + ]) + // The missing engine is the attach outcome behind the prompt, reported once as well. + expect(rows.filter((r) => r.type === "workspace_engine_attach")).toEqual([ + expect.objectContaining({ outcome: "engine_missing", declared: 2 }), + ]) + }) + + test("the bus surface reports itself, and no node on PATH is said rather than guessed", async () => { + install({}) + await beforeTurn("s1") + await settle() + expect(prompts()).toHaveLength(1) + expect(prompts()[0]).toEqual(expect.objectContaining({ surface: "bus", node_detected: false })) + expect("node_major" in prompts()[0]).toBe(false) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index ca845e28f5..9c92630251 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -29,6 +29,7 @@ import { } from "../../../src/altimate/workspace/engine-overlay" import type { ScopedBinding } from "../../../src/altimate/workspace/engine-seams" import type { AttachSnapshot } from "../../../src/altimate/workspace/attach-snapshot" +import { Telemetry } from "../../../src/altimate/telemetry" import { log } from "../../../src/altimate/workspace/engine-seams" import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" @@ -1262,3 +1263,55 @@ describe("beforeTurn — what a turn boundary does", () => { expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) }) }) + +describe("telemetry — the attach", () => { + const rows: Array> = [] + let spy: ReturnType + beforeEach(() => { + rows.length = 0 + spy = spyOn(Telemetry, "track").mockImplementation((e) => { + rows.push(e as Record) + }) + }) + afterEach(() => spy.mockRestore()) + const attaches = () => rows.filter((r) => r.type === "workspace_engine_attach") + + test("a settled attach is reported once with its inventory; a repeated boundary adds nothing; a change reports again", async () => { + const report = [{ key: "dbt_execute_sql", integrationId: "dbt", reason: "invalid-connection" }] + const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) + await beforeTurn("s1") + await beforeTurn("s1") + expect(attaches()).toEqual([ + expect.objectContaining({ + session_id: "s1", + outcome: "attached", + available: 2, + declared: 3, + missing: 1, + engine_version: "0.7.3", + }), + ]) + h.binding = null + await beforeTurn("s1") + expect(attaches().map((r) => r.outcome)).toEqual(["attached", "unbound"]) + }) + + test("an engine below the floor reports the version it found", async () => { + install({ version: "0.1.0" }) + await beforeTurn("s1") + expect(attaches()).toEqual([expect.objectContaining({ outcome: "engine_too_old", found_version: "0.1.0" })]) + }) + + test("a probe that throws during the overlay is an attach-stage error with a masked message", async () => { + const h = install({ mcp: { datamate: IDE_ENTRY } }) + syncInternals.versionOf = async () => { + throw new Error("probe exploded at /home/someone/bin/datamate") + } + await overlay(DIR, h.config) + const errors = rows.filter((r) => r.type === "workspace_engine_error") + expect(errors).toHaveLength(1) + expect(errors[0]).toEqual(expect.objectContaining({ stage: "attach" })) + expect(String(errors[0].message)).toContain("probe exploded") + expect(String(errors[0].message)).not.toContain("/home/someone") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts new file mode 100644 index 0000000000..8973abca85 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { Telemetry } from "../../../src/altimate/telemetry" +import { + attachEvent, + attachSignature, + connectErrorClass, + installPromptEvent, + precedenceEvent, + resetEngineTelemetryForTests, + trackAttach, + trackEngineError, + trackPrecedence, + trackRedirect, +} from "../../../src/altimate/workspace/engine-telemetry" + +describe("workspace engine telemetry builders", () => { + let events: any[] + let spy: ReturnType + beforeEach(() => { + events = [] + spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + resetEngineTelemetryForTests() + }) + afterEach(() => { + spy.mockRestore() + resetEngineTelemetryForTests() + }) + + test("attach outcomes map to the event with counts, versions and an error class", () => { + expect( + attachEvent({ kind: "attached", available: 3, declared: 4, missing: ["x"] }, { engineVersion: "1.2.3" }), + ).toEqual( + expect.objectContaining({ + type: "workspace_engine_attach", + outcome: "attached", + available: 3, + declared: 4, + missing: 1, + engine_version: "1.2.3", + }), + ) + expect(attachEvent({ kind: "engine-missing", declared: 2 })).toEqual( + expect.objectContaining({ outcome: "engine_missing", declared: 2 }), + ) + expect(attachEvent({ kind: "engine-too-old", found: "0.9.0" })).toEqual( + expect.objectContaining({ outcome: "engine_too_old", found_version: "0.9.0" }), + ) + expect(attachEvent({ kind: "connect-failed", error: "the workspace link could not be read" })).toEqual( + expect.objectContaining({ outcome: "connect_failed", error_class: "link_unreadable" }), + ) + expect(attachEvent({ kind: "unbound" })).toEqual(expect.objectContaining({ outcome: "unbound" })) + expect(connectErrorClass("the workspace engine could not be checked")).toBe("probe_failed") + expect((attachEvent({ kind: "unbound" }, {}, "ses_x") as any).session_id).toBe("ses_x") + expect(connectErrorClass("something else")).toBe("other") + }) + + test("an attach is tracked when the outcome changed, not on every turn boundary", () => { + const attached = { kind: "attached", available: 2 } as const + expect(trackAttach(attached, undefined)).toBe(true) + expect(trackAttach(attached, attached)).toBe(false) + expect(trackAttach({ kind: "attached", available: 3 }, attached)).toBe(true) + expect(trackAttach({ kind: "engine-missing" }, { kind: "attached", available: 3 })).toBe(true) + expect(events.map((e) => e.outcome)).toEqual(["attached", "attached", "engine_missing"]) + expect(attachSignature({ kind: "connect-failed", error: "a" })).toBe( + attachSignature({ kind: "connect-failed", error: "b" }), + ) + }) + + test("the install prompt event carries the offer, the surface and node detection", () => { + const offer = { reason: "engine-missing" as const, workspaceId: "17", declared: 2 } + expect(installPromptEvent("shown", offer, { surface: "headless", nodeMajor: 22 })).toEqual( + expect.objectContaining({ + type: "workspace_engine_install_prompt", + action: "shown", + reason: "engine-missing", + workspace_id: 17, + declared: 2, + surface: "headless", + node_detected: true, + node_major: 22, + }), + ) + const shown = installPromptEvent( + "shown", + { reason: "engine-too-old", workspaceId: "x", found: "0.9.0" }, + { nodeMajor: null }, + ) as any + expect(shown.node_detected).toBe(false) + expect("node_major" in shown).toBe(false) + expect("workspace_id" in shown).toBe(false) + expect(shown.found_version).toBe("0.9.0") + expect(installPromptEvent("declined", offer)).toEqual( + expect.objectContaining({ action: "declined", workspace_id: 17 }), + ) + }) + + test("precedence snapshots are tracked per session when they change", () => { + const shadowed = new Map([ + [ + "snowflake", + new Map([ + ["sql_execute", {} as any], + ["sql_explain", {} as any], + ]), + ], + ["dbt", new Map([["dbt_run", {} as any]])], + ]) + const on: any = { enabled: true, shadowed, extensions: [{ integration: "x", tools: [] }], workspaceId: "7" } + expect(precedenceEvent(on, false)).toEqual( + expect.objectContaining({ + type: "workspace_engine_precedence", + enabled: true, + shadowed_types: 2, + shadowed_connections: 3, + extensions: 1, + escape_hatch: false, + workspace_id: 7, + }), + ) + expect(trackPrecedence("s1", on, false)).toBe(true) + expect(trackPrecedence("s1", on, false)).toBe(false) // unchanged + expect(trackPrecedence("s2", on, false)).toBe(true) // another session + const off: any = { enabled: false, disabledReason: "escape-hatch" as const, shadowed: new Map(), workspaceId: "7" } + expect(trackPrecedence("s1", off, true)).toBe(true) + expect(events.at(-1)).toEqual( + expect.objectContaining({ enabled: false, reason: "escape-hatch", shadowed_types: 0, escape_hatch: true }), + ) + }) + + test("errors are masked and bounded; a refusal names the operation and workspace", () => { + trackEngineError("attach", "ENOENT: /home/someone/.altimate/engine " + "x".repeat(500)) + const err = events.at(-1) + expect(err.type).toBe("workspace_engine_error") + expect(err.stage).toBe("attach") + expect(err.message.length).toBeLessThanOrEqual(200) + expect(err.message).not.toContain("/home/someone") + trackRedirect("datamate", "status", 9) + expect(events.at(-1)).toEqual( + expect.objectContaining({ + type: "workspace_engine_redirect", + tool: "datamate", + operation: "status", + outcome: "refused", + workspace_id: 9, + }), + ) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/precedence.test.ts b/packages/opencode/test/altimate/workspace/precedence.test.ts index 680b916965..d88957ddcb 100644 --- a/packages/opencode/test/altimate/workspace/precedence.test.ts +++ b/packages/opencode/test/altimate/workspace/precedence.test.ts @@ -4,7 +4,7 @@ // bound workspace's engine has attached. The binding and the engine-attribution read // both go through `precedenceInternals`, so these exercise the decision logic without // booting an instance, reading config, or touching MCP state. -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { INTEGRATION_TYPE, MAX_TRACKED_SESSIONS, @@ -26,6 +26,7 @@ import { servedInventory, } from "../../../src/altimate/workspace/precedence" import * as Registry from "../../../src/altimate/native/connections/registry" +import { Telemetry } from "../../../src/altimate/telemetry" // altimate_change - shared with awareness.test.ts; see precedence-fixture.ts import { BIGQUERY_TOOLS, SNOWFLAKE_TOOLS, WAREHOUSE_CONFIGS, bindTo } from "./precedence-fixture" import { canonicalType } from "../../../src/altimate/native/connections/registry" @@ -1403,3 +1404,30 @@ describe("servedInventory — what the model will be told is routed", () => { }) }) // altimate_change end + +describe("telemetry — the precedence snapshot", () => { + test("a snapshot is reported when it changes for the session, not on every refresh", async () => { + const rows: Array> = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + rows.push(e as Record) + }) + try { + await refresh(SESSION, SNOWFLAKE_TOOLS) + await refresh(SESSION, SNOWFLAKE_TOOLS) + const snapshots = () => rows.filter((r) => r.type === "workspace_engine_precedence") + expect(snapshots()).toEqual([ + expect.objectContaining({ enabled: true, shadowed_types: 1, escape_hatch: false, workspace_id: 42 }), + ]) + expect(snapshots()[0].shadowed_connections).toBeGreaterThan(0) + await refresh(SESSION, {}) + expect(snapshots().at(-1)).toEqual( + expect.objectContaining({ enabled: false, reason: "nothing-materialised", shadowed_types: 0 }), + ) + await refresh("ses_other", SNOWFLAKE_TOOLS) + expect(snapshots()).toHaveLength(3) + expect(snapshots()[2].session_id).toBe("ses_other") + } finally { + spy.mockRestore() + } + }) +}) From 9557d1d56ab781c79812b4c918ffe3c4eb59670d Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 19:58:43 +0800 Subject: [PATCH 32/59] fix: count the local connections the engine shadows The precedence row summed served capabilities; it now reports the registry connections whose type the engine serves, the count the inventory line uses, and re-reports when that count moves. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../altimate/workspace/engine-telemetry.ts | 19 +++++++++---------- .../src/altimate/workspace/precedence.ts | 5 +++-- .../workspace/engine-telemetry.test.ts | 13 +++++++------ .../altimate/workspace/precedence.test.ts | 4 +++- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index 8ab3481eda..d83ec151d4 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -140,22 +140,19 @@ export function precedenceSignature( export function precedenceEvent( p: Pick, - escapeHatch: boolean, - sessionID?: string, + opts: { escapeHatch: boolean; connections: number; sessionID?: string }, ): Telemetry.Event { const id = Number(p.workspaceId) - let connections = 0 - for (const caps of p.shadowed.values()) connections += caps.size return { type: "workspace_engine_precedence", timestamp: Date.now(), - session_id: sessionID ?? sessionId(), + session_id: opts.sessionID ?? sessionId(), enabled: p.enabled, ...(p.disabledReason ? { reason: p.disabledReason } : {}), shadowed_types: p.shadowed.size, - shadowed_connections: connections, + shadowed_connections: opts.connections, extensions: p.extensions?.length ?? 0, - escape_hatch: escapeHatch, + escape_hatch: opts.escapeHatch, ...(Number.isInteger(id) && id > 0 ? { workspace_id: id } : {}), } } @@ -168,14 +165,16 @@ const MAX_SEEN_SESSIONS = 256 export function trackPrecedence( sessionID: string, p: Pick, - escapeHatch: boolean, + opts: { escapeHatch: boolean; connections: number }, ): boolean { try { - const signature = precedenceSignature(p) + // The local connections shadowed are part of what is reported, so a registry change that + // moves the count re-reports the same snapshot. + const signature = `${precedenceSignature(p)}|c=${opts.connections}` if (precedenceSeen.get(sessionID) === signature) return false precedenceSeen.set(sessionID, signature) if (precedenceSeen.size > MAX_SEEN_SESSIONS) precedenceSeen.delete(precedenceSeen.keys().next().value!) - Telemetry.track(precedenceEvent(p, escapeHatch, sessionID)) + Telemetry.track(precedenceEvent(p, { ...opts, sessionID })) return true } catch { return false diff --git a/packages/opencode/src/altimate/workspace/precedence.ts b/packages/opencode/src/altimate/workspace/precedence.ts index 03b5ffac76..38dd358360 100644 --- a/packages/opencode/src/altimate/workspace/precedence.ts +++ b/packages/opencode/src/altimate/workspace/precedence.ts @@ -538,7 +538,8 @@ export async function refresh( } if (ruleset) result.ruleset = ruleset remember(sessionID, result) - trackPrecedence(sessionID, result, escapeHatchOn()) // telemetry: only when the snapshot changed + // telemetry: only when the snapshot (or the local connections it shadows) changed + trackPrecedence(sessionID, result, { escapeHatch: escapeHatchOn(), connections: countShadowedConnections(result) }) // Mechanism 6 — say once, per session, what is now served where. Silence is the one // thing this design does not allow, but repeating it every turn would be noise. // A session that never had routing is told nothing — there is nothing to say. But a @@ -1145,7 +1146,7 @@ export function inventoryLine(precedence: Precedence): string { return `Workspace integrations — ${parts.join("; ")}. ${shadowedCount} local connection${shadowedCount === 1 ? "" : "s"} shadowed.` } -function countShadowedConnections(precedence: Precedence): number { +export function countShadowedConnections(precedence: Precedence): number { try { return Registry.list().warehouses.filter((w) => { const type = canonicalType(w.type) diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index 8973abca85..6ba3c58d1d 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -108,22 +108,23 @@ describe("workspace engine telemetry builders", () => { ["dbt", new Map([["dbt_run", {} as any]])], ]) const on: any = { enabled: true, shadowed, extensions: [{ integration: "x", tools: [] }], workspaceId: "7" } - expect(precedenceEvent(on, false)).toEqual( + expect(precedenceEvent(on, { escapeHatch: false, connections: 1 })).toEqual( expect.objectContaining({ type: "workspace_engine_precedence", enabled: true, shadowed_types: 2, - shadowed_connections: 3, + shadowed_connections: 1, extensions: 1, escape_hatch: false, workspace_id: 7, }), ) - expect(trackPrecedence("s1", on, false)).toBe(true) - expect(trackPrecedence("s1", on, false)).toBe(false) // unchanged - expect(trackPrecedence("s2", on, false)).toBe(true) // another session + expect(trackPrecedence("s1", on, { escapeHatch: false, connections: 1 })).toBe(true) + expect(trackPrecedence("s1", on, { escapeHatch: false, connections: 1 })).toBe(false) // unchanged + expect(trackPrecedence("s1", on, { escapeHatch: false, connections: 2 })).toBe(true) // a connection was added locally + expect(trackPrecedence("s2", on, { escapeHatch: false, connections: 2 })).toBe(true) // another session const off: any = { enabled: false, disabledReason: "escape-hatch" as const, shadowed: new Map(), workspaceId: "7" } - expect(trackPrecedence("s1", off, true)).toBe(true) + expect(trackPrecedence("s1", off, { escapeHatch: true, connections: 0 })).toBe(true) expect(events.at(-1)).toEqual( expect.objectContaining({ enabled: false, reason: "escape-hatch", shadowed_types: 0, escape_hatch: true }), ) diff --git a/packages/opencode/test/altimate/workspace/precedence.test.ts b/packages/opencode/test/altimate/workspace/precedence.test.ts index d88957ddcb..93060cc717 100644 --- a/packages/opencode/test/altimate/workspace/precedence.test.ts +++ b/packages/opencode/test/altimate/workspace/precedence.test.ts @@ -1418,7 +1418,9 @@ describe("telemetry — the precedence snapshot", () => { expect(snapshots()).toEqual([ expect.objectContaining({ enabled: true, shadowed_types: 1, escape_hatch: false, workspace_id: 42 }), ]) - expect(snapshots()[0].shadowed_connections).toBeGreaterThan(0) + // One local snowflake connection in the fixture registry is shadowed; the four served + // capabilities are not what is counted. + expect(snapshots()[0].shadowed_connections).toBe(1) await refresh(SESSION, {}) expect(snapshots().at(-1)).toEqual( expect.objectContaining({ enabled: false, reason: "nothing-materialised", shadowed_types: 0 }), From 49f3f8f13f2e0df287360880a6d35107a3fc01fc Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:08:41 +0800 Subject: [PATCH 33/59] fix: skip the retry sentinel in attach telemetry; report an install that rejects The in-session retry records a transient connect-failed before the real outcome; it no longer reports, and the next settled outcome is compared with the last reported one. A throw past installEngine's result now tracks the same failure rows as a reported failure. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/engine-overlay.ts | 16 +++++++++++++--- .../src/plugin/tui/altimate/workspace.tsx | 4 ++++ .../altimate/workspace/engine-overlay.test.ts | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index c35505549d..958f728e9e 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -360,18 +360,28 @@ type SessionRecord = { /** The workspace the directory was linked to when the boundary settled this * outcome. */ linked?: { id: string; name: string } | null + /** The last outcome reported to telemetry; a transient record carries the previous one. */ + tracked?: Outcome } const sessions = new Map() const declaredCache = new Map() /** Verdict signatures a headless process has already printed to stderr. */ const headlessPrinted = new Set() -function record(sessionID: string, outcome: Outcome, extra: { engineVersion?: string | null } = {}): SessionRecord { +function record( + sessionID: string, + outcome: Outcome, + extra: { engineVersion?: string | null; transient?: boolean } = {}, +): SessionRecord { const previous = sessions.get(sessionID) - trackAttach(outcome, previous?.outcome, extra, sessionID) // telemetry: only when the outcome changed + // telemetry: a settled outcome, when it changed. A transient record (the retry sentinel) reports + // nothing and keeps the last reported outcome for the next comparison. + const tracked = extra.transient ? previous?.tracked : outcome + if (!extra.transient) trackAttach(outcome, previous?.tracked, extra, sessionID) sessions.delete(sessionID) const next: SessionRecord = { outcome, + tracked, announced: previous?.announced, announcedAt: previous?.announcedAt, retried: previous?.retried, @@ -831,7 +841,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS let status = statusMap[DATAMATE_KEY] const session = sessions.get(sessionID) if (status?.status !== "connected" && !session?.retried) { - ;(session ?? record(sessionID, { kind: "connect-failed", error: "retrying" })).retried = true + ;(session ?? record(sessionID, { kind: "connect-failed", error: "retrying" }, { transient: true })).retried = true log.info("workspace engine not connected; retrying once for this session", { workspaceId: workspace.id, sessionID, diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index b1181bf067..f07034ea24 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1954,6 +1954,10 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { trackInstallPrompt("accepted", props.offer) void runInstall().catch((err) => { const message = err instanceof Error ? err.message : String(err) + // A rejection (a throw past installEngine's own result) is the same failure to the + // funnel as a reported one. + trackInstallPrompt("install_failed", props.offer) + trackEngineError("install", message) installing = false if (!mounted) { // Same as a reported failure after dismissal: the rows are gone, diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 9c92630251..48ed692a81 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -1296,6 +1296,21 @@ describe("telemetry — the attach", () => { expect(attaches().map((r) => r.outcome)).toEqual(["attached", "unbound"]) }) + test("the retry sentinel is not a settled attach: a retry that succeeds reports one attach, one that fails reports one failure", async () => { + const h = install({ status: "failed" }) + h.onAdd = () => { + h.status = "connected" + } + await beforeTurn("s1") + await beforeTurn("s1") + expect(attaches().map((r) => r.outcome)).toEqual(["attached"]) + rows.length = 0 + install({ status: "failed", statusError: "Connection closed" }) + await beforeTurn("s2") + await beforeTurn("s2") + expect(attaches().map((r) => r.outcome)).toEqual(["connect_failed"]) + }) + test("an engine below the floor reports the version it found", async () => { install({ version: "0.1.0" }) await beforeTurn("s1") From 1d7bfdcefc9a26ed85602369e1ac1fef06ccf9dd Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:16:33 +0800 Subject: [PATCH 34/59] fix: name the refreshed session on a precedence error row Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- packages/opencode/src/altimate/workspace/engine-telemetry.ts | 4 ++-- packages/opencode/src/altimate/workspace/precedence.ts | 2 +- .../opencode/test/altimate/workspace/engine-telemetry.test.ts | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index d83ec151d4..75cf56e221 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -185,12 +185,12 @@ export function resetEngineTelemetryForTests(): void { precedenceSeen.clear() } -export function trackEngineError(stage: EngineStage, message: string): void { +export function trackEngineError(stage: EngineStage, message: string, sessionID?: string): void { try { Telemetry.track({ type: "workspace_engine_error", timestamp: Date.now(), - session_id: sessionId(), + session_id: sessionID ?? sessionId(), stage, message: Telemetry.maskString(message).slice(0, 200), }) diff --git a/packages/opencode/src/altimate/workspace/precedence.ts b/packages/opencode/src/altimate/workspace/precedence.ts index 38dd358360..9d5bbea2b2 100644 --- a/packages/opencode/src/altimate/workspace/precedence.ts +++ b/packages/opencode/src/altimate/workspace/precedence.ts @@ -533,7 +533,7 @@ export async function refresh( result = await derive(sessionID, tools) } catch (err) { log.warn("precedence could not be derived; running locally with a notice", { sessionID, err: String(err) }) - trackEngineError("precedence", String(err)) + trackEngineError("precedence", String(err), sessionID) result = EMPTY("derive-failed") } if (ruleset) result.ruleset = ruleset diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index 6ba3c58d1d..03070a0f0e 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -137,6 +137,8 @@ describe("workspace engine telemetry builders", () => { expect(err.stage).toBe("attach") expect(err.message.length).toBeLessThanOrEqual(200) expect(err.message).not.toContain("/home/someone") + trackEngineError("precedence", "derive boom", "ses_p") + expect(events.at(-1)).toEqual(expect.objectContaining({ stage: "precedence", session_id: "ses_p" })) trackRedirect("datamate", "status", 9) expect(events.at(-1)).toEqual( expect.objectContaining({ From 6e4660416feb88b818d4cf150a64369db5bd4c3a Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:43:56 +0800 Subject: [PATCH 35/59] fix: address cubic's review of the engine events Rows name their session and workspace explicitly; shown is reported by the surface that rendered the offer; a retry add that throws settles as a failure; turn-hook attach errors are tracked; dedupe signatures cover the escape hatch and extensions; the registry is read only for an enabled snapshot. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/telemetry/index.ts | 2 ++ .../opencode/src/altimate/tools/datamate.ts | 29 +++++++++++-------- .../workspace/datamate-manager-gate.ts | 4 +-- .../src/altimate/workspace/engine-offer.ts | 16 ++++------ .../src/altimate/workspace/engine-overlay.ts | 20 +++++++++---- .../altimate/workspace/engine-telemetry.ts | 22 +++++++++----- .../src/altimate/workspace/precedence.ts | 6 +++- .../src/plugin/tui/altimate/workspace.tsx | 2 ++ .../workspace/datamate-manager-gate.test.ts | 3 +- .../workspace/engine-install-offer.test.ts | 11 +++++-- .../altimate/workspace/engine-overlay.test.ts | 10 +++++++ .../workspace/engine-telemetry.test.ts | 7 +++++ 12 files changed, 92 insertions(+), 40 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index d041b7a013..9deec3e4c2 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -646,6 +646,8 @@ export namespace Telemetry { timestamp: number session_id: string outcome: "attached" | "engine_missing" | "engine_too_old" | "connect_failed" | "disabled" | "unbound" + /** The workspace the boundary settled for; absent for unbound and disabled. */ + workspace_id?: number available?: number declared?: number missing?: number diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index e7f2da3827..007493d815 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -79,14 +79,14 @@ export const DatamateManagerTool = Tool.define("datamate_manager", { .optional() .describe("Server name to remove (for 'remove'). Use 'list-config' or 'status' to find names."), }), - async execute(args): Promise<{ title: string; metadata: Record; output: string }> { + async execute(args, ctx): Promise<{ title: string; metadata: Record; output: string }> { // altimate_change start — a linked project gets its integrations from the // workspace's own engine only. The tool is kept out of the model's catalog // there (datamate-manager-gate.ts); this covers callers that run it // directly. Every operation is refused before anything is looked up or // written, so the refusal does not depend on the API being reachable. const workspace = await disablingWorkspace() - if (workspace) return refusal(args.operation, workspace) + if (workspace) return refusal(args.operation, workspace, ctx.sessionID) // altimate_change end if (args.operation !== "status" && args.operation !== "list-config") { const configured = await AltimateApi.isConfigured() @@ -106,9 +106,9 @@ export const DatamateManagerTool = Tool.define("datamate_manager", { case "list-integrations": return handleListIntegrations() case "add": - return handleAdd(args) + return handleAdd(args, "add", ctx.sessionID) case "create": - return handleCreate(args) + return handleCreate(args, ctx.sessionID) case "edit": return handleEdit(args) case "delete": @@ -116,7 +116,7 @@ export const DatamateManagerTool = Tool.define("datamate_manager", { case "status": return handleStatus() case "remove": - return handleRemove(args) + return handleRemove(args, ctx.sessionID) case "list-config": return handleListConfig() } @@ -250,6 +250,8 @@ function mergeRefreshedEntry( async function handleAdd( args: { datamate_id?: string; name?: string; scope?: "project" | "global" }, operation = "add", + /** The calling session, for the refusal's telemetry row. */ + sessionID?: string, ) { if (!args.datamate_id) { return { @@ -315,7 +317,7 @@ async function handleAdd( // it, as a boundary starts the engine's, or it could replace the engine's. using lock = await holdDirectoryLock() const off = await disablingWorkspace() - if (off) return refusal(operation, off) + if (off) return refusal(operation, off, sessionID) // altimate_change end if (transport !== null) { @@ -452,7 +454,7 @@ async function handleAdd( else await removeMcpFromConfig(serverName, configPath) again.release() if (prior && prior.enabled !== false) await MCP.add(serverName, prior).catch(() => {}) - return refusal(operation, linked) + return refusal(operation, linked, sessionID) } } // altimate_change end @@ -492,14 +494,17 @@ async function handleAdd( } } -async function handleCreate(args: { +async function handleCreate( + args: { name?: string description?: string integration_ids?: string[] memory_enabled?: boolean privacy?: string scope?: "project" | "global" -}) { + }, + sessionID?: string, +) { if (!args.name) { return { title: "Datamate create: FAILED", @@ -514,7 +519,7 @@ async function handleCreate(args: { // altimate_change start — checked again before the datamate is created: a // boundary may have linked the project since the check in `execute`. const off = await disablingWorkspace() - if (off) return refusal("create", off) + if (off) return refusal("create", off, sessionID) // The account the datamate is created in, kept for deleting it again: ids // are per tenant, and the credentials can change while the request runs. const creds = await AltimateApi.getCredentials() @@ -691,7 +696,7 @@ async function handleStatus() { } } -async function handleRemove(args: { server_name?: string; scope?: "project" | "global" }) { +async function handleRemove(args: { server_name?: string; scope?: "project" | "global" }, sessionID?: string) { if (!args.server_name) { return { title: "Datamate remove: FAILED", @@ -706,7 +711,7 @@ async function handleRemove(args: { server_name?: string; scope?: "project" | "g // check at the top of `execute` must not have its engine's key removed. using _lock = await holdDirectoryLock() const off = await disablingWorkspace() - if (off) return refusal("remove", off) + if (off) return refusal("remove", off, sessionID) // altimate_change end // Fully remove from runtime state (disconnect + purge from MCP list) // altimate_change start — MCP.remove (was disconnect): delete the status entry + publish diff --git a/packages/opencode/src/altimate/workspace/datamate-manager-gate.ts b/packages/opencode/src/altimate/workspace/datamate-manager-gate.ts index c1c79e2b98..2c94bc2f7b 100644 --- a/packages/opencode/src/altimate/workspace/datamate-manager-gate.ts +++ b/packages/opencode/src/altimate/workspace/datamate-manager-gate.ts @@ -53,8 +53,8 @@ export async function hiddenToolIds(): Promise> { /** What `datamate_manager` answers in a linked project, whatever the operation. * Nothing is looked up or written before it. */ -export function refusal(operation: string, workspace: { id: string; name: string }) { - trackRedirect("datamate", operation, Number(workspace.id)) // telemetry: the engine is off for this workspace +export function refusal(operation: string, workspace: { id: string; name: string }, sessionID?: string) { + trackRedirect("datamate", operation, Number(workspace.id), sessionID) // telemetry: the engine is off for this workspace return { title: `Datamate ${operation}: off in a project linked to a workspace`, metadata: { operation, managedBy: workspace.id }, diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts index 1f7fe5350e..93cf85eb8e 100644 --- a/packages/opencode/src/altimate/workspace/engine-offer.ts +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -331,14 +331,10 @@ export async function offerOrNotify(offer: EngineOffer, toast: Toast, sessionID: shown("headless") return } - if (offerInstall(offer)) { - shown("dialog") - return - } - if (await publishOffer(sessionID)) { - shown("bus") - return - } - await notify(toast) - shown("toast") + // A dialog, whether taken here or over the bus, reports `shown` itself when it renders: the TUI + // may hold the offer back (its 7-day latch), so dispatch is not display. The toast reports here + // only when it was shown. + if (offerInstall(offer)) return + if (await publishOffer(sessionID)) return + if (await notify(toast)) shown("toast") } diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 958f728e9e..3107277ed8 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -374,10 +374,13 @@ function record( extra: { engineVersion?: string | null; transient?: boolean } = {}, ): SessionRecord { const previous = sessions.get(sessionID) - // telemetry: a settled outcome, when it changed. A transient record (the retry sentinel) reports - // nothing and keeps the last reported outcome for the next comparison. + // telemetry: a settled outcome, when it changed, named for the directory's workspace. A transient + // record (the retry sentinel) reports nothing and keeps the last reported outcome for the next + // comparison. const tracked = extra.transient ? previous?.tracked : outcome - if (!extra.transient) trackAttach(outcome, previous?.tracked, extra, sessionID) + if (!extra.transient) { + trackAttach(outcome, previous?.tracked, { ...extra, workspaceId: Number(linkedWorkspace()?.id) }, sessionID) + } sessions.delete(sessionID) const next: SessionRecord = { outcome, @@ -503,6 +506,7 @@ export async function atTurnStart(sessionID: string, body: () => Promise): await reconcile(sessionID, directory, state) } catch (err) { log.warn("workspace engine turn hook failed", { sessionID, err: String(err) }) + trackEngineError("attach", String(err), sessionID) } // Kept with the outcome, under the lock: the directory's link moves with any // later config load, and a turn's notice must name the workspace its own @@ -847,8 +851,14 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS sessionID, status: status?.status, }) - await mcp().add(DATAMATE_KEY, overlayNow.entry) - status = (await mcp().status())[DATAMATE_KEY] + try { + await mcp().add(DATAMATE_KEY, overlayNow.entry) + status = (await mcp().status())[DATAMATE_KEY] + } catch (err) { + // A retry that throws settles as a failure for this session, rather than leaving the turn + // hook to swallow it with no outcome recorded. + status = { status: "failed", error: String(err) } + } } if (status?.status !== "connected") { const outcome: Outcome = { diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index 75cf56e221..cf72fee3cf 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -33,7 +33,7 @@ export function connectErrorClass(error: string): "link_unreadable" | "probe_fai export function attachSignature(outcome: Outcome): string { switch (outcome.kind) { case "attached": - return `attached:${outcome.available}:${outcome.declared ?? ""}:${outcome.missing?.length ?? ""}` + return `attached:${outcome.available}:${outcome.declared ?? ""}:${outcome.missing?.length ?? ""}:${outcome.extensions?.length ?? 0}` case "engine-missing": return `engine-missing:${outcome.declared ?? ""}` case "engine-too-old": @@ -47,10 +47,18 @@ export function attachSignature(outcome: Outcome): string { export function attachEvent( outcome: Outcome, - extra: { engineVersion?: string | null } = {}, + extra: { engineVersion?: string | null; workspaceId?: number } = {}, sessionID?: string, ): Telemetry.Event { - const base = { type: "workspace_engine_attach" as const, timestamp: Date.now(), session_id: sessionID ?? sessionId() } + // The boundary's own workspace, named on the row rather than left to the process context; an + // unbound or disabled outcome has none. + const settledFor = outcome.kind !== "unbound" && outcome.kind !== "disabled" ? extra.workspaceId : undefined + const base = { + type: "workspace_engine_attach" as const, + timestamp: Date.now(), + session_id: sessionID ?? sessionId(), + ...(settledFor !== undefined && Number.isInteger(settledFor) && settledFor > 0 ? { workspace_id: settledFor } : {}), + } switch (outcome.kind) { case "attached": return { @@ -81,7 +89,7 @@ export function attachEvent( export function trackAttach( outcome: Outcome, previous: Outcome | undefined, - extra: { engineVersion?: string | null } = {}, + extra: { engineVersion?: string | null; workspaceId?: number } = {}, sessionID?: string, ): boolean { try { @@ -170,7 +178,7 @@ export function trackPrecedence( try { // The local connections shadowed are part of what is reported, so a registry change that // moves the count re-reports the same snapshot. - const signature = `${precedenceSignature(p)}|c=${opts.connections}` + const signature = `${precedenceSignature(p)}|c=${opts.connections}|e=${opts.escapeHatch}` if (precedenceSeen.get(sessionID) === signature) return false precedenceSeen.set(sessionID, signature) if (precedenceSeen.size > MAX_SEEN_SESSIONS) precedenceSeen.delete(precedenceSeen.keys().next().value!) @@ -200,12 +208,12 @@ export function trackEngineError(stage: EngineStage, message: string, sessionID? } /** A datamate tool call refused because the engine is off for that workspace. */ -export function trackRedirect(tool: string, operation: string, workspaceId: number): void { +export function trackRedirect(tool: string, operation: string, workspaceId: number, sessionID?: string): void { try { Telemetry.track({ type: "workspace_engine_redirect", timestamp: Date.now(), - session_id: sessionId(), + session_id: sessionID ?? sessionId(), tool, operation, outcome: "refused", diff --git a/packages/opencode/src/altimate/workspace/precedence.ts b/packages/opencode/src/altimate/workspace/precedence.ts index 9d5bbea2b2..a7e1aae12f 100644 --- a/packages/opencode/src/altimate/workspace/precedence.ts +++ b/packages/opencode/src/altimate/workspace/precedence.ts @@ -539,7 +539,11 @@ export async function refresh( if (ruleset) result.ruleset = ruleset remember(sessionID, result) // telemetry: only when the snapshot (or the local connections it shadows) changed - trackPrecedence(sessionID, result, { escapeHatch: escapeHatchOn(), connections: countShadowedConnections(result) }) + trackPrecedence(sessionID, result, { + escapeHatch: escapeHatchOn(), + // The registry is read only when something can be shadowed: a disabled snapshot shadows nothing. + connections: result.enabled ? countShadowedConnections(result) : 0, + }) // Mechanism 6 — say once, per session, what is now served where. Silence is the one // thing this design does not allow, but repeating it every turn would be noise. // A session that never had routing is told nothing — there is nothing to say. But a diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index f07034ea24..4d388dbd12 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1785,6 +1785,8 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { // opened in our place. So completion reports through a toast when we are // gone, and only touches the dialog while we still own it. let mounted = true + // The offer was shown: reported from the surface that rendered it, not from the dispatch. + onMount(() => trackInstallPrompt("shown", props.offer, { surface: "dialog", nodeMajor: props.nodeMajor })) onCleanup(() => { mounted = false // Release the single-offer latch however this dialog goes away — chosen, diff --git a/packages/opencode/test/altimate/workspace/datamate-manager-gate.test.ts b/packages/opencode/test/altimate/workspace/datamate-manager-gate.test.ts index 0ba1fad302..301b511517 100644 --- a/packages/opencode/test/altimate/workspace/datamate-manager-gate.test.ts +++ b/packages/opencode/test/altimate/workspace/datamate-manager-gate.test.ts @@ -825,7 +825,7 @@ describe("telemetry — the refusal", () => { rows.push(e as Record) }) try { - const out = refusal("status", { id: "42", name: "analytics" }) + const out = refusal("status", { id: "42", name: "analytics" }, "ses_r") expect(out.metadata).toEqual({ operation: "status", managedBy: "42" }) expect(rows).toEqual([ expect.objectContaining({ @@ -834,6 +834,7 @@ describe("telemetry — the refusal", () => { operation: "status", outcome: "refused", workspace_id: 42, + session_id: "ses_r", }), ]) } finally { diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index ad759300c1..54f2338e84 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -499,12 +499,19 @@ describe("telemetry — the install prompt", () => { ]) }) - test("the bus surface reports itself, and no node on PATH is said rather than guessed", async () => { + test("a dialog reports shown itself when it renders, so dispatch over the bus reports nothing here", async () => { install({}) await beforeTurn("s1") await settle() + expect(prompts()).toHaveLength(0) + }) + + test("the toast reports shown once shown, and no node on PATH is said rather than guessed", async () => { + install({ bus: false }) + await beforeTurn("s1") + await settle() expect(prompts()).toHaveLength(1) - expect(prompts()[0]).toEqual(expect.objectContaining({ surface: "bus", node_detected: false })) + expect(prompts()[0]).toEqual(expect.objectContaining({ surface: "toast", node_detected: false })) expect("node_major" in prompts()[0]).toBe(false) }) }) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 48ed692a81..e9a096d636 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -1284,6 +1284,7 @@ describe("telemetry — the attach", () => { expect(attaches()).toEqual([ expect.objectContaining({ session_id: "s1", + workspace_id: 42, outcome: "attached", available: 2, declared: 3, @@ -1309,6 +1310,15 @@ describe("telemetry — the attach", () => { await beforeTurn("s2") await beforeTurn("s2") expect(attaches().map((r) => r.outcome)).toEqual(["connect_failed"]) + // A retry whose add throws settles as a failure too, rather than vanishing into the turn hook. + rows.length = 0 + const g = install({ status: "failed" }) + g.onAdd = () => { + throw new Error("spawn failed") + } + await beforeTurn("s3") + expect(settledOutcome("s3")?.kind).toBe("connect-failed") + expect(attaches().map((r) => r.outcome)).toEqual(["connect_failed"]) }) test("an engine below the floor reports the version it found", async () => { diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index 03070a0f0e..bf325ed09b 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -66,6 +66,11 @@ describe("workspace engine telemetry builders", () => { expect(attachSignature({ kind: "connect-failed", error: "a" })).toBe( attachSignature({ kind: "connect-failed", error: "b" }), ) + expect(attachSignature({ kind: "attached", available: 2, extensions: [{ integration: "x" } as any] })).not.toBe( + attachSignature({ kind: "attached", available: 2 }), + ) + expect((attachEvent({ kind: "attached", available: 1 }, { workspaceId: 42 }) as any).workspace_id).toBe(42) + expect("workspace_id" in (attachEvent({ kind: "unbound" }, { workspaceId: 42 }) as any)).toBe(false) }) test("the install prompt event carries the offer, the surface and node detection", () => { @@ -128,6 +133,8 @@ describe("workspace engine telemetry builders", () => { expect(events.at(-1)).toEqual( expect.objectContaining({ enabled: false, reason: "escape-hatch", shadowed_types: 0, escape_hatch: true }), ) + expect(trackPrecedence("s1", off, { escapeHatch: false, connections: 0 })).toBe(true) // the hatch toggled + expect((events.at(-1) as any).escape_hatch).toBe(false) }) test("errors are masked and bounded; a refusal names the operation and workspace", () => { From 07a1c07116d0e22d16dbe1aebd1cdbb482e6036a Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 20:55:14 +0800 Subject: [PATCH 36/59] fix: report an attach again after a relink; keep the session on a create's add Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/tools/datamate.ts | 13 +++++++------ .../src/altimate/workspace/engine-overlay.ts | 18 +++++++++--------- .../src/altimate/workspace/engine-telemetry.ts | 11 +++++++++-- .../altimate/workspace/engine-overlay.test.ts | 12 ++++++++++++ .../workspace/engine-telemetry.test.ts | 12 ++++++++---- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 007493d815..b2ea4c4d8d 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -496,12 +496,12 @@ async function handleAdd( async function handleCreate( args: { - name?: string - description?: string - integration_ids?: string[] - memory_enabled?: boolean - privacy?: string - scope?: "project" | "global" + name?: string + description?: string + integration_ids?: string[] + memory_enabled?: boolean + privacy?: string + scope?: "project" | "global" }, sessionID?: string, ) { @@ -540,6 +540,7 @@ async function handleCreate( const added = await handleAdd( { datamate_id: created.id, name: `datamate-${slugify(args.name)}`, scope: args.scope }, "create", + sessionID, ) if (!("managedBy" in added.metadata)) return added const rolledBack = await AltimateApi.deleteDatamate(created.id, creds).then( diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 3107277ed8..db0995a4e6 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -44,7 +44,7 @@ import { } from "./engine-seams" import { declaredBounded, fingerprint, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" -import { trackAttach, trackEngineError } from "./engine-telemetry" +import { attachKey, trackAttach, trackEngineError } from "./engine-telemetry" import { ENGINE_BINARY, INSTALL_HELPS, @@ -360,8 +360,9 @@ type SessionRecord = { /** The workspace the directory was linked to when the boundary settled this * outcome. */ linked?: { id: string; name: string } | null - /** The last outcome reported to telemetry; a transient record carries the previous one. */ - tracked?: Outcome + /** The key of the last attach reported to telemetry (workspace and outcome); a transient record + * carries the previous one. */ + tracked?: string } const sessions = new Map() const declaredCache = new Map() @@ -374,13 +375,12 @@ function record( extra: { engineVersion?: string | null; transient?: boolean } = {}, ): SessionRecord { const previous = sessions.get(sessionID) - // telemetry: a settled outcome, when it changed, named for the directory's workspace. A transient - // record (the retry sentinel) reports nothing and keeps the last reported outcome for the next + // telemetry: a settled outcome, when it or the workspace it settled for changed. A transient + // record (the retry sentinel) reports nothing and keeps the last reported key for the next // comparison. - const tracked = extra.transient ? previous?.tracked : outcome - if (!extra.transient) { - trackAttach(outcome, previous?.tracked, { ...extra, workspaceId: Number(linkedWorkspace()?.id) }, sessionID) - } + const workspaceId = Number(linkedWorkspace()?.id) || undefined + const tracked = extra.transient ? previous?.tracked : attachKey(outcome, workspaceId) + if (!extra.transient) trackAttach(outcome, previous?.tracked, { ...extra, workspaceId }, sessionID) sessions.delete(sessionID) const next: SessionRecord = { outcome, diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index cf72fee3cf..c55ac41473 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -86,14 +86,21 @@ export function attachEvent( } /** Track the attach when the session's outcome changed (not on every turn boundary). */ +/** What an attach row is deduped on: the directory's workspace and the outcome's signature, so the + * same-shaped attach to another workspace after a relink is a change. */ +export function attachKey(outcome: Outcome, workspaceId?: number): string { + return `${workspaceId ?? ""}|${attachSignature(outcome)}` +} + +/** Track a session's attach when its key differs from the last one reported for the session. */ export function trackAttach( outcome: Outcome, - previous: Outcome | undefined, + previousKey: string | undefined, extra: { engineVersion?: string | null; workspaceId?: number } = {}, sessionID?: string, ): boolean { try { - if (previous && attachSignature(previous) === attachSignature(outcome)) return false + if (previousKey !== undefined && previousKey === attachKey(outcome, extra.workspaceId)) return false Telemetry.track(attachEvent(outcome, extra, sessionID)) return true } catch { diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index e9a096d636..e3486387a6 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -1321,6 +1321,18 @@ describe("telemetry — the attach", () => { expect(attaches().map((r) => r.outcome)).toEqual(["connect_failed"]) }) + test("a relink to another workspace reports the attach again, named for the new workspace", async () => { + const h = install({}) + await beforeTurn("s1") + h.binding = bound(7, "growth") + await beforeTurn("s1") + await beforeTurn("s1") + expect(attaches().map((r) => [r.outcome, r.workspace_id])).toEqual([ + ["attached", 42], + ["attached", 7], + ]) + }) + test("an engine below the floor reports the version it found", async () => { install({ version: "0.1.0" }) await beforeTurn("s1") diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index bf325ed09b..232e365aca 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { Telemetry } from "../../../src/altimate/telemetry" import { attachEvent, + attachKey, attachSignature, connectErrorClass, installPromptEvent, @@ -59,10 +60,13 @@ describe("workspace engine telemetry builders", () => { test("an attach is tracked when the outcome changed, not on every turn boundary", () => { const attached = { kind: "attached", available: 2 } as const expect(trackAttach(attached, undefined)).toBe(true) - expect(trackAttach(attached, attached)).toBe(false) - expect(trackAttach({ kind: "attached", available: 3 }, attached)).toBe(true) - expect(trackAttach({ kind: "engine-missing" }, { kind: "attached", available: 3 })).toBe(true) - expect(events.map((e) => e.outcome)).toEqual(["attached", "attached", "engine_missing"]) + expect(trackAttach(attached, attachKey(attached))).toBe(false) + expect(trackAttach({ kind: "attached", available: 3 }, attachKey(attached))).toBe(true) + expect(trackAttach({ kind: "engine-missing" }, attachKey({ kind: "attached", available: 3 }))).toBe(true) + // The same-shaped attach to another workspace (a relink) is a change. + expect(trackAttach(attached, attachKey(attached, 42), { workspaceId: 42 })).toBe(false) + expect(trackAttach(attached, attachKey(attached, 42), { workspaceId: 7 })).toBe(true) + expect(events.map((e) => e.outcome)).toEqual(["attached", "attached", "engine_missing", "attached"]) expect(attachSignature({ kind: "connect-failed", error: "a" })).toBe( attachSignature({ kind: "connect-failed", error: "b" }), ) From 7f12f7418a2eb93159528d393dfe4f9bbd5c2bdc Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:03:49 +0800 Subject: [PATCH 37/59] fix: dedupe attach rows on the account-scoped workspace identity Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/engine-overlay.ts | 12 ++++++++++-- .../src/altimate/workspace/engine-telemetry.ts | 15 +++++++++------ .../altimate/workspace/engine-overlay.test.ts | 8 ++++++++ .../altimate/workspace/engine-telemetry.test.ts | 16 ++++++++++++---- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index db0995a4e6..dd065ef388 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -337,6 +337,12 @@ export function linkedWorkspace(directory: string | null = currentDirectory()): return linked ? { id: linked.id, name: linked.name } : null } +/** The linked workspace's account-scoped identity (credential scope plus id), for telemetry's dedupe. */ +function linkedWorkspaceKey(directory: string | null = currentDirectory()): string | undefined { + if (!directory) return undefined + return directories.get(directory)?.linked?.key +} + /** `linkedWorkspace` once the overlay has run for this instance. */ export async function linkedWorkspaceLoaded( directory: string | null = currentDirectory(), @@ -379,8 +385,10 @@ function record( // record (the retry sentinel) reports nothing and keeps the last reported key for the next // comparison. const workspaceId = Number(linkedWorkspace()?.id) || undefined - const tracked = extra.transient ? previous?.tracked : attachKey(outcome, workspaceId) - if (!extra.transient) trackAttach(outcome, previous?.tracked, { ...extra, workspaceId }, sessionID) + const tracked = extra.transient ? previous?.tracked : attachKey(outcome, linkedWorkspaceKey()) + if (!extra.transient) { + trackAttach(outcome, previous?.tracked, { ...extra, workspaceId, workspaceIdentity: linkedWorkspaceKey() }, sessionID) + } sessions.delete(sessionID) const next: SessionRecord = { outcome, diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index c55ac41473..d1d19e75a4 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -86,21 +86,24 @@ export function attachEvent( } /** Track the attach when the session's outcome changed (not on every turn boundary). */ -/** What an attach row is deduped on: the directory's workspace and the outcome's signature, so the - * same-shaped attach to another workspace after a relink is a change. */ -export function attachKey(outcome: Outcome, workspaceId?: number): string { - return `${workspaceId ?? ""}|${attachSignature(outcome)}` +/** What an attach row is deduped on: the directory's workspace identity (the credential scope plus the + * tenant-local id, as the overlay keys it, so the same id under another account is another workspace) + * and the outcome's signature; the same-shaped attach to another workspace after a relink is a change. */ +export function attachKey(outcome: Outcome, workspaceIdentity?: string): string { + return `${workspaceIdentity ?? ""}|${attachSignature(outcome)}` } /** Track a session's attach when its key differs from the last one reported for the session. */ export function trackAttach( outcome: Outcome, previousKey: string | undefined, - extra: { engineVersion?: string | null; workspaceId?: number } = {}, + extra: { engineVersion?: string | null; workspaceId?: number; workspaceIdentity?: string } = {}, sessionID?: string, ): boolean { try { - if (previousKey !== undefined && previousKey === attachKey(outcome, extra.workspaceId)) return false + const identity = + extra.workspaceIdentity ?? (extra.workspaceId !== undefined ? String(extra.workspaceId) : undefined) + if (previousKey !== undefined && previousKey === attachKey(outcome, identity)) return false Telemetry.track(attachEvent(outcome, extra, sessionID)) return true } catch { diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index e3486387a6..601a853366 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -1331,6 +1331,14 @@ describe("telemetry — the attach", () => { ["attached", 42], ["attached", 7], ]) + // The same id under another account is another workspace: reported again. + h.binding = bound(7, "growth", "other|https://api.other.example") + await beforeTurn("s1") + expect(attaches().map((r) => [r.outcome, r.workspace_id])).toEqual([ + ["attached", 42], + ["attached", 7], + ["attached", 7], + ]) }) test("an engine below the floor reports the version it found", async () => { diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index 232e365aca..249f03a303 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -63,10 +63,18 @@ describe("workspace engine telemetry builders", () => { expect(trackAttach(attached, attachKey(attached))).toBe(false) expect(trackAttach({ kind: "attached", available: 3 }, attachKey(attached))).toBe(true) expect(trackAttach({ kind: "engine-missing" }, attachKey({ kind: "attached", available: 3 }))).toBe(true) - // The same-shaped attach to another workspace (a relink) is a change. - expect(trackAttach(attached, attachKey(attached, 42), { workspaceId: 42 })).toBe(false) - expect(trackAttach(attached, attachKey(attached, 42), { workspaceId: 7 })).toBe(true) - expect(events.map((e) => e.outcome)).toEqual(["attached", "attached", "engine_missing", "attached"]) + // The same-shaped attach to another workspace (a relink) is a change, and the identity is the + // account-scoped key: the same id under another account is another workspace. + expect( + trackAttach(attached, attachKey(attached, "acme|42"), { workspaceId: 42, workspaceIdentity: "acme|42" }), + ).toBe(false) + expect(trackAttach(attached, attachKey(attached, "acme|42"), { workspaceId: 7, workspaceIdentity: "acme|7" })).toBe( + true, + ) + expect( + trackAttach(attached, attachKey(attached, "acme|42"), { workspaceId: 42, workspaceIdentity: "other|42" }), + ).toBe(true) + expect(events.map((e) => e.outcome)).toEqual(["attached", "attached", "engine_missing", "attached", "attached"]) expect(attachSignature({ kind: "connect-failed", error: "a" })).toBe( attachSignature({ kind: "connect-failed", error: "b" }), ) From 8393f46d5e7d837c469ac1f0e9e2d9d8ca91bb9e Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:16:43 +0800 Subject: [PATCH 38/59] fix: dedupe precedence rows on the account-scoped workspace identity Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/workspace/engine-overlay.ts | 2 +- .../opencode/src/altimate/workspace/engine-telemetry.ts | 9 +++++---- packages/opencode/src/altimate/workspace/precedence.ts | 2 ++ .../test/altimate/workspace/engine-telemetry.test.ts | 4 ++++ 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index dd065ef388..3d535c41ef 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -338,7 +338,7 @@ export function linkedWorkspace(directory: string | null = currentDirectory()): } /** The linked workspace's account-scoped identity (credential scope plus id), for telemetry's dedupe. */ -function linkedWorkspaceKey(directory: string | null = currentDirectory()): string | undefined { +export function linkedWorkspaceKey(directory: string | null = currentDirectory()): string | undefined { if (!directory) return undefined return directories.get(directory)?.linked?.key } diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index d1d19e75a4..c49629397f 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -183,12 +183,13 @@ const MAX_SEEN_SESSIONS = 256 export function trackPrecedence( sessionID: string, p: Pick, - opts: { escapeHatch: boolean; connections: number }, + opts: { escapeHatch: boolean; connections: number; identity?: string }, ): boolean { try { - // The local connections shadowed are part of what is reported, so a registry change that - // moves the count re-reports the same snapshot. - const signature = `${precedenceSignature(p)}|c=${opts.connections}|e=${opts.escapeHatch}` + // The local connections shadowed and the escape hatch are part of what is reported, so a change + // in either re-reports the same snapshot; the account-scoped identity (credential scope plus + // id) tells the same id under another account apart. + const signature = `${precedenceSignature(p)}|c=${opts.connections}|e=${opts.escapeHatch}|w=${opts.identity ?? ""}` if (precedenceSeen.get(sessionID) === signature) return false precedenceSeen.set(sessionID, signature) if (precedenceSeen.size > MAX_SEEN_SESSIONS) precedenceSeen.delete(precedenceSeen.keys().next().value!) diff --git a/packages/opencode/src/altimate/workspace/precedence.ts b/packages/opencode/src/altimate/workspace/precedence.ts index a7e1aae12f..202bb3b641 100644 --- a/packages/opencode/src/altimate/workspace/precedence.ts +++ b/packages/opencode/src/altimate/workspace/precedence.ts @@ -66,6 +66,7 @@ import { settledOutcome, type EntryLike, type Outcome, + linkedWorkspaceKey, } from "./engine-overlay" import { readLocalBindingScopedStrict, resolvePinnedBindingForRouting } from "./state" import { liveBridge } from "./engine-probes" @@ -543,6 +544,7 @@ export async function refresh( escapeHatch: escapeHatchOn(), // The registry is read only when something can be shadowed: a disabled snapshot shadows nothing. connections: result.enabled ? countShadowedConnections(result) : 0, + identity: linkedWorkspaceKey(), }) // Mechanism 6 — say once, per session, what is now served where. Silence is the one // thing this design does not allow, but repeating it every turn would be noise. diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index 249f03a303..b1367afd30 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -146,6 +146,10 @@ describe("workspace engine telemetry builders", () => { expect.objectContaining({ enabled: false, reason: "escape-hatch", shadowed_types: 0, escape_hatch: true }), ) expect(trackPrecedence("s1", off, { escapeHatch: false, connections: 0 })).toBe(true) // the hatch toggled + // The same id under another account is another workspace: reported again. + expect(trackPrecedence("s3", on, { escapeHatch: false, connections: 1, identity: "acme|7" })).toBe(true) + expect(trackPrecedence("s3", on, { escapeHatch: false, connections: 1, identity: "acme|7" })).toBe(false) + expect(trackPrecedence("s3", on, { escapeHatch: false, connections: 1, identity: "other|7" })).toBe(true) expect((events.at(-1) as any).escape_hatch).toBe(false) }) From b641b894d0e07f4cd511e4b1b6b3e71c4375e779 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:24:12 +0800 Subject: [PATCH 39/59] fix: count the shadowed types the session can route Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../altimate/workspace/engine-telemetry.ts | 10 ++++++---- .../src/altimate/workspace/precedence.ts | 2 ++ .../workspace/engine-telemetry.test.ts | 20 +++++++++---------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index c49629397f..d3eebdd676 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -158,7 +158,7 @@ export function precedenceSignature( export function precedenceEvent( p: Pick, - opts: { escapeHatch: boolean; connections: number; sessionID?: string }, + opts: { escapeHatch: boolean; connections: number; types: number; sessionID?: string }, ): Telemetry.Event { const id = Number(p.workspaceId) return { @@ -167,7 +167,9 @@ export function precedenceEvent( session_id: opts.sessionID ?? sessionId(), enabled: p.enabled, ...(p.disabledReason ? { reason: p.disabledReason } : {}), - shadowed_types: p.shadowed.size, + // Types the session can route through the engine (the inventory line's count), not every + // materialised type: a session whose rules deny the engine keys shadows nothing. + shadowed_types: opts.types, shadowed_connections: opts.connections, extensions: p.extensions?.length ?? 0, escape_hatch: opts.escapeHatch, @@ -183,13 +185,13 @@ const MAX_SEEN_SESSIONS = 256 export function trackPrecedence( sessionID: string, p: Pick, - opts: { escapeHatch: boolean; connections: number; identity?: string }, + opts: { escapeHatch: boolean; connections: number; types: number; identity?: string }, ): boolean { try { // The local connections shadowed and the escape hatch are part of what is reported, so a change // in either re-reports the same snapshot; the account-scoped identity (credential scope plus // id) tells the same id under another account apart. - const signature = `${precedenceSignature(p)}|c=${opts.connections}|e=${opts.escapeHatch}|w=${opts.identity ?? ""}` + const signature = `${precedenceSignature(p)}|c=${opts.connections}|t=${opts.types}|e=${opts.escapeHatch}|w=${opts.identity ?? ""}` if (precedenceSeen.get(sessionID) === signature) return false precedenceSeen.set(sessionID, signature) if (precedenceSeen.size > MAX_SEEN_SESSIONS) precedenceSeen.delete(precedenceSeen.keys().next().value!) diff --git a/packages/opencode/src/altimate/workspace/precedence.ts b/packages/opencode/src/altimate/workspace/precedence.ts index 202bb3b641..733269d1ad 100644 --- a/packages/opencode/src/altimate/workspace/precedence.ts +++ b/packages/opencode/src/altimate/workspace/precedence.ts @@ -544,6 +544,8 @@ export async function refresh( escapeHatch: escapeHatchOn(), // The registry is read only when something can be shadowed: a disabled snapshot shadows nothing. connections: result.enabled ? countShadowedConnections(result) : 0, + // Types the session can route through the engine, as the inventory line counts them. + types: result.enabled ? servedInventory(result).length : 0, identity: linkedWorkspaceKey(), }) // Mechanism 6 — say once, per session, what is now served where. Silence is the one diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index b1367afd30..8fafb4cdb8 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -125,7 +125,7 @@ describe("workspace engine telemetry builders", () => { ["dbt", new Map([["dbt_run", {} as any]])], ]) const on: any = { enabled: true, shadowed, extensions: [{ integration: "x", tools: [] }], workspaceId: "7" } - expect(precedenceEvent(on, { escapeHatch: false, connections: 1 })).toEqual( + expect(precedenceEvent(on, { escapeHatch: false, connections: 1, types: 2 })).toEqual( expect.objectContaining({ type: "workspace_engine_precedence", enabled: true, @@ -136,20 +136,20 @@ describe("workspace engine telemetry builders", () => { workspace_id: 7, }), ) - expect(trackPrecedence("s1", on, { escapeHatch: false, connections: 1 })).toBe(true) - expect(trackPrecedence("s1", on, { escapeHatch: false, connections: 1 })).toBe(false) // unchanged - expect(trackPrecedence("s1", on, { escapeHatch: false, connections: 2 })).toBe(true) // a connection was added locally - expect(trackPrecedence("s2", on, { escapeHatch: false, connections: 2 })).toBe(true) // another session + expect(trackPrecedence("s1", on, { escapeHatch: false, connections: 1, types: 2 })).toBe(true) + expect(trackPrecedence("s1", on, { escapeHatch: false, connections: 1, types: 2 })).toBe(false) // unchanged + expect(trackPrecedence("s1", on, { escapeHatch: false, connections: 2, types: 2 })).toBe(true) // a connection was added locally + expect(trackPrecedence("s2", on, { escapeHatch: false, connections: 2, types: 2 })).toBe(true) // another session const off: any = { enabled: false, disabledReason: "escape-hatch" as const, shadowed: new Map(), workspaceId: "7" } - expect(trackPrecedence("s1", off, { escapeHatch: true, connections: 0 })).toBe(true) + expect(trackPrecedence("s1", off, { escapeHatch: true, connections: 0, types: 0 })).toBe(true) expect(events.at(-1)).toEqual( expect.objectContaining({ enabled: false, reason: "escape-hatch", shadowed_types: 0, escape_hatch: true }), ) - expect(trackPrecedence("s1", off, { escapeHatch: false, connections: 0 })).toBe(true) // the hatch toggled + expect(trackPrecedence("s1", off, { escapeHatch: false, connections: 0, types: 0 })).toBe(true) // the hatch toggled // The same id under another account is another workspace: reported again. - expect(trackPrecedence("s3", on, { escapeHatch: false, connections: 1, identity: "acme|7" })).toBe(true) - expect(trackPrecedence("s3", on, { escapeHatch: false, connections: 1, identity: "acme|7" })).toBe(false) - expect(trackPrecedence("s3", on, { escapeHatch: false, connections: 1, identity: "other|7" })).toBe(true) + expect(trackPrecedence("s3", on, { escapeHatch: false, connections: 1, types: 2, identity: "acme|7" })).toBe(true) + expect(trackPrecedence("s3", on, { escapeHatch: false, connections: 1, types: 2, identity: "acme|7" })).toBe(false) + expect(trackPrecedence("s3", on, { escapeHatch: false, connections: 1, types: 2, identity: "other|7" })).toBe(true) expect((events.at(-1) as any).escape_hatch).toBe(false) }) From 87c9ebd7f9550a3a6899277985c94028d0719886 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:32:52 +0800 Subject: [PATCH 40/59] fix: name the linked workspace on a disabled precedence row Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/engine-telemetry.ts | 12 ++++++++---- .../opencode/src/altimate/workspace/precedence.ts | 3 +++ .../test/altimate/workspace/engine-telemetry.test.ts | 8 ++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index d3eebdd676..69c8956c26 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -158,9 +158,13 @@ export function precedenceSignature( export function precedenceEvent( p: Pick, - opts: { escapeHatch: boolean; connections: number; types: number; sessionID?: string }, + opts: { escapeHatch: boolean; connections: number; types: number; sessionID?: string; workspaceId?: number }, ): Telemetry.Event { - const id = Number(p.workspaceId) + // The snapshot's own workspace, else the directory's linked one passed by refresh(): a disabled + // snapshot (escape hatch, unattributed) names no workspace itself, and the row must not be left + // to the process context. + const own = Number(p.workspaceId) + const id = Number.isInteger(own) && own > 0 ? own : opts.workspaceId return { type: "workspace_engine_precedence", timestamp: Date.now(), @@ -173,7 +177,7 @@ export function precedenceEvent( shadowed_connections: opts.connections, extensions: p.extensions?.length ?? 0, escape_hatch: opts.escapeHatch, - ...(Number.isInteger(id) && id > 0 ? { workspace_id: id } : {}), + ...(id !== undefined && Number.isInteger(id) && id > 0 ? { workspace_id: id } : {}), } } @@ -185,7 +189,7 @@ const MAX_SEEN_SESSIONS = 256 export function trackPrecedence( sessionID: string, p: Pick, - opts: { escapeHatch: boolean; connections: number; types: number; identity?: string }, + opts: { escapeHatch: boolean; connections: number; types: number; identity?: string; workspaceId?: number }, ): boolean { try { // The local connections shadowed and the escape hatch are part of what is reported, so a change diff --git a/packages/opencode/src/altimate/workspace/precedence.ts b/packages/opencode/src/altimate/workspace/precedence.ts index 733269d1ad..bb3d9f1fa1 100644 --- a/packages/opencode/src/altimate/workspace/precedence.ts +++ b/packages/opencode/src/altimate/workspace/precedence.ts @@ -66,6 +66,7 @@ import { settledOutcome, type EntryLike, type Outcome, + linkedWorkspace, linkedWorkspaceKey, } from "./engine-overlay" import { readLocalBindingScopedStrict, resolvePinnedBindingForRouting } from "./state" @@ -547,6 +548,8 @@ export async function refresh( // Types the session can route through the engine, as the inventory line counts them. types: result.enabled ? servedInventory(result).length : 0, identity: linkedWorkspaceKey(), + // Named explicitly for disabled snapshots that carry no binding of their own. + workspaceId: Number(linkedWorkspace()?.id) || undefined, }) // Mechanism 6 — say once, per session, what is now served where. Silence is the one // thing this design does not allow, but repeating it every turn would be noise. diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index 8fafb4cdb8..da2cf3a206 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -125,6 +125,14 @@ describe("workspace engine telemetry builders", () => { ["dbt", new Map([["dbt_run", {} as any]])], ]) const on: any = { enabled: true, shadowed, extensions: [{ integration: "x", tools: [] }], workspaceId: "7" } + // A disabled snapshot carries no binding; the directory's linked workspace names the row. + const offSnap: any = { enabled: false, disabledReason: "escape-hatch" as const, shadowed: new Map() } + expect( + (precedenceEvent(offSnap, { escapeHatch: true, connections: 0, types: 0, workspaceId: 7 }) as any).workspace_id, + ).toBe(7) + expect("workspace_id" in (precedenceEvent(offSnap, { escapeHatch: true, connections: 0, types: 0 }) as any)).toBe( + false, + ) expect(precedenceEvent(on, { escapeHatch: false, connections: 1, types: 2 })).toEqual( expect.objectContaining({ type: "workspace_engine_precedence", From a590b796d5ed735e6f507ccbb768b7c8aba8d0e0 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 23:41:31 +0800 Subject: [PATCH 41/59] fix: an unbound attach row names no workspace explicitly Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../opencode/src/altimate/workspace/engine-telemetry.ts | 5 +++-- .../test/altimate/workspace/engine-telemetry.test.ts | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index 69c8956c26..47e564dbd1 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -51,13 +51,14 @@ export function attachEvent( sessionID?: string, ): Telemetry.Event { // The boundary's own workspace, named on the row rather than left to the process context; an - // unbound or disabled outcome has none. + // unbound or disabled outcome has none, and says so with an explicit undefined so the session's + // entry is not stamped onto it either (the envelope drops an undefined value). const settledFor = outcome.kind !== "unbound" && outcome.kind !== "disabled" ? extra.workspaceId : undefined const base = { type: "workspace_engine_attach" as const, timestamp: Date.now(), session_id: sessionID ?? sessionId(), - ...(settledFor !== undefined && Number.isInteger(settledFor) && settledFor > 0 ? { workspace_id: settledFor } : {}), + workspace_id: settledFor !== undefined && Number.isInteger(settledFor) && settledFor > 0 ? settledFor : undefined, } switch (outcome.kind) { case "attached": diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index da2cf3a206..b5a383576c 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -82,7 +82,11 @@ describe("workspace engine telemetry builders", () => { attachSignature({ kind: "attached", available: 2 }), ) expect((attachEvent({ kind: "attached", available: 1 }, { workspaceId: 42 }) as any).workspace_id).toBe(42) - expect("workspace_id" in (attachEvent({ kind: "unbound" }, { workspaceId: 42 }) as any)).toBe(false) + // An unbound attach names no workspace, explicitly: the key is present and undefined, so the + // session's entry cannot be stamped onto it. + const unbound = attachEvent({ kind: "unbound" }, { workspaceId: 42 }) as any + expect("workspace_id" in unbound).toBe(true) + expect(unbound.workspace_id).toBeUndefined() }) test("the install prompt event carries the offer, the surface and node detection", () => { From 7605dd64b3c9340ead3f0324b2ae3bbc20f778f1 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Fri, 16 Oct 2026 00:03:53 +0800 Subject: [PATCH 42/59] fix: track `copied` only on a confirmed copy; an unbound precedence row names no workspace `confirmedCopy` writes and reads back; the dialog tracks `copied` only when the clipboard then holds the command. `precedenceEvent` sets `workspace_id` explicitly (undefined when unbound and unlinked), as the attach row does. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../altimate/workspace/engine-telemetry.ts | 4 +- .../src/plugin/tui/altimate/workspace.tsx | 41 +++++++++++-------- .../test/altimate/plugin/workspace.test.ts | 36 ++++++++++++++++ .../workspace/engine-telemetry.test.ts | 7 ++-- 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-telemetry.ts b/packages/opencode/src/altimate/workspace/engine-telemetry.ts index 47e564dbd1..75f34962d0 100644 --- a/packages/opencode/src/altimate/workspace/engine-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/engine-telemetry.ts @@ -178,7 +178,9 @@ export function precedenceEvent( shadowed_connections: opts.connections, extensions: p.extensions?.length ?? 0, escape_hatch: opts.escapeHatch, - ...(id !== undefined && Number.isInteger(id) && id > 0 ? { workspace_id: id } : {}), + // Present and undefined for an unbound or disabled snapshot with no linked workspace, as on the + // attach row: the row names no workspace, and nothing else may name one for it. + workspace_id: id !== undefined && Number.isInteger(id) && id > 0 ? id : undefined, } } diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 4d388dbd12..01cd39d5e0 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -99,7 +99,7 @@ import { OFFER_SKIP_TTL_MS, type EngineOffer, } from "@/altimate/workspace/engine-offer" -import { useClipboard } from "@opencode-ai/tui/context/clipboard" +import { useClipboard, type ClipboardService } from "@opencode-ai/tui/context/clipboard" import { AltimateApi } from "@/altimate/api/client" import { Log } from "@/altimate/util/log" @@ -1911,21 +1911,12 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { const copyCommand = () => { const cmd = command() void (async () => { - try { - await clipboard.write?.(cmd) - // A resolved write is NOT proof of a copy. The host's writer picks - // xclip/xsel on Linux and otherwise falls back to clipboardy, and it - // swallows backend failures (`.catch(() => undefined)`), so on the many - // Linux/WSL boxes with neither tool installed the write silently does - // nothing. Read back and compare before claiming success. (Caught by - // E2E: the toast said "Copied:" while the clipboard was untouched.) - const back = await clipboard.read?.() - if (back?.data.trim() === cmd) { - props.api.ui.toast({ variant: "info", message: `Copied: ${cmd}` }) - return - } - } catch { - // Unreadable or unwritable clipboard — fall through and show it. + if (await confirmedCopy(clipboard, cmd)) { + // `copied` only once the read-back confirms the clipboard holds the command: the + // selection itself proves nothing (an unconfirmed copy tracks no row). + trackInstallPrompt("copied", props.offer) + props.api.ui.toast({ variant: "info", message: `Copied: ${cmd}` }) + return } props.api.ui.toast({ variant: "warning", @@ -1977,7 +1968,6 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { return } if (option.value === "copy") { - trackInstallPrompt("copied", props.offer) copyCommand() props.api.ui.dialog.clear() return @@ -1992,6 +1982,23 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { ) } +/** Write `text` to the clipboard and read it back: true only when the clipboard then holds it. + * + * A resolved write is NOT proof of a copy. The host's writer picks xclip/xsel on Linux and + * otherwise falls back to clipboardy, and it swallows backend failures (`.catch(() => undefined)`), + * so on the many Linux/WSL boxes with neither tool installed the write silently does nothing. + * (Caught by E2E: the toast said "Copied:" while the clipboard was untouched.) An unreadable or + * unwritable clipboard is the same unconfirmed outcome. */ +export async function confirmedCopy(clipboard: Pick, text: string) { + try { + await clipboard.write?.(text) + const back = await clipboard.read?.() + return back?.data.trim() === text + } catch { + return false + } +} + /** Show the offer unless the 7-day latch suppresses it. * * The overlay raises this as a bare command over the event bus — it cannot hand diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index 9670a24113..9caba70d13 100644 --- a/packages/opencode/test/altimate/plugin/workspace.test.ts +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -35,6 +35,7 @@ const { recordEngineSkip, awaitKvReady, canInstallWith, + confirmedCopy, engineOfferInternals, showEngineInstallOffer, linkDialogInternals, @@ -1249,3 +1250,38 @@ describe("/integrations", () => { expect(h.selects[1]!.options[0]!.description).toContain("No session has attached yet") }) }) + +describe("confirmedCopy", () => { + const cmd = "npm i -g @altimateai/altimate-code-engine" + test("true only when the read-back holds the command", async () => { + let held = "" + const clipboard = { + write: async (data: string) => { + held = data + }, + read: async () => ({ data: `${held}\n`, mime: "text/plain" }), + } + expect(await confirmedCopy(clipboard, cmd)).toBe(true) + }) + test("false when the writer silently did nothing", async () => { + const clipboard = { + write: async () => {}, + read: async () => ({ data: "something else", mime: "text/plain" }), + } + expect(await confirmedCopy(clipboard, cmd)).toBe(false) + }) + test("false when the clipboard throws or cannot be read", async () => { + expect( + await confirmedCopy( + { + write: async () => { + throw new Error("no backend") + }, + read: async () => ({ data: cmd, mime: "text/plain" }), + }, + cmd, + ), + ).toBe(false) + expect(await confirmedCopy({ write: async () => {}, read: undefined }, cmd)).toBe(false) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts index b5a383576c..4c0e84ce6b 100644 --- a/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-telemetry.test.ts @@ -134,9 +134,10 @@ describe("workspace engine telemetry builders", () => { expect( (precedenceEvent(offSnap, { escapeHatch: true, connections: 0, types: 0, workspaceId: 7 }) as any).workspace_id, ).toBe(7) - expect("workspace_id" in (precedenceEvent(offSnap, { escapeHatch: true, connections: 0, types: 0 }) as any)).toBe( - false, - ) + // Unbound and no linked workspace: the key is present and undefined, so the row names none. + const unboundRow = precedenceEvent(offSnap, { escapeHatch: true, connections: 0, types: 0 }) as any + expect("workspace_id" in unboundRow).toBe(true) + expect(unboundRow.workspace_id).toBeUndefined() expect(precedenceEvent(on, { escapeHatch: false, connections: 1, types: 2 })).toEqual( expect.objectContaining({ type: "workspace_engine_precedence", From 2d21addfe8cd3aebef458ca80f7d74589e5b13b2 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Fri, 16 Oct 2026 00:18:04 +0800 Subject: [PATCH 43/59] fix: an attached run reports the bus offer it prints as shown The offer command carries the offer's telemetry fields and the server's node major (`arguments`, JSON); `run --attach` reports `shown` with surface `bus` from them when it prints the line. The server's publish is not a display. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/engine-offer.ts | 60 ++++++++++++++++-- .../src/altimate/workspace/engine-seams.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 5 +- packages/opencode/src/server/tui-event.ts | 2 + .../workspace/engine-install-offer.test.ts | 62 +++++++++++++++++-- 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts index 93cf85eb8e..58bbfd75e4 100644 --- a/packages/opencode/src/altimate/workspace/engine-offer.ts +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -273,15 +273,65 @@ export async function installEngine(): Promise { return { ok: false, error: detail || `npm exited with code ${run.code ?? "unknown"}` } } +/** What the bus carries with the offer command: the offer's telemetry fields plus the server's + * node major (the install happens where the server runs), so the surface that prints the offer in + * another process (an attached run) reports `shown` with them. */ +export type OfferDetail = Pick & { + nodeMajor: number | null +} + +export function encodeOfferDetail(offer: EngineOffer, nodeMajor: number | null): string { + const detail: OfferDetail = { + reason: offer.reason, + workspaceId: offer.workspaceId, + declared: offer.declared, + found: offer.found, + nodeMajor, + } + return JSON.stringify(detail) +} + +/** The detail back from the bus, or undefined for anything that is not one. */ +export function decodeOfferDetail(raw: unknown): OfferDetail | undefined { + if (typeof raw !== "string") return undefined + try { + const v = JSON.parse(raw) as Partial> + const reason = v.reason === "engine-missing" || v.reason === "engine-too-old" ? v.reason : undefined + const workspaceId = typeof v.workspaceId === "string" ? v.workspaceId : undefined + const declared = typeof v.declared === "number" ? v.declared : v.declared === undefined ? undefined : NaN + const found = typeof v.found === "string" ? v.found : v.found === undefined ? undefined : "" + const nodeMajor = v.nodeMajor === null ? null : typeof v.nodeMajor === "number" ? v.nodeMajor : undefined + if (!reason || workspaceId === undefined || Number.isNaN(declared) || found === "" || nodeMajor === undefined) { + return undefined + } + return { reason, workspaceId, declared, found, nodeMajor } + } catch { + return undefined + } +} + +/** An attached run printed the offer it read off the bus: that process is the surface that showed + * it, so it reports `shown` (surface `bus`) from the fields the event carried. False when the + * event carried none that decode, and nothing is reported. */ +export function trackPrintedOffer(raw: unknown, sessionID: string): boolean { + const detail = decodeOfferDetail(raw) + if (!detail) return false + trackInstallPrompt("shown", detail, { surface: "bus", nodeMajor: detail.nodeMajor }, sessionID) + return true +} + /** Ask the TUI to raise the offer. False when the bus is unavailable. The * session is carried so an attached headless run, which reads the same event - * stream, prints the offer raised for its own session only. */ -async function publishOffer(sessionID: string): Promise { - if (syncInternals.publishOffer) return syncInternals.publishOffer(sessionID) + * stream, prints the offer raised for its own session only; the offer's fields + * ride along so that run reports `shown` from them (the TUI dialog re-derives + * the offer and reports its own). */ +async function publishOffer(offer: EngineOffer, sessionID: string): Promise { + const detail = encodeOfferDetail(offer, await nodeMajor().catch(() => null)) + if (syncInternals.publishOffer) return syncInternals.publishOffer(sessionID, detail) try { await AppRuntime.runPromise( EventV2Bridge.Service.use((events) => - events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND, sessionID }), + events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND, sessionID, arguments: detail }), ), ) return true @@ -335,6 +385,6 @@ export async function offerOrNotify(offer: EngineOffer, toast: Toast, sessionID: // may hold the offer back (its 7-day latch), so dispatch is not display. The toast reports here // only when it was shown. if (offerInstall(offer)) return - if (await publishOffer(sessionID)) return + if (await publishOffer(offer, sessionID)) return if (await notify(toast)) shown("toast") } diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 73d6609159..4ab0553a47 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -33,7 +33,7 @@ export const syncInternals: { printLine?: (line: string) => void /** Install-offer seams (see engine-offer.ts). */ offer?: (offer: EngineOffer) => boolean - publishOffer?: (sessionID: string) => Promise + publishOffer?: (sessionID: string, detail: string) => Promise runInstall?: ( argv: string[], timeoutMs: number, diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3e3afe3a15..f6208dbbb5 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -71,7 +71,7 @@ import { SessionTermination } from "../../session/termination" // altimate_change start — upstream_fix: type-only import for the tracing-config cast (see tracer setup below) import type { ConfigV1 } from "@opencode-ai/core/v1/config/config" // altimate_change - render the workspace engine offer in an attached run -import { OFFER_COMMAND, installCommand } from "@/altimate/workspace/engine-offer" +import { OFFER_COMMAND, installCommand, trackPrintedOffer } from "@/altimate/workspace/engine-offer" import { OutputCollector } from "./run-output" // altimate_change end @@ -1345,6 +1345,9 @@ You are speaking to a non-technical business executive. Follow these rules stric `This workspace's integration tools need the local engine on the server. Install it there with: ${installCommand()}` + EOL, ) + // This process is the surface that showed the offer, so it reports `shown` (surface + // `bus`) from the fields the event carried; the server's publish is not a display. + if (sessionID) trackPrintedOffer((event.properties as { arguments?: unknown }).arguments, sessionID) continue } // altimate_change end diff --git a/packages/opencode/src/server/tui-event.ts b/packages/opencode/src/server/tui-event.ts index 93d3f7e6df..c7e62862d4 100644 --- a/packages/opencode/src/server/tui-event.ts +++ b/packages/opencode/src/server/tui-event.ts @@ -36,6 +36,8 @@ export const TuiEvent = { // same stream: the session it was raised for lets that run print only its // own offer, not another session's in the same directory. sessionID: Schema.optional(Schema.String), + // The offer's telemetry fields (JSON), for the surface that prints it to report `shown` with. + arguments: Schema.optional(Schema.String), // altimate_change end }, }), diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index 54f2338e84..a9a2618b78 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -22,7 +22,12 @@ import { type EngineOffer, type Toast, } from "../../../src/altimate/workspace/engine-overlay" -import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS } from "../../../src/altimate/workspace/engine-offer" +import { + OFFER_RECHECK_MS, + OFFER_SKIP_TTL_MS, + decodeOfferDetail, + trackPrintedOffer, +} from "../../../src/altimate/workspace/engine-offer" import type { CachedBinding } from "../../../src/altimate/workspace/state" import { Telemetry } from "../../../src/altimate/telemetry" @@ -38,7 +43,14 @@ const binding: CachedBinding = { linkedAt: 0, } as CachedBinding -type Harness = { offers: EngineOffer[]; toasts: Toast[]; printed: string[]; published: number; publishedFor: string[] } +type Harness = { + offers: EngineOffer[] + toasts: Toast[] + printed: string[] + published: number + publishedFor: string[] + publishedDetail: string[] +} /** No engine on PATH (or an old one) plus captured surfaces. */ function install(opts: { @@ -50,7 +62,7 @@ function install(opts: { surface?: boolean bound?: boolean }): Harness { - const h: Harness = { offers: [], toasts: [], printed: [], published: 0, publishedFor: [] } + const h: Harness = { offers: [], toasts: [], printed: [], published: 0, publishedFor: [], publishedDetail: [] } delete process.env.ALTIMATE_DISABLE_WORKSPACE syncInternals.serve = () => false syncInternals.headless = () => opts.headless === true @@ -68,10 +80,11 @@ function install(opts: { syncInternals.printLine = (line) => { h.printed.push(line) } - syncInternals.publishOffer = async (sessionID) => { + syncInternals.publishOffer = async (sessionID, detail) => { if (opts.bus === false) return false h.published += 1 h.publishedFor.push(sessionID) + h.publishedDetail.push(detail) return true } if (opts.surface) { @@ -161,6 +174,47 @@ describe("offer routing — engine missing", () => { expect(h.toasts).toEqual([]) expect(h.printed).toEqual([]) }) + test("the published offer carries its fields, and the surface that prints them reports shown", async () => { + const h = install({}) + syncInternals.nodeMajor = async () => 22 + await beforeTurn("s1") + expect(decodeOfferDetail(h.publishedDetail[0])).toEqual({ + reason: "engine-missing", + workspaceId: "42", + declared: 2, + found: undefined, + nodeMajor: 22, + }) + const rows: Record[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + rows.push(e as Record) + }) + try { + // An attached run prints the offer from this event in its own process and reports from it. + expect(trackPrintedOffer(h.publishedDetail[0], "s1")).toBe(true) + expect(trackPrintedOffer(undefined, "s1")).toBe(false) + expect(trackPrintedOffer("not json", "s1")).toBe(false) + expect(trackPrintedOffer(JSON.stringify({ reason: "other", workspaceId: "42", nodeMajor: null }), "s1")).toBe( + false, + ) + expect(trackPrintedOffer(JSON.stringify({ reason: "engine-missing", workspaceId: "42" }), "s1")).toBe(false) + } finally { + spy.mockRestore() + } + expect(rows).toEqual([ + expect.objectContaining({ + type: "workspace_engine_install_prompt", + action: "shown", + surface: "bus", + session_id: "s1", + workspace_id: 42, + reason: "engine-missing", + declared: 2, + node_detected: true, + node_major: 22, + }), + ]) + }) test("falls back to the toast only when the bus is unavailable", async () => { const h = install({ bus: false }) await beforeTurn("s1") From ead39c6ee92cd4d13730272b0745fae83f652ad5 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Fri, 16 Oct 2026 02:11:20 +0800 Subject: [PATCH 44/59] fix: no node probe on the offer's dispatch path; no writer is no copy The bus offer carries the offer's fields only, so publishing no longer awaits `node --version` ahead of the turn boundary; the printed-offer row has no node fields. `confirmedCopy` returns false without a writer, whatever the clipboard holds. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE --- .../src/altimate/workspace/engine-offer.ts | 22 ++++++++----------- .../src/plugin/tui/altimate/workspace.tsx | 4 +++- .../test/altimate/plugin/workspace.test.ts | 2 ++ .../workspace/engine-install-offer.test.ts | 12 ++++------ 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts index 58bbfd75e4..5d2b83983f 100644 --- a/packages/opencode/src/altimate/workspace/engine-offer.ts +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -273,20 +273,17 @@ export async function installEngine(): Promise { return { ok: false, error: detail || `npm exited with code ${run.code ?? "unknown"}` } } -/** What the bus carries with the offer command: the offer's telemetry fields plus the server's - * node major (the install happens where the server runs), so the surface that prints the offer in - * another process (an attached run) reports `shown` with them. */ -export type OfferDetail = Pick & { - nodeMajor: number | null -} +/** What the bus carries with the offer command: the offer's telemetry fields, so the surface that + * prints the offer in another process (an attached run) reports `shown` with them. No node probe + * rides along: it would sit on the dispatch path, ahead of the turn boundary. */ +export type OfferDetail = Pick -export function encodeOfferDetail(offer: EngineOffer, nodeMajor: number | null): string { +export function encodeOfferDetail(offer: EngineOffer): string { const detail: OfferDetail = { reason: offer.reason, workspaceId: offer.workspaceId, declared: offer.declared, found: offer.found, - nodeMajor, } return JSON.stringify(detail) } @@ -300,11 +297,10 @@ export function decodeOfferDetail(raw: unknown): OfferDetail | undefined { const workspaceId = typeof v.workspaceId === "string" ? v.workspaceId : undefined const declared = typeof v.declared === "number" ? v.declared : v.declared === undefined ? undefined : NaN const found = typeof v.found === "string" ? v.found : v.found === undefined ? undefined : "" - const nodeMajor = v.nodeMajor === null ? null : typeof v.nodeMajor === "number" ? v.nodeMajor : undefined - if (!reason || workspaceId === undefined || Number.isNaN(declared) || found === "" || nodeMajor === undefined) { + if (!reason || workspaceId === undefined || Number.isNaN(declared) || found === "") { return undefined } - return { reason, workspaceId, declared, found, nodeMajor } + return { reason, workspaceId, declared, found } } catch { return undefined } @@ -316,7 +312,7 @@ export function decodeOfferDetail(raw: unknown): OfferDetail | undefined { export function trackPrintedOffer(raw: unknown, sessionID: string): boolean { const detail = decodeOfferDetail(raw) if (!detail) return false - trackInstallPrompt("shown", detail, { surface: "bus", nodeMajor: detail.nodeMajor }, sessionID) + trackInstallPrompt("shown", detail, { surface: "bus" }, sessionID) return true } @@ -326,7 +322,7 @@ export function trackPrintedOffer(raw: unknown, sessionID: string): boolean { * ride along so that run reports `shown` from them (the TUI dialog re-derives * the offer and reports its own). */ async function publishOffer(offer: EngineOffer, sessionID: string): Promise { - const detail = encodeOfferDetail(offer, await nodeMajor().catch(() => null)) + const detail = encodeOfferDetail(offer) if (syncInternals.publishOffer) return syncInternals.publishOffer(sessionID, detail) try { await AppRuntime.runPromise( diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 01cd39d5e0..c072953e16 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1990,8 +1990,10 @@ function EngineInstallOfferDialog(props: EngineOfferProps) { * (Caught by E2E: the toast said "Copied:" while the clipboard was untouched.) An unreadable or * unwritable clipboard is the same unconfirmed outcome. */ export async function confirmedCopy(clipboard: Pick, text: string) { + // No writer means nothing was copied, whatever the clipboard happens to hold. + if (!clipboard.write) return false try { - await clipboard.write?.(text) + await clipboard.write(text) const back = await clipboard.read?.() return back?.data.trim() === text } catch { diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index 9caba70d13..fad6d04623 100644 --- a/packages/opencode/test/altimate/plugin/workspace.test.ts +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -1283,5 +1283,7 @@ describe("confirmedCopy", () => { ), ).toBe(false) expect(await confirmedCopy({ write: async () => {}, read: undefined }, cmd)).toBe(false) + // No writer: the clipboard holding the command already is not a copy. + expect(await confirmedCopy({ write: undefined, read: async () => ({ data: cmd, mime: "text/plain" }) }, cmd)).toBe(false) }) }) diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index a9a2618b78..d99aaf2be4 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -176,14 +176,12 @@ describe("offer routing — engine missing", () => { }) test("the published offer carries its fields, and the surface that prints them reports shown", async () => { const h = install({}) - syncInternals.nodeMajor = async () => 22 await beforeTurn("s1") expect(decodeOfferDetail(h.publishedDetail[0])).toEqual({ reason: "engine-missing", workspaceId: "42", declared: 2, found: undefined, - nodeMajor: 22, }) const rows: Record[] = [] const spy = spyOn(Telemetry, "track").mockImplementation((e) => { @@ -194,10 +192,8 @@ describe("offer routing — engine missing", () => { expect(trackPrintedOffer(h.publishedDetail[0], "s1")).toBe(true) expect(trackPrintedOffer(undefined, "s1")).toBe(false) expect(trackPrintedOffer("not json", "s1")).toBe(false) - expect(trackPrintedOffer(JSON.stringify({ reason: "other", workspaceId: "42", nodeMajor: null }), "s1")).toBe( - false, - ) - expect(trackPrintedOffer(JSON.stringify({ reason: "engine-missing", workspaceId: "42" }), "s1")).toBe(false) + expect(trackPrintedOffer(JSON.stringify({ reason: "other", workspaceId: "42" }), "s1")).toBe(false) + expect(trackPrintedOffer(JSON.stringify({ reason: "engine-missing", declared: "2" }), "s1")).toBe(false) } finally { spy.mockRestore() } @@ -210,10 +206,10 @@ describe("offer routing — engine missing", () => { workspace_id: 42, reason: "engine-missing", declared: 2, - node_detected: true, - node_major: 22, }), ]) + // No node fields: the probe stays off the dispatch path, so the event carries none. + expect("node_detected" in rows[0]!).toBe(false) }) test("falls back to the toast only when the bus is unavailable", async () => { const h = install({ bus: false }) From fd4d933338c12fb4547238403d6724536800eb8f Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Mon, 26 Oct 2026 22:46:19 +0800 Subject: [PATCH 45/59] feat: report `starter_action_picked` from the TUI starter One row per selection in either TUI starter dialog: the action, the prompt's index, and the starter's own workspace, never the prompt text. Nothing before the starter arrives. The served starter now carries its workspace id. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../opencode/src/altimate/telemetry/index.ts | 10 ++ .../altimate/workspace/starter-telemetry.ts | 53 +++++++ .../src/altimate/workspace/starter.ts | 4 +- .../src/plugin/tui/altimate/workspace.tsx | 6 +- .../telemetry/starter-action-picked.test.ts | 133 ++++++++++++++++++ 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/starter-telemetry.ts create mode 100644 packages/opencode/test/altimate/telemetry/starter-action-picked.test.ts diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 9deec3e4c2..28b157c84d 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -622,6 +622,16 @@ export namespace Telemetry { knowledge: string memory_enabled?: boolean } + | { + /** One selection from the TUI's workspace starter: a suggested prompt, open in browser, or done. */ + type: "starter_action_picked" + timestamp: number + session_id: string + workspace_id: number + action: "prompt" | "open" | "done" + /** 0-based position of the suggested prompt; only for `prompt`. The prompt's text is never sent. */ + prompt_index?: number + } | { /** A memory block was written back to the bound workspace's cloud memory. */ type: "workspace_memory_contributed" diff --git a/packages/opencode/src/altimate/workspace/starter-telemetry.ts b/packages/opencode/src/altimate/workspace/starter-telemetry.ts new file mode 100644 index 0000000000..dd3ae3c9de --- /dev/null +++ b/packages/opencode/src/altimate/workspace/starter-telemetry.ts @@ -0,0 +1,53 @@ +// altimate_change - new file +// +// `starter_action_picked`: what the user chose from the workspace starter in the TUI. Kept apart from +// `starter.ts`, which the TUI imports for its type only: building a starter loads the skill module and +// the workspace service client, which the TUI's thread does not otherwise load. +import { Telemetry } from "@/altimate/telemetry" +import type { Starter } from "./starter" + +export type StarterAction = "prompt" | "open" | "done" + +export interface StarterPick { + action: StarterAction + /** Which suggested prompt, 0-based; only for `prompt`. */ + promptIndex?: number +} + +/** A starter dialog option's value (`prompt:`, `open`, `done`) as the action it picks; null for any + * other value. Pure. */ +export function starterPick(value: string): StarterPick | null { + if (value === "open" || value === "done") return { action: value } + const match = /^prompt:(\d+)$/.exec(value) + if (!match) return null + const index = Number(match[1]) + return Number.isSafeInteger(index) ? { action: "prompt", promptIndex: index } : null +} + +/** The row for a pick: the action, the prompt's index for a prompt, and the workspace. Never the prompt's text. */ +export function starterActionPickedEvent(workspaceId: number, pick: StarterPick, sessionID?: string): Telemetry.Event { + return { + type: "starter_action_picked", + timestamp: Date.now(), + session_id: sessionID ?? Telemetry.getContext().sessionId, + workspace_id: workspaceId, + action: pick.action, + ...(pick.action === "prompt" && pick.promptIndex !== undefined ? { prompt_index: pick.promptIndex } : {}), + } +} + +/** Track one selection from a starter dialog. `starter` is what the dialog shows; null while it has not + * arrived, when the dialog is the bare link confirmation and a selection is no starter outcome. The row names + * the starter's own workspace, the one its `starter_shown` row named. Nothing for a value that is not a + * starter action or a workspace id that is not a positive integer. Never throws. */ +export function trackStarterActionPicked(starter: Pick | null, value: string): boolean { + try { + const workspaceId = starter?.workspaceId + const pick = starterPick(value) + if (!pick || workspaceId === undefined || !Number.isInteger(workspaceId) || workspaceId <= 0) return false + Telemetry.track(starterActionPickedEvent(workspaceId, pick)) + return true + } catch { + return false + } +} diff --git a/packages/opencode/src/altimate/workspace/starter.ts b/packages/opencode/src/altimate/workspace/starter.ts index 5b7680426a..6d4db78e0e 100644 --- a/packages/opencode/src/altimate/workspace/starter.ts +++ b/packages/opencode/src/altimate/workspace/starter.ts @@ -19,6 +19,8 @@ export interface Starter { prompts: string[] /** The whole message as plain text, for a terminal or a chat bubble. */ text: string + /** The workspace the starter describes, so a client can report a pick against it. Set by `starterFor`. */ + workspaceId?: number } const MAX_PROMPTS = 3 @@ -174,5 +176,5 @@ export async function starterFor( const contents = await workspaceContents(directory, binding.datamateId, waitMs) const starter = renderStarter(binding.datamateName, contents) trackStarterShown(binding.datamateId, contents) - return starter + return { ...starter, workspaceId: binding.datamateId } } diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index c072953e16..210df8e669 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -43,6 +43,7 @@ import { } from "@/altimate/workspace/workspace-name" import { currentAttachSnapshot } from "@/altimate/workspace/attach-snapshot" import { trackEngineError, trackInstallPrompt } from "@/altimate/workspace/engine-telemetry" +import { trackStarterActionPicked } from "@/altimate/workspace/starter-telemetry" import { accountScope, loadStatusView, @@ -449,7 +450,9 @@ function WorkspaceLinkedDialog(props: LinkedProps) { if (!props.starter()?.prompts.length && option.value !== initial) setMovedEarly(true) }} onSelect={(option) => { - // altimate_change start - put the chosen prompt in the input without sending it + // altimate_change start - the pick, once the starter is on screen (before, this is the bare confirmation) + trackStarterActionPicked(props.starter(), option.value) + // put the chosen prompt in the input without sending it if (option.value.startsWith("prompt:")) { fillPrompt(props.api, option.title) return @@ -524,6 +527,7 @@ function WorkspaceStarterDialog(props: { api: TuiPluginApi; starter: Starter }) ]} current={props.starter.prompts.length > 0 ? "prompt:0" : "done"} onSelect={(option) => { + trackStarterActionPicked(props.starter, option.value) if (option.value.startsWith("prompt:")) fillPrompt(props.api, option.title) else props.api.ui.dialog.clear() }} diff --git a/packages/opencode/test/altimate/telemetry/starter-action-picked.test.ts b/packages/opencode/test/altimate/telemetry/starter-action-picked.test.ts new file mode 100644 index 0000000000..815065eca4 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/starter-action-picked.test.ts @@ -0,0 +1,133 @@ +// altimate_change - new file +// +// `starter_action_picked`: one row per selection from a TUI starter dialog, naming the action, the +// prompt's position for a prompt, and the starter's own workspace; never the prompt's text. +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import * as Contents from "../../../src/altimate/workspace/contents" +import type { WorkspaceContents } from "../../../src/altimate/workspace/contents" +import { starterFor } from "../../../src/altimate/workspace/starter" +import { + starterActionPickedEvent, + starterPick, + trackStarterActionPicked, +} from "../../../src/altimate/workspace/starter-telemetry" +import { Telemetry } from "../../../src/altimate/telemetry" + +const contents: WorkspaceContents = { + skills: [{ name: "incident-runbook", description: "" }], + integrations: [], + memoryEnabled: true, + knowledge: { kind: "off" }, +} + +let events: any[] +let trackSpy: ReturnType + +beforeEach(() => { + events = [] + trackSpy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) +}) + +afterEach(() => { + trackSpy.mockRestore() +}) + +describe("starterPick", () => { + test.each([ + ["prompt:0", { action: "prompt", promptIndex: 0 }], + ["prompt:2", { action: "prompt", promptIndex: 2 }], + ["open", { action: "open" }], + ["done", { action: "done" }], + ] as const)("%s is a starter action", (value, pick) => { + expect(starterPick(value)).toEqual(pick) + }) + + test.each(["", "prompt:", "prompt:-1", "prompt:1.5", "prompt:x", "status", "Done", " done"])( + "%p is not a starter action", + (value) => { + expect(starterPick(value)).toBeNull() + }, + ) +}) + +describe("starter_action_picked row", () => { + test("a prompt carries its index and the workspace, and no text", () => { + const row = starterActionPickedEvent(7, { action: "prompt", promptIndex: 1 }, "ses_1") as Record + expect(Object.keys(row).sort()).toEqual( + ["action", "prompt_index", "session_id", "timestamp", "type", "workspace_id"].sort(), + ) + expect(row).toEqual( + expect.objectContaining({ + type: "starter_action_picked", + session_id: "ses_1", + workspace_id: 7, + action: "prompt", + prompt_index: 1, + }), + ) + }) + + test.each(["open", "done"] as const)("%s carries no prompt_index", (action) => { + const row = starterActionPickedEvent(7, { action }, "ses_1") as Record + expect(row.action).toBe(action) + expect("prompt_index" in row).toBe(false) + }) + + test("falls back to the process context's session", () => { + const row = starterActionPickedEvent(7, { action: "done" }) + expect(row.session_id).toBe(Telemetry.getContext().sessionId) + }) +}) + +describe("trackStarterActionPicked", () => { + test("tracks exactly one row per selection, against the starter's workspace", () => { + expect(trackStarterActionPicked({ workspaceId: 9 }, "prompt:2")).toBe(true) + expect(events).toHaveLength(1) + expect(events[0]).toEqual( + expect.objectContaining({ type: "starter_action_picked", workspace_id: 9, action: "prompt", prompt_index: 2 }), + ) + expect(JSON.stringify(events[0])).not.toContain("Which") + }) + + test("nothing before the starter arrives: the dialog is then the bare link confirmation", () => { + expect(trackStarterActionPicked(null, "done")).toBe(false) + expect(trackStarterActionPicked(null, "open")).toBe(false) + expect(events).toEqual([]) + }) + + test("nothing for a value that is not a starter action", () => { + expect(trackStarterActionPicked({ workspaceId: 9 }, "status")).toBe(false) + expect(events).toEqual([]) + }) + + test.each([undefined, 0, -3, 1.5, Number.NaN])("nothing for workspace id %p", (workspaceId) => { + expect(trackStarterActionPicked({ workspaceId }, "done")).toBe(false) + expect(events).toEqual([]) + }) + + test("never throws when telemetry does", () => { + trackSpy.mockImplementation(() => { + throw new Error("boom") + }) + expect(trackStarterActionPicked({ workspaceId: 9 }, "done")).toBe(false) + }) +}) + +describe("starterFor names its workspace", () => { + test("the served starter carries the id its starter_shown row reported", async () => { + const contentsSpy = spyOn(Contents, "workspaceContents").mockResolvedValue(contents) + try { + const starter = await starterFor("/tmp/project", { datamateId: 31, datamateName: "analytics" }) + expect(starter.workspaceId).toBe(31) + const shown = events.find((e) => e.type === "starter_shown") + expect(shown?.workspace_id).toBe(starter.workspaceId) + events.length = 0 + trackStarterActionPicked(starter, "prompt:0") + expect(events[0].workspace_id).toBe(shown.workspace_id) + } finally { + contentsSpy.mockRestore() + } + }) +}) From e52f10524112b06353005574ad694e31a9a23f7d Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 00:06:06 +0800 Subject: [PATCH 46/59] fix: address cubic review of the workspace telemetry rows - training saves pass the writer's session to the memory write-back - workspace tool-call errors also lose the user and host name - an untracked tool call reports false - `fetch failed` is a network sync failure Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../src/altimate/tools/training-import.ts | 1 + .../src/altimate/tools/training-save.ts | 1 + .../opencode/src/altimate/training/store.ts | 23 ++++++++------ .../src/altimate/workspace/sync-state.ts | 2 +- .../altimate/workspace/tool-call-telemetry.ts | 31 ++++++++++++++++--- .../altimate/training-store-session.test.ts | 25 +++++++++++++++ .../altimate/workspace/memory-sync.test.ts | 6 ++-- .../altimate/workspace/sync-state.test.ts | 1 + .../workspace/tool-call-telemetry.test.ts | 16 ++++++++++ 9 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/test/altimate/training-store-session.test.ts diff --git a/packages/opencode/src/altimate/tools/training-import.ts b/packages/opencode/src/altimate/tools/training-import.ts index 5cac31512b..39660c9a63 100644 --- a/packages/opencode/src/altimate/tools/training-import.ts +++ b/packages/opencode/src/altimate/tools/training-import.ts @@ -116,6 +116,7 @@ export const TrainingImportTool = Tool.define("training_import", { scope: args.scope, content: section.content.slice(0, 1800), // Enforce max content length source: args.file_path, + sessionID: ctx.sessionID, }) imported++ results.push(` + ${section.name}`) diff --git a/packages/opencode/src/altimate/tools/training-save.ts b/packages/opencode/src/altimate/tools/training-save.ts index dd0880005c..8e83b63c9d 100644 --- a/packages/opencode/src/altimate/tools/training-save.ts +++ b/packages/opencode/src/altimate/tools/training-save.ts @@ -98,6 +98,7 @@ export const TrainingSaveTool = Tool.define("training_save", { content: args.content, source: args.source, citations: args.citations, + sessionID: ctx.sessionID, }) // Build response with context diff --git a/packages/opencode/src/altimate/training/store.ts b/packages/opencode/src/altimate/training/store.ts index 8597465d85..599ffdf43c 100644 --- a/packages/opencode/src/altimate/training/store.ts +++ b/packages/opencode/src/altimate/training/store.ts @@ -34,6 +34,8 @@ export namespace TrainingStore { content: string source?: string citations?: MemoryBlock["citations"] + /** The writing session, for the workspace write-back's row. */ + sessionID?: string }): Promise<{ entry: TrainingEntry; duplicates: MemoryBlock[] }> { const id = trainingId(input.kind, input.name) const existing = await MemoryStore.read(input.scope, id) @@ -48,15 +50,18 @@ export namespace TrainingStore { const enriched = embedTrainingMeta(input.content, meta) - const { duplicates } = await MemoryStore.write({ - id, - scope: input.scope, - tags: trainingTags(input.kind), - created: existing?.created ?? now, - updated: now, - citations: input.citations, - content: enriched, - }) + const { duplicates } = await MemoryStore.write( + { + id, + scope: input.scope, + tags: trainingTags(input.kind), + created: existing?.created ?? now, + updated: now, + citations: input.citations, + content: enriched, + }, + { sessionID: input.sessionID }, + ) return { entry: { diff --git a/packages/opencode/src/altimate/workspace/sync-state.ts b/packages/opencode/src/altimate/workspace/sync-state.ts index c8dd5819dc..5af5b9d17e 100644 --- a/packages/opencode/src/altimate/workspace/sync-state.ts +++ b/packages/opencode/src/altimate/workspace/sync-state.ts @@ -281,7 +281,7 @@ export function syncFailureReason(error: string | undefined): "auth" | "not_foun const e = (error ?? "").toLowerCase() if (/\b(401|403)\b|unauthori[sz]ed|forbidden|\bsign(ed)? in\b|credential/.test(e)) return "auth" if (/\b404\b|not found|no longer exists|unknown workspace/.test(e)) return "not_found" - if (/timed? ?out|cannot reach|unreachable|econn|enotfound|network|socket/.test(e)) return "network" + if (/timed? ?out|cannot reach|unreachable|econn|enotfound|network|socket|fetch failed/.test(e)) return "network" return "other" } diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts index 7a6ecf66bc..d2657c8abf 100644 --- a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -1,7 +1,27 @@ // altimate_change — tool_call rows for the bound workspace's integration calls. import { Telemetry } from "@/altimate/telemetry" import { DATAMATE_KEY } from "@/altimate/datamate-transport" -import { redact } from "@/altimate/debug/report" +import os from "os" +import { redact, type RedactContext } from "@/altimate/debug/report" + +let redactCtx: RedactContext | undefined +/** The home folder, user name and host name to strip from error text; gathered once, never throws. */ +function redactContext(): RedactContext { + if (redactCtx) return redactCtx + const tryGet = (f: () => string) => { + try { + return f() + } catch { + return undefined + } + } + redactCtx = { + home: tryGet(() => os.homedir()), + username: tryGet(() => os.userInfo().username) ?? process.env["USER"] ?? process.env["USERNAME"], + hostname: tryGet(() => os.hostname()), + } + return redactCtx +} /** True when the MCP client serving a tool is the engine the workspace overlay manages. */ export function isWorkspaceClient(clientName: string): boolean { @@ -37,10 +57,10 @@ export function trackWorkspaceToolCall(input: { const error = input.error === undefined ? undefined - : redact(Telemetry.maskString(input.error instanceof Error ? input.error.message : String(input.error))).slice( - 0, - 500, - ) + : redact( + Telemetry.maskString(input.error instanceof Error ? input.error.message : String(input.error)), + redactContext(), + ).slice(0, 500) Telemetry.track({ type: "tool_call", timestamp: Date.now(), @@ -59,6 +79,7 @@ export function trackWorkspaceToolCall(input: { }) } catch { // telemetry never surfaces into the tool call + return false } return true } diff --git a/packages/opencode/test/altimate/training-store-session.test.ts b/packages/opencode/test/altimate/training-store-session.test.ts new file mode 100644 index 0000000000..feb1019397 --- /dev/null +++ b/packages/opencode/test/altimate/training-store-session.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { MemoryStore } from "../../src/memory" +import { TrainingStore } from "../../src/altimate/training" + +describe("TrainingStore.save", () => { + const spies: Array> = [] + afterEach(() => { + for (const s of spies.splice(0)) s.mockRestore() + }) + + test("passes the writing session to the memory write, for the workspace write-back's row", async () => { + spies.push(spyOn(MemoryStore, "read").mockResolvedValue(undefined as any)) + const write = spyOn(MemoryStore, "write").mockResolvedValue({ duplicates: [] }) + spies.push(write) + await TrainingStore.save({ + kind: "rule", + name: "naming", + scope: "project", + content: "Use snake_case.", + sessionID: "s-7", + }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][1]).toEqual({ sessionID: "s-7" }) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index 3747a18a4a..b1d20fa7c5 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -1753,8 +1753,10 @@ describe("backfill", () => { const result = await backfill(bound, BINDING as any) expect(result.ok).toBe(2) const contributed = events.filter((e) => e.type === "workspace_memory_contributed") - expect(contributed).toHaveLength(2) - expect(contributed[0]).toEqual(expect.objectContaining({ workspace_id: 42, block_count: 1 })) + expect(contributed).toEqual([ + expect.objectContaining({ workspace_id: 42, block_count: 1 }), + expect.objectContaining({ workspace_id: 42, block_count: 1 }), + ]) } finally { spy.mockRestore() } diff --git a/packages/opencode/test/altimate/workspace/sync-state.test.ts b/packages/opencode/test/altimate/workspace/sync-state.test.ts index 9796884a4d..1dfe37abb1 100644 --- a/packages/opencode/test/altimate/workspace/sync-state.test.ts +++ b/packages/opencode/test/altimate/workspace/sync-state.test.ts @@ -343,6 +343,7 @@ describe("workspace_sync_failed telemetry", () => { expect(SyncState.syncFailureReason("Please sign in again")).toBe("auth") expect(SyncState.syncFailureReason("Workspace not found (404)")).toBe("not_found") expect(SyncState.syncFailureReason("Request to x timed out after 15s")).toBe("network") + expect(SyncState.syncFailureReason("TypeError: fetch failed")).toBe("network") expect(SyncState.syncFailureReason("The server returned no usable id")).toBe("other") expect(SyncState.syncFailureReason(undefined)).toBe("other") }) diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts index fc8389f040..5b70de2d2d 100644 --- a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import os from "os" import { Telemetry } from "../../../src/altimate/telemetry" import { isWorkspaceClient, trackWorkspaceToolCall } from "../../../src/altimate/workspace/tool-call-telemetry" @@ -69,6 +70,21 @@ describe("workspace integration tool calls", () => { expect(rows[1].error).not.toContain("u:pw@") }) + test("a failed call's message loses the user name and host name too", () => { + const user = os.userInfo().username + const host = os.hostname() + expect(call("datamate", "error", `login failed for user=${user} on ${host}`)).toBe(true) + if (user.length > 2) expect(rows[0].error).not.toContain(`=${user}`) + if (host.length > 2) expect(rows[0].error).not.toContain(host) + }) + + test("a call whose row could not be tracked reports false", () => { + spy.mockImplementation(() => { + throw new Error("sink down") + }) + expect(call("datamate", "error", "boom")).toBe(false) + }) + test("the engine key in an unbound turn is not the workspace's; the turn's workspace, not the session's entry, names the row", () => { expect( trackWorkspaceToolCall({ From 5cd6d704acfc6027effde719750c0f9ba2f05bfc Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 21:19:40 +0800 Subject: [PATCH 47/59] fix: report no `workspace_api_error` for expected answers; 403 is not expired auth Callers declare expected statuses: a lookup's or unlink's 404, and a gone skill's owner 404, report no row. `/users/me` reports nothing. Only 401 is `workspace_auth_expired`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../src/altimate/workspace/api-client.ts | 35 +++++-- .../src/altimate/workspace/skill-publish.ts | 3 +- .../altimate/workspace/link-telemetry.test.ts | 96 +++++++++++++++++-- 3 files changed, 118 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 6de32e0e2d..373139bd37 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -178,8 +178,9 @@ async function creds(): Promise<{ url: string; instance: string; apiKey: string } // 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 +// `workspace_api_error` (or `workspace_auth_expired` on 401) with the endpoint masked of ids. A +// status the caller declares expected (a lookup's 404 is "not linked") is an answer, not a failure, +// and reports nothing; 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 @@ -195,7 +196,6 @@ const ROUTES: Record> = { [/^\/by-path$/, "/by-path"], ], "/datamates": [[/^\/?$/, "/"]], - "/users": [[/^\/me$/, "/me"]], "/skills": [ [/^\/[^/]+$/, "/:id"], [/^\/[^/]+\/datamates$/, "/:id/datamates"], @@ -229,7 +229,7 @@ function workspaceIdIn(body: unknown): number | undefined { return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : undefined } -function reportApiError(input: { +function reportApiErrorRow(input: { endpoint: string status?: number kind: "4xx" | "5xx" | "network" | "timeout" | "response" @@ -237,7 +237,9 @@ function reportApiError(input: { }) { const session_id = Telemetry.getContext().sessionId const ws = input.workspaceId !== undefined ? { workspace_id: input.workspaceId } : {} - if (input.status === 401 || input.status === 403) { + // Only a 401 says the session expired. A 403 is a refusal (a teammate's private workspace), so + // it is an API error with its status. + if (input.status === 401) { Telemetry.track({ type: "workspace_auth_expired", timestamp: Date.now(), @@ -358,6 +360,13 @@ async function req( * It still completes as the principal it captured, and the next call fails * at its own credential read. */ actAs?: { url: string; instance: string; apiKey: string } + // altimate_change start — telemetry: statuses that are answers for this caller + /** Non-2xx statuses this caller handles as an answer, not a failure (a lookup's 404 is "not + * linked"). They still throw the typed error, but report no `workspace_api_error`. */ + expectedStatuses?: readonly number[] + /** false for a call that is not a workspace call (`GET /users/me`): it reports no row at all. */ + reportErrors?: boolean + // altimate_change end } = {}, ): Promise { const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS @@ -367,6 +376,9 @@ async function req( const target = `${url}${basePath}${subpath}${qs}` const endpoint = endpointOf(method, basePath, subpath) const bodyWorkspaceId = workspaceIdIn(opts.body) + const reportApiError = (input: Parameters[0]) => { + if (opts.reportErrors !== false) reportApiErrorRow(input) + } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) let res: Response @@ -454,7 +466,7 @@ async function req( } } const detail = (json as { detail?: unknown } | undefined)?.detail - if (!res.ok) { + if (!res.ok && !opts.expectedStatuses?.includes(res.status)) { reportApiError({ endpoint, status: res.status, @@ -554,6 +566,7 @@ export namespace WorkspaceApi { try { return await req("GET", "/by-remote", { query: { repo_remote: remote }, + expectedStatuses: [404], ...(actAs ? { actAs } : {}), }) } catch (err) { @@ -568,6 +581,7 @@ export namespace WorkspaceApi { try { return await req("GET", "/by-path", { query: { project_path: projectPath }, + expectedStatuses: [404], ...(actAs ? { actAs } : {}), }) } catch (err) { @@ -618,7 +632,7 @@ export namespace WorkspaceApi { else if (id.projectPath) query.project_path = id.projectPath else return false try { - await req("DELETE", "/", { query, allowEmptyBody: true }) + await req("DELETE", "/", { query, allowEmptyBody: true, expectedStatuses: [404] }) Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), @@ -874,7 +888,12 @@ export namespace WorkspaceApi { * client must compare ownership — skill attachment requires the caller to * OWN the workspace, and the credentials carry no user id of their own. */ export async function whoami(actAs?: ActAs): Promise { - const me = await req<{ id?: unknown }>("GET", "/me", { base: "/users", ...(actAs ? { actAs } : {}) }) + // Not a workspace call: no workspace_api_error row for it. + const me = await req<{ id?: unknown }>("GET", "/me", { + base: "/users", + reportErrors: false, + ...(actAs ? { actAs } : {}), + }) const id = Number(me?.id) if (!Number.isInteger(id) || id <= 0) throw new WorkspaceApiError("The server did not say who this account is.") return id diff --git a/packages/opencode/src/altimate/workspace/skill-publish.ts b/packages/opencode/src/altimate/workspace/skill-publish.ts index 412f287f8c..098456f572 100644 --- a/packages/opencode/src/altimate/workspace/skill-publish.ts +++ b/packages/opencode/src/altimate/workspace/skill-publish.ts @@ -594,7 +594,8 @@ async function skillOwner(publicId: string): Promise { const detail = await altimateRequest<{ created_by?: unknown; skill?: { created_by?: unknown } }>( "GET", `/${encodeURIComponent(publicId)}`, - { base: SKILLS_BASE }, + // altimate_change - a 404 is the answer "the skill is gone", not a failed call + { base: SKILLS_BASE, expectedStatuses: [404] }, ) const raw = detail?.skill?.created_by ?? detail?.created_by return typeof raw === "number" ? raw : null diff --git a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts index 0809f1797c..9ff3d5ce29 100644 --- a/packages/opencode/test/altimate/workspace/link-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/link-telemetry.test.ts @@ -150,8 +150,14 @@ describe("workspace link telemetry", () => { replies = [{ status: 404, body: { detail: "no binding" } }] expect(await WorkspaceApi.unbindProject({ repoRemote: "git@x:y.git" })).toBe(false) expect(of("workspace_unlinked")).toHaveLength(0) + // "Already unlinked" is an answer, not a failed call. + expect(of("workspace_api_error")).toHaveLength(0) + + events = [] + replies = [{ status: 500, body: { detail: "boom" } }] + await expect(WorkspaceApi.unbindProject({ repoRemote: "git@x:y.git" })).rejects.toBeInstanceOf(WorkspaceApiError) expect(of("workspace_api_error")).toEqual([ - expect.objectContaining({ endpoint: "DELETE /datamate-project-bindings/", status: 404, status_class: "4xx" }), + expect.objectContaining({ endpoint: "DELETE /datamate-project-bindings/", status: 500, status_class: "5xx" }), ]) }) @@ -170,7 +176,19 @@ describe("workspace link telemetry", () => { 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 })]) + // A 403 is a refusal, not an expired session. + expect(of("workspace_auth_expired")).toHaveLength(0) + expect(of("workspace_api_error")).toEqual([ + expect.objectContaining({ status: 403, status_class: "4xx", workspace_id: 42 }), + ]) + + events = [] + replies = [{ status: 401, body: { detail: "expired" } }] + await expect(WorkspaceApi.bindExisting(42, { projectPath: "/p" })).rejects.toBeInstanceOf(WorkspaceApiError) + expect(of("workspace_link_failed")[0].reason).toBe("auth") + expect(of("workspace_auth_expired")).toEqual([ + expect.objectContaining({ endpoint: "POST /datamate-project-bindings/bind", status: 401, workspace_id: 42 }), + ]) expect(of("workspace_api_error")).toHaveLength(0) events = [] @@ -202,11 +220,10 @@ describe("workspace link telemetry", () => { }) 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. + replies = [{ status: 500, body: { detail: "x" } }] + await expect(WorkspaceApi.getBindingForRemote("git@x:y.git")).rejects.toBeInstanceOf(WorkspaceApiError) expect(of("workspace_link_failed")).toHaveLength(0) - expect(of("workspace_api_error")[0].endpoint).toMatch(/^GET \/datamate-project-bindings\//) + expect(of("workspace_api_error")[0].endpoint).toBe("GET /datamate-project-bindings/by-remote") events = [] replies = [{ hang: true }] @@ -238,7 +255,8 @@ describe("workspace link telemetry", () => { 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") + // Not a workspace router (whoami reports nothing), so not listed: masked whole. + expect(endpointOf("GET", "/users", "/me")).toBe("GET /:id/:id") 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", @@ -309,6 +327,70 @@ describe("workspace link telemetry", () => { expect(of("workspace_api_error")).toEqual([expect.objectContaining({ status_class: "response", status: 200 })]) }) + test("a not-linked lookup is an answer: a 404 from either lookup reports no row", async () => { + replies = [ + { status: 404, body: { detail: "no binding" } }, + { status: 404, body: { detail: "no binding" } }, + ] + await expect( + WorkspaceApi.getBindingForProject({ repoRemote: "git@x:y.git", projectPath: "/p" }), + ).resolves.toBeNull() + expect(requests.map((r) => new URL(r.url).pathname)).toEqual([ + "/datamate-project-bindings/by-remote", + "/datamate-project-bindings/by-path", + ]) + expect(events).toEqual([]) + }) + + test("a declared status reports nothing; any other failure of the same call still does", async () => { + replies = [{ status: 404, body: { detail: "gone" } }] + await expect( + altimateRequest("GET", "/x", { base: "/skills", expectedStatuses: [404] }), + ).rejects.toBeInstanceOf(NotFoundError) + expect(events).toEqual([]) + + replies = [{ status: 409, body: { detail: "taken" } }] + await expect( + altimateRequest("GET", "/x", { base: "/skills", expectedStatuses: [404] }), + ).rejects.toBeInstanceOf(ConflictError) + expect(of("workspace_api_error")).toEqual([expect.objectContaining({ status: 409, status_class: "4xx" })]) + }) + + test("whoami is not a workspace call: its failures report no row", async () => { + for (const status of [401, 403, 404, 500]) { + replies = [{ status, body: { detail: "x" } }] + await expect(WorkspaceApi.whoami()).rejects.toBeInstanceOf(Error) + } + replies = [{ throw: new Error("ECONNREFUSED") }] + await expect(WorkspaceApi.whoami()).rejects.toBeInstanceOf(WorkspaceApiError) + expect(events).toEqual([]) + }) + + test("the 409 -> rebind fallback reports the failed bind, then the switch (rows as they are today)", async () => { + // `link` with a failed pre-check: bindExisting answers 409, and the command retries as a rebind on + // the identifier the conflict names. Pins the rows this sequence emits; the recovered conflict + // still counts one link_failed (disclosed, follow-up). + replies = [ + { status: 409, body: { detail: { message: "already linked", project_path: "/p" } } }, + { status: 200, body: binding(42) }, + ] + await expect(WorkspaceApi.bindExisting(42, { projectPath: "/p" })).rejects.toBeInstanceOf(ConflictError) + await WorkspaceApi.rebindByPath({ projectPath: "/p", targetDatamateId: 42 }) + expect(events.map((e) => e.type)).toEqual([ + "workspace_api_error", + "workspace_link_failed", + "workspace_switched", + "workspace_linked", + ]) + expect(of("workspace_api_error")[0]).toEqual(expect.objectContaining({ status: 409, workspace_id: 42 })) + expect(of("workspace_link_failed")[0]).toEqual( + expect.objectContaining({ how: "bind_existing", reason: "conflict", workspace_id: 42 }), + ) + expect(of("workspace_switched")[0]).toEqual(expect.objectContaining({ to_workspace_id: 42, how: "rebind_path" })) + expect("from_workspace_id" in of("workspace_switched")[0]).toBe(false) + expect(of("workspace_linked")[0]).toEqual(expect.objectContaining({ workspace_id: 42, how: "rebind_path" })) + }) + test("linkFailureReason classifies every error the client throws", () => { expect(linkFailureReason(new ForbiddenError("f"))).toBe("auth") expect(linkFailureReason(new WorkspaceApiError("unauthorized", 401))).toBe("auth") From cd96a8a337b50ac9f2e038636135c79260bf712b Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 21:19:47 +0800 Subject: [PATCH 48/59] fix: drop the unread context workspace; `turn` on a failed tool row; typed knowledge `setContext` no longer holds a workspace id nobody reads. A failed workspace tool call's row carries its turn. `starter_shown.knowledge` is an enum. A test pins that the workspace rows are dropped when telemetry is off. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../opencode/src/altimate/telemetry/index.ts | 14 ++-- packages/opencode/src/session/prompt.ts | 4 +- .../telemetry/workspace-context.test.ts | 68 +++++++++++++++++-- .../workspace/tool-call-telemetry.test.ts | 4 +- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 28b157c84d..61e331d3d9 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -619,7 +619,7 @@ export namespace Telemetry { /** Absent when the starter could not tell (unsynced, unreadable, timed out). */ skills?: number integrations?: number - knowledge: string + knowledge: "off" | "all" | "selected" | "unknown" memory_enabled?: boolean } | { @@ -2071,10 +2071,6 @@ export namespace Telemetry { let machineId = "" let sessionId = "" let projectId = "" - // altimate_change — the workspace the current session's project is linked to (the backend - // datamate id), set per prompt turn from the local binding cache and read back through - // `getContext()` by callers that ask for it; nothing is stamped from it (see `track`). - let workspaceId: number | undefined // altimate_change start — the current turn token per session: `setContext` issues one, `endSession` // ends only its own, so a cancelled turn unwinding late cannot end the replacement's counters. // Tokens come from one process-wide counter, so a token is never reused. The map holds sessions @@ -2390,10 +2386,9 @@ export namespace Telemetry { // altimate_change end /** Returns the turn generation for the session; pass it to `endSession` so only this turn ends it. */ - export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }): number { + export function setContext(opts: { sessionId: string; projectId: string }): number { sessionId = opts.sessionId projectId = opts.projectId - workspaceId = opts.workspaceId // altimate_change start — a turn token for the session's counters and its end if (!opts.sessionId) return 0 const turn = ++nextTurn @@ -2402,8 +2397,8 @@ export namespace Telemetry { // altimate_change end } - export function getContext(): { sessionId: string; projectId: string; workspaceId?: number } { - return { sessionId, projectId, ...(workspaceId !== undefined ? { workspaceId } : {}) } + export function getContext(): { sessionId: string; projectId: string } { + return { sessionId, projectId } } /** Returns true only after init() has completed and telemetry is enabled. */ @@ -2771,7 +2766,6 @@ export namespace Telemetry { droppedEvents = 0 sessionId = "" projectId = "" - workspaceId = undefined machineId = "" // doInit() only assigns userEmail when Account.active() returns one, so without this a // logout followed by a re-init in the same process kept hashing the previous account into diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d6ed9463a3..ecedc18104 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -780,12 +780,11 @@ export namespace SessionPrompt { } // altimate_change end // altimate_change start — the workspace this project is linked to (local binding cache, no - // network) goes on the telemetry context, so every row of the turn carries workspace_id. + // network), named by the rows of this turn that carry workspace_id. const boundWorkspace = await readLocalBinding(Instance.directory).catch(() => null) const telemetryTurn = Telemetry.setContext({ sessionId: sessionID, projectId: Instance.project?.id ?? "", - workspaceId: boundWorkspace?.datamateId, }) // The turn's own workspace, named on the rows this loop emits at its end: a cancelled turn // unwinding after its replacement started must not take the replacement's entry. @@ -2992,6 +2991,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, messageID: ctx.messageID, workspaceId: input.workspaceId, + turn: input.turn, startedAt, outcome: "error", error: err, diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts index 69016cca41..85dc8ff8e0 100644 --- a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -84,9 +84,9 @@ describe("workspace id on the telemetry context", () => { }) test("a turn ends only its own counters; a stale turn's use does not count", () => { - const first = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) + const first = Telemetry.setContext({ sessionId: "s7", projectId: "p1" }) Telemetry.noteWorkspaceUse("s7", "skills") - const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) + const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1" }) expect(second).toBeGreaterThan(first) Telemetry.noteWorkspaceUse("s7", "memory", 2) // Counts are per turn: the cancelled turn's rows read its own, the replacement's its own. @@ -103,9 +103,9 @@ describe("workspace id on the telemetry context", () => { Telemetry.endSession("s7", second) expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) // Tokens never repeat, so a stale end can never hit a later turn. - const stale = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 5 }) + const stale = Telemetry.setContext({ sessionId: "s8", projectId: "p1" }) Telemetry.endSession("s8", stale) - const fresh = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 6 }) + const fresh = Telemetry.setContext({ sessionId: "s8", projectId: "p1" }) expect(fresh).toBeGreaterThan(stale) Telemetry.noteWorkspaceUse("s8", "skills") Telemetry.endSession("s8", stale) @@ -117,8 +117,9 @@ describe("workspace id on the telemetry context", () => { test("a row carries the workspace it names and nothing is stamped from the context", async () => { await Telemetry.shutdown() await Telemetry.init() - Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) - expect(Telemetry.getContext().workspaceId).toBe(42) + Telemetry.setContext({ sessionId: "s1", projectId: "p1" }) + // The context holds no workspace: a row names its own. + expect("workspaceId" in Telemetry.getContext()).toBe(false) Telemetry.track(startup("bound")) // names none: carries none, whatever the context holds Telemetry.track({ @@ -140,4 +141,59 @@ describe("workspace id on the telemetry context", () => { expect(unlinked.map((e) => e.data.baseData.properties.workspace_id)).toEqual([undefined, undefined]) Telemetry.endSession("s1") }) + + // Every event type the workspace rows added, one each. + const workspaceRows = (): Telemetry.Event[] => { + const base = { timestamp: Date.now(), session_id: "s1" } + return [ + { ...base, type: "workspace_linked", workspace_id: 1, how: "bind_existing" }, + { ...base, type: "workspace_created", workspace_id: 1, how: "create_and_bind" }, + { ...base, type: "workspace_link_failed", how: "bind_existing", reason: "auth" }, + { ...base, type: "workspace_unlinked", workspace_id: 1 }, + { ...base, type: "workspace_switched", to_workspace_id: 2, how: "rebind_remote" }, + { ...base, type: "workspace_auth_expired", endpoint: "GET /datamates/", status: 401 }, + { ...base, type: "workspace_api_error", endpoint: "GET /datamates/", status_class: "5xx", status: 500 }, + { ...base, type: "starter_shown", workspace_id: 1, knowledge: "off" }, + { ...base, type: "starter_action_picked", workspace_id: 1, action: "done" }, + { ...base, type: "workspace_memory_contributed", workspace_id: 1, block_count: 1 }, + { ...base, type: "workspace_sync_failed", workspace_id: 1, kind: "skills", reason: "network" }, + { ...base, type: "workspace_engine_attach", outcome: "attached", workspace_id: 1 }, + { ...base, type: "workspace_engine_install_prompt", action: "shown", reason: "engine-missing" }, + { + ...base, + type: "workspace_engine_precedence", + enabled: true, + shadowed_types: 0, + shadowed_connections: 0, + extensions: 0, + escape_hatch: false, + }, + { ...base, type: "workspace_engine_redirect", tool: "datamate", operation: "status", outcome: "refused" }, + { ...base, type: "workspace_engine_error", stage: "attach", message: "boom" }, + ] + } + + test("the workspace rows are sent when telemetry is on and dropped when it is off", async () => { + const names = workspaceRows().map((e) => e.type) + expect(new Set(names).size).toBe(16) // plus tool_call rows, which are not new types + + await Telemetry.shutdown() + await Telemetry.init() + for (const row of workspaceRows()) Telemetry.track(row) + await Telemetry.flush() + const sent = new Set(envelopes().map((e) => e.data.baseData.name)) + for (const name of names) expect(sent.has(name)).toBe(true) + + for (const flag of DISABLE_FLAGS) { + posts = [] + await Telemetry.shutdown() + process.env[flag] = "1" + await Telemetry.init() + expect(Telemetry.isEnabled()).toBe(false) + for (const row of workspaceRows()) Telemetry.track(row) + await Telemetry.flush() + expect(posts).toEqual([]) + delete process.env[flag] + } + }) }) diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts index 5b70de2d2d..f4d38eb31c 100644 --- a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -98,8 +98,8 @@ describe("workspace integration tool calls", () => { }), ).toBe(false) expect(rows).toHaveLength(0) - // The session's current entry says 7 (a replacement turn), but this call ran for turn 42. - Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 7 }) + // A replacement turn is current for the session, but this call ran for workspace 42. + Telemetry.setContext({ sessionId: "s1", projectId: "p1" }) expect(call("datamate", "success")).toBe(true) expect(rows[0].workspace_id).toBe(42) Telemetry.setContext({ sessionId: "", projectId: "" }) From a9b9f7a84c67464c395311f8f71f3bb99fb93437 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 21:19:52 +0800 Subject: [PATCH 49/59] docs: list the workspace telemetry events A row per new workspace event, the `client` property, `workspace_id` on existing rows, workspace `tool_call` rows, the `workspace` skill source, and how `endpoint` is masked. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- docs/docs/reference/telemetry.md | 33 +++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 0f32d31dcd..5836aabaab 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -37,7 +37,7 @@ We collect the following categories of events: | `error_recovered` | Successful recovery from a transient error (error type, strategy, attempt count) | | `mcp_server_census` | MCP server capabilities after connect (tool and resource counts, but no tool names) | | `context_overflow_recovered` | Context overflow is handled (strategy) | -| `skill_used` | A skill is loaded (skill name, source — `builtin`, `global`, or `project`, and trigger — `user`, `auto`, or `suggestion` — no skill content) | +| `skill_used` | A skill is loaded (skill name, source — `builtin`, `global`, `project`, or `workspace`, and trigger — `user`, `auto`, or `suggestion` — no skill content) | | `plan_revision` | A plan revision occurs in Plan mode (revision_number, action: `refine`, `approve`, `reject`, or `cap_reached`) | | `feature_suggestion` | A post-connection feature suggestion is shown (suggestion_type, suggestions_shown, warehouse_type — no user input) | | `sql_execute_failure` | A SQL execution fails (warehouse type, query type, error message, PII-masked SQL — no raw values) | @@ -76,6 +76,37 @@ We collect the following categories of events: Each event includes a timestamp, anonymous session ID, a per-launch correlation ID (`launch_id` — a random value regenerated every process start, not persisted and not derived from your machine or identity; it exists only to group events from the same run), CLI version, and an anonymous machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and never tied to any personal information). +### Workspace events + +These events describe projects linked to an Altimate workspace. A workspace is identified only by its numeric id (`workspace_id`, or `to_workspace_id` and `from_workspace_id` on a switch). No workspace name, project path, repository remote, skill content, memory content or prompt text is sent. + +| Event | Description | +|-------|-------------| +| `workspace_linked` | A project was linked to a workspace and the server confirmed it. `how` is `bind_existing`, `create_and_bind`, `rebind_remote` or `rebind_path`. | +| `workspace_created` | A workspace was created from the CLI. `how` is `create_and_bind`, or `create_unbound` when the link follows as a separate step. | +| `workspace_link_failed` | A link step failed. `how` names the step, and `reason` is a class: `auth`, `not_found`, `network`, `conflict` or `other`. No error text. | +| `workspace_unlinked` | A project was unlinked, with the workspace id when the CLI knew it. | +| `workspace_switched` | A linked project was moved to another workspace. `how` is `rebind_remote` or `rebind_path`. | +| `workspace_auth_expired` | A workspace call was answered 401: the sign-in has expired. Carries `endpoint` and `status`. | +| `workspace_api_error` | A workspace call failed. `status_class` is `4xx`, `5xx`, `network`, `timeout`, or `response` (a success status with an empty or unreadable body), plus the HTTP `status` when there was one. A 403 is reported here, not as expired auth. A status the caller handles as an answer is not a failure and is not reported, such as a 404 from the link lookups ("not linked") or from an unlink ("already unlinked"). `GET /users/me` is not a workspace call and is not reported. | +| `starter_shown` | The "what this workspace provides" starter was served after a link: to the TUI, to the IDE extension's chat, or printed by `altimate-code link`. It counts the starter being served, so a client that closes before rendering it is still counted. Contains the counts of skills and integrations when known, `knowledge` (`off`, `all`, `selected` or `unknown`) and `memory_enabled`. | +| `starter_action_picked` | The user chose an option from the TUI starter: `action` is `prompt`, `open` or `done`, with `prompt_index` (its position) for a suggested prompt. Never the prompt's text. | +| `workspace_memory_contributed` | A memory block was written back to the workspace's shared memory, including the existing blocks seeded when a project is linked. `block_count` is per write. No memory content. | +| `workspace_sync_failed` | Syncing the workspace's skills or memory to this project changed to failing. `kind` is `skills` or `memory`, and `reason` is a class. Sent once per change, not on every retry. | +| `workspace_engine_attach` | A session's connection to the workspace integration engine settled on an outcome (`attached`, `engine_missing`, `engine_too_old`, `connect_failed`, `disabled` or `unbound`), with the engine version and counts of integrations. Sent when the outcome changes, not every turn. | +| `workspace_engine_install_prompt` | The offer to install or update the engine: `action` is `shown`, `accepted`, `installed`, `install_failed`, `copied` or `declined`, with the `reason` and whether Node.js was found and its major version. | +| `workspace_engine_precedence` | Whether the workspace's integrations take precedence over the project's own connections, with counts of shadowed connection types and connections. Sent when the state changes. | +| `workspace_engine_redirect` | A `datamate_manager` call was refused because the project is linked to a workspace. Contains the tool and operation names. | +| `workspace_engine_error` | The engine failed at a stage (`attach`, `install` or `precedence`). `message` is masked (paths, credentials and quoted strings are replaced) and capped at 200 characters. | + +Other rows gain workspace fields in a linked project: + +- Every event carries `client: "altimate-code"`, which names the product that sent it. `source` still says how the CLI was launched. +- Rows of a turn in a linked project carry that project's `workspace_id`: `session_start`, `session_end`, `first_prompt_sent`, `first_job_completed`, `skill_used`, `skill_published`, `memory_injection`, `agent_outcome`, `task_classified`, `task_outcome_signal`, `tool_chain_outcome`, `core_failure`, `error_fingerprint`, `plan_revision`, `sql_quality` and `tool_call`. +- `session_end` counts the workspace skills, memory blocks and integration calls the session used. `memory_injection` counts the workspace's blocks. `task_outcome_signal` says whether workspace context was used. +- A call to a workspace integration through the engine is reported as a `tool_call` with `tool_type: "mcp"` and `via_workspace: true`. As for other tools, there are no arguments or outputs, and an error message is masked and capped at 500 characters. +- An `endpoint` value is a route label with every id and path segment replaced by `:id`, for example `POST /datamate-project-bindings/bind`. It never contains an id, a file path or a query string. + ### Notes on the review events - `degraded` is a fidelity flag, not a warehouse flag. It is set when a review found no reviewable From 4016f59820b1ec889fabbb3e07ca95371bf9009e Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 21:17:42 +0800 Subject: [PATCH 50/59] fix: read the config telemetry opt-out without an Instance `TelemetryConfig.disabledState()` reads `telemetry.disabled` from the config files and says disabled, enabled or unreadable. Shared with the opt-out PR; files as of its head 49a3dbe09a. Its init changes stay there. (cherry picked from commit f7512552bd9d4e24a4bd04b042846fb2a87bb296) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../src/altimate/telemetry/config-state.ts | 126 ++++++++++++++++++ .../altimate/telemetry/config-state.test.ts | 80 +++++++++++ 2 files changed, 206 insertions(+) create mode 100644 packages/opencode/src/altimate/telemetry/config-state.ts create mode 100644 packages/opencode/test/altimate/telemetry/config-state.test.ts diff --git a/packages/opencode/src/altimate/telemetry/config-state.ts b/packages/opencode/src/altimate/telemetry/config-state.ts new file mode 100644 index 0000000000..ab89fd6186 --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/config-state.ts @@ -0,0 +1,126 @@ +import path from "path" +import fs from "fs/promises" +import { existsSync } from "fs" +import * as Effect from "effect/Effect" +import { Flag, env as flagEnv } from "@opencode-ai/core/flag/flag" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "../../global" +import { ConfigPaths } from "@/config/paths" +import { ConfigManaged } from "@/config/managed" + +/** + * Whether the user config turns telemetry off, read without an Instance. + * + * `Config.get()` needs an Instance, and the CLI's main thread initialises telemetry in middleware + * before one exists, so there it throws. Treating that as "enabled" let an opted-out user's main + * thread clear the opt-out marker and resolve their identity. This reads the same files `Config` + * merges, in the same order (a later source overrides an earlier one), straight from disk: + * the global config files (the legacy TOML `config` file included), `OPENCODE_CONFIG`, the project files and `.altimate-code`/`.opencode` + * directories found from `directory`, the inline `OPENCODE_CONFIG_CONTENT`, the managed config + * directory and macOS managed preferences. Remote and account configs are not read (they need + * the network). + * + * - `disabled`: the last source that sets `telemetry.disabled` sets it truthy. + * - `enabled`: no source turns it off. + * - `unreadable`: a source that could not be read or parsed comes after the last one that set + * the value, so the answer is unknown. Callers fail closed for anything an opt-out forbids. + */ +export type State = "disabled" | "enabled" | "unreadable" + +const DIR_FILES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc"] +const GLOBAL_FILES = ["config.json", "opencode.json", "opencode.jsonc", "altimate-code.json", "altimate-code.jsonc"] + +/** The git root above `directory`, which `Config` uses as the stop for its upward search. */ +function worktreeOf(directory: string): string | undefined { + let dir = path.resolve(directory) + while (true) { + if (existsSync(path.join(dir, ".git"))) return dir + const parent = path.dirname(dir) + if (parent === dir) return undefined + dir = parent + } +} + +export async function disabledState(directory: string = process.cwd()): Promise { + try { + let value: boolean | undefined + let unknown = false + const apply = (data: unknown) => { + const disabled = (data as { telemetry?: { disabled?: unknown } } | undefined)?.telemetry?.disabled + if (disabled === undefined) return + // Config's schema accepts only a boolean; anything else makes the whole config invalid there. + if (typeof disabled !== "boolean") { + unknown = true + return + } + value = disabled + unknown = false + } + const readFile = async (file: string) => { + let text: string + try { + text = await fs.readFile(file, "utf-8") + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") unknown = true + return + } + try { + apply(await ConfigPaths.parseText(text, file, "empty")) + } catch { + unknown = true + } + } + const readText = async (text: string, source: string, dir: string) => { + try { + apply(await ConfigPaths.parseText(text, { source, dir }, "empty")) + } catch { + unknown = true + } + } + + for (const file of GLOBAL_FILES) await readFile(path.join(Global.Path.config, file)) + // The legacy TOML global config, which Config merges after the JSON files (and migrates). + const legacy = path.join(Global.Path.config, "config") + try { + apply(Bun.TOML.parse(await fs.readFile(legacy, "utf-8"))) + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") unknown = true + } + if (Flag.OPENCODE_CONFIG) await readFile(Flag.OPENCODE_CONFIG) + + const worktree = worktreeOf(directory) + const located = await Effect.runPromise( + Effect.all([ + Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? Effect.succeed([] as string[]) + : ConfigPaths.files("opencode", directory, worktree), + ConfigPaths.directories(directory, worktree), + ]).pipe(Effect.provide(FSUtil.defaultLayer)), + ) + const [projectFiles, directories] = located + for (const file of projectFiles) await readFile(file) + for (const dir of directories) { + if (dir.endsWith(".altimate-code") || dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { + for (const file of DIR_FILES) await readFile(path.join(dir, file)) + } + } + + const inline = flagEnv("OPENCODE_CONFIG_CONTENT") + if (inline) await readText(inline, "OPENCODE_CONFIG_CONTENT", directory) + + const managedDir = ConfigManaged.managedConfigDir() + if (existsSync(managedDir)) for (const file of DIR_FILES) await readFile(path.join(managedDir, file)) + const managed = await ConfigManaged.readManagedPreferences().catch(() => { + unknown = true + return undefined + }) + if (managed) await readText(managed.text, managed.source, path.dirname(managed.source)) + + if (unknown) return "unreadable" + return value ? "disabled" : "enabled" + } catch { + return "unreadable" + } +} + +export * as TelemetryConfig from "./config-state" diff --git a/packages/opencode/test/altimate/telemetry/config-state.test.ts b/packages/opencode/test/altimate/telemetry/config-state.test.ts new file mode 100644 index 0000000000..9137b47f79 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/config-state.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs" +import os from "os" +import path from "path" +import { Global } from "../../../src/global" +import { TelemetryConfig } from "../../../src/altimate/telemetry/config-state" + +describe("TelemetryConfig.disabledState (no Instance)", () => { + let dir: string + let savedContent: string | undefined + // The preload points the global config at a per-run temp directory; other suites may have + // seeded these files there, so each test puts back what it found. + const globalFile = () => path.join(Global.Path.config, "altimate-code.json") + const legacyFile = () => path.join(Global.Path.config, "config") + const savedFiles = new Map() + + beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "telemetry-config-state-")) + savedContent = process.env.OPENCODE_CONFIG_CONTENT + delete process.env.OPENCODE_CONFIG_CONTENT + for (const file of [globalFile(), legacyFile()]) { + savedFiles.set(file, existsSync(file) ? readFileSync(file, "utf-8") : undefined) + rmSync(file, { force: true }) + } + }) + afterEach(() => { + if (savedContent === undefined) delete process.env.OPENCODE_CONFIG_CONTENT + else process.env.OPENCODE_CONFIG_CONTENT = savedContent + for (const [file, text] of savedFiles) { + if (text === undefined) rmSync(file, { force: true }) + else writeFileSync(file, text) + } + rmSync(dir, { recursive: true, force: true }) + }) + + test("nothing sets it: enabled", async () => { + expect(await TelemetryConfig.disabledState(dir)).toBe("enabled") + }) + + test("the global config turns it off", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } })) + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + }) + + test("the legacy TOML global config turns it off, and a broken one is unreadable", async () => { + writeFileSync(legacyFile(), "[telemetry]\ndisabled = true\n") + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + writeFileSync(legacyFile(), "[telemetry\ndisabled = \n") + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + + test("a value Config's schema would reject is unreadable, not enabled", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: 0 } })) + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + + test("a project's .altimate-code directory turns it off", async () => { + mkdirSync(path.join(dir, ".altimate-code")) + writeFileSync(path.join(dir, ".altimate-code", "altimate-code.json"), '{ "telemetry": { "disabled": true } }') + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + }) + + test("a later source overrides an earlier one, as in Config", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } })) + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: false } }) + expect(await TelemetryConfig.disabledState(dir)).toBe("enabled") + }) + + test("an unparseable source after the last setting makes it unreadable", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: false } })) + process.env.OPENCODE_CONFIG_CONTENT = "{ not json" + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + + test("a later source that sets the value settles an earlier unreadable one", async () => { + writeFileSync(globalFile(), "{ not json") + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: true } }) + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + }) +}) From 93b3aac3ae43ff4086a05f811449bc83900c8e74 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 23:35:03 +0800 Subject: [PATCH 51/59] fix: no `machine_id` in `cli_context` when the config opts out or cannot be read Without an Instance, the sign-in context reads `telemetry.disabled` from the config files. An opt-out or unreadable files omit the id. Only a readable enabled config follows the telemetry state. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../opencode/src/altimate/plugin/altimate.ts | 25 ++++++---- .../test/altimate/altimate-plugin.test.ts | 50 +++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index 34e2b25589..5a984f7753 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -6,6 +6,7 @@ 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" +import { TelemetryConfig } from "../telemetry/config-state" // altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) import { getOrCreateMachineId } from "../util/machine-id" import { Config } from "@/config/config" @@ -92,17 +93,21 @@ export async function buildCliContext(machineIdPath?: string): Promise { // Instance.provide() (AsyncLocalStorage-propagated across awaits), so // 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", { + // create-and-link hand-off of `altimate-code link`. Read `telemetry.disabled` + // from the config files directly: an opt-out there, or files that cannot be + // read (an opt-out may be behind them), omit the id. Only a config that + // readably leaves telemetry on defers to the telemetry module's own state + // (opted out by env, no sink, test runner). + const state = await TelemetryConfig.disabledState() + if (state === "enabled") { + await Telemetry.init().catch(() => {}) + disabled = !Telemetry.isEnabled() + } else { + disabled = true + } + log.info("cli_context: config unavailable, read the opt-out from the files", { code: (err as NodeJS.ErrnoException)?.code, + state, enabled: !disabled, }) } diff --git a/packages/opencode/test/altimate/altimate-plugin.test.ts b/packages/opencode/test/altimate/altimate-plugin.test.ts index 36b003d5d2..7a1af64dfb 100644 --- a/packages/opencode/test/altimate/altimate-plugin.test.ts +++ b/packages/opencode/test/altimate/altimate-plugin.test.ts @@ -166,6 +166,56 @@ describe("buildCliContext", () => { } }) + // Config.get() throws (no Instance) while telemetry is ON for the process: the context reads the + // opt-out from the config files itself, and fails closed when it cannot read them. + describe.each([ + ["a config opt-out", JSON.stringify({ telemetry: { disabled: true } })], + ["an unreadable config", "{ not json"], + ])("Config.get() throws and the files hold %s", (_label, content) => { + test("omits machine_id even though 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 saved = { + cs: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING, + opencodeOptOut: process.env.OPENCODE_DISABLE_TELEMETRY, + content: process.env.OPENCODE_CONFIG_CONTENT, + } + 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) + process.env.OPENCODE_CONFIG_CONTENT = content + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + expect(Object.prototype.hasOwnProperty.call(ctx, "machine_id")).toBe(false) + expect(ctx["v"]).toBe(1) + } finally { + await Telemetry.shutdown() + net.mockRestore() + home.mockRestore() + for (const [key, value] of [ + ["APPLICATIONINSIGHTS_CONNECTION_STRING", saved.cs], + ["OPENCODE_DISABLE_TELEMETRY", saved.opencodeOptOut], + ["OPENCODE_CONFIG_CONTENT", saved.content], + ] as const) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + }) + test("includes machine_id when telemetry is enabled", async () => { delete process.env.ALTIMATE_TELEMETRY_DISABLED await using dir = await tmpdir() From 09088682e49acd5fa9506ef6a11ee165f537f305 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 27 Oct 2026 23:38:13 +0800 Subject: [PATCH 52/59] chore: keep the opt-out reader import apart from the shared telemetry import The opt-out PR adds the same `Telemetry` import line; a neighbouring addition there turned that identical change into a merge conflict. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- packages/opencode/src/altimate/plugin/altimate.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index 5a984f7753..c366143ecb 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -6,13 +6,14 @@ 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" -import { TelemetryConfig } from "../telemetry/config-state" // altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) import { getOrCreateMachineId } from "../util/machine-id" import { Config } from "@/config/config" import { Flag } from "@/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Log } from "@/altimate/util/log" +// altimate_change — reads the config telemetry opt-out without an Instance +import { TelemetryConfig } from "../telemetry/config-state" /** * Why a failure reason is attached at the rejection site rather than inferred from the message: From 81cab6d297a043d511709a791c54b199da4afbd0 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 28 Oct 2026 00:15:22 +0800 Subject: [PATCH 53/59] fix: read the sign-in opt-out from the linked project's config `buildCliContext` takes the project directory, and the workspace hand-off passes the linked project's path. A project-level opt-out then holds when the command starts elsewhere. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- .../opencode/src/altimate/plugin/altimate.ts | 6 ++- .../src/altimate/workspace/browser-handoff.ts | 3 +- .../test/altimate/altimate-plugin.test.ts | 50 +++++++++++++++++++ .../workspace/browser-handoff.test.ts | 22 +++++++- 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index c366143ecb..471f604d63 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -77,7 +77,9 @@ const log = Log.create({ service: "altimate-plugin" }) // and `cli_version` are strings, treat `cli_version: "local"` as a valid dev // build, and treat the payload as untrusted (anyone can craft a URL). An absent // `machine_id` means "do not attribute" — it must never be aliased on. -export async function buildCliContext(machineIdPath?: string): Promise { +/** `directory`: the project the command acts on, whose config may opt out; the process's working + * directory when the command has none (`auth login`). */ +export async function buildCliContext(machineIdPath?: string, directory?: string): Promise { // altimate_change start — honour both telemetry opt-out gates, mirroring // telemetry/index.ts::doInit: // 1. ALTIMATE_TELEMETRY_DISABLED / OPENCODE_DISABLE_TELEMETRY env vars @@ -99,7 +101,7 @@ export async function buildCliContext(machineIdPath?: string): Promise { // read (an opt-out may be behind them), omit the id. Only a config that // readably leaves telemetry on defers to the telemetry module's own state // (opted out by env, no sink, test runner). - const state = await TelemetryConfig.disabledState() + const state = await TelemetryConfig.disabledState(directory) if (state === "enabled") { await Telemetry.init().catch(() => {}) disabled = !Telemetry.isEnabled() diff --git a/packages/opencode/src/altimate/workspace/browser-handoff.ts b/packages/opencode/src/altimate/workspace/browser-handoff.ts index 1f9cb6468d..65a0c7a8ec 100644 --- a/packages/opencode/src/altimate/workspace/browser-handoff.ts +++ b/packages/opencode/src/altimate/workspace/browser-handoff.ts @@ -556,7 +556,8 @@ export async function runHandoffWithOpener( // Import buildCliContext lazily so this module doesn't pull altimate.ts // into every consumer's import graph at load time. const { buildCliContext } = await import("../plugin/altimate") - const cliContext = await buildCliContext().catch((err) => { + // The linked project's config decides the opt-out, not the directory the command started in. + const cliContext = await buildCliContext(undefined, input.identifier.projectPath).catch((err) => { log.warn("buildCliContext failed; proceeding without", { err: String(err) }) return "" }) diff --git a/packages/opencode/test/altimate/altimate-plugin.test.ts b/packages/opencode/test/altimate/altimate-plugin.test.ts index 7a1af64dfb..3993fce4f2 100644 --- a/packages/opencode/test/altimate/altimate-plugin.test.ts +++ b/packages/opencode/test/altimate/altimate-plugin.test.ts @@ -216,6 +216,56 @@ describe("buildCliContext", () => { }) }) + test("Config.get() throws: the opt-out of the project the command acts on is read, not the cwd's", async () => { + delete process.env.ALTIMATE_TELEMETRY_DISABLED + cfg.mockImplementation((async () => { + throw new Error("InstanceRef not provided") + }) as never) + await using dir = await tmpdir() + await using project = await tmpdir() + fs.mkdirSync(path.join(project.path, ".altimate-code")) + fs.writeFileSync( + path.join(project.path, ".altimate-code", "altimate-code.json"), + JSON.stringify({ telemetry: { disabled: true } }), + ) + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, VALID_UUID, "utf8") + const saved = { + cs: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING, + opencodeOptOut: process.env.OPENCODE_DISABLE_TELEMETRY, + content: process.env.OPENCODE_CONFIG_CONTENT, + } + delete process.env.OPENCODE_DISABLE_TELEMETRY + delete process.env.OPENCODE_CONFIG_CONTENT + 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) + const decode = (encoded: string) => + JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + try { + await Telemetry.shutdown() + await Telemetry.init() + expect(Telemetry.isEnabled()).toBe(true) + // The opted-out project: no id. + expect("machine_id" in decode(await buildCliContext(idPath, project.path))).toBe(false) + // The same process from a directory with no opt-out: the id is sent. + expect(decode(await buildCliContext(idPath, dir.path))["machine_id"]).toBe(VALID_UUID) + } finally { + await Telemetry.shutdown() + net.mockRestore() + home.mockRestore() + for (const [key, value] of [ + ["APPLICATIONINSIGHTS_CONNECTION_STRING", saved.cs], + ["OPENCODE_DISABLE_TELEMETRY", saved.opencodeOptOut], + ["OPENCODE_CONFIG_CONTENT", saved.content], + ] as const) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + 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/workspace/browser-handoff.test.ts b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts index c3df0d561a..d5dc0314e8 100644 --- a/packages/opencode/test/altimate/workspace/browser-handoff.test.ts +++ b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts @@ -7,10 +7,11 @@ // launching a real browser. The listener itself binds to 127.0.0.1, walks // 7317..7325, and processes real HTTP requests — this is genuine end-to-end // coverage for the callback validation path. -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { createServer, connect } from "node:net" import { AltimateApi } from "../../../src/altimate/api/client" +import * as AltimatePlugin from "../../../src/altimate/plugin/altimate" import { buildManageUrl, openWorkspaceBrowserHandoff, @@ -344,6 +345,25 @@ describe("runHandoffWithOpener end-to-end", () => { } }) + test("cli_context is built for the linked project's directory, so its config opt-out applies", async () => { + const spy = spyOn(AltimatePlugin, "buildCliContext").mockImplementation(async () => "ctx") + try { + let observed = "" + await runHandoffWithOpener( + { identifier: { repoRemote: "git@github.com:acme/foo.git", projectPath: "/w/foo" }, projectName: "foo" }, + async (url) => { + observed = url + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { workspace_id: "1", state, tenant: "acme" }) + }, + ) + expect(spy).toHaveBeenCalledWith(undefined, "/w/foo") + expect(new URLSearchParams(new URL(observed).hash.replace(/^#/, "")).get("cli_context")).toBe("ctx") + } finally { + spy.mockRestore() + } + }) + test("URL keeps project_name, project_remote, project_path out of the query", async () => { // Per m6 in the consensus review: project_path + project_remote MUST NOT // be sent as query params (they'd land in browser history, SaaS/CDN/WAF From 5f2733b9c980380613a2e90451a0e5328887b002 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 28 Oct 2026 00:19:49 +0800 Subject: [PATCH 54/59] fix: the config opt-out probe emits no telemetry of its own The probe parses with `track: false`, so reading a config that uses env interpolation queues no `config_env_interpolation` row. Its tests set aside every global config source and both inline-config variables. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X (cherry picked from commit 12b9f6ec3d3f9863372c763d141ceeab1f7be3b6) --- .../src/altimate/telemetry/config-state.ts | 15 +++--- packages/opencode/src/config/paths.ts | 16 +++++-- .../altimate/telemetry/config-state.test.ts | 46 +++++++++++++++---- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/config-state.ts b/packages/opencode/src/altimate/telemetry/config-state.ts index ab89fd6186..b24050d88f 100644 --- a/packages/opencode/src/altimate/telemetry/config-state.ts +++ b/packages/opencode/src/altimate/telemetry/config-state.ts @@ -15,12 +15,13 @@ import { ConfigManaged } from "@/config/managed" * before one exists, so there it throws. Treating that as "enabled" let an opted-out user's main * thread clear the opt-out marker and resolve their identity. This reads the same files `Config` * merges, in the same order (a later source overrides an earlier one), straight from disk: - * the global config files (the legacy TOML `config` file included), `OPENCODE_CONFIG`, the project files and `.altimate-code`/`.opencode` - * directories found from `directory`, the inline `OPENCODE_CONFIG_CONTENT`, the managed config - * directory and macOS managed preferences. Remote and account configs are not read (they need - * the network). + * the global config files (the legacy TOML `config` file included), `OPENCODE_CONFIG`, the project + * files and `.altimate-code`/`.opencode` directories found from `directory`, the inline + * `OPENCODE_CONFIG_CONTENT`, the managed config directory and macOS managed preferences. Remote and + * account configs are not read (they need the network). The read emits no telemetry of its own: it + * runs before telemetry knows whether it may. * - * - `disabled`: the last source that sets `telemetry.disabled` sets it truthy. + * - `disabled`: the last source that sets `telemetry.disabled` sets it to `true`. * - `enabled`: no source turns it off. * - `unreadable`: a source that could not be read or parsed comes after the last one that set * the value, so the answer is unknown. Callers fail closed for anything an opt-out forbids. @@ -65,14 +66,14 @@ export async function disabledState(directory: string = process.cwd()): Promise< return } try { - apply(await ConfigPaths.parseText(text, file, "empty")) + apply(await ConfigPaths.parseText(text, file, "empty", { track: false })) } catch { unknown = true } } const readText = async (text: string, source: string, dir: string) => { try { - apply(await ConfigPaths.parseText(text, { source, dir }, "empty")) + apply(await ConfigPaths.parseText(text, { source, dir }, "empty", { track: false })) } catch { unknown = true } diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 2a0f8dc550..9c979ef90c 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -142,7 +142,7 @@ function dir(input: ParseSource) { } /** Apply ${VAR}/${VAR:-default}/$${VAR}/{env:VAR} and {file:path} substitutions to config text. */ -async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") { +async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error", track = true) { // Single-pass substitution against the ORIGINAL text prevents output of one // pattern being re-matched by another (e.g. {env:A}="${B}" expanding B). // Uses the shared ENV_VAR_PATTERN grammar, adding JSON-escaping for values @@ -186,7 +186,7 @@ async function substitute(text: string, input: ParseSource, missing: "error" | " // the telemetry schema). Dynamic import avoids a circular dep with @/altimate/telemetry (which // imports @/config/config). Matches main's single emit site in substitute(). const { dollarRefs, dollarUnresolved, dollarDefaulted, dollarEscaped, legacyBraceRefs, legacyBraceUnresolved } = stats - if (dollarRefs > 0 || legacyBraceRefs > 0 || dollarEscaped > 0) { + if (track && (dollarRefs > 0 || legacyBraceRefs > 0 || dollarEscaped > 0)) { import("@/altimate/telemetry") .then(({ Telemetry }) => { Telemetry.track({ @@ -258,10 +258,16 @@ async function substitute(text: string, input: ParseSource, missing: "error" | " return out } -/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */ -export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error"): Promise { +/** Substitute and parse JSONC text, throwing JsonError on syntax errors. `track: false` skips the + * interpolation telemetry row, for a read that must not emit telemetry (the opt-out probe). */ +export async function parseText( + text: string, + input: ParseSource, + missing: "error" | "empty" = "error", + opts: { track?: boolean } = {}, +): Promise { const configSource = source(input) - text = await substitute(text, input, missing) + text = await substitute(text, input, missing, opts.track !== false) const errors: JsoncParseError[] = [] const data = parseJsonc(text, errors, { allowTrailingComma: true }) diff --git a/packages/opencode/test/altimate/telemetry/config-state.test.ts b/packages/opencode/test/altimate/telemetry/config-state.test.ts index 9137b47f79..42010f8f46 100644 --- a/packages/opencode/test/altimate/telemetry/config-state.test.ts +++ b/packages/opencode/test/altimate/telemetry/config-state.test.ts @@ -1,31 +1,47 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs" import os from "os" import path from "path" import { Global } from "../../../src/global" import { TelemetryConfig } from "../../../src/altimate/telemetry/config-state" +import { Telemetry } from "../../../src/altimate/telemetry" describe("TelemetryConfig.disabledState (no Instance)", () => { let dir: string - let savedContent: string | undefined // The preload points the global config at a per-run temp directory; other suites may have - // seeded these files there, so each test puts back what it found. + // seeded files there, so every global source the reader takes is set aside and put back. + const GLOBAL_SOURCES = [ + "config.json", + "opencode.json", + "opencode.jsonc", + "altimate-code.json", + "altimate-code.jsonc", + "config", + ] + // Both names of the inline config (the documented alias is read first). + const INLINE_ENV = ["ALTIMATE_CLI_CONFIG_CONTENT", "OPENCODE_CONFIG_CONTENT"] const globalFile = () => path.join(Global.Path.config, "altimate-code.json") const legacyFile = () => path.join(Global.Path.config, "config") const savedFiles = new Map() + const savedEnv = new Map() beforeEach(() => { dir = mkdtempSync(path.join(os.tmpdir(), "telemetry-config-state-")) - savedContent = process.env.OPENCODE_CONFIG_CONTENT - delete process.env.OPENCODE_CONFIG_CONTENT - for (const file of [globalFile(), legacyFile()]) { + for (const key of INLINE_ENV) { + savedEnv.set(key, process.env[key]) + delete process.env[key] + } + for (const name of GLOBAL_SOURCES) { + const file = path.join(Global.Path.config, name) savedFiles.set(file, existsSync(file) ? readFileSync(file, "utf-8") : undefined) rmSync(file, { force: true }) } }) afterEach(() => { - if (savedContent === undefined) delete process.env.OPENCODE_CONFIG_CONTENT - else process.env.OPENCODE_CONFIG_CONTENT = savedContent + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } for (const [file, text] of savedFiles) { if (text === undefined) rmSync(file, { force: true }) else writeFileSync(file, text) @@ -33,6 +49,20 @@ describe("TelemetryConfig.disabledState (no Instance)", () => { rmSync(dir, { recursive: true, force: true }) }) + test("reading a config that uses env interpolation emits no telemetry", async () => { + const track = spyOn(Telemetry, "track") + try { + process.env.CONFIG_STATE_PROBE_FLAG = "true" + writeFileSync(globalFile(), '{ "telemetry": { "disabled": {env:CONFIG_STATE_PROBE_FLAG} }, "x": "${HOME}" }') + expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + await new Promise((r) => setTimeout(r, 50)) // the parser's row would arrive after a dynamic import + expect(track.mock.calls.filter(([event]) => event.type === "config_env_interpolation")).toHaveLength(0) + } finally { + delete process.env.CONFIG_STATE_PROBE_FLAG + track.mockRestore() + } + }) + test("nothing sets it: enabled", async () => { expect(await TelemetryConfig.disabledState(dir)).toBe("enabled") }) From 1cbcf2b44dacd08aa170d19c16399fd84db6aaa0 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 28 Oct 2026 00:30:34 +0800 Subject: [PATCH 55/59] docs: `workspace_created` comes from the CLI or the TUI Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X --- docs/docs/reference/telemetry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 5836aabaab..1c9d8370c1 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -83,7 +83,7 @@ These events describe projects linked to an Altimate workspace. A workspace is i | Event | Description | |-------|-------------| | `workspace_linked` | A project was linked to a workspace and the server confirmed it. `how` is `bind_existing`, `create_and_bind`, `rebind_remote` or `rebind_path`. | -| `workspace_created` | A workspace was created from the CLI. `how` is `create_and_bind`, or `create_unbound` when the link follows as a separate step. | +| `workspace_created` | A workspace was created from the CLI or the TUI. `how` is `create_and_bind`, or `create_unbound` when the link follows as a separate step. | | `workspace_link_failed` | A link step failed. `how` names the step, and `reason` is a class: `auth`, `not_found`, `network`, `conflict` or `other`. No error text. | | `workspace_unlinked` | A project was unlinked, with the workspace id when the CLI knew it. | | `workspace_switched` | A linked project was moved to another workspace. `how` is `rebind_remote` or `rebind_path`. | From 40026d12a4805a8af5c32bd0e6301f7c19b3d295 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 28 Oct 2026 00:33:20 +0800 Subject: [PATCH 56/59] test: the probe's no-telemetry check waits on the import, not a timer Also restores the env variable it sets instead of deleting it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X (cherry picked from commit 38c790d84929f9e7463b198a88a2e5bc7ed117b1) --- .../test/altimate/telemetry/config-state.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/altimate/telemetry/config-state.test.ts b/packages/opencode/test/altimate/telemetry/config-state.test.ts index 42010f8f46..3ea59868b4 100644 --- a/packages/opencode/test/altimate/telemetry/config-state.test.ts +++ b/packages/opencode/test/altimate/telemetry/config-state.test.ts @@ -51,14 +51,19 @@ describe("TelemetryConfig.disabledState (no Instance)", () => { test("reading a config that uses env interpolation emits no telemetry", async () => { const track = spyOn(Telemetry, "track") + const savedFlag = process.env.CONFIG_STATE_PROBE_FLAG try { process.env.CONFIG_STATE_PROBE_FLAG = "true" writeFileSync(globalFile(), '{ "telemetry": { "disabled": {env:CONFIG_STATE_PROBE_FLAG} }, "x": "${HOME}" }') expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") - await new Promise((r) => setTimeout(r, 50)) // the parser's row would arrive after a dynamic import + // The parser tracks its row behind `import("@/altimate/telemetry")`. That module is loaded, so + // awaiting the same import settles after any such callback queued during the read. + await import("../../../src/altimate/telemetry") + await Promise.resolve() expect(track.mock.calls.filter(([event]) => event.type === "config_env_interpolation")).toHaveLength(0) } finally { - delete process.env.CONFIG_STATE_PROBE_FLAG + if (savedFlag === undefined) delete process.env.CONFIG_STATE_PROBE_FLAG + else process.env.CONFIG_STATE_PROBE_FLAG = savedFlag track.mockRestore() } }) From 9ceb8a4b66f3d24b45c8d37b0a334e6409cd6566 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 28 Oct 2026 01:05:56 +0800 Subject: [PATCH 57/59] fix: the opt-out probe rejects any source the config loader would reject Each source is parsed with missing `{file:}` references as errors and validated by the loader's own schema step (`validateLoaded`); any failure makes the read unreadable. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X (cherry picked from commit 78babddd2d09574cd46893c9ac99945cbc2acd67) --- .../src/altimate/telemetry/config-state.ts | 42 +++++++++---------- packages/opencode/src/config/config.ts | 7 ++++ .../altimate/telemetry/config-state.test.ts | 22 ++++++++-- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/altimate/telemetry/config-state.ts b/packages/opencode/src/altimate/telemetry/config-state.ts index b24050d88f..b16468588b 100644 --- a/packages/opencode/src/altimate/telemetry/config-state.ts +++ b/packages/opencode/src/altimate/telemetry/config-state.ts @@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "../../global" import { ConfigPaths } from "@/config/paths" import { ConfigManaged } from "@/config/managed" +import { validateLoaded } from "@/config/config" /** * Whether the user config turns telemetry off, read without an Instance. @@ -23,8 +24,9 @@ import { ConfigManaged } from "@/config/managed" * * - `disabled`: the last source that sets `telemetry.disabled` sets it to `true`. * - `enabled`: no source turns it off. - * - `unreadable`: a source that could not be read or parsed comes after the last one that set - * the value, so the answer is unknown. Callers fail closed for anything an opt-out forbids. + * - `unreadable`: some source could not be read, parsed or validated as `Config` does (a missing + * `{file:}` reference, a value its schema rejects). `Config` would fail to load at all, so the + * answer is unknown. Callers fail closed for anything an opt-out forbids. */ export type State = "disabled" | "enabled" | "unreadable" @@ -45,17 +47,25 @@ function worktreeOf(directory: string): string | undefined { export async function disabledState(directory: string = process.cwd()): Promise { try { let value: boolean | undefined + // Any source that fails to read, parse or validate fails Config's whole load, so the answer + // is unknown however the other sources set it. let unknown = false - const apply = (data: unknown) => { + const take = (data: unknown) => { const disabled = (data as { telemetry?: { disabled?: unknown } } | undefined)?.telemetry?.disabled if (disabled === undefined) return - // Config's schema accepts only a boolean; anything else makes the whole config invalid there. - if (typeof disabled !== "boolean") { + if (typeof disabled !== "boolean") unknown = true + else value = disabled + } + // Parse as the loader does (a missing `{file:}` reference is an error) and validate with the + // loader's own schema step, so a source Config would reject cannot set the answer. + const parse = async (text: string, input: string | { source: string; dir: string }) => { + if (!text.trim()) return // the loader treats an empty file as `{}` + const source = typeof input === "string" ? input : input.source + try { + take(validateLoaded(await ConfigPaths.parseText(text, input, "error", { track: false }), source)) + } catch { unknown = true - return } - value = disabled - unknown = false } const readFile = async (file: string) => { let text: string @@ -65,25 +75,15 @@ export async function disabledState(directory: string = process.cwd()): Promise< if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") unknown = true return } - try { - apply(await ConfigPaths.parseText(text, file, "empty", { track: false })) - } catch { - unknown = true - } - } - const readText = async (text: string, source: string, dir: string) => { - try { - apply(await ConfigPaths.parseText(text, { source, dir }, "empty", { track: false })) - } catch { - unknown = true - } + await parse(text, file) } + const readText = (text: string, source: string, dir: string) => parse(text, { source, dir }) for (const file of GLOBAL_FILES) await readFile(path.join(Global.Path.config, file)) // The legacy TOML global config, which Config merges after the JSON files (and migrates). const legacy = path.join(Global.Path.config, "config") try { - apply(Bun.TOML.parse(await fs.readFile(legacy, "utf-8"))) + take(Bun.TOML.parse(await fs.readFile(legacy, "utf-8"))) } catch (err) { if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") unknown = true } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 63284ffce4..60f4399964 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -224,6 +224,13 @@ const LocalInfo = ConfigV1.Info type LocalInfo = ConfigV1.Info // altimate_change end +// altimate_change start — the loader's validation of one parsed source, side-effect free, for the +// telemetry opt-out probe that reads the config before an Instance exists. Throws as loadConfig does. +export function validateLoaded(parsed: unknown, source: string): LocalInfo { + return ConfigParse.schema(LocalInfo, normalizeLoadedConfig(parsed), source) +} +// altimate_change end + // altimate_change start — opencode config includes local learn settings type Info = LocalInfo & { // altimate_change end diff --git a/packages/opencode/test/altimate/telemetry/config-state.test.ts b/packages/opencode/test/altimate/telemetry/config-state.test.ts index 3ea59868b4..9b11601331 100644 --- a/packages/opencode/test/altimate/telemetry/config-state.test.ts +++ b/packages/opencode/test/altimate/telemetry/config-state.test.ts @@ -54,7 +54,7 @@ describe("TelemetryConfig.disabledState (no Instance)", () => { const savedFlag = process.env.CONFIG_STATE_PROBE_FLAG try { process.env.CONFIG_STATE_PROBE_FLAG = "true" - writeFileSync(globalFile(), '{ "telemetry": { "disabled": {env:CONFIG_STATE_PROBE_FLAG} }, "x": "${HOME}" }') + writeFileSync(globalFile(), '{ "telemetry": { "disabled": {env:CONFIG_STATE_PROBE_FLAG} }, "model": "${HOME}" }') expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") // The parser tracks its row behind `import("@/altimate/telemetry")`. That module is loaded, so // awaiting the same import settles after any such callback queued during the read. @@ -68,6 +68,22 @@ describe("TelemetryConfig.disabledState (no Instance)", () => { } }) + test("a later source the loader would reject cannot override an earlier opt-out", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } })) + // Config's schema rejects a numeric model, so the real loader fails on this source. + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: false }, model: 123 }) + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + + test("a missing {file:} reference makes the read unreadable", async () => { + writeFileSync(globalFile(), JSON.stringify({ telemetry: { disabled: true } })) + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + telemetry: { disabled: false }, + model: `{file:${path.join(dir, "no-such-file")}}`, + }) + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + test("nothing sets it: enabled", async () => { expect(await TelemetryConfig.disabledState(dir)).toBe("enabled") }) @@ -107,9 +123,9 @@ describe("TelemetryConfig.disabledState (no Instance)", () => { expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") }) - test("a later source that sets the value settles an earlier unreadable one", async () => { + test("a source that fails anywhere makes the read unreadable, as it fails Config's whole load", async () => { writeFileSync(globalFile(), "{ not json") process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ telemetry: { disabled: true } }) - expect(await TelemetryConfig.disabledState(dir)).toBe("disabled") + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") }) }) From ea3fcc7f21099d3a33753f4e95a0eb16e24afb37 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 28 Oct 2026 01:40:09 +0800 Subject: [PATCH 58/59] fix: the opt-out probe parses a whitespace-only source, as the loader does Only an empty source is skipped; whitespace goes to the parser and fails, so the read is unreadable. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNasdD5tQMNtzXsuMEy82X (cherry picked from commit 8c763900c923c1bf662aee25fe5fba389f44397b) --- .../opencode/src/altimate/telemetry/config-state.ts | 2 +- .../test/altimate/telemetry/config-state.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/telemetry/config-state.ts b/packages/opencode/src/altimate/telemetry/config-state.ts index b16468588b..a5d1789755 100644 --- a/packages/opencode/src/altimate/telemetry/config-state.ts +++ b/packages/opencode/src/altimate/telemetry/config-state.ts @@ -59,7 +59,7 @@ export async function disabledState(directory: string = process.cwd()): Promise< // Parse as the loader does (a missing `{file:}` reference is an error) and validate with the // loader's own schema step, so a source Config would reject cannot set the answer. const parse = async (text: string, input: string | { source: string; dir: string }) => { - if (!text.trim()) return // the loader treats an empty file as `{}` + if (!text) return // the loader skips only an empty file; whitespace still goes to its parser const source = typeof input === "string" ? input : input.source try { take(validateLoaded(await ConfigPaths.parseText(text, input, "error", { track: false }), source)) diff --git a/packages/opencode/test/altimate/telemetry/config-state.test.ts b/packages/opencode/test/altimate/telemetry/config-state.test.ts index 9b11601331..7be170f674 100644 --- a/packages/opencode/test/altimate/telemetry/config-state.test.ts +++ b/packages/opencode/test/altimate/telemetry/config-state.test.ts @@ -84,6 +84,16 @@ describe("TelemetryConfig.disabledState (no Instance)", () => { expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") }) + test("an empty source is skipped but a whitespace-only one is unreadable, as in the loader", async () => { + writeFileSync(globalFile(), "") + expect(await TelemetryConfig.disabledState(dir)).toBe("enabled") + process.env.OPENCODE_CONFIG_CONTENT = " " + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + delete process.env.OPENCODE_CONFIG_CONTENT + writeFileSync(globalFile(), " \n") + expect(await TelemetryConfig.disabledState(dir)).toBe("unreadable") + }) + test("nothing sets it: enabled", async () => { expect(await TelemetryConfig.disabledState(dir)).toBe("enabled") }) From 5718138f36c5b002bd4f452005f03167c44c3d53 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Wed, 28 Oct 2026 20:54:05 +0800 Subject: [PATCH 59/59] test: read the starter row's `session_id` through `objectContaining` The event union now carries the opt-out row, which has no session, so the direct property read no longer type-checks. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VEmKQuB7SVUWpabSm51jon --- .../test/altimate/telemetry/starter-action-picked.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/altimate/telemetry/starter-action-picked.test.ts b/packages/opencode/test/altimate/telemetry/starter-action-picked.test.ts index 815065eca4..6ce17d54ae 100644 --- a/packages/opencode/test/altimate/telemetry/starter-action-picked.test.ts +++ b/packages/opencode/test/altimate/telemetry/starter-action-picked.test.ts @@ -77,7 +77,7 @@ describe("starter_action_picked row", () => { test("falls back to the process context's session", () => { const row = starterActionPickedEvent(7, { action: "done" }) - expect(row.session_id).toBe(Telemetry.getContext().sessionId) + expect(row).toEqual(expect.objectContaining({ session_id: Telemetry.getContext().sessionId })) }) })