From ceb212e3becb8329b914975307c75b3d894640c8 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 12 Sep 2026 22:11:57 +0800 Subject: [PATCH] feat(workspace): post a sanitized session attach report when the outcome settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an attach settles — attached, engine missing, engine too old, or the engine failed to start — the CLI posts what the session received to the backend: binding identity (the same remote or path the server row holds), CLI and engine versions, bridge state, declared and delivered keys, and the engine's unfulfilled report. Once per distinct report, fire-and-forget, never on the turn's path. Engine detail strings never leave the machine: each is reduced to a code plus, for spawn failures, the command's basename. Closes #1309 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- packages/opencode/src/altimate/api/client.ts | 6 + .../src/altimate/workspace/attach-report.ts | 143 ++++++++++++++++++ .../src/altimate/workspace/engine-overlay.ts | 84 +++++++++- .../src/altimate/workspace/engine-seams.ts | 8 +- .../altimate/workspace/attach-report.test.ts | 141 +++++++++++++++++ .../altimate/workspace/engine-overlay.test.ts | 98 ++++++++++++ 6 files changed, 472 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/attach-report.ts create mode 100644 packages/opencode/test/altimate/workspace/attach-report.test.ts diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 9d4caaae5..970930521 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -319,6 +319,12 @@ export namespace AltimateApi { await request(creds, "DELETE", `/datamates/${id}`) } + /** Post this session's attach report for a workspace (session attach report store). */ + export async function postAttachReport(datamateId: string, report: unknown): Promise { + const creds = await getCredentials() + await request(creds, "POST", `/datamates/${datamateId}/attach-reports`, report) + } + export async function listIntegrations() { const creds = await getCredentials() const data = await request(creds, "GET", "/datamate_integrations/") diff --git a/packages/opencode/src/altimate/workspace/attach-report.ts b/packages/opencode/src/altimate/workspace/attach-report.ts new file mode 100644 index 000000000..fb1b80d83 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/attach-report.ts @@ -0,0 +1,143 @@ +// altimate_change - new file +// +// The session attach report: what this session actually received from its +// workspace, posted to the backend when the outcome settles so the workspace +// page can show it. Pure shaping here; the one I/O function at the bottom +// goes through the API client and never throws. +import { AltimateApi } from "@/altimate/api/client" +import { log, syncInternals } from "./engine-seams" +import type { Declared, Outcome, Unfulfilled } from "./engine-types" + +/** What the backend accepts as an unserved key's detail: a code and, for + * spawn failures, the basename of the command. Never the engine's raw error + * text, which can name paths and hosts. */ +export type AttachReportDetail = { code: string; command?: string } + +export type AttachReportUnfulfilled = { + key: string + integration_id: string + reason: string + detail?: AttachReportDetail +} + +export type AttachReportOutcome = "attached" | "engine-missing" | "engine-too-old" | "connect-failed" + +export type AttachReport = { + binding_key: string + outcome: AttachReportOutcome + cli_version: string + engine_version: string | null + bridge_connected: boolean + declared_keys: string[] + delivered_keys: string[] + unfulfilled: AttachReportUnfulfilled[] + reported_at: string +} + +/** The identity the server binding row already carries: the git remote when + * the project has one, else its absolute path. Nothing new about the machine + * leaves it. */ +export function bindingKey(binding: { repoRemote: string | null; projectPath: string | null }): string | null { + return binding.repoRemote || binding.projectPath || null +} + +const CODES: Array<[RegExp, string]> = [ + [/\bENOENT\b/, "ENOENT"], + [/\bEACCES\b|\bEPERM\b/, "EACCES"], + [/\bETIMEDOUT\b|timed? ?out/i, "ETIMEDOUT"], + [/\bECONNREFUSED\b/, "ECONNREFUSED"], + [/invalid url/i, "invalid-url"], +] + +/** A code for an error string, never the string. */ +export function errorCode(text: string): string { + return CODES.find(([re]) => re.test(text))?.[1] ?? "other" +} + +/** Reduce an engine detail to what may leave the machine. For a spawn + * failure the spawned command's basename is kept (`spawn /Users/x/bin/docker + * ENOENT` → `docker`); the directory, and everything else, is dropped. */ +export function sanitizeDetail(detail: string | undefined, reason: string): AttachReportDetail | undefined { + if (!detail) return undefined + const code = errorCode(detail) + if (reason !== "spawn-failed") return { code } + const match = /\bspawn\s+(\S+)/.exec(detail) + const command = match ? match[1].split(/[\\/]/).pop() : undefined + return command ? { code, command } : { code } +} + +function sanitizeUnfulfilled(entries: Unfulfilled[]): AttachReportUnfulfilled[] { + return entries.map((u) => { + const detail = sanitizeDetail(u.detail, u.reason) + return { key: u.key, integration_id: u.integrationId, reason: u.reason, ...(detail ? { detail } : {}) } + }) +} + +export type AttachReportInput = { + outcome: Outcome + bindingKey: string + cliVersion: string + /** The probed engine version, when the engine ran at all. */ + engineVersion: string | null + declared: Declared | null + /** Keys the engine served under the workspace key (attached only). */ + present?: Set + bridgeConnected: boolean + reportedAt: string +} + +/** The report for a settled outcome, or null for outcomes that are not about + * the engine at all (disabled, unbound). */ +export function buildAttachReport(input: AttachReportInput): AttachReport | null { + const { outcome, declared } = input + const declaredKeys = declared ? [...declared.keys, ...declared.extensionKeys] : [] + const base = { + binding_key: input.bindingKey, + cli_version: input.cliVersion, + bridge_connected: input.bridgeConnected, + declared_keys: declaredKeys, + delivered_keys: [] as string[], + unfulfilled: [] as AttachReportUnfulfilled[], + reported_at: input.reportedAt, + } + switch (outcome.kind) { + case "attached": { + const present = input.present ?? new Set() + const delivered = declared ? declaredKeys.filter((k) => present.has(k)) : [...present] + return { + ...base, + outcome: "attached", + engine_version: input.engineVersion, + delivered_keys: delivered, + unfulfilled: sanitizeUnfulfilled(outcome.unfulfilled ?? []), + } + } + case "engine-missing": + return { ...base, outcome: "engine-missing", engine_version: null } + case "engine-too-old": + return { ...base, outcome: "engine-too-old", engine_version: outcome.found } + case "connect-failed": + return { ...base, outcome: "connect-failed", engine_version: input.engineVersion } + default: + return null + } +} + +/** Everything that would make the backend row different — so an identical + * re-attach does not post again, and a changed reason or version does. */ +export function attachReportSignature(report: AttachReport): string { + const { reported_at: _at, ...rest } = report + return JSON.stringify(rest) +} + +/** Post a report; fire-and-forget by contract. A failure is logged once at + * debug and never reaches the user or the turn. */ +export async function postAttachReport(datamateId: string, report: AttachReport): Promise { + try { + if (syncInternals.reportAttach) return await syncInternals.reportAttach(datamateId, report) + if (!(await AltimateApi.isConfigured())) return + await AltimateApi.postAttachReport(datamateId, report) + } catch (err) { + log.debug("attach report not posted", { datamateId, err: String(err) }) + } +} diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 13d6ce12c..895a3f39a 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -35,7 +35,18 @@ import { syncInternals, type ScopedBinding, } from "./engine-seams" -import { declaredBounded, fingerprint, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" +import { + declaredBounded, + fingerprint, + liveBridge, + notify, + printLine, + resolveBinding, + versionOf, + which, +} from "./engine-probes" +import { attachReportSignature, bindingKey, buildAttachReport, postAttachReport } from "./attach-report" +import { Installation } from "@/installation" import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" import { ENGINE_BINARY, @@ -136,6 +147,8 @@ type Overlay = { /** The derived entry, or null when the engine is unusable. */ entry: LocalMcpConfig | null refusal: Extract | null + /** The probed engine version when the engine ran; null when it is missing. */ + version: string | null } /** Per-directory state. Config and MCP state are per project instance, and one @@ -247,7 +260,7 @@ export async function overlay( const entry = engineEntry(workspace.id) config.mcp ??= {} config.mcp[DATAMATE_KEY] = entry - state.current = { directory, workspace, entry, refusal: null } + state.current = { directory, workspace, entry, refusal: null, version: probe.version } log.info("workspace engine overlay applied", { workspaceId: workspace.id, version: probe.version }) return } @@ -261,6 +274,7 @@ export async function overlay( workspace, entry: null, refusal: probe.kind === "missing" ? { kind: "engine-missing" } : { kind: "engine-too-old", found: probe.found }, + version: probe.kind === "missing" ? null : probe.found, } log.info("workspace engine overlay refused", { workspaceId: workspace.id, reason: probe.kind }) } catch (err) { @@ -303,7 +317,14 @@ export async function managedWorkspaceLoaded( /** `retried`: this session already spent its one re-add on a failed handshake. * Per session, so "start a new session to try again" is true. */ -type SessionRecord = { outcome: Outcome; announced?: string; announcedAt?: number; retried?: boolean } +type SessionRecord = { + outcome: Outcome + announced?: string + announcedAt?: number + retried?: boolean + /** Signature of the last attach report posted for this session. */ + reported?: string +} const sessions = new Map() const declaredCache = new Map() /** Verdict signatures a headless process has already printed to stderr. */ @@ -317,6 +338,7 @@ function record(sessionID: string, outcome: Outcome): SessionRecord { announced: previous?.announced, announcedAt: previous?.announcedAt, retried: previous?.retried, + reported: previous?.reported, } sessions.set(sessionID, next) while (sessions.size > MAX_TRACKED_SESSIONS) { @@ -329,6 +351,34 @@ function record(sessionID: string, outcome: Outcome): SessionRecord { /** The outcome a session settled at its last turn boundary. A pure read; * `undefined` before the first `beforeTurn` for that session. */ +/** Post the settled outcome as this session's attach report, once per + * distinct report. Never awaited by the turn: the post is fire-and-forget and + * swallows its own failures. */ +function reportOutcome( + sessionID: string, + binding: ScopedBinding, + extras: { engineVersion: string | null; declared: Declared | null; present?: Set; bridgeConnected: boolean }, +): void { + const rec = sessions.get(sessionID) + const key = bindingKey(binding) + if (!rec || !key) return + const report = buildAttachReport({ + outcome: rec.outcome, + bindingKey: key, + cliVersion: Installation.VERSION, + engineVersion: extras.engineVersion, + declared: extras.declared, + present: extras.present, + bridgeConnected: extras.bridgeConnected, + reportedAt: new Date(now()).toISOString(), + }) + if (!report) return + const signature = attachReportSignature(report) + if (rec.reported === signature) return + rec.reported = signature + void postAttachReport(String(binding.datamateId), report) +} + export function settledOutcome(sessionID: string): Outcome | undefined { return sessions.get(sessionID)?.outcome } @@ -369,7 +419,9 @@ async function refuseUnreadableLink(sessionID: string, state: DirectoryState, er record(sessionID, outcome) const kept = state.applied?.entry ? "the running engine is kept and " : "" await announceRefusal(sessionID, outcome, { - title: state.applied ? `Workspace "${state.applied.workspace.name}": link could not be read` : "Workspace link could not be read", + title: state.applied + ? `Workspace "${state.applied.workspace.name}": link could not be read` + : "Workspace link could not be read", message: `${outcome.error} (${error}); ${kept}it is read again next turn.`, variant: "warning", }) @@ -505,7 +557,9 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // probe memo bounds how often that is asked). let reload = state.current ? state.current.workspace.key !== boundKey - : state.linkUnreadable !== undefined || state.failedAt === undefined || now() - state.failedAt >= FAILED_PROBE_TTL_MS + : state.linkUnreadable !== undefined || + state.failedAt === undefined || + now() - state.failedAt >= FAILED_PROBE_TTL_MS if (!reload && state.current && !state.current.entry) { const probe = await probeEngine() reload = probe.kind === "ok" @@ -517,7 +571,8 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS } // The boundary read the binding but the reload could not: the link is // flapping, and the reload's verdict is the one the config now reflects. - if (!state.current && state.linkUnreadable !== undefined) return refuseUnreadableLink(sessionID, state, state.linkUnreadable) + if (!state.current && state.linkUnreadable !== undefined) + return refuseUnreadableLink(sessionID, state, state.linkUnreadable) // A transient overlay failure (its retry is throttled above) keeps what was // last applied for this same workspace: a running engine is not released @@ -540,6 +595,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // Say so, once, rather than settling a bound directory as unbound in silence. const outcome: Outcome = { kind: "connect-failed", error: "the workspace engine could not be checked" } record(sessionID, outcome) + reportOutcome(sessionID, binding, { engineVersion: null, declared: null, bridgeConnected: false }) await announceRefusal(sessionID, outcome, { title: `Workspace "${binding.datamateName}": engine unavailable`, message: `${outcome.error}; it is checked again shortly.`, @@ -575,6 +631,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS const outcome: Outcome = count === undefined ? { kind: "engine-missing" } : { kind: "engine-missing", declared: count } record(sessionID, outcome) + reportOutcome(sessionID, binding, { engineVersion: null, declared, bridgeConnected: false }) const what = count === undefined ? `Workspace "${workspace.name}" has integration tools that run on the local engine, which is not installed.` @@ -598,7 +655,13 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS return } record(sessionID, refusal) - const declared = (await declaredFor(workspace))?.keys.length + const declaredAll = await declaredFor(workspace) + const declared = declaredAll?.keys.length + reportOutcome(sessionID, binding, { + engineVersion: overlayNow.version, + declared: declaredAll, + bridgeConnected: false, + }) await announceRefusal( sessionID, refusal, @@ -640,6 +703,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS error: status?.error ?? `engine status: ${status?.status ?? "unknown"}`, } record(sessionID, outcome) + reportOutcome(sessionID, binding, { engineVersion: overlayNow.version, declared, bridgeConnected: false }) await announceRefusal(sessionID, outcome, { title: `Workspace "${workspace.name}": engine failed to start`, message: `${outcome.error}. Start a new session to try again.`, @@ -672,6 +736,12 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS ...(unfulfilled === undefined ? {} : { unfulfilled }), } const rec = record(sessionID, outcome) + reportOutcome(sessionID, binding, { + engineVersion: overlayNow.version, + declared, + present, + bridgeConnected: extServed > 0 || liveBridge(directory), + }) // Keyed on the workspace too: a re-link with an identical inventory is still // a new verdict the user should hear. // extServed is part of what the user hears, so it is part of the signature: diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index b499617e1..4ff4aaf15 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -7,6 +7,7 @@ import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import type { CachedBinding } from "./state" import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types" +import type { AttachReport } from "./attach-report" import type { EngineOffer, InstallResult } from "./engine-offer" export const log = Log.create({ service: "workspace-engine" }) @@ -19,7 +20,10 @@ export type ScopedBinding = CachedBinding & { scope?: string } /** What a binding read established. `failed` is not `unbound`: the link may * well exist, it could not be read, and nothing may be handed the key on the * strength of that. */ -export type BindingRead = { kind: "bound"; binding: ScopedBinding } | { kind: "unbound" } | { kind: "failed"; error: string } +export type BindingRead = + | { kind: "bound"; binding: ScopedBinding } + | { kind: "unbound" } + | { kind: "failed"; error: string } export const syncInternals: { resolveBinding?: (directory: string) => Promise @@ -29,6 +33,8 @@ export const syncInternals: { declared?: (workspaceId: string) => Promise liveBridge?: (cwd: string) => boolean notify?: (toast: Toast) => Promise + /** Attach-report sink (see attach-report.ts); production posts through the API client. */ + reportAttach?: (datamateId: string, report: AttachReport) => Promise printLine?: (line: string) => void /** Install-offer seams (see engine-offer.ts). */ offer?: (offer: EngineOffer) => boolean diff --git a/packages/opencode/test/altimate/workspace/attach-report.test.ts b/packages/opencode/test/altimate/workspace/attach-report.test.ts new file mode 100644 index 000000000..a04903821 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/attach-report.test.ts @@ -0,0 +1,141 @@ +// altimate_change - new file +import { describe, expect, test } from "bun:test" +import { + attachReportSignature, + bindingKey, + buildAttachReport, + errorCode, + sanitizeDetail, +} from "../../../src/altimate/workspace/attach-report" + +const declared = { keys: ["jira_search_issues", "echo", "ghost"], extensionKeys: ["pu_lineage"] } +const base = { + bindingKey: "ssh://git@github.com/acme/jaffle-shop", + cliVersion: "0.11.2", + engineVersion: "0.7.2", + declared, + bridgeConnected: false, + reportedAt: "2026-09-12T13:50:00.000Z", +} + +describe("bindingKey", () => { + test("prefers the git remote, falls back to the path, and is null with neither", () => { + expect(bindingKey({ repoRemote: "ssh://a", projectPath: "/p" })).toBe("ssh://a") + expect(bindingKey({ repoRemote: null, projectPath: "/p" })).toBe("/p") + expect(bindingKey({ repoRemote: "", projectPath: null })).toBeNull() + }) +}) + +describe("sanitizeDetail", () => { + test("keeps only a code and, for spawn failures, the command basename", () => { + expect(sanitizeDetail("spawn /Users/x/bin/docker ENOENT", "spawn-failed")).toEqual({ + code: "ENOENT", + command: "docker", + }) + expect(sanitizeDetail("spawn C:\\Users\\x\\tools\\gh.exe ENOENT", "spawn-failed")).toEqual({ + code: "ENOENT", + command: "gh.exe", + }) + expect(sanitizeDetail("spawn altimate-e2e-missing-binary ENOENT", "spawn-failed")).toEqual({ + code: "ENOENT", + command: "altimate-e2e-missing-binary", + }) + }) + test("never forwards free text: paths, hosts and messages collapse to a code", () => { + expect(sanitizeDetail("connect ECONNREFUSED 10.0.0.7:8443", "exception")).toEqual({ code: "ECONNREFUSED" }) + expect(sanitizeDetail("Invalid URL: http://[bad", "spawn-failed")).toEqual({ code: "invalid-url" }) + expect(sanitizeDetail("token expired for user@corp.example", "invalid-connection")).toEqual({ code: "other" }) + expect(sanitizeDetail(undefined, "spawn-failed")).toBeUndefined() + expect(sanitizeDetail("", "spawn-failed")).toBeUndefined() + }) + test("errorCode maps the recognised patterns", () => { + expect(errorCode("Request timed out")).toBe("ETIMEDOUT") + expect(errorCode("EACCES: permission denied")).toBe("EACCES") + expect(errorCode("boom")).toBe("other") + }) +}) + +describe("buildAttachReport", () => { + test("attached: declared vs delivered from the served set, unfulfilled sanitized", () => { + const report = buildAttachReport({ + ...base, + outcome: { + kind: "attached", + available: 1, + declared: 3, + missing: ["jira_search_issues", "ghost"], + unfulfilled: [ + { key: "jira_search_issues", integrationId: "jira", reason: "invalid-connection" }, + { key: "ghost", integrationId: "mcp-ok", reason: "unknown-key" }, + { key: "pu_lineage", integrationId: "vscode-power-user", reason: "no-bridge" }, + { + key: "whatever", + integrationId: "mcp-missing-binary", + reason: "spawn-failed", + detail: "spawn /opt/tools/altimate-e2e-missing-binary ENOENT", + }, + ], + }, + present: new Set(["echo", "altimate_knowledge_search"]), + }) + expect(report).toEqual({ + binding_key: base.bindingKey, + outcome: "attached", + cli_version: "0.11.2", + engine_version: "0.7.2", + bridge_connected: false, + declared_keys: ["jira_search_issues", "echo", "ghost", "pu_lineage"], + delivered_keys: ["echo"], + unfulfilled: [ + { key: "jira_search_issues", integration_id: "jira", reason: "invalid-connection" }, + { key: "ghost", integration_id: "mcp-ok", reason: "unknown-key" }, + { key: "pu_lineage", integration_id: "vscode-power-user", reason: "no-bridge" }, + { + key: "whatever", + integration_id: "mcp-missing-binary", + reason: "spawn-failed", + detail: { code: "ENOENT", command: "altimate-e2e-missing-binary" }, + }, + ], + reported_at: base.reportedAt, + }) + expect(JSON.stringify(report)).not.toContain("/opt/tools") + }) + test("attached without an allowlist reports what was served as delivered", () => { + const report = buildAttachReport({ + ...base, + declared: null, + outcome: { kind: "attached", available: 2 }, + present: new Set(["echo", "dbt_build_model"]), + }) + expect(report?.declared_keys).toEqual([]) + expect(report?.delivered_keys).toEqual(["echo", "dbt_build_model"]) + }) + test("failed outcomes carry the version the CLI saw, or null when the engine is missing", () => { + expect(buildAttachReport({ ...base, outcome: { kind: "engine-missing", declared: 3 } })).toMatchObject({ + outcome: "engine-missing", + engine_version: null, + declared_keys: declared.keys.concat(declared.extensionKeys), + delivered_keys: [], + }) + expect(buildAttachReport({ ...base, outcome: { kind: "engine-too-old", found: "0.7.1" } })).toMatchObject({ + outcome: "engine-too-old", + engine_version: "0.7.1", + }) + expect(buildAttachReport({ ...base, outcome: { kind: "connect-failed", error: "x" } })).toMatchObject({ + outcome: "connect-failed", + engine_version: "0.7.2", + }) + }) + test("outcomes that are not about the engine produce no report", () => { + expect(buildAttachReport({ ...base, outcome: { kind: "disabled" } })).toBeNull() + expect(buildAttachReport({ ...base, outcome: { kind: "unbound" } })).toBeNull() + }) + test("the signature ignores the timestamp and changes with the content", () => { + const a = buildAttachReport({ ...base, outcome: { kind: "engine-too-old", found: "0.7.1" } })! + const b = { ...a, reported_at: "2026-09-12T14:00:00.000Z" } + const c = { ...a, engine_version: "0.7.0" } + expect(attachReportSignature(a)).toBe(attachReportSignature(b)) + expect(attachReportSignature(a)).not.toBe(attachReportSignature(c)) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index cc2f1ab1c..bd5b4d694 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -28,6 +28,7 @@ import { type Toast, } from "../../../src/altimate/workspace/engine-overlay" import type { ScopedBinding } from "../../../src/altimate/workspace/engine-seams" +import type { AttachReport } from "../../../src/altimate/workspace/attach-report" import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" const DIR = "/tmp/analytics" @@ -52,6 +53,7 @@ type Harness = { invalidates: number probes: number toasts: Toast[] + reports: { datamateId: string; report: AttachReport }[] lines: string[] clock: number /** Whether MCP holds a client under the key — set when MCP "bootstraps" from @@ -94,6 +96,7 @@ function install(opts: { invalidates: 0, probes: 0, toasts: [], + reports: [], lines: [], clock: 1_000_000, fingerprint: "bin-1", @@ -115,6 +118,9 @@ function install(opts: { syncInternals.notify = async (toast) => { h.toasts.push(toast) } + syncInternals.reportAttach = async (datamateId, report) => { + h.reports.push({ datamateId, report }) + } syncInternals.printLine = (line) => { h.lines.push(line) } @@ -1016,3 +1022,95 @@ describe("beforeTurn — what a turn boundary does", () => { expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) }) }) + +describe("attach reports — what the session posts when an outcome settles", () => { + test("an attached session posts one sanitized report for its binding", async () => { + const report = [ + { key: "dbt_execute_sql", integrationId: "dbt", reason: "invalid-connection" }, + { + key: "gh_list_prs", + integrationId: "github-mcp", + reason: "spawn-failed", + detail: "spawn /Users/ralph/.local/bin/docker ENOENT", + }, + ] + const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) + await beforeTurn("s1") + expect(h.reports).toHaveLength(1) + expect(h.reports[0].datamateId).toBe("42") + expect(h.reports[0].report).toMatchObject({ + binding_key: DIR, + outcome: "attached", + engine_version: "0.7.2", + bridge_connected: false, + declared_keys: ["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"], + delivered_keys: ["dbt_build_model", "dbt_compile_model"], + unfulfilled: [ + { key: "dbt_execute_sql", integration_id: "dbt", reason: "invalid-connection" }, + { + key: "gh_list_prs", + integration_id: "github-mcp", + reason: "spawn-failed", + detail: { code: "ENOENT", command: "docker" }, + }, + ], + }) + expect(JSON.stringify(h.reports[0].report)).not.toContain("/Users/ralph") + expect(typeof h.reports[0].report.cli_version).toBe("string") + expect(h.reports[0].report.reported_at).toBe(new Date(h.clock).toISOString()) + }) + + test("an unchanged outcome does not post again; a changed reason does", async () => { + const h = install({ + meta: { [UNFULFILLED_META_KEY]: [{ key: "gh_list_prs", integrationId: "github-mcp", reason: "spawn-failed" }] }, + }) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.reports).toHaveLength(1) + h.meta = { + [UNFULFILLED_META_KEY]: [{ key: "gh_list_prs", integrationId: "github-mcp", reason: "invalid-connection" }], + } + await beforeTurn("s1") + expect(h.reports).toHaveLength(2) + expect(h.reports[1].report.unfulfilled[0].reason).toBe("invalid-connection") + }) + + test("failed attaches post too: too old carries the found version, missing carries null", async () => { + const old = install({ version: "0.6.3" }) + await beforeTurn("s1") + expect(old.reports.map((r) => r.report)).toMatchObject([ + { + outcome: "engine-too-old", + engine_version: "0.6.3", + declared_keys: ["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"], + delivered_keys: [], + }, + ]) + const missing = install({ which: null }) + await beforeTurn("s2") + expect(missing.reports.map((r) => r.report)).toMatchObject([{ outcome: "engine-missing", engine_version: null }]) + }) + + test("an engine that fails to start posts connect-failed", async () => { + const h = install({ status: "failed", statusError: "spawn datamate ENOENT" }) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("connect-failed") + expect(h.reports.map((r) => r.report.outcome)).toEqual(["connect-failed"]) + }) + + test("nothing is posted for an unbound directory", async () => { + const h = install({ binding: null }) + await beforeTurn("s1") + expect(h.reports).toHaveLength(0) + }) + + test("a failing sink never reaches the turn or the outcome", async () => { + const h = install({}) + syncInternals.reportAttach = async () => { + throw new Error("backend down") + } + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("attached") + expect(h.toasts).toHaveLength(1) + }) +})