diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 85531e36b3..c785b3fb99 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -37,6 +37,11 @@ const DatamateSummary = z.object({ const IntegrationSummary = z.object({ id: z.coerce.string(), name: z.string().optional(), + // altimate_change start — catalog `type` (tool | mcp | code | api | extension). + // Extension-type integrations are RPC into a live VS Code host and have no + // meaning on the CLI surface; callers filter on this. + type: z.string().optional(), + // altimate_change end description: z.string().nullable().optional(), tools: z .array( @@ -227,19 +232,33 @@ export namespace AltimateApi { async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) { const url = `${creds.altimateUrl}${endpoint}` - const res = await fetch(url, { - method, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${creds.altimateApiKey}`, - "x-tenant": creds.altimateInstanceName, - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }) - if (!res.ok) { - throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`) + // altimate_change start — upstream_fix: bound every API request. Without a + // signal a stalled server holds the caller indefinitely; the workspace attach + // awaited this on its critical path. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15_000) + // altimate_change end + try { + const res = await fetch(url, { + signal: controller.signal, + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${creds.altimateApiKey}`, + "x-tenant": creds.altimateInstanceName, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + if (!res.ok) { + throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`) + } + // The abort stays armed until the BODY is read. `fetch` resolves on + // headers, so clearing it here would leave a server that sends headers and + // then stalls mid-body hanging indefinitely — with the socket held open. + return await res.json() + } finally { + clearTimeout(timeout) } - return res.json() } export async function listDatamates() { diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 7e1bb6944d..4da3813fd9 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -8,11 +8,13 @@ import { listMcpInConfig, resolveConfigPath, findAllConfigPaths, + readMcpEntryFromDisk, } from "../../mcp/config" import { Instance } from "../../project/instance" import { Global } from "../../global" import { Log } from "@/altimate/util/log" import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" +import { pinnedWorkspace } from "../workspace/engine-sync" const log = Log.create({ service: "datamate" }) @@ -138,12 +140,27 @@ async function handleList() { async function handleListIntegrations() { try { - const integrations = await AltimateApi.listIntegrations() + const catalog = await AltimateApi.listIntegrations() + // altimate_change start — extension-type integrations need a live VS Code + // bridge and cannot work from the CLI. Hide them from this surface (the + // workspace UI still offers them), but say how many were hidden rather than + // pretending they don't exist. + const integrations = catalog.filter((i) => i.type !== "extension") + const hidden = catalog.length - integrations.length + // altimate_change end if (integrations.length === 0) { + // A catalog of nothing but extension-type entries filters down to empty, + // and the footer below never runs — so this branch used to report a + // genuinely empty catalog. Say what was hidden here too, or the model + // reports "no integrations" when the workspace in fact has several. + const omitted = + hidden > 0 + ? ` ${hidden} extension-type integration${hidden === 1 ? " was" : "s were"} omitted — they require a live VS Code bridge and are not available from the CLI.` + : "" return { - title: "Integrations: none found", - metadata: { count: 0 }, - output: "No integrations available.", + title: hidden > 0 ? `Integrations: none available on the CLI (${hidden} hidden)` : "Integrations: none found", + metadata: { count: 0, hidden }, + output: `No integrations available.${omitted}`, } } const lines = ["ID | Name | Tools", "---|------|------"] @@ -151,9 +168,17 @@ async function handleListIntegrations() { const tools = i.tools?.map((t) => t.key).join(", ") ?? "none" lines.push(`${i.id} | ${i.name} | ${tools}`) } + // altimate_change start + if (hidden > 0) { + lines.push( + "", + `(${hidden} extension-type integration${hidden === 1 ? "" : "s"} omitted — they require a live VS Code bridge and are not available from the CLI.)`, + ) + } + // altimate_change end return { title: `Integrations: ${integrations.length} available`, - metadata: { count: integrations.length }, + metadata: { count: integrations.length, hidden }, output: lines.join("\n"), } } catch (e) { @@ -167,6 +192,19 @@ async function handleListIntegrations() { // DATAMATE_KEY is imported from altimate/datamate-transport.ts (shared constant). +/** Is the configured gateway entry pinned to a DIFFERENT workspace than the one + * being asked for? + * + * The workspace attach persists this shared key with `--datamate `, so + * "configured and connected" stopped meaning "serving whatever you asked for". + * An unpinned entry is the generic gateway and still answers for any datamate; + * a pin for another workspace does not, and saying otherwise would report success + * while the runtime served another workspace's tools and credentials. */ +export function isPinnedToOtherWorkspace(entry: unknown, datamateId: string | number): boolean { + const pin = pinnedWorkspace((entry ?? null) as never) + return pin !== null && pin !== String(datamateId) +} + async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) { if (!args.datamate_id) { return { @@ -228,8 +266,35 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p }) } - if (existingNames.includes(DATAMATE_KEY)) { - // Already in config — just ensure it is connected in this session + // The workspace attach persists this same key PINNED to one workspace + // (`--datamate `), so "configured and connected" no longer means "serving + // whatever you asked for". Reporting success here would tell the user their + // datamate is connected while the runtime kept serving another workspace's + // tools — and its credentials. A pin for a different workspace is replaced, + // which is what the user asked for by naming a datamate explicitly. + const configuredEntry = await readMcpEntryFromDisk(DATAMATE_KEY, configPath) + // Attribution is a claim about the RUNNING engine, so the running engine + // gets a vote here too. The config says what should run; MCP's spawn + // record says what IS running, and they diverge while a re-pin is in + // flight — during which this branch reported "already connected via + // datamate (N tools)" about a process serving a different workspace's + // data, under this workspace's name. Judging on the config alone is the + // same defect the attach flow was fixed for, surviving in its consumer. + const runningEntry = await MCP.spawned(DATAMATE_KEY).catch(() => undefined) + const pinnedElsewhere = + isPinnedToOtherWorkspace(configuredEntry, args.datamate_id) || + (!!runningEntry && isPinnedToOtherWorkspace(runningEntry, args.datamate_id)) + if (pinnedElsewhere) { + log.info("handleAdd: existing entry is pinned to another workspace; replacing", { + serverName: DATAMATE_KEY, + pinnedTo: pinnedWorkspace((configuredEntry ?? null) as never), + runningPinnedTo: pinnedWorkspace((runningEntry ?? null) as never), + requested: args.datamate_id, + }) + } + + if (existingNames.includes(DATAMATE_KEY) && !pinnedElsewhere) { + // Already in config for THIS datamate — just ensure it is connected. const allStatus = await MCP.status() if (allStatus[DATAMATE_KEY]?.status === "connected") { log.info("handleAdd: already connected, skipping add", { @@ -258,7 +323,8 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p }) await MCP.connect(DATAMATE_KEY) } else { - // Not in config yet — write to disk then connect + // Not in config yet, or pinned to a workspace other than the one asked + // for — write to disk then connect, replacing the pin either way. log.info("handleAdd: adding new datamate entry", { serverName: DATAMATE_KEY, type: mcpConfig.type, diff --git a/packages/opencode/src/altimate/workspace/engine-chain.ts b/packages/opencode/src/altimate/workspace/engine-chain.ts new file mode 100644 index 0000000000..87caa6b677 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-chain.ts @@ -0,0 +1,48 @@ +// altimate_change - new file +// +// Per-project serialization. Two attaches for the same project must not race to +// `MCP.add`, because whichever lands LAST owns the runtime client. +import { projectRoot } from "./engine-seams" + +/** In-flight attach chain per project. + * + * Per-session ordering is not enough: the MCP client is instance-wide, not per + * session, `MCP.add` is last-writer-wins, and `SessionRunState` keeps + * independent runners per session id — so two prompts in the same project + * genuinely overlap. Without this, a slower attach from one session can land + * after another session's and leave the runtime serving a workspace nobody is + * bound to, with both memos settled so no later turn repairs it. */ +export const attachChains = new Map>() + +export function projectKey(): string { + try { + return projectRoot() + } catch { + return "" + } +} + +export function serializeAttach(fn: () => Promise): Promise { + const key = projectKey() + const previous = attachChains.get(key) ?? Promise.resolve() + // Run regardless of how the previous attach ended — a failure must not wedge + // the chain for the rest of the process. + const next = previous.then(fn, fn) + const tail = next.then( + () => {}, + () => {}, + ) + attachChains.set(key, tail) + // Drop the entry once it settles, unless another attach has already queued + // behind it — otherwise every project path a long-running server opens is + // retained for the life of the process. Bounding `sessions` did not cover this. + void tail.then(() => { + if (attachChains.get(key) === tail) attachChains.delete(key) + }) + return next +} + +/** Test seam — how many project attach chains are currently retained. */ +export function trackedChainsForTests(): number { + return attachChains.size +} diff --git a/packages/opencode/src/altimate/workspace/engine-config.ts b/packages/opencode/src/altimate/workspace/engine-config.ts new file mode 100644 index 0000000000..4319e426d7 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-config.ts @@ -0,0 +1,195 @@ +// altimate_change - new file +// +// The module's only path to configuration. Every read refreshes first, because +// a cached read after someone else's write is wrong in every case here, and the +// writers cannot be enumerated. +import { Config } from "@/config/config" +import { addMcpToConfig, readMcpEntryFromDisk, removeMcpFromConfig, resolveConfigPath } from "@/mcp/config" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { log, syncInternals, projectRoot } from "./engine-seams" +import type { ExistingEntry, LocalMcpConfig } from "./engine-types" + +/** Where this project's config lives. + * + * Exposed so a caller can resolve it BEFORE a guard rather than inside the + * write that follows one: `resolveConfigPath` probes up to nine candidate paths + * on disk, and every one of those awaits sits between the last check and the + * mutation it is supposed to protect. */ +export async function projectConfigPath(): Promise { + if (syncInternals.projectConfigPath) return syncInternals.projectConfigPath() + return resolveConfigPath(projectRoot()) +} + +/** Why a write did not happen. `written` is the ordinary case. */ +export type PersistResult = "written" | "disabled" + +export async function persist(name: string, cfg: LocalMcpConfig, configPath?: string): Promise { + if (syncInternals.persist) return (await syncInternals.persist(name, cfg)) ?? "written" + configPath = configPath ?? (await resolveConfigPath(projectRoot())) + // The check travels WITH the write rather than preceding it. `addMcpToConfig` + // replaces the whole `mcp.` node, so a disable landing after a caller's + // guard is not merely raced — it is erased, and the post-install check then + // reads the file WE just wrote and finds nothing to undo. Invisible rather + // than reverted. + // + // It is decided on the same text the write modifies, which is as close as this + // can be got: a check that reads the file separately from the write has + // checked a different read. It does NOT make the window vanish — one read and + // one write to one file is not atomic, and a disable landing between the read + // and the `write` syscall is still lost. That residual is named on the PR + // rather than papered over; closing it needs write-then-verify. + // The node on disk is the PROJECT file's; intent can also live in the global + // config the project inherits from. A global disable landing after the + // caller's merged read would not be on the text below — and a project pin + // written over it shadows that disable for good, since project wins the + // merge. So the merged view is asked once more, immediately before the + // write. Same window as the write's own read; named, not closed. + let merged: ExistingEntry | null + try { + merged = await existingEntry(name) + } catch (err) { + log.warn("could not confirm intent before writing the engine entry; not writing", { name, err: String(err) }) + return "disabled" + } + if (merged?.enabled === false) { + log.info("refusing to write a project entry over a disable in the merged config", { name }) + return "disabled" + } + if ((await addMcpToConfig(name, cfg, configPath, { refuseIfDisabled: true })) === null) { + log.info("refusing to write over an entry that is disabled on disk", { name }) + return "disabled" + } + // `Config.get()` is cached per instance, and `addMcpToConfig` is a raw file + // write that does not touch that cache — so without this, every later + // `existingEntry()` in this process still sees the pre-write config. That is + // how a managed entry becomes unrecognisable to `isManagedEntry` later in the + // same server process, leaving a stale engine attached in an unbound project. + // The local-config write path in `config.ts` invalidates for the same reason. + // NOT observable from this module's own tests, and worth saying so rather than + // leaving a claim the suite silently fails to check: every read here + // invalidates first, so a missing invalidation on the WRITE side changes + // nothing we can see. It is here for the other `Config` consumers in the + // process, which do not invalidate before reading and would otherwise serve a + // cached config that predates our write. + await Config.invalidate().catch((err) => { + log.warn("could not invalidate the config cache after persisting the engine entry", { err: String(err) }) + }) + return "written" +} + +/** The module's ONLY path to config, and it is always fresh. + * + * `Config.get()` is cached per instance, and three different writers land + * behind it: our own `addMcpToConfig`, `MCP.disconnect` writing + * `enabled: false`, and an IDE rewriting the entry — which never goes through + * `Config` at all. + * + * Enumerating the writers is therefore not possible, so freshness is structural + * at the point of READ rather than remembered at each write site. The cost is + * real and shared: invalidating drops the per-instance cache for every other + * `Config` consumer too. That is the price of not having a fourth instance. */ +export async function freshConfig(): Promise<{ mcp?: Record }> { + if (syncInternals.freshConfig) return syncInternals.freshConfig() + await Config.invalidate().catch((err) => { + log.warn("could not refresh the config cache", { err: String(err) }) + }) + return (await Config.get()) as { mcp?: Record } +} + +/** The entry in the PROJECT config only, not the merged view. + * + * `existingEntry()` returns the merged value, which may come from global config, + * while `persist()` writes to the project file. Restoring the merged value would + * write a copy of the global entry into the project — a permanent override that + * shadows every later global update, disable or removal, from an attach that was + * meant to leave configuration untouched. */ +export async function projectEntry(configPath?: string): Promise { + // The seam takes the path too, so a test can assert the snapshot is read from + // the file the write will use. A seam that never receives the argument makes + // dropping it invisible. + if (syncInternals.projectEntry) return syncInternals.projectEntry(configPath) + // THROWS rather than returning null on a read error, because the two answers + // mean opposite things to the caller: `null` says "the project file has no + // entry of its own", and a restore acts on that by REMOVING ours. Conflating + // "there was nothing here" with "I could not look" turned an unreadable + // project config into a deletion of the user's own entry. If we cannot record + // what to put back, we must not write in the first place. + // Reads the path it is GIVEN. Resolving independently means the snapshot can + // come from one file while the write goes to another — an IDE creating or + // removing a higher-priority config between the two is enough — after which + // the undo restores the first file's entry into the second, over whatever the + // user had there. + const target = configPath ?? (await resolveConfigPath(projectRoot())) + return ((await readMcpEntryFromDisk(DATAMATE_KEY, target)) as ExistingEntry | undefined) ?? null +} + +/** Put the config back the way we found it. + * + * `persist()` commits the pin BEFORE the engine is known to be ours, so a + * supersede after that point leaves the abandoned workspace pinned on disk — + * and MCP bootstraps every enabled entry, so a restart before the next attach + * would start the workspace we just walked away from. Removing the runtime + * client is only half of undoing an attach. */ +export async function persistRestore( + name: string, + previous: ExistingEntry | null, + configPath?: string, +): Promise<"restored" | "failed"> { + // The seam takes the path too, so a test can assert the undo uses the path the + // write used. Without it, dropping that argument was invisible: the stub + // discarded what it was never given. + if (syncInternals.persistRestore) return (await syncInternals.persistRestore(name, previous, configPath)) ?? "restored" + try { + // The SAME path the write used, not a fresh resolution: re-resolving can + // pick a different file than the one we wrote to, in which case the undo + // edits a config we never touched and leaves the one we did. + const target = configPath ?? (await resolveConfigPath(projectRoot())) + // The undo's write needs the same same-text check as the write it undoes. + // Without it the restore has its own version of the window `persist` closes: + // a disable landing before this write is replaced wholesale — and in the + // `previous === null` case it is DELETED, which is worse than overwritten. + if (previous) { + if ((await addMcpToConfig(name, previous as never, target, { refuseIfDisabled: true })) === null) { + log.info("not restoring over an entry that is disabled on disk", { name }) + return "restored" + } + } else { + // We were going to remove our entry because there was none before. If the + // user has since switched this one off, that is an instruction about this + // node — honour it rather than deleting what they just edited. + // + // Decided on the same text the delete modifies, like the replace case. A + // separate read followed by a delete is worse than a separate read + // followed by a replace: the user's edit is not overwritten, it is gone. + if (!(await removeMcpFromConfig(name, target, { refuseIfDisabled: true }))) { + log.info("did not remove the entry; it is absent or the user has disabled it", { name }) + } + } + // Unobservable from this module's own tests for the same reason `persist`'s + // is — every read here invalidates first. It is here for the other `Config` + // consumers in the process. + await Config.invalidate().catch(() => undefined) + return "restored" + } catch (err) { + // Reported, not swallowed. An undo that could not be confirmed leaves our + // pin on disk, and MCP bootstraps every enabled entry — so the next restart + // starts the workspace this attach walked away from. That is an actionable + // failure, and the caller can only tell the user about it if it is told. + log.warn("could not restore the config after a superseded attach", { name, err: String(err) }) + return "failed" + } +} + +export async function existingEntry(name: string): Promise { + if (syncInternals.existingEntry) return syncInternals.existingEntry(name) + // THROWS rather than returning null, for the same reason `projectEntry` does: + // `null` already means "there is no entry", and every caller acts on that — + // the guard reads it as "nothing forbids this write", the inspection plans it + // as "nothing here, spawn". Swallowing here made the fail-closed guard one + // layer above UNREACHABLE: the guard's own catch could never fire, because the + // failure had already been converted into a confident answer beneath it. + // + // A rule enforced at one layer and undone at the layer below is not enforced. + const cfg = await freshConfig() + return cfg.mcp?.[name] ?? null +} diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts new file mode 100644 index 0000000000..214fa84617 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -0,0 +1,212 @@ +// altimate_change - new file +// +// Everything that asks the outside world a question: the binary, its version, +// MCP, the workspace allowlist, and the user-facing toast. Moved verbatim — the +// state machine buys nothing here, and every touched line is new-bug surface. +import path from "path" +import launch from "cross-spawn" +import { which as whichBinary } from "@opencode-ai/core/util/which" +import { MCP, ToolsChanged } from "@/mcp" +import { AltimateApi } from "@/altimate/api/client" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { AppRuntime } from "@/effect/app-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { readLocalBinding, type CachedBinding } from "./state" +import { log, syncInternals, currentDirectory } from "./engine-seams" +import { + commandArgv, + engineToolKeys, + type Declared, + type ExistingEntry, + type LocalMcpConfig, + type McpStatus, + type Toast, +} from "./engine-types" + +/** How long the optional allowlist lookup may delay a local spawn. */ +export const DECLARED_TIMEOUT_MS = 4_000 + +export async function resolveBinding(): Promise { + if (syncInternals.resolveBinding) return syncInternals.resolveBinding() + const directory = currentDirectory() + if (!directory) return null + try { + return await readLocalBinding(directory) + } catch (err) { + log.warn("could not resolve binding for engine attach", { err: String(err) }) + return null + } +} + +export function which(cmd: string): string | null { + return syncInternals.which ? syncInternals.which(cmd) : whichBinary(cmd) +} + +/** `datamate --version` — the engine inlines its real package version here, + * unlike its MCP `serverInfo`, which is a hard-coded placeholder. A version + * string proves output, not identity; it is a compatibility floor only. */ +export function versionOf(bin: string, spawn?: { environment?: Record; cwd?: string }): Promise { + if (syncInternals.versionOf) return syncInternals.versionOf(bin, spawn) + return new Promise((resolve) => { + // cross-spawn, not execFile. An npm-installed engine on Windows is resolved + // by `which` to a `.cmd` shim (it honours PATHEXT), and Node cannot execute + // `.cmd` or `.bat` directly without a shell — the callback just errors. That + // would report "not runnable" to every bound Windows user with an ordinary + // global install, while MCP's own launcher started the same engine fine. + // This is the launcher the rest of the repo already uses for that reason. + let settled = false + const done = (value: string | null) => { + if (settled) return + settled = true + resolve(value) + } + try { + // In the environment the entry would be SPAWNED in, not this process's. + // A bare `datamate` under a custom `environment.PATH` resolves to a + // different binary than the parent PATH does, so probing here would let a + // modern binary on our PATH approve the pre-floor engine the entry + // actually selects — and a relative command with a configured `cwd` would + // be probed from the wrong directory entirely. + const child = launch(bin, ["--version"], { + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + ...(spawn?.environment ? { env: { ...process.env, ...spawn.environment } } : {}), + ...(spawn?.cwd ? { cwd: spawn.cwd } : {}), + }) + let out = "" + child.stdout?.on("data", (chunk) => { + out += String(chunk) + }) + child.on("error", () => done(null)) + child.on("close", (code) => { + if (code !== 0) return done(null) + const line = out.trim().split(/\r?\n/)[0] ?? "" + done(line || null) + }) + } catch { + done(null) + } + }) +} + +export function mcp() { + return ( + syncInternals.mcp ?? { + status: () => MCP.status() as Promise, + add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), + remove: (name: string) => MCP.remove(name), + spawned: (name: string) => MCP.spawned(name) as Promise, + tools: () => MCP.tools() as Promise>, + } + ) +} + +export async function declared(datamateId: string): Promise { + if (syncInternals.declared) return syncInternals.declared(datamateId) + try { + if (!(await AltimateApi.isConfigured())) return null + const [workspace, catalog] = await Promise.all([ + AltimateApi.getDatamate(datamateId), + AltimateApi.listIntegrations(), + ]) + const extensionIds = new Set(catalog.filter((i) => i.type === "extension").map((i) => i.id)) + const keys: string[] = [] + const extensionKeys: string[] = [] + for (const integration of workspace.integrations ?? []) { + const target = extensionIds.has(integration.id) ? extensionKeys : keys + for (const tool of integration.tools ?? []) target.push(tool.key) + } + return { keys, extensionKeys } + } catch (err) { + log.warn("could not read declared workspace integrations", { datamateId, err: String(err) }) + return null + } +} + +/** Tell the session its tool list changed. + * + * `MCP.add` stores the client but publishes nothing, so nothing downstream could + * even observe a late attach. This restores that signal. + * + * What it does NOT do, stated plainly because the name suggests otherwise: it + * does not give tools to the invocation already running. + * That turn's tool set was passed to the model before the attach finished and + * cannot be rebuilt mid-call — the session's subscriber only logs, and the next + * `resolveTools` is what picks the tools up. So exceeding the bounded wait costs + * a turn, not a session. The event is worth publishing for traceability and for + * any subscriber that can act between turns; it is not a live refresh. */ +export async function announceToolsChanged(): Promise { + if (syncInternals.toolsChanged) return syncInternals.toolsChanged() + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => events.publish(ToolsChanged, { server: DATAMATE_KEY })), + ) + } catch (err) { + log.warn("could not announce the workspace engine tool change", { err: String(err) }) + } +} + +/** The workspace allowlist, bounded. + * + * Reporting only — the attach must never wait on it. The bound was previously + * applied to the spawn path alone, leaving a reused engine awaiting it with no + * limit. Both paths go through here now, so there is one answer rather than two. + * + * The underlying request is separately abortable (the API client attaches a + * signal), so a stalled server releases its socket instead of accumulating + * pending fetches across repair retries. */ +export async function declaredBounded(workspaceId: string): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + declared(workspaceId), + new Promise((resolve) => { + timer = setTimeout(() => { + log.warn("workspace allowlist lookup timed out; continuing without the declared-vs-delivered report", { + workspaceId, + timeoutMs: DECLARED_TIMEOUT_MS, + }) + resolve(null) + }, DECLARED_TIMEOUT_MS) + timer.unref?.() + }), + ]) + } finally { + // Racing does not cancel the loser: left running, the timer fires later and + // warns about a lookup that had already succeeded, on every normal attach. + if (timer) clearTimeout(timer) + } +} + +export async function notify(toast: Toast): Promise { + if (syncInternals.notify) return syncInternals.notify(toast) + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => events.publish(TuiEvent.ToastShow, { ...toast, duration: 10000 })), + ) + } catch (err) { + log.warn("could not show workspace engine toast", { err: String(err) }) + } +} + +/** The version of the ENGINE an entry runs, not of whatever wraps it. + * + * `npx @altimateai/datamate@0.6.3 start-stdio --datamate 42` would otherwise + * have us run `npx --version` and let a pre-floor engine clear the floor on the + * wrapper's version. Asking the running server instead is not an option: + * `serverInfo.version` is a hard-coded placeholder on the very engines this + * floor excludes. An unidentifiable command yields null, which `clearsFloor` + * treats as below the floor. */ +export async function engineVersionOf(entry: ExistingEntry | null): Promise { + const bin = commandArgv(entry)[0] + const direct = bin && /(^|[\\/])datamate(\.[a-z]+)?$/i.test(bin) ? bin : null + if (!direct) return null + // Probed the way the engine is launched: MCP resolves a relative `cwd` + // against the instance directory, so the probe does too. Left relative, it + // would resolve against wherever this process happened to start, and a + // relative command or PATH entry could name a different binary there. + const base = currentDirectory() + const cwd = entry?.cwd ? (base ? path.resolve(base, entry.cwd) : entry.cwd) : undefined + return await versionOf(direct, { environment: entry?.environment, cwd }) +} diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts new file mode 100644 index 0000000000..b44c65313e --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -0,0 +1,60 @@ +// altimate_change - new file +// +// Ambient access and the single test seam. `syncInternals` stays ONE flat object +// on purpose: it is the override surface every other module reaches for, and +// splitting it per module would break every consumer that assigns to it. +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 { Declared, ExistingEntry, LocalMcpConfig, McpStatus, Toast } from "./engine-types" + +export const log = Log.create({ service: "workspace-engine" }) + +/** Test seams. Production leaves every field unset. */ +export const syncInternals: { + resolveBinding?: () => Promise + which?: (cmd: string) => string | null + versionOf?: (bin: string, spawn?: { environment?: Record; cwd?: string }) => Promise + /** The instance directory a relative launch `cwd` resolves against. */ + instanceDirectory?: () => string | null + mcp?: { + status: () => Promise + add: (name: string, cfg: LocalMcpConfig) => Promise + remove: (name: string) => Promise + spawned?: (name: string) => Promise + tools: () => Promise> + } + persist?: (name: string, cfg: LocalMcpConfig) => Promise + projectConfigPath?: () => Promise + persistRestore?: ( + name: string, + previous: ExistingEntry | null, + configPath?: string, + ) => Promise + projectEntry?: (configPath?: string) => Promise + /** The configured (merged) MCP entry under `name`, or null if none. */ + existingEntry?: (name: string) => Promise + freshConfig?: () => Promise<{ mcp?: Record }> + toolsChanged?: () => Promise + declared?: (datamateId: string) => Promise + notify?: (toast: Toast) => Promise +} = {} + +export function isEnabled(): boolean { + return CoreFlag.ALTIMATE_WORKSPACE +} + +export function currentDirectory(): string | null { + if (syncInternals.instanceDirectory) return syncInternals.instanceDirectory() + try { + return Instance.directory + } catch { + return null + } +} + +export function projectRoot(): string { + const wt = Instance.worktree + return wt === "/" ? Instance.directory : wt +} diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts new file mode 100644 index 0000000000..bada8fcc64 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -0,0 +1,1930 @@ +// altimate_change - new file +// +// Attach the bound workspace's integration engine to an altimate-code session. +// +// Integrations are served by the local datamate engine — the same process the +// VS Code extension spawns as `datamate start-stdio`. altimate-code can reuse an +// entry an IDE already wrote, but until now it could not acquire an engine on +// its own: with no entry present it fell to the hosted SSE endpoint, which runs +// in multi-user mode and serves a DIFFERENT tool set (no connection validation, +// no extension-bridge tools, server-side cwd). This module closes that gap. +// +// Rules, in order: +// 1. Reuse, but only what is ATTRIBUTABLE. An entry already registered under +// DATAMATE_KEY is reused only when it is live AND its command pins the +// engine to this workspace (`--datamate `) AND that binary clears the +// version floor. Being connected proves none of that: an unpinned engine +// serves whichever teammate its owner has active, and that changes at +// runtime from a UI this client does not control — the extension writes +// exactly such an entry. Reusing one would report "workspace X: N tools" +// about a process serving Y. +// Anything live but not attributable — unpinned, pinned elsewhere, below the +// floor, or a URL — is replaced by a pinned local spawn, and what it was is +// reported. That costs the other client nothing: a stdio entry is a +// per-client child process, so the IDE keeps its own engine and only our +// registration changes. A connected URL entry is replaced for the same +// reason rule 4 exists — the hosted endpoint serves a different tool set. +// If the entry is DOWN, what it is decides what happens first: +// - a URL entry is an IDE's in-process engine (normally localhost) or the +// hosted endpoint. Neither can be revived from here — only the IDE can +// bring its port back — so with a binding and a usable engine on PATH we +// spawn locally and say what was replaced. The IDE's own config file is +// never touched; when the IDE returns, its sync overwrites ours. +// - a command entry that failed is retried once, then reported. Spawning a +// second engine beside a failing one is the duplicate-process problem the +// single-gateway design exists to avoid. A retry that succeeds is then +// gated for attribution exactly like an entry that never dropped. +// 2. Opportunistic use. If a `datamate` binary is on PATH and its `--version` +// clears the floor, spawn it for this workspace. A lookup, never an install. +// 3. Offer, never silently install. No engine → tell the user exactly which +// workspace tools are unavailable and how to install. The CLI ships as a +// self-contained binary with no Node runtime, so it must not pull one in. +// 4. NEVER fall back to hosted on failure. The local and hosted tool sets +// diverge in both directions, so a silent fallback would change the +// workspace's declared contract. A failed engine is reported, not routed +// around. +// 5. Report what was declared but not delivered. The engine intersects the +// workspace allowlist with what it managed to build and says nothing about +// the difference; this module diffs declared keys against the tools that +// actually arrived and surfaces the gap. +// +// Attaching runs beside the turn, not inside it: `ensure` is started before the +// turn's tools are resolved, and `whenAttached` gives a fresh spawn a bounded +// window to land so the engine's tools make the first tool list rather than +// arriving a turn late. Past the cap the turn proceeds and `tools/list_changed` +// delivers them. +// +// Gated on the workspace pilot flag; inert without a local binding. +import { type CachedBinding } from "./state" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { log, syncInternals, isEnabled } from "./engine-seams" +import { + attributableEngine, + clearsFloor, + commandArgv, + describeEntry, + describeMissing, + describeRefusal, + engineToolKeys, + installWouldHelp, + isUrlEntry, + pinnedWorkspace, + sameEntry, + entryIdentity, + ENGINE_BINARY, + INSTALL_HINT, + MIN_ENGINE_VERSION, + type ExistingEntry, + type McpStatus, + type LocalMcpConfig, + type Outcome, + type Toast, +} from "./engine-types" +import { + declaredBounded, + engineVersionOf, + announceToolsChanged, + mcp, + notify, + resolveBinding, + versionOf, + which, +} from "./engine-probes" +import { existingEntry, persist, persistRestore, projectConfigPath, projectEntry } from "./engine-config" +import { serializeAttach, trackedChainsForTests, attachChains } from "./engine-chain" + +// The module's public surface is deliberately unchanged by the split: consumers +// import from `engine-sync` and should not have to know which file a symbol +// moved to. +export { + attributableEngine, + sameEntry, + entryIdentity, + clearsFloor, + compareVersions, + engineToolKeys, + installWouldHelp, + pinnedWorkspace, + ENGINE_BINARY, + INSTALL_HINT, + MIN_ENGINE_VERSION, + type Declared, + type ExistingEntry, + type LocalMcpConfig, + type Outcome, +} from "./engine-types" +export { isEnabled, syncInternals } from "./engine-seams" +export { trackedChainsForTests } from "./engine-chain" + +// --------------------------------------------------------------------------- +// Production implementations behind the seams +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// The attach flow +// --------------------------------------------------------------------------- + +/** What an existing entry means for this workspace — the whole decision, taken + * in one synchronous step over one snapshot. + * + * The order below is the contract, and it is the part of this module with the + * worst history: intent outranks connectivity, connectivity outranks + * attribution, attribution outranks version. Each of those checks is defeated + * by sitting on the wrong side of another, and an await between them — a config + * read, a status call, a version probe — is what lets that happen. A function + * that cannot await cannot reorder itself. + * + * `retried` is why "one retry, never two" is a property here rather than a + * branch someone has to remember not to re-enter. */ +type EntryPlan = + | { act: "spawn" } + | { act: "honour-disable" } + | { act: "retry-connect" } + | { act: "refuse-unreachable"; error: string } + | { act: "replace-unreachable-url"; url: string } + | { act: "replace-unattributable"; entry: string; pinnedTo: string | null } + | { act: "check-version" } + +export type Inspection = { + entry: ExistingEntry | null + observed: { status: string; error?: string } | undefined + /** What MCP actually spawned under this key, when it knows. + * + * The config says what SHOULD run; this says what IS running, and they + * diverge whenever the file is rewritten after a client started — another + * process re-pinning a shared config, an IDE replacing the entry through + * `MCP.add`, a re-link. Judging attribution on the config alone let this + * module agree with itself while the live client served another workspace's + * data under this workspace's name. */ + runtime?: ExistingEntry | undefined + /** Every server MCP knows about, not only ours. + * + * Kept from the status read the inspection already performs, so a question + * about the neighbours costs no second call — and is answered from the same + * moment as everything else this inspection decided from. */ + all?: McpStatus +} + +/** Config and runtime, read together, in the one correct order. + * + * The order is not incidental: `existingEntry` refreshes the config cache that + * `MCP.status()` then reads, so reading status first means judging this entry + * against a config that predates it — which is how an entry an IDE had just + * added went missing from status entirely and our own was persisted over it. + * + * They travel as one value because the decision needs BOTH and they must + * describe the same moment. Passing them as two arguments left it to each + * caller to pair them correctly, and "the caller remembers" is the property + * this whole rewrite is trying to stop relying on. */ +/** The engine that is RUNNING, else the one configured. + * + * Every question about the running engine — its version, its pin, how it is + * described to a user — goes through here, because the answer is not always the + * config: a config edit can change the command while the existing client stays + * connected, so the two can carry the same pin and be different binaries. + * + * It is one function rather than an expression at each call site so that there + * is no second place to write it. The same question was asked correctly in one + * site and incorrectly in the site beside it twice, and both times the second + * site was found by someone reading the two together rather than by the person + * fixing the first. A shared expression is a trap of exactly that shape. + * + * The fallback matters: with no runtime record, nothing of ours is running and + * the configured entry is the only evidence there is. */ +export function runningEngine(inspection: Inspection): ExistingEntry | null { + return inspection.runtime ?? inspection.entry +} + +/** The entry the user CONFIGURED, whatever may be running. + * + * The mirror of `runningEngine`, and it exists for the same reason: so that + * every read is a named question rather than a field access whose meaning has + * to be inferred from its surroundings. Naming only one of the two would leave + * the other implicit, which is the condition this class of defect grows in. + * + * Use this where the question really is about configuration — what the user + * asked for, what a pin declares, what a message should describe — and + * `runningEngine` where it is about the process that is actually up. */ +export function configuredEntry(inspection: Inspection): ExistingEntry | null { + return inspection.entry +} + +async function inspectEntry(): Promise { + const entry = await existingEntry(DATAMATE_KEY) + const client = mcp() + const all = await client.status() + const runtime = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined + return { entry, observed: all[DATAMATE_KEY], runtime, all } +} + +export function planForEntry(inspection: Inspection, workspaceId: string, retried: boolean): EntryPlan { + const entry = configuredEntry(inspection) + const { observed } = inspection + + // 1. INTENT. Outranks everything, including whether anything is observed at + // all. `{ "datamate": { "enabled": false } }` with no `type` is the upstream + // idiom for switching an entry off, and the only durable way to disable an + // IDE-discovered one from this config — and `isMcpConfigured` requires a + // `type`, so MCP omits it from status entirely. Checking intent below the + // no-observation branch meant that marker reached the spawn path and was + // overwritten with our own pinned `enabled: true`. "Intent outranks + // connectivity" was too weak: absence of runtime is not connectivity. + if (entry?.enabled === false) return { act: "honour-disable" } + + // 2. Nothing is registered under this key, so there is nothing to attribute + // and nothing to revive. Note this is BELOW intent and above everything else: + // a disable marker must be honoured even when it is invisible to status, but + // once intent is settled, absence really does mean there is nothing here. + if (!observed) return { act: "spawn" } + + // 3. ATTRIBUTION, before connectivity — whose engine is this, not how is it + // doing. Nursing an engine back to health before asking whose it is has no + // defensible reading: at best it is work spent on another client's process, + // at worst it revives the very engine we rejected last turn and then rejects + // it again. It also wedges: an entry pinned elsewhere that is also DOWN was + // retried every turn and never replaced, because the retry answered before + // the pin was ever consulted, so the project sat on `connect-failed` until + // someone edited config by hand. + // + // The previous order — connectivity first — was an artifact rather than a + // decision: the pin check lived inside an `if (connected)` block, and + // extracting this function faithfully carried that accident along with the + // intent, which made it look deliberate. + const pin = pinnedWorkspace(entry) + // Attribution is a claim about the RUNNING engine, so the running engine gets + // a vote. A config entry that names this workspace while MCP is serving a + // process started from a different one is the silent case: every check agrees, + // and the tools, and the credentials, belong to somewhere else. + const running = runningEngine(inspection) + const runtimeKnown = running !== entry + const runtimePin = runtimeKnown ? pinnedWorkspace(running) : null + if (runtimeKnown && runtimePin !== workspaceId) { + return { + act: "replace-unattributable", + entry: describeEntry(running), + pinnedTo: runtimePin, + } + } + if (pin !== workspaceId) { + // A URL entry pins nothing, so it lands here too — which is the point: + // the hosted endpoint serves a different tool set and rule 4 forbids + // adopting it. An unreachable one keeps its own message because that names + // the port the user's IDE is not serving. + if (isUrlEntry(entry) && observed.status !== "connected") { + return { act: "replace-unreachable-url", url: entry.url } + } + return { act: "replace-unattributable", entry: describeEntry(entry), pinnedTo: pin } + } + + // 4. CONNECTIVITY. Reached only for an entry that IS ours, which is the only + // kind worth reviving. + if (observed.status !== "connected") { + if (retried) { + return { act: "refuse-unreachable", error: observed.error ?? observed.status ?? "not connected" } + } + return { act: "retry-connect" } + } + + // 5. VERSION. + return { act: "check-version" } +} + +/** The last verdict announced to a session — stored ON the session record. + * + * + * Repairable refusals are re-decided every turn — deliberately, because that is + * how a repair gets noticed — but re-DECIDING is not a reason to re-TELL. A + * missing engine, an unreadable config or a below-floor binary that has not + * changed produced an identical toast on every single turn, which is nagging + * rather than informing. It matters more once the toast becomes a dialog: one + * dialog per turn would be unusable. + * + * Keyed by session and by the verdict itself, so a CHANGED verdict speaks, and a + * successful attach clears it so the next problem is heard. + * + * NOT a module-level map of its own. A second map keyed by session id is a + * second thing to evict, and this one would only ever grow on the sessions that + * never succeed — a long-running server whose new sessions keep hitting + * `engine-missing` would retain every one of them. Hanging it on the session + * record means it is bounded by whatever bounds the sessions, which is already + * solved and already tested. */ +function verdictSignature(outcome: Outcome, workspaceId?: string): string { + const detail = + "error" in outcome ? outcome.error : "found" in outcome ? outcome.found : "declared" in outcome ? "" : "" + // The workspace is part of the identity. Without it, a session re-linked from + // A to B is silenced about B by an identical-kind refusal it was told about + // for A — the record is carried across the re-link, so the user is left with + // guidance naming a workspace they have left. "Same verdict" has to mean the + // same verdict about the same thing. + return `${workspaceId ?? "-"}:${outcome.kind}:${detail}` +} + +/** Forget what a session was last told, so the next verdict is announced even if + * it repeats an older one. Called when an attach succeeds: the problem the user + * was told about is gone, and if it comes back they should hear about it. */ +function clearAnnouncement(sessionID: string): void { + const record = sessions.get(sessionID) + if (record) record.announced = undefined +} + +/** Tell the user about a refusal — exactly once, from one place. + * + * This is a whole function for what is currently one call because it is a + * substitution point, and the substitution is easy to get wrong in a way no + * test on either side would catch. + * + * `installWouldHelp` names the refusals an install would actually fix. Those + * belong to an install offer when one exists, and the offer owns the MESSAGING + * for them: it replaces this toast rather than joining it, and falls back to + * this same toast whenever it cannot reach a surface. So "an actionable failure + * is never silent" holds either way, and neither path emits twice. + * + * The toast and the offer are alternatives, not a sequence. A refusal that + * raises both is the double signal — a dialog and a toast saying the same thing + * — and it would pass a suite asserting a toast fires alongside one asserting an + * offer is raised, because neither asserts the user sees exactly ONE thing. + * Replace this function's body; do not add beside it. + * + * Module-level so the unexpected-throw path uses it too. That path had grown its + * own toast — a second place a refusal reaches the user, which is exactly the + * kind of site an offer would double up on, and the kind nobody writes a fixture + * for. + * + * NEVER throws: "never silent" also has to mean "never relabelled". A throw here + * reached the catch-all and turned a decided outcome into `connect-failed` with + * a second toast, so failing to DESCRIBE a verdict silently rewrote it. */ +/** What the announcement knows about the refusal beyond the outcome itself. + * + * `Outcome` carries `found` and `declared` but no workspace identity, and the + * announcement needs one: a message that names the workspace, and anything + * keyed per workspace downstream. + * + * Every field is OPTIONAL, and that is the contract rather than laziness. This + * function is the single exit for exceptions as well as decisions, and a throw + * can happen before a binding is resolved — the flag read, the MCP handle, the + * serialization chain all precede it. A body that assumes a workspace is here + * will crash on the one path nobody writes a fixture for. When identity is + * absent the toast still fires; anything that needs to NAME a workspace must + * stay silent rather than guess at one. */ +type RefusalContext = { + workspaceId?: string + workspaceName?: string + sessionID?: string +} + +async function announceRefusal(outcome: Outcome, toast: Toast, context?: RefusalContext): Promise { + try { + const record = context?.sessionID ? sessions.get(context.sessionID) : undefined + if (record) { + const signature = verdictSignature(outcome, context?.workspaceId) + if (record.announced === signature) { + log.info("verdict unchanged since the last turn; not repeating it", { + sessionID: context?.sessionID, + kind: outcome.kind, + }) + return + } + record.announced = signature + } + if (installWouldHelp(outcome)) { + log.info("refusal is remediable by installing the engine", { ...context, kind: outcome.kind }) + } + await notify(toast) + } catch (err) { + log.warn("could not announce the refusal; the outcome stands", { + ...context, + kind: outcome.kind, + err: String(err), + }) + } +} + +async function run(sessionID: string): Promise { + if (!isEnabled()) return { kind: "disabled" } + + const client = mcp() + + const binding = await resolveBinding() + if (!binding) { + // An entry left over from a binding that no longer exists still gets started + // by MCP bootstrap and can serve the OLD workspace's tools here. Tempting to + // tear it down — but we cannot prove we wrote it. argv shape is not + // provenance: a hand-authored `datamate start-stdio --datamate ` is + // byte-identical to ours, and removing it would take the user's own server + // offline on every first prompt. This module's whole thesis is that you do + // not act on something you cannot attribute, so it applies to itself here: + // report it and leave it alone. Attributing this properly needs an explicit + // ownership marker written at persist time, which is a separate change. + // This read is DIAGNOSTIC — it produces a log line and nothing else — so a + // failure to perform it must not change the outcome. Making the reader + // propagate was right for the paths that DECIDE on it, and this caller + // silently inherited that: a failed read here escaped to the catch-all and + // announced "Workspace engine attach failed" in a project with no workspace + // linked, where this module is documented inert — and because that outcome + // is repairable, it re-announced on every turn for as long as the config + // stayed unreadable. + // + // Propagating a failure is the right default, but it turns every caller that + // relied on the swallow into a decision that now has to be made explicitly. + // Here the decision is easy, because nothing is riding on the answer. + try { + const present = (await client.status())[DATAMATE_KEY] + if (present) { + const stale = await existingEntry(DATAMATE_KEY) + const pin = pinnedWorkspace(stale) + if (pin) { + log.info("unbound project has an engine entry pinned to a workspace; leaving it alone", { + pinnedTo: pin, + entry: describeEntry(stale), + }) + } + } + } catch (err) { + log.warn("could not inspect the stale entry in an unbound project; nothing depends on it", { + err: String(err), + }) + } + return { kind: "unbound" } + } + const workspaceId = String(binding.datamateId) + + // Rule 1 — reuse what already serves this session, but only if it can be shown + // to serve THIS workspace, on an engine that still clears the floor. + // + // "Connected" is not that proof. An entry without `--datamate ` follows + // its owner's active teammate, and that changes at runtime from a UI this + // client does not control — the extension writes exactly such an entry. Reusing + // one would let us report "workspace X: N tools" about a process serving Y, + // and once precedence acts on that inventory it would route the model into + // another workspace's credentials, with no fallback and nothing naming the + // discrepancy. The floor is re-checked here for the same reason: a stale + // persisted entry can be running an engine old enough that its pin is not + // locked, which is the drift this attribution is meant to exclude. + let replaced: string | undefined + let replacedNote = "" + + /** Is this attach still the one this project wants? + * + * The binding is snapshotted at the top of `run()`, but reaching a mutation + * costs seconds — a status call, one or two process spawns for `--version`, + * and the workspace allowlist over the network. A re-link inside that window + * leaves this attach acting for a workspace the project has already left, and + * per-project serialization does not help: it orders the writes, so the stale + * attach simply installs FIRST and the replacement queues behind it. Anything + * that mutates MCP state re-checks here and abandons instead. + * + * Cheap enough to call before every mutation — the binding is a local cache + * read, not a network one. */ + const stillCurrent = async (): Promise => { + const now = await resolveBinding().catch(() => null) + return !!now && String(now.datamateId) === workspaceId + } + + /** The PATH engine's version, probed at most once per attach. + * + * Probing spawns a process and takes about a second. Two paths ask the same + * question — "is there something better on PATH than the entry we just + * rejected" and "what would we spawn" — and the below-floor path reaches both, + * so a replaced pre-floor engine paid for the same answer twice. */ + let pathProbe: { bin: string | null; version: string | null } | undefined + const enginePath = async (): Promise<{ bin: string | null; version: string | null }> => { + if (!pathProbe) { + const bin = which(ENGINE_BINARY) + let version: string | null = null + try { + version = bin ? await versionOf(bin) : null + } catch (err) { + // Same rule as the entry probe: unreadable is below the floor, not a + // reason to abandon the turn to the catch-all. + log.warn("could not probe the PATH engine version; treating it as unreadable", { + workspaceId, + err: String(err), + }) + } + pathProbe = { bin, version } + } + return pathProbe + } + + /** Is the world this decision was made in still the world we are mutating? + * + * `stillCurrent` asks only about the binding, and a mutation guarded on half + * the world is guarded on none of it: the plan is held across a version probe, + * a PATH probe, the workspace allowlist and a disk read — seconds — and a + * disable landing anywhere in there was then overwritten by our own pinned + * `enabled: true`. `addMcpToConfig` replaces the whole entry node, so a + * project-level disable is destroyed outright and a global one is shadowed by + * the override, after which the memo reads OUR entry and stands forever. + * + * Both reads live in one function so nothing can be inserted between them, and + * this is the LAST await before any mutation. The invariant is not "no + * mutation on a stale binding" but "no mutation on a stale world". + * + * It does NOT make the window vanish. The write re-checks intent on the same + * text it modifies, which is as close as that can be got, but one read and one + * write to one file is not atomic — see the note on `persist`. This guard + * narrows the window; it does not close it. */ + const worldUnchanged = async ( + // The entry the PLAN was derived from, when the caller is about to act on + // that plan. Given only before a write: acting on a plan whose entry has + // been replaced overwrites a newer entry and can displace the client it + // started. + // + // Deliberately NOT given after the write. By then the entry on disk is our + // own, so there is nothing to compare a plan against — and a third-party + // rewrite landing after our write is a different question, answered by the + // undo, which already refuses to roll back an entry that is no longer ours. + expected?: ExistingEntry | null, + ): Promise<"ok" | "moved" | "disabled" | "unreadable" | "replaced"> => { + // Intent FIRST, binding LAST — reversed again, and this is the considered + // order rather than the obvious one. + // + // Reading the binding first put it one whole config read away from every + // mutation it guards, so a re-link landing inside that read installed for the + // workspace the project had just left and was only undone after the engine + // had booted with the per-project lock held. That is the exact defect this + // guard was written for, reintroduced by the guard's own ordering. + // + // Intent does not need to be last, because the write re-checks intent on the + // same text it modifies — so the intent window is covered whichever read + // comes first. The binding has no such second line of defence, so it takes + // the adjacent position. On the re-add path there is no write-side check at + // all and one half is necessarily a read away; the binding still goes last, + // because a stale binding starting another workspace's engine under the lock + // is the worse of the two harms. + let entryNow: ExistingEntry | null + try { + entryNow = await existingEntry(DATAMATE_KEY) + } catch (err) { + // Fails CLOSED. `null` from this read means "there is no entry", which + // reads as permission to write — so a read that merely FAILED must not + // produce it. If intent cannot be confirmed, nothing is written. + log.warn("could not confirm intent before mutating; abandoning the attach", { + workspaceId, + err: String(err), + }) + // NOT "moved". The same failure reaching the inspection is reported to the + // user; reporting it here as a silent binding-move would give one failure + // two labels and two signal counts depending only on which read hit it. + return "unreadable" + } + if (entryNow?.enabled === false) { + log.info("intent changed while deciding; not writing over a disable", { workspaceId }) + return "disabled" + } + // The plan was derived from a particular entry. If that entry has been + // REPLACED — a different enabled command, or a URL where a command was — + // the plan describes something that is no longer there, and acting on it + // overwrites a newer entry and can displace the client it started. A + // disable is one way the entry can change; it is not the only one. + if (expected !== undefined && !sameEntry(entryNow, expected)) { + log.info("the entry was replaced while deciding; re-deciding rather than acting on a stale plan", { + workspaceId, + }) + return "replaced" + } + if (!(await stillCurrent())) return "moved" + return "ok" + } + + /** Say once that a hosted datamate is also serving this session. + * + * `datamate_manager` can add standalone `datamate-` entries pointing at + * the hosted endpoint, and those keep their own clients. This flow owns one + * key and does not touch theirs, so after a successful attach the model can + * hold both tool sets at once — ours for the bound workspace, and another + * datamate's under its own credentials. + * + * Not filtered: the user added those servers deliberately, and removing a + * server from their turns is not this module's decision to make. Surfaced + * instead, so the ambiguity is visible rather than silent. + * + * One signal per session per SET, so a stable configuration says it once and a + * change says it again. Separate from the attach toast on purpose: two + * different things happened, so there are two signals — the rule is one signal + * per event, not one element per screen. */ + const noteHostedNeighbours = async (outcome: Outcome): Promise => { + if (!attributableEngine(outcome)) return + try { + const hosted = Object.entries(inspection.all ?? {}) + .filter( + ([key, value]) => + key !== DATAMATE_KEY && key.startsWith(`${DATAMATE_KEY}-`) && value?.status === "connected", + ) + .map(([key]) => key) + .sort() + if (hosted.length === 0) return + const signature = hosted.join(",") + const record = sessions.get(sessionID) + if (record?.announcedHosted === signature) return + if (record) record.announcedHosted = signature + await notify({ + title: "Another datamate is also connected", + message: + `Workspace "${binding.datamateName}" is attached, and ${hosted.join(", ")} ` + + `${hosted.length === 1 ? "is" : "are"} also connected. Tools from ${hosted.length === 1 ? "it" : "them"} ` + + `serve a different datamate, under its own credentials — check which you are using before running one.`, + variant: "warning", + }) + } catch (err) { + log.warn("could not check for other connected datamate servers", { workspaceId, err: String(err) }) + } + } + + /** The refusal an unreadable configuration earns. + * + * One failure, one label, wherever it lands: the reader propagates rather than + * inventing an answer, so both the inspection and the pre-write guard reach + * this. Nothing is written on the way here. */ + const refuseUnreadable = (why: string): Promise => + refuse({ kind: "connect-failed", error: `configuration unreadable: ${why}` }, { + title: "Workspace engine not attached", + message: + `Could not read this project's MCP configuration, so the engine was not attached — acting on a ` + + `configuration we cannot read risks overwriting your own "${DATAMATE_KEY}" entry. Integration tools ` + + `are unavailable until it can be read.`, + variant: "error", + }) + + /** The refusal a mid-decision disable earns. + * + * Reported as `entry-disabled` rather than `superseded` because the guard + * knows WHICH half of the world moved, and the two mean different things to a + * user: one says "something changed, try again", the other says "you switched + * this off, and it stays off". Collapsing them would throw away the more + * useful answer at the point we finally have it. */ + const refuseDisabled = (): Promise => + refuse( + { kind: "entry-disabled" }, + { + title: "Workspace engine is disabled", + message: + `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + + `integration tools are unavailable. Enable it to use them.`, + variant: "warning", + }, + { reason: "the entry is disabled" }, + false, + ) + + /** The two questions every answer that NAMES an engine asks before it is + * given — `attached` after an install, `reused` after a lookup. + * + * Is the world unchanged (binding AND intent), and is what is serving still + * the engine that was judged? Both answers follow awaits (the handshake, the + * tool listing, the allowlist lookup) that a re-link, a disable, or a + * replacement via `MCP.add` from the route or the IDE's reload can land + * inside; an answer given without asking names the bound workspace over + * whatever is serving now. One definition, so the two callers cannot drift. + * + * Returns a verdict rather than an outcome: teardown belongs to the caller + * (the install region undoes what it installed, the reuse answer detaches + * what it judged), and it must run BEFORE the refusal is announced. */ + const confirmServing = async ( + judged: ExistingEntry | null, + ): Promise<"ok" | "disabled" | "unreadable" | "moved" | "replaced" | "gone"> => { + const world = await worldUnchanged() + if (world !== "ok") return world === "replaced" ? "moved" : world + // The runtime's own record of what it launched is the only witness to + // "still serving". A record that names a different launch is a replacement; + // NO record means the client was removed or disconnected while we waited, + // and an answer naming an engine that is not there is as wrong as one naming + // the wrong engine. A harness that does not model the record is not asked. + if (client.spawned) { + const servingNow = await client.spawned(DATAMATE_KEY).catch(() => undefined) + if (!servingNow) return "gone" + if (judged && !sameEntry(servingNow, judged)) return "replaced" + } + return "ok" + } + + /** Stop serving an entry we have judged untrustworthy for this workspace. + * + * Runtime-only (`MCP.remove`): closes the client and drops it from the tool + * catalogue without touching any config file — `MCP.disconnect` would persist + * `enabled: false` into whichever config owns the entry, which for a global + * one disables the user's engine everywhere. + * + * This must run at the moment of REJECTION, not merely before a replacement + * spawn. Every exit that fails to produce a replacement — `engine-missing`, + * `engine-too-old` — would otherwise return with the rejected engine still + * connected, and the turn's `resolveTools` would hand the model exactly the + * tools we just decided it must not have. It also closes the client `MCP.add` + * would otherwise overwrite without closing, which orphans a second engine. */ + /** Close the client, but only if it is still the one we judged. + * + * Every destructive act verifies identity first, and it does so HERE so there + * is no second place to remember it. The MCP route and the IDE's reload both + * call `MCP.add` outside this flow's serialization, so between judging a + * client and closing it, someone else's replacement can take its place — and + * closing that leaves the engine they just asked for disconnected, with its + * tools and credentials gone from the turn. */ + const removeIfOurs = async ( + judged: ExistingEntry | null, + why: Record, + bindingDependent = false, + ): Promise => { + // Identity FIRST, binding LAST, so the binding read stays the last await + // before the mutation. Both checks live here rather than one here and one at + // the call site, because ordering two guards across two functions is how one + // of them ends up on the wrong side of the other. + const runningNow = client.spawned ? await client.spawned(DATAMATE_KEY).catch(() => undefined) : undefined + if (runningNow && judged && !sameEntry(runningNow, judged)) { + log.info("not detaching; something else replaced this client since we judged it", { workspaceId, ...why }) + return + } + if (bindingDependent && !(await stillCurrent())) { + log.info("skipping teardown; the binding changed while this attach was deciding", { workspaceId, ...why }) + return + } + await client.remove(DATAMATE_KEY).catch((err) => { + log.warn("could not detach the rejected engine entry", { err: String(err), ...why }) + }) + } + + const detachRejected = async (why: Record, bindingDependent = true): Promise => { + // The guard exists to stop us destroying something that may legitimately + // belong to the NEW binding. That applies to exactly one of the three + // reasons we tear down, and gating all of them on it left a disabled or a + // too-old client serving for the turn whenever a re-link raced the decision. + // + // Binding-INDEPENDENT, so never gated: + // - a disabled entry serves nothing. `enabled: false` is a property of the + // entry, not of a workspace, so no re-link makes it servable. + // - an engine below the floor serves nobody correctly. The floor is not + // workspace-specific either. + // - anything THIS attach started. It exists only because we made it, so + // leaving it is a leak whatever is bound now. + // + // Binding-DEPENDENT, and the only case the guard is for: + // - a pre-existing entry we did not create and judged unattributable. If + // the binding moved, that entry may be exactly what the new one wants. + await removeIfOurs(runningEngine(inspection), why, bindingDependent) + } + /** Abandon an install without trace. + * + * Both halves, together. A supersede that undoes only the runtime leaves our + * pin on disk, and MCP bootstrap starts every enabled entry — so a restart + * before the next attach starts the workspace this project walked away from. + * Naming them as one operation is what stops a caller remembering only one. + * + * `projectBefore` is the PROJECT file's own entry, not the merged view. + * Restoring the merged value writes a copy of a global entry into the project, + * which is a permanent override shadowing every later global change — undoing + * a write is only correct if it restores what that write replaced. */ + const undoInstall = async ( + projectBefore: ExistingEntry | null, + installed: LocalMcpConfig, + ): Promise<"restored" | "failed"> => { + // An undo may only undo its OWN work, and both halves are checked because + // either can be replaced between the install and the undo: the MCP route and + // the IDE's reload both call `MCP.add` outside this flow's serialization, + // and an IDE or the user may rewrite the file. Removing or restoring blindly + // destroys someone else's work while believing it is tidying up after + // itself. + await removeIfOurs(installed, { reason: "undoing our install" }) + // "Restore what the write replaced" stops being right the moment anything + // edits the thing we wrote. Between the install and this undo there is a + // whole engine boot: a disable landing in that window lands on OUR entry, + // and so does a new command or URL from an IDE. Restoring the pre-install + // state discards that edit, and the next turn — finding no entry, or ours — + // spawns over it. + // + // The same rule as the guard, applied to the undo's own write: no mutation + // on a stale world. Read at undo time, and restore only what is still ours. + let now: ExistingEntry | null = null + try { + now = await projectEntry(configPath) + } catch (err) { + // Fails CLOSED, like the guard's read and for the same reason. Restoring + // "what we replaced" on a read we could not perform can overwrite a + // disable that landed while we held the entry — writing blind is how the + // undo becomes the thing that needs undoing. Leave the file alone and let + // the caller tell the user what is still there. + log.warn("could not read the project entry before undoing; leaving the file alone", { + workspaceId, + err: String(err), + }) + return "failed" + } + if (now?.enabled === false) { + if (!sameEntry(now, installed)) { + // Rewritten AND disabled while we held it. Neither the transport nor + // the disable is ours: projecting the disable onto what we replaced + // would overwrite the newer transport with the old one. + log.info("not restoring; the entry was rewritten and disabled since we installed", { workspaceId }) + return "restored" + } + log.info("the entry was disabled while we held it; keeping the disable rather than undoing it", { + workspaceId, + }) + const keep = projectBefore ? ({ ...projectBefore, enabled: false } as ExistingEntry) : now + return await persistRestore(DATAMATE_KEY, keep, configPath) + } + if (now && !sameEntry(now, installed)) { + // Rewritten while we held it — a different command, or a URL where we + // wrote a command. That edit is newer than our pin and not ours to undo. + log.info("not restoring; the entry was rewritten since we installed", { workspaceId }) + return "restored" + } + return await persistRestore(DATAMATE_KEY, projectBefore, configPath) + } + + /** The single exit for every refusal. + * + * Three properties that were previously spread across six branches, each of + * which had to remember all three: + * + * 1. An actionable failure is never silent — the toast is not optional. + * 2. A refusal that leaves a client registered tears it down. The caller runs + * `resolveTools` whatever this returns, so declining while the old client + * stays registered hands that turn its tools and its credentials anyway. + * The outcome is advice; the registration is what the model sees. + * 3. Whether a remedy exists is asked in ONE place, of `installWouldHelp`, + * with the binding still in scope. "Refused" and "no engine is obtainable" + * are different questions, and unifying refusals is exactly what makes them + * diverge: a user who deliberately disabled their engine must never be + * offered an install for the engine they already have and switched off. */ + const refuse = async ( + outcome: Outcome, + toast: Toast, + detach?: Record, + bindingDependent = true, + ): Promise => { + // Teardown BEFORE the announcement, and this order is load-bearing rather + // than incidental: the announcement is a substitution point, and a body that + // waits on a person would hold a rejected client connected until they + // clicked. Stop serving first, explain second. + if (detach) await detachRejected(detach, bindingDependent) + // Revalidate before answering. A refusal is an answer: without this, a + // re-link during the config read reports `engine-missing` for the workspace + // the project has just left, and toasts a message naming it. + if (!(await stillCurrent())) { + log.info("binding changed before this refusal could be reported; not answering for the old workspace", { + workspaceId, + kind: outcome.kind, + }) + return { kind: "superseded" } + } + await announceRefusal(outcome, toast, { workspaceId, workspaceName: binding.datamateName, sessionID }) + return outcome + } + + // Read the entry BEFORE asking for status. `existingEntry` refreshes the config + // cache and `MCP.status()` reads that same cache — so an entry an IDE or user + // added after the cache was warmed is missing from status entirely, `existing` + // is undefined, rule 1 never runs, and we persist our managed entry straight + // over theirs. Refreshing first is what makes the status gate trustworthy. + // Intent, then connectivity, then attribution, then version. + // + // Each check is defeated by sitting on the wrong side of another, and an + // await between them is what lets that happen. `planForEntry` cannot await, + // so no such reordering is expressible against it. + // + // The entry is read BEFORE the status it is judged against. `existingEntry` + // refreshes the config cache that `MCP.status()` then reads, so an entry an + // IDE added after the cache warmed would otherwise be missing from status + // entirely — the entry check would never run and our managed entry would be + // persisted straight over theirs. + let inspection: Inspection + try { + inspection = await inspectEntry() + } catch (err) { + // Planning on a configuration we could not read means planning "there is + // nothing here", which is a spawn — straight over whatever is actually + // there. + return await refuseUnreadable(String(err)) + } + let plan = planForEntry(inspection, workspaceId, false) + + /** Did THIS attach start the client that is now registered? + * + * Scoped to the whole attach rather than to the revive block, because the + * teardown that matters happens later. By the teardown split's own definition + * — undoing what this attach created is right regardless of what is bound now + * — a client we revived is binding-INDEPENDENT, but it was exiting through the + * binding-dependent gate: revive, then a re-link plus an unpinning rewrite in + * the same window, and the teardown is correctly skipped as "might belong to + * the new binding" while being a process we started seconds earlier. + * + * The definition was right and the plumbing did not carry it this far. */ + let revived = false + + if (plan.act === "retry-connect") { + // Exactly one retry, then report — never a second spawn beside a failing + // one. "Never twice" is the `retried` argument rather than a branch someone + // has to remember not to re-enter. + // + // NOT `MCP.connect`, which is the wrong primitive three times over. It + // writes `enabled: true` into whichever config owns the entry — a global + // one for an IDE-written entry — so a disable landing in its window is + // destroyed on disk and nothing ever repairs it, because the next read says + // enabled. It resolves what to spawn from MCP's own retained state rather + // than from the entry this decision examined, so it can revive the engine + // we rejected last turn, or start a workspace we have already left. And it + // is a mutation, so it belongs behind the same guard as every other one. + // + // `add` is none of those: it writes no config and starts exactly what it is + // handed. Reviving becomes the same operation as spawning, which is the + // real win — the retry stops being a special path with special rules. + // The whole transport, not just the argv. `environment`, `cwd` and + // `timeout` are what the configured engine was meant to run under — a + // custom PATH may be the only place its binary exists, and a relative + // command resolves from `cwd`. Reviving with a flattened shadow of the + // entry restarts a different process than the one that failed. + const configured = configuredEntry(inspection) + const revive: LocalMcpConfig = { + type: "local", + command: commandArgv(configured), + enabled: true, + ...(configured?.environment ? { environment: configured.environment } : {}), + ...(configured?.cwd ? { cwd: configured.cwd } : {}), + ...(configured?.timeout !== undefined ? { timeout: configured.timeout } : {}), + } + // The whole world, not just the binding: this starts a process, and a + // disable that landed since the inspection forbids starting it just as + // surely as it forbids writing config. The plan was derived from a snapshot + // taken before a status read; re-confirm both halves before acting on it. + // No expected entry here. The revive re-inspects and re-plans immediately + // afterwards, so a change landing between the inspection and the restart is + // absorbed by that — this guard only has to answer intent and the binding. + // The spawn path is different: it acts on its plan with no further look. + const beforeRevive = await worldUnchanged() + if (beforeRevive === "disabled") return await refuseDisabled() + if (beforeRevive === "unreadable") return await refuseUnreadable("intent could not be confirmed") + if (beforeRevive !== "ok") return { kind: "superseded" } + await client + .add(DATAMATE_KEY, revive) + .then(() => { + revived = true + }) + .catch((err) => { + log.warn("could not restart the engine entry", { err: String(err), workspaceId }) + }) + // Re-inspected whole rather than re-reading status alone: the world may + // have moved in both halves while we were starting a process. + // + // A revive is an install, so it owns its undo like one. A throw in the + // re-inspection must not reach the catch-all with the client we just + // started still registered: the outcome is advice, the registration is what + // the model sees. + try { + inspection = await inspectEntry() + } catch (err) { + if (revived) { + log.info("undoing the revive we started, since we cannot decide about it", { workspaceId }) + await removeIfOurs(revive, { reason: "undoing our revive" }) + } + throw err + } + plan = planForEntry(inspection, workspaceId, true) + } + const entry = configuredEntry(inspection) + + if (plan.act === "honour-disable") { + // The user turned this entry off deliberately. Do NOT call `MCP.connect` to + // "retry" it: that persists `enabled: true` into whichever config owns the + // entry, so for a global `datamate` the first prompt in any bound project + // would silently re-enable it for every other project. + // + // Leaving the CONFIG alone is the point; leaving the RUNTIME alone is not. + // `MCP.status()` reports live client state and `MCP.tools()` gates on + // exactly that status, consulting the config only for a timeout — so an + // entry disabled after it connected keeps exporting its tools and its + // credentials to the turn. `remove` is runtime-only: it closes the client + // and publishes ToolsChanged without writing config, which is respecting + // the edit rather than re-applying it. + log.info("engine entry is explicitly disabled; leaving it alone", { workspaceId }) + return await refuse( + { kind: "entry-disabled" }, + { + title: "Workspace engine is disabled", + message: + `The "${DATAMATE_KEY}" MCP entry is disabled, so workspace "${binding.datamateName}" ` + + `integration tools are unavailable. Enable it to use them.`, + variant: "warning", + }, + { reason: "the entry is disabled" }, + false, + ) + } + + if (plan.act === "refuse-unreachable") { + // "It will not start" and "there is nothing to start" are different + // situations with different remedies, and an entry pinned to us whose binary + // has since been uninstalled looks exactly like the first while being the + // second. Reported as `connect-failed`, it produced a message with no + // install hint, every turn, forever — and `which` was never consulted on + // this path at all. + if (!which(ENGINE_BINARY)) { + const declaredForMissing = await declaredBounded(workspaceId) + const count = declaredForMissing?.keys.length ?? 0 + return await refuse({ kind: "engine-missing", declared: count }, { + title: "Workspace integrations unavailable", + message: + `Workspace "${binding.datamateName}" declares ${count} integration tool${count === 1 ? "" : "s"}. ` + + `They run on the local engine, which is not installed. Install it with: ${INSTALL_HINT}`, + variant: "warning", + }) + } + return await refuse({ kind: "connect-failed", error: plan.error }, { + title: "Workspace engine is not running", + message: + `The "${DATAMATE_KEY}" MCP entry for workspace "${binding.datamateName}" could not connect: ` + + `${plan.error}. Integration tools are unavailable until it does.`, + variant: "error", + }) + } + + if (plan.act === "replace-unreachable-url") { + // Dead URL: nothing here can bring that process back — only the IDE can + // restore its port. Fall through to a local spawn and report it below. + replaced = plan.url + replacedNote = ` Replaced the unreachable engine URL ${plan.url} for this session.` + log.info("existing engine entry is a URL that is not reachable; will spawn locally", { + workspaceId, + url: plan.url, + error: inspection.observed?.error, + }) + } + + if (plan.act === "replace-unattributable") { + // Not attributable to this workspace. Replacing it costs the other client + // nothing: a stdio entry is a per-client child process, so the IDE keeps its + // own engine and only OUR registration changes. A connected URL entry lands + // here too, which is the point — the hosted endpoint serves a different tool + // set, and rule 4 forbids adopting it. + replaced = plan.entry + replacedNote = plan.pinnedTo + ? ` Replaced an engine entry pinned to workspace ${plan.pinnedTo} for this session.` + : ` Replaced an engine entry that is not pinned to this workspace (${plan.entry}) for this session; ` + + `it serves whichever workspace its owner has active.` + log.info("existing engine entry is not attributable to this workspace; detaching", { + workspaceId, + pinnedTo: plan.pinnedTo, + entry: plan.entry, + }) + // `!revived` — if we started this client, tearing it down is undoing our own + // work and never depends on the binding. + await detachRejected({ workspaceId, reason: "not-attributable", pinnedTo: plan.pinnedTo }, !revived) + } + + if (plan.act === "check-version") { + // A probe that THROWS is a version we could not read, which `clearsFloor` + // already treats as below the floor — an engine that cannot say what it is + // cannot be shown to lock its pin. Letting it propagate instead sent the + // turn to the catch-all BEFORE any teardown, so a persistent probe failure + // toasted every single turn while the rejected client stayed registered and + // serving: the advice-versus-registration split this module exists to close. + // Read as unreadable, it is detached and refused once, and the memo holds. + // The RUNNING engine, not the configured one. A config edit can change the + // command while the existing client stays connected, so the two can carry + // the same pin and be different binaries — and a newly configured 0.7 + // command would then authorise reuse of a still-running pre-0.7 engine, + // which does not lock its pin and can drift to another workspace. The pin + // and the floor are one mechanism, so both are asked of the same thing. + let found: string | null + try { + found = await engineVersionOf(runningEngine(inspection)) + } catch (err) { + log.warn("could not probe the entry's engine version; treating it as unreadable", { + workspaceId, + err: String(err), + }) + found = null + } + if (clearsFloor(found)) { + // Rule 5 applies to a reused engine too. A running engine that lost an + // integration — a connection deleted, a restart that dropped it — serves + // fewer tools than the workspace declares, and only the fresh attach used + // to say so. Reuse is the COMMON path, so staying silent here is where the + // gap would actually go unnoticed. + const present = engineToolKeys(await client.tools()) + const declaredKeys = await declaredBounded(workspaceId) + const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] + const available = present.size + // Returning `reused` ASSERTS that the connected engine serves the current + // binding — and the lookup above can have waited. Every mutation already + // revalidates; so must this, because the caller acts on the answer just as + // surely. A re-link inside that await would otherwise hand this turn the + // previous workspace's tools, and its credentials, under the new binding. + // + // The tool and allowlist reads above are two awaits; `confirmServing` + // asks the two questions every named answer asks after them. + // What a non-`ok` verdict means for a reuse answer. Asked twice: once + // after the lookup awaits, and again after the announcements — which are + // awaits too, and every await after a guard belongs to the guard. + const settleReuse = async (verdict: Awaited>): Promise => { + if (verdict === "ok") return null + if (verdict === "replaced" || verdict === "gone") { + // A replacement is someone else's and not ours to detach; a client + // that is gone has nothing to detach. Either way the next decision + // judges what is there on its own merits. + log.info("the engine we judged is no longer the one serving; not answering for it", { + workspaceId, + verdict, + }) + return { kind: "superseded" } + } + if (verdict === "disabled") return await refuseDisabled() + if (verdict === "unreadable") return await refuseUnreadable("intent could not be confirmed") + // Moved. Detach, do not merely decline: the caller runs `resolveTools` + // whatever this returns, so leaving the old client registered hands + // that turn the previous workspace's tools and credentials anyway — the + // outcome is advice, the registration is what the model sees. + log.info("binding changed while reusing; detaching rather than answering for the old workspace", { + workspaceId, + }) + await removeIfOurs(runningEngine(inspection), { reason: "superseded while reusing" }) + return { kind: "superseded" } + } + const settled = await settleReuse(await confirmServing(runningEngine(inspection))) + if (settled) return settled + // The gap is reported only for the engine this turn is actually answered + // with. Announcing it before the questions above would warn about an + // engine that is then refused or found replaced — a second signal for a + // refusal, and a warning about a client that is not the one serving. + if (declaredKeys && missing.length > 0) { + await notify({ + title: `Workspace "${binding.datamateName}" is missing declared tools`, + message: + `The running engine serves ${available} of ${declaredKeys.keys.length} declared integration tools.` + + describeMissing(missing), + variant: "warning", + }) + } + const reused: Outcome = { + kind: "reused", + available, + ...(declaredKeys ? { declared: declaredKeys.keys.length, missing } : {}), + } + await noteHostedNeighbours(reused) + // The announcements above are awaits; the answer must be true when it is + // given, not only when it was fixed. Same check, same handling, after + // the last of them. + const afterAnnouncing = await settleReuse(await confirmServing(runningEngine(inspection))) + if (afterAnnouncing) return afterAnnouncing + clearAnnouncement(sessionID) + log.info("reusing existing engine entry", { + workspaceId, + available, + version: found, + declared: declaredKeys?.keys.length, + missing, + }) + return reused + } + + // Pinned to us, but below the floor or unreadable. Prefer a newer engine on + // PATH over keeping one whose pin the engine does not lock; if PATH cannot + // do better, say so rather than reuse it silently. + // + // PATH is probed HERE rather than inside the plan because probing spawns a + // process: folding it into the pure decision would charge the reuse path — + // the common one, run on every turn — for a question it never asks. + const { version: pathVersion } = await enginePath() + if (!clearsFloor(pathVersion)) { + const label = found ?? "unknown" + // Rejected and irreplaceable: detach anyway. Leaving it connected would + // return "too old" while still serving the too-old engine's tools. + return await refuse( + { kind: "engine-too-old", found: label }, + { + title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", + message: describeRefusal(found, binding.datamateName), + variant: "warning", + }, + { workspaceId, reason: "below-floor", found: label }, + false, + ) + } + replaced = describeEntry(entry) + replacedNote = ` Replaced an engine entry running ${found ?? "an unreadable version"}, below the ${MIN_ENGINE_VERSION} floor, for this session.` + log.info("existing engine entry is below the version floor; detaching", { + workspaceId, + found, + pathVersion, + }) + // Binding-INDEPENDENT, exactly like its irreplaceable sibling: an engine + // below the floor serves nobody correctly, whatever the project is bound to + // now. Gating this on the binding would let a re-link during the version + // probes leave a too-old client connected and serving under a silent + // `superseded`. + await detachRejected({ workspaceId, reason: "below-floor-replaceable", found }, false) + } + + // Bounded: this lookup is reporting only, but it runs BEFORE the engine is + // launched and its HTTP layer has no abort timeout — so an API that accepts a + // connection and then stalls stopped a good cached binding and an installed + // engine from ever attaching. Reporting degrades; attaching does not wait. + const declaredKeys = await declaredBounded(workspaceId) + const declaredCount = declaredKeys?.keys.length ?? 0 + + // Rule 2 / 3 — opportunistic use, or an offer. Never an install. + const { bin, version: found } = await enginePath() + if (!bin) { + return await refuse({ kind: "engine-missing", declared: declaredCount }, { + title: "Workspace integrations unavailable", + message: + `Workspace "${binding.datamateName}" declares ${declaredCount} integration tool${declaredCount === 1 ? "" : "s"}. ` + + `They run on the local engine, which is not installed. Install it with: ${INSTALL_HINT}`, + variant: "warning", + }) + } + + if (!clearsFloor(found)) { + const label = found ?? "unknown" + return await refuse({ kind: "engine-too-old", found: label }, { + title: found ? "Workspace engine is too old" : "Workspace engine is not runnable", + message: describeRefusal(found, binding.datamateName), + variant: "warning", + }) + } + + // Spawn under the same server key the IDE uses, bound to THIS workspace. + // `--datamate` is pinned engine-side so the settings watcher cannot swap it. + const cfg: LocalMcpConfig = { + type: "local", + command: [ENGINE_BINARY, "start-stdio", "--datamate", workspaceId], + enabled: true, + } + // Snapshot what persist() is about to overwrite — the project file's own + // entry, not the merged view — so a supersede can put back exactly that. + // + // Read BEFORE the guard rather than between it and the writes. Every await + // after the last check reopens the window that check exists to close, and a + // disk read is a wide one. The post-install guard would undo the stale attach, + // but only after it had spawned an engine and held the per-project lock — long + // enough for the replacement's first-turn wait to expire, which is the failure + // the guard was added to prevent. Nothing may await between the guard and the + // mutations it guards. + // Everything readable is read HERE, above the guard. `persist` otherwise + // probes up to nine candidate config paths on disk between the check and the + // write it protects, which is a window a re-link can land in. + // If we cannot record what to put back, we do not write. An unreadable + // config read that fails must not read as "no entry here": a later restore + // acts on that by REMOVING, so it would delete the user's own entry as the + // undo of an attach meant to leave it alone. + // The path FIRST, and then the snapshot read from that exact path. Resolving + // twice means the snapshot can come from one file while the write goes to + // another — an IDE creating or removing a higher-priority config between the + // two is enough — after which the undo restores the first file's entry into + // the second, over whatever the user had there. One resolution, used by the + // read, the write and the undo alike. + let configPath: string + try { + configPath = await projectConfigPath() + } catch (err) { + // Falling back to persist's own resolution would write to a path we could + // not resolve here, which the undo then re-resolves independently — two + // guesses about which file we touched. If we cannot say where we would + // write, we do not write. + return await refuseUnreadable(`config path could not be resolved: ${String(err)}`) + } + // If we cannot record what to put back, we do not write. An unreadable + // config read that fails must not read as "no entry here": a later restore + // acts on that by REMOVING, so it would delete the user's own entry as the + // undo of an attach meant to leave it alone. + let projectBefore: ExistingEntry | null + try { + projectBefore = await projectEntry(configPath) + } catch (err) { + return await refuse({ kind: "connect-failed", error: `project config unreadable: ${String(err)}` }, { + title: "Workspace engine not attached", + message: + `Could not read this project's configuration, so the engine was not installed — attaching without being ` + + `able to undo it risks overwriting your own "${DATAMATE_KEY}" entry. Integration tools are unavailable ` + + `until the config file can be read.`, + variant: "error", + }) + } + const beforeInstall = await worldUnchanged(configuredEntry(inspection)) + if (beforeInstall === "disabled") return await refuseDisabled() + if (beforeInstall === "unreadable") return await refuseUnreadable("intent could not be confirmed") + if (beforeInstall !== "ok") { + // Re-linked while we were probing. Installing now would attach a workspace + // this session has left, and would win by arriving first. + log.info("abandoning attach; the binding changed before the engine was installed", { workspaceId }) + return { kind: "superseded" } + } + + // ---- the install region ------------------------------------------------ + // + // Past the next two lines this attach OWNS two things: a pinned entry on disk + // and a registered runtime client. Every exit that is not `attached` has to + // give both back — including an exit nobody wrote. + // + // Three separate defects lived in this region because each exit remembered + // the undo separately. The post-install `connect-failed` return had no undo + // at all, so a failing engine left our pin on disk and the user's own project + // entry gone; because a failing pin is retried rather than replaced, the + // project then wedged on `connect-failed` until someone edited config by + // hand. A throw from the status or tool read — a malformed config written + // concurrently by an IDE is enough — unwound straight past every undo with + // the engine registered, connected and persisted. And the supersede guard's + // undo was correct but was the only one. + // + // `committed` rather than a bare `finally` because the attached path must not + // undo itself. One rule, one place, and exits nobody anticipated are covered + // by construction rather than by review. + let committed = false + // Distinct from `committed`: whether anything was actually written or + // registered. A write refused at the last moment left nothing behind, and the + // undo must not "restore" over a config it never touched. + let installed = false + let undone = false + /** Give back both halves, once, before anything else happens. + * + * In-region refusals undo before they announce, which is the rule `refuse` + * states for every other exit: stop serving first, explain second. The + * announcement is a substitution point, and a body that waits on a person + * would otherwise leave a failed engine's registration and its pin outliving + * the dialog, with a restart inside it bootstrapping the entry we had already + * decided against. + * + * Idempotent, so the `finally` stays as a backstop for exits nobody wrote. */ + const undoNow = async (): Promise => { + if (!installed || undone) return + undone = true + const restored = await undoInstall(projectBefore, cfg).catch((err) => { + log.warn("could not undo a non-attached install", { err: String(err), workspaceId }) + return "failed" as const + }) + if (restored === "failed") { + // An undo that could not be confirmed is an actionable failure, not a + // quiet one. Our pin is still on disk and MCP bootstraps every enabled + // entry, so the next restart starts the workspace this attach walked away + // from — and nothing else will ever mention it. `superseded` stays silent + // only when there is genuinely nothing left behind. + await announceRefusal( + { kind: "connect-failed", error: "restore failed" }, + { + title: "Workspace engine config left behind", + message: + `The engine entry for workspace "${binding.datamateName}" was installed and then abandoned, but the ` + + `previous "${DATAMATE_KEY}" entry could not be restored${configPath ? ` in ${configPath}` : ""}. ` + + `That pin is still on disk and will start on the next restart; edit or remove it to be sure.`, + variant: "error", + }, + { workspaceId, workspaceName: binding.datamateName, sessionID }, + ) + } + } + try { + if ((await persist(DATAMATE_KEY, cfg, configPath)) === "disabled") { + // A disable landed between our guard and the write, and the write saw it. + // Nothing was written and nothing registered. + log.info("write refused: the entry is disabled on disk", { workspaceId }) + return await refuseDisabled() + } + installed = true + await client.add(DATAMATE_KEY, cfg) + + // Rule 4 — a failed local engine is reported, never routed around. + const after = (await client.status())[DATAMATE_KEY] + if (after?.status !== "connected") { + const error = after?.error ?? after?.status ?? "not connected" + // Undo BEFORE announcing — see `undoNow`. + await undoNow() + // `which` rather than the error string: "the engine failed to start" and + // "there is no engine" are different situations with different remedies, + // and only the second is fixed by installing one. Reading ENOENT out of a + // message would be re-deriving from a platform detail what a PATH lookup + // answers directly. + if (!which(ENGINE_BINARY)) { + return await refuse({ kind: "engine-missing", declared: declaredCount }, { + title: "Workspace integrations unavailable", + message: + `Workspace "${binding.datamateName}" declares ${declaredCount} integration tool${declaredCount === 1 ? "" : "s"}. ` + + `They run on the local engine, which is not installed. Install it with: ${INSTALL_HINT}`, + variant: "warning", + }) + } + return await refuse({ kind: "connect-failed", error }, { + title: "Workspace engine failed to start", + message: `Could not start ${ENGINE_BINARY} for workspace "${binding.datamateName}": ${error}. Integration tools are unavailable; not falling back to the hosted endpoint because it serves a different tool set.`, + variant: "error", + }) + } + + // Rule 5 — report declared-but-missing. + const present = engineToolKeys(await client.tools()) + const missing = declaredKeys ? declaredKeys.keys.filter((k) => !present.has(k)) : [] + const available = present.size + // ONE guard, placed after every await that follows the install — the + // handshake AND the tool listing. Both are windows a re-link can land in; + // guarding only the first leaves a flip during the tool read with the + // previous workspace installed and reported as attached. + // + // Late rather than early on purpose: the check is only meaningful at the + // last moment before we announce and answer, because everything before that + // is still revocable. The undo itself now belongs to the region. + // After the write, "has the world moved" becomes "is what is SERVING still + // mine" — `confirmServing` asks both, the same two questions the reuse + // answer asks. The config half is deliberately not compared against the + // plan here: what is on disk after our write is our own, and an edit + // landing on it afterwards belongs to the undo, which already refuses to + // roll back an entry that is no longer ours. + const verdict = await confirmServing(cfg) + if (verdict !== "ok") { + log.info("the world changed before the attach could be reported; undoing what we installed", { + workspaceId, + why: verdict, + }) + // Either way the install is undone by the region, BEFORE anything is + // announced. A disable reports itself so the user learns their edit took + // effect, rather than a generic race. + await undoNow() + if (verdict === "disabled") return await refuseDisabled() + if (verdict === "unreadable") return await refuseUnreadable("intent could not be confirmed") + return { kind: "superseded" } + } + + // Ours, and staying. Answer BEFORE announcing: `announceToolsChanged` and + // the toast are two more awaits, and the outcome asserts which workspace is + // served — so it is fixed while that assertion is still true. + committed = true + // The problem the user was last told about is gone. If it returns, they + // should hear about it rather than have it deduplicated against a verdict + // from before the repair. + clearAnnouncement(sessionID) + const outcome: Outcome = { + kind: "attached", + available, + declared: declaredCount, + missing, + ...(replaced ? { replaced } : {}), + } + log.info("attached workspace engine", { workspaceId, available, declared: declaredCount, missing, replaced }) + + // Announce it so a turn that had already given up waiting still learns the + // tools arrived — and never let announcing change what happened. + // + // These two awaits carry no no-throw guarantee at the seam; only the + // production bodies happen to swallow, and the region did not encode that + // dependency. A throw here escaped to the catch-all and reported + // `connect-failed` for an engine that is attached, connected and persisted + // — the single toast telling the user the attach failed while the tools are + // in fact there. Describing an outcome must never rewrite it, on the success + // path exactly as on the refusal path. + try { + await announceToolsChanged() + await notify({ + title: `Workspace "${binding.datamateName}" connected`, + message: + (declaredKeys + ? `${available} of ${declaredCount} declared integration tools available.` + : `${available} integration tools available.`) + + describeMissing(missing) + + replacedNote, + variant: missing.length > 0 ? "warning" : "success", + }) + } catch (err) { + log.warn("could not announce the attach; the engine is attached regardless", { + workspaceId, + err: String(err), + }) + } + await noteHostedNeighbours(outcome) + // Three more awaits sit between fixing the answer and giving it, and every + // await after a guard belongs to the guard's problem: a re-link, a disable + // or a replacement landing inside the announcements would otherwise leave + // this turn holding `attached` for an engine that no longer serves the + // bound workspace. The toast was true when it was shown; the answer must be + // true when it is given. Superseded is repairable, so the next turn + // re-decides for whatever is bound then. + const afterAnnouncing = await confirmServing(cfg) + if (afterAnnouncing !== "ok") { + log.info("the world changed while the attach was being announced; undoing rather than answering for it", { + workspaceId, + why: afterAnnouncing, + }) + await undoNow() + return { kind: "superseded" } + } + return outcome + } catch (err) { + // Undo first, then decide how to report. A throw that lands after a re-link + // is the same situation as any other refusal for a workspace the project has + // left: answering names the wrong workspace and toasting is worse. The + // catch-all announces every throw it sees, so this one must not reach it. + // The `finally` performs the undo — one backstop, not two. It runs before + // this function's value reaches the caller, and before the catch-all + // announces anything, so the ordering that matters still holds. + if (!(await stillCurrent())) { + log.info("attach threw after the binding moved; not answering for the old workspace", { + workspaceId, + err: String(err), + }) + return { kind: "superseded" } + } + throw err + } finally { + if (!committed) { + await undoNow() + } + } +} + +// --------------------------------------------------------------------------- +// Public entry — idempotent per session, never throws. `ensure` never blocks a +// turn; `whenAttached` is the one bounded wait, and only turn 1 pays it. +// --------------------------------------------------------------------------- + +/** How long a turn may wait for a fresh attach before proceeding without it. + * + * A cold attach measured ~6.5s on a warm machine — ~1s to probe `--version`, + * ~1s for the workspace's declared allowlist, and ~4.5s for the engine to boot, + * handshake, and build its tools — and crossed 8s under the load of a real + * turn. The cap is set well clear of that so the common case lands inside it, + * and still far below MCP's own 30s connect timeout so an engine that never + * answers costs the first turn a pause rather than the turn itself. */ +export const ATTACH_WAIT_MS = 15_000 + +type SessionAttach = { + key?: string + /** The last verdict this session was told about — see `verdictSignature`. */ + announced?: string + /** The set of hosted datamate servers this session has been told about. */ + announcedHosted?: string + task: Promise + waitTimedOut?: boolean + outcome?: Outcome + /** The entry argv whose version we last verified against the floor. */ + validated?: string + /** The launch identity of the running engine the last memo validation + * judged — set only when the runtime had a record to judge. Compared once + * more after the final binding read, so the memo is not returned for a + * client that replaced it in between. */ + judged?: string +} + +/** Outcomes the user can repair without restarting: install the engine, update + * it, fix a broken entry. Caching these for the life of the session means the + * hint we just printed ("install it with …") can be followed and nothing + * happens until a new session — so they are re-probed on the next turn. */ +const REPAIRABLE = new Set([ + "engine-missing", + "engine-too-old", + "connect-failed", + "entry-disabled", + "superseded", +]) + +function isRepairable(outcome: Outcome | undefined): boolean { + return !!outcome && REPAIRABLE.has(outcome.kind) +} + +/** Did this outcome leave an engine serving this session? */ +function wasServing(outcome: Outcome | undefined): boolean { + return attributableEngine(outcome) +} + +/** Is the memoised success still true? + * + * Validated by the SAME reader and the SAME decision as a fresh attach. This is + * the common path — every turn after the first takes it — and a second + * implementation of the decision would be a second place for it to be wrong. + * + * "Still valid" is the plan saying reuse. Nothing else. */ +async function memoStillValid(workspaceId: string, record?: SessionAttach): Promise { + try { + const inspection = await inspectEntry() + // `retried: true` — this is not the place to revive anything. If the engine + // is down, the memo is not valid and a fresh attach decides what to do. + const plan = planForEntry(inspection, workspaceId, true) + if (plan.act !== "check-version") { + log.info("cached attach no longer describes a reusable engine; re-deciding", { + workspaceId, + act: plan.act, + }) + return false + } + + // The FLOOR is what makes the pin trustworthy, since engines below it do not + // lock it — and like the pin, it is a question about the engine that is + // RUNNING. Probing the configured command instead lets a newly configured + // modern binary vouch for a running pre-floor one under the same pin, and + // record that as validated for the rest of the session. + // + // Re-probed when either command changes, because probing spawns a process + // and this runs every turn. A divergence between the two IS the case that + // needs re-probing, so the key carries both. The residual is narrow and + // worth naming: a binary swapped in place under an unchanged command is not + // caught until the next session. + const running = runningEngine(inspection) + // Keyed on the whole launch identity of both halves, not their argv: a + // replacement with the same argv under a different PATH or working + // directory runs a different binary, and must be probed again. + const command = `${entryIdentity(running)}|${entryIdentity(configuredEntry(inspection))}` + // What was judged, for the caller's last question after its final binding + // read. Only when the runtime had a record — `runningEngine` falls back to + // the configured entry when it has none, and a later read of the record + // has nothing to disagree with in that case. + if (record) record.judged = running !== configuredEntry(inspection) ? entryIdentity(running) : undefined + if (record && record.validated === command) return true + const found = await engineVersionOf(running) + if (!clearsFloor(found)) { + log.info("cached attach no longer clears the version floor; re-attaching", { workspaceId, found }) + return false + } + // Recorded on the CURRENT entry — the one that will be remembered and copied + // forward. Writing it to the previous entry would be discarded next turn. + if (record) record.validated = command + return true + } catch (err) { + // Fails CLOSED. Returning true would serve a memo whose world could not be + // confirmed — a disabled entry or a moved pin riding a transient probe + // error, on the path every turn after the first takes. Returning false + // discards nothing: it routes back through `run()`, which re-inspects under + // the per-project lock and either attaches or refuses through the single + // exit, with no mutation. A failed read is never an answer. + log.warn("could not confirm the cached attach; re-deciding rather than serving it", { err: String(err) }) + return false + } +} + +/** Cap on remembered sessions. + * + * These maps are module-level and a long-running `serve` process creates + * sessions indefinitely, so without a bound they grow for the life of the + * process. Evicting the oldest is safe: a session whose memo is dropped simply + * re-attaches on its next turn, which is correct, just not free. */ +export const MAX_TRACKED_SESSIONS = 256 + +const sessions = new Map() + +/** Insertion-ordered eviction — `Map` preserves insertion order, so the first + * key is the least recently STARTED attach. */ +function rememberSession(sessionID: string, entry: SessionAttach): void { + sessions.delete(sessionID) + sessions.set(sessionID, entry) + while (sessions.size > MAX_TRACKED_SESSIONS) { + const oldest = sessions.keys().next() + if (oldest.done) break + sessions.delete(oldest.value) + } +} + +/** Test seam — how many sessions are currently remembered. */ +/** Test seam — the session map itself, for asserting wait bookkeeping. */ +export function sessionsForTests(): Map { + return sessions as unknown as Map +} + +export function trackedSessionsForTests(): number { + return sessions.size +} + +/** What a memoised attach is valid FOR. + * + * Memoising on the session id alone was wrong: the binding can change while a + * session is open — `recordApprovedBinding` is reachable mid-session from the + * TUI workspace panel as well as from `altimate-code link`. A session that + * started unbound would then never attach, and one that was re-linked to another + * workspace would keep serving the old workspace's tools, both silently and for + * the rest of the session. Keying on the bound workspace makes a re-link produce + * a fresh attach on the next turn and leaves everything else memoised as before. */ +/** The bound workspace id, or null when unbound or disabled. */ +async function attachKeyWorkspace(): Promise { + if (!isEnabled()) return null + const binding = await resolveBinding() + return binding ? String(binding.datamateId) : null +} + +async function attachKey(): Promise { + if (!isEnabled()) return "disabled" + const binding = await resolveBinding() + return binding ? `workspace:${binding.datamateId}` : "unbound" +} + +export function ensure(sessionID: string): Promise { + // NOT async, and the entry is registered SYNCHRONOUSLY. `whenAttached` is + // called on the line after this one and looks the session up by id; if the + // registration happened after an await, that lookup would find nothing and the + // turn would sail past without waiting — which is exactly the first-turn gap + // this module exists to close. All the async work happens inside the task. + const previous = sessions.get(sessionID) + // Decided SYNCHRONOUSLY, because the entry is published synchronously and + // `whenAttached` reads it on the very next line. Whether this is a repair + // retry depends only on the previous outcome, which is already known — the + // workspace comparison needs an await, and refining the flag after that await + // is too late: the timer is armed by then, so a hung retry charged the turn + // the full cap despite the retry being documented as non-blocking. + // + // Conservative in the right direction: if the binding also changed, the branch + // below resets this to false and that fresh attach may lose its wait for one + // turn. Failing to wait costs a turn's tools, which `tools/list_changed` + // repairs; waiting wrongly costs every turn 15 seconds. + const repairRetry = !!previous && isRepairable(previous.outcome) + // A previous timeout must not silence the wait forever. Re-validating a + // settled memo is a status read and a config read with no spawn — bounded, and + // cheap enough that a turn should always wait for it, because during that + // window the outcome reads as "not settled" and a consumer that fails open on + // that will quietly stop routing for the turn and announce it. The no-wait + // rule belongs to the attach that earned it: a repair that can spawn, or a + // spawn still in flight from an earlier turn. + const stillInFlight = !!previous && previous.outcome === undefined + const entry = { + key: previous?.key, + waitTimedOut: repairRetry || (!!previous?.waitTimedOut && stillInFlight), + // Carried forward, or the version re-probe spawns a process every turn: a + // fresh entry is built per call, so state that is not copied is state that + // is silently rebuilt. + validated: previous?.validated, + // Carried forward for the same reason `validated` is: a fresh entry is built + // per call, so state that is not copied is state that is silently rebuilt — + // and rebuilding this one turns "say it once" back into "say it every turn". + announced: previous?.announced, + announcedHosted: previous?.announcedHosted, + } as SessionAttach + // The whole task, not just the attach. `attachKey`, the memo re-validation and + // the serialization chain all run BEFORE the attach's own catch, so a throw in + // any of them escaped `ensure` as a rejected promise: no outcome, no toast, + // and — since the caller starts this fire-and-forget — silence, which is the + // one failure mode this module exists to remove. "Exactly one exit for throws + // too" has to mean the whole task, or it names a boundary rather than a rule. + entry.task = failSafely(sessionID, async (): Promise => { + const key = await attachKey() + const sameWorkspace = !!previous && previous.key === key + // Same workspace and the attach either succeeded or is still in flight: + // reuse it. A settled FAILURE is not reused — the user may have acted on + // the hint it produced. + if (sameWorkspace && !isRepairable(previous!.outcome)) { + // Re-probe before trusting a cached success — see `engineStillConnected`. + const boundTo = await attachKeyWorkspace() + const reusable = + !wasServing(previous!.outcome) || !boundTo || (await memoStillValid(boundTo, entry)) + // Validating the cached success is itself awaited work — status, config and + // possibly a version probe — so the binding can move underneath it. This + // path lives outside `run()` and therefore never had its final check; + // without one, a confirmed-valid engine for the workspace we just left is + // returned as the answer for the one we just joined. + if (reusable && (await attachKeyWorkspace()) === boundTo) { + // The memo names an engine, so it is given only after the same two + // questions every named answer asks. The binding was just confirmed; + // the runtime record was read inside the validation, one binding read + // ago — and the MCP route or the IDE's reload can replace the client + // in that gap. Asked again, last, because what is registered now is + // what `resolveTools` will hand the model. + const servingNow = entry.judged ? await mcp().spawned?.(DATAMATE_KEY).catch(() => undefined) : undefined + if (!entry.judged || (servingNow && entryIdentity(servingNow) === entry.judged)) return previous!.task + log.info("the running engine changed after the memo was validated; re-attaching", { sessionID }) + } else { + log.info("cached attach is no longer connected; re-attaching", { sessionID }) + } + } + // Recomputed AFTER the awaited validation above: a re-link landing inside it + // would otherwise file this fresh attach under the workspace key it started + // with, and the turn would drop its wait for an attach that is no longer the + // one it needs. Self-healing next turn, but a turn is what this exists to + // save. + const settledKey = await attachKey() + entry.key = settledKey + if (settledKey === key && sameWorkspace) { + // Re-probing a repairable failure. Do NOT re-arm the wait: this runs on + // every turn, and a retry that blocks would charge each one the full cap + // (a `connect-failed` retry can sit in MCP's 30s connect budget). The + // repaired engine's tools arrive over `tools/list_changed` instead. + entry.waitTimedOut = true + } else { + // The binding changed under this session (or changed while we validated). + // A fresh attach gets a fresh wait budget — the previous one was spent on a different workspace's engine. + entry.waitTimedOut = false + // Serialize against the attach being superseded. Both tasks end in + // `MCP.add`, and whichever completes LAST owns the runtime client, so a + // slower attach for the workspace we just left could otherwise land after + // this one and restore its tools — with this session's memo already + // settled, so no later turn would repair it. + if (previous) await previous.task.catch(() => {}) + } + return attachOnce(sessionID) + }) + entry.task.then( + (outcome) => { + entry.outcome = outcome + }, + () => {}, + ) + rememberSession(sessionID, entry) + return entry.task +} + +/** One attach, serialized against every other attach in this project, with the + * outcome logged exactly once. */ +/** Run an attach task so that NOTHING escapes as a rejection. + * + * Every explicit failure branch tells the user what is unavailable and why. An + * unexpected throw must not be the single path that leaves them with neither + * tools nor an explanation: the caller starts this fire-and-forget and + * `whenAttached` returns void, so a rejection here is silence. + * + * Announced through the same exit as every decided refusal, and with NO + * workspace identity — a throw can happen before a binding exists, so anything + * downstream that wants to name a workspace has to cope with not having one. */ +/** `String(err)` on a value with a null prototype throws INSIDE the catch, and + * the task rejects after all — the one remaining route to a session whose + * outcome never settles and whose await rejects into the prompt loop. Nothing in + * this codebase throws such a value; the cost of being sure is three lines. */ +function describeThrown(err: unknown): string { + if (err instanceof Error) return err.message + try { + return String(err) + } catch { + return typeof err + } +} + +async function failSafely(sessionID: string, task: () => Promise): Promise { + try { + return await task() + } catch (err) { + const error = describeThrown(err) + log.warn("workspace engine attach failed", { sessionID, err: error }) + const outcome: Outcome = { kind: "connect-failed", error } + await announceRefusal( + outcome, + { + title: "Workspace engine attach failed", + message: `Could not attach the workspace engine: ${error}. Integration tools are unavailable for this session.`, + variant: "error", + }, + { sessionID }, + ) + return outcome + } +} + +function attachOnce(sessionID: string): Promise { + return serializeAttach(() => run(sessionID)) + .then((outcome) => { + // One line per session, whatever happened — silence is the defect this + // module exists to remove, so it must not be silent about itself. + log.info("workspace engine outcome", { sessionID, ...outcome }) + return outcome + }) +} + +/** The memoised outcome for a session, if its attach has already settled. + * + * A pure read: it creates no task, registers nothing, awaits nothing, and does + * not touch the memo or the project chain. `ensure()` is deliberately unsuitable + * for this — it builds a fresh task per call and awaits the binding before + * resolving, so a caller polling it would never see an already-settled promise + * AND would re-register the session entry once per turn, mutating bookkeeping it + * only meant to read. Worse, awaiting it is unbounded, which reintroduces the + * prompt hang the bounded `whenAttached` exists to prevent. + * + * Returns undefined while an attach is still in flight, and for a session that + * has never attached. Callers must treat undefined as "not known yet", never as + * "no engine". */ +export function settledOutcome(sessionID: string): Outcome | undefined { + return sessions.get(sessionID)?.outcome +} + +/** Wait for a session's in-flight attach, capped. + * + * A turn resolves its tool list up front, before the per-turn block that starts + * the attach runs. A session that spawns its own engine therefore listed the + * engine's tools one turn late — the model saw `datamate_manager` alone on turn + * 1 and the integration tools only from turn 2. The caller starts `ensure` + * ahead of tool resolution and waits here to close that gap. + * + * Only a turn that actually spawns pays for it: `disabled` and `unbound` settle + * with no I/O beyond a local cache read, and a reused entry settles as fast as + * the status call it already makes. On timeout the turn proceeds without the + * engine's tools and `tools/list_changed` delivers them when the attach lands. */ +export async function whenAttached(sessionID: string, timeoutMs: number = ATTACH_WAIT_MS): Promise { + const state = sessions.get(sessionID) + if (!state) return + // A wait that already blew its budget must not be paid again: the caller's + // block runs on every user turn, and a hung engine keeps this promise pending + // for MCP's full connect timeout, so every later turn would pay the cap too. + if (state.waitTimedOut) return + let timer: ReturnType | undefined + let timedOut = false + try { + await Promise.race([ + state.task, + new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true + resolve() + }, timeoutMs) + timer.unref?.() + }), + ]) + } finally { + if (timer) clearTimeout(timer) + if (timedOut) { + state.waitTimedOut = true + log.info("workspace engine attach did not land in time for this turn", { sessionID, timeoutMs }) + } + } +} + +/** Test seam — drop memoised outcomes. */ +export function resetForTests(): void { + sessions.clear() + attachChains.clear() +} diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts new file mode 100644 index 0000000000..10c4dd0506 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -0,0 +1,331 @@ +// altimate_change - new file +// +// Vocabulary for the workspace engine attach: the outcome union, the shapes it +// reads, and the pure predicates over them. Nothing here performs I/O or reads +// ambient state, so nothing here can be reordered against anything else. +import { DATAMATE_KEY } from "@/altimate/datamate-transport" + +/** Oldest engine this client is known to work against. + * + * 0.7.0 is the first engine that LOCKS the `--datamate` pin, so a settings + * change cannot swap the workspace out from under a running engine. Everything + * below it can drift, which is precisely what the attribution check in rule 1 + * exists to exclude — so the floor and that check are one mechanism, not two. */ +export const MIN_ENGINE_VERSION = "0.7.0" +export const INSTALL_HINT = "npm i -g @altimateai/datamate" +export const ENGINE_BINARY = "datamate" + +/** Engine tools arrive under the MCP server key as `_`. */ +export const TOOL_PREFIX = `${DATAMATE_KEY}_` + +export type Outcome = + | { kind: "disabled" } + | { kind: "unbound" } + | { kind: "reused"; available: number; declared?: number; missing?: string[] } + | { kind: "attached"; available: number; declared: number; missing: string[]; replaced?: string } + | { kind: "engine-missing"; declared: number } + | { kind: "engine-too-old"; found: string } + | { kind: "connect-failed"; error: string } + | { kind: "entry-disabled" } + | { kind: "superseded" } + +export type LocalMcpConfig = { + type: "local" + command: string[] + enabled: boolean + environment?: Record + cwd?: string + timeout?: number +} + +/** A configured MCP entry, in either shape it can reach us: opencode's own + * `command: string[]` argv, or the `{ command, args }` split an IDE writes and + * `datamate-transport` normalises. Read defensively — this is merged config + * written by other clients. */ +export type ExistingEntry = { + type?: string + url?: string + command?: string[] | string + args?: string[] + environment?: Record + cwd?: string + timeout?: number + enabled?: boolean +} + +export type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } + +export type McpStatus = Record + +/** Declared allowlist for a workspace, split by whether the CLI can serve it. + * Extension-type integrations are RPC into a live VS Code host and have no + * meaning on the CLI surface, so they are excluded from the reported gap. */ +export type Declared = { keys: string[]; extensionKeys: string[] } + +/** SemVer precedence compare. Returns <0, 0, >0. + * + * Build metadata is ignored, and a NON-numeric core component compares as older + * so unreadable `--version` output can never clear a floor. + * + * Pre-release ordering is honoured rather than stripped: `0.7.0-beta.1` is + * BELOW `0.7.0`. That matters here — the floor exists to require behaviour that + * shipped in a specific release (the locked `--datamate` pin), and a pre-release + * of that version predates it. Treating them as equal let a beta clear the floor + * and be trusted for reuse. */ +export function compareVersions(a: string, b: string): number { + /** An exact `major.minor.patch` of digits, or null. + * + * `Number.parseInt` was too permissive: it reads "7rc" as 7, so "0.7rc.0" + * compared EQUAL to a 0.7.0 floor, and a bare "1" won on major before its + * missing components were ever examined. Unreadable output must never + * authorise reuse of an engine whose pin-locking cannot be established, so + * anything not exactly three numeric parts is treated as older. */ + const parseCore = (raw: string): number[] | null => { + const parts = raw.split(".") + if (parts.length !== 3) return null + if (!parts.every((part) => /^\d+$/.test(part))) return null + return parts.map((part) => Number(part)) + } + const split = (v: string) => { + const bare = v.trim().replace(/^v/, "") + const plus = bare.indexOf("+") + const noBuild = plus >= 0 ? bare.slice(0, plus) : bare + const dash = noBuild.indexOf("-") + return { + core: parseCore(dash >= 0 ? noBuild.slice(0, dash) : noBuild), + pre: dash >= 0 ? noBuild.slice(dash + 1) : "", + } + } + const pa = split(a) + const pb = split(b) + // A core we cannot read ranks below one we can, and two unreadable ones tie. + if (!pa.core || !pb.core) return !pa.core && !pb.core ? 0 : pa.core ? 1 : -1 + for (let i = 0; i < 3; i++) { + if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i] + } + // Same core: a release outranks every pre-release of it (SemVer §11.3). + if (!pa.pre && !pb.pre) return 0 + if (!pa.pre) return 1 + if (!pb.pre) return -1 + const ia = pa.pre.split(".") + const ib = pb.pre.split(".") + for (let i = 0; i < Math.max(ia.length, ib.length); i++) { + const x = ia[i] + const y = ib[i] + if (x === undefined) return -1 + if (y === undefined) return 1 + const nx = /^\d+$/.test(x) + const ny = /^\d+$/.test(y) + if (nx && ny) { + const d = Number(x) - Number(y) + if (d !== 0) return d + } else if (nx !== ny) { + return nx ? -1 : 1 + } else if (x !== y) { + return x < y ? -1 : 1 + } + } + return 0 +} + +/** Strip the server prefix from the engine tools present in the catalog. */ +export function engineToolKeys(tools: Record): Set { + const out = new Set() + for (const key of Object.keys(tools)) { + if (key.startsWith(TOOL_PREFIX)) out.add(key.slice(TOOL_PREFIX.length)) + } + return out +} + +/** URL-based entries (`type: "remote"`, or any `url`) point at a process this + * client does not own: an IDE's in-process engine, or the hosted endpoint. */ +export function isUrlEntry(entry: ExistingEntry | null): entry is ExistingEntry & { url: string } { + return !!entry && (entry.type === "remote" || typeof entry.url === "string") +} + +export const PIN_FLAG = "--datamate" + +/** The entry's full argv, flattening both config shapes. */ +export function commandArgv(entry: ExistingEntry | null): string[] { + if (!entry) return [] + const head = typeof entry.command === "string" ? [entry.command] : (entry.command ?? []) + return [...head, ...(entry.args ?? [])] +} + +/** Which workspace does this entry pin its engine to, if any? + * + * `--datamate ` is the whole of an engine's workspace identity: the engine + * locks it, so a settings change cannot swap it out underneath. An entry + * WITHOUT it is not neutral — it serves whichever teammate its owner currently + * has active, and that changes at runtime from a UI this client does not + * control. The extension writes exactly such an entry (`datamate start-stdio`, + * no pin), so "connected" alone never proves an engine serves this workspace. + * + * Scanned from the end because a repeated flag resolves last-wins, and both the + * `--datamate 5` and `--datamate=5` spellings are valid on the engine's CLI. */ +export function pinnedWorkspace(entry: ExistingEntry | null): string | null { + const argv = commandArgv(entry) + for (let i = argv.length - 1; i >= 0; i--) { + const arg = argv[i] + if (arg === PIN_FLAG) return argv[i + 1] ?? null + if (arg.startsWith(`${PIN_FLAG}=`)) return arg.slice(PIN_FLAG.length + 1) || null + } + return null +} + +/** Short, printable identity of an entry, for saying what was replaced. */ +export function describeEntry(entry: ExistingEntry | null): string { + if (isUrlEntry(entry)) return entry.url + const argv = commandArgv(entry) + return argv.length > 0 ? argv.join(" ") : "an engine entry with no command" +} + +/** Why an engine was refused, in the user's terms. + * + * "Too old" and "could not be run at all" are the same code path but very + * different problems, and conflating them sent more than one debugging session + * hunting a version mismatch that did not exist. `versionOf` reads stdout only + * and returns null when the process fails, so a null here means the binary did + * not produce a version — broken, not merely out of date. */ +export function describeRefusal(found: string | null, workspaceName: string): string { + if (!found) { + return ( + `The ${ENGINE_BINARY} on PATH did not report a usable version, so it cannot be used for workspace ` + + `"${workspaceName}". It is more likely broken than out of date — try running \`${ENGINE_BINARY} --version\` ` + + `directly. Reinstall with: ${INSTALL_HINT}` + ) + } + return ( + `Found ${ENGINE_BINARY} ${found}; workspace "${workspaceName}" needs ${MIN_ENGINE_VERSION} or newer. ` + + `Update with: ${INSTALL_HINT}` + ) +} + +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}.` +} + +/** Are these two entries the same entry? + * + * The identity a destructive act needs: is what is here still what I put here. + * Compared by value rather than by reference, because what comes back from disk + * or from MCP is a different object carrying the same meaning — and across + * everything that changes the process it describes, not argv alone. */ +/** Every field of an entry that identity depends on. + * + * Keyed by the type so the compiler asks the question: add a field to + * `ExistingEntry` and this fails to build until someone either lists it here or + * adds it to the exclusion, which makes ignoring it a decision rather than a + * default. A field the comparison silently forgets makes two different entries + * compare equal, and a teardown or an undo then acts on something that is not + * its own while believing it is. + * + * `enabled` is excluded on purpose: a disabled entry is still the same entry. + * Intent is decided above the comparison, which keeps a disable rather than + * rolling it back; folding it in here would make a disable read as somebody + * else's entry and take the wrong branch for the right-sounding reason. */ +const IDENTITY_FIELDS: Record, true> = { + type: true, + url: true, + command: true, + args: true, + environment: true, + cwd: true, + timeout: true, +} + +export function sameEntry(a: ExistingEntry | null | undefined, b: ExistingEntry | null | undefined): boolean { + return entryIdentity(a) === entryIdentity(b) +} + +/** The identity of the process an entry describes, as one comparable string — + * the same fields `sameEntry` compares, usable as a cache key. A cache keyed + * on argv alone accepts a replacement with the same argv under a different + * PATH or working directory, which runs a different binary. */ +export function entryIdentity(e: ExistingEntry | null | undefined): string { + const raw = (e ?? {}) as Record + const parts: Record = { + // `command` and `args` are compared as the argv they produce, since the + // same invocation can be spelled either way. + argv: commandArgv((e ?? null) as ExistingEntry | null), + } + for (const field of Object.keys(IDENTITY_FIELDS)) { + if (field === "command" || field === "args") continue + parts[field] = raw[field] ?? null + } + return JSON.stringify(parts) +} + +/** Is this engine version usable at all? + * + * The single definition of "unusable" for this module. An unreadable version is + * treated as below the floor: the floor exists because engines under it do not + * lock their `--datamate` pin, and an engine that cannot say what it is cannot + * be shown to lock it either. */ +export function clearsFloor(version: string | null): boolean { + return !!version && compareVersions(version, MIN_ENGINE_VERSION) >= 0 +} + +/** What each outcome MEANS, stated once, as tables over the whole union. + * + * Two different consumers — tool precedence and the install offer — each need a + * yes/no answer about an outcome, and each had derived it independently: one by + * comparing kinds inline, the other by relying on where its call site sat in the + * control flow. Both are the same latent bug, which is that adding a state to + * this union silently gives it an answer nobody chose. + * + * A `Record` keyed by the union is the strongest available guard: a new variant + * fails to compile until every table names it, and a removed one fails too. That + * holds regardless of tsconfig strictness, which a `switch` with no default does + * not. The safe answer is `false` in both tables, so the compiler asks the + * question and the answer is chosen deliberately. */ +export const SERVING: Record = { + attached: true, + reused: true, + disabled: false, + unbound: false, + "engine-missing": false, + "engine-too-old": false, + "connect-failed": false, + "entry-disabled": false, + // The binding moved while this attach was in flight, so whatever is connected + // was established for a workspace this project has already left. + superseded: false, +} + +/** Would installing the engine fix this outcome? + * + * NOT the same question as "did the attach refuse", and the two diverge exactly + * where it matters: a user who deliberately disabled their engine would be + * offered an install for an engine they already have and switched off, and a + * failed connection is not an absence. Only genuine unobtainability qualifies. */ +export const INSTALL_HELPS: Record = { + "engine-missing": true, + "engine-too-old": true, + attached: false, + reused: false, + disabled: false, + unbound: false, + "connect-failed": false, + "entry-disabled": false, + superseded: false, +} + +/** Is an engine attributable to THIS session serving it? + * + * The contract for tool precedence: the config pin is the naming signal and this + * is the runtime one, and both must agree before queries are routed into a + * workspace's credentials. `undefined` means not settled — in flight or never + * attached — and must stay distinguishable from a refusal, because the caller + * fails open on it. */ +export function attributableEngine(outcome: Outcome | undefined): boolean { + return !!outcome && SERVING[outcome.kind] +} + +/** Would offering to install the engine be a remedy for this outcome? */ +export function installWouldHelp(outcome: Outcome | undefined): boolean { + return !!outcome && INSTALL_HELPS[outcome.kind] +} diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index cccbc89f9d..27a01b4c4f 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -32,7 +32,22 @@ export async function resolveConfigPath(baseDir: string, global = false) { return candidates[0] } -export async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configPath: string) { +export async function addMcpToConfig( + // altimate_change start — the parameter list is split across lines only + // because the added `opts` exceeds the line budget; the reformat is ours too. + // + // `opts.refuseIfDisabled` refuses to replace a node that is switched off, + // decided on the SAME text this call is about to modify. A caller that reads + // the file itself and then calls this one has checked a different read than + // the write uses, so a disable landing between the two is replaced wholesale + // rather than honoured. One read, one decision, is the only version of this + // check that means anything. + name: string, + mcpConfig: ConfigMCPV1.Info, + configPath: string, + opts?: { refuseIfDisabled?: boolean }, +) { + // altimate_change end let text = "{}" if (await Filesystem.exists(configPath)) { text = await Filesystem.readText(configPath) @@ -51,6 +66,15 @@ export async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, } } + // altimate_change start — see `opts.refuseIfDisabled` + if (opts?.refuseIfDisabled) { + const current = parse(text, [], { allowTrailingComma: true }) as + | { mcp?: Record } + | undefined + if (current?.mcp?.[name]?.enabled === false) return null + } + // altimate_change end + const edits = modify(text, ["mcp", name], mcpConfig, { formattingOptions: { tabSize: 2, insertSpaces: true }, }) @@ -61,7 +85,20 @@ export async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, return configPath } -export async function removeMcpFromConfig(name: string, configPath: string): Promise { +export async function removeMcpFromConfig( + // altimate_change start — the parameter list is split across lines only + // because the added `opts` exceeds the line budget; the reformat is ours too. + // + // `opts.refuseIfDisabled` refuses to delete a node that is switched off, + // decided on the SAME text this call is about to modify. A caller that reads + // the file itself and then calls this one has checked a different read, which + // for a DELETE is worse than for a replace: the user's edit is not + // overwritten, it is gone. + name: string, + configPath: string, + opts?: { refuseIfDisabled?: boolean }, +): Promise { + // altimate_change end if (!(await Filesystem.exists(configPath))) return false const text = await Filesystem.readText(configPath) @@ -71,6 +108,15 @@ export async function removeMcpFromConfig(name: string, configPath: string): Pro const node = findNodeAtLocation(tree, ["mcp", name]) if (!node) return false + // altimate_change start — see `opts.refuseIfDisabled` + if (opts?.refuseIfDisabled) { + const current = parse(text, [], { allowTrailingComma: true }) as + | { mcp?: Record } + | undefined + if (current?.mcp?.[name]?.enabled === false) return false + } + // altimate_change end + const edits = modify(text, ["mcp", name], undefined, { formattingOptions: { tabSize: 2, insertSpaces: true }, }) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 27f2e85a14..e7a8722a60 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -272,11 +272,30 @@ interface State { status: Record clients: Record defs: Record + // altimate_change start — what this process actually SPAWNED for each key. + // + // `config` is only written by `add`, so for a client started at bootstrap it + // is empty and `getMcpConfig` falls back to the config FILE — which is a + // different question. The file says what should run now; this says what is + // running. They diverge whenever the file is rewritten after a client was + // started: another process re-pinning a shared config, an IDE replacing the + // entry, a re-link. Without this record a caller comparing the file to its + // own expectations can agree with itself while the live client serves + // something else entirely, and nothing in-process can tell. + // + // Deliberately NOT folded into `config`: that would make `connect` re-spawn + // the bootstrap-time entry rather than the current file, which is a + // behaviour change nobody asked for. + spawned: Record + // altimate_change end } export interface Interface { readonly status: () => Effect.Effect> readonly clients: () => Effect.Effect> + // altimate_change start — what this process spawned for a key; see State.spawned + readonly spawned: (name: string) => Effect.Effect + // altimate_change end // altimate_change start — carry the original (pre-sanitize) client name so tool-source // classification works from the real name, not the flattened `_` key // (see altimate/tool-source). @@ -691,6 +710,11 @@ export const layer = Layer.effect( if (s.clients[name] !== client) return delete s.clients[name] delete s.defs[name] + // altimate_change start — the child exited, so nothing is running under + // this key. The spawn record answers "what IS running" and must not + // outlive the process it describes. + delete s.spawned[name] + // altimate_change end s.status[name] = { status: "failed", error: "Connection closed" } bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( @@ -747,6 +771,9 @@ export const layer = Layer.effect( status: {}, clients: {}, defs: {}, + // altimate_change start — see State.spawned + spawned: {}, + // altimate_change end } // altimate_change start — auto-discover MCP servers from external AI tool configs @@ -778,6 +805,9 @@ export const layer = Layer.effect( if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! + // altimate_change start — bootstrap spawns too, so it records too. + s.spawned[key] = mcp + // altimate_change end watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -811,6 +841,9 @@ export const layer = Layer.effect( const clients = Object.values(s.clients) s.clients = {} s.defs = {} + // altimate_change start — nothing is running any more; see State.spawned + s.spawned = {} + // altimate_change end yield* Effect.forEach( clients, (client) => @@ -889,17 +922,60 @@ export const layer = Layer.effect( return s.clients }) + // altimate_change start — what this process spawned for a key, or undefined + // when nothing of ours is running under it. Read-only; see State.spawned. + const spawned = Effect.fn("MCP.spawned")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.spawned[name] + }) + // altimate_change end + const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) { const s = yield* InstanceState.get(state) + // altimate_change start — the client this call is replacing, captured + // before creation. Creation awaits a handshake, and another caller can + // register its own client under this key meanwhile; a failure here must + // close what THIS call was replacing, not whatever is registered now. + const replacing = s.clients[name] + // altimate_change end const result = yield* create(name, mcp) - s.status[name] = result.status if (!result.mcpClient) { + // altimate_change start — a replacement that failed to come up leaves + // nothing running under this key ONLY if nobody else registered a client + // while it was coming up. If someone did, theirs is what is serving: + // leave it, its status and its launch record alone, and report this + // failure without touching them. + if (s.clients[name] !== replacing) return result.status + s.status[name] = result.status yield* closeClient(s, name) delete s.clients[name] + // `add` over a live client closes the old one here; keeping its record + // would have `spawned()` describe a closed process, which is the one + // thing this record exists not to do. + delete s.spawned[name] + // altimate_change end return result.status } + // altimate_change start — the newer call wins, whichever completes first. + // If another caller registered a client under this key while this one was + // coming up, this result is the OLDER intent arriving late: storing it + // would close their newer client and hand the runtime back to whatever + // this call was asked to start. Close what we made instead, leave theirs + // — client, status and launch record — and answer with what is serving. + if (s.clients[name] !== replacing) { + yield* Effect.tryPromise(() => result.mcpClient!.close()).pipe(Effect.ignore) + return s.status[name] ?? result.status + } + // Recorded after the checks above, so neither a failed nor a superseded + // replacement overwrites the status of a client another caller registered. + s.status[name] = result.status + // altimate_change end + // altimate_change start — remember what we actually spawned, not what the + // file says. + s.spawned[name] = mcp + // altimate_change end return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) }) @@ -927,6 +1003,11 @@ export const layer = Layer.effect( // altimate_change end yield* closeClient(s, name) delete s.clients[name] + // altimate_change start — nothing is running under this key now, so the + // spawn record must not survive it either: it answers "what IS running", + // and a disabled key runs nothing. + delete s.spawned[name] + // altimate_change end s.status[name] = { status: "disabled" } // altimate_change start — telemetry + persist enabled:false so disable survives restarts Telemetry.track({ @@ -951,6 +1032,19 @@ export const layer = Layer.effect( yield* closeClient(s, name) delete s.clients[name] delete s.status[name] + // altimate_change start — nothing is running under this key any more, so + // neither the spawn record nor the runtime config may outlive it. + // + // `s.config` is what `getMcpConfig` prefers over the file, so a stale entry + // here outlives the client it described: `status()` keeps synthesising + // "disabled" from it for the rest of the process, and `connect` re-spawns + // whatever it holds rather than what the file now says. "Removed" has to + // mean the runtime forgets it, for every server key — a caller that has + // torn a client down and then asks about the key should be told nothing is + // there, not handed the description of the thing it just removed. + delete s.spawned[name] + delete s.config[name] + // altimate_change end yield* events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore) }) // altimate_change end @@ -1329,6 +1423,9 @@ export const layer = Layer.effect( return Service.of({ status, clients, + // altimate_change start — see State.spawned + spawned, + // altimate_change end tools, prompts, resources, @@ -1379,6 +1476,11 @@ export async function status() { export async function tools() { return runMcp((svc) => svc.tools()) } +// altimate_change start — read what this process spawned for a key (see State.spawned) +export async function spawned(name: string) { + return runMcp((svc) => svc.spawned(name)) +} +// altimate_change end export async function add(name: string, mcp: ConfigMCPV1.Info) { return runMcp((svc) => svc.add(name, mcp)) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 268babfc66..96e092e34c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -25,6 +25,7 @@ import { MemoryPrompt } from "../memory/prompt" import { UNIFIED_INJECTION_BUDGET } from "../memory/types" // altimate_change - workspace memory read path import * as WorkspaceMemory from "../altimate/workspace/memory-sync" +import * as WorkspaceEngine from "../altimate/workspace/engine-sync" import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -1007,6 +1008,27 @@ export namespace SessionPrompt { const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false + // altimate_change start — workspace engine readiness. + // + // `resolveTools` below snapshots the MCP tool catalog, and it runs ahead of + // the per-turn block further down where this attach used to be started. A + // session that spawned its own engine therefore listed the engine's tools one + // turn late: the model saw `datamate_manager` alone on the first turn and the + // integration tools only from the second. Starting the attach here and giving + // it a bounded window puts them in the first tool list instead. + // + // Only a turn that actually spawns waits: an unbound or disabled session + // settles with no I/O beyond a local cache read, and an engine an IDE already + // runs is reused as fast as the status call it already makes. Past the cap the + // turn proceeds without those tools and `tools/list_changed` delivers them + // when the attach lands. `ensure` is idempotent per session id, so the later + // turns this block also runs on return the settled outcome immediately. + if (step === 1) { + void WorkspaceEngine.ensure(sessionID).catch(() => {}) + await WorkspaceEngine.whenAttached(sessionID) + } + // altimate_change end + // altimate_change start (AI-7519) — trace resolveTools per step. // Included in the parent `bootstrap` span on step===1; on later steps // this measures the per-turn tool-listing overhead (MCP.tools connect @@ -1054,6 +1076,8 @@ export namespace SessionPrompt { // before the refetch, so workspace memory blinked out of the prompt whenever a // fetch ran long. void WorkspaceMemory.hydrate(sessionID).catch(() => {}) + // The bound workspace's integration engine is attached above, ahead of + // `resolveTools`, because its tools have to be in that turn's tool list. // altimate_change end SessionSummary.summarize({ sessionID: sessionID, diff --git a/packages/opencode/test/altimate/datamate.test.ts b/packages/opencode/test/altimate/datamate.test.ts index 50ff9ad2a8..c4bbd324c5 100644 --- a/packages/opencode/test/altimate/datamate.test.ts +++ b/packages/opencode/test/altimate/datamate.test.ts @@ -4,7 +4,7 @@ import os from "os" import fsp from "fs/promises" import { AltimateApi } from "../../src/altimate/api/client" -import { slugify } from "../../src/altimate/tools/datamate" +import { slugify, isPinnedToOtherWorkspace } from "../../src/altimate/tools/datamate" // --------------------------------------------------------------------------- // Helpers @@ -589,3 +589,33 @@ describe("slugify", () => { afterEach(async () => { await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}) }) + +describe("isPinnedToOtherWorkspace", () => { + // The workspace attach persists the shared gateway key pinned to one + // workspace, so "already configured and connected" stopped implying "serving + // the datamate you asked for". Reporting success in that case would tell the + // user their datamate is connected while another workspace's tools — and + // credentials — were the ones actually exposed. + test("a pin for another workspace is not the gateway you asked for", () => { + const entry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + expect(isPinnedToOtherWorkspace(entry, "99")).toBe(true) + expect(isPinnedToOtherWorkspace(entry, 99)).toBe(true) + }) + + test("a pin for the requested workspace is", () => { + const entry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + expect(isPinnedToOtherWorkspace(entry, "42")).toBe(false) + expect(isPinnedToOtherWorkspace(entry, 42)).toBe(false) + }) + + test("an UNPINNED entry is the generic gateway and answers for any datamate", () => { + // This is the pre-existing extension-written shape; it must keep working. + expect(isPinnedToOtherWorkspace({ type: "local", command: ["datamate", "start-stdio"] }, "99")).toBe(false) + expect(isPinnedToOtherWorkspace({ command: "datamate", args: ["start-stdio"] }, "99")).toBe(false) + }) + + test("a missing entry is not treated as a foreign pin", () => { + expect(isPinnedToOtherWorkspace(undefined, "99")).toBe(false) + expect(isPinnedToOtherWorkspace(null, "99")).toBe(false) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/config-on-disk.test.ts b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts new file mode 100644 index 0000000000..8b4b830349 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/config-on-disk.test.ts @@ -0,0 +1,417 @@ +// altimate_change - new file +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig } from "../../../src/altimate/workspace/engine-sync" +import { Config } from "../../../src/config/config" +import { addMcpToConfig, readMcpEntryFromDisk } from "../../../src/mcp/config" +import { persistRestore } from "../../../src/altimate/workspace/engine-config" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +describe("the write checks the text it is about to modify", () => { + const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + + type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: string[] + statusQueue: Array> + reads: Array + spawnedNow?: ExistingEntry + bindingCalls: number + } + + function install( + statuses: H["statusQueue"], + entry: () => ExistingEntry | null, + opts: { realPersist?: boolean; spawned?: ExistingEntry } = {}, + ): H { + const h: H = { + added: [], + persisted: [], + connects: [], + removes: [], + toasts: [], + statusQueue: statuses, + reads: [], + spawnedNow: opts.spawned, + bindingCalls: 0, + } + syncInternals.resolveBinding = async () => { + h.bindingCalls += 1 + return binding + } + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + if (!opts.realPersist) { + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + } + syncInternals.existingEntry = async () => { + // `entry()` IS the file here — these tests model it live, updating it from + // their own `persist` override and from the edits they stage. Nothing is + // inferred from what was persisted, because inferring would shadow the + // very rewrites this file exists to exercise. + const e = entry() + h.reads.push(e?.enabled) + return e + } + syncInternals.notify = async (t) => { + h.toasts.push(t.title) + } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + syncInternals.projectEntry = async () => null + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + h.spawnedNow = cfg as ExistingEntry + }, + remove: async (name) => { + h.removes.push(name) + h.spawnedNow = undefined + }, + spawned: async () => h.spawnedNow, + tools: async () => ({ datamate_dbt_build_model: 1 }), + } + return h + } + + beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() + }) + afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + }) + + describe("the guard's read and the write, against a real file", () => { + // No persist seam: the production `persist` → `addMcpToConfig` runs against a + // temp file. Only `Config.invalidate` is spied to a no-op (no instance here). + let dir: string + let file: string + let invalidateSpy: ReturnType + const unpinned: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + + beforeEach(async () => { + dir = mkdtempSync(path.join(tmpdir(), "l3r2-")) + file = path.join(dir, "altimate-code.json") + await addMcpToConfig("datamate", unpinned as never, file) + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) + }) + afterEach(() => invalidateSpy.mockRestore()) + + const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined + + function realInstall(landDisableAtIntentReads: number) { + let landed = false + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { + realPersist: true, + }) + syncInternals.projectConfigPath = async () => file + // The guard now reads the binding FIRST and intent LAST, + // so "after the guard's intent read and before the write" is no longer a + // window a later binding read can land in — the intent read + // IS the last thing before persist. The disable therefore lands at the end + // of that read, which is the narrowest and only remaining gap, and exactly + // the one persist's own re-read of the node it replaces exists to close. + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + if (!landed && h.reads.length === landDisableAtIntentReads) { + landed = true + const now = (await diskEntry())! + await addMcpToConfig("datamate", { ...now, enabled: false } as never, file) + } + return e + } + syncInternals.projectEntry = async () => (await diskEntry()) ?? null + syncInternals.resolveBinding = async () => { + h.bindingCalls += 1 + return binding + } + return h + } + + test("disable lands between the guard's intent read and persist's write → written over, memo stands", async () => { + // reads: inspect#1 (1), worldUnchanged intent (2) → land during the binding read that follows. + const h = realInstall(2) + const first = await ensure("s1") + const after = await diskEntry() + console.log("R1 outcome:", JSON.stringify(first), "disk:", JSON.stringify(after), "reads:", h.reads) + // No guard the caller can hold covers the gap between confirming intent and + // the write itself, so `persist` re-reads the node it is about to replace + // and refuses when that node says disabled. The write never happens, and + // because it never happens the post-install check no longer reads a file we + // wrote and conclude there is nothing to undo. + expect(first.kind).toBe("entry-disabled") + expect(after?.enabled, "the user's disable was written over").toBe(false) + expect(after?.command, "disk still holds the USER's entry").toEqual(["datamate", "start-stdio"]) + expect(h.added, "installed over a disable").toHaveLength(0) + // Next turn re-decides from disk and reaches the same answer. + const second = await ensure("s1") + expect(second.kind).toBe("entry-disabled") + expect(readFileSync(file, "utf8")).toContain('"enabled": false') + }) + + test("control: the same disable landing BEFORE the guard's intent read is caught → superseded, disk keeps it", async () => { + // reads: inspect#1 (1) → land during detachRejected's binding read (before worldUnchanged reads intent). + const h = realInstall(1) + const first = await ensure("s1") + const after = await diskEntry() + console.log("R1 control:", JSON.stringify(first), "disk:", JSON.stringify(after), "reads:", h.reads) + // The guard knows + // WHICH half of the world moved, and "you switched this off" is a more + // useful answer than "something changed, try again". + expect(first.kind).toBe("entry-disabled") + expect(after?.enabled).toBe(false) + expect(after?.command).toEqual(["datamate", "start-stdio"]) + expect(h.added).toHaveLength(0) + }) + + test("a GLOBAL disable landing after the guard is refused before the project write", async () => { + // The project file holds an enabled node, so the write's own on-disk check + // sees nothing wrong. Intent lives in the global config the project + // inherits from, and a project pin written over a global disable shadows + // it for good (project wins the merge). So persist asks the MERGED view + // once more, immediately before writing. + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { + realPersist: true, + }) + syncInternals.projectConfigPath = async () => file + syncInternals.projectEntry = async () => (await diskEntry()) ?? null + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + // reads: inspection (1), the guard (2), persist's merged re-read (3) — + // the global disable is visible from the third read on, never on disk. + return h.reads.length >= 3 && e ? { ...e, enabled: false } : e + } + const first = await ensure("s1") + expect(first.kind, "wrote a project pin over a global disable").toBe("entry-disabled") + expect((await diskEntry())?.command, "the project file was written").toEqual(["datamate", "start-stdio"]) + expect(h.added).toHaveLength(0) + }) + }) + + describe("a disable landing before the revive is honoured", () => { + test("spawns then tears down; writes nothing", async () => { + let enabled = true + const h = install( + [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + // The disable has to land after inspection #1 and + // before the revive guard reads intent, and the guard's read order moved + // under this test — binding-first, then back to intent-first — so keying the + // trigger to a binding read no longer places it in the intended window. It + // lands at the end of inspection #1 instead, which is that window's opening + // edge and is stable against the guard's internal ordering. + const realEntry = syncInternals.existingEntry! + syncInternals.existingEntry = async (name: string) => { + const e = await realEntry(name) + if (h.reads.length === 1) enabled = false + return e + } + const outcome = await ensure("s1") + // the revive guard checks the whole world now, so the + // entry is never started. Start-then-tear-down was the shape this branch + // already judged worse than never-started. + expect(outcome.kind).toBe("entry-disabled") + expect(h.added, "revived the entry the user had just disabled").toHaveLength(0) + expect(h.removes).toEqual(["datamate"]) + expect(h.persisted).toHaveLength(0) + expect(h.connects).toHaveLength(0) + }) + }) + + describe("an IDE rewrite between the write and the registration", () => { + test("this turn: attached with disk unpinned; next turn: our own engine is replaced", async () => { + let onDisk: ExistingEntry | null = null + const h = install([{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => onDisk) + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + onDisk = { type: "local", command: cfg.command, enabled: true } + } + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + // IDE sync lands after our persist, before our add + if (h.persisted.length === 1 && h.added.length === 0) onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + return prevAdd(n, c) + } + const first = await ensure("s1") + expect(first.kind).toBe("attached") + expect((onDisk as unknown as ExistingEntry)?.command).toEqual(["datamate", "start-stdio"]) + expect(h.spawnedNow?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + const second = await ensure("s1") + console.log("R3 second:", JSON.stringify(second), "removes:", h.removes, "persisted:", h.persisted.length) + expect(second).not.toBe(first) + expect(h.removes).toEqual(["datamate"]) // tore down OUR correctly pinned engine because the file says unpinned + expect(h.persisted).toHaveLength(2) + }) + }) + + describe("the spawn record when it is absent, stale, or from another process", () => { + test("(i) bootstrap failed (no record), config pinned to us → revived via add, never connect", async () => { + const h = install( + [{ datamate: { status: "failed", error: "spawn ENOENT" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }), + ) + const outcome = await ensure("s1") + expect(outcome.kind).toBe("reused") + expect(h.added).toHaveLength(1) + expect(h.connects).toHaveLength(0) + expect(h.spawnedNow?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + + test("(ii) dead child, record still says pinned 5 (onclose does not clear it), file re-pinned to 42 → replaced, not revived", async () => { + const h = install( + [{ datamate: { status: "failed", error: "Connection closed" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }), + { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } }, + ) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 5" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.persisted).toHaveLength(1) + }) + + test("(iii) cross-process: B bootstrapped pinned 5, A re-pinned the shared file to 7, B now bound to 7 → B replaces its own client", async () => { + syncInternals.resolveBinding = async () => ({ ...binding, datamateId: 7, datamateName: "seven" }) as CachedBinding + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "7"], enabled: true }), + { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } }, + ) + syncInternals.resolveBinding = async () => ({ ...binding, datamateId: 7, datamateName: "seven" }) as CachedBinding + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 5" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.added[0]!.cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "7"]) + }) + + test("(iv) record present but the file entry was removed by another process → plan is spawn; runtime ignored", async () => { + const h = install([{}, { datamate: { status: "connected" } }], () => null, { + spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, + }) + const outcome = await ensure("s1") + expect(outcome.kind).toBe("attached") + expect((outcome as { replaced?: string }).replaced).toBeUndefined() // the 5-engine's replacement is unreported + expect(h.removes).toHaveLength(0) // storeClient closes the previous client inside MCP; this module never says so + }) + + test("(v) memo path: record diverges from file after attach (file re-pinned to 7 under a 42 binding) → memo invalid, re-decided", async () => { + let onDisk: ExistingEntry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + () => onDisk, + { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } }, + ) + const first = await ensure("s1") + expect(first.kind).toBe("reused") + onDisk = { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"], enabled: true } + const second = await ensure("s1") + expect(second).not.toBe(first) + expect(h.removes).toEqual(["datamate"]) + }) + }) + + describe("edits landing between the two reads of one inspection", () => { + test("(a) disable after the config read, client live → honoured in the same turn, no persist", async () => { + // The inspection read the entry enabled; the disable lands before the + // status read. The reuse answer re-asks intent before naming the engine, + // so the turn is refused and the engine detached now, not a turn later. + let enabled = true + const h = install([{ datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled, + }), { spawned: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } }) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + enabled = false + return realStatus() + } + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toEqual([]) + expect(h.removes).toEqual(["datamate"]) + }) + }) +}) + +describe("the undo writes only what it can justify", () => { + const A = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/a" } as any + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as any[]) delete (syncInternals as any)[k] }) + function base(h: any) { + syncInternals.resolveBinding = async () => A + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: [], extensionKeys: [] }) + syncInternals.persist = async (n, c) => { h.persisted.push(c); return "written" as const } + syncInternals.projectConfigPath = async () => "/tmp/x/altimate-code.json" + syncInternals.existingEntry = async () => h.entry + syncInternals.notify = async (t) => { h.toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async (_n, p) => { h.restores.push(p ?? null); return "restored" as const } + const q = [{}, { datamate: { status: "connected" } }] + syncInternals.mcp = { + status: async () => (q.length > 1 ? q.shift()! : q[0]!) as any, + add: async () => { h.added += 1 }, remove: async () => { h.removes += 1 }, + spawned: async () => undefined, tools: async () => ({}), + } + } + describe("an undo whose read fails writes nothing", () => { + test("projectEntry throws at undo time: no restore write, one 'left behind' toast", async () => { + const h = { persisted: [] as any[], restores: [] as any[], toasts: [] as any[], added: 0, removes: 0, entry: null as any } + base(h) + let reads = 0 + syncInternals.projectEntry = async () => { reads += 1; if (reads >= 2) throw new Error("EIO"); return null } + const prevTools = syncInternals.mcp!.tools + syncInternals.mcp!.tools = async () => { h.entry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }; return prevTools() } + const out = await ensure("s1") + expect(out.kind).toBe("entry-disabled") + expect(h.restores, "the undo wrote blind after its re-read failed").toHaveLength(0) + expect(h.toasts.map((t: any) => t.title).some((t: string) => t.includes("left behind")), JSON.stringify(h.toasts.map((t: any) => t.title))).toBe(true) + }) + }) + describe("the restore honours a disable it finds on disk", () => { + test("previous non-null: a disabled node is not overwritten", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } } }, null, 2)) + const r = await persistRestore("datamate", { type: "local", command: ["datamate", "old"] } as any, file) + expect(r).toBe("restored") + const after = JSON.parse(readFileSync(file, "utf8")) + expect(after.mcp.datamate.enabled, "overwrote a disabled node").toBe(false) + expect(after.mcp.datamate.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + test("previous null (delete case): a disabled node is kept, not deleted", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } } }, null, 2)) + const r = await persistRestore("datamate", null, file) + expect(r).toBe("restored") + const after = JSON.parse(readFileSync(file, "utf8")) + expect(after.mcp?.datamate?.enabled, "deleted the node the user disabled").toBe(false) + }) + test("previous null, node enabled: removed as before", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ar-")); const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } } }, null, 2)) + await persistRestore("datamate", null, file) + const after = JSON.parse(readFileSync(file, "utf8")) + expect(after.mcp?.datamate).toBeUndefined() + }) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts new file mode 100644 index 0000000000..551f6bd4fa --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-config-freshness.test.ts @@ -0,0 +1,180 @@ +// altimate_change - new file +// +// The freshness invariant, asserted by OBSERVING staleness rather than by +// observing that a read went through the right function. +// +// The previous version of this check verified that config reads route through +// the refreshing accessor. That is a real property, but it is not the one the +// name claims — and deleting `Config.invalidate()` from the accessor, from +// `persist`, or from `persistRestore` left the whole suite green. A test named +// "every config read is fresh" that survives the removal of every invalidation +// is asserting something other than freshness. +// +// This file mocks `Config` with a cache that only updates when invalidated, so +// a stale read is directly observable: write to the "file", read, and see +// whether the write is visible. +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { Config } from "../../../src/config/config" +import { existingEntry } from "../../../src/altimate/workspace/engine-config" +import { syncInternals } from "../../../src/altimate/workspace/engine-seams" + +// Spies rather than a module mock. `mock.module` is registered process-wide and +// cannot be unregistered, so mocking the config module from here took down every +// later test file in the run that builds a real Config layer — a test file that +// breaks unrelated suites is worse than the gap it closes. +let fileContents: { mcp?: Record } = {} +let cached: { mcp?: Record } | null = null +let invalidations = 0 +let getSpy: ReturnType +let invalidateSpy: ReturnType + +beforeEach(() => { + fileContents = {} + cached = null + invalidations = 0 + // Models the real thing: `get()` is cached per instance and does NOT see a + // write made behind it until something invalidates. + getSpy = spyOn(Config, "get").mockImplementation(async () => { + if (cached === null) cached = structuredClone(fileContents) + return cached as never + }) + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => { + invalidations += 1 + cached = null + }) + delete syncInternals.existingEntry + delete syncInternals.freshConfig +}) + +afterEach(() => { + getSpy.mockRestore() + invalidateSpy.mockRestore() + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] +}) + +describe("INVARIANT — a config read observes writes made behind it", () => { + test("an external write between two reads is visible to the second", async () => { + fileContents = { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: true } } } + const before = await existingEntry("datamate") + expect(before?.enabled).toBe(true) + + // An IDE, another process, or `/mcps disable` writes the file. Nothing tells + // this process; the write never goes through `Config` at all. + fileContents = { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } } + + const after = await existingEntry("datamate") + expect(after?.enabled, "read a cached config and missed a write made behind it").toBe(false) + }) + + test("an entry added externally after the cache warmed is seen", async () => { + fileContents = { mcp: {} } + expect(await existingEntry("datamate")).toBeNull() + fileContents = { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } } } + expect(await existingEntry("datamate"), "missed an entry an IDE added after the cache warmed").not.toBeNull() + }) + + test("freshness costs an invalidation per read, which is the trade being made", async () => { + // Named rather than hidden: invalidating drops the per-instance cache for + // every other Config consumer too. That is the price of not having a fourth + // instance of the stale-read defect. + fileContents = { mcp: {} } + await existingEntry("datamate") + await existingEntry("datamate") + expect(invalidations).toBe(2) + }) +}) + +describe("INVARIANT — a failed read propagates, never becomes null", () => { + test("a config read that throws does not arrive at the caller as 'there is no entry'", async () => { + // The layer that matters. A guard above this one was written to fail closed + // on a throwing intent read — and could never fire, because this reader + // caught the throw and returned `null`, which every caller reads as "there + // is no entry": the guard as "nothing forbids this write", the inspection as + // "nothing here, spawn". A rule enforced at one layer and undone at the + // layer below is not enforced. + // + // Asserted HERE rather than through a stubbed seam, because a seam-level + // test cannot see a swallow that happens beneath the seam — which is exactly + // why the defect survived the invariant that was supposed to state it. + getSpy.mockImplementation(async () => { + throw new Error("EIO: config unreadable") + }) + await expect(existingEntry("datamate")).rejects.toThrow("EIO") + }) + + test("a genuinely absent entry is still null, not an error", async () => { + // The distinction is the whole point: absent and unreadable must stay + // different answers, or the caller cannot act differently on them. + fileContents = { mcp: {} } + expect(await existingEntry("datamate")).toBeNull() + }) +}) + +describe("INVARIANT — the restore reports failure from the real write, not just the seam", () => { + test("an unwritable config file yields 'failed', which is what raises the toast", async () => { + // The suite's "undo that could not be confirmed" test stubs the seam to + // RETURN "failed" — so the production path that decides to return it was + // never exercised, and making it return "restored" instead left everything + // green. Same layer-below shape that hid the reader's swallow. + const { persistRestore } = await import("../../../src/altimate/workspace/engine-config") + const { mkdtempSync, writeFileSync, chmodSync } = await import("node:fs") + const { tmpdir } = await import("node:os") + const path = await import("node:path") + + const dir = mkdtempSync(path.join(tmpdir(), "restore-")) + const file = path.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate"] } } }, null, 2)) + chmodSync(file, 0o444) + try { + const result = await persistRestore("datamate", { type: "local", command: ["datamate", "old"] }, file) + expect(result, "an unwritable file was reported as a successful restore").toBe("failed") + } finally { + chmodSync(file, 0o644) + } + }) +}) + +describe("INVARIANT — the restore's write refuses on the same text, both ways", () => { + async function tempConfig(entry: unknown): Promise { + const { mkdtempSync, writeFileSync } = await import("node:fs") + const { tmpdir } = await import("node:os") + const nodePath = await import("node:path") + const dir = mkdtempSync(nodePath.join(tmpdir(), "restore-refuse-")) + const file = nodePath.join(dir, "altimate-code.json") + writeFileSync(file, JSON.stringify({ mcp: { datamate: entry } }, null, 2)) + return file + } + + async function readBack(file: string): Promise<{ mcp: { datamate?: { enabled?: boolean } } }> { + const { readFileSync } = await import("node:fs") + return JSON.parse(readFileSync(file, "utf8")) + } + + test("REPLACING does not overwrite a node the user has disabled", async () => { + // The lifted real-file test pins the delete half and the failed-re-read + // half; this is the third, which survived both. A restore that replaces is + // still a write, and a disable landing before it is still the user's + // instruction about that node. + const { persistRestore } = await import("../../../src/altimate/workspace/engine-config") + const file = await tempConfig({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled: false, + }) + await persistRestore("datamate", { type: "local", command: ["datamate", "start-stdio"] }, file) + const after = await readBack(file) + expect(after.mcp.datamate?.enabled, "the undo overwrote a disable the user had just made").toBe(false) + }) + + test("REMOVING does not delete a node the user has disabled", async () => { + const { persistRestore } = await import("../../../src/altimate/workspace/engine-config") + const file = await tempConfig({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled: false, + }) + await persistRestore("datamate", null, file) + const after = await readBack(file) + expect(after.mcp.datamate, "the undo deleted the node the user had just disabled").toBeDefined() + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts new file mode 100644 index 0000000000..e8f8350329 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -0,0 +1,3206 @@ +// altimate_change - new file +// +// Unit coverage for the workspace → local engine attach flow. Every side +// effect goes through `syncInternals`, so this exercises the decision logic +// without booting an instance, spawning a process, or touching MCP state. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { + compareVersions, + engineToolKeys, + ensure, + resetForTests, + syncInternals, + pinnedWorkspace, + whenAttached, + ATTACH_WAIT_MS, + INSTALL_HINT, + MIN_ENGINE_VERSION, + MAX_TRACKED_SESSIONS, + trackedSessionsForTests, + sessionsForTests, + trackedChainsForTests, + settledOutcome, + attributableEngine, + installWouldHelp, + planForEntry, + clearsFloor, + runningEngine, + sameEntry, + type LocalMcpConfig, + type Outcome, +} from "../../../src/altimate/workspace/engine-sync" +import { engineVersionOf } from "../../../src/altimate/workspace/engine-probes" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding + +type Harness = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: Array<{ title: string; message: string; variant: string }> + toolsChanged: number + restores: Array + restorePaths: Array + statusQueue: Array> + tools: Record + spawnedNow?: ExistingEntry +} + +function install(opts: { + binding?: CachedBinding | null + which?: string | null + version?: string | null | ((bin: string) => string | null) + declared?: { keys: string[]; extensionKeys: string[] } | null + statuses?: Harness["statusQueue"] + tools?: Record + existing?: { type?: string; url?: string; command?: string[] | string; args?: string[]; enabled?: boolean } | null +}): Harness { + const h: Harness = { + added: [], + persisted: [], + connects: [], + removes: [], + toasts: [], + toolsChanged: 0, + restores: [], + restorePaths: [], + statusQueue: opts.statuses ?? [{}], + tools: opts.tools ?? {}, + // A configured entry that is already CONNECTED was bootstrapped from that + // entry, which is what MCP records. A failed one has no record: production + // only records a spawn when the client actually came up. + // A COPY, as in production: MCP's record is its own object, never the + // config entry itself. Aliasing them here would make "the runtime had a + // record" indistinguishable from "the runtime fell back to the config". + spawnedNow: (opts.statuses?.[0]?.["datamate"]?.status === "connected" && opts.existing + ? { ...opts.existing } + : undefined) as ExistingEntry | undefined, + } + syncInternals.resolveBinding = async () => (opts.binding === undefined ? binding : opts.binding) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => { + if (typeof opts.version === "function") return opts.version(bin) + return opts.version === undefined ? "0.7.0" : opts.version + } + syncInternals.declared = async () => + opts.declared === undefined ? { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] } : opts.declared + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + syncInternals.existingEntry = async () => { + // Mirrors production: once this attach has written, the entry on disk is + // OURS, and later reads see that rather than the starting value. Preferring + // the starting entry forever models a file that never received the write, + // which is invisible until something asks whether what is installed is still + // its own — and then it answers "no" for every successful attach. + const written = h.persisted[h.persisted.length - 1] + if (written) return { ...(written.cfg as unknown as ExistingEntry) } + if (opts.existing !== undefined) return opts.existing + // Production persists the pinned entry before adding it, so a later read + // sees it. Without this the harness under-reports and a legitimate memo + // looks like a workspace change. + const last = h.persisted[h.persisted.length - 1] + return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + } + syncInternals.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.toolsChanged = async () => { + h.toolsChanged += 1 + } + syncInternals.persistRestore = async (_name, previous, configPath?: string) => { + h.restores.push(previous ?? null) + h.restorePaths.push(configPath) + } + // The project file has no entry of its own unless a test says otherwise. This + // must be stated rather than left to the reader's error handling: "there was + // nothing here" and "I could not look" mean opposite things to a restore, so + // the reader throws and the harness says which case it wants. + syncInternals.projectEntry = async () => null + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!, + add: async (name, cfg) => { + h.added.push({ name, cfg }) + h.spawnedNow = cfg as ExistingEntry + }, + remove: async (name) => { + h.removes.push(name) + h.spawnedNow = undefined + }, + // Models MCP's own record of what it launched: whatever we last added, or — + // when nothing was added in this process — the entry MCP bootstrapped from. + spawned: async () => h.spawnedNow, + tools: async () => h.tools, + } + return h +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() +}) + +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG +}) + +describe("compareVersions", () => { + test("orders numerically, not lexically", () => { + expect(compareVersions("0.10.0", "0.6.3")).toBeGreaterThan(0) + expect(compareVersions("0.6.3", "0.6.3")).toBe(0) + expect(compareVersions("0.6.2", "0.6.3")).toBeLessThan(0) + }) + test("tolerates a v prefix and a pre-release tag", () => { + expect(compareVersions("v0.7.0-beta.1", "0.6.3")).toBeGreaterThan(0) + }) + test("garbage compares as older", () => { + expect(compareVersions("not-a-version", "0.6.3")).toBeLessThan(0) + }) +}) + +describe("engineToolKeys", () => { + test("keeps only datamate_-prefixed tools and strips the prefix", () => { + const keys = engineToolKeys({ datamate_dbt_build_model: 1, sql_execute: 1, other_x: 1 }) + expect([...keys]).toEqual(["dbt_build_model"]) + }) +}) + +describe("ensure", () => { + test("is inert when the pilot flag is off", async () => { + delete process.env.ALTIMATE_WORKSPACE + const h = install({}) + expect(await ensure("s1")).toEqual({ kind: "disabled" }) + expect(h.added).toHaveLength(0) + }) + + test("is inert with no local binding", async () => { + const h = install({ binding: null }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.added).toHaveLength(0) + expect(h.toasts).toHaveLength(0) + }) + + test("reuses a connected entry that is pinned to this workspace, without spawning", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "reused", available: 2, declared: 2, missing: [] }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("offers the install when no engine is on PATH — and does NOT fall back to hosted", async () => { + const h = install({ which: null }) + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("warning") + expect(h.toasts[0].message).toContain('Workspace "analytics" declares 2 integration tools') + expect(h.toasts[0].message).toContain(INSTALL_HINT) + }) + + test("refuses an engine below the version floor", async () => { + const h = install({ version: "0.5.9" }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.5.9" }) + expect(h.added).toHaveLength(0) + }) + + test("spawns the engine pinned to the bound workspace and reports the declared-vs-delivered gap", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, sql_execute: 1 }, + }) + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "attached", available: 1, declared: 2, missing: ["dbt_compile_model"] }) + + expect(h.persisted).toHaveLength(1) + expect(h.added).toHaveLength(1) + const cfg = h.added[0].cfg + expect(h.added[0].name).toBe("datamate") + expect(cfg.type).toBe("local") + expect(cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("warning") + expect(h.toasts[0].message).toContain("1 of 2 declared integration tools available") + expect(h.toasts[0].message).toContain("dbt_compile_model") + }) + + test("a clean attach reports success with no gap", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "attached", available: 2, declared: 2, missing: [] }) + expect(h.toasts[0].variant).toBe("success") + }) + + test("a failed spawn is reported, never routed to hosted", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "failed", error: "spawn ENOENT" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "connect-failed", error: "spawn ENOENT" }) + // exactly one add, and it was the LOCAL spawn — no second, remote config + expect(h.added).toHaveLength(1) + expect(h.added[0].cfg.type).toBe("local") + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("error") + expect(h.toasts[0].message).toContain("not falling back to the hosted endpoint") + }) + + test("a down COMMAND entry that is OURS is retried once, then reported — never double-spawned", async () => { + // Reviving is for our own engine. The entry must be pinned to this + // workspace to reach the retry at all — see the wedge test below. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "failed", error: "exit 1" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "connect-failed", error: "exit 1" }) + // Revived with `add`, never `connect`: connect writes `enabled: true` into + // whichever config owns the entry, turning a local repair into a global + // config write. One restart attempt, and nothing persisted. + expect(h.connects, "used the config-writing primitive to repair").toHaveLength(0) + expect(h.added).toHaveLength(1) + expect(h.persisted).toHaveLength(0) + }) + + test("a down entry pinned ELSEWHERE is replaced, never revived — this is the wedge", async () => { + // With connectivity above attribution this could not clear: the retry + // answered before the pin was ever consulted, so an entry pinned to a + // workspace the project no longer holds was retried every turn, reported + // `connect-failed` every turn, and never replaced — the project sat wedged + // until someone edited config by hand. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) + expect(h.added, "did not replace the unattributable entry").toHaveLength(1) + }) + + test("a dead URL entry (IDE engine not running) is replaced by a local spawn, and the replacement is reported", async () => { + const h = install({ + existing: { type: "remote", url: "http://localhost:7801/sse" }, + statuses: [ + { datamate: { status: "failed", error: "SSE error: Unable to connect" } }, + { datamate: { status: "connected" } }, + ], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + const outcome = await ensure("s1") + expect(outcome).toEqual({ + kind: "attached", + available: 2, + declared: 2, + missing: [], + replaced: "http://localhost:7801/sse", + }) + expect(h.connects).toHaveLength(0) // no pointless retry of a dead port + expect(h.removes).toHaveLength(0) // a dead URL has nothing live to close + expect(h.added).toHaveLength(1) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + expect(h.toasts[0].message).toContain("Replaced the unreachable engine URL http://localhost:7801/sse") + }) + + test("is idempotent per session", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + const first = await ensure("s1") + const second = await ensure("s1") + expect(second).toBe(first) + expect(h.added).toHaveLength(1) + }) +}) + +describe("whenAttached", () => { + test("the cap stays well under MCP's own connect timeout", () => { + // A turn must never inherit MCP's 30s connect budget; past this cap the + // tools arrive over `tools/list_changed` instead. + expect(ATTACH_WAIT_MS).toBeLessThan(30_000) + }) + + test("does not wait when no attach was started for the session", async () => { + install({}) + const started = performance.now() + await whenAttached("never-ensured", 1_000) + expect(performance.now() - started).toBeLessThan(50) + }) + + test("returns once a fresh attach has landed, so its tools make this turn", async () => { + const h = install({ tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 } }) + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - started).toBeLessThan(1_000) + // The engine is connected by the time the caller resolves its tool list. + expect(h.added).toHaveLength(1) + expect(engineToolKeys(h.tools).size).toBe(2) + }) + + test("an unbound session settles without waiting", async () => { + install({ binding: null }) + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - started).toBeLessThan(50) + }) + + test("a disabled session settles without waiting", async () => { + delete process.env.ALTIMATE_WORKSPACE + install({}) + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - started).toBeLessThan(50) + }) + + test("gives up after the cap, and later turns in the session do not pay it again", async () => { + install({}) + // An engine that never answers: the attach promise stays pending for MCP's + // full connect budget, which no turn may inherit. + syncInternals.versionOf = () => new Promise(() => {}) + void ensure("s1") + + const first = performance.now() + await whenAttached("s1", 25) + expect(performance.now() - first).toBeGreaterThanOrEqual(20) + + // Every user turn runs the same block; only the first one waits. + const second = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - second).toBeLessThan(50) + }) +}) + +describe("pinnedWorkspace", () => { + test("reads the pin from opencode's argv shape", () => { + expect(pinnedWorkspace({ type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] })).toBe("5") + }) + test("reads the pin from the IDE's { command, args } shape", () => { + expect(pinnedWorkspace({ command: "datamate", args: ["start-stdio", "--datamate", "5"] })).toBe("5") + }) + test("accepts the --datamate=5 spelling", () => { + expect(pinnedWorkspace({ type: "local", command: ["datamate", "start-stdio", "--datamate=5"] })).toBe("5") + }) + test("a repeated flag resolves last-wins, as the engine's CLI does", () => { + expect( + pinnedWorkspace({ type: "local", command: ["datamate", "--datamate", "5", "--datamate", "9"] }), + ).toBe("9") + }) + test("an entry with no pin is not attributable — this is what the extension writes", () => { + expect(pinnedWorkspace({ command: "datamate", args: ["start-stdio"] })).toBeNull() + expect(pinnedWorkspace({ type: "local", command: ["datamate", "start-stdio"] })).toBeNull() + }) + test("a URL entry pins nothing, and a missing entry is not attributable", () => { + expect(pinnedWorkspace({ type: "remote", url: "http://localhost:7801/sse" })).toBeNull() + expect(pinnedWorkspace(null)).toBeNull() + }) + test("a dangling --datamate with no value is not a pin", () => { + expect(pinnedWorkspace({ type: "local", command: ["datamate", "start-stdio", "--datamate"] })).toBeNull() + }) +}) + +describe("ensure — attribution of a CONNECTED entry", () => { + const liveTwice: Harness["statusQueue"] = [ + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ] + const twoTools = { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 } + + test("an UNPINNED entry is replaced by a pinned spawn — this is the extension's entry", async () => { + const h = install({ + existing: { command: "datamate", args: ["start-stdio"] }, + statuses: liveTwice, + tools: twoTools, + }) + const outcome = await ensure("s1") + expect(outcome).toEqual({ + kind: "attached", + available: 2, + declared: 2, + missing: [], + replaced: "datamate start-stdio", + }) + // The replacement is a pinned spawn, so the engine we end up on is ours. + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + // ...and the live one it displaced was torn down first. `MCP.add` does not + // close the client it overwrites, so skipping this orphans a second live + // engine. It must be `remove` (runtime-only), never `disconnect`, which + // would persist `enabled: false` into the config that owns the entry. + expect(h.removes).toEqual(["datamate"]) + expect(h.toasts[0].message).toContain("not pinned to this workspace") + }) + + test("an entry pinned to ANOTHER workspace is replaced, and says which", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] }, + statuses: liveTwice, + tools: twoTools, + }) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio --datamate 7" }) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + expect(h.toasts[0].message).toContain("pinned to workspace 7") + }) + + test("a CONNECTED url entry is replaced too — rule 4 forbids adopting hosted", async () => { + const h = install({ + existing: { type: "remote", url: "https://api.altimate.ai/sse" }, + statuses: liveTwice, + tools: twoTools, + }) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached", replaced: "https://api.altimate.ai/sse" }) + expect(h.added[0].cfg.type).toBe("local") + }) + + test("a matching pin is reused — no spawn, no persist", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: twoTools, + }) + expect(await ensure("s1")).toEqual({ kind: "reused", available: 2, declared: 2, missing: [] }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + expect(h.removes).toHaveLength(0) // reuse must never tear down what it reuses + }) + + test("a down UNPINNED entry is replaced without being revived first", async () => { + // It was previously retried back to life and only then judged unattributable + // and replaced — a spawn spent on a process we were always going to discard. + // Attribution above connectivity means we never start it. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [ + { datamate: { status: "failed", error: "exit 1" } }, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ], + tools: twoTools, + }) + const outcome = await ensure("s1") + expect(h.connects, "revived an entry it was going to replace anyway").toHaveLength(0) + expect(outcome).toMatchObject({ kind: "attached", replaced: "datamate start-stdio" }) + }) +}) + +describe("ensure — the version floor applies to a REUSED entry", () => { + test("a pinned entry below the floor is replaced when PATH has a newer engine", async () => { + const h = install({ + existing: { type: "local", command: ["/opt/old/datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + version: (bin) => (bin === "/opt/old/datamate" ? "0.6.3" : "0.7.0"), + }) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ + kind: "attached", + replaced: "/opt/old/datamate start-stdio --datamate 42", + }) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + + test("a pinned entry below the floor with nothing newer on PATH is reported, not reused", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + version: () => "0.6.3", + }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + expect(h.toasts[0].message).toContain(MIN_ENGINE_VERSION) + }) + + test("an entry whose binary reports no version is not trusted for reuse", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + version: () => null, + }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "unknown" }) + expect(h.added).toHaveLength(0) + }) +}) + +describe("compareVersions — pre-release precedence (SemVer §11.3)", () => { + test("a pre-release of the floor version does NOT clear the floor", () => { + // The floor exists to require behaviour that shipped in a release; a + // pre-release of that version predates it, so it must rank below. + expect(compareVersions("0.7.0-beta.1", "0.7.0")).toBeLessThan(0) + expect(compareVersions("0.7.0", "0.7.0-beta.1")).toBeGreaterThan(0) + expect(compareVersions("0.7.0-beta.1", MIN_ENGINE_VERSION)).toBeLessThan(0) + }) + test("identifiers order by SemVer rules", () => { + expect(compareVersions("0.7.0-alpha", "0.7.0-beta")).toBeLessThan(0) + expect(compareVersions("0.7.0-beta.2", "0.7.0-beta.10")).toBeLessThan(0) // numeric, not lexical + expect(compareVersions("0.7.0-alpha", "0.7.0-alpha.1")).toBeLessThan(0) // fewer fields rank lower + expect(compareVersions("0.7.0-alpha.1", "0.7.0-alpha.beta")).toBeLessThan(0) // numeric < alphanumeric + expect(compareVersions("0.7.0-beta.1", "0.7.0-beta.1")).toBe(0) + }) + test("build metadata is ignored", () => { + expect(compareVersions("0.7.0+build.5", "0.7.0")).toBe(0) + expect(compareVersions("0.8.0+x", "0.7.0")).toBeGreaterThan(0) + }) + test("a release still outranks an older release", () => { + expect(compareVersions("0.7.1", "0.7.0")).toBeGreaterThan(0) + expect(compareVersions("0.6.9", "0.7.0")).toBeLessThan(0) + }) +}) + +describe("ensure — pre-release engines are refused", () => { + test("an engine reporting a pre-release of the floor is too old", async () => { + const h = install({ version: "0.7.0-beta.1" }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.7.0-beta.1" }) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) +}) + +describe("ensure — the memo follows the BINDING, not just the session id", () => { + const spawnTwice: Harness["statusQueue"] = [ + {}, + { datamate: { status: "connected" } }, + {}, + { datamate: { status: "connected" } }, + ] + + test("a re-link mid-session attaches the NEW workspace", async () => { + // recordApprovedBinding is reachable mid-session from the TUI workspace + // panel, so a live session's binding really can change under it. + let current: CachedBinding | null = binding // datamate 42 + const h = install({ statuses: spawnTwice, tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + const second = await ensure("s1") + expect(second).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(2) + expect(h.added[1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) + + test("a session that starts UNBOUND attaches once the project is linked", async () => { + let current: CachedBinding | null = null + const h = install({ statuses: spawnTwice, tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.added).toHaveLength(0) + + current = binding + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(1) + }) + + test("an unchanged binding is still memoised — no second attach per turn", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const first = await ensure("s1") + const second = await ensure("s1") + const third = await ensure("s1") + expect(second).toBe(first) + expect(third).toBe(first) + expect(h.added).toHaveLength(1) + expect(h.persisted).toHaveLength(1) + }) + + test("registration is SYNCHRONOUS, so whenAttached on the next line sees it", async () => { + // ensure() must not await before registering: prompt.ts calls whenAttached + // immediately after, and a late registration would make the turn skip the + // wait entirely — the exact first-turn gap this module closes. + const h = install({ tools: { datamate_dbt_build_model: 1 } }) + syncInternals.versionOf = () => new Promise(() => {}) // never settles + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 30) + expect(performance.now() - started).toBeGreaterThanOrEqual(20) + expect(h).toBeDefined() + }) +}) + +describe("ensure — a REJECTED engine is detached even when it cannot be replaced", () => { + const liveUnpinned = { type: "local", command: ["datamate", "start-stdio"] } + + test("no engine on PATH: still detaches, so resolveTools cannot serve the wrong workspace", async () => { + const h = install({ + existing: liveUnpinned, + statuses: [{ datamate: { status: "connected" } }], + which: null, + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + // The whole point: we judged it untrustworthy, so it must not still be serving. + expect(h.removes).toEqual(["datamate"]) + expect(h.added).toHaveLength(0) + }) + + test("PATH engine below the floor: still detaches before reporting too-old", async () => { + const h = install({ + existing: liveUnpinned, + statuses: [{ datamate: { status: "connected" } }], + version: "0.5.9", + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.5.9" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.added).toHaveLength(0) + }) + + test("a pinned-but-below-floor engine with nothing better is detached, not left serving", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + version: () => "0.6.3", + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + expect(h.removes).toEqual(["datamate"]) + }) +}) + +describe("ensure — reuse reports the declared-vs-delivered gap (rule 5)", () => { + test("a reused engine missing a declared tool warns, and the outcome carries the gap", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, // declared has two keys + }) + expect(await ensure("s1")).toEqual({ + kind: "reused", + available: 1, + declared: 2, + missing: ["dbt_compile_model"], + }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("warning") + expect(h.toasts[0].message).toContain("1 of 2 declared integration tools") + expect(h.toasts[0].message).toContain("dbt_compile_model") + }) + + test("no gap means no toast", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "reused", missing: [] }) + expect(h.toasts).toHaveLength(0) + }) + + test("an unreadable allowlist degrades quietly rather than inventing a gap", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + declared: null, + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toEqual({ kind: "reused", available: 1 }) + expect(h.toasts).toHaveLength(0) + }) +}) + +describe("ensure — an unbound project does not keep a stale MANAGED entry", () => { + test("a pinned entry is LEFT ALONE when the binding is gone — argv is not provenance", async () => { + // A hand-authored entry is byte-identical + // to one we wrote, so tearing it down would take the user's server offline. + const h = install({ + binding: null, + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, + statuses: [{ datamate: { status: "connected" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toHaveLength(0) + }) + + test("an IDE-written entry is LEFT ALONE — it is the user's, not ours", async () => { + const h = install({ + binding: null, + existing: { command: "datamate", args: ["start-stdio"] }, + statuses: [{ datamate: { status: "connected" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toHaveLength(0) + }) + + test("nothing registered means nothing to detach", async () => { + const h = install({ binding: null, statuses: [{}] }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toHaveLength(0) + expect(h.toasts).toHaveLength(0) + }) +}) + +describe("ensure — a repairable failure is re-probed on the next turn", () => { + test("engine-missing is retried once the engine appears, without a new session", async () => { + let onPath: string | null = null + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.which = () => onPath + + // Turn 1: no engine. We print the install hint. + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.added).toHaveLength(0) + + // The user follows that hint mid-session. + onPath = "/usr/local/bin/datamate" + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(1) + }) + + test("engine-too-old is retried after an update", async () => { + let version = "0.5.9" + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.versionOf = async () => version + + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.5.9" }) + version = "0.7.0" + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(1) + }) + + test("a SUCCESSFUL outcome is still memoised — retry must not mean re-attach every turn", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + expect(await ensure("s1")).toBe(first) + expect(await ensure("s1")).toBe(first) + expect(h.added).toHaveLength(1) + }) + + test("a repairable retry does not re-arm the turn wait, even when the retry HANGS", async () => { + // The earlier version of this test let the retry settle immediately, so + // whenAttached returned on settle and the test passed whatever the flag + // said. The retry must hang for the flag to be the thing under test. + let onPath: string | null = null + install({}) + syncInternals.which = () => onPath + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + + onPath = "/usr/local/bin/datamate" + syncInternals.versionOf = () => new Promise(() => {}) // never settles + void ensure("s1") + const started = performance.now() + await whenAttached("s1", 5_000) + expect(performance.now() - started).toBeLessThan(150) + }) +}) + +describe("ensure — the version probe targets the engine, not its wrapper", () => { + test("an npx-wrapped entry is not trusted on the wrapper's version", async () => { + const probed: string[] = [] + const h = install({ + existing: { type: "local", command: ["npx", "@altimateai/datamate@0.6.3", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.versionOf = async (bin) => { + probed.push(bin) + return "0.7.0" + } + const outcome = await ensure("s1") + // npx is never probed — a modern wrapper must not vouch for an old engine. + expect(probed).not.toContain("npx") + // Unverifiable, so it is replaced by a pinned spawn we can vouch for. + expect(outcome).toMatchObject({ kind: "attached" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + + test("an absolute path to a real datamate IS probed and reused", async () => { + const probed: string[] = [] + const h = install({ + existing: { type: "local", command: ["/opt/bin/datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.versionOf = async (bin) => { + probed.push(bin) + return "0.7.0" + } + expect(await ensure("s1")).toMatchObject({ kind: "reused" }) + expect(probed).toContain("/opt/bin/datamate") + expect(h.added).toHaveLength(0) + }) +}) + +describe("ensure — a superseded attach cannot overwrite the current one", () => { + test("the re-linked workspace wins even when the old attach is slower", async () => { + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [ + {}, + { datamate: { status: "connected" } }, + {}, + { datamate: { status: "connected" } }, + ], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + // Make the FIRST attach slow, so without serialization it would land last. + let firstAdd = true + syncInternals.mcp!.add = async (name, cfg) => { + if (firstAdd) { + firstAdd = false + await new Promise((r) => setTimeout(r, 60)) + } + h.added.push({ name, cfg }) + } + + const a = ensure("s1") // workspace 42 + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + const b = ensure("s1") // re-link to 99 + await Promise.all([a, b]) + + // Both ran, but in order: the LAST add must be the workspace we re-linked to. + expect(h.added).toHaveLength(2) + expect(h.added[h.added.length - 1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) +}) + +describe("a deliberate disable is respected", () => { + test("an explicitly disabled entry is respected, never silently re-enabled", async () => { + // MCP.connect persists `enabled: true` into whichever config owns the entry, + // so retrying a DISABLED entry would undo a deliberate global disable for + // every other project. + const h = install({ + // A real user disable is `enabled: false` in the config. The runtime + // status alone is not evidence of intent. + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: false }, + statuses: [{ datamate: { status: "disabled" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(h.connects).toHaveLength(0) + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("a genuinely FAILED entry that is OURS is still retried once", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [ + { datamate: { status: "failed", error: "exit 1" } }, + { datamate: { status: "failed", error: "exit 1" } }, + ], + }) + expect(await ensure("s1")).toEqual({ kind: "connect-failed", error: "exit 1" }) + expect(h.connects, "used the config-writing primitive to repair").toHaveLength(0) + expect(h.added).toHaveLength(1) + }) + + test("two overlapping SESSIONS in one project never attach concurrently", async () => { + // MCP state is instance-wide and MCP.add is last-writer-wins, while + // SessionRunState keeps independent runners per session id — so per-session + // ordering is not enough. The invariant is that no two attaches for the same + // project are ever in their mutating phase at the same time. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + let inFlight = 0 + let peak = 0 + syncInternals.mcp!.add = async (name, cfg) => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await new Promise((r) => setTimeout(r, 40)) + h.added.push({ name, cfg }) + inFlight -= 1 + } + + await Promise.all([ensure("sessionA"), ensure("sessionB")]) + + expect(h.added).toHaveLength(2) + expect(peak).toBe(1) // 2 without project-scoped serialization + }) +}) + +describe("what may be torn down, and what may not", () => { + test("a REMOVED entry is not mistaken for a user disable — repair still works", async () => { + // MCP.remove deletes s.status[name], and MCP.status() reports a configured + // entry with no status as "disabled". Reading that as user intent made every + // turn after a rejection teardown return entry-disabled, permanently — + // silently undoing the repairable retry. + let onPath: string | null = null + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned -> rejected + statuses: [ + { datamate: { status: "connected" } }, + { datamate: { status: "disabled" } }, // synthesized after remove + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.which = () => onPath + + // Turn 1: rejected and torn down, and no engine to replace it with. + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.removes).toEqual(["datamate"]) + + // The user installs the engine and takes another turn. + onPath = "/usr/local/bin/datamate" + const second = await ensure("s1") + expect(second).not.toEqual({ kind: "entry-disabled" }) + expect(second).toMatchObject({ kind: "attached" }) + expect(h.added[h.added.length - 1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) + + test("an unbound project does NOT tear down an entry it cannot prove it owns", async () => { + // argv shape is not provenance: a hand-authored entry looks identical to ours. + const h = install({ + binding: null, + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, + statuses: [{ datamate: { status: "connected" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toHaveLength(0) // the user's server stays up + }) + + test("the session map does not grow without bound", async () => { + install({ binding: null }) + for (let i = 0; i < MAX_TRACKED_SESSIONS + 25; i++) await ensure(`s${i}`) + expect(trackedSessionsForTests()).toBeLessThanOrEqual(MAX_TRACKED_SESSIONS) + }) + + test("per-session announcement state is bounded by the same eviction", async () => { + // Every REFUSING session records what it was last told, so it can avoid + // repeating itself. A long-running server whose new sessions keep failing — + // `engine-missing` is the obvious case — would retain one record per session + // forever if that state lived in a map of its own. It lives on the session + // record instead, so it is bounded by whatever bounds the sessions, which is + // already solved and already tested above rather than solved twice. + install({ which: null }) + for (let i = 0; i < MAX_TRACKED_SESSIONS + 25; i++) { + expect((await ensure(`r${i}`)).kind).toBe("engine-missing") + } + expect(trackedSessionsForTests()).toBeLessThanOrEqual(MAX_TRACKED_SESSIONS) + }) +}) + +describe("a stale binding is never installed", () => { + test("a re-link DURING an attach abandons it instead of installing the old workspace", async () => { + // run() snapshots the binding, then spends seconds in status, version and + // API work before persisting. A re-link inside that window would install + // the workspace the session had already left. + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + // The re-link lands while the attach is in its slow phase. + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] } + } + + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + // The decisive assertion: workspace 42's engine is never installed. + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("an unchanged binding still attaches normally", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) +}) + +describe("an unexpected failure still reaches the user", () => { + test("an unexpected attach error still tells the user", async () => { + // Every explicit failure branch notifies; an unexpected throw must not be + // the one path that leaves the user with neither tools nor an explanation. + const h = install({ statuses: [{}] }) + syncInternals.persist = async () => { + throw new Error("EACCES: project config is not writable") + } + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "connect-failed" }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].variant).toBe("error") + expect(h.toasts[0].message).toContain("EACCES") + }) +}) + +describe("a malformed version is refused", () => { + test("a malformed core is refused, not treated as equal to the floor", () => { + // parseInt("7rc") is 7, so "0.7rc.0" compared EQUAL to a 0.7.0 floor, and a + // bare "1" won on major before its missing components were examined. + expect(compareVersions("0.7rc.0", MIN_ENGINE_VERSION)).toBeLessThan(0) + expect(compareVersions("1", MIN_ENGINE_VERSION)).toBeLessThan(0) + expect(compareVersions("1.0", MIN_ENGINE_VERSION)).toBeLessThan(0) + // Well-formed versions must still behave. + expect(compareVersions("0.7.0", MIN_ENGINE_VERSION)).toBe(0) + expect(compareVersions("1.0.0", MIN_ENGINE_VERSION)).toBeGreaterThan(0) + expect(compareVersions("0.6.9", MIN_ENGINE_VERSION)).toBeLessThan(0) + }) + + test("an engine reporting a malformed version is refused", async () => { + const h = install({ version: "0.7rc.0" }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.7rc.0" }) + expect(h.added).toHaveLength(0) + }) + + + + test("settled project attach chains are not retained", async () => { + install({ binding: null }) + await ensure("s1") + expect(trackedChainsForTests()).toBe(0) + }) +}) + +describe("settledOutcome — a read-only view for other modules", () => { + test("undefined before an attach exists, the outcome after it settles", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + expect(settledOutcome("s1")).toBeUndefined() // never attached + const outcome = await ensure("s1") + expect(settledOutcome("s1")).toEqual(outcome) + expect(h.added).toHaveLength(1) + }) + + test("undefined while the attach is still in flight — never a premature answer", async () => { + install({}) + syncInternals.versionOf = () => new Promise(() => {}) // never settles + void ensure("s1") + expect(settledOutcome("s1")).toBeUndefined() + await new Promise((r) => setTimeout(r, 20)) + expect(settledOutcome("s1")).toBeUndefined() + }) + + test("reading never mutates the memo or the project chain", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + const sessionsBefore = trackedSessionsForTests() + const chainsBefore = trackedChainsForTests() + for (let i = 0; i < 5; i++) settledOutcome("s1") + expect(trackedSessionsForTests()).toBe(sessionsBefore) + expect(trackedChainsForTests()).toBe(chainsBefore) + expect(h.added).toHaveLength(1) // no attach was triggered by reading + }) +}) + +describe("intent and connectivity disagree in both directions", () => { + test("a live disconnect is honoured even when the config cache is stale", async () => { + // MCP.disconnect writes enabled:false to disk without invalidating Config, + // so the cached entry still says enabled:true. Believing the cache would + // reconnect the entry and persist it enabled again — undoing the user's + // disconnect, globally if the owning entry is global. + let reads = 0 + const h = install({ + statuses: [{ datamate: { status: "disabled" } }], + }) + // Reads go through freshConfig now, so the disk value is what is seen. + syncInternals.existingEntry = undefined + syncInternals.freshConfig = async () => { + reads += 1 + return { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } } + } + + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(reads).toBeGreaterThan(0) + expect(h.connects).toHaveLength(0) // MCP.connect would persist enabled:true + expect(h.persisted).toHaveLength(0) + }) + + test("an unrunnable engine is described as broken, not as out of date", async () => { + const broken = install({ version: null }) + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "unknown" }) + expect(broken.toasts[0].title).toContain("not runnable") + expect(broken.toasts[0].message).toContain("did not report a usable version") + expect(broken.toasts[0].message).not.toContain("needs 0.7.0 or newer") + + resetForTests() + const old = install({ version: "0.6.9" }) + expect(await ensure("s2")).toEqual({ kind: "engine-too-old", found: "0.6.9" }) + expect(old.toasts[0].title).toContain("too old") + expect(old.toasts[0].message).toContain("needs 0.7.0 or newer") + }) +}) + +describe("announcing, bounding, and reading config fresh", () => { + test("a successful add announces the new tools", async () => { + // MCP.add stores the client but publishes nothing, so a late attach — after + // the bounded wait expired, or on a repair retry — left the session with + // tools it had no way to learn about until another user turn. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.toolsChanged).toBe(1) + }) + + + test("a stalled catalog lookup cannot block the local engine", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.declared = () => new Promise(() => {}) // API accepts then stalls + const outcome = await ensure("s1") + // The engine is on PATH and the binding is cached; reporting is optional. + expect(outcome).toMatchObject({ kind: "attached" }) + expect(h.added).toHaveLength(1) + }) + + test("config is read fresh, so an external write is never missed", async () => { + // Nothing in this module can enumerate the writers — MCP writes raw, and an + // IDE rewriting the entry never touches Config at all — so freshness has to + // be structural at the point of read. + let onDisk: Record = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + let invalidations = 0 + const h = install({ statuses: [{ datamate: { status: "disabled" } }] }) + syncInternals.existingEntry = undefined // let the real reader go through freshConfig + syncInternals.freshConfig = async () => { + invalidations += 1 + return { mcp: { datamate: onDisk as ExistingEntry } } + } + onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: false } + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(invalidations).toBeGreaterThan(0) + expect(h.connects).toHaveLength(0) + }) +}) + +describe("a stalled catalog lookup never blocks the engine", () => { + test("a stalled catalog lookup cannot block the REUSE path either", async () => { + // A bound that covers only the fresh-spawn path leaves a compatible + // pinned engine still awaited the lookup with no limit, and the generic API + // request attaches no abort signal at all. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.declared = () => new Promise(() => {}) // accepts, then stalls + const outcome = await ensure("s1") + // Reuse still succeeds; only the optional reporting degrades. + expect(outcome).toMatchObject({ kind: "reused", available: 1 }) + expect(h.added).toHaveLength(0) + }) +}) + +describe("config is read before the status it is judged against", () => { + test("an externally added entry is seen even when MCP status has not caught up", async () => { + // MCP.status() reads the same cached config as everything else, so an entry + // an IDE adds after the cache is warm is absent from status. Without a fresh + // read first, rule 1 never runs and we persist over the user's entry. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], // status omits it + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, // but config has it + tools: { datamate_dbt_build_model: 1 }, + }) + let readBeforeStatus = false + let statusCalls = 0 + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + statusCalls += 1 + return realStatus() + } + syncInternals.existingEntry = async () => { + if (statusCalls === 0) readBeforeStatus = true + return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + } + await ensure("s1") + // The ordering is the fix: the config refresh must precede the status gate. + expect(readBeforeStatus).toBe(true) + expect(h).toBeDefined() + }) +}) + +describe("an answer is revalidated before it is given", () => { + test("a re-link during the reuse lookup is not answered with the old workspace", async () => { + // The reuse branch awaits the allowlist lookup for up to the bound. Returning + // `reused` afterwards asserts the connected engine serves the CURRENT binding + // — so a re-link inside that await would hand this turn workspace A's tools, + // and its credentials, under binding B. + let current: CachedBinding | null = binding // 42 + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model"], extensionKeys: [] } + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.added).toHaveLength(0) + }) + + test("a client replaced during the reuse lookup is not answered as ours", async () => { + // Same writers as the install region — the MCP route and the IDE's reload + // call `MCP.add` outside this flow's serialization — and the same two + // awaits (tools, allowlist) sit between judging the engine and answering + // for it. Answering `reused` for the replacement names the bound workspace + // over a client that may be pinned elsewhere; the replacement is also not + // ours to detach. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] } as never + return prevTools() + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.added, "spawned over a replacement it did not judge").toHaveLength(0) + expect(h.removes, "detached a client that was not the one it judged").toHaveLength(0) + }) + + test("a disable during the reuse lookup is honoured, not answered with reused", async () => { + // Intent outranks everything, including a reuse already decided. The tool + // and allowlist reads are awaits a disable can land inside; answering + // `reused` afterwards serves the turn from an engine the user has just + // switched off, and the memo would only notice on the following turn. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + let reads = 0 + syncInternals.existingEntry = async () => { + reads += 1 + // The inspection sees the entry enabled; every read after it sees the + // disable the user wrote while the lookup was in flight. + return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: reads === 1 } + } + const outcome = await ensure("s1") + expect(outcome.kind, "served a turn from an engine the user disabled").toBe("entry-disabled") + expect(h.removes, "left the disabled engine serving").toEqual(["datamate"]) + expect(h.persisted, "wrote config while honouring a disable").toHaveLength(0) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine is disabled"]) + }) + + test("a client that vanished during the reuse lookup is not answered as serving", async () => { + // Someone disconnects or removes the entry while the tool listing is in + // flight. There is nothing to detach and nothing serving; answering + // `reused` would name an engine that is not there. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + h.spawnedNow = undefined + return prevTools() + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.added).toHaveLength(0) + expect(h.removes).toHaveLength(0) + }) + + test("a client that vanished during the post-install awaits is not reported as attached", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + h.spawnedNow = undefined + return prevTools() + } + expect((await ensure("s1")).kind, "reported an engine that is no longer there").toBe("superseded") + expect(h.restores, "left our pin on disk for a client that is gone").toHaveLength(1) + }) + + test("a re-link during the success announcements is not answered with the old workspace", async () => { + // The answer was fixed before the announcements, but it is GIVEN after + // them, and they are awaits. The toast was true when shown; the answer must + // be true when returned — so the world is asked once more after the last + // announcement, and the install is undone if it moved. No second toast. + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const prevNotify = syncInternals.notify! + syncInternals.notify = async (toast) => { + await prevNotify(toast) + if (toast.title.endsWith("connected")) current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + expect((await ensure("s1")).kind).toBe("superseded") + expect(h.removes, "left the old workspace's engine serving under the new binding").toEqual(["datamate"]) + expect(h.restores).toHaveLength(1) + expect(h.toasts.filter((t) => t.title.endsWith("connected"))).toHaveLength(1) + expect(h.toasts.filter((t) => t.variant === "error")).toHaveLength(0) + }) + + test("a re-link during the reuse announcements is not answered with the old workspace", async () => { + // The reuse answer is fixed after the lookup and given after the + // missing-tools warning and the hosted-neighbours note, which are awaits. + // Same rule as the attached path: asked again after the last announcement. + let current: CachedBinding | null = binding // 42 + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, // dbt_compile_model is declared but missing → a warning is announced + }) + syncInternals.resolveBinding = async () => current + const prevNotify = syncInternals.notify! + syncInternals.notify = async (toast) => { + await prevNotify(toast) + if (toast.title.includes("missing declared tools")) current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + expect((await ensure("s1")).kind).toBe("superseded") + expect(h.removes, "left the old workspace's engine serving under the new binding").toEqual(["datamate"]) + expect(h.added).toHaveLength(0) + }) + + test("a memo is re-probed when the running launch changes under an unchanged argv", async () => { + // Identity is the whole launch — a replacement with the same argv under a + // different PATH runs a different binary, and a cache keyed on argv alone + // would accept it without asking its version. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + let probes = 0 + syncInternals.versionOf = async () => ((probes += 1), "0.7.0") + await ensure("s1") // reuse: probes once + await ensure("s1") // memo validation: probes once and records the launch identity + const validated = probes + await ensure("s1") // unchanged launch: no probe + expect(probes, "re-probed an unchanged launch").toBe(validated) + h.spawnedNow = { + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + environment: { PATH: "/somewhere/else/bin" }, + } as never + await ensure("s1") + expect(probes, "accepted a replacement with the same argv under a different PATH without probing").toBe(validated + 1) + }) + + test("the undo keeps an entry that was rewritten AND disabled while it was held", async () => { + // Neither the new transport nor the disable is ours: projecting the disable + // onto what we replaced would overwrite the newer transport with the old one. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => + h.persisted.length + ? ({ type: "local", command: ["/their/datamate", "start-stdio", "--datamate", "42"], enabled: false } as ExistingEntry) + : null + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return prevTools() + } + expect((await ensure("s1")).kind).toBe("superseded") + expect(h.restores, "overwrote a transport the user rewrote while we held the entry").toHaveLength(0) + }) + + test("the version probe resolves a relative cwd the way the engine is launched", async () => { + // MCP resolves a relative `cwd` against the instance directory. Probed + // against the process's own directory instead, a relative command or PATH + // entry can name a different binary than the one the engine runs. + const seen: Array = [] + syncInternals.instanceDirectory = () => "/proj/root" + syncInternals.versionOf = async (_bin, spawn) => ((seen.push(spawn?.cwd)), "0.7.0") + await engineVersionOf({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], cwd: "tools" } as ExistingEntry) + await engineVersionOf({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], cwd: "/abs/tools" } as ExistingEntry) + await engineVersionOf({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } as ExistingEntry) + expect(seen).toEqual(["/proj/root/tools", "/abs/tools", undefined]) + }) + + test("a memo is not returned for a client that replaced the judged engine after the final binding read", async () => { + // Turn 2 validates the memo (reads the runtime record), then reads the + // binding one last time. A replacement landing between those two reads is + // what `resolveTools` will hand the model; the runtime is asked once more, + // last. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + expect((await ensure("s1")).kind).toBe("reused") + let bindingReads = 0 + const prevBinding = syncInternals.resolveBinding! + syncInternals.resolveBinding = async () => { + bindingReads += 1 + // attachKey, attachKeyWorkspace, then the final attachKeyWorkspace: the + // replacement lands as the last binding read is taken. + if (bindingReads === 3) h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] } as never + return prevBinding() + } + const second = await ensure("s1") + expect(bindingReads, "the staging assumed three binding reads on the memo path").toBeGreaterThanOrEqual(3) + expect(second.kind, "returned the memo for a client that had replaced the judged engine").not.toBe("reused") + expect(h.removes, "left the replacement registered under the cached attribution").toContain("datamate") + }) +}) + +describe("a cached success is re-probed against the floor", () => { + test("a cached success stops being trusted if the engine drops below the floor", async () => { + // The pin is only trustworthy because the floor is: engines below it do not + // lock the pin. An entry reconnected behind the same pin with a pre-floor + // binary would otherwise ride the cached success forever. + let version = "0.7.0" + let command = ["datamate", "start-stdio", "--datamate", "42"] + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.versionOf = async () => version + syncInternals.existingEntry = async () => ({ type: "local", command }) + + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + + // The entry is replaced behind the same pin by an older engine. + command = ["/opt/old/datamate", "start-stdio", "--datamate", "42"] + version = "0.6.3" + const second = await ensure("s1") + expect(second).not.toBe(first) + }) + + test("an unchanged command is not re-probed every turn", async () => { + let probes = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.versionOf = async () => { + probes += 1 + return "0.7.0" + } + await ensure("s1") + const afterAttach = probes + await ensure("s1") + await ensure("s1") + // Probing spawns a process; the reuse path must not pay it on every turn. + expect(probes).toBeLessThanOrEqual(afterAttach + 1) + expect(h.added).toHaveLength(1) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// INVARIANTS +// +// These assert the module's contract rather than the shape of any one fix. A +// per-fix test says "this bug is gone"; an invariant says "this cannot happen", +// which is what catches the NEXT instance of a class rather than the last one. +// Four fixes in this file's history created the following defect, and no per-fix +// test could have seen that. These are the net underneath the next change. +// ───────────────────────────────────────────────────────────────────────────── +describe("INVARIANT — one engine per project", () => { + test("a replacement never leaves two engines registered: every add over a live entry is preceded by a removal", async () => { + const live: Array<{ name: string; existing: Harness["statusQueue"][number]; entry: unknown }> = [ + { name: "unpinned", existing: { datamate: { status: "connected" } }, entry: { type: "local", command: ["datamate", "start-stdio"] } }, + { name: "pinned elsewhere", existing: { datamate: { status: "connected" } }, entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] } }, + { name: "connected url", existing: { datamate: { status: "connected" } }, entry: { type: "remote", url: "https://api.example/sse" } }, + ] + for (const scenario of live) { + resetForTests() + const h = install({ + existing: scenario.entry as never, + statuses: [scenario.existing, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure(`s-${scenario.name}`) + if (h.added.length > 0) { + expect(h.removes.length, `${scenario.name}: added without removing the live entry first`).toBeGreaterThan(0) + } + } + }) + + test("concurrent attaches in one project never overlap their mutating phase", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + let inFlight = 0 + let peak = 0 + syncInternals.mcp!.add = async (name, cfg) => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await new Promise((r) => setTimeout(r, 30)) + h.added.push({ name, cfg }) + inFlight -= 1 + } + await Promise.all([ensure("a"), ensure("b")]) + expect(peak).toBe(1) + }) +}) + +describe("INVARIANT — no MCP mutation on a stale binding", () => { + // The binding is flipped at each await seam in turn. Whatever the flow was + // doing, it must not mutate MCP state for a workspace the project has left. + const seams = ["existingEntry", "versionOf", "declared", "tools", "add"] as const + + for (const seam of seams) { + test(`a re-link at the ${seam} seam never installs or tears down for the old workspace`, async () => { + resetForTests() + let current: CachedBinding | null = binding // 42 + const flip = () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + if (seam === "existingEntry") syncInternals.existingEntry = async () => (flip(), null) + if (seam === "versionOf") syncInternals.versionOf = async () => (flip(), "0.7.0") + if (seam === "declared") syncInternals.declared = async () => (flip(), { keys: [], extensionKeys: [] }) + if (seam === "tools") syncInternals.mcp!.tools = async () => (flip(), {}) + if (seam === "add") { + const prev = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await prev(n, c) + flip() + } + } + await ensure("s1") + // Anything installed for 42 after the project moved to 99 must not survive. + const strayFor42 = h.added.filter((a) => a.cfg.command.includes("42")).length + if (strayFor42 > 0) { + expect(h.removes.length, `${seam}: installed workspace 42 after the re-link and left it`).toBeGreaterThan(0) + } + }) + } +}) + +describe("INVARIANT — every config read is fresh", () => { + test("no config read bypasses the refreshing accessor", async () => { + let fresh = 0 + const h = install({ statuses: [{ datamate: { status: "disabled" } }] }) + syncInternals.existingEntry = undefined + syncInternals.freshConfig = async () => { + fresh += 1 + return { mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } } + } + await ensure("s1") + // If any read went through a cached path instead, this would be 0. + expect(fresh).toBeGreaterThan(0) + expect(h.connects).toHaveLength(0) + }) +}) + +describe("INVARIANT — an actionable failure tells the user exactly once", () => { + const actionable: Array<{ name: string; opts: Parameters[0]; kind: string }> = [ + { name: "engine-missing", opts: { which: null }, kind: "engine-missing" }, + { name: "engine-too-old", opts: { version: "0.5.9" }, kind: "engine-too-old" }, + { name: "unrunnable engine", opts: { version: null }, kind: "engine-too-old" }, + { + name: "entry-disabled", + opts: { + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: false }, + statuses: [{ datamate: { status: "disabled" } }], + }, + kind: "entry-disabled", + }, + { + name: "connect-failed", + opts: { + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [ + { datamate: { status: "failed", error: "exit 1" } }, + { datamate: { status: "failed", error: "exit 1" } }, + ], + }, + kind: "connect-failed", + }, + ] + for (const c of actionable) { + test(`${c.name} is never silent`, async () => { + resetForTests() + const h = install(c.opts) + const outcome = await ensure("s1") + expect(outcome.kind).toBe(c.kind as never) + // EXACTLY one, not at least one. "At least one" accepts a double signal, + // and a double signal is what a refusal path grows when a second way of + // reaching the user is added beside the first — a dialog and a toast + // saying the same thing. A suite asserting a toast fires and a suite + // asserting an offer is raised can both be green while the user sees two. + expect(h.toasts.length, `${c.name} told the user ${h.toasts.length} times, not once`).toBe(1) + }) + } + + test("a refusal for a workspace the project has left says nothing at all", async () => { + // Zero, not one: the message would name a workspace this project no longer + // holds. The teardown still happens — it is binding-independent — but the + // answer becomes `superseded` and the user hears nothing about a decision + // that no longer applies to them. + let current: CachedBinding | null = binding + const h = install({ + which: null, + statuses: [{}], + }) + syncInternals.resolveBinding = async () => current + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model"], extensionKeys: [] } + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.toasts.length, "announced a refusal for the workspace the project had left").toBe(0) + }) + + test("the teardown happens before the announcement, not after", async () => { + // Load-bearing rather than incidental: the announcement is a substitution + // point, and a body that waits on a person — a dialog — would hold a + // rejected client connected until they clicked. Stop serving first, explain + // second. + const order: string[] = [] + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + const previousRemove = syncInternals.mcp!.remove + syncInternals.mcp!.remove = async (name: string) => { + order.push("teardown") + return previousRemove(name) + } + syncInternals.notify = async (toast) => { + order.push("announce") + h.toasts.push(toast) + } + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(order, "explained before it stopped serving").toEqual(["teardown", "announce"]) + }) +}) + +describe("INVARIANT — a superseded attach leaves nothing installed", () => { + test("whatever it installed before noticing, it does not leave it serving", async () => { + let current: CachedBinding | null = binding + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await prevAdd(n, c) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.removes, "superseded left the engine it installed still registered").toContain("datamate") + // The runtime is only half of it. `persist()` already wrote the old + // workspace's pin to disk, so a restart before the next attach would + // bootstrap it again — "leaves nothing installed" has to mean the config too. + expect(h.restores.length, "superseded left the old workspace pinned on disk").toBeGreaterThan(0) + }) + + test("a superseded REUSE detaches the engine it declined to answer with", async () => { + // The caller runs resolveTools regardless of the outcome, so returning + // `superseded` while the old client stays registered still hands that turn + // the previous workspace's tools — and its credentials. + let current: CachedBinding | null = binding // 42 + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model"], extensionKeys: [] } + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.removes, "left the old workspace's client registered for resolveTools to find").toContain("datamate") + }) +}) + +describe("INVARIANT — a cached success is re-probed and re-attributed", () => { + const invalidations = [ + { name: "engine died", statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "failed", error: "closed" } }, {}, { datamate: { status: "connected" } }], entry: null }, + { name: "pin moved to another workspace", statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], entry: "99" }, + ] as const + + for (const c of invalidations) { + test(`a cached success is not reused when the ${c.name}`, async () => { + resetForTests() + let pin = "42" + const h = install({ statuses: c.statuses as never, tools: { datamate_dbt_build_model: 1 } }) + if (c.entry !== null) { + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", pin] }) + } + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + if (c.entry !== null) pin = c.entry + const second = await ensure("s1") + expect(second, `${c.name}: the cached success was reused unchecked`).not.toBe(first) + }) + } +}) + +describe("a cached success is re-attributed before it is served", () => { + test("a re-link DURING cached-success validation is not answered with the old workspace", async () => { + // The memoised-success path does its own awaited validation outside run(), + // so it never had run()'s final binding check. Status, config and version + // work all await; a re-link inside them left `boundTo` pointing at the old + // workspace and returned its cached task — handing the turn A's tools and + // credentials under binding B. + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }, {}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + + // The re-link lands while the cached success is being re-validated. + syncInternals.versionOf = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return "0.7.0" + } + const second = await ensure("s1") + expect(second, "returned the cached success for a workspace the project had left").not.toBe(first) + }) + + test("a superseded attach removes the project override rather than copying the global entry", async () => { + // existingEntry() returns the MERGED value, which may come from global, while + // persist() writes to the project file. Restoring the merged value would + // write a copy of the global entry into the project — a permanent override + // shadowing every later global update, disable or removal. + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: true }, // merged, from global + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => null // the PROJECT file has no entry of its own + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await prevAdd(n, c) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.restores, "restored something into the project file instead of removing the override").toEqual([null]) + }) +}) + +describe("INVARIANT — a disabled entry serves nothing", () => { + // "Disabled" is a claim about what the model can reach, not about what the + // config file says. The config is where the user expresses it; the runtime is + // where it either holds or doesn't. + test("an entry disabled AFTER it connected is torn down, not merely reported", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + // The status a live disable actually produces. `MCP.status()` returns live + // client state and `MCP.tools()` gates on exactly that, consulting the + // config only for a timeout — so reporting `entry-disabled` while the + // client stays registered hands that turn the tools and the credentials + // of the workspace the user just switched off. + statuses: [{ datamate: { status: "connected" } }], + }) + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + expect(h.removes, "reported the entry disabled but left its client serving tools").toContain("datamate") + // Respecting the edit must not turn into rewriting it, and must not turn + // into attaching over it: for an unpinned entry the replacement path would + // otherwise persist it enabled again, undoing the very edit being honoured. + expect(h.added, "attached over an entry the user had disabled").toHaveLength(0) + expect(h.persisted, "wrote to the config while honouring a disable").toHaveLength(0) + expect(h.connects, "retried an entry the user disabled").toHaveLength(0) + }) + + test("a memoised success does not outlive the entry being disabled", async () => { + // The disable check lives in `run()`, and a settled success never re-enters + // it. Every later turn of that session is decided by the memo alone, so the + // check has to be reachable from the validation path too. + let enabled = true + const h = install({ + statuses: [ + {}, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.existingEntry = async () => + ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }) as ExistingEntry + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + + enabled = false + expect(await ensure("s1"), "rode the memo straight past the user's disable").toEqual({ kind: "entry-disabled" }) + expect(h.removes, "kept serving the disabled entry's tools for the rest of the session").toContain("datamate") + }) + + test("nothing awaits between the final binding check and the install", async () => { + // The guard is only worth what the gap after it is: any await between the + // check and the mutations reopens the window the check exists to close. The + // late guard would undo this attach — but only after it had spawned an + // engine and taken the per-project lock, which is long enough for the + // replacement attach's first-turn wait to expire. + let current: CachedBinding | null = binding + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return null + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(h.added, "installed an engine for a workspace the project had already left").toHaveLength(0) + expect(h.persisted, "pinned a workspace the project had already left").toHaveLength(0) + }) +}) + +describe("INVARIANT — every outcome answers both consumer questions deliberately", () => { + // Typed by the union on purpose. Adding a state to `Outcome` fails to compile + // here until someone decides what it means for BOTH consumers — which is the + // point: the bug this guards against is not a wrong answer, it is a state + // acquiring an answer nobody chose. + const EXPECTED: Record = { + attached: { serving: true, installHelps: false }, + reused: { serving: true, installHelps: false }, + disabled: { serving: false, installHelps: false }, + unbound: { serving: false, installHelps: false }, + "engine-missing": { serving: false, installHelps: true }, + "engine-too-old": { serving: false, installHelps: true }, + "connect-failed": { serving: false, installHelps: false }, + "entry-disabled": { serving: false, installHelps: false }, + superseded: { serving: false, installHelps: false }, + } + + test("attribution and remedy are decided across the whole union, not a sample", () => { + for (const [kind, want] of Object.entries(EXPECTED)) { + const outcome = { kind } as Outcome + expect(attributableEngine(outcome), `attribution for ${kind}`).toBe(want.serving) + expect(installWouldHelp(outcome), `install remedy for ${kind}`).toBe(want.installHelps) + } + }) + + test("an unsettled attach answers neither question", () => { + // `undefined` means in-flight OR never attached. Both consumers fail open on + // it, so it must never be mistaken for a settled verdict. + expect(attributableEngine(undefined)).toBe(false) + expect(installWouldHelp(undefined)).toBe(false) + }) + + test("refusing to attach is not the same as being unable to obtain an engine", () => { + // The distinction the offer depends on: these refused, but an install fixes + // none of them — a user who switched their engine off would be offered the + // engine they already have. + expect(installWouldHelp({ kind: "entry-disabled" })).toBe(false) + expect(installWouldHelp({ kind: "connect-failed", error: "exit 1" })).toBe(false) + expect(installWouldHelp({ kind: "superseded" })).toBe(false) + // ...and these are exactly the two an install does fix. + expect(installWouldHelp({ kind: "engine-missing", declared: 0 })).toBe(true) + expect(installWouldHelp({ kind: "engine-too-old", found: "0.6.3" })).toBe(true) + }) + + test("a superseded attach is never attributed to the session that raced it", () => { + // The binding moved mid-flight, so what is connected belongs to a workspace + // this project has left. Attributing it would route queries there with its + // credentials. + expect(attributableEngine({ kind: "superseded" })).toBe(false) + }) + + test("attribution is keyed to the session, not to the last attach anywhere", async () => { + install({ statuses: [{ datamate: { status: "connected" } }], existing: null, which: null }) + await ensure("s1") + expect(settledOutcome("s1")).toBeDefined() + expect(settledOutcome("s2"), "a session that never attached inherited another's verdict").toBeUndefined() + }) +}) + +describe("INVARIANT — the entry decision is ordered by authority and cannot await", () => { + // The order is the contract: intent > connectivity > attribution > version. + // Each check is defeated by sitting on the wrong side of another, and an + // await between them is what lets that happen. These assert the order + // directly, on the function that has no awaits to separate anything. + const live = { status: "connected" } + const ours = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + const theirs = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"], enabled: true } + const unpinned = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + + test("intent outranks connectivity — a disabled entry is honoured while its client is live", () => { + expect(planForEntry({ entry: { ...ours, enabled: false }, observed: live }, "42", false).act).toBe("honour-disable") + }) + + test("intent outranks attribution — a disabled entry is honoured even when it is not ours", () => { + expect(planForEntry({ entry: { ...theirs, enabled: false }, observed: live }, "42", false).act).toBe("honour-disable") + }) + + test("attribution outranks connectivity — an unreachable entry is judged ours BEFORE being revived", () => { + // Whose engine is this, not how is it doing. Reviving one that is not ours + // spends a spawn on another client's process, and — with the old order — + // wedged the project on `connect-failed` forever, because the retry + // answered before the pin was consulted. + expect(planForEntry({ entry: theirs, observed: { status: "failed", error: "exit 1" } }, "42", false).act).toBe( + "replace-unattributable", + ) + // Ours and down IS revived: that is what the retry is for. + expect(planForEntry({ entry: ours, observed: { status: "failed", error: "exit 1" } }, "42", false).act).toBe( + "retry-connect", + ) + }) + + test("attribution outranks version — an entry pinned elsewhere is replaced, never probed", () => { + expect(planForEntry({ entry: theirs, observed: live }, "42", false)).toEqual({ + act: "replace-unattributable", + entry: "datamate start-stdio --datamate 9", + pinnedTo: "9", + }) + // An unpinned entry is equally unattributable: it follows its owner's active + // teammate, which this client does not control. + expect(planForEntry({ entry: unpinned, observed: live }, "42", false).act).toBe("replace-unattributable") + }) + + test("one retry, never two — the bound is an argument, not a branch", () => { + const failed = { status: "failed", error: "exit 1" } + expect(planForEntry({ entry: ours, observed: failed }, "42", false).act).toBe("retry-connect") + expect(planForEntry({ entry: ours, observed: failed }, "42", true)).toEqual({ act: "refuse-unreachable", error: "exit 1" }) + }) + + test("a dead URL is replaced rather than retried — only the IDE can restore its port", () => { + const url = { type: "remote", url: "http://localhost:7801/sse", enabled: true } + expect(planForEntry({ entry: url, observed: { status: "failed" } }, "42", false)).toEqual({ + act: "replace-unreachable-url", + url: "http://localhost:7801/sse", + }) + }) + + test("nothing registered is a spawn, and ours-and-live goes to the version check", () => { + expect(planForEntry({ entry: null, observed: undefined }, "42", false).act).toBe("spawn") + expect(planForEntry({ entry: ours, observed: live }, "42", false).act).toBe("check-version") + }) + + test("the decision is a value, not a promise — nothing can interleave inside it", () => { + const plan = planForEntry({ entry: ours, observed: live }, "42", false) as unknown as { then?: unknown } + expect(typeof plan.then).toBe("undefined") + }) + + test("an unreadable version is below the floor, because it cannot be shown to lock its pin", () => { + expect(clearsFloor(null)).toBe(false) + expect(clearsFloor("0.6.3")).toBe(false) + expect(clearsFloor(MIN_ENGINE_VERSION)).toBe(true) + expect(clearsFloor("1.0.0")).toBe(true) + }) +}) + +describe("INVARIANT — the config-writing repair primitive is unreachable", () => { + // `MCP.connect` persists `enabled: true` into whichever config owns the entry, + // so repairing a down IDE-written global entry wrote global config from a + // local decision — and a disable landing in its window was destroyed on disk + // with nothing to repair it. The flow revives with `add`, which writes nothing. + // + // Asserted at compile time rather than by scenario: the seam does not carry + // `connect` at all, so a future call cannot be written. + // The `@ts-expect-error` is the test — if someone puts the member back, it + // becomes unused and the build fails. + test("the seam does not expose it, so it cannot be called", () => { + const seam = syncInternals.mcp + // @ts-expect-error `connect` is deliberately absent from the MCP seam. + expect(seam?.connect).toBeUndefined() + }) +}) + +describe("INVARIANT — attribution asks the running engine, not only the config", () => { + // The config says what SHOULD run; MCP's spawn record says what IS running. + // They diverge whenever the file is rewritten after a client started: another + // process re-pinning a shared config, an IDE replacing the entry through + // MCP.add, a re-link. Judging on the config alone let every check agree with + // itself while the live client served another workspace's data — and its + // credentials — under this workspace's name. Nothing in-process could tell. + const ours = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + const theirs = { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"], enabled: true } + + test("a config that names us over a runtime that does not is NOT reused", () => { + const plan = planForEntry({ entry: ours, observed: { status: "connected" }, runtime: theirs }, "42", false) + expect(plan, "reused an engine that was started for another workspace").toMatchObject({ + act: "replace-unattributable", + pinnedTo: "5", + }) + }) + + test("agreement between the two is what earns a reuse", () => { + expect(planForEntry({ entry: ours, observed: { status: "connected" }, runtime: ours }, "42", false).act).toBe( + "check-version", + ) + }) + + test("no runtime record means nothing of ours is running, so the config decides alone", () => { + // Absent is not "mismatched": a key with no live client has no record, and + // the config is then the only evidence there is. + expect(planForEntry({ entry: ours, observed: { status: "connected" }, runtime: undefined }, "42", false).act).toBe( + "check-version", + ) + }) + + test("the whole-session case: a re-pin under a live engine is caught end to end", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + // Another process started this client for workspace 5 and then re-pinned the + // shared config to 42 — which is what this project is bound to, so the + // config agrees with the binding and always would have. + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] } as never + const outcome = await ensure("s1") + expect(outcome, "answered `reused` about a process serving another workspace").toMatchObject({ kind: "attached" }) + expect(h.added, "did not replace the misattributed engine").toHaveLength(1) + }) +}) + +describe("INVARIANT — never write what you cannot undo, and never stop waiting forever", () => { + test("an unreadable project config refuses to install rather than installing something it cannot undo", async () => { + // The restore reads the project file to learn what to put back. If that read + // fails and is reported as "no entry here", the undo REMOVES — so a + // transient read failure could delete the user's own entry as the undo of an + // attach meant to leave it alone. + const h = install({ statuses: [{}], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.projectEntry = async () => { + throw new Error("EACCES: permission denied") + } + const outcome = await ensure("s1") + expect(outcome.kind, "installed an engine it had no way to undo").toBe("connect-failed") + expect(h.persisted, "wrote config it could not restore").toHaveLength(0) + expect(h.added, "registered a client it could not undo").toHaveLength(0) + expect(h.toasts, "failed silently").toHaveLength(1) + }) + + test("a settled memo is re-validated inside the turn's wait, even after an earlier timeout", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + // Turn 1 gave up waiting. That must not silence the wait for every later + // turn: re-validating a settled memo is a status read and a config read with + // no spawn, and during that window the outcome reads as "not settled" — a + // consumer that fails open on it stops routing for the turn and says so. + sessionsForTests().get("s1")!.waitTimedOut = true + + // Deterministic on purpose: the first draft of this test observed a flag + // during the wait and passed with the defect reinstated, because the task + // had not reached the seam yet when the check ran. It proved the fixture. + // Elapsed time is the thing that actually differs — with the wait silenced, + // `whenAttached` returns before the re-validation has happened at all. + const previousEntry = syncInternals.existingEntry! + syncInternals.existingEntry = async (name: string) => { + await new Promise((r) => setTimeout(r, 25)) + return previousEntry(name) + } + const started = performance.now() + const pending = ensure("s1") + await whenAttached("s1", 2000) + const waited = performance.now() - started + await pending + expect(waited, "resolved the turn's tools without waiting for the memo re-validation").toBeGreaterThanOrEqual(20) + expect(settledOutcome("s1"), "no settled outcome at the point tools are resolved").toBeDefined() + expect(h.added, "re-validating a good memo spawned a second engine").toHaveLength(1) + }) +}) + +describe("INVARIANT — the last thing awaited before a mutation is the whole world check", () => { + // The mechanical form of "every await after a guard belongs to the guard's + // problem". Individual tests flip a binding at one seam and check one + // outcome; that only ever catches the seam someone thought of, which is why + // an await inserted after the final guard survived the whole suite, and why + // deleting a teardown's guard outright survived it too. + // + // This records the order seams are awaited in and asserts adjacency: for every + // binding-DEPENDENT mutation, the seam awaited immediately before it is the + // binding read. persist -> add is sanctioned as one commit, since the guard + // covers the pair. + // + // Binding-INDEPENDENT teardowns are deliberately out of scope: a disabled or + // below-floor engine is torn down whatever is bound, so requiring a binding + // read before those would assert the opposite of what they are for. The + // scenarios below exercise only paths whose mutations are binding-dependent. + const MUTATIONS = new Set(["persist", "add", "remove", "persistRestore"]) + + function traced(opts: Parameters[0]) { + const h = install(opts) + const trace: string[] = [] + const wrapRead = Promise>(name: string, fn: T) => + (async (...args: never[]) => { + const out = await fn(...args) + trace.push(name) + return out + }) as T + const wrapMutation = Promise>(name: string, fn: T) => + (async (...args: never[]) => { + trace.push(name) + return await fn(...args) + }) as T + + syncInternals.resolveBinding = wrapRead("resolveBinding", syncInternals.resolveBinding!) + syncInternals.existingEntry = wrapRead("existingEntry", syncInternals.existingEntry!) + syncInternals.projectEntry = wrapRead("projectEntry", syncInternals.projectEntry!) + syncInternals.declared = wrapRead("declared", syncInternals.declared!) + syncInternals.versionOf = wrapRead("versionOf", syncInternals.versionOf!) + syncInternals.projectConfigPath = wrapRead("projectConfigPath", syncInternals.projectConfigPath!) + syncInternals.notify = wrapRead("notify", syncInternals.notify!) + syncInternals.toolsChanged = wrapRead("toolsChanged", syncInternals.toolsChanged!) + syncInternals.persist = wrapMutation("persist", syncInternals.persist!) + syncInternals.persistRestore = wrapMutation("persistRestore", syncInternals.persistRestore!) + const m = syncInternals.mcp! + syncInternals.mcp = { + ...m, + status: wrapRead("status", m.status), + tools: wrapRead("tools", m.tools!), + spawned: m.spawned ? wrapRead("spawned", m.spawned) : undefined, + add: wrapMutation("add", m.add), + remove: wrapMutation("remove", m.remove), + } + return { h, trace } + } + + const scenarios: Array<[string, Parameters[0]]> = [ + ["a fresh spawn", { statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }], + [ + "replacing an entry pinned elsewhere", + { + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }, + ], + [ + // Its teardown is binding-INDEPENDENT and therefore exempt: a below-floor + // engine serves nobody correctly whatever is bound now. + "replacing an engine below the floor", + { + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + version: (bin: string) => (bin === "datamate" ? "0.6.5" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }, + ], + [ + "replacing an unpinned entry", + { + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }, + ], + ] + + // Scenarios declare whether their teardowns are binding-DEPENDENT, because the + // trace cannot tell them apart: undoing what this attach created, and stopping + // a disabled or below-floor engine, are right whatever the project is bound to + // now, so requiring a binding read before those would assert the opposite of + // what they are for. + // + // LIMIT, stated rather than left implicit: the exemption is per SCENARIO, not + // per teardown. It is precise today only because no single run() produces both + // a binding-dependent and a binding-independent teardown — if one ever does, + // this needs the reason threaded through the trace instead. + const bindingIndependent = new Set(["replacing an engine below the floor"]) + + for (const [name, opts] of scenarios) { + test(`${name}: every mutation is preceded by the world check`, async () => { + const bindingDependentRemoves = !bindingIndependent.has(name) + const { trace } = traced(opts) + await ensure("s1") + const offenders: string[] = [] + trace.forEach((step, i) => { + if (!MUTATIONS.has(step)) return + // The world check is TWO reads in a fixed order — binding, then intent — + // so the adjacency to assert is the pair, not one seam. Intent goes last + // deliberately: the only thing left between confirming intent and + // writing is the write's own read of the node it replaces. + const before = trace[i - 1] + const beforeThat = trace[i - 2] + if (step === "add" && before === "persist") return // one commit, one guard + // A WRITE needs the whole world: `enabled: false` forbids creating + // anything, so intent is part of the question. + if (step === "persist" || step === "add") { + // The BINDING read is the one that must be adjacent: intent has a + // second line of defence in the write's own same-text check, and the + // binding has none. + if (before === "resolveBinding" && beforeThat === "existingEntry") return + } else if (!bindingDependentRemoves || before === "resolveBinding") { + // A TEARDOWN only needs the binding. Intent neither authorises nor + // forbids stopping a client: a disabled entry is torn down regardless, + // and the only question a foreign entry raises is whether it belongs + // to the workspace we are now bound to. + return + } + offenders.push(`${step} followed ${beforeThat ?? "(nothing)"} -> ${before ?? "(nothing)"}`) + }) + expect(offenders, `${name}: ${offenders.join("; ")} — trace was ${trace.join(" -> ")}`).toEqual([]) + }) + } +}) + +describe("INVARIANT — the single exit survives a failure with no workspace to name", () => { + test("a throw BEFORE the binding resolves still announces, exactly once", async () => { + // The refusal exit is the single exit for exceptions too, and an exception + // can happen before there is any workspace identity — the flag read, the MCP + // handle and the serialization chain all precede the binding. Anything in + // that exit that assumes a workspace will crash here, on the one path with + // no natural fixture. + const h = install({}) + syncInternals.resolveBinding = async () => { + throw new Error("credentials unavailable") + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("connect-failed") + expect(h.toasts, "a failure with no workspace to name went unannounced, or announced twice").toHaveLength(1) + expect(h.toasts[0]!.message).toContain("credentials unavailable") + // Nothing was installed, so nothing needs undoing. + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + + test("a throw AFTER the binding resolves still announces exactly once", async () => { + const h = install({ statuses: [{}] }) + syncInternals.declared = async () => { + throw new Error("allowlist exploded") + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("connect-failed") + expect(h.toasts).toHaveLength(1) + }) +}) + +describe("INVARIANT #13 — a failed read is never an answer", () => { + // The class: a failure to LEARN something, encoded as a confident fact. It is + // invisible to every other invariant here, because they all test what happens + // when a read succeeds — ordering, completeness, staleness, adjacency. None + // asks what a function does when the read throws. + // + // A guard that fails open is worse than no guard, because its presence is what + // stops the next person looking. + + test("a guard whose intent read THROWS writes nothing", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + const good = syncInternals.existingEntry! + let reads = 0 + syncInternals.existingEntry = async (name: string) => { + reads += 1 + // The inspection succeeds; the guard's confirming read fails. + if (reads > 1) throw new Error("EIO: config unreadable") + return good(name) + } + const outcome = await ensure("s1") + expect(h.persisted, "wrote config without confirming the user still wants it").toHaveLength(0) + expect(h.added, "started an engine without confirming the user still wants it").toHaveLength(0) + // Reported, not silent, and reported the SAME way wherever the failure lands + // — the identical failure reaching the inspection is told to the user, so + // labelling this one a silent binding-move would give one failure two labels + // and two signal counts depending only on which read hit it. + expect(outcome.kind).toBe("connect-failed") + expect(h.toasts, "an unreadable configuration was handled silently").toHaveLength(1) + expect(h.toasts[0]!.message).toContain("Could not read") + }) + + test("a memo whose validating read THROWS is re-decided, not served", async () => { + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + + const good = syncInternals.existingEntry! + let failNext = true + syncInternals.existingEntry = async (name: string) => { + if (failNext) { + failNext = false + throw new Error("EIO: config unreadable") + } + return good(name) + } + // Serving the memo would mean answering with a world we could not confirm — + // a disabled entry or a moved pin riding a transient probe error, on the + // path every turn after the first takes. Re-deciding costs an inspection. + const first = settledOutcome("s1") + const second = await ensure("s1") + // The property is that the memo was not SERVED, not that the re-decision + // reaches a different verdict — re-deciding may well conclude reuse, and + // that is fine, because it concluded it from a world it could actually read. + // Identity is what separates "handed back the cached answer" from "worked it + // out again". + expect(second, "served a memo whose world could not be confirmed").not.toBe(first) + }) +}) + +describe("INVARIANT — announcing never changes what happened", () => { + test("a throwing success announcement leaves the engine attached and installed", async () => { + // The two announce awaits carry no no-throw guarantee at the seam; only the + // production bodies happen to swallow, and the region did not encode that. + // A throw here reported `connect-failed` for an engine that is attached, + // connected and persisted — the single toast telling the user the attach + // failed while the tools are in fact there. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }, + }) + syncInternals.toolsChanged = async () => { + throw new Error("event bus exploded") + } + const outcome = await ensure("s1") + expect(outcome.kind, "a failed announcement rewrote a successful attach").toBe("attached") + expect(h.removes, "a failed announcement undid a live attach").toHaveLength(0) + expect(h.added, "the engine was not installed").toHaveLength(1) + }) + + test("an undo that could not be confirmed is an actionable failure, not a silent one", async () => { + // `superseded` is silent because normally nothing is left behind. When the + // restore fails, our pin IS left behind and MCP bootstraps every enabled + // entry — so the next restart starts the workspace this attach walked away + // from, and nothing else will ever mention it. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.persistRestore = async () => { + h.restores.push(null) + return "failed" + } + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.toasts, "left our pin on disk and said nothing about it").toHaveLength(1) + expect(h.toasts[0]!.message, "did not say what was left behind or where").toContain("datamate") + }) +}) + +describe("INVARIANT — a rejected engine is detached even when the rejection is a failure to know", () => { + test("a probe that THROWS detaches and refuses, and says so once across turns", async () => { + // A probe throw that propagates reaches the catch-all BEFORE any teardown, + // so a persistent failure toasts every turn while the rejected client stays + // registered and serving — the outcome is advice, the registration is what + // the model sees. + const h = install({ + existing: { type: "local", command: ["/opt/datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }], + which: null, + }) + syncInternals.versionOf = async () => { + throw new Error("EACCES: cannot exec") + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("engine-too-old") + expect(h.removes, "left a rejected engine registered and serving").toContain("datamate") + expect(h.toasts).toHaveLength(1) + + // Repairable refusals are re-DECIDED every turn — that is how a repair gets + // noticed — but re-deciding is not a reason to re-TELL. The title of this + // test used to claim that and assert only the first turn; it asserts the + // claim now. + const second = await ensure("s1") + expect(second.kind).toBe("engine-too-old") + const third = await ensure("s1") + expect(third.kind).toBe("engine-too-old") + expect(h.toasts.length, "repeated an unchanged verdict on every turn").toBe(1) + }) + + test("a re-link during the version probes still detaches a below-floor engine", async () => { + // Binding-INDEPENDENT: an engine below the floor serves nobody correctly, + // whatever the project is bound to now. This branch kept the default and so + // skipped its teardown on a re-link, leaving a too-old client connected. + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + version: (bin) => (bin === "datamate" ? "0.6.5" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const previousVersion = syncInternals.versionOf! + syncInternals.versionOf = async (bin: string) => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return previousVersion(bin) + } + await ensure("s1") + expect(h.removes, "a below-floor engine survived a re-link still connected").toContain("datamate") + }) +}) + +describe("INVARIANT — the undo obeys the world it undoes into", () => { + test("a disable that lands while we hold the entry is kept, not undone", async () => { + // Between the install and the undo there is a whole engine boot, and a + // disable landing in that window lands on OUR entry. Restoring the + // pre-install state deletes the edit the user just made — and the next turn, + // finding no entry at all, spawns and re-enables. Round 4 arriving through + // the undo path. + let current: CachedBinding | null = binding + let projectNow: ExistingEntry | null = null + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => projectNow + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // The user switches the entry off during the boot window, and the binding + // moves, so the attach is superseded and must undo. + projectNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.restores, "the undo ran").toHaveLength(1) + const restored = h.restores[0] as ExistingEntry | null + expect(restored, "deleted the entry the user had just disabled").not.toBeNull() + expect(restored?.enabled, "undid the user's disable").toBe(false) + }) +}) + +describe("INVARIANT #13 as a property — every seam, made to throw", () => { + // Stated once over the whole seam list rather than as a handful of cases, + // because the defect this catches is not a wrong answer but a MISSING + // question: nothing else here asks what a function does when a read fails. + // Ordering, completeness, staleness and adjacency all test what happens when + // reads SUCCEED, so none of them can see this class at all. + // + // Three things must hold for every seam: + // 1. no mutation is performed on the strength of a failed read; + // 2. the session settles with an outcome — never a rejected promise, which + // the caller starts fire-and-forget and would therefore never see; + // 3. the user is told at most once, and never twice. + // + // NOTE THE LIMIT, because it is the same limit that hid the original defect: + // these throw from the SEAM, so they prove the CALLERS handle a failed read. + // They cannot see a reader that swallows beneath the seam and hands up a + // confident `null` — restoring exactly that swallow leaves every test here + // green. That layer is covered in `engine-config-freshness.test.ts`, which + // throws from the config module itself. A property is only as deep as the + // layer it is written at, and this class lives at whichever layer answers. + const SEAMS = [ + "resolveBinding", + "existingEntry", + "projectEntry", + "projectConfigPath", + "versionOf", + "declared", + "persist", + "persistRestore", + ] as const + + for (const seam of SEAMS) { + test(`${seam} throwing never becomes an answer`, async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const boom = async () => { + throw new Error(`${seam} exploded`) + } + ;(syncInternals as Record)[seam] = boom + + // (2) settles rather than rejecting + const outcome = await ensure("s1") + expect(outcome, `${seam}: the session never settled`).toBeDefined() + expect(typeof outcome.kind).toBe("string") + + // (1) a failed read never authorises a write + if (seam !== "persist" && seam !== "persistRestore") { + expect(h.persisted, `${seam}: wrote config on the strength of a failed read`).toHaveLength(0) + } + + // (3) told at most once + expect(h.toasts.length, `${seam}: told the user ${h.toasts.length} times`).toBeLessThanOrEqual(1) + }) + } + + for (const seam of ["status", "tools", "spawned"] as const) { + test(`mcp.${seam} throwing never becomes an answer`, async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + ;(syncInternals.mcp as unknown as Record)[seam] = async () => { + throw new Error(`${seam} exploded`) + } + const outcome = await ensure("s1") + expect(outcome, `mcp.${seam}: the session never settled`).toBeDefined() + expect(h.toasts.length, `mcp.${seam}: told the user ${h.toasts.length} times`).toBeLessThanOrEqual(1) + }) + } +}) + +describe("INVARIANT — an unbound project stays silent, whatever fails inside it", () => { + test("an unreadable config in a project with no binding does not announce, on any turn", async () => { + // The module is documented inert when nothing is linked, and most projects + // are not linked. The config reader propagates for the paths that DECIDE on + // it; this read produces a log line and nothing else, so a failure here must + // not escape to the catch-all and announce "attach failed" in a project that + // never wanted an attach — `connect-failed` is repairable, so it would + // announce again every turn. + const h = install({ + binding: null, + statuses: [{ datamate: { status: "connected" } }], + }) + syncInternals.existingEntry = async () => { + throw new Error("EIO: config unreadable") + } + for (const turn of [1, 2, 3]) { + const outcome = await ensure(`s${turn}`) + expect(outcome.kind, `turn ${turn}: an unbound project reported an attach failure`).toBe("unbound") + } + expect(h.toasts, "an unbound project announced something").toHaveLength(0) + }) +}) + +describe("INVARIANT — an unchanged verdict is announced once, a changed one speaks", () => { + // Repairable refusals re-enter the machine every turn by design: re-probing is + // how a repair gets noticed. Re-deciding is not a reason to re-tell, and the + // difference matters more once the toast becomes a dialog — one dialog per + // turn would be unusable. + test("three turns of the same verdict produce one signal", async () => { + const h = install({ which: null }) + for (const _ of [1, 2, 3]) expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts.length, "nagged on every turn about a verdict that had not changed").toBe(1) + }) + + test("a verdict that CHANGES is announced again", async () => { + const h = install({ which: null }) + expect((await ensure("s1")).kind).toBe("engine-missing") + // The user installs something, but it is too old — a different problem, and + // one they need to hear about. + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.5.9" + expect((await ensure("s1")).kind).toBe("engine-too-old") + expect(h.toasts.length, "a changed verdict was swallowed as a repeat").toBe(2) + }) + + test("after a repair succeeds, the next problem is heard again", async () => { + const h = install({ + which: null, + statuses: [ + {}, + {}, + { datamate: { status: "connected" } }, + { datamate: { status: "failed", error: "exit 1" } }, + { datamate: { status: "failed", error: "exit 1" } }, + ], + tools: { datamate_dbt_build_model: 1 }, + }) + expect((await ensure("s1")).kind).toBe("engine-missing") + // Repair. + syncInternals.which = () => "/usr/local/bin/datamate" + expect((await ensure("s1")).kind).toBe("attached") + // The engine then dies AND the binary goes away — the same problem as turn + // one, and news again, because it was fixed in between. + syncInternals.which = () => null + expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts.filter((t) => t.title.includes("unavailable")).length, "silenced a problem that had returned").toBe( + 2, + ) + }) +}) + +describe("INVARIANT — the coverage the mutants demanded", () => { + test("a disable inside the boot window is caught even when the binding never moves", async () => { + // The only test that staged a disable during the boot window ALSO flipped + // the binding, so the post-install guard's intent half was never the thing + // doing the work — the binding half would have caught it either way. + let projectNow: ExistingEntry | null = null + let entryNow: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.existingEntry = async () => entryNow + syncInternals.projectEntry = async () => projectNow + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // The user switches it off while the engine boots. The binding is + // untouched, so only the intent half of the guard can see this. + entryNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } + projectNow = entryNow + } + const outcome = await ensure("s1") + expect(outcome.kind, "the guard's intent half was not load-bearing").toBe("entry-disabled") + expect(h.removes, "left a disabled engine registered").toContain("datamate") + }) + + test("an in-region refusal tears down BEFORE it announces", async () => { + // The `finally` would undo either way, so the ORDER was unpinned — and the + // order is the point: the announcement is a substitution point, and a body + // that waits on a person would hold a failed engine's registration and its + // pin for as long as the dialog is open. + const order: string[] = [] + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + const prevRemove = syncInternals.mcp!.remove + syncInternals.mcp!.remove = async (name: string) => { + order.push("teardown") + return prevRemove(name) + } + syncInternals.notify = async (toast) => { + order.push("announce") + h.toasts.push(toast) + } + await ensure("s1") + expect(order.indexOf("teardown"), "announced before it stopped serving").toBeLessThan(order.indexOf("announce")) + }) + + test("the undo restores through the path the write used, not one it resolves again", async () => { + // Re-resolving can pick a different file than the one we wrote to, in which + // case the undo edits a config we never touched and leaves the one we did. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.restorePaths, "the undo resolved its own path instead of using the write's").toEqual([ + "/tmp/test/.altimate-code/altimate-code.json", + ]) + }) + + test("a FIRST-read-only failure at the inspection does not plan as 'nothing here'", async () => { + // The seam property throws on every read, so the guard stops the write and + // the property passes without the inspection's handling ever mattering. With + // only the first read failing, planning a failed read as "no entry" writes. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const good = syncInternals.existingEntry! + let reads = 0 + syncInternals.existingEntry = async (name: string) => { + reads += 1 + if (reads === 1) throw new Error("EIO: first read only") + return good(name) + } + const outcome = await ensure("s1") + expect(h.persisted, "planned a failed inspection read as 'nothing here' and wrote").toHaveLength(0) + expect(h.added, "planned a failed inspection read as 'nothing here' and spawned").toHaveLength(0) + expect(outcome.kind).toBe("connect-failed") + }) +}) + +describe("INVARIANT — a revive is an install and owns its undo", () => { + test("a throw after a successful revive removes the client we started", async () => { + // One external failure, not two: the revive succeeds and the very next read + // throws. That must not reach the catch-all with the client WE just started + // still registered and serving — the outcome would say failed while the + // registration says otherwise, and the registration is what the model sees. + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + }) + const good = syncInternals.existingEntry! + let reads = 0 + syncInternals.existingEntry = async (name: string) => { + reads += 1 + // Reads: inspection (1), the pre-revive guard's intent read (2), then the + // re-inspection — which is the one that fails. + if (reads === 3) throw new Error("EIO: re-inspection failed") + return good(name) + } + const outcome = await ensure("s1") + expect(h.added, "the revive happened").toHaveLength(1) + expect(h.removes, "left the engine this attach started registered and serving").toContain("datamate") + expect(outcome.kind).toBe("connect-failed") + }) +}) + +describe("INVARIANT — an undo that fails is never silent, however it fails", () => { + test("a persistRestore that THROWS does not become a silent superseded", async () => { + // The undo reports failure by returning "failed"; a throw is the other way + // it can fail, and the catch around it is load-bearing precisely because + // nothing else would notice. Dropping that catch turns a left-behind pin + // into a quiet `superseded`. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.persistRestore = async () => { + throw new Error("EROFS: read-only file system") + } + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.toasts, "an undo that threw left a pin on disk and said nothing").toHaveLength(1) + expect(h.toasts[0]!.title).toContain("left behind") + }) + + test("two distinct failures are two signals; one failure is one", async () => { + // The dedupe is by VERDICT, not by turn, so a second and different failure + // must still be heard — otherwise deduplication becomes suppression. + const h = install({ which: null }) + await ensure("s1") + await ensure("s1") + expect(h.toasts.length, "one unchanged failure spoke more than once").toBe(1) + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => null + await ensure("s1") + expect(h.toasts.length, "a second, different failure was swallowed as a repeat").toBe(2) + }) +}) + +describe("INVARIANT — identity and paths are resolved once", () => { + test("a re-link is not silenced by the same refusal about the workspace it left", async () => { + // The dedupe record is carried across a re-link, so without the workspace in + // the key an identical-kind refusal about A silences B — and the user is + // left holding guidance that names a workspace they have left. + let current: CachedBinding | null = binding + const h = install({ which: null }) + syncInternals.resolveBinding = async () => current + expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts).toHaveLength(1) + + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts.length, "the new workspace's refusal was swallowed as a repeat of the old one").toBe(2) + expect(h.toasts[1]!.message).toContain("other") + }) + + test("the snapshot, the write and the undo all use one resolved path", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + const seen: Array = [] + syncInternals.resolveBinding = async () => current + syncInternals.projectConfigPath = async () => "/tmp/one/.altimate-code/altimate-code.json" + syncInternals.projectEntry = async (configPath?: string) => { + seen.push(configPath) + return null + } + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + // Resolving twice lets the snapshot come from one file while the write goes + // to another, after which the undo restores the first file's entry into the + // second — over whatever the user had there. + // The snapshot, the write and the undo must all name the same file. + // Both reads — the snapshot before the write and the undo's own re-read — + // name the file the write will use. + expect(new Set(seen), "a project read used a path resolved separately").toEqual( + new Set(["/tmp/one/.altimate-code/altimate-code.json"]), + ) + expect(seen.length).toBeGreaterThan(0) + expect(h.restorePaths, "the undo used a path other than the one the write used").toEqual([ + "/tmp/one/.altimate-code/altimate-code.json", + ]) + }) +}) + +describe("an undo only undoes its own work", () => { + test("a config rewritten to a new command while we held it is not rolled back", async () => { + // A disable is not the only edit that can land in the boot window: an IDE + // writing a new command or URL is newer than our pin, and rolling it back + // discards a change the user made deliberately. + let current: CachedBinding | null = binding + let projectNow: ExistingEntry | null = null + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => projectNow + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // An IDE rewrites the entry to its own transport, and the binding moves. + projectNow = { type: "remote", url: "http://localhost:7801/sse", enabled: true } + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.restores, "rolled back over an edit that was not ours").toHaveLength(0) + }) + + test("a client replaced by another caller while we held it is not closed", async () => { + // The MCP route and the IDE's reload both call `MCP.add` outside this + // flow's serialization. Removing unconditionally closes whatever is there — + // which, after such a replacement, is the engine someone else just asked + // for, left disconnected with its tools gone. + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // Someone else replaces the client, then the binding moves. + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] } as never + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.removes, "closed a client another caller had just installed").toHaveLength(0) + }) +}) + +describe("INVARIANT — the floor is asked of the engine that is running", () => { + test("a newly configured modern command does not vouch for a still-running old engine", async () => { + // A config edit can change the command while the existing client stays + // connected, so the two can carry the same pin and be different binaries. + // Probing the CONFIGURED one then lets a fresh 0.7 command authorise reuse + // of a running pre-0.7 engine — which does not lock its pin, and can drift + // to another workspace while we report this one. The pin and the floor are + // one mechanism, so both are asked of the same thing. + const h = install({ + existing: { type: "local", command: ["/new/datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + version: (bin) => (bin.startsWith("/old") ? "0.6.5" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }) + // What is actually running is the OLD binary, same pin. + h.spawnedNow = { type: "local", command: ["/old/datamate", "start-stdio", "--datamate", "42"] } as never + const outcome = await ensure("s1") + expect(outcome.kind, "reused a pre-floor engine on the strength of a newer configured command").not.toBe("reused") + expect(h.removes, "left the pre-floor engine registered").toContain("datamate") + }) +}) + +describe("INVARIANT — a memo is validated against the engine that is running", () => { + test("editing the config to a modern binary does not validate a running pre-floor engine", async () => { + // The same question as the fresh path, on the path every later turn takes. + // A memo attached to a running pre-floor engine, then a config edit to a + // floor-clearing command under the same pin: probing the CONFIG command + // clears the floor, records it as validated, and the running pre-floor + // engine — which does not lock its pin — keeps serving for the session. + const h = install({ + existing: { type: "local", command: ["/old/datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [ + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + { datamate: { status: "connected" } }, + ], + version: (bin) => (bin.startsWith("/old") ? "0.7.0" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }) + h.spawnedNow = { type: "local", command: ["/old/datamate", "start-stdio", "--datamate", "42"] } as never + expect((await ensure("s1")).kind).toBe("reused") + + // The running engine is now known to be pre-floor, and the config is edited + // to a modern binary under the same pin. + syncInternals.versionOf = async (bin: string) => (bin.startsWith("/old") ? "0.6.5" : "0.7.0") + syncInternals.existingEntry = async () => + ({ type: "local", command: ["/new/datamate", "start-stdio", "--datamate", "42"], enabled: true }) as never + const second = await ensure("s1") + expect(second.kind, "served a memo for a running pre-floor engine").not.toBe("reused") + expect(h.removes, "left the pre-floor engine registered and serving").toContain("datamate") + }) +}) + +describe("INVARIANT — there is one place that answers 'the engine that is running'", () => { + // Not a behaviour test. The same question was asked correctly at one site and + // incorrectly at the site beside it twice over, and both times the second site + // was found by someone reading the two together — not by the person fixing the + // first. A shared EXPRESSION invites that; a shared FUNCTION does not, because + // there is no second place to write it. + // + // So this asserts the shape rather than an outcome: the fallback expression + // appears once, inside the accessor, and every other site calls it. + test("the runtime-or-config fallback is written exactly once, in the accessor", async () => { + const { readFileSync } = await import("node:fs") + const source = readFileSync( + new URL("../../../src/altimate/workspace/engine-sync.ts", import.meta.url).pathname, + "utf8", + ) + const occurrences = source.split("\n").filter((l) => /inspection\.runtime\s*\?\?/.test(l)) + expect( + occurrences.length, + `the runtime-or-config fallback is written ${occurrences.length} times; it belongs only in runningEngine()`, + ).toBe(1) + expect( + source.includes("export function runningEngine(inspection: Inspection)"), + "the accessor every running-engine question goes through is missing", + ).toBe(true) + expect( + source.includes("export function configuredEntry(inspection: Inspection)"), + "the mirror accessor is missing, which leaves the other question unnamed", + ).toBe(true) + + // And no site reads either field bare. Naming only one of the two questions + // would leave the other implicit, which is the condition this class of + // defect grows in — a field access whose meaning has to be inferred from + // what happens to surround it. + const bare = source + .split("\n") + .map((l, i) => [i + 1, l] as const) + .filter(([, l]) => /inspection\.(entry|runtime)\b/.test(l)) + .filter(([, l]) => !/return inspection\.runtime \?\? inspection\.entry|return inspection\.entry/.test(l)) + expect( + bare.map(([n, l]) => `${n}: ${l.trim()}`), + "these read the inspection's fields directly instead of asking a named question", + ).toEqual([]) + }) + + test("the accessor prefers what is running and falls back to what is configured", () => { + const configured = { type: "local", command: ["/new/datamate", "start-stdio", "--datamate", "42"] } + const running = { type: "local", command: ["/old/datamate", "start-stdio", "--datamate", "42"] } + expect(runningEngine({ entry: configured, observed: undefined, runtime: running })).toBe(running) + // Nothing of ours running: the configured entry is the only evidence there is. + expect(runningEngine({ entry: configured, observed: undefined, runtime: undefined })).toBe(configured) + }) +}) + +describe("INVARIANT — identity covers everything that changes the process", () => { + test("an edit to the environment under unchanged argv is not rolled back", async () => { + // `environment`, `cwd` and `timeout` all change the process an entry + // describes. Comparing argv alone reads such an edit as "still the entry I + // wrote", so the undo reverts it while believing it is reverting its own + // write — the same wrongness as rolling back a changed command, arriving + // through a field the comparison did not look at. + let current: CachedBinding | null = binding + let projectNow: ExistingEntry | null = null + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + syncInternals.projectEntry = async () => projectNow + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + await prevAdd(n, cfg) + // Same argv as ours, different environment — a deliberate edit. + projectNow = { + ...(cfg as unknown as ExistingEntry), + environment: { DATAMATE_LOG: "debug" }, + } as unknown as ExistingEntry + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(h.restores, "rolled back an environment edit it had not made").toHaveLength(0) + }) +}) + +describe("INVARIANT — identity normalises every field that changes the process", () => { + // `removeIfOurs` and the undo both decide from `sameEntry`. A field it does + // not look at makes two different entries compare equal, and the caller then + // destroys or restores something that is not its own while believing it is. + // + // Each case changes exactly one field and asserts the comparison notices. + const base = { + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + environment: { A: "1" }, + cwd: "/work", + timeout: 5000, + } as unknown as ExistingEntry + + const variants: Array<[string, ExistingEntry]> = [ + ["command", { ...base, command: ["datamate", "start-stdio", "--datamate", "9"] } as ExistingEntry], + ["environment", { ...(base as object), environment: { A: "2" } } as unknown as ExistingEntry], + ["cwd", { ...(base as object), cwd: "/elsewhere" } as unknown as ExistingEntry], + ["timeout", { ...(base as object), timeout: 9000 } as unknown as ExistingEntry], + ["type/url", { type: "remote", url: "http://localhost:7801/sse" } as unknown as ExistingEntry], + ] + + for (const [field, changed] of variants) { + test(`a change to ${field} is not the same entry`, () => { + expect(sameEntry(base, changed), `${field} is invisible to the comparison`).toBe(false) + }) + } + + test("the same entry from a different source still compares equal", () => { + // What comes back from disk or from MCP is a different object with the same + // meaning, so the comparison is by value. + expect(sameEntry(base, JSON.parse(JSON.stringify(base)) as ExistingEntry)).toBe(true) + }) + + test("intent is not identity: enabled is deliberately excluded", () => { + // A disabled entry is still the same entry. Intent is handled by the branch + // above the comparison, which keeps the disable rather than rolling it back; + // folding it in here would make a disable read as "someone else's entry" and + // take a different path for the same reason. + expect(sameEntry(base, { ...(base as object), enabled: false } as unknown as ExistingEntry)).toBe(true) + }) +}) + +describe("INVARIANT — the version probe runs where the engine would run", () => { + test("the entry's own environment and working directory reach the probe", async () => { + // A bare `datamate` under a custom `environment.PATH` resolves to a + // different binary than this process's PATH does. Probing here rather than + // there lets a modern binary we happen to have approve the pre-floor engine + // the entry actually selects — and that engine does not lock its pin. A + // relative command with a configured `cwd` is resolved from the wrong + // directory for the same reason. + const seen: Array<{ environment?: Record; cwd?: string } | undefined> = [] + const h = install({ + existing: { + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + environment: { PATH: "/opt/pinned/bin" }, + cwd: "/work/project", + enabled: true, + } as never, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.versionOf = async (_bin: string, spawn?: { environment?: Record; cwd?: string }) => { + seen.push(spawn) + return "0.7.0" + } + await ensure("s1") + expect(seen[0]?.environment, "probed with this process's environment, not the entry's").toEqual({ + PATH: "/opt/pinned/bin", + }) + expect(seen[0]?.cwd, "probed from the wrong directory").toBe("/work/project") + void h + }) +}) + +describe("INVARIANT — a hosted datamate serving alongside us is surfaced, once", () => { + const hostedConnected = { + datamate: { status: "connected" }, + "datamate-acme": { status: "connected" }, + } + + function withHosted(extra: Record = {}) { + const statuses = [{ ...hostedConnected, ...extra }, { ...hostedConnected, ...extra }, { ...hostedConnected, ...extra }] + return install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: statuses as never, + tools: { datamate_dbt_build_model: 1 }, + }) + } + + test("three turns with the same hosted set produce one signal", async () => { + const h = withHosted() + for (const _ of [1, 2, 3]) await ensure("s1") + const notes = h.toasts.filter((t) => t.title.includes("Another datamate")) + expect(notes.length, `told the user ${notes.length} times about an unchanged set`).toBe(1) + expect(notes[0]!.message).toContain("datamate-acme") + }) + + test("a change to the hosted set is announced again", async () => { + const h = withHosted() + await ensure("s1") + // A second standalone server appears, and the memo is no longer valid — so + // this turn re-decides and sees the new set. + syncInternals.mcp!.status = async () => + ({ ...hostedConnected, "datamate-beta": { status: "connected" } }) as never + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] } as never + await ensure("s1") + expect(h.toasts.filter((t) => t.title.includes("Another datamate")).length).toBe(2) + expect(h.toasts.filter((t) => t.title.includes("Another datamate"))[1]!.message).toContain("datamate-beta") + }) + + test("the note is attached to a decision, so a memoised turn does not repeat or refresh it", async () => { + // Named rather than hidden: the signal rides the flow's decisions, so a set + // that changes while a memo stays valid is surfaced at the next + // re-decision, not the moment it changes. That is the cost of not adding a + // read to every turn for a warning. + const h = withHosted() + await ensure("s1") + syncInternals.mcp!.status = async () => + ({ ...hostedConnected, "datamate-beta": { status: "connected" } }) as never + await ensure("s1") // memo still valid — no re-decision, so no new note + expect(h.toasts.filter((t) => t.title.includes("Another datamate")).length).toBe(1) + }) + + test("no hosted server means no signal at all", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(h.toasts.filter((t) => t.title.includes("Another datamate"))).toHaveLength(0) + }) + + test("it is a second signal, not a rewrite of the attach toast", async () => { + // Two different things happened — an attach, and an ambiguity about whose + // tools the model is holding — so the user gets two signals. The rule is one + // signal per event, not one element per screen. + const h = withHosted() + await ensure("s1") + const titles = h.toasts.map((t) => t.title) + expect(titles.some((t) => t.includes("Another datamate")), "the ambiguity went unmentioned").toBe(true) + expect(titles.length, "the two events did not produce two signals").toBeGreaterThanOrEqual(2) + }) +}) + +describe("INVARIANT — what is committed is what we installed", () => { + test("a client replaced during the post-install awaits is not reported as ours", async () => { + // The status and tool reads are two awaits, and the MCP route and the IDE's + // reload both call `MCP.add` outside this flow's serialization. A + // replacement landing there is what serves the turn — so committing without + // asking reports the bound workspace as served by a client that may be + // unpinned or pinned elsewhere, whose tools and credentials then reach the + // model. + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + const prevTools = syncInternals.mcp!.tools! + syncInternals.mcp!.tools = async () => { + // Someone else replaces the client while we are listing tools. + h.spawnedNow = { type: "local", command: ["datamate", "start-stdio", "--datamate", "9"] } as never + return prevTools() + } + const outcome = await ensure("s1") + expect(outcome.kind, "reported a replacement as the bound workspace's engine").toBe("superseded") + }) + + test("a revive restarts the entry with its own environment and working directory", async () => { + // `environment`, `cwd` and `timeout` are what the configured engine was + // meant to run under — a custom PATH may be the only place its binary + // exists. Reviving with a flattened argv restarts a different process than + // the one that failed. + const h = install({ + existing: { + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + environment: { PATH: "/opt/pinned/bin" }, + cwd: "/work/project", + timeout: 12_000, + enabled: true, + } as never, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + const revived = h.added[0]?.cfg as unknown as Record + expect(revived?.environment, "revived with this process's environment").toEqual({ PATH: "/opt/pinned/bin" }) + expect(revived?.cwd, "revived from the wrong directory").toBe("/work/project") + expect(revived?.timeout, "revived with the default timeout").toBe(12_000) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/launch-resolve.test.ts b/packages/opencode/test/altimate/workspace/launch-resolve.test.ts index 21ecd4fc9e..5dec92fc54 100644 --- a/packages/opencode/test/altimate/workspace/launch-resolve.test.ts +++ b/packages/opencode/test/altimate/workspace/launch-resolve.test.ts @@ -144,7 +144,7 @@ describe("resolveWorkspaceForLaunch", () => { expect(getResolvedWorkspaceId()).toBe(42) }) - test("mismatched name → env var STILL set (attaches to linked workspace with a note per AI-8504 spec)", async () => { + test("a mismatched name still attaches to the linked workspace, with a note", async () => { await resolveWorkspaceForLaunch(DIRECTORY, "Other") expect(getResolvedWorkspaceId()).toBe(42) }) diff --git a/packages/opencode/test/altimate/workspace/mutation-guards.test.ts b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts new file mode 100644 index 0000000000..311e47b344 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/mutation-guards.test.ts @@ -0,0 +1,776 @@ +// altimate_change - new file +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig, planForEntry, installWouldHelp, whenAttached, settledOutcome } from "../../../src/altimate/workspace/engine-sync" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +describe("the world check sits adjacent to every mutation", () => { + const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + + const A: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + const B: CachedBinding = { ...A, datamateId: 99, datamateName: "other" } as CachedBinding + + type Harness = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: Array<{ title: string; message: string; variant: string }> + restores: unknown[] + statusQueue: Array> + tools: Record + /** Every awaited seam, in call order, with the binding it observed. */ + trace: string[] + current: CachedBinding | null + } + + function install(opts: { + which?: string | null + version?: string | null | ((bin: string) => string | null) + statuses?: Harness["statusQueue"] + tools?: Record + existing?: ExistingEntry | null + }): Harness { + const h: Harness = { + added: [], + persisted: [], + connects: [], + removes: [], + toasts: [], + restores: [], + statusQueue: opts.statuses ?? [{}], + tools: opts.tools ?? {}, + trace: [], + current: A, + } + const seam = (name: string) => h.trace.push(name) + syncInternals.resolveBinding = async () => (seam("resolveBinding"), h.current) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => { + seam("versionOf") + if (typeof opts.version === "function") return opts.version(bin) + return opts.version === undefined ? "0.7.0" : opts.version + } + syncInternals.declared = async () => (seam("declared"), { keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { + seam("persist") + h.persisted.push({ name, cfg }) + } + syncInternals.projectEntry = async () => (seam("projectEntry"), null) + syncInternals.existingEntry = async () => { + seam("existingEntry") + // Mirrors production: once this attach has written, the entry on disk is + // OURS, and later reads see that rather than the pre-install value. A stub + // that keeps returning the starting entry models a file that never + // received the write — which is invisible to a test until something starts + // asking whether what is installed is still its own. + const last = h.persisted[h.persisted.length - 1] + if (last) return { ...(last.cfg as unknown as ExistingEntry) } + return opts.existing !== undefined ? opts.existing : null + } + syncInternals.notify = async (toast) => { + seam("notify") + h.toasts.push(toast) + } + syncInternals.toolsChanged = async () => { + seam("toolsChanged") + } + syncInternals.persistRestore = async (_name, previous) => { + seam("persistRestore") + h.restores.push(previous ?? null) + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (seam("status"), h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + seam("add") + h.added.push({ name, cfg }) + }, + remove: async (name) => { + seam("remove") + h.removes.push(name) + }, + tools: async () => (seam("tools"), h.tools), + } + return h + } + + beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() + }) + + afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG + }) + + // --------------------------------------------------------------------------- + // T1 — the property the author names, tested as a property: the seam awaited + // IMMEDIATELY before every mutation must be the binding read. Catches any + // awaited seam inserted between the guard and persist/add/remove/connect, + // which the existing first-call-flip tests cannot (they flip before the guard). + // --------------------------------------------------------------------------- + describe("the last awaited seam before every mutation is the world check", () => { + const MUTATIONS = new Set(["persist", "add", "remove", "connect", "persistRestore"]) + + /** Which teardowns in a scenario are binding-DEPENDENT. + * + * The split is the point: a teardown that undoes what this attach created, or + * that stops a disabled or below-floor engine, is right whatever the project + * is bound to now — requiring a binding read before those would assert the + * opposite of what they are for. Only acting on a pre-existing entry we did + * not create depends on the binding. Scenarios declare which kind they + * exercise, because the trace cannot tell them apart. */ + function violations(trace: string[], removesAreBindingDependent = true): string[] { + const out: string[] = [] + for (let i = 0; i < trace.length; i++) { + if (!MUTATIONS.has(trace[i])) continue + // Walk back to the previous non-mutation seam. + let j = i - 1 + while (j >= 0 && MUTATIONS.has(trace[j])) j-- + const before = trace[j] + const beforeThat = trace[j - 1] + // persist→add is the one sanctioned adjacency (persist has no seam of its own + // to re-read after); everything else must sit directly on the world check. + if (trace[i] === "add" && trace[i - 1] === "persist") continue + // the world check is now TWO reads in a fixed order — + // binding, then intent — because a guard that confirms only the binding is + // a guard on half the world. Intent goes last so the only thing between + // confirming it and the write is the write's own read of the node it + // replaces, which checks again where nothing can intervene. + // A WRITE needs the whole world (intent forbids creating anything); a + // TEARDOWN needs only the binding, since intent neither authorises nor + // forbids stopping a client. + const isWrite = trace[i] === "persist" || trace[i] === "add" + if (isWrite && before === "resolveBinding" && beforeThat === "existingEntry") continue + if (!isWrite && !removesAreBindingDependent) continue + if (!isWrite && before === "resolveBinding") continue + out.push(`${trace[i]} at #${i} follows ${beforeThat ?? ""} -> ${before ?? ""}`) + } + return out + } + + test("fresh spawn", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) + + test("replace an unpinned live entry", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) + + // Its teardown is binding-INDEPENDENT: an engine below the floor serves + // nobody correctly whatever is bound now. + test("pinned-but-below-floor, PATH newer", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + version: (bin) => (bin === "datamate" ? "0.6.5" : "0.7.0"), + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(violations(h.trace, false), h.trace.join(" > ")).toEqual([]) + }) + + test("retry-connect of a down command entry", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(violations(h.trace), h.trace.join(" > ")).toEqual([]) + }) + }) + + // --------------------------------------------------------------------------- + // T2 — retry-connect on a stale binding, then the refusal skips teardown + // because the binding is stale: the engine THIS attach brought up stays. + // --------------------------------------------------------------------------- + describe("reviving an engine is a guarded mutation", () => { + test("a re-link before the retry: the engine we reconnected is left serving under the new binding", async () => { + const h = install({ + // Pinned to 42, down, and (once revived) below the floor; PATH no better. + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + version: () => "0.6.5", + }) + // The re-link lands while the config is being read — before the retry. + syncInternals.existingEntry = async () => { + h.trace.push("existingEntry") + h.current = B + return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + } + const outcome = await ensure("s1") + // It is never started now: the retry is a guarded mutation, so a binding that + // moved before it means we abandon rather than start-then-undo. Nothing + // brought up is strictly better than something brought up and removed. + expect(h.connects, "reconnected an entry for a workspace the project had already left").toEqual([]) + expect(h.added, "started an engine for a workspace the project had already left").toHaveLength(0) + expect(outcome.kind).toBe("superseded") + }) + + test("a re-link DURING the retry's connect window: same result", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + version: () => "0.6.5", + }) + // The retry re-adds rather than connecting, so the window a + // re-link can land in is `add`, not `connect`. + const previousAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (name, cfg) => { + h.trace.push("add") + h.current = B // a TUI re-link inside the restart is the likely timing + return previousAdd(name, cfg) + } + const outcome = await ensure("s1") + // The engine THIS attach brought up is torn down whatever is bound now — + // undoing what we created is binding-independent by definition. + expect(h.removes, "the engine this attach brought up was left connected under binding 99").toContain("datamate") + expect(outcome.kind).toBe("superseded") + }) + }) + + // --------------------------------------------------------------------------- + // T3 — production persist() awaits ~10 fs operations (resolveConfigPath's + // exists() loop, addMcpToConfig's exists+readText) before its write and before + // MCP.add. Model ONE of them in the seam and flip inside it. + // --------------------------------------------------------------------------- + describe("no await separates the final check from the write it guards", () => { + test("a re-link inside persist's config-path probe still spawns the old workspace's engine", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + // The config-path probe — up to nine `exists` calls — is no + // longer inside the write: it is resolved ABOVE the guard and handed in, so + // this models it where it now lives. That is the fix; flipping inside the + // resolved-path lookup must be caught by the guard, not undone after it. + syncInternals.projectConfigPath = async () => { + h.trace.push("resolveConfigPath") + await Promise.resolve() // Filesystem.exists(candidate) #1 of up to 9 + h.current = B + return "/tmp/test/.altimate-code/altimate-code.json" + } + const outcome = await ensure("s1") + // Round 19's own standard: the late guard undoing it is the failure, not the fix. + expect(h.added.filter((a) => a.cfg.command.includes("42")), "spawned workspace 42's engine after the re-link").toHaveLength(0) + expect(h.persisted, "wrote workspace 42's pin after the re-link").toHaveLength(0) + expect(outcome.kind).toBe("superseded") + }) + + test("a re-link inside the WRITE itself is undone rather than prevented — the named residual", async () => { + // Nothing can guard the inside of the write. What must hold is that the + // region gives back both halves of what it took. + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + h.current = B + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("superseded") + expect(h.removes, "left the old workspace's engine registered").toContain("datamate") + expect(h.restores.length, "left the old workspace's pin on disk").toBeGreaterThan(0) + }) + }) + + // --------------------------------------------------------------------------- + // T4 — answered after awaits that follow the final guard (announce, notify). + // --------------------------------------------------------------------------- + describe("the attached answer is true when it is given, not only when it was fixed", () => { + test("a re-link during announceToolsChanged is not answered `attached` for the old workspace", async () => { + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.toolsChanged = async () => { + h.trace.push("toolsChanged") + h.current = B + } + const outcome = await ensure("s1") + // The answer is fixed before the announcements and GIVEN after them, and + // the announcements are awaits. The world is asked once more after the + // last of them: a re-link landing inside is undone and answered + // `superseded`, so this turn never holds `attached` for a workspace the + // project has left. The toast was true when it was shown; no second one. + expect(outcome.kind).toBe("superseded") + expect(h.removes, "left the old workspace's engine serving under the new binding").toEqual(["datamate"]) + expect(h.restores).toHaveLength(1) + // Superseded is repairable: the next turn attaches for the new binding. + const second = await ensure("s1") + expect(second.kind).toBe("attached") + expect(h.added.at(-1)?.cfg.command, "did not re-attach for the new binding").toEqual([ + "datamate", + "start-stdio", + "--datamate", + "99", + ]) + }) + }) + + // --------------------------------------------------------------------------- + // T5 — the skip-teardown in detachRejected applies to binding-INDEPENDENT + // teardowns too: a disabled entry keeps serving for this turn after a re-link. + // --------------------------------------------------------------------------- + describe("a disabled entry is torn down whatever is bound now", () => { + test("re-link during the status read: the disabled-but-connected client is left serving", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + syncInternals.mcp!.status = async () => { + h.trace.push("status") + h.current = B + return { datamate: { status: "connected" } } + } + const outcome = await ensure("s1") + // The teardown is the property under test: a + // disabled entry is disabled for every workspace, so its teardown does not + // consult the binding. The ANSWER is now `superseded` rather than + // `entry-disabled`, because a refusal is an answer too and this one would + // otherwise describe — and toast about — a workspace the project has left. + expect(outcome.kind).toBe("superseded") + expect(h.removes, "a disabled entry is disabled for every workspace; its teardown does not depend on the binding").toContain("datamate") + }) + }) +}) + +describe("a mutation is never made on a world that has moved", () => { + const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + + type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: string[] + statusQueue: Array> + reads: Array + probes: string[] + } + + function install(statuses: H["statusQueue"], entry: () => ExistingEntry | null): H { + const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [], statusQueue: statuses, reads: [], probes: [] } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async (bin) => { + h.probes.push(bin) + return "0.7.0" + } + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + syncInternals.existingEntry = async () => { + const e = entry() + h.reads.push(e?.enabled) + return e + } + syncInternals.notify = async (t) => { + h.toasts.push(t.title) + } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + syncInternals.projectEntry = async () => null + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + }, + remove: async (name) => { + h.removes.push(name) + }, + tools: async () => ({ datamate_dbt_build_model: 1 }), + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + return h + } + + beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() + }) + afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + }) + + describe("a disable landing inside the revive window", () => { + test("the revive re-inspects both halves, so a disable that survives on disk is honoured", async () => { + let enabled = true + const h = install( + [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + // The retry re-adds instead of connecting. + const previousAddA = syncInternals.mcp!.add + syncInternals.mcp!.add = async (name, cfg) => { + enabled = false + return previousAddA(name, cfg) + } + const outcome = await ensure("s1") + expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) + expect(h.reads, "inspection, pre-revive guard, re-inspection").toEqual([true, true, false]) // two inspections + expect(outcome.kind).toBe("entry-disabled") + expect(h.removes).toEqual(["datamate"]) + }) + + // staged by hooking `MCP.connect`, which the attach flow no + // longer has. Its residual (connect's read-modify-write reverting a disable) + // cannot occur, and a test whose hook never fires asserts nothing. + }) + + // this describe staged its scenario by hooking `MCP.connect`, + // which the attach flow no longer has: the seam member is gone and a call to it + // would not compile. Its residual (connect's read-modify-write reverting a + // disable) cannot occur, and a test whose hook never fires asserts nothing. + // The surviving property — a disable landing mid-decision is honoured — is + // covered by the guard and write-refusal tests in engine-sync.test.ts. + + describe("a plan held across the probes never writes over a disable", () => { + test("replace-unattributable: a disable landing during the PATH probe is persisted over, and the memo never re-checks", async () => { + // The extension's own entry: unpinned, live. Rule 1 replaces it. + let onDisk: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"], enabled: true } + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }], + () => onDisk, + ) + // The user disables the entry while the flow is probing `datamate --version` + // on PATH (seconds: declaredBounded up to 4s, versionOf ~1s, projectEntry). + syncInternals.versionOf = async (bin) => { + h.probes.push(bin) + onDisk = { ...onDisk, enabled: false } + return "0.7.0" + } + // persist() replaces the whole `mcp.datamate` node in the project file + // (mcp/config.ts:54-59), so a later fresh read returns OUR entry. + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + onDisk = { type: "local", command: cfg.command, enabled: cfg.enabled } + } + const first = await ensure("s1") + // it documented was the defect: the plan was held across the probes and then + // persisted our `enabled: true` over a disable that had landed meanwhile, + // after which the memo read our own entry and stood forever. The guard + // re-reads intent as well as the binding now, so the write never happens — + // and it reports WHICH half moved, so the user learns their edit took + // effect rather than being told about a generic race. + expect(first.kind).toBe("entry-disabled") + expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) + expect(h.added, "installed over a disable that landed during the probes").toHaveLength(0) + + // Next turn: the memo validator reads fresh config — which is now our pinned, enabled entry. + const second = await ensure("s1") + // The next turn re-decides rather than riding a memo: it reads the disable + // and reports it by name. + expect(second.kind).toBe("entry-disabled") + // Three teardowns now, all correct: the pre-spawn detach of the unpinned + // entry, the disabled entry's teardown when the guard catches the disable + // before the write, and its teardown again on the next turn. A disabled + // entry serves nothing, so it is never left registered. + expect(h.removes).toEqual(["datamate", "datamate", "datamate"]) + }) + + test("same shape on the pinned-but-below-floor path", async () => { + let onDisk: ExistingEntry = { type: "local", command: ["/opt/old/datamate", "start-stdio", "--datamate", "42"], enabled: true } + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => onDisk) + syncInternals.versionOf = async (bin) => { + h.probes.push(bin) + if (bin.startsWith("/opt/old")) return "0.6.3" + onDisk = { ...onDisk, enabled: false } // disable lands during the PATH probe + return "0.7.0" + } + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + onDisk = { type: "local", command: cfg.command, enabled: cfg.enabled } + } + const first = await ensure("s1") + // + expect(first.kind).toBe("entry-disabled") + expect(h.persisted, "wrote our pinned enabled:true over a disable that landed during the probes").toHaveLength(0) + }) + + test("control: a disable that lands BEFORE the inspection is honoured on the same entry", async () => { + const h = install([{ datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio"], + enabled: false, + })) + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toHaveLength(0) + }) + }) + + describe("edits landing between the two reads of one inspection, with no revive", () => { + test("(a) disable after the config read, client live → honoured in the same turn, no persist", async () => { + // The reuse answer re-asks intent before naming the engine, so a disable + // that lands between the inspection's two reads is refused now, with the + // engine detached, rather than served for a turn and repaired on the next. + let enabled = true + const h = install([{ datamate: { status: "connected" } }], () => ({ + type: "local", + command: ["datamate", "start-stdio", "--datamate", "42"], + enabled, + })) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + enabled = false + return realStatus() + } + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toEqual([]) + expect(h.removes).toEqual(["datamate"]) + }) + + test("(b) re-enable after the config read → honour-disable on the stale half, config untouched, next turn repairs", async () => { + let enabled = false + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], + () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled }), + ) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + enabled = true + return realStatus() + } + expect((await ensure("s1")).kind).toBe("entry-disabled") + expect(h.persisted).toEqual([]) + expect(["reused", "attached"]).toContain((await ensure("s1")).kind) + }) + + test("an entry an IDE adds after the config read is not spawned over", async () => { + // The plan was derived from "there is no entry here". If one appears + // before the write, acting on that plan persists over it and can displace + // the client it started — so the attach abandons and the next turn + // re-decides against the entry that is actually there. + let onDisk: ExistingEntry | null = null + const h = install([{}, { datamate: { status: "connected" } }], () => onDisk) + const realStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + onDisk = { type: "local", command: ["datamate", "start-stdio"], enabled: true } // IDE sync lands here + return realStatus() + } + const outcome = await ensure("s1") + expect(outcome.kind).toBe("superseded") + expect(h.persisted, "wrote over an entry that appeared after the plan was made").toHaveLength(0) + expect(h.removes).toEqual([]) + }) + }) +}) + +describe("a reused engine is re-judged, never assumed", () => { + const binding = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding + + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) + + test("planForEntry: a disable marker with no runtime status is honoured", () => { + // MCP.status() omits a config entry that has no `type` (mcp/index.ts:875-878), + // and the schema allows `{ enabled: false }` alone (core config.ts:119). + expect(planForEntry({ entry: { enabled: false }, observed: undefined }, "42", false)).toEqual({ act: "honour-disable" }) + }) + + test("ensure: a project `datamate: { enabled: false }` marker is not spawned over", async () => { + const added: unknown[] = [], persisted: unknown[] = [], toasts: unknown[] = [] + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.existingEntry = async () => ({ enabled: false }) + syncInternals.projectEntry = async () => ({ enabled: false }) + syncInternals.persist = async (n, c) => { persisted.push({ n, c }) } + syncInternals.notify = async (t) => { toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + let live = false + syncInternals.mcp = { + // The entry has no `type`, so status() never lists it — until WE add it. + status: async () => (live ? { datamate: { status: "connected" } } : {}), + add: async (n, c) => { added.push({ n, c }); live = true }, + remove: async () => {}, + tools: async () => ({ datamate_dbt_build_model: {} }), + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + const outcome = await ensure("s1") + console.log("outcome:", JSON.stringify(outcome), "persisted:", JSON.stringify(persisted), "toasts:", JSON.stringify(toasts.map((t: any) => t.title))) + expect(outcome.kind).toBe("entry-disabled") + expect(added).toHaveLength(0) + expect(persisted).toHaveLength(0) + }) +}) + +describe("what a decision may conclude from an entry it did not create", () => { + const b42 = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding + const b99 = { datamateId: 99, datamateName: "other", repoRemote: "x", projectPath: "/tmp/x" } as CachedBinding + + type H = { added: unknown[]; persisted: unknown[]; connects: string[]; removes: string[]; toasts: { title: string; message: string }[] } + function base(opts: { existing: unknown; statuses: Record[]; which?: string | null; binding?: () => CachedBinding | null }): H { + const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [] } + const q = opts.statuses + syncInternals.resolveBinding = async () => (opts.binding ? opts.binding() : b42) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.existingEntry = async () => opts.existing as never + syncInternals.projectEntry = async () => null + syncInternals.persist = async (n, c) => { h.persisted.push({ n, c }) } + syncInternals.notify = async (t) => { h.toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async () => {} + syncInternals.mcp = { + status: async () => (q.length > 1 ? q.shift()! : q[0]!), + add: async (n, c) => { h.added.push({ n, c }) }, + remove: async (n) => { h.removes.push(n) }, + tools: async () => ({ datamate_dbt_build_model: {} }), + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + return h + } + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) + + test("(a) the repair turn RECONNECTS the entry this flow tore down last turn, then rejects it again", async () => { + let onPath: string | null = null + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned -> rejected + statuses: [ + { datamate: { status: "connected" } }, + { datamate: { status: "disabled" } }, // synthesised by MCP.status() after OUR remove (mcp/index.ts:877) + { datamate: { status: "connected" } }, // MCP.connect brought the rejected engine back + { datamate: { status: "connected" } }, + ], + }) + syncInternals.which = () => onPath + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 1 }) + expect(h.removes).toEqual(["datamate"]) + onPath = "/usr/local/bin/datamate" + await ensure("s1") + console.log("(a) turn2 connects:", h.connects, "removes:", h.removes, "added:", h.added.length) + expect(h.connects, "reconnected an engine judged unattributable one turn earlier").toHaveLength(0) + }) + + test("(b) an entry REMOVED from config but still known to the runtime is retried via MCP's runtime cfg", async () => { + // MCP.status() lists every key in s.config (mcp/index.ts:880-882) — runtime cfg + // set by our own earlier MCP.add and never cleared by MCP.remove (949-955). + // An entry MCP still knows about but + // config no longer contains cannot be attributed to this workspace, so it is + // replaced rather than revived from whatever MCP happens to have retained. + expect(planForEntry({ entry: null, observed: { status: "disabled" } }, "42", false)).toMatchObject({ + act: "replace-unattributable", + pinnedTo: null, + }) + const h = base({ existing: null, statuses: [{ datamate: { status: "disabled" } }, { datamate: { status: "connected" } }, { datamate: { status: "connected" } }] }) + const out = await ensure("s1") + console.log("(b) outcome:", JSON.stringify(out), "connects:", h.connects, "removes:", h.removes) + expect(h.connects).toEqual([]) // fails: connect("datamate") reconnects whatever s.config holds — planForEntry never saw it + }) + + test("(c) connect-failed with the engine binary gone: install would help, table says no", async () => { + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [ + { datamate: { status: "failed", error: "spawn datamate ENOENT" } }, + { datamate: { status: "failed", error: "spawn datamate ENOENT" } }, + ], + which: null, + }) + const out = await ensure("s1") + console.log("(c) outcome:", JSON.stringify(out), "toast:", h.toasts.map((t) => t.message)) + // `connect-failed` with the binary gone was a lie — the engine did not fail to + // start, there was no engine — so the outcome now says `engine-missing` and + // the remedy predicate is right about it without needing a special case. + // `which` is consulted before answering, rather than reading ENOENT out of a + // platform-specific message. + expect(out.kind).toBe("engine-missing") + expect(installWouldHelp(out)).toBe(true) + expect(h.toasts[0]?.message, "told the user it failed to start rather than that it is missing").toContain( + "not installed", + ) + }) + + test("(d) a refusal is answered for a binding the project already left, with the rejected client left serving", async () => { + let current = b42 + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio"] }, // unpinned, connected + statuses: [{ datamate: { status: "connected" } }], + which: null, + binding: () => current, + }) + // Re-link lands right after run() snapshots the binding (during the config read). + const realExisting = syncInternals.existingEntry! + syncInternals.existingEntry = async (n) => { current = b99; return realExisting(n) } + const out = await ensure("s1") + console.log("(d) outcome:", JSON.stringify(out), "removes:", h.removes, "toasts:", h.toasts.map((t) => t.message)) + expect(out.kind).not.toBe("engine-missing") // fails: answers engine-missing for ws 42 while ws 99 is bound; detach skipped, toast names "analytics" + }) + + test("(e) a re-link during memo validation: the next attach is filed under the OLD key and loses its wait", async () => { + let current: CachedBinding = b42 + let calls = 0 + const h = base({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }, + statuses: [{ datamate: { status: "connected" } }], + binding: () => current, + }) + expect(await ensure("s1")).toMatchObject({ kind: "reused" }) + // Turn 2: engineStillOurs runs; the binding flips to 99 during its status read. + syncInternals.mcp!.status = async () => { calls += 1; if (calls === 1) current = b99; return { datamate: { status: "connected" } } } + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", String(current.datamateId)], enabled: true }) as never + const t2 = ensure("s1") + const started = Date.now() + await whenAttached("s1", 2000) + const waited = Date.now() - started + const settledAtResolve = settledOutcome("s1") + const out2 = await t2 + await ensure("s1") + // observation, not a test: it could not fail and so could not protect + // anything. + // + // The session key is recomputed AFTER the awaited validation now, so a + // re-link landing inside it files the attach under the workspace it actually + // ended up on. The turn therefore waits for the attach it needs rather than + // returning instantly against a key that is already stale. + // `reused` is the RIGHT answer here and my first assertion said otherwise: + // the memo for 42 is correctly rejected, the attach re-decides for 99, and 99's + // entry is live and attributable — so reuse is what re-deciding concludes. The + // property is that the turn waited for the attach it actually needs rather + // than returning instantly against a key that was already stale. + // Not elapsed time — that assertion was flaky by construction, since a fast + // path measures 0ms at `Date.now()` resolution and the suite duly failed on + // it. The property is that the wait was actually honoured: the attach has + // SETTLED by the time `whenAttached` returns, which is what "the turn waits + // for the attach it needs" means and what dropping the wait would break. + void waited + expect(settledAtResolve, "resolved the turn before the attach it needs had settled").toBeDefined() + expect(out2.kind).toBe("reused") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/seam-contract.test.ts b/packages/opencode/test/altimate/workspace/seam-contract.test.ts new file mode 100644 index 0000000000..7d9a5a06db --- /dev/null +++ b/packages/opencode/test/altimate/workspace/seam-contract.test.ts @@ -0,0 +1,626 @@ +// The `settledOutcome` seam and the +// `pinnedWorkspace` parser against the precedence contract. Disposable. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { + ensure, + resetForTests, + syncInternals, + pinnedWorkspace, + settledOutcome, + attributableEngine, + MAX_TRACKED_SESSIONS, + trackedSessionsForTests, + type LocalMcpConfig, + type Outcome, +} from "../../../src/altimate/workspace/engine-sync" +import { SERVING, INSTALL_HELPS } from "../../../src/altimate/workspace/engine-types" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding + +type Harness = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: Array<{ title: string; message: string; variant: string }> + toolsChanged: number + restores: Array + statusQueue: Array> + tools: Record +} + +function install(opts: { + binding?: CachedBinding | null + which?: string | null + version?: string | null | ((bin: string) => string | null) + declared?: { keys: string[]; extensionKeys: string[] } | null + statuses?: Harness["statusQueue"] + tools?: Record + existing?: ExistingEntry | null +}): Harness { + const h: Harness = { + added: [], + persisted: [], + connects: [], + removes: [], + toasts: [], + toolsChanged: 0, + restores: [], + statusQueue: opts.statuses ?? [{}], + tools: opts.tools ?? {}, + } + syncInternals.resolveBinding = async () => (opts.binding === undefined ? binding : opts.binding) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => { + if (typeof opts.version === "function") return opts.version(bin) + return opts.version === undefined ? "0.7.0" : opts.version + } + syncInternals.declared = async () => + opts.declared === undefined ? { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] } : opts.declared + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + syncInternals.existingEntry = async () => { + // Mirrors production: after this attach writes, the entry on disk is ours. + const written = h.persisted[h.persisted.length - 1] + if (written) return { ...(written.cfg as unknown as ExistingEntry) } + return opts.existing !== undefined ? opts.existing : null + } + syncInternals.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.toolsChanged = async () => { + h.toolsChanged += 1 + } + syncInternals.persistRestore = async (_name, previous) => { + h.restores.push(previous ?? null) + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + }, + remove: async (name) => { + h.removes.push(name) + }, + tools: async () => h.tools, + } + return h +} + +const connected = { datamate: { status: "connected" } } +const never = () => new Promise(() => {}) +const tick = (ms = 20) => new Promise((r) => setTimeout(r, ms)) + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() +}) + +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG +}) + +// ───────────────────────────── P1: pure synchronous read ───────────────────────────── +describe("settledOutcome is a pure synchronous read", () => { + test("is a plain function whose body has no await/then and never touches the task", () => { + expect(settledOutcome.constructor.name).toBe("Function") + const src = settledOutcome.toString() + expect(src).not.toMatch(/\bawait\b/) + expect(src).not.toMatch(/\.then\b/) + expect(src).not.toMatch(/\btask\b/) + expect(src).toMatch(/outcome/) + }) + + test("returns immediately, not a promise, while an attach is in flight on a probe that never resolves", async () => { + install({}) + syncInternals.versionOf = never + void ensure("s1") + const t0 = performance.now() + const one = settledOutcome("s1") + const dtOne = performance.now() - t0 + expect(one).toBeUndefined() + expect(one).not.toBeInstanceOf(Promise) + expect(dtOne).toBeLessThan(5) + + const t1 = performance.now() + for (let i = 0; i < 10_000; i++) settledOutcome("s1") + expect(performance.now() - t1).toBeLessThan(200) + + await tick() + expect(settledOutcome("s1")).toBeUndefined() + }) +}) + +// ───────────────────── P2: undefined means "not settled", never stale ───────────────────── +describe("undefined means in flight or never attached, and is never written early", () => { + test("never attached → undefined; attributableEngine(undefined) → false", () => { + expect(settledOutcome("nobody")).toBeUndefined() + expect(attributableEngine(undefined)).toBe(false) + }) + + test("no outcome table has a pending/in-flight kind that a caller could mistake for a verdict", () => { + const kinds = Object.keys(SERVING).sort() + expect(kinds).toEqual( + [ + "attached", + "reused", + "disabled", + "unbound", + "engine-missing", + "engine-too-old", + "connect-failed", + "entry-disabled", + "superseded", + ].sort(), + ) + expect(Object.keys(INSTALL_HELPS).sort()).toEqual(kinds) + }) + + test("nothing is written to the memo before run() returns — probed at MCP.add and at the final notify", async () => { + const h = install({ statuses: [{}, connected], tools: { datamate_dbt_build_model: 1 } }) + const seen: Array = [] + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + seen.push(settledOutcome("s1")) + await add(n, c) + } + syncInternals.notify = async (t) => { + seen.push(settledOutcome("s1")) + h.toasts.push(t) + } + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "attached" }) + expect(seen).toEqual([undefined, undefined]) + expect(settledOutcome("s1")).toBe(outcome) + }) + + test("a re-link re-attach does NOT carry the previous session outcome forward while the new attach is in flight", async () => { + let current: CachedBinding | null = binding // 42 + const h = install({ + statuses: [{}, connected, connected, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + expect(settledOutcome("s1")).toBe(first) + + // Project re-linked to 99; the replacement spawn hangs at the version probe. + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + syncInternals.versionOf = never + void ensure("s1") + // The dangerous reading would be `attached` (42's engine) under binding 99. + expect(settledOutcome("s1")).toBeUndefined() + await tick() + expect(settledOutcome("s1")).toBeUndefined() + expect(attributableEngine(settledOutcome("s1"))).toBe(false) + // and the 42 client was already torn down by the re-attach's rejection + expect(h.removes).toContain("datamate") + }) + + test("a concurrent second ensure for the same session while the first is in flight stays undefined until settle", async () => { + install({ statuses: [{}, connected], tools: { datamate_dbt_build_model: 1 } }) + let release: (v: string | null) => void = () => {} + syncInternals.versionOf = () => new Promise((r) => (release = r)) + const a = ensure("s1") + await tick(5) + const b = ensure("s1") // turn 2 while turn 1 is still probing + expect(settledOutcome("s1")).toBeUndefined() + release("0.7.0") + const [oa, ob] = await Promise.all([a, b]) + expect(oa).toMatchObject({ kind: "attached" }) + expect(ob).toBe(oa) + expect(settledOutcome("s1")).toBe(oa) + }) + + test("the seam reads undefined while a memo is being re-validated", async () => { + const h = install({ statuses: [{}, connected, connected, connected], tools: { datamate_dbt_build_model: 1 } }) + const first = await ensure("s1") + expect(settledOutcome("s1")).toBe(first) + // Turn 2, same binding, engine still live → the memo path. + const t = ensure("s1") + const during = settledOutcome("s1") + const after = await t + expect(after).toBe(first) + expect(settledOutcome("s1")).toBe(first) + // Record what the window reads. `undefined` = fail-open (shadowing off) for the + // duration of the awaited re-validation; not a mis-route. + expect(during).toBeUndefined() + expect(h.added).toHaveLength(1) + }) +}) + +// ───────────────────────────── P3: allowlist {attached, reused} ───────────────────────────── +describe("only attached and reused are attributable", () => { + test("SERVING is true for exactly the consumer's two kinds", () => { + const serving = Object.entries(SERVING) + .filter(([, v]) => v) + .map(([k]) => k) + .sort() + expect(serving).toEqual(["attached", "reused"]) + }) + + test("the consumer's inline allowlist and attributableEngine agree on every kind", () => { + for (const kind of Object.keys(SERVING) as Array) { + const outcome = { kind } as Outcome + const consumer = outcome.kind === "attached" || outcome.kind === "reused" + expect(attributableEngine(outcome), kind).toBe(consumer) + } + }) + + test("`attached` with `replaced` set describes the NEW pinned spawn, not the displaced entry", async () => { + const h = install({ + existing: { command: "datamate", args: ["start-stdio"] }, // the extension's unpinned entry, live + statuses: [connected, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + const out = settledOutcome("s1") + expect(out).toMatchObject({ kind: "attached", replaced: "datamate start-stdio" }) + // the engine serving this session is the pinned spawn; the unpinned one was closed first + expect(h.removes).toEqual(["datamate"]) + expect(h.added).toHaveLength(1) + expect(h.added[0].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + }) +}) + +// ─────────────────────── P4: describes the engine serving THIS session ─────────────────────── +describe("the outcome describes the engine actually serving this session", () => { + test("`reused` is only emitted when the live entry's pin equals this binding; any other pin is replaced", async () => { + const cases: Array<{ name: string; entry: ExistingEntry; want: "reused" | "attached" }> = [ + { name: "pinned to us", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, want: "reused" }, + { name: "unpinned (extension entry)", entry: { command: "datamate", args: ["start-stdio"] }, want: "attached" }, + { name: "pinned elsewhere", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] }, want: "attached" }, + { name: "pinned elsewhere, = spelling", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate=7"] }, want: "attached" }, + { name: "pinned to us then overridden elsewhere (last wins)", entry: { type: "local", command: ["datamate", "--datamate", "42", "--datamate", "7"] }, want: "attached" }, + { name: "pinned elsewhere then to us (last wins)", entry: { type: "local", command: ["datamate", "--datamate", "7", "--datamate=42"] }, want: "reused" }, + { name: "connected URL", entry: { type: "remote", url: "https://api.altimate.ai/sse" }, want: "attached" }, + ] + for (const c of cases) { + resetForTests() + const h = install({ existing: c.entry, statuses: [connected, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure(`s-${c.name}`) + const out = settledOutcome(`s-${c.name}`) + expect(out?.kind, c.name).toBe(c.want) + if (c.want === "attached") { + expect(h.added[0]?.cfg.command, c.name).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + } else { + expect(h.added, c.name).toHaveLength(0) + } + } + }) + + test("a re-link during the REUSE lookup settles as `superseded` in the memo, not `reused`, and detaches", async () => { + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [connected], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + syncInternals.declared = async () => { + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + return { keys: ["dbt_build_model"], extensionKeys: [] } + } + await ensure("s1") + expect(settledOutcome("s1")).toEqual({ kind: "superseded" }) + expect(attributableEngine(settledOutcome("s1"))).toBe(false) + expect(h.removes).toContain("datamate") + }) + + test("a re-link after the SPAWN's add settles as `superseded` in the memo, not `attached`", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, connected], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await add(n, c) + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + await ensure("s1") + expect(settledOutcome("s1")).toEqual({ kind: "superseded" }) + expect(h.removes).toContain("datamate") + expect(h.restores.length).toBeGreaterThan(0) + }) + + test("`superseded` is repairable: the next turn re-attaches rather than riding the memo", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, connected, {}, connected], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => current + let flipOnce = true + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { + await add(n, c) + if (flipOnce) { + flipOnce = false + current = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + } + } + expect(await ensure("s1")).toEqual({ kind: "superseded" }) + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + expect(h.added[h.added.length - 1].cfg.command).toEqual(["datamate", "start-stdio", "--datamate", "99"]) + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + }) + + test("a memoised `attached` is dropped when the config pin moves under it (A→B→A with another session serving B)", async () => { + let pin = "42" + install({ statuses: [{}, connected, connected, {}, connected], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", pin] }) + const first = await ensure("s1") + expect(first).toMatchObject({ kind: "attached" }) + pin = "99" // the instance-wide client now serves B + const t = ensure("s1") + expect(settledOutcome("s1")).toBeUndefined() + const second = await t + expect(second).not.toBe(first) + }) + + test("a config-only rewrite is indistinguishable from a reconnect through status alone", async () => { + // Harness: config says pinned-to-42 and status says connected. Nothing in + // run() can tell whether the connected process was launched with that argv. + install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [connected], + tools: { datamate_dbt_build_model: 1 }, + }) + await ensure("s1") + expect(settledOutcome("s1")).toMatchObject({ kind: "reused" }) + }) +}) + +// ───────────────────────────── P5: keyed by session ID ───────────────────────────── +describe("attribution is keyed by session id", () => { + test("two sessions in one project hold distinct outcomes", async () => { + const h = install({ statuses: [{}, connected, connected, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") // spawns → attached + await ensure("s2") // finds the persisted pinned entry live → reused + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + expect(settledOutcome("s2")).toMatchObject({ kind: "reused" }) + expect(settledOutcome("s3")).toBeUndefined() + expect(h.added).toHaveLength(1) + }) + + test("keys are session ids, not project/binding: a refused session does not overwrite a served one", async () => { + install({ statuses: [{}, connected, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + // s2 in the same project sees the entry disabled → refuses (and tears down). + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }) + await ensure("s2") + expect(settledOutcome("s2")).toEqual({ kind: "entry-disabled" }) + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + }) + + test("eviction may drop a settled outcome while its session is live, and fails open", async () => { + install({ statuses: [{}, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) + syncInternals.resolveBinding = async () => null + for (let i = 0; i < MAX_TRACKED_SESSIONS; i++) await ensure(`other-${i}`) + expect(trackedSessionsForTests()).toBeLessThanOrEqual(MAX_TRACKED_SESSIONS) + expect(settledOutcome("s1")).toBeUndefined() + }) + + test("another session's teardown leaves this session's outcome stale until its next turn", async () => { + const h = install({ statuses: [{}, connected, connected, connected], tools: { datamate_dbt_build_model: 1 } }) + await ensure("s1") + syncInternals.existingEntry = async () => ({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }) + await ensure("s2") // honours the disable: removes the instance-wide client + expect(h.removes).toEqual(["datamate"]) + expect(settledOutcome("s1")).toMatchObject({ kind: "attached" }) // stale: client is gone + // s1's next turn re-decides + h.statusQueue = [{ datamate: { status: "disabled" } }] + expect(await ensure("s1")).toEqual({ kind: "entry-disabled" }) + }) +}) + +// ───────────────────────────── P6: pinnedWorkspace table ───────────────────────────── +describe("the pin is read from every argv shape", () => { + const table: Array<{ name: string; entry: unknown; want: string | null | "THROWS" }> = [ + // contract shapes + { name: "opencode argv, two tokens", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", "5"] }, want: "5" }, + { name: "IDE {command,args}, two tokens", entry: { command: "datamate", args: ["start-stdio", "--datamate", "5"] }, want: "5" }, + { name: "IDE {command,args}, = spelling", entry: { command: "datamate", args: ["start-stdio", "--datamate=5"] }, want: "5" }, + { name: "opencode argv, = spelling", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate=5"] }, want: "5" }, + { name: "repeated two-token, last wins", entry: { type: "local", command: ["datamate", "--datamate", "5", "--datamate", "9"] }, want: "9" }, + { name: "repeated = then two-token, last wins", entry: { type: "local", command: ["datamate", "--datamate=5", "--datamate", "9"] }, want: "9" }, + { name: "repeated two-token then =, last wins", entry: { type: "local", command: ["datamate", "--datamate", "5", "--datamate=9"] }, want: "9" }, + { name: "pin split across command and args", entry: { command: ["datamate", "start-stdio", "--datamate"], args: ["5"] }, want: "5" }, + { name: "no pin", entry: { type: "local", command: ["datamate", "start-stdio"] }, want: null }, + { name: "null entry", entry: null, want: null }, + { name: "URL entry", entry: { type: "remote", url: "http://localhost:7801/sse" }, want: null }, + { name: "empty command", entry: { type: "local", command: [] }, want: null }, + // dangling / empty + { name: "--datamate as last token, no value", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate"] }, want: null }, + { name: "earlier pin then dangling --datamate (engine would refuse to start)", entry: { type: "local", command: ["datamate", "--datamate", "5", "--datamate"] }, want: null }, + { name: "--datamate= empty", entry: { type: "local", command: ["datamate", "--datamate="] }, want: null }, + { name: "--datamate=5 then --datamate= (engine: last wins = empty)", entry: { type: "local", command: ["datamate", "--datamate=5", "--datamate="] }, want: null }, + // odd values + { name: "non-numeric value", entry: { type: "local", command: ["datamate", "--datamate", "abc"] }, want: "abc" }, + { name: "value that looks like a flag (commander: argument missing)", entry: { type: "local", command: ["datamate", "--datamate", "--verbose"] }, want: "--verbose" }, + { name: "quoted value inside the token", entry: { type: "local", command: ["datamate", "--datamate=\"5\""] }, want: "\"5\"" }, + { name: "value with surrounding whitespace", entry: { type: "local", command: ["datamate", "--datamate", " 5"] }, want: " 5" }, + { name: "=-value containing another =", entry: { type: "local", command: ["datamate", "--datamate=5=6"] }, want: "5=6" }, + { name: "leading-zero id", entry: { type: "local", command: ["datamate", "--datamate", "05"] }, want: "05" }, + // near-miss flag names + { name: "--datamate-id is not the pin flag", entry: { type: "local", command: ["datamate", "--datamate-id", "5"] }, want: null }, + { name: "--datamatex=5 is not the pin flag", entry: { type: "local", command: ["datamate", "--datamatex=5"] }, want: null }, + { name: "case differs (commander is case-sensitive too)", entry: { type: "local", command: ["datamate", "--DATAMATE", "5"] }, want: null }, + { name: "single-dash", entry: { type: "local", command: ["datamate", "-datamate", "5"] }, want: null }, + // the flag inside another token / shell wrappers + { name: "shell -c wrapper, whole command in one token", entry: { type: "local", command: ["sh", "-c", "datamate start-stdio --datamate 5"] }, want: null }, + { name: "cmd /c wrapper", entry: { type: "local", command: ["cmd", "/c", "datamate start-stdio --datamate 5"] }, want: null }, + { name: "IDE command string with spaces and no args", entry: { command: "datamate start-stdio --datamate 5" }, want: null }, + { name: "npx wrapper still parses the pin", entry: { type: "local", command: ["npx", "-y", "@altimateai/datamate", "start-stdio", "--datamate", "5"] }, want: "5" }, + { name: "pin as the VALUE of another flag (--config-file --datamate 5)", entry: { type: "local", command: ["datamate", "--config-file", "--datamate", "5"] }, want: "5" }, + { name: "pin only via environment, not argv", entry: { type: "local", command: ["datamate", "start-stdio"], environment: { DATAMATE_ID: "5" } }, want: null }, + { name: "URL entry that also carries args", entry: { type: "remote", url: "http://x/sse", args: ["--datamate", "5"] }, want: "5" }, + // defensive-read shapes (merged config written by other clients) + { name: "args as a string, not an array", entry: { command: "datamate", args: "start-stdio --datamate 5" }, want: null }, + { name: "numeric token in argv (raw disk JSON)", entry: { type: "local", command: ["datamate", "start-stdio", "--datamate", 5] }, want: "THROWS" }, + { name: "null token BEFORE the last pin is never reached (scan from the end)", entry: { type: "local", command: ["datamate", null, "--datamate", "5"] }, want: "5" }, + { name: "null token AFTER the last pin throws", entry: { type: "local", command: ["datamate", "--datamate", "5", null] }, want: "THROWS" }, + { name: "numeric token AFTER the last pin throws", entry: { type: "local", command: ["datamate", "--datamate", "5", 7] }, want: "THROWS" }, + { name: "command is an object", entry: { type: "local", command: {} }, want: "THROWS" }, + ] + + for (const row of table) { + test(row.name, () => { + if (row.want === "THROWS") { + expect(() => pinnedWorkspace(row.entry as never)).toThrow() + } else { + expect(pinnedWorkspace(row.entry as never)).toBe(row.want) + } + }) + } + + test("the consumer's comparison: pin vs String(datamateId)", () => { + const pin = pinnedWorkspace({ type: "local", command: ["datamate", "--datamate", "5"] }) + expect(pin !== null && pin !== String(5)).toBe(false) + expect(pin !== null && pin !== String("5")).toBe(false) + const quoted = pinnedWorkspace({ type: "local", command: ["datamate", "--datamate=\"5\""] }) + expect(quoted !== null && quoted !== String(5)).toBe(true) // treated as pinned elsewhere + }) +}) + +// ───────────── 2d8bea2d0: the connect-retry re-inspection vs the memo ───────────── +describe("the retry re-inspects, and never writes the memo early or twice", () => { + // The retry + // no longer uses: connect writes `enabled: true` into whichever config owns + // the entry, so a local repair became a global config write, and it started + // whatever MCP had retained rather than the entry the decision examined. The + // retry re-adds instead. The PROPERTIES here are unchanged — re-inspect whole, + // judge on the post-retry entry, write the memo exactly once — only the seam + // that stands in for "the retry happened" has moved from `connect` to `add`. + test("two inspections, one status per inspection, memo written exactly once, after the retry settles", async () => { + const reads: Array = [] + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + let entryReads = 0 + let statusReads = 0 + syncInternals.existingEntry = async () => { + entryReads += 1 + reads.push(settledOutcome("s1")) + return { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + } + const status = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { + statusReads += 1 + reads.push(settledOutcome("s1")) + return status() + } + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + reads.push(settledOutcome("s1")) + return add(n, cfg) + } + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "reused" }) + expect(h.connects, "repaired with the config-writing primitive").toHaveLength(0) + expect(h.added, "the retry restarts the entry exactly once").toHaveLength(1) + // Five: the inspection, the pre-revive world check's intent read, the + // re-inspection, and the reuse answer's two world checks — one after the + // lookup awaits and one after the announcements. The second is the guard + // confirming intent immediately before starting a process; the last two + // confirm it before the answer names the engine and again when the answer + // is given — mutations and named answers both re-read. + expect(entryReads).toBe(5) + expect(statusReads).toBe(2) + expect(reads.every((r) => r === undefined)).toBe(true) // nothing observable mid-run + expect(settledOutcome("s1")).toBe(outcome) + }) + + test("the pin is judged on the POST-retry entry", async () => { + // One case changed verdict when attribution moved above connectivity, and it + // changed for the better: an UNPINNED entry is no longer revived and then + // discovered to be unattributable — it is replaced without being started at + // all, so the retry never runs for it. The retry is for OUR engine. + const cases: Array<{ name: string; before: ExistingEntry; after: ExistingEntry; want: "reused" | "attached" }> = [ + { + name: "unpinned → never retried, replaced outright", + before: { type: "local", command: ["datamate", "start-stdio"] }, + after: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + want: "attached", + }, + { + name: "pinned 42 → unpinned", + before: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + after: { type: "local", command: ["datamate", "start-stdio"] }, + want: "attached", + }, + { + name: "pinned 42 → pinned 7", + before: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + after: { type: "local", command: ["datamate", "start-stdio", "--datamate", "7"] }, + want: "attached", + }, + { + name: "pinned 42 → pinned 42", + before: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + after: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + want: "reused", + }, + ] + for (const c of cases) { + resetForTests() + let retried = false + const h = install({ + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, connected, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.existingEntry = async () => (retried ? c.after : c.before) + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + retried = true + return add(n, cfg) + } + await ensure(`s-${c.name}`) + const out = settledOutcome(`s-${c.name}`) + expect(out?.kind, c.name).toBe(c.want) + if (c.want === "attached") { + expect(h.added.at(-1)?.cfg.command, c.name).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + } + } + }) + + test("a disable that lands during the retry is honoured on re-inspection and torn down", async () => { + let retried = false + const h = install({ + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, connected], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.existingEntry = async () => + retried + ? { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false } + : { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] } + const add = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, cfg) => { + retried = true + return add(n, cfg) + } + await ensure("s1") + expect(settledOutcome("s1")).toEqual({ kind: "entry-disabled" }) + expect(h.removes).toEqual(["datamate"]) + expect(h.persisted, "wrote config while honouring a disable").toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/unbound-and-silence.test.ts b/packages/opencode/test/altimate/workspace/unbound-and-silence.test.ts new file mode 100644 index 0000000000..84c8c0f0f3 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/unbound-and-silence.test.ts @@ -0,0 +1,38 @@ +// altimate_change - new file +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { ensure, resetForTests, syncInternals, planForEntry, settledOutcome } from "../../../src/altimate/workspace/engine-sync" + +describe("a project with no workspace linked stays silent", () => { + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] }) + + test("an unbound project whose config read throws stays unbound and silent", async () => { + const toasts: { title: string }[] = [] + syncInternals.resolveBinding = async () => null // unbound + syncInternals.existingEntry = async () => { throw new Error("EACCES altimate-code.json") } + syncInternals.notify = async (t) => { toasts.push(t) } + syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } + const out = await ensure("s1") + void 0; console.log("AH unbound:", JSON.stringify(out), "| toasts:", toasts.map((t) => t.title), "| settled:", JSON.stringify(settledOutcome("s1"))) + expect(out.kind).toBe("unbound") + expect(toasts).toHaveLength(0) + }) + + test("T phantom: entry null + synthesised status (key known to MCP but not to config)", () => { + const noRuntime = planForEntry({ entry: null, observed: { status: "failed", error: "exit 1" }, runtime: undefined }, "42", false) + const withRuntime = planForEntry({ entry: null, observed: { status: "connected" }, runtime: { type: "local", command: ["datamate", "start-stdio"] } }, "42", false) + console.log("T phantom noRuntime:", JSON.stringify(noRuntime), "| withRuntime:", JSON.stringify(withRuntime)) + }) + + test("an unbound project stays silent on every turn, not just the first", async () => { + const toasts: { title: string }[] = [] + syncInternals.resolveBinding = async () => null + syncInternals.existingEntry = async () => { throw new Error("EACCES altimate-code.json") } + syncInternals.notify = async (t) => { toasts.push(t) } + syncInternals.mcp = { status: async () => ({ datamate: { status: "connected" } }), add: async () => {}, remove: async () => {}, tools: async () => ({}) } + await ensure("s1"); await ensure("s1"); await ensure("s1") + // which could not fail and so protected nothing. An unbound project announces + // nothing at all, on any turn, whatever fails inside it. + expect(toasts, `an unbound project announced ${toasts.length} times`).toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts new file mode 100644 index 0000000000..2c68a7f483 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts @@ -0,0 +1,833 @@ +// altimate_change - new file +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" +import { ensure, resetForTests, syncInternals, type LocalMcpConfig, settledOutcome } from "../../../src/altimate/workspace/engine-sync" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { Config } from "../../../src/config/config" +import { Filesystem } from "../../../src/util/filesystem" +import { addMcpToConfig, readMcpEntryFromDisk } from "../../../src/mcp/config" +import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ExistingEntry } from "../../../src/altimate/workspace/engine-sync" + +describe("every exit gives back what it took", () => { + const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + const other = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + + type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + connects: string[] + removes: string[] + toasts: Array<{ title: string; message: string; variant: string }> + restores: Array + statusQueue: Array> + tools: Record + } + function install(opts: { + which?: string | null + version?: string | null | ((bin: string) => string | null) + statuses?: H["statusQueue"] + tools?: Record + existing?: ExistingEntry | null + projectEntry?: ExistingEntry | null + }): H { + const h: H = { added: [], persisted: [], connects: [], removes: [], toasts: [], restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {} } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => (typeof opts.version === "function" ? opts.version(bin) : opts.version === undefined ? "0.7.0" : opts.version) + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { h.persisted.push({ name, cfg }) } + syncInternals.existingEntry = async () => { + if (opts.existing !== undefined) return opts.existing + const last = h.persisted[h.persisted.length - 1] + return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null + } + // Mirrors production: once this attach has persisted, the project file holds + // OUR entry — which is what the undo reads to decide whether what is there + // is still its own work. A stub that always returns the pre-install value + // models a file that never received the write. + syncInternals.projectEntry = async () => { + const last = h.persisted[h.persisted.length - 1] + return last ? ({ ...last.cfg } as unknown as ExistingEntry) : (opts.projectEntry ?? null) + } + syncInternals.notify = async (t) => { h.toasts.push(t) } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async (_n, prev) => { h.restores.push(prev ?? null) } + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { h.added.push({ name, cfg }) }, + remove: async (name) => { h.removes.push(name) }, + tools: async () => h.tools, + } + // The project file has no entry of its own unless a test says otherwise. + // Required since the project reader stopped swallowing its own errors. + if (!syncInternals.projectEntry) syncInternals.projectEntry = async () => null + if (!syncInternals.projectConfigPath) + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + return h + } + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { + for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG + }) + + describe("a supersede does not skip a teardown that does not depend on the binding", () => { + test("A: entry DISABLED + connected, re-link lands between status() and refuse → client left serving", async () => { + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + syncInternals.resolveBinding = async () => current + const prevStatus = syncInternals.mcp!.status + syncInternals.mcp!.status = async () => { const s = await prevStatus(); current = other; return s } + const outcome = await ensure("s1") + // The answer is + // `superseded` because a refusal is an answer too, and this one would have + // described a workspace the project had already left. + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.removes, "disabled entry reported but its live client was NOT removed (detachRejected skipped on supersede)").toContain("datamate") + }) + test("B: pinned-to-us, below floor, nothing better on PATH, re-link lands in versionOf → too-old client left serving", async () => { + let current: CachedBinding | null = binding + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"] }, + statuses: [{ datamate: { status: "connected" } }], + version: () => { current = other; return "0.5.0" }, + }) + syncInternals.resolveBinding = async () => current + const outcome = await ensure("s1") + // Teardown holds; the answer is `superseded`. + expect(outcome).toMatchObject({ kind: "superseded" }) + expect(h.removes, "too-old engine reported but left registered (detachRejected skipped on supersede)").toContain("datamate") + }) + }) + + describe("D — connect-failed AFTER install never restores what persist() replaced", () => { + test("user's hand-authored PROJECT entry is overwritten by our pin; spawn fails; nothing puts it back", async () => { + const users: ExistingEntry = { type: "local", command: ["datamate", "start-stdio"] } // unpinned, in project file, live + const h = install({ + existing: users, + projectEntry: users, + statuses: [{ datamate: { status: "connected" } }, { datamate: { status: "failed", error: "exit 1" } }], + }) + const outcome = await ensure("s1") + // The install region gives back both + // halves on every non-attached exit, so a failed spawn puts the user's own + // entry back instead of leaving our pin over it. + expect(outcome).toMatchObject({ kind: "connect-failed" }) + expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.restores, "the failed spawn left our pin over the user's project entry").toEqual([users]) + }) + }) + + describe("F — connect-failed after install, superseded: stale pin stays on disk and wedges the new workspace", () => { + test("turn 1: install 42, re-link to 99 during add, spawn fails → refuse() without undoInstall", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + const outcome = await ensure("s1") + // A failed spawn is a non-attached + // exit, so the region gives back the pin it wrote — it does not survive to + // wedge the next turn. + // The re-link lands during the add, so the refusal revalidates and declines + // to answer for the workspace the project has left. + expect(outcome).toMatchObject({ kind: "superseded" }) + // Both halves: the pin WAS written, and it was given back. + expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.restores.length, "the failed spawn's pin was left on disk to wedge the next turn").toBeGreaterThan(0) + }) + test("turn 2 under binding 99: the failing 42 pin is retried once and refused — 99 never spawns", async () => { + let current: CachedBinding | null = binding + const h = install({ + statuses: [ + {}, // turn 1 initial + { datamate: { status: "failed", error: "exit 1" } }, // turn 1 after add + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 initial + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 after retry + ], + }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + // Turn 1's re-link + // during the add makes the refusal decline to answer for the workspace just + // left, and its pin is given back rather than left to wedge turn 2. Turn 2 + // then judges 42's pin unattributable under binding 99 and REPLACES it + // instead of retrying it, so 99 gets its engine. `connect-failed` on turn 2 + // is the fixture's own doing: its status queue reports the freshly spawned + // engine as failed too. + expect(await ensure("s1")).toMatchObject({ kind: "superseded" }) + syncInternals.mcp!.add = prevAdd + const second = await ensure("s1") + expect(second).toMatchObject({ kind: "connect-failed" }) + expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) + expect(h.added.map((a) => a.cfg.command), "workspace 99 never gets an engine: the stale failing 42 pin blocks it every turn").toContainEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) + }) + + describe("E — a throw after install bypasses undoInstall entirely", () => { + test("re-link during add, then tools() throws → engine for 42 stays installed under binding 99, outcome connect-failed", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }] }) + syncInternals.resolveBinding = async () => current + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + syncInternals.mcp!.tools = async () => { throw new Error("tools listing exploded") } + const outcome = await ensure("s1") + // A throw does not unwind past the undo — the + // region gives back both halves on any non-attached exit, including one + // nobody wrote. And because this throw lands AFTER a re-link, it is now the + // same silent `superseded` as every other refusal for a workspace the + // project has left: answering would name the wrong workspace, and toasting + // about it would be worse. + expect(outcome).toMatchObject({ kind: "superseded" }) + expect(h.toasts, "announced a failure for the workspace the project had left").toHaveLength(0) + expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.removes, "a throw left the client registered").toContain("datamate") + expect(h.restores, "a throw left our pin on disk").toHaveLength(1) + }) + }) + + describe("C — retry-connect calls MCP.connect on a global-only entry (persists enabled:true into the owning file)", () => { + test("a down, enabled, IDE-shaped entry is retried via MCP.connect", async () => { + const h = install({ + existing: { command: "datamate", args: ["start-stdio"] }, // IDE shape, no `enabled` field, lives in global + statuses: [{ datamate: { status: "failed", error: "exit 1" } }, { datamate: { status: "connected" } }], + }) + await ensure("s1") + // Repairing a down + // IDE-shaped entry used `MCP.connect`, which persists `enabled: true` into + // the file that owns the entry — a global write from a local decision. + // + // Asserting only "connect was not called" is now vacuous, since the seam no + // longer carries it. What earns its place is that the repair happened, with + // the right primitive and the entry we judged, and wrote nothing. + // And the scenario no longer reaches the repair at all: an IDE-shaped entry + // is UNPINNED, so attribution replaces it before connectivity is ever + // consulted. What lands is our own pinned entry, written to the project + // config — not a global write to theirs, which was the defect. + expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(h.persisted.map((p) => p.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + }) + }) + + describe("a failing pin never blocks the workspace the project is bound to", () => { + test("clean attach of 42; user re-links to 99; 42's engine is now down → retried once, refused; 99 never spawns", async () => { + let current: CachedBinding | null = binding + const h = install({ + statuses: [ + {}, // turn 1 initial + { datamate: { status: "connected" } }, // turn 1 after add + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 initial (42's engine died) + { datamate: { status: "failed", error: "exit 1" } }, // turn 2 after retry + ], + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => current + expect(await ensure("s1")).toMatchObject({ kind: "attached" }) + current = other + const second = await ensure("s1") + // 42's pin is unattributable under + // binding 99, so it is replaced rather than retried, and 99 gets its engine. + // `connect-failed` here is the fixture's own doing — the status queue reports + // the freshly spawned engine as failed too. + expect(second).toMatchObject({ kind: "connect-failed" }) + expect(h.connects, "revived an engine belonging to another workspace").toHaveLength(0) + expect(h.added.map((a) => a.cfg.command), "99 blocked behind the failing 42 pin").toContainEqual(["datamate", "start-stdio", "--datamate", "99"]) + }) + }) + + describe("G — refuse() order at 2d8bea2d0: teardown runs BEFORE announceRefusal; a throwing announce relabels the outcome", () => { + test("disabled+connected entry, notify seam throws: client IS removed (teardown first), but outcome becomes connect-failed and a 2nd toast fires", async () => { + const h = install({ + existing: { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: false }, + statuses: [{ datamate: { status: "connected" } }], + }) + let notifyCalls = 0 + syncInternals.notify = async (t) => { + notifyCalls += 1 + if (notifyCalls === 1) throw new Error("dialog surface exploded") + h.toasts.push(t) + } + const outcome = await ensure("s1") + expect(h.removes, "teardown did not run before the announce").toContain("datamate") + // A throwing announce no longer + // reaches the catch-all, so the verdict stands and no second toast fires. + expect(notifyCalls, "a failed announcement was retried through a second toast site").toBe(1) + expect(outcome.kind, "a throwing announce relabels entry-disabled as connect-failed").toBe("entry-disabled") + }) + }) +}) + +describe("the undo obeys the world it undoes into", () => { + const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + const binding: CachedBinding = { datamateId: 42, datamateName: "analytics", repoRemote: "x", projectPath: "/tmp/analytics" } as CachedBinding + const other = { ...binding, datamateId: 99, datamateName: "other" } as CachedBinding + type H = { trace: string[]; added: Array<{ name: string; cfg: LocalMcpConfig }>; persisted: Array<{ name: string; cfg: LocalMcpConfig }>; removes: string[]; toasts: Array<{ title: string; message: string; variant: string }>; restores: Array; statusQueue: Array>; tools: Record } + function install(opts: { which?: string | null; version?: string | null | ((bin: string) => string | null); statuses?: H["statusQueue"]; tools?: Record; existing?: ExistingEntry | null | (() => ExistingEntry | null); projectEntry?: ExistingEntry | null | (() => ExistingEntry | null) }): H { + const h: H = { trace: [], added: [], persisted: [], removes: [], toasts: [], restores: [], statusQueue: opts.statuses ?? [{}], tools: opts.tools ?? {} } + const t = (s: string) => h.trace.push(s) + syncInternals.resolveBinding = async () => (t("resolveBinding"), binding) + syncInternals.which = () => (opts.which === undefined ? "/usr/local/bin/datamate" : opts.which) + syncInternals.versionOf = async (bin) => (t("versionOf"), typeof opts.version === "function" ? opts.version(bin) : opts.version === undefined ? "0.7.0" : opts.version) + syncInternals.declared = async () => (t("declared"), { keys: ["dbt_build_model"], extensionKeys: [] }) + syncInternals.persist = async (name, cfg) => { t("persist"); h.persisted.push({ name, cfg }) } + syncInternals.existingEntry = async () => { t("existingEntry"); if (typeof opts.existing === "function") return opts.existing(); if (opts.existing !== undefined) return opts.existing; const last = h.persisted[h.persisted.length - 1]; return last ? ({ type: "local", command: last.cfg.command, enabled: true } as ExistingEntry) : null } + syncInternals.projectEntry = async () => { t("projectEntry"); return typeof opts.projectEntry === "function" ? opts.projectEntry() : (opts.projectEntry ?? null) } + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.notify = async (tt) => { t("notify"); h.toasts.push(tt) } + syncInternals.toolsChanged = async () => { t("toolsChanged") } + syncInternals.persistRestore = async (_n, prev) => { t("persistRestore"); h.restores.push(prev ?? null) } + syncInternals.mcp = { status: async () => (t("status"), h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), add: async (name, cfg) => { t("add"); h.added.push({ name, cfg }) }, remove: async (name) => { t("remove"); h.removes.push(name) }, tools: async () => (t("tools"), h.tools) } + return h + } + beforeEach(() => { process.env.ALTIMATE_WORKSPACE = "1"; resetForTests() }) + afterEach(() => { for (const k of Object.keys(syncInternals) as Array) delete syncInternals[k]; if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE; else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG }) + const ours: ExistingEntry = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } + + describe("the undo reads the world at undo time", () => { + test("a disable landing on our node during the boot is kept, not deleted", async () => { + let phase = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, + existing: () => (phase === 0 ? null : { ...ours, enabled: false }), + projectEntry: () => (phase === 0 ? null : { ...ours, enabled: false }), + }) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.removes).toContain("datamate") + expect(h.restores, "the user's disable was undone").toEqual([{ ...ours, enabled: false }]) + }) + test("an undo whose re-read throws does not write blind", async () => { + let phase = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, + existing: () => (phase === 0 ? null : { ...ours, enabled: false }), + projectEntry: () => { if (phase === 0) return null; throw new Error("EACCES on re-read") }, + }) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.restores, "a failed re-read fell back to restoring the snapshot: the user's disabled node is removed").not.toEqual([null]) + }) + test("a disable on the global entry leaves the project node ours to remove", async () => { + let phase = 0 + const h = install({ + statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 }, + existing: () => (phase === 0 ? null : { type: "local", command: ["datamate", "start-stdio"], enabled: false }), + projectEntry: () => (phase === 0 ? null : ours), + }) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); phase = 1 } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.restores).toEqual([null]) + }) + }) + + describe("an in-region refusal undoes before it announces", () => { + test("post-add connect-failed: remove and persistRestore precede notify; exactly one remove and one restore", async () => { + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "connect-failed" }) + const iRemove = h.trace.indexOf("remove"), iRestore = h.trace.indexOf("persistRestore"), iNotify = h.trace.indexOf("notify") + expect(iRemove).toBeGreaterThanOrEqual(0) + expect(iRestore).toBeGreaterThan(iRemove) + expect(iNotify, `trace: ${h.trace.join(" > ")}`).toBeGreaterThan(iRestore) + expect(h.removes).toEqual(["datamate"]) + expect(h.restores).toEqual([null]) + expect(h.toasts).toHaveLength(1) + }) + test("persist refused as 'disabled' → nothing installed, nothing undone, entry-disabled announced once", async () => { + const h = install({ statuses: [{}] }) + syncInternals.persist = async () => "disabled" + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "entry-disabled" }) + expect(h.added).toHaveLength(0) + expect(h.restores).toHaveLength(0) + expect(h.toasts).toHaveLength(1) + }) + }) + + describe("W — an undo that fails is announced once, naming the file; the triggering outcome survives", () => { + test("post-add connect-failed + restore failed → two toasts (engine failed; config left behind in ), outcome connect-failed", async () => { + const h = install({ statuses: [{}, { datamate: { status: "failed", error: "exit 1" } }] }) + syncInternals.persistRestore = async () => "failed" + const outcome = await ensure("s1") + expect(outcome).toMatchObject({ kind: "connect-failed", error: "exit 1" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind", "Workspace engine failed to start"]) + expect(h.toasts[0]!.message).toContain("/tmp/test/.altimate-code/altimate-code.json") + }) + test("superseded + restore failed → exactly one toast (config left behind), outcome superseded", async () => { + let current: CachedBinding | null = binding + const h = install({ statuses: [{}, { datamate: { status: "connected" } }], tools: { datamate_dbt_build_model: 1 } }) + syncInternals.resolveBinding = async () => (h.trace.push("resolveBinding"), current) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + syncInternals.persistRestore = async () => { throw new Error("EACCES") } + const outcome = await ensure("s1") + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind"]) + }) + }) + + // A client this attach started is + // torn down whatever is bound now, which is what the teardown split said + // all along — the definition was right and the plumbing did not carry it + // as far as this exit. + describe("INVARIANT — a client we started is torn down whatever is bound now", () => { + test("(i) revive succeeds, then the re-inspection read THROWS → revived client left connected, outcome connect-failed via the catch-all", async () => { + let reads = 0 + const h = install({ + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + existing: () => { reads += 1; if (reads >= 3) throw new Error("config unreadable"); return ours }, + tools: { datamate_dbt_build_model: 1 }, + }) + const outcome = await ensure("s1") + expect(h.added.map((a) => a.cfg.command)).toEqual([["datamate", "start-stdio", "--datamate", "42"]]) + expect(outcome.kind).toBe("connect-failed") + expect(h.removes, "the client this attach started is left registered and connected under a connect-failed outcome").toContain("datamate") + }) + test("(ii) revive succeeds, the file is rewritten unpinned and the binding moves: the revived client is still torn down", async () => { + let current: CachedBinding | null = binding + let reads = 0 + const h = install({ + statuses: [{ datamate: { status: "failed", error: "closed" } }, { datamate: { status: "connected" } }], + existing: () => { reads += 1; return reads >= 3 ? { type: "local", command: ["datamate", "start-stdio"] } : ours }, + tools: { datamate_dbt_build_model: 1 }, + }) + syncInternals.resolveBinding = async () => (h.trace.push("resolveBinding"), current) + const prevAdd = syncInternals.mcp!.add + syncInternals.mcp!.add = async (n, c) => { await prevAdd(n, c); current = other } + const outcome = await ensure("s1") + expect(h.added).toHaveLength(1) + expect(outcome).toEqual({ kind: "superseded" }) + expect(h.removes, "the client this attach started is left registered and connected").toContain("datamate") + }) + }) +}) + +describe("the restore refuses on the text it edits", () => { + const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", + } as CachedBinding + + type H = { + added: Array<{ name: string; cfg: LocalMcpConfig }> + persisted: Array<{ name: string; cfg: LocalMcpConfig }> + removes: string[] + restores: Array + toasts: Array<{ title: string; message: string }> + statusQueue: Array> + reads: Array + spawnedNow?: ExistingEntry + } + + function install(statuses: H["statusQueue"], entry: () => ExistingEntry | null, opts: { realPersist?: boolean; spawned?: ExistingEntry } = {}): H { + const h: H = { added: [], persisted: [], removes: [], restores: [], toasts: [], statusQueue: statuses, reads: [], spawnedNow: opts.spawned } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.7.0" + syncInternals.declared = async () => ({ keys: ["dbt_build_model"], extensionKeys: [] }) + if (!opts.realPersist) { + syncInternals.persist = async (name, cfg) => { + h.persisted.push({ name, cfg }) + } + } + syncInternals.existingEntry = async () => { + const e = entry() + h.reads.push(e?.enabled) + return e + } + syncInternals.notify = async (t) => { + h.toasts.push({ title: t.title, message: t.message }) + } + syncInternals.toolsChanged = async () => {} + syncInternals.persistRestore = async (_n, prev) => { + h.restores.push(prev) + } + syncInternals.projectEntry = async () => null + syncInternals.projectConfigPath = async () => "/tmp/test/.altimate-code/altimate-code.json" + syncInternals.mcp = { + status: async () => (h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!), + add: async (name, cfg) => { + h.added.push({ name, cfg }) + h.spawnedNow = cfg as ExistingEntry + }, + remove: async (name) => { + h.removes.push(name) + h.spawnedNow = undefined + }, + spawned: async () => h.spawnedNow, + tools: async () => ({ datamate_dbt_build_model: 1 }), + } + return h + } + + beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + resetForTests() + }) + afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + }) + + const DISABLED_FILE = JSON.stringify({ mcp: { datamate: { type: "local", command: ["datamate", "start-stdio"], enabled: false } } }, null, 2) + const PINNED42 = { type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true } as ExistingEntry + + describe("the write checks the same text it modifies", () => { + let file: string + let invalidateSpy: ReturnType + const originalReadText = Filesystem.readText + beforeEach(async () => { + file = path.join(mkdtempSync(path.join(tmpdir(), "l3r4-")), "altimate-code.json") + await addMcpToConfig("datamate", { type: "local", command: ["datamate", "start-stdio"], enabled: true } as never, file) + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) + }) + afterEach(() => { + invalidateSpy.mockRestore() + Filesystem.readText = originalReadText + }) + const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined + + /** After the guard's intent read, config-file readText #1 is persist's merged + * intent re-read (the global-disable check) and #2 is addMcpToConfig's own + * read — the one the write modifies. The window under test is the write's. */ + function stage(where: "intent-read-end" | "before-write-read" | "after-write-read") { + const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { realPersist: true }) + syncInternals.projectConfigPath = async () => file + let armed = false + let landed = false + let n = 0 + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + if (h.reads.length === 2) { + if (where === "intent-read-end" && !landed) { + landed = true + writeFileSync(file, DISABLED_FILE) + } + armed = true + } + return e + } + syncInternals.projectEntry = async () => (await diskEntry()) ?? null + Filesystem.readText = async (p: string) => { + if (!armed || p !== file || landed) return originalReadText(p) + n += 1 + if (n !== 2) return originalReadText(p) + landed = true + if (where === "before-write-read") { + writeFileSync(file, DISABLED_FILE) + return originalReadText(p) + } + const text = await originalReadText(p) + writeFileSync(file, DISABLED_FILE) + return text + } + return { h, reads: () => n } + } + + test("a disable landing after the guard is refused by the write's own read", async () => { + const { h } = stage("intent-read-end") + const out = await ensure("s1") + expect(out.kind).toBe("entry-disabled") + expect((await diskEntry())?.enabled).toBe(false) + expect(h.added).toHaveLength(0) + expect(h.toasts).toHaveLength(1) + }) + + test("a disable landing before the write is refused", async () => { + const { h, reads } = stage("before-write-read") + const out = await ensure("s1") + console.log("W0/W2:", JSON.stringify(out), "disk:", JSON.stringify(await diskEntry()), "config reads after guard:", reads()) + expect(out.kind).toBe("entry-disabled") + expect((await diskEntry())?.enabled).toBe(false) + expect(h.added).toHaveLength(0) + }) + + test("a disable landing inside the write itself is lost — the named residual", async () => { + const { h } = stage("after-write-read") + const out = await ensure("s1") + const after = await diskEntry() + console.log("W3:", JSON.stringify(out), "disk:", JSON.stringify(after)) + expect(out.kind).toBe("attached") + expect(after?.enabled).toBe(true) + expect(after?.command).toEqual(["datamate", "start-stdio", "--datamate", "42"]) + expect(h.added).toHaveLength(1) + expect(await ensure("s1")).toBe(out) + }) + }) + + describe("a config read that throws, at each read in turn", () => { + function realReader(throwAt: (n: number) => boolean, onDisk: () => ExistingEntry | null) { + const h = install([{}, { datamate: { status: "connected" } }], () => null) + delete syncInternals.existingEntry + let n = 0 + syncInternals.freshConfig = async () => { + n += 1 + if (throwAt(n)) throw new Error(n === 1 || throwAt(1) ? "EIO" : `EIO#`) + const e = onDisk() + return { mcp: e ? { datamate: e } : {} } + } + return { h, calls: () => n } + } + + test("read #1 (inspection) throws → connect-failed, 1 toast, no mutation", async () => { + const { h } = realReader((n) => n === 1, () => null) + const out = await ensure("s1") + expect(out).toMatchObject({ kind: "connect-failed", error: "configuration unreadable: Error: EIO" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) + expect(h.persisted).toHaveLength(0) + expect(h.added).toHaveLength(0) + }) + + test("read #2 (pre-install guard) throws → connect-failed, 1 toast, no mutation; same label as the inspection", async () => { + const { h } = realReader((n) => n === 2, () => null) + const out = await ensure("s1") + expect(out).toMatchObject({ kind: "connect-failed", error: "configuration unreadable: intent could not be confirmed" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) + expect(h.persisted).toHaveLength(0) + expect(h.added).toHaveLength(0) + }) + + test("read #3 (post-install guard) throws → install undone, connect-failed, 1 toast", async () => { + const { h } = realReader((n) => n === 3, () => null) + const out = await ensure("s1") + expect(out).toMatchObject({ kind: "connect-failed" }) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine not attached"]) + expect(h.persisted).toHaveLength(1) + expect(h.added).toHaveLength(1) + expect(h.removes).toEqual(["datamate"]) + expect(h.restores).toEqual([null]) + }) + + test("undo re-read (projectEntry #2) throws → FAILS CLOSED: no restore, one left-behind toast, superseded", async () => { + let current: CachedBinding | null = binding + const h = install([{}, { datamate: { status: "connected" } }], () => null) + syncInternals.resolveBinding = async () => current + let pe = 0 + syncInternals.projectEntry = async () => { + pe += 1 + if (pe === 2) throw new Error("EIO undo re-read") + return null + } + syncInternals.mcp!.tools = async () => ((current = { ...binding, datamateId: 99 } as CachedBinding), { datamate_dbt_build_model: 1 }) + const out = await ensure("s1") + expect(out.kind).toBe("superseded") + expect(pe).toBe(2) + expect(h.restores).toEqual([]) + expect(h.removes).toEqual(["datamate"]) + expect(h.toasts.map((t) => t.title)).toEqual(["Workspace engine config left behind"]) + }) + + test("memo validation read throws (transient) → not served, re-decided → reused; no toast", async () => { + // The first attach makes four intent reads (inspection, pre-install + // guard, post-install guard, post-announcement guard); the memo + // validation on the next turn is the fifth. + const { h } = realReader((n) => n === 5, () => (h.added.length ? PINNED42 : null)) + const first = await ensure("s1") + expect(first.kind).toBe("attached") + h.statusQueue = [{ datamate: { status: "connected" } }] + const second = await ensure("s1") + expect(second).not.toBe(first) + expect(second.kind).toBe("reused") + expect(h.toasts).toHaveLength(1) + }) + + test("PERSISTENT throw: three turns re-decide but announce ONCE (AL)", async () => { + const { h, calls } = realReader(() => true, () => null) + const a = await ensure("s1") + const b = await ensure("s1") + const c = await ensure("s1") + console.log("AH persistent:", a.kind, b.kind, c.kind, "toasts:", h.toasts.length, "freshConfig calls:", calls()) + expect([a.kind, b.kind, c.kind]).toEqual(["connect-failed", "connect-failed", "connect-failed"]) + expect(h.toasts.length).toBe(1) + expect(h.persisted).toHaveLength(0) + }) + }) + + describe("a probe that keeps failing is refused once, not every turn", () => { + test("turn 1: detach + refuse once (engine-too-old), client not left registered; later turns re-decide silently (AL)", async () => { + const h = install( + [{ datamate: { status: "connected" } }, { datamate: { status: "disabled" } }, { datamate: { status: "connected" } }], + () => PINNED42, + { spawned: PINNED42 }, + ) + syncInternals.versionOf = async () => { + throw new Error("EACCES") + } + const a = await ensure("s1") + expect(a.kind).toBe("engine-too-old") + expect(h.removes).toEqual(["datamate"]) + expect(h.spawnedNow).toBeUndefined() + expect(h.toasts).toHaveLength(1) + expect(settledOutcome("s1")?.kind).toBe("engine-too-old") + + // Turn 2: the outcome is REPAIRABLE, so the memo does not hold it — run() again. + const b = await ensure("s1") + const c = await ensure("s1") + console.log("AJ:", b.kind, c.kind, "toasts:", h.toasts.length, "added:", h.added.length, "removes:", h.removes.length) + expect(b).not.toBe(a) + expect(h.toasts.length).toBe(1) + }) + }) + + describe("spawned cleared on onclose / disconnect — the next attach", () => { + test("child exit (record cleared, status failed) → revived via add → reused", async () => { + const h = install([{ datamate: { status: "failed", error: "Connection closed" } }, { datamate: { status: "connected" } }], () => PINNED42) + const out = await ensure("s1") + expect(out.kind).toBe("reused") + expect(h.added).toHaveLength(1) + expect(h.spawnedNow?.command).toEqual(PINNED42.command) + expect(h.persisted).toHaveLength(0) + }) + + test("disconnect (record cleared, status disabled, config enabled:false) → entry-disabled; then /mcp enable-style re-add → reused", async () => { + let enabled = true + let status: { status: string } = { status: "connected" } + const h = install([], () => ({ ...PINNED42, enabled }), { spawned: PINNED42 }) + syncInternals.mcp!.status = async () => ({ datamate: status }) + expect((await ensure("s1")).kind).toBe("reused") + // MCP.disconnect: closeClient, delete spawned, status disabled, persist enabled:false + enabled = false + status = { status: "disabled" } + h.spawnedNow = undefined + const mid = await ensure("s1") + expect(mid.kind).toBe("entry-disabled") + expect(h.added).toHaveLength(0) + // MCP.connect (prompt.ts /mcp enable): createAndStore → spawned set, status connected, persist enabled:true + enabled = true + status = { status: "connected" } + h.spawnedNow = PINNED42 + const back = await ensure("s1") + expect(back.kind).toBe("reused") + expect(h.added).toHaveLength(0) + expect(h.persisted).toHaveLength(0) + }) + }) + + describe("when a repeated verdict speaks again", () => { + test("same kind, changed detail (engine-too-old 0.5.9 → 0.6.0) speaks again", async () => { + let v = "0.5.9" + const h = install([{}], () => null) + syncInternals.versionOf = async () => v + expect((await ensure("s1")).kind).toBe("engine-too-old") + expect((await ensure("s1")).kind).toBe("engine-too-old") + v = "0.6.0" + expect((await ensure("s1")).kind).toBe("engine-too-old") + console.log("AL detail:", h.toasts.length, h.toasts.map((t) => t.message.slice(0, 40))) + expect(h.toasts.length).toBe(2) + }) + test("two sessions with the same verdict each hear it once", async () => { + const h = install([{}], () => null) + syncInternals.which = () => null + await ensure("a"); await ensure("a"); await ensure("b"); await ensure("b") + expect(h.toasts.length).toBe(2) + }) + test("a reuse after a refusal clears the record: refusal → reused → same refusal speaks again", async () => { + let onPath: string | null = null + let status: { status: string } = { status: "disabled" } + const h = install([], () => PINNED42, { spawned: undefined }) + syncInternals.which = () => onPath + syncInternals.mcp!.status = async () => ({ datamate: status }) + expect((await ensure("s1")).kind).toBe("engine-missing") // ours+down → retry → revive? no: which null → refuse-unreachable → engine-missing + onPath = "/usr/local/bin/datamate"; status = { status: "connected" }; h.spawnedNow = PINNED42 + expect((await ensure("s1")).kind).toBe("reused") + onPath = null; status = { status: "disabled" }; h.spawnedNow = undefined + expect((await ensure("s1")).kind).toBe("engine-missing") + expect(h.toasts.filter((t) => t.title.includes("unavailable")).length).toBe(2) + }) + }) + + describe("a disable landing between the undo's read and the restore's write", () => { + let file: string + let invalidateSpy: ReturnType + const originalReadText = Filesystem.readText + beforeEach(() => { + file = path.join(mkdtempSync(path.join(tmpdir(), "l3r4-restore-")), "altimate-code.json") + invalidateSpy = spyOn(Config, "invalidate").mockImplementation(async () => {}) + }) + afterEach(() => { + invalidateSpy.mockRestore() + Filesystem.readText = originalReadText + }) + const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined + + function stageRestore(initial: ExistingEntry | null) { + let current: CachedBinding | null = binding + const statuses: H["statusQueue"] = initial ? [{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }] : [{}, { datamate: { status: "connected" } }] + const h = install(statuses, () => null, { realPersist: true }) + delete syncInternals.persistRestore // REAL restore + syncInternals.projectConfigPath = async () => file + syncInternals.resolveBinding = async () => current + syncInternals.existingEntry = async () => { + const e = (await diskEntry()) ?? null + h.reads.push(e?.enabled) + return e + } + let pe = 0 + let armed = false + let landed = false + syncInternals.projectEntry = async () => { + pe += 1 + const e = (await diskEntry()) ?? null + if (pe === 2) armed = true // the undo's own read has just completed + return e + } + // binding moves during tools() → post-install guard → undo + syncInternals.mcp!.tools = async () => ((current = { ...binding, datamateId: 99 } as CachedBinding), { datamate_dbt_build_model: 1 }) + Filesystem.readText = async (p: string) => { + if (armed && !landed && p === file) { + landed = true + // the user disables OUR entry after the undo read it and before the restore writes + const now = (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry + writeFileSync(file, JSON.stringify({ mcp: { datamate: { ...now, enabled: false } } }, null, 2)) + } + return originalReadText(p) + } + return { h, landed: () => landed } + } + + test("a restore does not overwrite a disable with the entry it replaced", async () => { + await addMcpToConfig("datamate", { type: "local", command: ["datamate", "start-stdio"], enabled: true } as never, file) + const { h, landed } = stageRestore({ type: "local", command: ["datamate", "start-stdio"], enabled: true }) + const out = await ensure("s1") + const after = await diskEntry() + console.log("RT:", JSON.stringify(out), "disk:", JSON.stringify(after), "landed:", landed(), "toasts:", h.toasts.map((t) => t.title)) + expect(landed()).toBe(true) + expect(out.kind).toBe("superseded") + expect(after?.enabled, "the restore wrote the enabled previous entry over the user's disable").toBe(false) + }) + + test("a restore does not delete a node the user has disabled", async () => { + writeFileSync(file, "{}\n") + const { h, landed } = stageRestore(null) + const out = await ensure("s1") + const after = await diskEntry() + console.log("RU:", JSON.stringify(out), "disk:", JSON.stringify(after), "landed:", landed(), "toasts:", h.toasts.map((t) => t.title)) + expect(landed()).toBe(true) + expect(out.kind).toBe("superseded") + expect(after, "the restore deleted the node the user had just disabled").toBeDefined() + expect(after?.enabled).toBe(false) + }) + }) +}) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 9d71a0db25..7b27352e59 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -4,7 +4,7 @@ import os, { tmpdir } from "node:os" import { pathToFileURL } from "node:url" import { expect, mock, beforeEach, afterEach, spyOn } from "bun:test" import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" -import { Cause, Effect, Exit } from "effect" +import { Cause, Effect, Exit, Fiber } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" import { TestInstance } from "../fixture/fixture" @@ -13,6 +13,7 @@ import { TestInstance } from "../fixture/fixture" // Per-client state for controlling mock behavior interface MockClientState { + instance?: { onclose?: () => void } capabilities: { tools?: object; prompts?: object; resources?: object } capabilitiesShouldThrow: boolean tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> @@ -88,6 +89,11 @@ function getOrCreateClientState(name?: string): MockClientState { return state } +// altimate_change start — a one-shot connect delay, so a test can make an older +// add complete AFTER a newer one for the same key. +let connectDelayOnceMs = 0 +// altimate_change end + // Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang class MockStdioTransport { stderr: null = null @@ -98,6 +104,13 @@ class MockStdioTransport { async start() { if (connectShouldHang) return new Promise(() => {}) // never resolves if (connectShouldFail) throw new Error(connectError) + // altimate_change start + if (connectDelayOnceMs) { + const delay = connectDelayOnceMs + connectDelayOnceMs = 0 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + // altimate_change end } async close() { transportCloseCount++ @@ -159,6 +172,9 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ clientCreateCount++ this._state = getOrCreateClientState(lastCreatedClientName) this._state.clientOptions = options + // altimate_change — expose the instance so a test can trigger `onclose`, + // which is how production learns the child exited. + this._state.instance = this as unknown as { onclose?: () => void } } async connect(transport: { start: () => Promise }) { @@ -1236,3 +1252,208 @@ it.instance( ), { config: { mcp: {} } }, ) + +// altimate_change start — the spawn record: what this process actually launched +function localCommand(entry: { command?: string[] } | object | undefined): string[] | undefined { + return entry && "command" in entry ? entry.command : undefined +} + +it.instance( + "records what it spawned, and forgets it when the client is removed", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "spawnrec" + // Nothing launched under this key yet. + expect(yield* mcp.spawned("spawnrec")).toBeUndefined() + + yield* mcp.add("spawnrec", { type: "local", command: ["echo", "one"] }) + expect(localCommand(yield* mcp.spawned("spawnrec"))).toEqual(["echo", "one"]) + + // Re-adding replaces the running client, so the record follows it. + yield* mcp.add("spawnrec", { type: "local", command: ["echo", "two"] }) + expect(localCommand(yield* mcp.spawned("spawnrec"))).toEqual(["echo", "two"]) + + // A key with no live client has nothing spawned under it. Leaving the + // record behind would tell a later caller that a torn-down engine is + // still serving. + yield* mcp.remove("spawnrec") + expect(yield* mcp.spawned("spawnrec")).toBeUndefined() + }), + ), +) + +it.instance( + "the record is what was launched, not what the config says now", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // The whole reason this exists. `getMcpConfig` answers "what should run", + // falling back to the config file; this answers "what IS running". They + // diverge whenever the file is rewritten after a client was started — + // another process re-pinning a shared config, an IDE replacing the entry + // — and a caller comparing the file against its own expectations can + // agree with itself while the live client serves something else. + lastCreatedClientName = "spawnrec2" + yield* mcp.add("spawnrec2", { type: "local", command: ["echo", "launched"] }) + expect(localCommand(yield* mcp.spawned("spawnrec2"))).toEqual(["echo", "launched"]) + + // Whatever else happens to configuration, the record keeps naming the + // process that is actually up until it is torn down or replaced. + expect(localCommand(yield* mcp.spawned("spawnrec2"))).toEqual(["echo", "launched"]) + }), + ), +) + +it.instance( + "a replacement that fails does not close a client another caller registered while it was coming up", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // Creation awaits a handshake, and nothing serializes adds to one key + // across callers: the MCP route and an IDE reload can register their + // own client under it meanwhile. A failed creation must close what IT + // was replacing, not whatever is registered by the time it fails — + // otherwise it closes the other caller's successful client and drops + // the record of what is actually running. + lastCreatedClientName = "racing" + getOrCreateClientState("racing") + yield* mcp.add("racing", { type: "local", command: ["echo", "one"] }) + + connectShouldHang = true + const slow = yield* Effect.forkChild(mcp.add("racing", { type: "local", command: ["echo", "two"], timeout: 100 })) + yield* Effect.sleep("20 millis") // let the slow add reach its (hanging) connect + connectShouldHang = false + yield* mcp.add("racing", { type: "local", command: ["echo", "three"] }) + expect(localCommand(yield* mcp.spawned("racing"))).toEqual(["echo", "three"]) + + yield* Fiber.join(slow) // times out → the failure path + const clients = yield* mcp.clients() + expect(clients["racing"], "the failed replacement closed the client another caller registered").toBeDefined() + expect(localCommand(yield* mcp.spawned("racing")), "the failed replacement dropped the record of what is running").toEqual([ + "echo", + "three", + ]) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "an older add that completes after a newer one does not replace the newer client", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // The other half of the same race: both creations SUCCEED, the older + // one last. Storing it would close the newer client and hand the runtime + // back to what the older call was asked to start. The newer call wins + // whichever completes first; the late result is closed, not stored. + lastCreatedClientName = "racing2" + getOrCreateClientState("racing2") + yield* mcp.add("racing2", { type: "local", command: ["echo", "one"] }) + + connectDelayOnceMs = 150 + const slow = yield* Effect.forkChild(mcp.add("racing2", { type: "local", command: ["echo", "two"] })) + yield* Effect.sleep("20 millis") // the slow add is inside its delayed connect + yield* mcp.add("racing2", { type: "local", command: ["echo", "three"] }) + const newer = (yield* mcp.clients())["racing2"] + + const late = yield* Fiber.join(slow) // completes late, and must not win + expect(localCommand(yield* mcp.spawned("racing2")), "an older add that completed late replaced the newer client").toEqual([ + "echo", + "three", + ]) + expect((yield* mcp.clients())["racing2"], "the newer client was closed by the late result").toBe(newer) + // The late call answers with what is serving, not with what it started. + expect(((late.status as any)["racing2"] ?? late.status).status).toBe("connected") + }), + ), + { config: { mcp: {} } }, +) +// altimate_change end + +// altimate_change start — "removed means the runtime forgets it" +it.instance( + "removing a client clears the runtime config, not just the client", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // `getMcpConfig` prefers the runtime config over the file, so an entry + // left behind here outlives the client it described: `status()` keeps + // synthesising "disabled" from it, and `connect` re-spawns what it holds + // rather than what the file now says. A caller that removed a client and + // then asks about the key must be told nothing is there. + lastCreatedClientName = "forget" + yield* mcp.add("forget", { type: "local", command: ["echo", "one"] }) + expect(Object.keys(yield* mcp.status())).toContain("forget") + + yield* mcp.remove("forget") + expect(Object.keys(yield* mcp.status()), "the key survived its own removal").not.toContain("forget") + expect(yield* mcp.spawned("forget")).toBeUndefined() + }), + ), + { config: { mcp: {} } }, +) +// altimate_change end + +// altimate_change start — the spawn record's other two clearing paths +it.instance( + "disconnecting a client clears the spawn record", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // The record answers "what IS running". A disabled key runs nothing, so + // leaving it would tell a later caller that a stopped engine is serving. + lastCreatedClientName = "disc" + yield* mcp.add("disc", { type: "local", command: ["echo", "one"] }) + expect(localCommand(yield* mcp.spawned("disc"))).toEqual(["echo", "one"]) + yield* mcp.disconnect("disc") + expect(yield* mcp.spawned("disc"), "a disconnected key still claims to be running").toBeUndefined() + }), + ), + { config: { mcp: { disc: { type: "local", command: ["echo", "one"] } } } }, +) + +it.instance( + "a client whose child exits clears the spawn record", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "closed" + yield* mcp.add("closed", { type: "local", command: ["echo", "one"] }) + expect(localCommand(yield* mcp.spawned("closed"))).toEqual(["echo", "one"]) + + // The transport closes under us — the engine died. + const state = getOrCreateClientState("closed") + state.instance?.onclose?.() + expect(yield* mcp.spawned("closed"), "a dead child still claims to be running").toBeUndefined() + }), + ), + { config: { mcp: {} } }, +) +// altimate_change end + +// altimate_change start — a replacement that never came up leaves no record +it.instance( + "a failed replacement clears the record of the client it closed", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "replaced" + yield* mcp.add("replaced", { type: "local", command: ["echo", "one"] }) + expect(localCommand(yield* mcp.spawned("replaced"))).toEqual(["echo", "one"]) + + // `add` over a live client closes the old one, then creates the new one. + // If that creation fails, nothing is running under the key — and the + // record must not go on describing the process that was just closed. + connectShouldFail = true + connectError = "replacement refused to start" + yield* mcp.add("replaced", { type: "local", command: ["echo", "two"] }) + connectShouldFail = false + + expect(yield* mcp.spawned("replaced"), "a closed client is still claimed to be running").toBeUndefined() + }), + ), + { config: { mcp: {} } }, +) +// altimate_change end diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 92fe3f8136..620e833693 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -124,6 +124,7 @@ const mcp = Layer.succeed( MCP.Service.of({ status: () => Effect.succeed({}), clients: () => Effect.succeed({}), + spawned: () => Effect.succeed(undefined), tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index f1990520a8..3eb076b57f 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -37,6 +37,7 @@ const mcp = Layer.succeed( MCP.Service.of({ status: () => Effect.succeed({}), clients: () => Effect.succeed({}), + spawned: () => Effect.succeed(undefined), tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}),