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..ef2a88d3e --- /dev/null +++ b/packages/opencode/src/altimate/workspace/attach-report.ts @@ -0,0 +1,146 @@ +// 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 { sanitize } from "@/mcp/catalog" +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() + // Never a key the engine reports unfulfilled: two raw keys can sanitise to one catalog name. + const reported = new Set((outcome.unfulfilled ?? []).map((u) => u.key)) + const delivered = declared ? declaredKeys.filter((k) => present.has(sanitize(k)) && !reported.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/attach-snapshot.ts b/packages/opencode/src/altimate/workspace/attach-snapshot.ts new file mode 100644 index 000000000..938713b12 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/attach-snapshot.ts @@ -0,0 +1,83 @@ +// altimate_change - new file +// +// What the last attach in a directory produced, on disk. The overlay settles +// an attach inside the server process; the TUI plugin (the `/workspace` +// menu, the sidebar tile) runs in another, so the memory the overlay keeps is +// invisible to it — the same reason the binding cache lives in a file. One +// small JSON under the state directory, keyed by project directory, latest +// attach per directory, bounded. +import path from "node:path" +import { chmodSync, existsSync, readFileSync } from "node:fs" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { Log } from "@/altimate/util/log" +import type { Declared, Unfulfilled } from "./engine-types" + +const log = Log.create({ service: "altimate-workspace-attach-snapshot" }) + +export interface AttachSnapshot { + workspace: { id: string; name: string } + engineVersion: string | null + /** The allowlist the workspace declared, split like `Declared`; null when + * the lookup failed and the engine was taken at its word. */ + declared: Declared | null + /** Every key the engine served under the workspace key, allowlisted or not. */ + present: string[] + /** The engine's full report; undefined when it sent none. */ + unfulfilled: Unfulfilled[] | undefined + /** Extension-declared keys a live IDE bridge served. */ + extServed: number + at: number +} + +interface SnapshotFile { + version: 1 + snapshots: Record +} + +/** Enough for a machine's worth of projects; the oldest go first. */ +const MAX_SNAPSHOTS = 64 + +export function snapshotPath(): string { + return path.join(Global.Path.state, "altimate-attach-snapshots.json") +} + +function readFile(): SnapshotFile | null { + const p = snapshotPath() + if (!existsSync(p)) return null + try { + const raw = JSON.parse(readFileSync(p, "utf8")) as Partial | null + if (!raw || raw.version !== 1 || typeof raw.snapshots !== "object" || raw.snapshots === null) return null + return raw as SnapshotFile + } catch (err) { + log.warn("attach snapshot file is corrupt, discarding", { code: (err as NodeJS.ErrnoException)?.code }) + return null + } +} + +/** Best-effort, like every write to the state directory: a read-only home + * must not turn a successful attach into a failure. */ +export function writeAttachSnapshot(directory: string, snapshot: AttachSnapshot): void { + try { + const file = readFile() ?? { version: 1, snapshots: {} } + file.snapshots[path.resolve(directory)] = snapshot + const entries = Object.entries(file.snapshots) + if (entries.length > MAX_SNAPSHOTS) { + entries.sort((a, b) => a[1].at - b[1].at) + file.snapshots = Object.fromEntries(entries.slice(entries.length - MAX_SNAPSHOTS)) + } + const p = snapshotPath() + Filesystem.writeJsonAtomic(p, file) + try { + chmodSync(p, 0o600) + } catch { + // Umask permissions until the next write; the file holds tool keys, not credentials. + } + } catch (err) { + log.warn("could not write the attach snapshot", { err: String(err) }) + } +} + +export function readAttachSnapshot(directory: string): AttachSnapshot | undefined { + return readFile()?.snapshots[path.resolve(directory)] +} diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 521ef8c2e..07e24d26b 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -25,6 +25,7 @@ // that turn's start. import { DATAMATE_KEY } from "@/altimate/datamate-transport" import { MCP } from "@/mcp" +import { sanitize } from "@/mcp/catalog" import { Config } from "@/config/config" import { currentDirectory, @@ -35,7 +36,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, @@ -43,8 +55,8 @@ import { REPAIRABLE, TOOL_PREFIX, clearsFloor, - describeExtensionServed, - describeMissing, + parseUnfulfilled, + reportedMissing, describeRefusal, engineEntry, engineToolKeys, @@ -56,6 +68,7 @@ import { type Outcome, type Toast, } from "./engine-types" +import { readAttachSnapshot, writeAttachSnapshot, type AttachSnapshot } from "./attach-snapshot" export * from "./engine-types" export * from "./engine-offer" @@ -134,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 @@ -245,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 } @@ -259,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) { @@ -301,12 +317,32 @@ 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. */ const headlessPrinted = new Set() +/** What the last attach in a directory produced, kept for the surfaces that + * describe it after the fact — the sidebar tile and the `/workspace` status + * view. In memory for this process, and on disk for the TUI process, which + * is where those surfaces run (see `attach-snapshot.ts`). */ +const lastAttach = new Map() + +/** The last attach snapshot for a directory: this process's, else the one on + * disk, else undefined before any session has settled there. */ +export function attachSnapshot(directory: string | null = currentDirectory()): AttachSnapshot | undefined { + if (directory === null) return undefined + return lastAttach.get(directory) ?? readAttachSnapshot(directory) +} + function record(sessionID: string, outcome: Outcome): SessionRecord { const previous = sessions.get(sessionID) sessions.delete(sessionID) @@ -315,6 +351,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) { @@ -327,6 +364,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 } @@ -338,6 +403,9 @@ function mcp() { add: (name: string, cfg: LocalMcpConfig | McpEntry) => MCP.add(name, cfg as Parameters[1]), remove: (name: string) => MCP.remove(name), tools: () => MCP.tools() as Promise>, + listMeta: (name: string) => MCP.listMeta(name), + snapshot: (name: string) => + MCP.snapshot(name) as Promise<{ tools: Record; meta: Record | undefined }>, } ) } @@ -366,7 +434,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", }) @@ -502,7 +572,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" @@ -514,7 +586,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 @@ -537,6 +610,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.`, @@ -572,6 +646,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.` @@ -595,7 +670,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, @@ -637,6 +718,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.`, @@ -645,46 +727,127 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS return } - const present = engineToolKeys(await mcp().tools()) - const missing = declared ? declared.keys.filter((k) => !present.has(k)) : undefined + // One read for both: a tools/list refresh that completes between two separate + // reads would pair one listing's tools with another's report. (multi-model review) + const { tools, meta } = await mcp().snapshot(DATAMATE_KEY) + const present = engineToolKeys(tools) + // The gaps come from the engine's own report, with reasons; this client no + // longer diffs the allowlist against what arrived. No report (nothing at or + // above the floor omits it) means no gap is claimed, not that there is none. + const unfulfilled = parseUnfulfilled(meta) + const missingReport = unfulfilled === undefined ? undefined : reportedMissing(unfulfilled) + const missing = missingReport?.map((u) => u.key) // `available` is everything the engine serves under the key. The engine adds // tools beyond the allowlist (knowledge, memory) when the workspace enables // them, so the "N of M declared" line counts only the declared ones present. - const served = declared ? declared.keys.length - (missing?.length ?? 0) : present.size + // Compared in the catalog's key space: `present` holds tool names as the MCP + // layer sanitised them (`[a-zA-Z0-9_-]`), while the declaration carries the + // raw keys, so a raw key with any other character would never count as served + // and the headline would disagree with a report that names no gap. (multi-model review) + // And never a key the engine itself reports as unfulfilled: two raw keys can + // sanitise to one catalog name, and the report is the authority on which of + // them the served tool stands for. (codex) + // And counted per catalog entry, not per declaration: two raw keys that both + // sanitise to `foo_bar` are one callable tool however many the engine lists. + // Consumed across both groups: an ordinary key and an extension key that + // collide are still one entry, counted where it is met first — with the + // ordinary keys, which are counted first. + const reported = new Set((unfulfilled ?? []).map((u) => u.key)) + const consumed = new Set() + const servedEntries = (keys: string[]) => { + let n = 0 + for (const k of keys) { + const entry = sanitize(k) + if (!present.has(entry) || reported.has(k) || consumed.has(entry)) continue + consumed.add(entry) + n += 1 + } + return n + } + const served = declared ? servedEntries(declared.keys) : present.size // Extension-declared tools appear in `present` only while the engine holds a // live IDE bridge; when they do they are real capability and the line names // them, but their absence is the normal no-IDE case, never `missing`. - const extServed = declared ? declared.extensionKeys.filter((k) => present.has(k)).length : 0 + const extServed = declared ? servedEntries(declared.extensionKeys) : 0 const outcome: Outcome = { kind: "attached", available: present.size, - ...(declared ? { declared: declared.keys.length, missing } : {}), + ...(declared ? { declared: declared.keys.length } : {}), + ...(missing === undefined ? {} : { missing }), + ...(unfulfilled === undefined ? {} : { unfulfilled }), } const rec = record(sessionID, outcome) + const snapshot: AttachSnapshot = { + workspace: { id: workspace.id, name: workspace.name }, + engineVersion: overlayNow.version, + declared, + present: [...present], + unfulfilled, + extServed, + at: now(), + } + lastAttach.set(directory, snapshot) + ;(syncInternals.persistSnapshot ?? writeAttachSnapshot)(directory, snapshot) + 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: // an equal-count tool swap that changes only the extension share must still // re-announce. (bot review) - const signature = `attached:${workspace.key}:${outcome.available}:${outcome.declared ?? "?"}:${(missing ?? []).join(",")}:${extServed}` + // A gap whose reason changed (a connection fixed, a binary still absent) + // is a new verdict too, so the reasons are in the signature. + const gaps = (missingReport ?? []).map((u) => `${u.key}=${u.reason}`).join(",") + const signature = `attached:${workspace.key}:${outcome.available}:${outcome.declared ?? "?"}:${gaps}:${extServed}` if (rec.announced === signature) return rec.announced = signature log.info("workspace engine attached", { workspaceId: workspace.id, available: outcome.available, declared: outcome.declared, - missing, + unfulfilled, }) if (isHeadless()) return + // Numbers only. The keys and their reasons live in the `/workspace` status + // view, which the toast points at; a toast that tried to carry them read as + // noise (review of the first cut). await notify({ title: `Workspace "${workspace.name}"`, - message: declared - ? `${served} of ${declared.keys.length} declared integration tools available.${describeMissing(missing ?? [])}${describeExtensionServed(extServed)}` - : `${outcome.available} integration tools available.`, - variant: missing && missing.length > 0 ? "warning" : "info", + message: attachSummary({ + served, + declared: declared?.keys.length, + available: outcome.available, + gaps: missingReport?.length ?? 0, + extServed, + }), + variant: missingReport !== undefined && missingReport.length > 0 ? "warning" : "info", }) } +/** The one line a settled attach is announced with: counts, then where the + * detail is. `declared` undefined means no allowlist was readable, so only + * what the engine serves can be counted. */ +export function attachSummary(input: { + served: number + declared: number | undefined + available: number + gaps: number + extServed: number +}): string { + const parts = [ + input.declared === undefined + ? `${input.available} integration tools available` + : `${input.served} of ${input.declared} integration tools available`, + ] + if (input.gaps > 0) parts.push(`${input.gaps} need${input.gaps === 1 ? "s" : ""} attention`) + if (input.extServed > 0) parts.push(`${input.extServed} more via VS Code`) + return `${parts.join(" · ")}. Details: /workspace` +} + /** Tell the session about a refusal, once per unchanged verdict. * * The substitution point for the install offer: when installing would help @@ -754,6 +917,7 @@ export function isRepairable(outcome: Outcome | undefined): boolean { /** Test-only: forget everything this process learned. */ export function resetForTests(): void { + lastAttach.clear() directories.clear() probeMemo = null sessions.clear() diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 093a02b97..d2ad3151c 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -6,7 +6,9 @@ import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import type { CachedBinding } from "./state" +import type { AttachSnapshot } from "./attach-snapshot" 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 +21,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 +34,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 @@ -45,11 +52,15 @@ export const syncInternals: { headless?: () => boolean serve?: () => boolean now?: () => number + /** Tests keep the attach snapshot out of the real state directory. */ + persistSnapshot?: (directory: string, snapshot: AttachSnapshot) => void mcp?: { status: () => Promise add: (name: string, cfg: LocalMcpConfig | McpEntry) => Promise remove: (name: string) => Promise tools: () => Promise> + listMeta: (name: string) => Promise | undefined> + snapshot: (name: string) => Promise<{ tools: Record; meta: Record | undefined }> } config?: { invalidate: () => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 070c26dc6..76487c4db 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -14,9 +14,11 @@ import { DATAMATE_KEY } from "@/altimate/datamate-transport" * workspace promise rests on: integrations configured purely in the workspace * UI must produce working tools with no local files. It also passes the * resolved connection to MCP-type handlers, so their credential placeholders - * resolve. A 0.7.0 engine holds the pin but serves none of those tools, which - * is why the floor is 0.7.1. */ -export const MIN_ENGINE_VERSION = "0.7.1" + * resolve. A 0.7.0 engine holds the pin but serves none of those tools. 0.7.2 + * is the first that reports, on every tools/list, the allowlist keys it could + * not serve and why (`UNFULFILLED_META_KEY`); this client no longer diffs the + * allowlist itself, so below 0.7.2 it would announce no gaps at all. */ +export const MIN_ENGINE_VERSION = "0.7.2" export const ENGINE_PACKAGE = "@altimateai/datamate" export const ENGINE_BINARY = "datamate" export const INSTALL_COMMAND = `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}` @@ -29,7 +31,16 @@ export const TOOL_PREFIX = `${DATAMATE_KEY}_` export type Outcome = | { kind: "disabled" } | { kind: "unbound" } - | { kind: "attached"; available: number; declared?: number; missing?: string[] } + | { + kind: "attached" + available: number + declared?: number + /** Keys of `unfulfilled` that count as gaps (see `reportedMissing`). */ + missing?: string[] + /** The engine's full report, `no-bridge` entries included; absent when + * the engine sent none. */ + unfulfilled?: Unfulfilled[] + } | { kind: "engine-missing"; declared?: number } /** `found` is null when the binary ran but printed nothing usable — broken * rather than old; the message says so. */ @@ -207,19 +218,72 @@ export function describeRefusal( ) } -export function describeMissing(missing: string[]): string { - if (missing.length === 0) return "" - const shown = missing.slice(0, 5).join(", ") - const more = missing.length > 5 ? ` (+${missing.length - 5} more)` : "" - return ` Declared but not available: ${shown}${more}.` +/** Where the engine (0.7.2+) reports the allowlist keys it could not serve, + * on every tools/list response, so the client never diffs the allowlist + * against what arrived: a diff can name the keys, never the reason. */ +export const UNFULFILLED_META_KEY = "ai.altimate/unfulfilled" + +export type UnfulfilledReason = + | "catalog-missing" + | "invalid-connection" + | "spawn-failed" + | "no-bridge" + | "unknown-key" + | "exception" + +/** One declared key the engine did not serve, in the engine's own words. A + * reason outside the known set is kept verbatim: a newer engine may add one. */ +export type Unfulfilled = { + key: string + integrationId: string + reason: UnfulfilledReason | (string & {}) + detail?: string +} + +/** The engine's report out of a tools/list `_meta`. Undefined when there is + * none, or it is malformed: the caller then knows nothing about gaps, which + * is not the same as knowing there are none. */ +export function parseUnfulfilled(meta: Record | undefined): Unfulfilled[] | undefined { + const raw = meta?.[UNFULFILLED_META_KEY] + if (!Array.isArray(raw)) return undefined + const out: Unfulfilled[] = [] + for (const item of raw) { + if (typeof item !== "object" || item === null) return undefined + const { key, integrationId, reason, detail } = item as Record + // Custom (tenant-created) integrations carry numeric ids; take them as strings. + const id = typeof integrationId === "number" ? String(integrationId) : integrationId + if (typeof key !== "string" || typeof id !== "string" || typeof reason !== "string") return undefined + // A present `detail` must be a string: an entry with a malformed one is a + // malformed report, not a report with one field dropped. Fails closed like + // the fields above. (codex) + if (detail !== undefined && typeof detail !== "string") return undefined + out.push({ key, integrationId: id, reason, ...(detail ? { detail } : {}) }) + } + return out +} + +/** Absent extension tools without an IDE window are expected, not missing: + * `no-bridge` entries never join the "declared but not available" line. + * Everything else the engine reports is a real gap. */ +export function reportedMissing(unfulfilled: Unfulfilled[]): Unfulfilled[] { + return unfulfilled.filter((u) => u.reason !== "no-bridge") +} + +const REASON_PHRASE: Record = { + "invalid-connection": "no usable connection", + // The engine records transport construction, connect AND list failures under + // this one reason, so the phrase must not claim more than "could not be reached". + "spawn-failed": "server could not be started or reached", + "catalog-missing": "no longer in the catalog", + "unknown-key": "not offered by the integration", + exception: "failed to load", + "no-bridge": "needs a VS Code window", } -/** Extension-declared tools a connected IDE bridge is actually serving. Zero - * is the normal no-IDE case and says nothing — absent extension tools are - * expected, not missing, so they never join `describeMissing`. */ -export function describeExtensionServed(count: number): string { - if (count === 0) return "" - return ` Plus ${count} extension tool${count === 1 ? "" : "s"} via the connected VS Code window.` +/** A reason in the user's words. An unknown reason (a newer engine) is shown + * verbatim rather than dropped. */ +export function reasonPhrase(reason: string): string { + return (REASON_PHRASE as Record)[reason] ?? reason } /** What each outcome MEANS, as tables over the whole union: a new variant diff --git a/packages/opencode/src/altimate/workspace/status-view.ts b/packages/opencode/src/altimate/workspace/status-view.ts new file mode 100644 index 000000000..ee8484efe --- /dev/null +++ b/packages/opencode/src/altimate/workspace/status-view.ts @@ -0,0 +1,217 @@ +// altimate_change - new file +// +// What the last session got from its workspace, per integration — the view +// behind `/workspace` → Status and the sidebar's counts line. Built from the +// overlay's attach snapshot (what the engine served and what it reported it +// could not) joined to the workspace's own selection and the catalog (which +// integration each key belongs to, and its display name). +// +// TRANSPORT-AGNOSTIC, like `manage.ts`: plain data in, plain data out, no TUI +// or CLI imports, nothing printed. The dialog and the sidebar render it; a +// headless route could serve it as is. +import { AltimateApi } from "@/altimate/api/client" +import { sanitize } from "@/mcp/catalog" +import { Log } from "@/altimate/util/log" +import { attachSnapshot } from "./engine-overlay" +import type { AttachSnapshot } from "./attach-snapshot" +import { reasonPhrase, type Unfulfilled } from "./engine-types" + +const log = Log.create({ service: "altimate-workspace-status" }) + +export interface Gap { + key: string + reason: string + /** The reason in the user's words. */ + phrase: string + detail?: string +} + +/** One integration the workspace declared, and how much of it this session got. */ +export interface IntegrationRow { + id: string + name: string + /** `served`: every declared key present. `partial`: some. `missing`: none, + * with reasons. `idle`: an extension integration with no IDE bridge — expected + * without a VS Code window, not a gap. */ + state: "served" | "partial" | "missing" | "idle" + extension: boolean + declared: string[] + served: string[] + gaps: Gap[] +} + +export interface StatusView { + workspace: { id: string; name: string } + engineVersion: string | null + /** Declared keys present, over declared keys — the same pair the toast says. */ + served: number + declared: number | undefined + /** Gaps the engine reported, excluding the expected no-bridge case. */ + gaps: number + extServed: number + at: number + rows: IntegrationRow[] + /** Keys the engine served beyond the allowlist (knowledge, memory). */ + extras: string[] +} + +interface SelectionIntegration { + id: string + tools?: { key: string }[] +} +interface CatalogEntry { + id: string + name: string + type?: string +} + +/** Join the snapshot to the selection and the catalog. Pure. A key the engine + * reported for an integration the selection no longer lists still gets a row, + * named by its id, so a report is never silently dropped. */ +export function buildStatusView( + snapshot: AttachSnapshot, + selection: SelectionIntegration[], + catalog: CatalogEntry[], +): StatusView { + const byId = new Map(catalog.map((c) => [String(c.id), c])) + const present = new Set(snapshot.present) + const reported = new Map() + for (const u of snapshot.unfulfilled ?? []) { + const list = reported.get(u.integrationId) ?? [] + list.push(u) + reported.set(u.integrationId, list) + } + const rows: IntegrationRow[] = [] + const declaredKeys = new Set() + // Never a key the engine reports unfulfilled: two raw keys can sanitise to one catalog name. + const reportedKeys = new Set((snapshot.unfulfilled ?? []).map((u) => u.key)) + const seen = new Set() + for (const integration of selection) { + const id = String(integration.id) + seen.add(id) + const entry = byId.get(id) + const declared = (integration.tools ?? []).map((t) => t.key) + for (const k of declared) declaredKeys.add(k) + const served = declared.filter((k) => present.has(sanitize(k)) && !reportedKeys.has(k)) + const gaps = toGaps(reported.get(id) ?? []) + const extension = entry?.type === "extension" + rows.push({ + id, + name: entry?.name ?? `Integration ${id}`, + extension, + declared, + served, + gaps, + state: rowState({ declared, served, gaps, extension }), + }) + } + // Reported for an integration the selection does not carry: keep it visible. + for (const [id, list] of reported) { + if (seen.has(id)) continue + const gaps = toGaps(list) + rows.push({ + id, + name: byId.get(id)?.name ?? `Integration ${id}`, + extension: byId.get(id)?.type === "extension", + declared: list.map((u) => u.key), + served: [], + gaps, + state: gaps.length > 0 ? "missing" : "idle", + }) + } + rows.sort(byAttention) + const extras = snapshot.present.filter((k) => !declaredKeys.has(k)).sort() + const declaredCount = snapshot.declared?.keys.length + // Counted per catalog entry: declarations that sanitise to one name are one tool. + const served = snapshot.declared + ? new Set(snapshot.declared.keys.filter((k) => present.has(sanitize(k)) && !reportedKeys.has(k)).map(sanitize)).size + : present.size + const gapCount = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + return { + workspace: snapshot.workspace, + engineVersion: snapshot.engineVersion, + served, + declared: declaredCount, + gaps: gapCount, + extServed: snapshot.extServed, + at: snapshot.at, + rows, + extras, + } +} + +function toGaps(list: Unfulfilled[]): Gap[] { + return list + .filter((u) => u.reason !== "no-bridge") + .map((u) => ({ + key: u.key, + reason: u.reason, + phrase: reasonPhrase(u.reason), + ...(u.detail ? { detail: u.detail } : {}), + })) +} + +function rowState(row: { + declared: string[] + served: string[] + gaps: Gap[] + extension: boolean +}): IntegrationRow["state"] { + if (row.declared.length > 0 && row.served.length === row.declared.length) return "served" + if (row.served.length > 0) return "partial" + if (row.gaps.length > 0) return "missing" + // Nothing served and nothing reported wrong: an extension waiting for its + // window, or an integration the engine had nothing to say about. + return row.extension ? "idle" : row.declared.length === 0 ? "served" : "idle" +} + +/** Rows that need attention first, then partial, then served, then idle. */ +const ORDER: Record = { missing: 0, partial: 1, served: 2, idle: 3 } +function byAttention(a: IntegrationRow, b: IntegrationRow): number { + return ORDER[a.state] - ORDER[b.state] || a.name.localeCompare(b.name) +} + +/** The headline the dialog and the sidebar share: counts only. */ +export function statusHeadline(view: Pick): string { + const parts = [ + view.declared === undefined + ? `${view.served} integration tools available` + : `${view.served} of ${view.declared} integration tools available`, + ] + if (view.gaps > 0) parts.push(`${view.gaps} need${view.gaps === 1 ? "s" : ""} attention`) + if (view.extServed > 0) parts.push(`${view.extServed} more via VS Code`) + return parts.join(" · ") +} + +/** One line for a row: counts and, when something is wrong, why. */ +export function rowLine(row: IntegrationRow): string { + const counts = row.declared.length > 0 ? `${row.served.length} of ${row.declared.length}` : `${row.served.length}` + if (row.state === "idle") return `${counts} · needs a VS Code window open on this project` + if (row.gaps.length === 0) return counts + const phrases = [...new Set(row.gaps.map((g) => g.phrase))] + const detail = row.gaps.find((g) => g.detail)?.detail + return `${counts} · ${phrases.join("; ")}${detail ? ` (${detail})` : ""}` +} + +/** Load the view for a directory: the snapshot from memory, the selection and + * the catalog from the API. Null when no session has attached there yet; the + * snapshot alone (rows named by id) when the API cannot be reached, so a + * network blip does not hide what the session already knows. */ +export async function loadStatusView(directory: string): Promise { + const snapshot = attachSnapshot(directory) + if (!snapshot) return null + try { + const [workspace, catalog] = await Promise.all([ + AltimateApi.getDatamate(snapshot.workspace.id), + AltimateApi.listIntegrations(), + ]) + return buildStatusView( + snapshot, + (workspace.integrations ?? []).map((i) => ({ id: String(i.id), tools: i.tools })), + catalog.map((c) => ({ id: String(c.id), name: c.name ?? `Integration ${c.id}`, type: c.type })), + ) + } catch (err) { + log.warn("could not load the workspace selection for the status view", { err: String(err) }) + return buildStatusView(snapshot, [], []) + } +} diff --git a/packages/opencode/src/altimate/workspace/welcome-lines.ts b/packages/opencode/src/altimate/workspace/welcome-lines.ts new file mode 100644 index 000000000..92cf2e169 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/welcome-lines.ts @@ -0,0 +1,60 @@ +// altimate_change - new file +// +// The three lines the boot box shows under "What is Altimate Code" when the +// CLI runs in workspace mode: which mode and workspace, which slash commands +// the mode adds, and what the last session got from the workspace. Pure, so +// the plugin that renders them stays a thin view. +import type { AttachSnapshot } from "./attach-snapshot" +import { sanitize } from "@/mcp/catalog" +import type { CachedBinding } from "./state" +import { statusHeadline } from "./status-view" + +export interface WelcomeLines { + /** "Workspace mode · linked to …" or the unlinked variant. */ + mode: string + /** The slash commands workspace mode adds, with what each does. */ + commands: string + /** What the last session got, or what will happen on the first message. */ + integrations: string +} + +/** The commands workspace mode registers in the palette. Kept here rather + * than read from the palette so the line is stable and testable; the plugin + * that registers them is the same one that renders this. */ +export const WORKSPACE_COMMANDS = "/workspace — status, refresh, sync, unlink · /skills — the workspace's skills" + +export function welcomeLines(input: { + binding: CachedBinding | null + snapshot: AttachSnapshot | undefined +}): WelcomeLines { + const { binding, snapshot } = input + if (!binding) { + return { + mode: "Workspace mode · this project is not linked", + commands: "altimate-code link — bind this project to a workspace, then the commands below apply", + integrations: "Integrations: none until the project is linked", + } + } + const current = snapshot && snapshot.workspace.id === String(binding.datamateId) ? snapshot : undefined + if (!current) { + return { + mode: `Workspace mode · linked to ${binding.datamateName}`, + commands: WORKSPACE_COMMANDS, + integrations: "Integrations: attach on your first message", + } + } + const present = new Set(current.present) + const declared = current.declared?.keys.length + // Never a key the engine reports unfulfilled: two raw keys can sanitise to one catalog name. + const reported = new Set((current.unfulfilled ?? []).map((u) => u.key)) + // Counted per catalog entry: declarations that sanitise to one name are one tool. + const served = current.declared + ? new Set(current.declared.keys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).map(sanitize)).size + : present.size + const gaps = (current.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + return { + mode: `Workspace mode · linked to ${binding.datamateName}`, + commands: WORKSPACE_COMMANDS, + integrations: `Integrations: ${statusHeadline({ served, declared, gaps, extServed: current.extServed, rows: [] })}${gaps > 0 ? " — /workspace for the reasons" : ""}`, + } +} diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index f7fbcf3ec..0f8c7aff5 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -15,6 +15,22 @@ import z from "zod/v4" const DEFAULT_TIMEOUT = 30_000 const MAX_LIST_PAGES = 1_000 +// altimate_change start — keep the `_meta` of a server's last tools/list. +// `paginate` keeps only each page's items, so the result object — the sole +// carrier of `_meta` — is dropped. The workspace engine reports the allowlist +// keys it could not serve there (altimate/workspace/engine-types). Kept per +// client and committed only when a listing COMPLETES: the last page that +// carries a `_meta` wins, a listing with none clears it, and a listing that +// is still pending or that failed leaves the previous value standing — so the +// tools and their report, which the caller commits together, never describe +// two different listings. (multi-model review) +const listMetaByClient = new WeakMap>() + +export function listMeta(client: Client): Record | undefined { + return listMetaByClient.get(client) +} +// altimate_change end + // altimate_change start — Microsoft Fabric Core MCP returns `null` (instead of // omitting the field) for `tool.annotations.{readOnlyHint,destructiveHint, // idempotentHint,openWorldHint}`, which the SDK's strict schema (boolean, @@ -58,9 +74,18 @@ export async function paginate( throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`) } +// altimate_change start — `defs` is the tools half of `defsWithMeta`: a listing +// and its own `_meta` as one value. The caller commits the pair; reading the +// per-client `listMeta` after the fact could hand it another listing's `_meta` +// when two refreshes overlap. (codex) export function defs(client: Client, timeout?: number) { + return defsWithMeta(client, timeout).pipe(Effect.map((listing) => listing?.tools)) +} + +export function defsWithMeta(client: Client, timeout?: number) { return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) } +// altimate_change end export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool { const inputSchema: JSONSchema7 = { @@ -150,8 +175,11 @@ export function resources(client: Client, timeout?: number) { function listTools(client: Client, timeout: number) { return Effect.tryPromise({ - try: () => - paginate( + // altimate_change start — `_meta` is committed with the completed listing (see listMeta). + try: async () => { + let meta: Record | undefined + const tools = await paginate( + // altimate_change end async (cursor) => { const params = cursor === undefined ? undefined : { cursor } try { @@ -169,8 +197,17 @@ function listTools(client: Client, timeout: number) { // altimate_change end } }, - (result) => result.tools, - ), + // altimate_change start — the last page that carries a `_meta` wins. + (result) => { + if (result._meta !== undefined) meta = result._meta as Record + return result.tools + }, + ) + if (meta === undefined) listMetaByClient.delete(client) + else listMetaByClient.set(client, meta) + return { tools, meta } + }, + // altimate_change end catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d5dbb65b9..cf6ae63c3 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -272,6 +272,9 @@ interface CreateResult { mcpClient?: MCPClient status: Status defs?: MCPToolDef[] + // altimate_change start — the `_meta` of the listing `defs` came from, committed with it + meta?: Record + // altimate_change end // altimate_change start — carry transport label for census telemetry transport?: TransportLabel // altimate_change end @@ -290,6 +293,11 @@ interface State { status: Record clients: Record defs: Record + // altimate_change start — the `_meta` of the listing `defs` came from, committed + // in the same statement as `defs` so a reader never pairs one listing's tools + // with another's report (see Interface.snapshot). + meta: Record | undefined> + // altimate_change end } export interface Interface { @@ -332,6 +340,18 @@ export interface Interface { // without re-deriving the merge. readonly entry: (name: string) => Effect.Effect // altimate_change end + // altimate_change start — the `_meta` of a connected server's last tools/list + // (undefined while not connected, or when the server sent none). The + // workspace engine reports the allowlist keys it could not serve there. + readonly listMeta: (name: string) => Effect.Effect | undefined> + // The tools of every connected server and one server's `_meta`, read in a + // single pass over the state so they come from the same listings: a refresh + // that lands between two separate reads cannot pair old tools with a new + // report, or the reverse. What the workspace overlay reconciles from. + readonly snapshot: ( + name: string, + ) => Effect.Effect<{ tools: Record; meta: Record | undefined }> + // altimate_change end } export class Service extends Context.Service()("@opencode/MCP") {} @@ -659,18 +679,22 @@ export const layer = Layer.effect( } return yield* Effect.gen(function* () { - // altimate_change — McpCatalog.defs() tolerates both outputSchema - // reference errors and Fabric-style null annotation hints (#792). - const listed = mcpClient.getServerCapabilities()?.tools - ? yield* McpCatalog.defs(mcpClient, mcp.timeout) - : [] - if (!listed) { + // altimate_change start — McpCatalog.defsWithMeta() tolerates both outputSchema + // reference errors and Fabric-style null annotation hints (#792), and hands + // back the listing with its own `_meta`. + const listing = mcpClient.getServerCapabilities()?.tools + ? yield* McpCatalog.defsWithMeta(mcpClient, mcp.timeout) + : { tools: [], meta: undefined } + if (!listing) { return yield* Effect.fail(new Error("Failed to get tools")) } + // altimate_change end // altimate_change start — fire-and-forget census telemetry once tools are listed - if (transport) trackCensus(key, transport, listed.length) + if (transport) trackCensus(key, transport, listing.tools.length) + // altimate_change end + // altimate_change start — the pair, committed together by the caller + return { mcpClient, status, defs: listing.tools, meta: listing.meta, transport } satisfies CreateResult // altimate_change end - return { mcpClient, status, defs: listed, transport } satisfies CreateResult }).pipe( Effect.catchCause((cause) => Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))), @@ -717,6 +741,9 @@ export const layer = Layer.effect( if (s.clients[name] !== client) return delete s.clients[name] delete s.defs[name] + // altimate_change start — the report goes with the listing + delete s.meta[name] + // altimate_change end s.status[name] = { status: "failed", error: "Connection closed" } bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( @@ -734,13 +761,20 @@ export const layer = Layer.effect( client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - // altimate_change — matches create(): McpCatalog.defs() tolerates - // annotation-null tools on a live tool-list refresh (#792). - const listed = await bridge.promise(McpCatalog.defs(client, timeout)) - if (!listed) return + // altimate_change start — matches create(): McpCatalog.defsWithMeta() tolerates + // annotation-null tools on a live tool-list refresh (#792) and hands back the + // listing with its own `_meta`. + const listing = await bridge.promise(McpCatalog.defsWithMeta(client, timeout)) + if (!listing) return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + // altimate_change end - s.defs[name] = listed + // altimate_change start — tools and THEIR report land in one statement: the + // pair the listing returned, not a per-client value another refresh + // may have overwritten while this one was awaiting. (codex) + s.defs[name] = listing.tools + s.meta[name] = listing.meta + // altimate_change end await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) }) } @@ -773,6 +807,9 @@ export const layer = Layer.effect( status: {}, clients: {}, defs: {}, + // altimate_change start — see State.meta + meta: {}, + // altimate_change end } // altimate_change start — auto-discover MCP servers from external AI tool configs @@ -804,6 +841,9 @@ export const layer = Layer.effect( if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! + // altimate_change start — the report goes with the listing + s.meta[key] = result.meta + // altimate_change end watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -870,6 +910,9 @@ export const layer = Layer.effect( const client = s.clients[name] delete s.clients[name] delete s.defs[name] + // altimate_change start — the report goes with the listing + delete s.meta[name] + // altimate_change end if (!client) return Effect.void return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) } @@ -879,6 +922,9 @@ export const layer = Layer.effect( name: string, client: MCPClient, listed: MCPToolDef[], + // altimate_change start — the listing's own `_meta`, committed beside it + meta: Record | undefined, + // altimate_change end timeout?: number, ) { const bridge = yield* EffectBridge.make() @@ -886,6 +932,9 @@ export const layer = Layer.effect( s.status[name] = { status: "connected" } s.clients[name] = client s.defs[name] = listed + // altimate_change start — the report goes with the listing + s.meta[name] = meta + // altimate_change end watch(s, name, client, bridge, timeout) if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore) return s.status[name] @@ -915,6 +964,28 @@ export const layer = Layer.effect( return s.clients }) + // altimate_change start — see Interface.listMeta / Interface.snapshot + const listMeta = Effect.fn("MCP.listMeta")(function* (name: string) { + const s = yield* InstanceState.get(state) + if (!s.clients[name] || s.status[name]?.status !== "connected") return undefined + return s.meta[name] + }) + + const snapshot = Effect.fn("MCP.snapshot")(function* (name: string) { + // The config first: it is the one read that can suspend. What follows is + // one synchronous pass over the state, so a listing committed by another + // fiber lands either wholly before it or wholly after. + const cfg = yield* cfgSvc.get() + const s = yield* InstanceState.get(state) + const { result, missing } = toolsFrom(s, cfg) + for (const clientName of missing) { + yield* Effect.logWarning("missing cached tools for connected server", { clientName }) + } + const meta = s.clients[name] && s.status[name]?.status === "connected" ? s.meta[name] : undefined + return { tools: result, meta } + }) + // altimate_change end + const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) { const s = yield* InstanceState.get(state) const result = yield* create(name, mcp) @@ -926,7 +997,9 @@ export const layer = Layer.effect( return result.status } - return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) + // altimate_change start — the listing's `_meta` rides along + return yield* storeClient(s, name, result.mcpClient, result.defs!, result.meta, mcp.timeout) + // altimate_change end }) const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCPV1.Info) { @@ -1044,13 +1117,12 @@ export const layer = Layer.effect( return s.config[name]?.timeout ?? staticTimeout ?? fallback } - const tools = Effect.fn("MCP.tools")(function* () { - // altimate_change start — values carry the original client name (see Interface.tools). + // altimate_change start — the synchronous half of `tools`, shared with `snapshot` + // so the two read the same state in one pass. Values carry the original client + // name (see Interface.tools). + function toolsFrom(s: State, cfg: Effect.Success>) { const result: Record = {} - // altimate_change end - const s = yield* InstanceState.get(state) - - const cfg = yield* cfgSvc.get() + const missing: string[] = [] const config = cfg.mcp ?? {} const defaultTimeout = cfg.experimental?.mcp_timeout @@ -1059,19 +1131,29 @@ export const layer = Layer.effect( const mcpConfig = config[clientName] const listed = s.defs[clientName] if (!listed) { - yield* Effect.logWarning("missing cached tools for connected server", { clientName }) + missing.push(clientName) continue } const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const mcpTool of listed) { const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name) - // altimate_change start — attach the original client name for source classification downstream. + // attach the original client name for source classification downstream. result[key] = Object.assign(McpCatalog.convertTool(mcpTool, client, timeout), { client: clientName }) - // altimate_change end } } + return { result, missing } + } + + const tools = Effect.fn("MCP.tools")(function* () { + const s = yield* InstanceState.get(state) + const cfg = yield* cfgSvc.get() + const { result, missing } = toolsFrom(s, cfg) + for (const clientName of missing) { + yield* Effect.logWarning("missing cached tools for connected server", { clientName }) + } return result }) + // altimate_change end function collectFromConnected( s: State, @@ -1247,21 +1329,23 @@ export const layer = Layer.effect( Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)), ) - // altimate_change — McpCatalog.defs() tolerates annotation-null tools so - // they don't block the post-OAuth connect from completing (#792). - const listed = client + // altimate_change start — McpCatalog.defsWithMeta() tolerates annotation-null tools so + // they don't block the post-OAuth connect from completing (#792), and hands back the + // listing with its own `_meta`. + const listing = client ? client.getServerCapabilities()?.tools - ? yield* McpCatalog.defs(client, mcpConfig.timeout) - : [] + ? yield* McpCatalog.defsWithMeta(client, mcpConfig.timeout) + : { tools: [], meta: undefined } : undefined - if (!client || !listed) { + if (!client || !listing) { yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore) return { status: "failed", error: "Failed to get tools" } satisfies Status } const s = yield* InstanceState.get(state) yield* auth.clearOAuthState(mcpName) - return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout) + return yield* storeClient(s, mcpName, client, listing.tools, listing.meta, mcpConfig.timeout) + // altimate_change end } const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) @@ -1360,6 +1444,10 @@ export const layer = Layer.effect( return Service.of({ status, clients, + // altimate_change start + listMeta, + snapshot, + // altimate_change end tools, prompts, resources, @@ -1413,6 +1501,15 @@ export async function status() { export async function tools() { return runMcp((svc) => svc.tools()) } +// altimate_change start — see Interface.listMeta / Interface.snapshot +export async function snapshot(name: string) { + return runMcp((svc) => svc.snapshot(name)) +} + +export async function listMeta(name: string) { + return runMcp((svc) => svc.listMeta(name)) +} +// altimate_change end // altimate_change start — see Interface.entry export async function entry(name: string) { return runMcp((svc) => svc.entry(name)) diff --git a/packages/opencode/src/plugin/tui/altimate/index.ts b/packages/opencode/src/plugin/tui/altimate/index.ts index 8e90e364b..2ba576bbf 100644 --- a/packages/opencode/src/plugin/tui/altimate/index.ts +++ b/packages/opencode/src/plugin/tui/altimate/index.ts @@ -16,6 +16,7 @@ import SkillOps from "./skill-ops" import TraceViewer from "./trace-viewer" import Workspace from "./workspace" import WorkspaceSidebar from "./workspace-sidebar" +import WorkspaceWelcome from "./workspace-welcome" // Feature plugins are registered here as they are ported from the pre-merge sources on `main` // (see the ADR re-home plan). Each lives in its own file under this directory and default-exports @@ -32,6 +33,6 @@ export function altimateTuiPlugins(_flags: Pick(null) // altimate_change end + // altimate_change start - what the last session got, in numbers + const [attachLine, setAttachLine] = createSignal(null) + const readAttachLine = (bound: CachedBinding | null) => { + const snapshot = attachSnapshot(props.api.state.path.directory) + // Only for the workspace this project is bound to now; a snapshot from a + // previous binding would describe the wrong workspace under this name. + if (!snapshot || !bound || snapshot.workspace.id !== String(bound.datamateId)) return setAttachLine(null) + const present = new Set(snapshot.present) + const declared = snapshot.declared?.keys.length + const reported = new Set((snapshot.unfulfilled ?? []).map((u) => u.key)) + const served = snapshot.declared + ? new Set(snapshot.declared.keys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).map(sanitize)).size + : present.size + const gaps = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + setAttachLine(statusHeadline({ served, declared, gaps, extServed: snapshot.extServed, rows: [] })) + } + // altimate_change end let refreshInFlight = false let refreshQueued = false @@ -172,6 +194,9 @@ function View(props: { api: TuiPluginApi }) { setBinding(null) } const b = binding() + // altimate_change start - what the last session got, in numbers + readAttachLine(b ?? null) + // altimate_change end // No clear here: every path that reaches this with no binding has already // cleared the manage URL, or never set one. if (!b) return @@ -243,43 +268,30 @@ function View(props: { api: TuiPluginApi }) { {(b) => ( <> {/* Clicking the name (or the URL line below) opens the workspace - * in the browser — the manage URL is deterministic from tenant - * + id (see resolveManageBase above), so there's no extra - * round-trip before it's clickable. The whole line is the click - * target (mouse events only land on block-level ``/``, - * not inline ``/`` nodes), while only the name itself - * is styled to look like a link — matching the footer's docs/ - * community links (sidebar/footer.tsx), which use the same - * span-style + onMouseUp pair because raw `` hyperlink - * nodes crash in this JSX layer. ``onMouseUp`` is omitted - * entirely (not just a no-op) when there's no URL yet, so the - * name never advertises a click target that does nothing. The - * "pinned via --workspace" hint lives on its own line below - * (rather than appended inline here) so the click region - * doesn't extend over text that isn't part of the link — same - * reasoning as the URL line already being separate. (multi-model - * review, PR #1274.) */} - openManageUrl(props.api, manageUrl()!) : undefined}> + * in the browser — the manage URL is deterministic from tenant + * + id (see resolveManageBase above), so there's no extra + * round-trip before it's clickable. The whole line is the click + * target (mouse events only land on block-level ``/``, + * not inline ``/`` nodes), while only the name itself + * is styled to look like a link — matching the footer's docs/ + * community links (sidebar/footer.tsx), which use the same + * span-style + onMouseUp pair because raw `` hyperlink + * nodes crash in this JSX layer. ``onMouseUp`` is omitted + * entirely (not just a no-op) when there's no URL yet, so the + * name never advertises a click target that does nothing. The + * "pinned via --workspace" hint lives on its own line below + * (rather than appended inline here) so the click region + * doesn't extend over text that isn't part of the link — same + * reasoning as the URL line already being separate. (multi-model + * review, PR #1274.) */} + openManageUrl(props.api, manageUrl()!) : undefined} + > {(_u) => {b().datamateName}} - {/* ``pinned via --workspace`` means "this SESSION was launched - * with --workspace and it resolved to this id". It does NOT - * mean "the current binding was set by --workspace" — if the - * user relinks mid-session to a different workspace, the pin - * disappears (id mismatch); if they relink to the same id, - * the pin correctly stays because the launch fact is - * unchanged. Known imprecision: relink-to-same-id looks - * indistinguishable from "never relinked". Accepted per - * altimate-harness-bot round 8 (option b of the review). - * ``getResolvedWorkspaceId`` returns null when the launch - * had no --workspace flag or the flag failed to resolve, - * so the pin never falsely appears for a session that - * wasn't launched with the flag. */} - - (pinned via --workspace) - {/* altimate_change start - status lines: what has drifted, so the * reason to run `/workspace` is visible before you need it. */} @@ -294,6 +306,26 @@ function View(props: { api: TuiPluginApi }) { {(at) => {`skills synced ${describeAge(at())}`}} {/* altimate_change end */} + {/* ``pinned via --workspace`` means "this SESSION was launched + * with --workspace and it resolved to this id". It does NOT + * mean "the current binding was set by --workspace" — if the + * user relinks mid-session to a different workspace, the pin + * disappears (id mismatch); if they relink to the same id, + * the pin correctly stays because the launch fact is + * unchanged. Known imprecision: relink-to-same-id looks + * indistinguishable from "never relinked". Accepted per + * altimate-harness-bot round 8 (option b of the review). + * ``getResolvedWorkspaceId`` returns null when the launch + * had no --workspace flag or the flag failed to resolve, + * so the pin never falsely appears for a session that + * wasn't launched with the flag. */} + {/* altimate_change start - the toast's numbers, kept visible; + * the reasons are under /workspace → Status */} + {(line) => {line()} · /workspace} + {/* altimate_change end */} + + (pinned via --workspace) + {(u) => ( openManageUrl(props.api, u())}> diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-welcome.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-welcome.tsx new file mode 100644 index 000000000..b382e6af5 --- /dev/null +++ b/packages/opencode/src/plugin/tui/altimate/workspace-welcome.tsx @@ -0,0 +1,69 @@ +// altimate_change - new file +// The workspace-mode block inside the boot box, under "What is Altimate Code": +// which mode and workspace this is, the slash commands the mode adds, and what +// the last session got from the workspace. Registered only under the +// ALTIMATE_WORKSPACE flag (see ./index.ts), so outside workspace mode the box +// is unchanged. Read-only, like the sidebar tile: the binding from its cache +// file, the attach outcome from its snapshot file. +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" +import { createSignal, onCleanup, onMount } from "solid-js" +import { readLocalBinding, type CachedBinding } from "@/altimate/workspace/state" +import { attachSnapshot } from "@/altimate/workspace/engine-overlay" +import { welcomeLines, type WelcomeLines } from "@/altimate/workspace/welcome-lines" + +const id = "altimate:welcome-workspace" + +/** The box is on screen before the first message and through the session, + * so the integrations line has to pick up the attach after it settles; a + * short poll of two small files is the cheapest way without an event bus. */ +const POLL_MS = 5_000 + +function View(props: { api: TuiPluginApi }) { + const theme = () => props.api.theme.current + const [lines, setLines] = createSignal(null) + let inFlight = false + const refresh = async () => { + if (inFlight) return + inFlight = true + try { + const dir = props.api.state.path.directory + const binding: CachedBinding | null = await readLocalBinding(dir).catch(() => null) + setLines(welcomeLines({ binding, snapshot: attachSnapshot(dir) })) + } finally { + inFlight = false + } + } + onMount(() => { + void refresh() + const timer = setInterval(() => void refresh(), POLL_MS) + onCleanup(() => clearInterval(timer)) + }) + const current = () => lines() + return ( + + + {current()?.mode ?? "Workspace mode"} + + + {current()?.commands ?? ""} + + + {current()?.integrations ?? ""} + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 100, + slots: { + welcome_extra() { + return + }, + }, + }) +} + +export default { id, tui } satisfies BuiltinTuiPlugin diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 228a279aa..4c3569952 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -29,6 +29,8 @@ import open from "open" // altimate_change start - the /workspace action menu import * as Manage from "@/altimate/workspace/manage" import { inertWorkspaceName } from "@/altimate/workspace/workspace-name" +import { attachSnapshot } from "@/altimate/workspace/engine-overlay" +import { loadStatusView, rowLine, statusHeadline, type IntegrationRow } from "@/altimate/workspace/status-view" // altimate_change end import { createSignal, onCleanup, onMount } from "solid-js" import { @@ -48,11 +50,7 @@ import { resolveWorkspaceWebUrl, type HandoffResult, } from "@/altimate/workspace/browser-handoff" -import { - projectNameFromPath, - projectNameFromRemote, - resolveProjectIdentifier, -} from "@/altimate/workspace/detect" +import { projectNameFromPath, projectNameFromRemote, resolveProjectIdentifier } from "@/altimate/workspace/detect" import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" import { describeOffer, @@ -124,12 +122,7 @@ function skipKey(id: ProjectIdentifier, scope: LatchScope | null): string { ) } -function isSkipActive( - api: TuiPluginApi, - id: ProjectIdentifier, - scope: LatchScope | null, - nowMs: number, -): boolean { +function isSkipActive(api: TuiPluginApi, id: ProjectIdentifier, scope: LatchScope | null, nowMs: number): boolean { const rec = api.kv.get<{ skippedAt: number }>(skipKey(id, scope)) if (!rec || typeof rec.skippedAt !== "number") return false // Reject records timestamped in the future — a system-clock rewind after @@ -142,12 +135,7 @@ function isSkipActive( return delta < SKIP_TTL_MS } -function recordSkip( - api: TuiPluginApi, - id: ProjectIdentifier, - scope: LatchScope | null, - nowMs: number, -): void { +function recordSkip(api: TuiPluginApi, id: ProjectIdentifier, scope: LatchScope | null, nowMs: number): void { api.kv.set(skipKey(id, scope), { skippedAt: nowMs }) } @@ -238,9 +226,7 @@ function OfferDialog(props: OfferProps) { return } // link → picker (fresh-project attach path) - props.api.ui.dialog.replace(() => ( - - )) + props.api.ui.dialog.replace(() => ) }} /> ) @@ -347,11 +333,7 @@ let activeHandoffAbort: AbortController | null = null * returned workspace via the existing ``POST /bind`` endpoint. Every failure * mode surfaces as a toast; the user can always fall back to another option * by re-invoking the dialog. */ -async function runBrowserHandoff( - api: TuiPluginApi, - identifier: ProjectIdentifier, - projectName: string, -): Promise { +async function runBrowserHandoff(api: TuiPluginApi, identifier: ProjectIdentifier, projectName: string): Promise { api.ui.dialog.clear() api.ui.toast({ variant: "info", @@ -383,10 +365,7 @@ async function runBrowserHandoff( // credentials we're about to bind under, and refuse if either drifted. try { const fresh = await AltimateApi.getCredentials() - if ( - fresh.altimateInstanceName !== result.credentials.tenant || - fresh.altimateUrl !== result.credentials.apiUrl - ) { + if (fresh.altimateInstanceName !== result.credentials.tenant || fresh.altimateUrl !== result.credentials.apiUrl) { api.ui.toast({ variant: "error", message: `Your Altimate credentials changed while the browser was open (was ${result.credentials.tenant}, now ${fresh.altimateInstanceName}). Re-run to link this project.`, @@ -445,7 +424,8 @@ function toastHandoffFailure(api: TuiPluginApi, result: Extract { // stored — the repo was renamed / remote swapped. The dialog surfaces // this so the user isn't silently attached to a stale binding. (M3) const boundIdent = - serverBinding.matchedBy === "remote" - ? serverBinding.binding.repo_remote - : serverBinding.binding.project_path - const currentIdent = - serverBinding.matchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + serverBinding.matchedBy === "remote" ? serverBinding.binding.repo_remote : serverBinding.binding.project_path + const currentIdent = serverBinding.matchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = boundIdent != null && currentIdent != null && boundIdent !== currentIdent // Resolved before the dialog renders — see AlreadyLinkedDialog's comment // on why this can't be fetched async inside the dialog itself. @@ -1193,8 +1160,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { // ordering as the server-side pre-check: remote first, path fallback. const cachedMatchedBy: MatchedIdentifier = local.repoRemote ? "remote" : "path" const cachedIdent = local.repoRemote ?? local.projectPath ?? "" - const currentIdent = - cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + const currentIdent = cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = cachedIdent !== "" && currentIdent != null && cachedIdent !== currentIdent const manageUrl = await resolveManageUrl(local.datamateId) api.ui.dialog.replace(() => ( @@ -1276,12 +1242,7 @@ async function awaitKvReady( /** Same clock-rewind handling as the post-scan latch; the TTL is the one the * attach side's announce dedupe expires on, so both agree on "7 days". */ -function isEngineSkipActive( - api: TuiPluginApi, - workspaceId: string, - scope: LatchScope | null, - nowMs: number, -): boolean { +function isEngineSkipActive(api: TuiPluginApi, workspaceId: string, scope: LatchScope | null, nowMs: number): boolean { const rec = api.kv.get<{ skippedAt: number }>(engineSkipKey(workspaceId, scope)) if (!rec || typeof rec.skippedAt !== "number") return false const delta = nowMs - rec.skippedAt @@ -1289,12 +1250,7 @@ function isEngineSkipActive( return delta < OFFER_SKIP_TTL_MS } -function recordEngineSkip( - api: TuiPluginApi, - workspaceId: string, - scope: LatchScope | null, - nowMs: number, -): void { +function recordEngineSkip(api: TuiPluginApi, workspaceId: string, scope: LatchScope | null, nowMs: number): void { api.kv.set(engineSkipKey(workspaceId, scope), { skippedAt: nowMs }) } @@ -1771,6 +1727,151 @@ function syncMessage(result: Manage.SyncReport): string { return parts.join(", ") + "." } +/** The description of the Status row: counts from the last attach, or why + * there are none yet. Read from memory, so the menu opens without waiting. */ +function statusRowDescription(directory: string, boundId: number): string { + const snapshot = attachSnapshot(directory) + // A snapshot from a workspace this project was since re-linked away from is + // about the wrong workspace; say nothing rather than something stale. + if (!snapshot || snapshot.workspace.id !== String(boundId)) { + return "No session has attached yet — send a message first." + } + const present = new Set(snapshot.present) + const declared = snapshot.declared?.keys.length + const served = snapshot.declared ? snapshot.declared.keys.filter((k) => present.has(k)).length : present.size + const gaps = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + return statusHeadline({ served, declared, gaps, extServed: snapshot.extServed, rows: [] }) +} + +const STATE_MARK: Record = { + served: "●", + partial: "◐", + missing: "○", + idle: "◌", +} + +/** `/workspace` → Status: what the last session got from each integration + * and why, the detail the attach toast now only points at. Rows are + * informational; the actions open the workspace on the web or re-read. */ +async function showWorkspaceStatus(api: TuiPluginApi, directory: string, boundId: number): Promise { + const view = await loadStatusView(directory) + if (!view || view.workspace.id !== String(boundId)) { + api.ui.dialog.replace(() => ( + api.ui.dialog.clear()} + /> + )) + return + } + const manageUrl = await resolveManageUrl(Number(view.workspace.id)) + const title = `${view.workspace.name} · ${statusHeadline(view)}` + // The plugin's DialogSelect renders a row's footer inline with its title, + // which squeezes the title to a few characters, so the keys go on sub-rows + // under each integration instead — ordinary rows, since the dialog hides + // disabled ones: gaps with their reason first, then what is available, + // capped so a 40-tool integration stays readable. + const rows: { title: string; value: string; description?: string; category: string }[] = [] + for (const row of view.rows) { + rows.push({ + title: `${STATE_MARK[row.state]} ${row.name}`, + value: `row:${row.id}`, + description: rowLine(row), + category: "Integrations", + }) + for (const line of rowDetails(row)) { + rows.push({ + title: ` ${line.key}`, + value: `key:${row.id}:${line.key}`, + description: line.note, + category: "Integrations", + }) + } + } + if (view.extras.length > 0) { + rows.push({ + title: `${STATE_MARK.served} Workspace extras`, + value: "row:extras", + description: `${view.extras.length} beyond the allowlist (knowledge, memory)`, + category: "Integrations", + }) + for (const line of capped( + view.extras.map((key) => ({ key, note: "available" })), + 4, + )) { + rows.push({ + title: ` ${line.key}`, + value: `key:extras:${line.key}`, + description: line.note, + category: "Integrations", + }) + } + } + const actions = [ + ...(manageUrl + ? [ + { + title: "Open on the web", + value: "open", + description: "Connections and the selection live there.", + category: "Actions", + }, + ] + : []), + { + title: "Re-read", + value: "reread", + description: `Read the selection and the last attach again${view.engineVersion ? ` (engine ${view.engineVersion})` : ""}.`, + category: "Actions", + }, + { title: "Done", value: "done", description: "Close this view.", category: "Actions" }, + ] + api.ui.dialog.replace(() => ( + { + if (option.value === "open" && manageUrl) { + api.ui.dialog.clear() + openManageUrl(api, manageUrl) + return + } + if (option.value === "reread") { + showWorkspaceStatus(api, directory, boundId).catch((err) => reportFlowFailure(api, err)) + return + } + // A key row is information, not an action: choosing it keeps the view open. + if (String(option.value).startsWith("key:")) return + api.ui.dialog.clear() + }} + /> + )) +} + +/** The sub-rows under an integration: gaps with their reason, then what is + * available (or would be through a VS Code window), capped. */ +function rowDetails(row: IntegrationRow): { key: string; note: string }[] { + const gaps = row.gaps.map((gap) => ({ key: gap.key, note: `${gap.phrase}${gap.detail ? ` (${gap.detail})` : ""}` })) + const served = row.served.map((key) => ({ key, note: "available" })) + const idle = row.state === "idle" ? row.declared.map((key) => ({ key, note: "via VS Code" })) : [] + return [...capped(gaps, 6), ...capped(served, 4), ...capped(idle, 4)] +} + +/** The first `max` lines, then one line saying how many were left out. */ +function capped(lines: { key: string; note: string }[], max: number): { key: string; note: string }[] { + if (lines.length <= max) return lines + return [...lines.slice(0, max), { key: `+${lines.length - max} more`, note: "" }] +} + /** The `/workspace` menu. */ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise { const report = await Manage.status(directory) @@ -1782,6 +1883,11 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise options={ linked ? [ + { + title: "Status", + value: "status", + description: statusRowDescription(directory, report.binding!.datamateId), + }, { title: "Refresh", value: "refresh", @@ -1805,8 +1911,12 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise }, ] } - current={linked ? "refresh" : "done"} + current={linked ? "status" : "done"} onSelect={(option) => { + if (option.value === "status") { + showWorkspaceStatus(api, directory, report.binding!.datamateId).catch((err) => reportFlowFailure(api, err)) + return + } if (option.value === "unlink") { confirmUnlink(api, directory, report.binding?.datamateName ?? "this workspace") return @@ -1911,9 +2021,7 @@ const tui: TuiPlugin = async (api) => { run() { // User-initiated → jump straight to picker (currently-linked marked, // "+ Create new" as the first row). No Skip funnel — they invoked. - runOnDemandPicker(api, api.state.path.directory).catch((err) => - reportFlowFailure(api, err), - ) + runOnDemandPicker(api, api.state.path.directory).catch((err) => reportFlowFailure(api, err)) }, }, ], 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/attach-snapshot.test.ts b/packages/opencode/test/altimate/workspace/attach-snapshot.test.ts new file mode 100644 index 000000000..07c5114c0 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/attach-snapshot.test.ts @@ -0,0 +1,59 @@ +// The attach snapshot file: what the overlay writes for the TUI process to +// read. Sandboxed state directory, like manage.test.ts. +import { afterAll, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" + +const SANDBOX = mkdtempSync(path.join(tmpdir(), "attach-snapshot-")) +const ORIGINAL_XDG_STATE_HOME = process.env.XDG_STATE_HOME +process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") +afterAll(() => { + if (ORIGINAL_XDG_STATE_HOME === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = ORIGINAL_XDG_STATE_HOME + rmSync(SANDBOX, { recursive: true, force: true }) +}) + +const { readAttachSnapshot, writeAttachSnapshot, snapshotPath } = await import( + "../../../src/altimate/workspace/attach-snapshot" +) + +const snap = (at: number, id = "6") => ({ + workspace: { id, name: "e2e-demo-live" }, + engineVersion: "0.7.2", + declared: { keys: ["a", "b"], extensionKeys: [] }, + present: ["a"], + unfulfilled: [{ key: "b", integrationId: "jira", reason: "invalid-connection" }], + extServed: 0, + at, +}) + +describe("attach snapshot file", () => { + beforeEach(() => rmSync(snapshotPath(), { force: true })) + + test("round-trips per directory, latest attach wins, and a directory with none reads undefined", () => { + writeAttachSnapshot("/proj/a", snap(1)) + writeAttachSnapshot("/proj/b", snap(2, "7")) + writeAttachSnapshot("/proj/a", snap(3)) + expect(readAttachSnapshot("/proj/a")).toEqual(snap(3)) + expect(readAttachSnapshot("/proj/b")).toEqual(snap(2, "7")) + expect(readAttachSnapshot("/proj/c")).toBeUndefined() + }) + + test("keeps the newest 64 directories", () => { + for (let i = 0; i < 70; i++) writeAttachSnapshot(`/proj/${i}`, snap(i)) + expect(readAttachSnapshot("/proj/0")).toBeUndefined() + expect(readAttachSnapshot("/proj/5")).toBeUndefined() + expect(readAttachSnapshot("/proj/6")).toEqual(snap(6)) + expect(readAttachSnapshot("/proj/69")).toEqual(snap(69)) + }) + + test("a corrupt file reads as empty and is replaced by the next write", () => { + const { mkdirSync, writeFileSync } = require("node:fs") as typeof import("node:fs") + mkdirSync(path.dirname(snapshotPath()), { recursive: true }) + writeFileSync(snapshotPath(), "{not json") + expect(readAttachSnapshot("/proj/a")).toBeUndefined() + writeAttachSnapshot("/proj/a", snap(1)) + expect(readAttachSnapshot("/proj/a")).toEqual(snap(1)) + }) +}) 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 4ec56fd29..a0b3ddd2a 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -100,6 +100,8 @@ function install(opts: { add: async () => {}, remove: async () => {}, tools: async () => ({}), + listMeta: async () => undefined, + snapshot: async () => ({ tools: {}, meta: undefined }), } return h } diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 3b4ee48d6..d5cd9347a 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -20,13 +20,17 @@ import { resetForTests, settledOutcome, syncInternals, + UNFULFILLED_META_KEY, trackedSessionsForTests, type Declared, type LocalMcpConfig, type McpEntry, type Toast, + attachSnapshot, } 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 type { AttachReport } from "../../../src/altimate/workspace/attach-report" import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" const DIR = "/tmp/analytics" @@ -44,12 +48,15 @@ type Harness = { statusError?: string onAdd?: () => void tools: Record + meta: Record | null added: Array removes: number gets: number invalidates: number probes: number toasts: Toast[] + persisted: AttachSnapshot[] + reports: { datamateId: string; report: AttachReport }[] lines: string[] clock: number /** Whether MCP holds a client under the key — set when MCP "bootstraps" from @@ -70,6 +77,8 @@ function install(opts: { statusError?: string onAdd?: () => void tools?: Record + /** The engine's tools/list `_meta`; `null` models an engine that sends none. */ + meta?: Record | null mcp?: Record noMcpKey?: boolean managed?: boolean @@ -78,17 +87,20 @@ function install(opts: { config: opts.noMcpKey ? {} : { mcp: opts.mcp ?? {} }, binding: opts.binding === undefined ? bound(42) : opts.binding, which: opts.which === undefined ? "/usr/local/bin/datamate" : opts.which, - version: opts.version === undefined ? "0.7.1" : opts.version, + version: opts.version === undefined ? "0.7.2" : opts.version, status: opts.status ?? "connected", statusError: opts.statusError, onAdd: opts.onAdd, tools: opts.tools ?? { datamate_dbt_build_model: {}, datamate_dbt_compile_model: {} }, + meta: opts.meta === undefined ? { [UNFULFILLED_META_KEY]: [] } : opts.meta, added: [], removes: 0, gets: 0, invalidates: 0, probes: 0, toasts: [], + persisted: [], + reports: [], lines: [], clock: 1_000_000, fingerprint: "bin-1", @@ -107,9 +119,15 @@ function install(opts: { opts.declared === undefined ? { keys: ["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"], extensionKeys: [] } : opts.declared + syncInternals.persistSnapshot = (_dir, snap) => { + h.persisted.push(snap) + } 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) } @@ -131,6 +149,8 @@ function install(opts: { h.removes += 1 }, tools: async () => h.tools, + listMeta: async () => h.meta ?? undefined, + snapshot: async () => ({ tools: h.tools, meta: h.meta ?? undefined }), } // Models the real Config cache: `get` loads once and is then served from // cache until `invalidate`; a load rebuilds the config from its sources (so @@ -390,17 +410,27 @@ describe("beforeTurn — what a turn boundary does", () => { }) test("a connected engine settles attached with the inventory and announces it once", async () => { - const h = install({}) + const report = [{ key: "dbt_execute_sql", integrationId: "dbt", reason: "invalid-connection" }] + const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) await beforeTurn("s1") expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, declared: 3, missing: ["dbt_execute_sql"], + unfulfilled: report, }) expect(h.toasts).toHaveLength(1) - expect(h.toasts[0].message).toContain("2 of 3 declared integration tools available") - expect(h.toasts[0].message).toContain("dbt_execute_sql") + expect(h.toasts[0].message).toBe("2 of 3 integration tools available · 1 needs attention. Details: /workspace") + expect(h.toasts[0].variant).toBe("warning") + const snap = attachSnapshot(DIR)! + expect(snap.workspace).toEqual({ id: String(h.binding!.datamateId), name: h.binding!.datamateName }) + expect([...snap.present].sort()).toEqual(["dbt_build_model", "dbt_compile_model"]) + expect(snap.unfulfilled).toEqual(report) + expect(snap.extServed).toBe(0) + expect(snap.declared?.keys).toEqual(["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"]) + // Persisted for the TUI process, which cannot see this one's memory. + expect(h.persisted).toEqual([snap]) // The engine was started by MCP bootstrap from the injected entry, not by the hook. expect(h.added).toEqual([]) await beforeTurn("s1") @@ -414,15 +444,15 @@ describe("beforeTurn — what a turn boundary does", () => { declared: { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] }, }) await beforeTurn("s1") - expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [] }) - expect(h.toasts[0].message).toBe("2 of 2 declared integration tools available.") + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [], unfulfilled: [] }) + expect(h.toasts[0].message).toBe("2 of 2 integration tools available. Details: /workspace") }) test("attached without an allowlist reports only what is available", async () => { const h = install({ declared: null }) await beforeTurn("s1") - expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2 }) - expect(h.toasts[0].message).toBe("2 integration tools available.") + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, missing: [], unfulfilled: [] }) + expect(h.toasts[0].message).toBe("2 integration tools available. Details: /workspace") }) test("extension tools a live bridge serves are announced; absent ones are expected, not missing", async () => { @@ -433,13 +463,126 @@ describe("beforeTurn — what a turn boundary does", () => { await beforeTurn("s1") // `run_model` is declared extension-type but no bridge serves it: that is // the normal no-IDE case, so the outcome stays clean and unwarned. - expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [] }) - expect(h.toasts[0].message).toBe( - "2 of 2 declared integration tools available. Plus 1 extension tool via the connected VS Code window.", - ) + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [], unfulfilled: [] }) + expect(h.toasts[0].message).toBe("2 of 2 integration tools available · 1 more via VS Code. Details: /workspace") + expect(h.toasts[0].variant).toBe("info") + }) + + test("gaps come from the engine's report, with reasons — not from a client-side diff", async () => { + // The report names a key the allowlist lookup never saw (`gh_list_prs`): + // it is still a gap, because the engine says so. + const report = [ + { key: "dbt_execute_sql", integrationId: "dbt", reason: "invalid-connection" }, + { key: "gh_list_prs", integrationId: "github-mcp", reason: "spawn-failed", detail: "spawn docker ENOENT" }, + { key: "gh_create_pr", integrationId: "github-mcp", reason: "spawn-failed", detail: "spawn docker ENOENT" }, + ] + const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toMatchObject({ missing: ["dbt_execute_sql", "gh_list_prs", "gh_create_pr"] }) + expect(h.toasts[0].message).toBe("2 of 3 integration tools available · 3 need attention. Details: /workspace") + expect(h.toasts[0].variant).toBe("warning") + }) + + test("the headline counts in the catalog's key space, and never a key the report names", async () => { + // `foo.bar` and `foo_bar` both sanitise to the served `datamate_foo_bar`; + // the report says which of them the tool stands for. Counting both would + // print "2 of 2" over a gap line naming `foo.bar`. (multi-model review; codex) + const report = [{ key: "foo.bar", integrationId: "i", reason: "unknown-key" }] + const h = install({ + declared: { keys: ["foo.bar", "foo_bar"], extensionKeys: [] }, + tools: { datamate_foo_bar: {} }, + meta: { [UNFULFILLED_META_KEY]: report }, + }) + await beforeTurn("s1") + // This branch's toast is one line; the reason for `foo.bar` lives under /workspace → Status. + expect(h.toasts[0].message).toBe("1 of 2 integration tools available · 1 needs attention. Details: /workspace") + }) + + test("two declarations that sanitise to one catalog entry count once, even with nothing reported", async () => { + // The engine listed both `foo.bar` and `foo_bar`, so it reports neither; the + // MCP catalog keeps one `datamate_foo_bar`, so one tool is callable. (codex) + const h = install({ + declared: { keys: ["foo.bar", "foo_bar"], extensionKeys: [] }, + tools: { datamate_foo_bar: {} }, + meta: { [UNFULFILLED_META_KEY]: [] }, + }) + await beforeTurn("s1") + expect(h.toasts[0].message).toBe("1 of 2 integration tools available. Details: /workspace") + }) + + test("a collision across the ordinary and extension groups is one entry, counted once", async () => { + // `foo.bar` declared as an ordinary key and `foo_bar` as an extension key + // are one `datamate_foo_bar`; it counts with the ordinary keys and not + // again as an extension tool. (codex) + const h = install({ + declared: { keys: ["foo.bar"], extensionKeys: ["foo_bar"] }, + tools: { datamate_foo_bar: {} }, + meta: { [UNFULFILLED_META_KEY]: [] }, + }) + await beforeTurn("s1") + expect(h.toasts[0].message).toBe("1 of 1 integration tools available. Details: /workspace") + }) + + test("no-bridge entries in the report are expected, never missing", async () => { + const report = [ + { key: "get_projects", integrationId: "vscode-power-user", reason: "no-bridge" }, + { key: "run_model", integrationId: "vscode-power-user", reason: "no-bridge" }, + ] + const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ + kind: "attached", + available: 2, + declared: 3, + missing: [], + unfulfilled: report, + }) + expect(h.toasts[0].message).toBe("2 of 3 integration tools available. Details: /workspace") expect(h.toasts[0].variant).toBe("info") }) + test("an engine that sends no report is not read as having no gaps", async () => { + const h = install({ meta: null }) + await beforeTurn("s1") + // Two of three declared keys are present; without the engine's report + // the third is neither claimed missing nor claimed served. + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, declared: 3 }) + expect(h.toasts[0].message).toBe("2 of 3 integration tools available. Details: /workspace") + expect(h.toasts[0].variant).toBe("info") + }) + + test("the report names gaps even when the allowlist lookup failed", async () => { + const report = [{ key: "jira_search_issues", integrationId: "jira", reason: "invalid-connection" }] + const h = install({ declared: null, meta: { [UNFULFILLED_META_KEY]: report } }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ + kind: "attached", + available: 2, + missing: ["jira_search_issues"], + unfulfilled: report, + }) + expect(h.toasts[0].message).toBe("2 integration tools available · 1 needs attention. Details: /workspace") + expect(h.toasts[0].variant).toBe("warning") + }) + + test("a gap whose reason changed is announced again", 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.toasts).toHaveLength(1) + h.meta = { + [UNFULFILLED_META_KEY]: [{ key: "gh_list_prs", integrationId: "github-mcp", reason: "invalid-connection" }], + } + await beforeTurn("s1") + expect(h.toasts).toHaveLength(2) + // The toast carries numbers only; the changed reason is in the snapshot the + // status view reads. + expect(h.toasts[1].message).toBe("2 of 3 integration tools available · 1 needs attention. Details: /workspace") + expect(attachSnapshot(DIR)?.unfulfilled?.map((u) => u.reason)).toEqual(["invalid-connection"]) + }) + test("the inventory is announced per session, not per process", async () => { const h = install({}) await beforeTurn("s1") @@ -801,7 +944,7 @@ describe("beforeTurn — what a turn boundary does", () => { test("a failed probe is repeated on its own after the TTL", async () => { const h = install({ version: "0.6.3" }) await beforeTurn("s1") - h.version = "0.7.1" + h.version = "0.7.2" h.clock += FAILED_PROBE_TTL_MS await beforeTurn("s1") expect(h.added).toHaveLength(1) @@ -931,3 +1074,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) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-types.test.ts b/packages/opencode/test/altimate/workspace/engine-types.test.ts index 091f39b03..f61e75722 100644 --- a/packages/opencode/test/altimate/workspace/engine-types.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-types.test.ts @@ -12,7 +12,9 @@ import { attributableEngine, clearsFloor, compareVersions, - describeMissing, + parseUnfulfilled, + reportedMissing, + UNFULFILLED_META_KEY, describeRefusal, engineEntry, engineToolKeys, @@ -20,6 +22,7 @@ import { installWouldHelp, pinnedWorkspace, type Outcome, + reasonPhrase, } from "../../../src/altimate/workspace/engine-types" describe("compareVersions", () => { @@ -53,10 +56,11 @@ describe("clearsFloor", () => { expect(clearsFloor(null)).toBe(false) expect(clearsFloor("")).toBe(false) expect(clearsFloor(MIN_ENGINE_VERSION)).toBe(true) - expect(clearsFloor("0.7.1")).toBe(true) + expect(clearsFloor("0.7.2")).toBe(true) expect(clearsFloor("1.0.0")).toBe(true) expect(clearsFloor("0.6.9")).toBe(false) - expect(clearsFloor("0.7.0")).toBe(false) // the previous floor no longer clears + expect(clearsFloor("0.7.1")).toBe(false) // the previous floor no longer clears: no unfulfilled report + expect(clearsFloor("0.7.0")).toBe(false) expect(clearsFloor(`${MIN_ENGINE_VERSION}-beta.1`)).toBe(false) expect(clearsFloor("0.7rc.0")).toBe(false) }) @@ -153,11 +157,59 @@ describe("messages", () => { "Update with: npm i -g @altimateai/datamate@next", ) }) - test("the missing list is truncated after five", () => { - expect(describeMissing([])).toBe("") - expect(describeMissing(["a", "b"])).toBe(" Declared but not available: a, b.") - expect(describeMissing(["a", "b", "c", "d", "e", "f", "g"])).toBe( - " Declared but not available: a, b, c, d, e (+2 more).", - ) + test("a reason is named in the user's words, and an unknown one is kept verbatim", () => { + expect(reasonPhrase("invalid-connection")).toBe("no usable connection") + expect(reasonPhrase("spawn-failed")).toBe("server could not be started or reached") + expect(reasonPhrase("catalog-missing")).toBe("no longer in the catalog") + expect(reasonPhrase("unknown-key")).toBe("not offered by the integration") + expect(reasonPhrase("exception")).toBe("failed to load") + expect(reasonPhrase("no-bridge")).toBe("needs a VS Code window") + expect(reasonPhrase("quota-exceeded")).toBe("quota-exceeded") + }) + + + test("an entry with a malformed detail is a malformed report, not a report missing a field", () => { + // Dropping the field and accepting the rest would announce a gap on the + // strength of a report that failed its own contract. (codex) + const meta = (detail: unknown) => ({ + [UNFULFILLED_META_KEY]: [{ key: "x", integrationId: "i", reason: "exception", detail }], + }) + expect(parseUnfulfilled(meta(42))).toBeUndefined() + expect(parseUnfulfilled(meta(null))).toBeUndefined() + expect(parseUnfulfilled(meta({ code: "ENOENT" }))).toBeUndefined() + expect(parseUnfulfilled(meta("boom"))).toEqual([{ key: "x", integrationId: "i", reason: "exception", detail: "boom" }]) + expect(parseUnfulfilled(meta(""))).toEqual([{ key: "x", integrationId: "i", reason: "exception" }]) + expect(parseUnfulfilled(meta(undefined))).toEqual([{ key: "x", integrationId: "i", reason: "exception" }]) + }) + + test("the engine's report is read out of tools/list _meta, and nothing is invented", () => { + const report = [ + { key: "a", integrationId: "jira", reason: "invalid-connection" }, + { key: "b", integrationId: "gh", reason: "spawn-failed", detail: "spawn docker ENOENT" }, + { key: "c", integrationId: "pu", reason: "no-bridge", detail: "" }, + ] + expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: report })).toEqual([ + report[0], + report[1], + { key: "c", integrationId: "pu", reason: "no-bridge" }, + ]) + expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: [] })).toEqual([]) + // A custom integration's id arrives as a number from the engine; it is a string here. + expect( + parseUnfulfilled({ [UNFULFILLED_META_KEY]: [{ key: "demo_tool", integrationId: 7, reason: "spawn-failed" }] }), + ).toEqual([{ key: "demo_tool", integrationId: "7", reason: "spawn-failed" }]) + expect(parseUnfulfilled(undefined)).toBeUndefined() + expect(parseUnfulfilled({})).toBeUndefined() + expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: "nope" })).toBeUndefined() + expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: [{ key: "a" }] })).toBeUndefined() + }) + + test("no-bridge entries are the only ones kept out of the missing set", () => { + const report = [ + { key: "a", integrationId: "jira", reason: "invalid-connection" }, + { key: "b", integrationId: "pu", reason: "no-bridge" }, + { key: "c", integrationId: "pu", reason: "unknown-key" }, + ] + expect(reportedMissing(report).map((u) => u.key)).toEqual(["a", "c"]) }) }) diff --git a/packages/opencode/test/altimate/workspace/status-view.test.ts b/packages/opencode/test/altimate/workspace/status-view.test.ts new file mode 100644 index 000000000..e89749fe2 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/status-view.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import type { AttachSnapshot } from "../../../src/altimate/workspace/attach-snapshot" +import { buildStatusView, rowLine, statusHeadline } from "../../../src/altimate/workspace/status-view" + +const snapshot = (over: Partial = {}): AttachSnapshot => ({ + workspace: { id: "6", name: "e2e-demo-live" }, + engineVersion: "0.7.2", + declared: { + keys: ["altimate_a", "altimate_b", "jira_search", "jira_create", "demo_tool"], + extensionKeys: ["get_projects"], + }, + present: ["altimate_a", "altimate_b", "altimate_knowledge_search"], + unfulfilled: [ + { key: "jira_search", integrationId: "jira", reason: "invalid-connection" }, + { key: "jira_create", integrationId: "jira", reason: "invalid-connection" }, + { key: "demo_tool", integrationId: "1", reason: "spawn-failed", detail: "altimate-demo-missing-mcp: ENOENT" }, + { key: "get_projects", integrationId: "power-user-for-dbt", reason: "no-bridge" }, + ], + extServed: 0, + at: 1, + ...over, +}) +const selection = [ + { id: "altimate", tools: [{ key: "altimate_a" }, { key: "altimate_b" }] }, + { id: "jira", tools: [{ key: "jira_search" }, { key: "jira_create" }] }, + { id: "power-user-for-dbt", tools: [{ key: "get_projects" }] }, + { id: "1", tools: [{ key: "demo_tool" }] }, +] +const catalog = [ + { id: "altimate", name: "Altimate", type: "tool" }, + { id: "jira", name: "Jira", type: "tool" }, + { id: "power-user-for-dbt", name: "Power User for dbt", type: "extension" }, +] + +describe("buildStatusView", () => { + test("one row per declared integration, attention first, named from the catalog with an id fallback", () => { + const view = buildStatusView(snapshot(), selection, catalog) + expect(view.rows.map((r) => [r.name, r.state])).toEqual([ + ["Integration 1", "missing"], + ["Jira", "missing"], + ["Altimate", "served"], + ["Power User for dbt", "idle"], + ]) + const jira = view.rows.find((r) => r.name === "Jira")! + expect(jira.gaps.map((g) => g.phrase)).toEqual(["no usable connection", "no usable connection"]) + expect(rowLine(jira)).toBe("0 of 2 · no usable connection") + expect(rowLine(view.rows[0]!)).toBe("0 of 1 · server could not be started or reached (altimate-demo-missing-mcp: ENOENT)") + expect(rowLine(view.rows.find((r) => r.name === "Altimate")!)).toBe("2 of 2") + expect(rowLine(view.rows.find((r) => r.name === "Power User for dbt")!)).toBe( + "0 of 1 · needs a VS Code window open on this project", + ) + }) + + test("counts match the toast: declared keys present over declared, gaps without no-bridge, extras beyond the allowlist", () => { + const view = buildStatusView(snapshot(), selection, catalog) + expect(view.served).toBe(2) + expect(view.declared).toBe(5) + expect(view.gaps).toBe(3) + expect(view.extras).toEqual(["altimate_knowledge_search"]) + expect(statusHeadline(view)).toBe("2 of 5 integration tools available · 3 need attention") + }) + + test("a partially served integration and a live bridge read as such", () => { + const view = buildStatusView( + snapshot({ + present: ["altimate_a", "get_projects"], + unfulfilled: [{ key: "altimate_b", integrationId: "altimate", reason: "exception" }], + extServed: 1, + }), + selection, + catalog, + ) + const altimate = view.rows.find((r) => r.name === "Altimate")! + expect(altimate.state).toBe("partial") + expect(rowLine(altimate)).toBe("1 of 2 · failed to load") + expect(view.rows.find((r) => r.name === "Power User for dbt")!.state).toBe("served") + expect(statusHeadline(view)).toBe("1 of 5 integration tools available · 1 needs attention · 1 more via VS Code") + }) + + test("a report for an integration the selection no longer lists still gets a row", () => { + const view = buildStatusView( + snapshot({ unfulfilled: [{ key: "old_tool", integrationId: "retired", reason: "catalog-missing" }] }), + [{ id: "altimate", tools: [{ key: "altimate_a" }] }], + catalog, + ) + expect(view.rows.map((r) => [r.name, r.state])).toEqual([ + ["Integration retired", "missing"], + ["Altimate", "served"], + ]) + }) + + test("without an allowlist the headline counts what the engine serves", () => { + const view = buildStatusView(snapshot({ declared: null, unfulfilled: undefined }), [], []) + expect(view.declared).toBeUndefined() + expect(statusHeadline(view)).toBe("3 integration tools available") + expect(view.rows).toEqual([]) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/welcome-lines.test.ts b/packages/opencode/test/altimate/workspace/welcome-lines.test.ts new file mode 100644 index 000000000..c2e7f8c6b --- /dev/null +++ b/packages/opencode/test/altimate/workspace/welcome-lines.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { welcomeLines, WORKSPACE_COMMANDS } from "../../../src/altimate/workspace/welcome-lines" + +const binding = { + datamateId: 6, + datamateName: "e2e-demo-live", + repoRemote: null, + projectPath: "/proj", + linkedAt: 1, +} +const snapshot = (id = "6") => ({ + workspace: { id, name: "e2e-demo-live" }, + engineVersion: "0.7.2", + declared: { keys: ["a", "b", "c"], extensionKeys: ["x"] }, + present: ["a", "x"], + unfulfilled: [ + { key: "b", integrationId: "jira", reason: "invalid-connection" }, + { key: "c", integrationId: "jira", reason: "invalid-connection" }, + ], + extServed: 1, + at: 1, +}) + +describe("welcomeLines", () => { + test("unlinked: says so, and points at the link command rather than the menu", () => { + const lines = welcomeLines({ binding: null, snapshot: snapshot() }) + expect(lines.mode).toBe("Workspace mode · this project is not linked") + expect(lines.commands).toContain("altimate-code link") + expect(lines.integrations).toBe("Integrations: none until the project is linked") + }) + + test("linked before any session: names the workspace and promises the attach", () => { + const lines = welcomeLines({ binding, snapshot: undefined }) + expect(lines.mode).toBe("Workspace mode · linked to e2e-demo-live") + expect(lines.commands).toBe(WORKSPACE_COMMANDS) + expect(lines.integrations).toBe("Integrations: attach on your first message") + }) + + test("after a session: the toast's numbers, with a pointer when something needs attention", () => { + const lines = welcomeLines({ binding, snapshot: snapshot() }) + expect(lines.integrations).toBe( + "Integrations: 1 of 3 integration tools available · 2 need attention · 1 more via VS Code — /workspace for the reasons", + ) + }) + + test("a snapshot from another workspace is ignored", () => { + const lines = welcomeLines({ binding, snapshot: snapshot("9") }) + expect(lines.integrations).toBe("Integrations: attach on your first message") + }) +}) diff --git a/packages/opencode/test/mcp/catalog-list-meta.test.ts b/packages/opencode/test/mcp/catalog-list-meta.test.ts new file mode 100644 index 000000000..302d84798 --- /dev/null +++ b/packages/opencode/test/mcp/catalog-list-meta.test.ts @@ -0,0 +1,128 @@ +// altimate_change - new file +// +// The MCP catalog keeps the `_meta` of a server's last tools/list page per +// client (the workspace engine reports unserved allowlist keys there), even +// though pagination keeps only the tools themselves. +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import * as McpCatalog from "../../src/mcp/catalog" + +const KEY = "ai.altimate/unfulfilled" + +async function connected(listTools: () => Record) { + const server = new Server({ name: "fake", version: "0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, async () => listTools() as never) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: "test", version: "0" }) + await client.connect(clientTransport) + return { client, close: () => Promise.all([client.close(), server.close()]) } +} + +const echo = { name: "echo", description: "", inputSchema: { type: "object", properties: {} } } + +describe("McpCatalog.listMeta", () => { + test("keeps the last tools/list page's _meta next to the listed tools", async () => { + const report = [{ key: "jira_search_issues", integrationId: "jira", reason: "invalid-connection" }] + const { client, close } = await connected(() => ({ tools: [echo], _meta: { [KEY]: report } })) + try { + expect(McpCatalog.listMeta(client)).toBeUndefined() + const defs = await Effect.runPromise(McpCatalog.defs(client)) + expect(defs?.map((t) => t.name)).toEqual(["echo"]) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: report }) + } finally { + await close() + } + }) + + test("a listing that carries no _meta clears what an earlier one left", async () => { + let withMeta = true + const { client, close } = await connected(() => + withMeta ? { tools: [echo], _meta: { [KEY]: [] } } : { tools: [echo] }, + ) + try { + await Effect.runPromise(McpCatalog.defs(client)) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [] }) + withMeta = false + await Effect.runPromise(McpCatalog.defs(client)) + expect(McpCatalog.listMeta(client)).toBeUndefined() + } finally { + await close() + } + }) + + test("_meta survives the multi-page path", async () => { + let page = 0 + const { client, close } = await connected(() => { + page += 1 + return page === 1 + ? { tools: [echo], nextCursor: "p2" } + : { tools: [{ ...echo, name: "echo2" }], _meta: { [KEY]: [] } } + }) + try { + const defs = await Effect.runPromise(McpCatalog.defs(client)) + expect(defs?.map((t) => t.name)).toEqual(["echo", "echo2"]) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [] }) + } finally { + await close() + } + }) + + test("a _meta on the first page is kept when the last page carries none", async () => { + // The rule is "the last page that carries one wins", stated so it is not + // mistaken for per-page clearing. (multi-model review) + let page = 0 + const { client, close } = await connected(() => { + page += 1 + return page === 1 + ? { tools: [echo], nextCursor: "p2", _meta: { [KEY]: [{ key: "x", integrationId: "i", reason: "unknown-key" }] } } + : { tools: [{ ...echo, name: "echo2" }] } + }) + try { + await Effect.runPromise(McpCatalog.defs(client)) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [{ key: "x", integrationId: "i", reason: "unknown-key" }] }) + } finally { + await close() + } + }) + + test("a listing that fails part-way leaves the previous _meta standing", async () => { + // Cleared at the start of a listing, a refresh that failed on its second + // page left the tools of the last good listing beside no report at all. + let attempt = 0 + let page = 0 + const { client, close } = await connected(() => { + if (attempt === 0) return { tools: [echo], _meta: { [KEY]: [] } } + page += 1 + if (page === 1) return { tools: [echo], nextCursor: "p2", _meta: { [KEY]: [{ key: "y", integrationId: "i", reason: "exception" }] } } + throw new Error("second page exploded") + }) + try { + await Effect.runPromise(McpCatalog.defs(client)) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [] }) + attempt = 1 + expect(await Effect.runPromise(McpCatalog.defs(client))).toBeUndefined() + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [] }) + } finally { + await close() + } + }) + + test("defsWithMeta hands back the listing and its own _meta as one value", async () => { + // What the MCP service commits: the pair from THIS listing, not the + // per-client value a later listing may have overwritten meanwhile. (codex) + const report = [{ key: "k", integrationId: "i", reason: "unknown-key" }] + const { client, close } = await connected(() => ({ tools: [echo], _meta: { [KEY]: report } })) + try { + const listing = await Effect.runPromise(McpCatalog.defsWithMeta(client)) + expect(listing?.tools.map((t) => t.name)).toEqual(["echo"]) + expect(listing?.meta).toEqual({ [KEY]: report }) + } finally { + await close() + } + }) +}) diff --git a/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts new file mode 100644 index 000000000..7e1933d6e --- /dev/null +++ b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts @@ -0,0 +1,245 @@ +// altimate_change - new file +// +// End to end through the real MCP service: a real `@altimateai/datamate` +// engine (0.7.2+) is spawned over stdio the way the workspace overlay spawns +// it, against a fake Altimate API, and its `ai.altimate/unfulfilled` report +// arrives through the catalog as `MCP.listMeta(...)`, ready for the toast. +// +// Needs an engine checkout with a built `dist/cli.js`; skipped otherwise: +// ALTIMATE_ENGINE_E2E_ROOT=/path/to/altimate-mcp-engine bun test test/mcp/engine-unfulfilled.e2e.test.ts +import http from "node:http" +import path from "node:path" +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" +import { testEffect } from "../lib/effect" +import { MCP } from "../../src/mcp/index" +import { + reasonPhrase, + parseUnfulfilled, + reportedMissing, + UNFULFILLED_META_KEY, +} from "../../src/altimate/workspace/engine-types" + +const root = process.env["ALTIMATE_ENGINE_E2E_ROOT"] +// The engine ships as a node shebang script; under `bun test` the test runner's own +// executable is bun, which the engine cannot run on. +const node = Bun.which("node") ?? "node" +const cli = root ? path.join(root, "dist/cli.js") : undefined +const runnable = !!cli && existsSync(cli) +const it = testEffect(MCP.defaultLayer) + +const DATAMATE_ID = "77" + +// The workspace declares five integrations; the engine can serve one of them. +const catalog = [ + { + id: "jira", + type: "tool", + name: "Jira", + description: "", + url: "", + supportsLocalConnectionTest: true, + supportsSaasConnectionTest: false, + config: [ + { key: "url", name: "URL", type: "string", required: true }, + { key: "email", name: "Email", type: "string", required: true }, + { key: "token", name: "Token", type: "string", required: true }, + ], + tools: [{ key: "jira_search_issues", name: "Search issues" }], + }, + { + id: "vscode-power-user", + type: "extension", + name: "Power User for dbt", + description: "", + url: "", + supportsLocalConnectionTest: false, + supportsSaasConnectionTest: false, + config: [], + tools: [{ key: "pu_lineage", name: "Lineage" }], + }, +] +const custom = [ + { + id: "mcp-ok", + type: "mcp", + name: "Echo MCP", + description: "", + url: "", + config: [], + toolConfig: [ + { key: "type", name: "type", type: "string", required: false, value: "stdio" }, + { key: "command", name: "command", type: "string", required: true, value: node }, + { + key: "arguments", + name: "arguments", + type: "array", + required: false, + value: [path.join(import.meta.dir, "fixtures/echo-mcp-server.mjs")], + }, + ], + tools: [{ key: "echo" }, { key: "ghost" }], + }, + { + id: "mcp-missing-binary", + type: "mcp", + name: "Missing MCP", + description: "", + url: "", + config: [], + toolConfig: [ + { key: "type", name: "type", type: "string", required: false, value: "stdio" }, + { key: "command", name: "command", type: "string", required: true, value: "altimate-e2e-missing-binary" }, + ], + tools: [{ key: "whatever" }], + }, +] +const datamate = { + id: DATAMATE_ID, + name: "e2e", + description: "", + privacy: "private", + memory_enabled: false, + knowledge_engine_enabled: false, + knowledge_bases: [], + integrations: [ + { id: "jira", type: "tool", name: "Jira", description: "", url: "", tools: [{ key: "jira_search_issues" }] }, + { + id: "vscode-power-user", + type: "extension", + name: "PU", + description: "", + url: "", + tools: [{ key: "pu_lineage" }], + }, + { + id: "mcp-ok", + type: "mcp", + name: "Echo MCP", + description: "", + url: "", + tools: [{ key: "echo" }, { key: "ghost" }], + }, + { + id: "mcp-missing-binary", + type: "mcp", + name: "Missing MCP", + description: "", + url: "", + tools: [{ key: "whatever" }], + }, + { + id: "retired-integration", + type: "tool", + name: "Retired", + description: "", + url: "", + tools: [{ key: "retired_tool" }], + }, + ], +} + +async function fakeAltimateApi() { + const unhandled: string[] = [] + const server = http.createServer((req, res) => { + const p = new URL(req.url ?? "/", "http://x").pathname + const json = (code: number, body?: unknown) => { + res.writeHead(code, { "content-type": "application/json" }) + res.end(body === undefined ? "" : JSON.stringify(body)) + } + if (p === "/dbt/v3/validate-credentials") return json(200, { ok: true }) + if (p === "/datamates") return json(200, { datamates: [datamate] }) + if (p === "/datamate_integrations") return json(200, catalog) + if (p === "/datamate_integrations/custom") return json(200, { items: custom }) + if (p === "/mask") return json(200, { mask_data: [] }) + if (p === "/connections") return json(200, { connections: [] }) + if (p === `/datamates/${DATAMATE_ID}/knowledge_bases`) return json(200, { knowledge_bases: [] }) + if (p === `/datamates/${DATAMATE_ID}/knowledge_engine_description`) return json(200, {}) + if (p === "/datamates/audit/create_batch") return json(204) + unhandled.push(p) + return json(404, { detail: `unhandled ${p}` }) + }) + await new Promise((r) => server.listen(0, "127.0.0.1", r)) + const address = server.address() as { port: number } + return { url: `http://127.0.0.1:${address.port}`, unhandled, close: () => server.close() } +} + +function isolatedHome(apiUrl: string) { + const home = mkdtempSync(path.join(tmpdir(), "engine-unfulfilled-e2e-")) + mkdirSync(path.join(home, ".altimate"), { recursive: true }) + writeFileSync( + path.join(home, ".altimate/altimate.json"), + JSON.stringify({ altimateUrl: apiUrl, altimateInstanceName: "e2e", altimateApiKey: "e2e-key" }), + ) + writeFileSync(path.join(home, ".altimate/settings.json"), "{}") + writeFileSync(path.join(home, ".altimate/connections.json"), "[]") + return home +} + +describe.skipIf(!runnable)("engine unfulfilled report through the MCP service", () => { + it.instance( + "the engine's report reaches MCP.listMeta and reads as the attach toast", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + const api = yield* Effect.promise(fakeAltimateApi) + try { + const home = isolatedHome(api.url) + yield* mcp.add("datamate", { + type: "local", + command: [node, cli!, "start-stdio", "--datamate", DATAMATE_ID], + environment: { HOME: home }, + cwd: root!, + }) + const status = yield* mcp.status() + if (status["datamate"]?.status !== "connected") { + const logDir = path.join(home, ".altimate/logs") + const logs = existsSync(logDir) ? readdirSync(logDir) : [] + const tail = logs.map((f) => readFileSync(path.join(logDir, f), "utf8").slice(-1500)).join("\n") + throw new Error( + `engine not connected: ${JSON.stringify(status["datamate"])}\n--- engine log tail ---\n${tail}`, + ) + } + + const tools = yield* mcp.tools() + expect(Object.keys(tools).filter((k) => k.startsWith("datamate_"))).toEqual(["datamate_echo"]) + + const meta = yield* mcp.listMeta("datamate") + const report = parseUnfulfilled(meta) + expect(report).toBeDefined() + const byKey = Object.fromEntries(report!.map((u) => [u.key, u])) + expect(byKey["jira_search_issues"]).toMatchObject({ integrationId: "jira", reason: "invalid-connection" }) + expect(byKey["pu_lineage"]).toMatchObject({ integrationId: "vscode-power-user", reason: "no-bridge" }) + expect(byKey["ghost"]).toMatchObject({ integrationId: "mcp-ok", reason: "unknown-key" }) + expect(byKey["whatever"]).toMatchObject({ integrationId: "mcp-missing-binary", reason: "spawn-failed" }) + expect(byKey["whatever"]?.detail).toMatch(/ENOENT/) + expect(byKey["retired_tool"]).toMatchObject({ + integrationId: "retired-integration", + reason: "catalog-missing", + }) + expect(report!.some((u) => `datamate_${u.key}` in tools)).toBe(false) + + // What the status view would list on attach: every gap but the IDE one, each with its reason. + expect(reportedMissing(report!).map((u) => `${u.key}: ${reasonPhrase(u.reason)}`)).toEqual([ + "jira_search_issues: no usable connection", + "ghost: not offered by the integration", + "whatever: server could not be started or reached", + "retired_tool: no longer in the catalog", + ]) + expect(api.unhandled).toEqual([]) + yield* mcp.remove("datamate") + expect(yield* mcp.listMeta("datamate")).toBeUndefined() + } finally { + api.close() + } + }), + ), + 60_000, + ) +}) + +// Referenced so the key is visibly the contract this test exercises. +void UNFULFILLED_META_KEY diff --git a/packages/opencode/test/mcp/fixtures/echo-mcp-server.mjs b/packages/opencode/test/mcp/fixtures/echo-mcp-server.mjs new file mode 100644 index 000000000..8f78dbe07 --- /dev/null +++ b/packages/opencode/test/mcp/fixtures/echo-mcp-server.mjs @@ -0,0 +1,9 @@ +// A real MCP server over stdio offering exactly one tool, "echo" (test fixture). +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { z } from "zod" +const server = new McpServer({ name: "e2e-echo", version: "0.0.1" }) +server.tool("echo", "Echoes its input", { text: z.string() }, async ({ text }) => ({ + content: [{ type: "text", text }], +})) +await server.connect(new StdioServerTransport()) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index cf14e14c2..b2208c392 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -125,6 +125,8 @@ const mcp = Layer.succeed( status: () => Effect.succeed({}), clients: () => Effect.succeed({}), tools: () => Effect.succeed({}), + listMeta: () => Effect.succeed(undefined), + snapshot: () => Effect.succeed({ tools: {}, meta: undefined }), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index c39aa1b83..0ab4c0a69 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -38,6 +38,8 @@ const mcp = Layer.succeed( status: () => Effect.succeed({}), clients: () => Effect.succeed({}), tools: () => Effect.succeed({}), + listMeta: () => Effect.succeed(undefined), + snapshot: () => Effect.succeed({ tools: {}, meta: undefined }), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 70c15b8f4..787740baa 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -462,6 +462,9 @@ export type TuiHostSlotMap = { app: {} app_bottom: {} home_logo: {} + // altimate_change start — a line block inside the boot box, under "What is Altimate Code" + welcome_extra: {} + // altimate_change end home_prompt: { ref?: (ref: TuiPromptRef | undefined) => void } diff --git a/packages/tui/src/component/welcome-panel.tsx b/packages/tui/src/component/welcome-panel.tsx index e42982d70..e126eae17 100644 --- a/packages/tui/src/component/welcome-panel.tsx +++ b/packages/tui/src/component/welcome-panel.tsx @@ -5,6 +5,9 @@ import { Logo } from "./logo" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { useReady } from "./altimate-onboarding" import { welcomePanelVariant } from "./welcome-panel-utils" +// altimate_change start — workspace-mode lines under "What is Altimate Code" (plugin slot) +import { usePluginRuntimeOptional } from "../plugin/runtime" +// altimate_change end const CONNECT_CTA = "Connect your AI model to start." @@ -40,6 +43,12 @@ export function WelcomePanel(props: { availableWidth: number; availableHeight: n // props are reactive getters, so reading them inside the memo tracks — the // variant recomputes when the caller's dimensions/sidebar change. const variant = createMemo(() => welcomePanelVariant(props.availableWidth, props.availableHeight)) + // altimate_change start — the workspace plugin fills `welcome_extra` in + // workspace mode (mode, the commands it adds, integration status); outside + // a plugin runtime (unit tests) the slot is simply absent. + const runtime = usePluginRuntimeOptional() + const extra = () => (runtime ? : null) + // altimate_change end const title = InstallationVersion === "local" ? " Altimate Code " : ` Altimate Code v${InstallationVersion} ` @@ -81,6 +90,7 @@ export function WelcomePanel(props: { availableWidth: number; availableHeight: n {CONNECT_CTA} + {extra()} @@ -133,6 +143,7 @@ export function WelcomePanel(props: { availableWidth: number; availableHeight: n + {extra()} diff --git a/packages/tui/src/plugin/runtime.tsx b/packages/tui/src/plugin/runtime.tsx index 4130ac9be..85cf27669 100644 --- a/packages/tui/src/plugin/runtime.tsx +++ b/packages/tui/src/plugin/runtime.tsx @@ -79,3 +79,11 @@ export function usePluginRuntime() { if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider") return runtime } + +// altimate_change start — a component that is also rendered without the +// provider (the boot box, in its unit tests) asks for the runtime without +// throwing, and simply omits its slot when there is none. +export function usePluginRuntimeOptional() { + return useContext(Context) +} +// altimate_change end