diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 85531e36b3..9d4caaae54 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -37,6 +37,10 @@ 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 have no meaning on the CLI surface. + type: z.string().optional(), + // altimate_change end description: z.string().nullable().optional(), tools: z .array( @@ -227,19 +231,30 @@ 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 abort stays + // armed until the BODY is read: `fetch` resolves on headers. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15_000) + 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}`) + } + return await res.json() + } finally { + clearTimeout(timeout) } - return res.json() + // altimate_change end } export async function listDatamates() { diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 7e1bb6944d..849c99f0eb 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -13,6 +13,8 @@ import { Instance } from "../../project/instance" import { Global } from "../../global" import { Log } from "@/altimate/util/log" import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" +// altimate_change - workspace mode owns the datamate key +import { managedWorkspaceLoaded } from "../workspace/engine-overlay" const log = Log.create({ service: "datamate" }) @@ -138,22 +140,35 @@ async function handleList() { async function handleListIntegrations() { try { - const integrations = await AltimateApi.listIntegrations() + const catalog = await AltimateApi.listIntegrations() + // altimate_change start — extension-type integrations are RPC into a live VS + // Code host and cannot work from the CLI. Hide them from this surface (the + // workspace UI still offers them) and say how many were hidden. + const integrations = catalog.filter((i) => i.type !== "extension") + const hidden = catalog.length - integrations.length + 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.` + : "" if (integrations.length === 0) { 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: omitted ? `No integrations available. ${omitted}` : "No integrations available.", } } + // altimate_change end const lines = ["ID | Name | Tools", "---|------|------"] for (const i of integrations) { const tools = i.tools?.map((t) => t.key).join(", ") ?? "none" lines.push(`${i.id} | ${i.name} | ${tools}`) } + // altimate_change start + if (omitted) lines.push("", `(${omitted})`) + // altimate_change end return { title: `Integrations: ${integrations.length} available`, - metadata: { count: integrations.length }, + metadata: { count: integrations.length, hidden }, output: lines.join("\n"), } } catch (e) { @@ -176,11 +191,30 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p } } try { - const datamate = await AltimateApi.getDatamate(args.datamate_id) // readDatamateTransportFromIde returns the exact command from the IDE config so we // reuse the same process the extension already manages, not a second one. const transport = await readDatamateTransportFromIde(projectRoot()) + // altimate_change start — in workspace mode the shared `datamate` key is the + // bound workspace's own engine, derived at config load. With an IDE transport + // the add would go under that key; refuse and say why, before anything is + // looked up — the refusal must not depend on the API being reachable. + // Standalone `datamate-` entries are a different key and stay the user's. + const managed = transport !== null ? await managedWorkspaceLoaded() : null + if (managed) { + return { + title: `Datamate add: '${DATAMATE_KEY}' is managed by workspace "${managed.name}"`, + metadata: { serverName: DATAMATE_KEY, managedBy: managed.id, datamateId: args.datamate_id }, + output: + `This project is linked to workspace "${managed.name}", whose integrations are served by the ` + + `workspace's own engine under the '${DATAMATE_KEY}' MCP server. Adding datamate '${args.datamate_id}' ` + + `there is not applied. Unlink the project, or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`, + } + } + // altimate_change end + + const datamate = await AltimateApi.getDatamate(args.datamate_id) + if (transport !== null) { log.info("handleAdd: IDE transport detected, entering single-gateway mode", { serverName: DATAMATE_KEY, @@ -325,6 +359,23 @@ async function handleCreate(args: { } } try { + // altimate_change start — with an IDE transport the add that follows would go + // under the shared `datamate` key; in workspace mode that add is refused, so + // refuse here before creating an API datamate nothing would connect to. + if ((await readDatamateTransportFromIde(projectRoot())) !== null) { + const managedKey = await managedWorkspaceLoaded() + if (managedKey) { + return { + title: `Datamate create: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`, + metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id }, + output: + `This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` + + `workspace's own engine under the '${DATAMATE_KEY}' MCP server. Creating datamate '${args.name}' ` + + `here would not connect it. Unlink the project, or run without ALTIMATE_WORKSPACE, first.`, + } + } + } + // altimate_change end const integrations = args.integration_ids ? await AltimateApi.resolveIntegrations(args.integration_ids) : undefined @@ -487,6 +538,22 @@ async function handleRemove(args: { server_name?: string; scope?: "project" | "g } } try { + // altimate_change start — the workspace-managed `datamate` key is not the + // user's to remove either: it would stop the engine under a turn and delete + // the entry that unlinking hands back. Standalone `datamate-` entries + // are unaffected. + const managedKey = args.server_name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null + if (managedKey) { + return { + title: `Datamate remove: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`, + metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id }, + output: + `This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` + + `workspace's own engine under the '${DATAMATE_KEY}' MCP server. It is not removed. Unlink the project, ` + + `or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`, + } + } + // altimate_change end // Fully remove from runtime state (disconnect + purge from MCP list) // altimate_change start — MCP.remove (was disconnect): delete the status entry + publish // ToolsChanged so the removed server's tools stop being offered without a restart. diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts new file mode 100644 index 0000000000..6110c86848 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -0,0 +1,617 @@ +// altimate_change - new file +// +// The workspace engine overlay. +// +// A project bound to a workspace gets that workspace's integration tools from +// the local engine (`datamate start-stdio --datamate `), served under the +// `datamate` MCP key. This module derives that entry at config-load time and +// never writes it anywhere: +// +// config load → overlay(): bound + engine on PATH clearing the floor +// → `mcp.datamate` is the pinned local spawn, whatever any +// file, IDE or discovery pass put there; otherwise the key +// is removed so nothing else answers for the workspace. +// MCP bootstrap starts it like any configured stdio server and awaits it +// before the first tool list — first-turn readiness for free. +// turn boundary → beforeTurn(): re-read the binding; on a re-link reload +// config and replace the engine; on a failed handshake +// retry once per process; settle this session's outcome; +// tell the user once per verdict. +// +// What this deliberately is not: a reconciler over other writers of the key. +// In workspace mode the key is owned here — the in-process writers refuse it +// (see `managedWorkspace`), and anything another process changes is observed +// at the next turn boundary. The tools a turn holds are the ones resolved at +// that turn's start. +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { MCP } from "@/mcp" +import { Config } from "@/config/config" +import { + currentDirectory, + isEnabled, + isHeadless, + isServe, + log, + syncInternals, + type ScopedBinding, +} from "./engine-seams" +import { declaredBounded, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" +import { + ENGINE_BINARY, + INSTALL_COMMAND, + REPAIRABLE, + TOOL_PREFIX, + clearsFloor, + describeMissing, + describeRefusal, + engineEntry, + engineToolKeys, + isMcpEntry, + type Declared, + type LocalMcpConfig, + type McpEntry, + type McpStatus, + type Outcome, + type Toast, +} from "./engine-types" + +export * from "./engine-types" +export { isEnabled, isHeadless, isServe, syncInternals } from "./engine-seams" + +/** Sessions remembered per process. It is a memo; an evicted session just re-settles. */ +export const MAX_TRACKED_SESSIONS = 256 +/** A failed probe is repeated at most this often, so a missing engine does not + * cost a process spawn on every turn while still being noticed once installed. */ +export const FAILED_PROBE_TTL_MS = 30_000 +/** A failed allowlist lookup is retried at most this often. */ +const DECLARED_RETRY_MS = 60_000 + +// ── the engine on PATH ────────────────────────────────────────────────────── + +type Probe = { kind: "ok"; version: string } | { kind: "missing" } | { kind: "too-old"; found: string | null } + +let probeMemo: { result: Probe; at: number } | null = null + +function now(): number { + return syncInternals.now ? syncInternals.now() : Date.now() +} + +async function probeEngine(): Promise { + const at = now() + if (probeMemo && (probeMemo.result.kind === "ok" || at - probeMemo.at < FAILED_PROBE_TTL_MS)) { + return probeMemo.result + } + const bin = which(ENGINE_BINARY) + let result: Probe + if (!bin) { + result = { kind: "missing" } + } else { + const version = await versionOf(bin) + result = clearsFloor(version) ? { kind: "ok", version: version! } : { kind: "too-old", found: version } + } + probeMemo = { result, at } + return result +} + +/** Forget the last probe, so the next turn boundary looks for the engine + * again immediately. The install offer calls this after an install. */ +export function invalidateProbe(): void { + probeMemo = null +} + +// ── the overlay ───────────────────────────────────────────────────────────── + +type Overlay = { + directory: string + /** `key` is the workspace's identity across accounts — ids are tenant-local, + * so the same number in another tenant is another workspace, and a session + * that switched accounts must not keep the old one's engine or inventory. */ + workspace: { id: string; name: string; key: string } + /** The derived entry, or null when the engine is unusable. */ + entry: LocalMcpConfig | null + refusal: Extract | null +} + +/** Per-directory state. Config and MCP state are per project instance, and one + * server process can host several directories, so the overlay is keyed the same + * way — a module-wide value would let project B's overlay start B's engine + * inside A's MCP state. */ +type DirectoryState = { + /** The overlay as of the last config load for this directory. */ + current: Overlay | null + /** Turn hooks for one directory run one at a time. Sessions in a directory + * share the key (a sub-agent's session is enough to make two concurrent), + * and a hook's binding read, reload and engine replacement must not + * interleave with another's — otherwise one session's boundary could + * replace the engine between another's read and its apply. */ + chain: Promise + /** What MCP is believed to be running under the key: the overlay as it stood + * when MCP bootstrapped (config load precedes MCP init, which reads the cached + * config), then whatever the turn hook last applied. `undefined` until the + * first turn boundary. Kept apart from `current` because any consumer can + * invalidate and reload config between turns, re-running the overlay without + * touching MCP. */ + applied: Overlay | null | undefined + /** When the last overlay attempt threw. A failed attempt is retried at the + * probe TTL, not on every turn — each retry invalidates the whole config. */ + failedAt?: number + /** The key is set by organisation-managed config: nothing here claims it. */ + managed?: boolean +} +const directories = new Map() + +function stateFor(directory: string): DirectoryState { + let state = directories.get(directory) + if (!state) { + state = { current: null, applied: undefined, chain: Promise.resolve() } + directories.set(directory, state) + } + return state +} + +/** Identity of the workspace a binding names: the credential scope it was + * read under plus the tenant-local id. */ +function workspaceKey(binding: ScopedBinding): string { + return `${binding.scope ?? ""}|${binding.datamateId}` +} + +function sameEntry(a: LocalMcpConfig | null, b: LocalMcpConfig | null): boolean { + return !!a && !!b && a.command.join("\0") === b.command.join("\0") +} + +/** Derive the `datamate` entry for a bound directory into `config.mcp`. + * + * Called from the config loader after external MCP discovery, so it has the + * last word over every other source of the key. Mutates `config.mcp` only when + * the directory is bound with the pilot on. Never throws. */ +export async function overlay( + directory: string, + config: { mcp?: Record }, + opts: { managed?: boolean } = {}, +): Promise { + const state = stateFor(directory) + state.failedAt = undefined + state.managed = opts.managed === true + try { + if (!isEnabled() || isServe()) { + state.current = null + return + } + if (opts.managed) { + // Organisation-managed config (MDM) is authoritative over everything, + // this overlay included: the key stays as managed, and nothing here + // claims it, so its writers are not refused either. + log.info("workspace engine overlay skipped: the datamate key is set by managed preferences", { directory }) + state.current = null + return + } + const binding = await resolveBinding(directory) + if (!binding) { + // Logged because "flag on, nothing happened" is the question every + // first-run report asks; the directory is the usual answer. + log.info("workspace engine overlay skipped: directory is not bound", { directory }) + state.current = null + return + } + const workspace = { + id: String(binding.datamateId), + name: binding.datamateName, + key: workspaceKey(binding), + } + const probe = await probeEngine() + if (probe.kind === "ok") { + const entry = engineEntry(workspace.id) + config.mcp ??= {} + config.mcp[DATAMATE_KEY] = entry + state.current = { directory, workspace, entry, refusal: null } + log.info("workspace engine overlay applied", { workspaceId: workspace.id, version: probe.version }) + return + } + // No hosted fallback in workspace mode: the hosted endpoint serves a + // different tool set, and an IDE's unpinned engine serves whichever + // teammate is active there. Either would answer for the workspace with + // tools it did not declare. + if (config.mcp && DATAMATE_KEY in config.mcp) delete config.mcp[DATAMATE_KEY] + state.current = { + directory, + workspace, + entry: null, + refusal: probe.kind === "missing" ? { kind: "engine-missing" } : { kind: "engine-too-old", found: probe.found }, + } + log.info("workspace engine overlay refused", { workspaceId: workspace.id, reason: probe.kind }) + } catch (err) { + log.warn("workspace engine overlay failed; leaving the MCP config as loaded", { err: String(err) }) + state.current = null + state.failedAt = now() + } +} + +/** The workspace that owns the `datamate` key for the current instance's + * directory, or null. + * + * Synchronous, for the in-process writers of that key (the reload endpoint, + * the HTTP add route, `datamate_manager add`): in workspace mode they refuse + * the key and say why, instead of replacing the engine underneath a turn. */ +export function managedWorkspace(directory: string | null = currentDirectory()): { id: string; name: string } | null { + if (!directory) return null + const state = directories.get(directory) + // While a transient overlay failure is being retried, the turn boundary + // keeps the applied engine running; the key stays owned for that long too, + // or a writer could replace the very engine the sessions are still using. + const workspace = state?.current?.workspace ?? (state?.failedAt !== undefined ? state.applied?.workspace : undefined) + return workspace ? { id: workspace.id, name: workspace.name } : null +} + +/** `managedWorkspace` once the overlay has run for this instance. The overlay + * runs inside config load, and on a fresh instance a writer's request can be + * the first thing that happens — asked before the load, the key looks free. */ +export async function managedWorkspaceLoaded( + directory: string | null = currentDirectory(), +): Promise<{ id: string; name: string } | null> { + if (!directory) return null + await config().get() + return managedWorkspace(directory) +} + +// ── per-session outcome ───────────────────────────────────────────────────── + +/** `retried`: this session already spent its one re-add on a failed handshake. + * Per session, so "start a new session to try again" is true. */ +type SessionRecord = { outcome: Outcome; announced?: string; retried?: boolean } +const sessions = new Map() +const declaredCache = new Map() + +function record(sessionID: string, outcome: Outcome): SessionRecord { + const previous = sessions.get(sessionID) + sessions.delete(sessionID) + const next: SessionRecord = { outcome, announced: previous?.announced, retried: previous?.retried } + sessions.set(sessionID, next) + while (sessions.size > MAX_TRACKED_SESSIONS) { + const oldest = sessions.keys().next().value + if (oldest === undefined) break + sessions.delete(oldest) + } + return next +} + +/** The outcome a session settled at its last turn boundary. A pure read; + * `undefined` before the first `beforeTurn` for that session. */ +export function settledOutcome(sessionID: string): Outcome | undefined { + return sessions.get(sessionID)?.outcome +} + +function mcp() { + return ( + syncInternals.mcp ?? { + status: () => MCP.status() as Promise, + add: (name: string, cfg: LocalMcpConfig | McpEntry) => MCP.add(name, cfg as Parameters[1]), + remove: (name: string) => MCP.remove(name), + tools: () => MCP.tools() as Promise>, + } + ) +} + +function config() { + return ( + syncInternals.config ?? { + invalidate: () => Config.invalidate(), + get: async () => (await Config.get()) as { mcp?: Record }, + } + ) +} + +/** Hand the key back to whatever the reloaded config says now that the overlay + * no longer fills it: the user's own hosted or IDE-written entry, if any. MCP + * enumerates live clients only, so a restored config entry must be started or + * the project's standalone datamate tools stay gone for the rest of the process. */ +async function releaseKey(loaded: { mcp?: Record } | undefined, hadEngine: boolean): Promise { + if (hadEngine) await mcp().remove(DATAMATE_KEY) + const restored = loaded?.mcp?.[DATAMATE_KEY] + if (isMcpEntry(restored) && restored.enabled !== false) { + log.info("workspace engine released the datamate key; starting the configured entry", { type: restored.type }) + await mcp().add(DATAMATE_KEY, restored) + } +} + +async function declaredFor(workspace: { id: string; key: string }): Promise { + // Cached per workspace identity, not per id: the same id in another tenant + // is another allowlist. + const cached = declaredCache.get(workspace.key) + if (cached && (cached.value || now() - cached.at < DECLARED_RETRY_MS)) return cached.value + const value = await declaredBounded(workspace.id) + declaredCache.set(workspace.key, { value, at: now() }) + return value +} + +/** Reconcile, settle and announce for one session. Runs at the start of every + * user turn, before the tool list is resolved. Never throws. */ +export async function beforeTurn(sessionID: string): Promise { + await atTurnStart(sessionID, async () => undefined) +} + +/** Run the turn boundary and then `body` — the turn's tool cataloguing — under + * the directory's lock, so no other session's boundary can replace the engine + * between this session's reconcile and its catalog snapshot. The hook's own + * failures are logged and swallowed; `body`'s propagate. */ +export async function atTurnStart(sessionID: string, body: () => Promise): Promise { + if (!isEnabled() || isServe()) { + record(sessionID, { kind: "disabled" }) + return body() + } + const directory = currentDirectory() + if (!directory) { + record(sessionID, { kind: "unbound" }) + return body() + } + const state = stateFor(directory) + const run = state.chain.then(async () => { + try { + await reconcile(sessionID, directory, state) + } catch (err) { + log.warn("workspace engine turn hook failed", { sessionID, err: String(err) }) + } + return body() + }) + state.chain = run.then( + () => undefined, + () => undefined, + ) + return run +} + +/** The engine tools a turn catalogued first, kept for its later catalogs. + * `resolveTools` re-snapshots MCP on every step, so a re-link applied by + * another session's boundary mid-turn would otherwise be re-catalogued here; + * pinning keeps this turn on the engine its boundary read. A call through a + * pinned wrapper after a replacement reaches the closed client and fails — it + * never routes to the other workspace. */ +const turnTools = new Map>() + +export function pinTurnTools(sessionID: string, firstCatalog: boolean, tools: Record): void { + if (!isEnabled() || isServe()) return + const engine = Object.fromEntries(Object.entries(tools).filter(([key]) => key.startsWith(TOOL_PREFIX))) + if (firstCatalog) { + turnTools.delete(sessionID) + turnTools.set(sessionID, engine) + while (turnTools.size > MAX_TRACKED_SESSIONS) { + const oldest = turnTools.keys().next().value + if (oldest === undefined) break + turnTools.delete(oldest) + } + return + } + const pinned = turnTools.get(sessionID) + if (!pinned) return + for (const key of Object.keys(engine)) delete tools[key] + for (const [key, tool] of Object.entries(pinned)) tools[key] = tool as T +} + +async function reconcile(sessionID: string, directory: string, state: DirectoryState): Promise { + // The overlay runs inside config load; make sure it has run at least once. + await config().get() + // First turn boundary: MCP bootstrapped from the config as loaded, i.e. from + // the overlay as it stands now. + if (state.applied === undefined) state.applied = state.current + + // Organisation-managed config owns the key: the feature is off for this + // directory, whatever the binding says. Nothing to reload per turn. + if (state.managed) { + if (state.applied?.entry) await releaseKey(await config().get(), true) + state.applied = null + record(sessionID, { kind: "disabled" }) + return + } + + const binding = await resolveBinding(directory) + if (!binding) { + // Unlinked (or never linked): the key is not ours to fill. + let loaded: { mcp?: Record } | undefined + if (state.current || state.applied) { + await config().invalidate() + loaded = await config().get() + } + // Whether the overlay had an engine running or had refused one (and so had + // removed the key from the config it shadowed), the key is handed back. + if (state.applied) await releaseKey(loaded, !!state.applied.entry) + state.applied = null + record(sessionID, { kind: "unbound" }) + return + } + const boundKey = workspaceKey(binding) + + // Reload the overlay when the binding moved — to another workspace, or the + // same id under another account — or when a refused engine may have + // appeared since (the probe memo bounds how often that is asked). + let reload = state.current + ? state.current.workspace.key !== boundKey + : state.failedAt === undefined || now() - state.failedAt >= FAILED_PROBE_TTL_MS + if (!reload && state.current && !state.current.entry) { + const probe = await probeEngine() + reload = probe.kind === "ok" + } + let loaded: { mcp?: Record } | undefined + if (reload) { + await config().invalidate() + loaded = await config().get() + } + + // A transient overlay failure (its retry is throttled above) keeps what was + // last applied for this same workspace: a running engine is not released + // over a fault in the probe. After a relink nothing is kept — workspace A's + // engine must not serve a directory now bound to B. + const retained = state.failedAt !== undefined && state.applied?.workspace.key === boundKey ? state.applied : null + const overlayNow = state.current ?? retained + if (!overlayNow) { + if (state.failedAt === undefined) { + if (state.applied) await releaseKey(loaded, !!state.applied.entry) + state.applied = null + record(sessionID, { kind: "unbound" }) + return + } + // Bound, but the overlay could not be derived. Whatever runs under the key + // is dropped and nothing is handed back: the reloaded config may carry a + // raw IDE or hosted entry, and that must not answer for this workspace. + if (state.applied?.entry || DATAMATE_KEY in (await mcp().status())) await mcp().remove(DATAMATE_KEY) + state.applied = null + // Say so, once, rather than settling a bound directory as unbound in silence. + const outcome: Outcome = { kind: "connect-failed", error: "the workspace engine could not be checked" } + record(sessionID, outcome) + await announceRefusal(sessionID, outcome, { + title: `Workspace "${binding.datamateName}": engine unavailable`, + message: `${outcome.error}; it is checked again shortly.`, + variant: "warning", + }) + return + } + const workspace = overlayNow.workspace + + // Bring MCP in line with the overlay: start or replace the engine when the + // derived entry changed, drop it when there is none any more. + if (overlayNow.entry) { + // The argv is the same for the same id in another tenant; the engine + // reads its credentials when it starts, so it is replaced on identity, not + // only on argv. + const replaced = + !sameEntry(state.applied?.entry ?? null, overlayNow.entry) || state.applied?.workspace.key !== workspace.key + if (replaced) await mcp().add(DATAMATE_KEY, overlayNow.entry) + } else if (state.applied?.entry || DATAMATE_KEY in (await mcp().status())) { + // Ours to drop — or a client that predates the link, which MCP bootstrapped + // from an IDE or hosted entry while the directory was unbound. With the + // overlay refusing, nothing may serve the workspace under the key. + await mcp().remove(DATAMATE_KEY) + } + state.applied = overlayNow + + if (!overlayNow.entry) { + const refusal = overlayNow.refusal ?? { kind: "engine-missing" as const } + if (refusal.kind === "engine-missing") { + const declared = await declaredFor(workspace) + const count = declared?.keys.length + const outcome: Outcome = + count === undefined ? { kind: "engine-missing" } : { kind: "engine-missing", declared: count } + record(sessionID, outcome) + const what = + count === undefined + ? `Workspace "${workspace.name}" has integration tools that run on the local engine, which is not installed.` + : `Workspace "${workspace.name}" declares ${count} integration tool${count === 1 ? "" : "s"}. They run on the local engine, which is not installed.` + await announceRefusal(sessionID, outcome, { + title: `Workspace "${workspace.name}" needs the local engine`, + message: `${what} Install it with: ${INSTALL_COMMAND}`, + variant: "warning", + }) + return + } + record(sessionID, refusal) + await announceRefusal(sessionID, refusal, { + title: `Workspace "${workspace.name}": engine not usable`, + message: describeRefusal(refusal.found, workspace.name), + variant: "warning", + }) + return + } + + // The engine is configured. The first status call boots MCP, which awaits + // the engine's handshake; the allowlist lookup overlaps with it. + const [statusMap, declared] = await Promise.all([mcp().status(), declaredFor(workspace)]) + let status = statusMap[DATAMATE_KEY] + const session = sessions.get(sessionID) + if (status?.status !== "connected" && !session?.retried) { + ;(session ?? record(sessionID, { kind: "connect-failed", error: "retrying" })).retried = true + log.info("workspace engine not connected; retrying once for this session", { + workspaceId: workspace.id, + sessionID, + status: status?.status, + }) + await mcp().add(DATAMATE_KEY, overlayNow.entry) + status = (await mcp().status())[DATAMATE_KEY] + } + if (status?.status !== "connected") { + const outcome: Outcome = { + kind: "connect-failed", + error: status?.error ?? `engine status: ${status?.status ?? "unknown"}`, + } + record(sessionID, outcome) + await announceRefusal(sessionID, outcome, { + title: `Workspace "${workspace.name}": engine failed to start`, + message: `${outcome.error}. Start a new session to try again.`, + variant: "error", + }) + return + } + + const present = engineToolKeys(await mcp().tools()) + const missing = declared ? declared.keys.filter((k) => !present.has(k)) : undefined + // `available` is everything the engine serves under the key. The engine adds + // tools beyond the allowlist (knowledge, memory) when the workspace enables + // them, so the "N of M declared" line counts only the declared ones present. + const served = declared ? declared.keys.length - (missing?.length ?? 0) : present.size + const outcome: Outcome = { + kind: "attached", + available: present.size, + ...(declared ? { declared: declared.keys.length, missing } : {}), + } + const rec = record(sessionID, outcome) + // Keyed on the workspace too: a re-link with an identical inventory is still + // a new verdict the user should hear. + const signature = `attached:${workspace.key}:${outcome.available}:${outcome.declared ?? "?"}:${(missing ?? []).join(",")}` + if (rec.announced === signature) return + rec.announced = signature + log.info("workspace engine attached", { + workspaceId: workspace.id, + available: outcome.available, + declared: outcome.declared, + missing, + }) + if (isHeadless()) return + await notify({ + title: `Workspace "${workspace.name}"`, + message: declared + ? `${served} of ${declared.keys.length} declared integration tools available.${describeMissing(missing ?? [])}` + : `${outcome.available} integration tools available.`, + variant: missing && missing.length > 0 ? "warning" : "info", + }) +} + +/** Tell the session about a refusal, once per unchanged verdict. + * + * The substitution point for the install offer: when `installWouldHelp(outcome)` + * a dialog replaces the toast here; it never adds a second message. Headless + * `run` prints one stderr line instead. */ +export async function announceRefusal(sessionID: string, outcome: Outcome, toast: Toast): Promise { + const rec = sessions.get(sessionID) ?? record(sessionID, outcome) + const detail = "error" in outcome ? outcome.error : "found" in outcome ? String(outcome.found) : "" + const declared = "declared" in outcome ? String(outcome.declared ?? "?") : "" + const signature = `${outcome.kind}:${detail}:${declared}:${toast.title}` + if (rec.announced === signature) return + rec.announced = signature + if (isHeadless()) { + printLine(`${toast.title}: ${toast.message}`) + return + } + await notify(toast) +} + +/** Is a re-probe worth asking for on the next turn? Exposed for the install + * offer, which schedules nothing itself: it installs, invalidates the probe, + * and the next turn boundary attaches. */ +export function isRepairable(outcome: Outcome | undefined): boolean { + return !!outcome && REPAIRABLE[outcome.kind] +} + +/** Test-only: forget everything this process learned. */ +export function resetForTests(): void { + directories.clear() + probeMemo = null + sessions.clear() + turnTools.clear() + declaredCache.clear() +} + +/** Test-only views. */ +export function overlayForTests(directory?: string): Overlay | null { + const dir = directory ?? currentDirectory() + return dir ? (directories.get(dir)?.current ?? null) : null +} +export function trackedSessionsForTests(): number { + return sessions.size +} 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..1ca22bb5b1 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -0,0 +1,158 @@ +// altimate_change - new file +// +// Everything that asks the outside world a question: the binary, its +// version, the workspace allowlist, and the user-facing surfaces. +import launch from "cross-spawn" +import { which as whichBinary } from "@opencode-ai/core/util/which" +import { AltimateApi } from "@/altimate/api/client" +import { AppRuntime } from "@/effect/app-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { readLocalBindingScoped } from "./state" +import { log, syncInternals, type ScopedBinding } from "./engine-seams" +import type { Declared, Toast } from "./engine-types" + +/** How long the allowlist lookup may hold a turn. Once per workspace per process. */ +export const DECLARED_TIMEOUT_MS = 4_000 + +export async function resolveBinding(directory: string): Promise { + if (syncInternals.resolveBinding) return syncInternals.resolveBinding(directory) + try { + // One credential snapshot validates the hit and names its scope, so the + // binding cannot be paired with another tenant's scope by a credentials + // change between two reads. The id alone is tenant-local. + const { binding, scope } = await readLocalBindingScoped(directory) + if (!binding) return null + return { ...binding, scope: scope ?? undefined } + } catch (err) { + log.warn("could not resolve the workspace binding", { err: String(err) }) + return null + } +} + +export function which(cmd: string): string | null { + return syncInternals.which ? syncInternals.which(cmd) : whichBinary(cmd) +} + +/** `datamate --version`, stdout only. The engine prints its real package + * version here; its MCP `serverInfo` was a hard-coded placeholder on the very + * engines the floor excludes, so the handshake cannot be asked instead. + * + * cross-spawn, not execFile: an npm-installed engine on Windows resolves to a + * `.cmd` shim that Node cannot execute without a shell. */ +/** How long `--version` may take before the engine counts as unreadable. */ +export const VERSION_TIMEOUT_MS = 5_000 + +export function versionOf(bin: string): Promise { + if (syncInternals.versionOf) return syncInternals.versionOf(bin) + return new Promise((resolve) => { + let settled = false + let timer: ReturnType | undefined + const done = (value: string | null) => { + if (settled) return + settled = true + if (timer) clearTimeout(timer) + resolve(value) + } + try { + const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }) + let out = "" + child.stdout?.on("data", (chunk) => { + out += String(chunk) + }) + // Settle on `exit`, not `close`: a descendant that inherited stdout would + // keep `close` from firing after the engine itself has answered. The + // deadline is ours as well — the runtime's `timeout` only signals the + // direct child, so it could not end a wait on a straggler's pipe. + timer = setTimeout(() => { + try { + child.kill("SIGKILL") + } catch { + // Already gone. + } + child.stdout?.destroy() + done(null) + }, VERSION_TIMEOUT_MS) + child.on("error", () => done(null)) + child.on("exit", (code) => { + // Let any bytes still in flight land before reading `out`. + setImmediate(() => { + child.stdout?.destroy() + if (code !== 0) return done(null) + const line = out.trim().split(/\r?\n/)[0] ?? "" + done(line || null) + }) + }) + } catch { + done(null) + } + }) +} + +/** The workspace allowlist, split by whether the CLI can serve it. */ +export async function declared(workspaceId: string): Promise { + if (syncInternals.declared) return syncInternals.declared(workspaceId) + try { + if (!(await AltimateApi.isConfigured())) return null + const [workspace, catalog] = await Promise.all([ + AltimateApi.getDatamate(workspaceId), + 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 the declared workspace integrations", { workspaceId, err: String(err) }) + return null + } +} + +/** The allowlist, bounded. Reporting only; the losing timer is cancelled so a + * lookup that succeeded in time is not later reported as timed out. */ +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 { + 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 the workspace engine toast", { err: String(err) }) + } +} + +/** stderr, deliberately: `run --format json` documents stdout as raw JSON + * events, and this is a status notice, not run output. */ +export function printLine(line: string): void { + if (syncInternals.printLine) return syncInternals.printLine(line) + try { + process.stderr.write(line + "\n") + } catch { + // A closed stream must not take down the turn. + } +} 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..0e53f00632 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -0,0 +1,69 @@ +// altimate_change - new file +// +// Ambient access and the single test seam. `syncInternals` stays ONE flat +// object on purpose: it is the override surface every consumer reaches for. +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, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types" + +export const log = Log.create({ service: "workspace-engine" }) + +/** Test seams. Production leaves every field unset. */ +/** A binding plus the credential scope it was read under (`tenant|apiUrl`). + * Absent when the scope could not be resolved. */ +export type ScopedBinding = CachedBinding & { scope?: string } + +export const syncInternals: { + resolveBinding?: (directory: string) => Promise + which?: (cmd: string) => string | null + versionOf?: (bin: string) => Promise + declared?: (workspaceId: string) => Promise + notify?: (toast: Toast) => Promise + printLine?: (line: string) => void + instanceDirectory?: () => string | null + headless?: () => boolean + serve?: () => boolean + now?: () => number + mcp?: { + status: () => Promise + add: (name: string, cfg: LocalMcpConfig | McpEntry) => Promise + remove: (name: string) => Promise + tools: () => Promise> + } + config?: { + invalidate: () => Promise + /** Loads config, which runs the overlay as a side effect, and returns the + * loaded `mcp` map. */ + get: () => Promise<{ mcp?: Record }> + } +} = {} + +export function isEnabled(): boolean { + return CoreFlag.ALTIMATE_WORKSPACE +} + +/** Headless `run`: no TUI can render a toast, so refusals print one stderr + * line. An env var because it must be readable from every module realm. */ +export function isHeadless(): boolean { + if (syncInternals.headless) return syncInternals.headless() + return process.env["ALTIMATE_CODE_HEADLESS"] === "1" +} + +/** `altimate serve` — the extension's host process. Workspace mode is + * terminal-only: the extension runs its own engine and bridge under the same + * key, and overriding it there would remove its extension-type tools. */ +export function isServe(): boolean { + if (syncInternals.serve) return syncInternals.serve() + return process.env["ALTIMATE_CODE_SERVE"] === "1" +} + +export function currentDirectory(): string | null { + if (syncInternals.instanceDirectory) return syncInternals.instanceDirectory() + try { + return Instance.directory + } catch { + return null + } +} 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..728c7abe37 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -0,0 +1,232 @@ +// altimate_change - new file +// +// Vocabulary for the workspace engine overlay: the outcome union, the +// derived MCP entry, and the pure predicates over them. Nothing here performs +// I/O or reads ambient state. +import { DATAMATE_KEY } from "@/altimate/datamate-transport" + +/** Oldest engine this client works against. + * + * 0.7.0 is the first engine that LOCKS the `--datamate` pin, so a settings + * change in the IDE cannot swap the workspace out from under a running engine. + * Everything below it can drift. */ +export const MIN_ENGINE_VERSION = "0.7.0" +export const ENGINE_PACKAGE = "@altimateai/datamate" +export const ENGINE_BINARY = "datamate" +export const INSTALL_COMMAND = `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}` + +/** Engine tools arrive under the MCP server key as `_`. */ +export const TOOL_PREFIX = `${DATAMATE_KEY}_` + +/** What a session knows about its workspace engine, settled at the turn + * boundary. `undefined` (never settled) is distinct from every kind here. */ +export type Outcome = + | { kind: "disabled" } + | { kind: "unbound" } + | { kind: "attached"; available: number; declared?: number; missing?: string[] } + | { kind: "engine-missing"; declared?: number } + /** `found` is null when the binary ran but printed nothing usable — broken + * rather than old; the message says so. */ + | { kind: "engine-too-old"; found: string | null } + | { kind: "connect-failed"; error: string } + +/** The MCP entry this module derives. Never written to disk. */ +export type LocalMcpConfig = { + type: "local" + command: string[] + enabled: true +} + +/** A configured MCP entry as the loader hands it to MCP (any transport). */ +export type McpEntry = { type: string } & Record + +export function isMcpEntry(value: unknown): value is McpEntry { + return typeof value === "object" && value !== null && typeof (value as { type?: unknown }).type === "string" +} + +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[] } + +/** 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. */ +export type EntryLike = { + type?: string + url?: string + command?: string[] | string + args?: string[] +} + +export const PIN_FLAG = "--datamate" + +export function engineEntry(workspaceId: string): LocalMcpConfig { + return { type: "local", command: [ENGINE_BINARY, "start-stdio", PIN_FLAG, workspaceId], enabled: true } +} + +/** SemVer precedence compare. Returns <0, 0, >0. + * + * Build metadata is ignored. A NON-numeric or non-three-part core compares as + * older than any readable one, so unreadable `--version` output can never + * clear the floor (`parseInt` would read "7rc" as 7). Pre-releases rank below + * their release (SemVer §11.3): the floor names behaviour that shipped in a + * specific release, and a pre-release of it predates that. */ +export function compareVersions(a: string, b: string): number { + 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) + 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] + } + 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 +} + +/** Is this engine version usable? An unreadable version is below the floor: + * an engine that cannot say what it is cannot be shown to lock its pin. */ +export function clearsFloor(version: string | null): boolean { + return !!version && compareVersions(version, MIN_ENGINE_VERSION) >= 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 +} + +/** The entry's full argv, flattening both config shapes. */ +export function commandArgv(entry: EntryLike | 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. An entry + * without it serves whichever teammate its owner has active, which changes at + * runtime from a UI this client does not control — the extension writes + * exactly such an entry. Scanned from the end (repeated flag → last wins); + * both `--datamate 5` and `--datamate=5` are valid. Fails open on a miss. */ +export function pinnedWorkspace(entry: EntryLike | null): string | null { + const argv = commandArgv(entry) + for (let i = argv.length - 1; i >= 0; i--) { + const arg = argv[i] + if (typeof arg !== "string") continue + if (arg === PIN_FLAG) return typeof argv[i + 1] === "string" ? argv[i + 1] : null + if (arg.startsWith(`${PIN_FLAG}=`)) return arg.slice(PIN_FLAG.length + 1) || null + } + return null +} + +/** Why an engine was refused, in the user's terms. "Too old" and "could not be + * run" are one code path and very different problems; conflating them sent + * more than one debugging session hunting a version mismatch that did not + * exist. */ +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 serve workspace ` + + `"${workspaceName}". It is more likely broken than out of date — try \`${ENGINE_BINARY} --version\` ` + + `directly. Reinstall with: ${INSTALL_COMMAND}` + ) + } + return ( + `Found ${ENGINE_BINARY} ${found}; workspace "${workspaceName}" needs ${MIN_ENGINE_VERSION} or newer. ` + + `Update with: ${INSTALL_COMMAND}` + ) +} + +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}.` +} + +/** What each outcome MEANS, as tables over the whole union: a new variant + * fails to compile until every table names it, and the safe answer is false. */ +export const SERVING: Record = { + attached: true, + disabled: false, + unbound: false, + "engine-missing": false, + "engine-too-old": false, + "connect-failed": false, +} + +/** Would installing the engine fix this outcome? Only genuine unobtainability + * qualifies; a failed connection is not an absence. */ +export const INSTALL_HELPS: Record = { + "engine-missing": true, + "engine-too-old": true, + attached: false, + disabled: false, + unbound: false, + "connect-failed": false, +} + +/** Outcomes a later turn may re-probe for, because the world can have changed + * in a way the user was told how to fix. */ +export const REPAIRABLE: Record = { + "engine-missing": true, + "engine-too-old": true, + "connect-failed": true, + attached: false, + disabled: false, + unbound: false, +} + +/** Is the engine serving this session attributable to its bound workspace? + * `undefined` means not settled and must stay distinguishable from a refusal: + * the precedence consumer fails open on it. */ +export function attributableEngine(outcome: Outcome | undefined): boolean { + return !!outcome && SERVING[outcome.kind] +} + +export function installWouldHelp(outcome: Outcome | undefined): boolean { + return !!outcome && INSTALL_HELPS[outcome.kind] +} diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 1c5930e02a..c75c415e92 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -189,8 +189,26 @@ async function tenantKey(): Promise<{ tenant: string; apiUrl: string } | null> { * unresolved key (macOS ``/tmp`` → ``/private/tmp``), then relies on direct * lookup for the process's remaining lifetime. */ export async function readLocalBinding(directory: string): Promise { + return (await readLocalBindingScoped(directory)).binding +} + +/** `readLocalBinding` plus the credential scope (`tenant|apiUrl`) the hit was + * validated against — one credential snapshot for both, so a binding can never + * be paired with another tenant's scope. Workspace ids are tenant-local; the + * scope is what tells the same id in two tenants apart. */ +export async function readLocalBindingScoped( + directory: string, +): Promise<{ binding: CachedBinding | null; scope: string | null }> { const key = await tenantKey() - if (!key) return null + if (!key) return { binding: null, scope: null } + const scope = `${key.tenant}|${key.apiUrl}` + return { binding: await readCachedBinding(directory, key), scope } +} + +async function readCachedBinding( + directory: string, + key: { tenant: string; apiUrl: string }, +): Promise { let cache = readCache() if (!cache) return null if (cache.tenant !== key.tenant || cache.apiUrl !== key.apiUrl) return null diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 50638bcd14..3f6bf66bd7 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -375,6 +375,11 @@ export const RunCommand = cmd({ // altimate_change end }, handler: async (args) => { + // altimate_change start — mark the headless surface. Nothing here can render a + // toast, so workspace engine refusals degrade to one stderr line. An env var + // because it must be readable from every module realm. + process.env["ALTIMATE_CODE_HEADLESS"] = "1" + // altimate_change end // altimate_change start — `run` is the only entrypoint without an answer // channel for the question tool: no TUI is mounted and the in-process // Server.Default() shim below does not bind a port, so a connected IDE diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 52ac4646c1..b817cded2e 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -19,6 +19,12 @@ export const ServeCommand = effectCmd({ // need for an ambient project InstanceContext at startup. instance: false, handler: Effect.fn("Cli.serve")(function* (args) { + // altimate_change start — mark the extension-host surface. Workspace mode is + // terminal-only: under `serve` the extension runs its own engine and bridge + // under the `datamate` key, and the overlay must leave it alone. An env var + // because it must be readable from every module realm. + process.env["ALTIMATE_CODE_SERVE"] = "1" + // altimate_change end const { Server } = yield* Effect.promise(() => import("../../server/server")) if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28b5cb0ade..6e59e55901 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -33,6 +33,9 @@ import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigManaged } from "./managed" +// altimate_change start — the workspace engine overlay yields to managed config for this key +import { DATAMATE_KEY } from "../altimate/datamate-transport" +// altimate_change end import { ConfigParse } from "./parse" import { ConfigPaths } from "./paths" import { ConfigPlugin } from "./plugin" @@ -641,6 +644,9 @@ export const layer = Layer.effect( ) } + // altimate_change start — whether organisation-managed config sets the datamate MCP key + let managedOwnsDatamate = false + // altimate_change end const managedDir = ConfigManaged.managedConfigDir() if (existsSync(managedDir)) { // altimate_change start - support altimate-code.json config filename @@ -654,20 +660,25 @@ export const layer = Layer.effect( ]) { // altimate_change end const source = path.join(managedDir, file) - yield* merge(source, yield* loadFile(source), "global") + // altimate_change start — note a managed datamate key before merging + const managedFile = yield* loadFile(source) + if (managedFile?.mcp && DATAMATE_KEY in managedFile.mcp) managedOwnsDatamate = true + yield* merge(source, managedFile, "global") + // altimate_change end } } // macOS managed preferences (.mobileconfig deployed via MDM) override everything const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) if (managed) { - result = mergeConfigConcatArrays( - result, - yield* loadConfig(managed.text, { - dir: path.dirname(managed.source), - source: managed.source, - }), - ) + // altimate_change start — note a managed datamate key before merging + const managedPrefs = yield* loadConfig(managed.text, { + dir: path.dirname(managed.source), + source: managed.source, + }) + if (managedPrefs.mcp && DATAMATE_KEY in managedPrefs.mcp) managedOwnsDatamate = true + result = mergeConfigConcatArrays(result, managedPrefs) + // altimate_change end } for (const [name, mode] of Object.entries(result.mode ?? {})) { @@ -740,6 +751,19 @@ export const layer = Layer.effect( } // altimate_change end + // altimate_change start — workspace engine overlay. When the pilot is on and + // this directory is bound to a workspace, the `datamate` MCP entry is the + // workspace's pinned local engine — derived here, after discovery, so it has + // the last word over IDE-written, hosted and stale entries, and never written + // to any file. See altimate/workspace/engine-overlay.ts. + if (Flag.ALTIMATE_WORKSPACE) { + const { overlay } = yield* Effect.promise(() => import("../altimate/workspace/engine-overlay")) + yield* Effect.promise(() => + overlay(ctx.directory, result as { mcp?: Record }, { managed: managedOwnsDatamate }), + ) + } + // altimate_change end + return { config: result, directories, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 27f2e85a14..27720e0123 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -951,6 +951,11 @@ export const layer = Layer.effect( yield* closeClient(s, name) delete s.clients[name] delete s.status[name] + // "Removed" means the runtime forgets it. `s.config` is what `getMcpConfig` + // prefers over the file, so a retained entry has `status()` synthesising + // "disabled" for the rest of the process and `connect` re-spawning the + // removed configuration instead of what the file now says. + delete s.config[name] yield* events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore) }) // altimate_change end diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index a6fb064d73..86ced98317 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -28,6 +28,13 @@ export class UnsupportedOAuthError extends Schema.ErrorClass("McpServerManagedError")( + { error: Schema.String }, + { httpApiStatus: 409 }, +) {} +// altimate_change end export const McpPaths = { status: "/mcp", @@ -56,7 +63,9 @@ export const McpApi = HttpApi.make("mcp") query: WorkspaceRoutingQuery, payload: AddPayload, success: described(StatusMap, "MCP server added successfully"), - error: HttpApiError.BadRequest, + // altimate_change start — the workspace-managed refusal is a declared error + error: [HttpApiError.BadRequest, McpServerManagedError], + // altimate_change end }).annotateMerge( OpenApi.annotations({ identifier: "mcp.add", @@ -118,7 +127,9 @@ export const McpApi = HttpApi.make("mcp") params: { name: Schema.String }, query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server connected successfully"), - error: McpServerNotFoundError, + // altimate_change start — the workspace-managed key refuses connect/disconnect + error: [McpServerNotFoundError, McpServerManagedError], + // altimate_change end }).annotateMerge( OpenApi.annotations({ identifier: "mcp.connect", @@ -129,7 +140,9 @@ export const McpApi = HttpApi.make("mcp") params: { name: Schema.String }, query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server disconnected successfully"), - error: McpServerNotFoundError, + // altimate_change start — the workspace-managed key refuses connect/disconnect + error: [McpServerNotFoundError, McpServerManagedError], + // altimate_change end }).annotateMerge( OpenApi.annotations({ identifier: "mcp.disconnect", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index cdf0cc1e70..f89bd4596b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -4,16 +4,45 @@ import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { McpServerNotFoundError } from "../errors" import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp" +// altimate_change start — workspace mode owns the datamate key +import { InstanceState } from "@/effect/instance-state" +import { Config } from "@/config/config" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { managedWorkspace } from "@/altimate/workspace/engine-overlay" +import { McpServerManagedError } from "../groups/mcp" +// altimate_change end export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) => Effect.gen(function* () { const mcp = yield* MCP.Service + // altimate_change start — config is loaded before the managed-key check + const configSvc = yield* Config.Service + // altimate_change end const status = Effect.fn("McpHttpApi.status")(function* () { return yield* mcp.status() }) const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { + // altimate_change start — in workspace mode the `datamate` key is the bound + // workspace's own engine, derived at config load; adding over it would replace + // the engine underneath a turn. Refuse and say why. + if (ctx.payload.name === DATAMATE_KEY) { + // The overlay runs inside config load; on a fresh instance this can be + // the first request, so load before asking who owns the key. + yield* configSvc.get() + const managed = managedWorkspace(yield* InstanceState.directory) + if (managed) { + yield* Effect.logWarning("mcp add refused: key is managed by a workspace", { + name: DATAMATE_KEY, + workspace: managed.id, + }) + return yield* new McpServerManagedError({ + error: `MCP server "${DATAMATE_KEY}" is managed by workspace "${managed.name}" in this project`, + }) + } + } + // altimate_change end const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status return yield* Schema.decodeUnknownEffect(StatusMap)( "status" in result ? { [ctx.payload.name]: result } : result, @@ -72,7 +101,28 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler return { success: true as const } }) + // altimate_change start — connect/disconnect persist `enabled` for the key and + // restart or close its client: neither may touch the workspace-managed + // `datamate`, which is derived per process and never written to a file. + const refuseManaged = Effect.fn("McpHttpApi.refuseManaged")(function* (name: string) { + if (name !== DATAMATE_KEY) return + yield* configSvc.get() + const managed = managedWorkspace(yield* InstanceState.directory) + if (!managed) return + yield* Effect.logWarning("mcp connect/disconnect refused: key is managed by a workspace", { + name: DATAMATE_KEY, + workspace: managed.id, + }) + return yield* new McpServerManagedError({ + error: `MCP server "${DATAMATE_KEY}" is managed by workspace "${managed.name}" in this project`, + }) + }) + // altimate_change end + const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { + // altimate_change start + yield* refuseManaged(ctx.params.name) + // altimate_change end yield* mcp .connect(ctx.params.name) .pipe( @@ -86,6 +136,9 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler }) const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { + // altimate_change start + yield* refuseManaged(ctx.params.name) + // altimate_change end yield* mcp .disconnect(ctx.params.name) .pipe( diff --git a/packages/opencode/src/server/routes/mcp.ts b/packages/opencode/src/server/routes/mcp.ts index 303e68086f..1a9dfcd742 100644 --- a/packages/opencode/src/server/routes/mcp.ts +++ b/packages/opencode/src/server/routes/mcp.ts @@ -2,6 +2,10 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { MCP } from "../../mcp" +// altimate_change start — workspace mode owns the datamate key +import { DATAMATE_KEY } from "../../altimate/datamate-transport" +import { managedWorkspaceLoaded } from "../../altimate/workspace/engine-overlay" +// altimate_change end // altimate_change start — Config.Mcp + MCP.Status migrated to Effect Schema in v1.17.9; convert to zod for HTTP schemas import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { zod } from "@/util/effect-zod" @@ -59,6 +63,15 @@ export const McpRoutes = lazy(() => ), async (c) => { const { name, config } = c.req.valid("json") + // altimate_change start — workspace mode owns the `datamate` key + const managed = name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null + if (managed) { + return c.json( + { error: `MCP server "${name}" is managed by workspace "${managed.name}" in this project` }, + 400, + ) + } + // altimate_change end const result = await MCP.add(name, config) return c.json(result.status) }, @@ -202,6 +215,15 @@ export const McpRoutes = lazy(() => validator("param", z.object({ name: z.string() })), async (c) => { const { name } = c.req.valid("param") + // altimate_change start — the workspace-managed key is not restarted or persisted from here + const managed = name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null + if (managed) { + return c.json( + { error: `MCP server "${name}" is managed by workspace "${managed.name}" in this project` }, + 409, + ) + } + // altimate_change end await MCP.connect(name) return c.json(true) }, @@ -225,6 +247,15 @@ export const McpRoutes = lazy(() => validator("param", z.object({ name: z.string() })), async (c) => { const { name } = c.req.valid("param") + // altimate_change start — the workspace-managed key is not closed or persisted from here + const managed = name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null + if (managed) { + return c.json( + { error: `MCP server "${name}" is managed by workspace "${managed.name}" in this project` }, + 409, + ) + } + // altimate_change end await MCP.disconnect(name) return c.json(true) }, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6f6af78a1a..d8c9caf23a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -34,6 +34,8 @@ import { MCP } from "../mcp" // Import sync + fresh-read helpers directly from the shared transport module. // Using datamate-transport.ts instead of serve.ts avoids a dep on a cmd handler. import { syncDatamateUrlFromVscodeMcp } from "../altimate/datamate-transport" +// altimate_change - workspace mode owns the datamate key +import { managedWorkspaceLoaded } from "../altimate/workspace/engine-overlay" import { readMcpEntryFromDisk } from "../mcp/config" import { resolveConfigPath } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" @@ -685,6 +687,16 @@ export namespace Server { .post("/altimate/mcp/reload-datamate", async (c) => { try { const directory = Instance.directory + // In workspace mode the `datamate` key is the bound workspace's own engine, + // derived at config load; an IDE reload must not replace it under a turn. + const managed = await managedWorkspaceLoaded() + if (managed) { + log.info("reload-datamate: refused, key is managed by a workspace", { workspace: managed.id }) + return c.json( + { ok: false, error: `The datamate MCP server is managed by workspace "${managed.name}" in this project.` }, + 409, + ) + } log.info("reload-datamate: syncing IDE MCP config", { directory }) // Sync IDE MCP config → altimate-code.json; returns updated server names. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 268babfc66..5f6d320cfb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -25,6 +25,10 @@ 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" +// altimate_change start — workspace engine turn boundary and managed-key refusal +import * as WorkspaceEngine from "../altimate/workspace/engine-overlay" +import { DATAMATE_KEY } from "../altimate/datamate-transport" +// altimate_change end import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -409,6 +413,11 @@ export namespace SessionPrompt { let structuredOutput: unknown | undefined let step = 0 + // altimate_change start — first tool catalog of this loop. `step` counts loop + // iterations, and an iteration can `continue` before cataloguing (pending + // compaction, context overflow), so "step === 1" is not "first catalog". + let catalogued = false + // altimate_change end // altimate_change start (AI-7519) — capture bootstrap start; emitted as a // single "bootstrap" span right before the first processor.process call so // the pre-first-generation region has a visible parent duration in traces. @@ -1013,21 +1022,35 @@ export namespace SessionPrompt { // cost etc.). Distinct span name per phase so telemetry doesn't // double-count non-bootstrap turns under "bootstrap.*", and the TUI // falls back to the safe "Thinking..." label on later turns. - const tools = await traceSpan( - step === 1 ? "bootstrap.resolve-tools" : "turn.resolve-tools", - () => - resolveTools({ - agent, - session, - model, - tools: lastUser.tools, - processor, - bypassAgentCheck, - messages: msgs, - }), - { step, agent: agent.name }, - sessionID, - ) + const catalog = () => + traceSpan( + step === 1 ? "bootstrap.resolve-tools" : "turn.resolve-tools", + () => + resolveTools({ + agent, + session, + model, + tools: lastUser.tools, + processor, + bypassAgentCheck, + messages: msgs, + }), + { step, agent: agent.name }, + sessionID, + ) + // Workspace engine turn boundary, on the turn's FIRST catalog (not its first + // loop iteration — a compaction can run before any catalog): reconcile the + // bound workspace's engine (re-link, one retry on a failed handshake), settle + // this session's outcome, announce it once per verdict — then catalog the + // tools under the same per-directory lock, so another session's boundary + // cannot replace the engine between this reconcile and this snapshot. The + // cold engine boot happens inside MCP's own bootstrap, bounded by its + // per-server timeout. Later catalogs keep the engine tools this turn started + // with. + const firstCatalog = !catalogued + catalogued = true + const tools = firstCatalog ? await WorkspaceEngine.atTurnStart(sessionID, catalog) : await catalog() + WorkspaceEngine.pinTurnTools(sessionID, firstCatalog, tools) // altimate_change end // Inject StructuredOutput tool if JSON schema mode enabled @@ -2951,6 +2974,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) const model = await lastModel(input.sessionID) + // The workspace-managed `datamate` key is derived per process: this + // command must not close or restart that engine, nor persist `enabled` + // for it. Asked before the config check — a refused engine has no + // config entry at all, and "not found" would be the wrong answer. + const managed = name === DATAMATE_KEY ? await WorkspaceEngine.managedWorkspaceLoaded() : null + if (managed) { + return respond( + userMsg.info.id, + `MCP server **${name}** is managed by workspace **${managed.name}** in this project and cannot be ${subCmd}d here. Unlink the project, or run without ALTIMATE_WORKSPACE, to manage it by hand.`, + model, + ) + } // MCP.connect/disconnect on an unknown name logs and returns silently, so // validate against config first and give the user a clear signal on a typo. const cfg = await Config.get() diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 4773f11ec6..11d6ee9a5e 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -176,6 +176,11 @@ export const BashTool = Tool.define("bash", async () => { // process.env spread above would silently disable that path in every // nested server invocation. See PR #937 review (Issue #3). delete mergedEnv["ALTIMATE_NON_INTERACTIVE"] + // Same reasoning for the headless marker: `run` sets it so the workspace + // engine's refusals degrade to a printed line, but a nested entrypoint + // launched from here may well have a TUI. Left in place, the child would + // inherit "headless" and print to stderr instead of showing its surface. + delete mergedEnv["ALTIMATE_CODE_HEADLESS"] // altimate_change end const sep = process.platform === "win32" ? ";" : ":" const basePath = mergedEnv.PATH ?? mergedEnv.Path ?? "" diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts new file mode 100644 index 0000000000..eaea653cb9 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -0,0 +1,778 @@ +// altimate_change - new file +// +// The workspace engine overlay, end to end through its seams: what the config +// loader gets, what a turn boundary does, what the session is told. No +// instance is booted, no process spawned, no MCP state touched. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { + FAILED_PROBE_TTL_MS, + INSTALL_COMMAND, + MAX_TRACKED_SESSIONS, + atTurnStart, + beforeTurn, + invalidateProbe, + pinTurnTools, + managedWorkspace, + overlay, + overlayForTests, + pinnedWorkspace, + resetForTests, + settledOutcome, + syncInternals, + trackedSessionsForTests, + type Declared, + type LocalMcpConfig, + type McpEntry, + type Toast, +} from "../../../src/altimate/workspace/engine-overlay" +import type { ScopedBinding } from "../../../src/altimate/workspace/engine-seams" +import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" + +const DIR = "/tmp/analytics" +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + +const bound = (id: number, name = "analytics", scope = "acme|https://api.acme.example"): ScopedBinding => + ({ datamateId: id, datamateName: name, repoRemote: null, projectPath: DIR, linkedAt: 0, scope }) as ScopedBinding + +type Harness = { + config: { mcp?: Record } + binding: ScopedBinding | null + which: string | null + version: string | null + status: string + statusError?: string + onAdd?: () => void + tools: Record + added: Array + removes: number + gets: number + invalidates: number + probes: number + toasts: Toast[] + lines: string[] + clock: number + /** Whether MCP holds a client under the key — set when MCP "bootstraps" from + * the first config load, then tracked through add/remove, as in the runtime. */ + live?: boolean +} + +function install(opts: { + flag?: boolean + serve?: boolean + headless?: boolean + binding?: ScopedBinding | null + which?: string | null + version?: string | null + declared?: Declared | null + status?: string + statusError?: string + onAdd?: () => void + tools?: Record + mcp?: Record + noMcpKey?: boolean + managed?: boolean +}): Harness { + const h: Harness = { + config: opts.noMcpKey ? {} : { mcp: opts.mcp ?? {} }, + binding: opts.binding === undefined ? bound(42) : opts.binding, + which: opts.which === undefined ? "/usr/local/bin/datamate" : opts.which, + version: opts.version === undefined ? "0.7.0" : opts.version, + status: opts.status ?? "connected", + statusError: opts.statusError, + onAdd: opts.onAdd, + tools: opts.tools ?? { datamate_dbt_build_model: {}, datamate_dbt_compile_model: {} }, + added: [], + removes: 0, + gets: 0, + invalidates: 0, + probes: 0, + toasts: [], + lines: [], + clock: 1_000_000, + } + process.env.ALTIMATE_WORKSPACE = opts.flag === false ? "" : "1" + syncInternals.serve = () => opts.serve === true + syncInternals.headless = () => opts.headless === true + syncInternals.instanceDirectory = () => DIR + syncInternals.resolveBinding = async () => h.binding + syncInternals.which = () => h.which + syncInternals.versionOf = async () => { + h.probes += 1 + return h.version + } + syncInternals.declared = async () => + opts.declared === undefined + ? { keys: ["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"], extensionKeys: [] } + : opts.declared + syncInternals.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.printLine = (line) => { + h.lines.push(line) + } + syncInternals.now = () => h.clock + syncInternals.mcp = { + status: async () => + h.live ? { datamate: { status: h.status, ...(h.statusError ? { error: h.statusError } : {}) } } : {}, + add: async (_name, cfg) => { + h.live = true + h.added.push(cfg) + h.onAdd?.() + }, + remove: async () => { + h.live = false + h.removes += 1 + }, + tools: async () => h.tools, + } + // Models the real Config cache: `get` loads once and is then served from + // cache until `invalidate`; a load rebuilds the config from its sources (so + // nothing the overlay injected earlier survives a reload) and runs the overlay. + const initialMcp = opts.mcp ? structuredClone(opts.mcp) : undefined + let loaded = false + syncInternals.config = { + invalidate: async () => { + h.invalidates += 1 + loaded = false + }, + get: async () => { + h.gets += 1 + if (loaded) return h.config + h.config = opts.noMcpKey ? {} : { mcp: structuredClone(initialMcp ?? {}) } + await overlay(DIR, h.config, { managed: opts.managed === true }) + // MCP bootstraps from the config as first loaded — after the overlay had + // its say — and keeps whatever client that started until told otherwise. + if (h.live === undefined) h.live = DATAMATE_KEY in (h.config.mcp ?? {}) + loaded = true + return h.config + }, + } + return h +} + +beforeEach(() => resetForTests()) +afterEach(() => { + resetForTests() + for (const key of Object.keys(syncInternals)) delete (syncInternals as Record)[key] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG +}) + +const IDE_ENTRY = { type: "local", command: ["datamate", "start-stdio"] } +const HOSTED_ENTRY = { type: "remote", url: "https://mcpserver.example.invalid/sse" } + +describe("overlay — what the config loader gets", () => { + test("flag off leaves config.mcp untouched and nothing is managed", async () => { + const h = install({ flag: false, mcp: { datamate: IDE_ENTRY, github: { type: "remote", url: "x" } } }) + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ datamate: IDE_ENTRY, github: { type: "remote", url: "x" } }) + expect(managedWorkspace()).toBeNull() + expect(h.probes).toBe(0) + }) + + test("under serve the overlay is inert even when bound", async () => { + const h = install({ serve: true, mcp: { datamate: IDE_ENTRY } }) + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ datamate: IDE_ENTRY }) + expect(managedWorkspace()).toBeNull() + }) + + test("an unbound directory leaves config.mcp untouched, including a hosted entry", async () => { + const h = install({ binding: null, mcp: { datamate: HOSTED_ENTRY } }) + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ datamate: HOSTED_ENTRY }) + expect(managedWorkspace()).toBeNull() + expect(h.probes).toBe(0) + }) + + test("an unbound directory with no mcp block gets none", async () => { + const h = install({ binding: null, noMcpKey: true }) + await overlay(DIR, h.config) + expect("mcp" in h.config).toBe(false) + }) + + test("a bound directory with a usable engine injects the pinned entry over an IDE entry", async () => { + const h = install({ mcp: { datamate: IDE_ENTRY, github: { type: "remote", url: "x" } } }) + await overlay(DIR, h.config) + const entry = h.config.mcp!.datamate as LocalMcpConfig + expect(entry).toEqual({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }) + expect(pinnedWorkspace(entry)).toBe("42") + expect(h.config.mcp!.github).toEqual({ type: "remote", url: "x" }) + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + }) + + test("a bound directory with a usable engine replaces a hosted entry", async () => { + const h = install({ mcp: { datamate: HOSTED_ENTRY } }) + await overlay(DIR, h.config) + expect((h.config.mcp!.datamate as LocalMcpConfig).command).toContain("--datamate") + }) + + test("a bound directory with a usable engine creates the mcp block when there was none", async () => { + const h = install({ noMcpKey: true }) + await overlay(DIR, h.config) + expect(pinnedWorkspace(h.config.mcp!.datamate as LocalMcpConfig)).toBe("42") + }) + + test("a bound directory without an engine removes the key rather than falling back", async () => { + const h = install({ which: null, mcp: { datamate: HOSTED_ENTRY, github: { type: "remote", url: "x" } } }) + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ github: { type: "remote", url: "x" } }) + expect(overlayForTests()?.refusal).toEqual({ kind: "engine-missing" }) + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + }) + + test("an engine below the floor is refused with its version; one that prints nothing is refused as broken", async () => { + const old = install({ version: "0.6.3", mcp: { datamate: IDE_ENTRY } }) + await overlay(DIR, old.config) + expect(old.config.mcp).toEqual({}) + expect(overlayForTests()?.refusal).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + + resetForTests() + const broken = install({ version: null }) + await overlay(DIR, broken.config) + expect(overlayForTests()?.refusal).toEqual({ kind: "engine-too-old", found: null }) + }) + + test("a pre-release of the floor version does not clear it", async () => { + const h = install({ version: "0.7.0-beta.1" }) + await overlay(DIR, h.config) + expect(overlayForTests()?.entry).toBeNull() + }) + + test("a usable engine is probed once per process; a failed probe is asked again only after the TTL", async () => { + const h = install({}) + await overlay(DIR, h.config) + await overlay(DIR, h.config) + expect(h.probes).toBe(1) + + resetForTests() + const missing = install({ version: "0.6.3" }) + await overlay(DIR, missing.config) + await overlay(DIR, missing.config) + expect(missing.probes).toBe(1) + missing.clock += FAILED_PROBE_TTL_MS + await overlay(DIR, missing.config) + expect(missing.probes).toBe(2) + }) + + test("a binding read that throws leaves the config as loaded", async () => { + const h = install({ mcp: { datamate: IDE_ENTRY } }) + syncInternals.resolveBinding = async () => { + throw new Error("cache unreadable") + } + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ datamate: IDE_ENTRY }) + expect(managedWorkspace()).toBeNull() + }) +}) + +describe("beforeTurn — what a turn boundary does", () => { + test("flag off settles disabled and touches nothing", async () => { + const h = install({ flag: false }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "disabled" }) + expect(h.gets).toBe(0) + expect(h.added).toEqual([]) + }) + + test("settledOutcome is undefined before the first turn boundary", async () => { + install({}) + expect(settledOutcome("never")).toBeUndefined() + }) + + test("unbound settles unbound without touching MCP", async () => { + const h = install({ binding: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + expect(h.added).toEqual([]) + expect(h.removes).toBe(0) + expect(h.toasts).toEqual([]) + }) + + test("a connected engine settles attached with the inventory and announces it once", async () => { + const h = install({}) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ + kind: "attached", + available: 2, + declared: 3, + missing: ["dbt_execute_sql"], + }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].message).toContain("2 of 3 declared integration tools available") + expect(h.toasts[0].message).toContain("dbt_execute_sql") + // The engine was started by MCP bootstrap from the injected entry, not by the hook. + expect(h.added).toEqual([]) + await beforeTurn("s1") + expect(h.toasts).toHaveLength(1) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("the inventory counts declared tools that are present, not every tool the engine serves", async () => { + const h = install({ + tools: { datamate_dbt_build_model: {}, datamate_dbt_compile_model: {}, datamate_altimate_knowledge_search: {} }, + declared: { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] }, + }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [] }) + expect(h.toasts[0].message).toBe("2 of 2 declared integration tools available.") + }) + + test("attached without an allowlist reports only what is available", async () => { + const h = install({ declared: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2 }) + expect(h.toasts[0].message).toBe("2 integration tools available.") + }) + + test("the inventory is announced per session, not per process", async () => { + const h = install({}) + await beforeTurn("s1") + await beforeTurn("s2") + expect(h.toasts).toHaveLength(2) + }) + + test("a missing engine settles engine-missing with the declared count and announces the install command once", async () => { + const h = install({ which: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "engine-missing", declared: 3 }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].message).toContain("declares 3 integration tools") + expect(h.toasts[0].message).toContain(INSTALL_COMMAND) + expect(h.added).toEqual([]) + await beforeTurn("s1") + expect(h.toasts).toHaveLength(1) + }) + + test("a missing engine with no reachable allowlist still names the install command", async () => { + const h = install({ which: null, declared: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "engine-missing" }) + expect(h.toasts[0].message).toContain(INSTALL_COMMAND) + }) + + test("an engine below the floor settles engine-too-old and says which version was found", async () => { + const h = install({ version: "0.6.3" }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + expect(h.toasts[0].message).toContain("Found datamate 0.6.3") + }) + + test("headless run prints exactly one stderr line for a refusal and no toast", async () => { + const h = install({ which: null, headless: true }) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.lines).toHaveLength(1) + expect(h.lines[0]).toContain(INSTALL_COMMAND) + expect(h.toasts).toEqual([]) + }) + + test("headless run stays silent on the happy path", async () => { + const h = install({ headless: true }) + await beforeTurn("s1") + expect(h.lines).toEqual([]) + expect(h.toasts).toEqual([]) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a failed handshake is retried once per session, then settles connect-failed and is announced once", async () => { + const h = install({ status: "failed", statusError: "Connection closed" }) + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(settledOutcome("s1")).toEqual({ kind: "connect-failed", error: "Connection closed" }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].message).toContain("Connection closed") + expect(h.toasts[0].message).toContain("Start a new session") + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + // The toast says a new session will try again, so a new session does. + await beforeTurn("s2") + expect(h.added).toHaveLength(2) + expect(h.toasts).toHaveLength(2) + await beforeTurn("s2") + expect(h.added).toHaveLength(2) + }) + + test("overlay state is kept per directory, so one process can host two bound projects", async () => { + const DIR_B = "/tmp/growth" + const h = install({}) + const bindings: Record = { [DIR]: bound(42), [DIR_B]: bound(7, "growth") } + syncInternals.resolveBinding = async (directory) => bindings[directory] ?? null + const configA: { mcp?: Record } = { mcp: {} } + const configB: { mcp?: Record } = { mcp: {} } + await overlay(DIR, configA) + await overlay(DIR_B, configB) + expect(pinnedWorkspace(configA.mcp!.datamate as LocalMcpConfig)).toBe("42") + expect(pinnedWorkspace(configB.mcp!.datamate as LocalMcpConfig)).toBe("7") + expect(overlayForTests(DIR)?.workspace.id).toBe("42") + expect(overlayForTests(DIR_B)?.workspace.id).toBe("7") + // The writers ask for the current instance's directory. + syncInternals.instanceDirectory = () => DIR_B + expect(managedWorkspace()).toEqual({ id: "7", name: "growth" }) + syncInternals.instanceDirectory = () => DIR + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + // An Effect-side caller passes the instance directory explicitly. + expect(managedWorkspace(DIR_B)).toEqual({ id: "7", name: "growth" }) + expect(managedWorkspace("/tmp/elsewhere")).toBeNull() + // A's turn boundary sees A's overlay: nothing to reapply, no engine started for B. + await beforeTurn("s1") + expect(h.added).toEqual([]) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a retry that succeeds settles attached", async () => { + const h = install({ status: "failed" }) + h.onAdd = () => { + h.status = "connected" + } + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a re-link reloads the overlay and replaces the engine for the new workspace", async () => { + const h = install({}) + await beforeTurn("s1") + expect(managedWorkspace()?.id).toBe("42") + h.binding = bound(7, "growth") + await beforeTurn("s1") + expect(h.invalidates).toBe(1) + expect(h.added).toHaveLength(1) + expect(pinnedWorkspace(h.added[0])).toBe("7") + expect(pinnedWorkspace(h.config.mcp!.datamate as LocalMcpConfig)).toBe("7") + expect(managedWorkspace()).toEqual({ id: "7", name: "growth" }) + expect(settledOutcome("s1")?.kind).toBe("attached") + expect(h.toasts.at(-1)?.title).toContain("growth") + }) + + test("an unlink mid-session removes the engine and settles unbound", async () => { + const h = install({}) + await beforeTurn("s1") + h.binding = null + await beforeTurn("s1") + expect(h.removes).toBe(1) + expect(h.added).toEqual([]) + expect(h.config.mcp!.datamate).toBeUndefined() + expect(managedWorkspace()).toBeNull() + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + }) + + test("a client that predates a mid-session link is removed when the overlay refuses", async () => { + // Unbound at boot with an IDE entry, so MCP bootstrapped that client. The + // directory is then linked with no engine on PATH: the overlay refuses, and + // the pre-link client must not go on serving the workspace under the key. + const h = install({ binding: null, mcp: { datamate: IDE_ENTRY }, which: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toBe(0) + h.binding = bound(42) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("engine-missing") + expect(h.removes).toBe(1) + expect(h.config.mcp!.datamate).toBeUndefined() + await beforeTurn("s1") + expect(h.removes).toBe(1) + }) + + test("an overlay that threw is retried at the probe TTL, not on every turn", async () => { + // Each retry invalidates the whole config cache; a persistent fault must + // not turn every turn boundary into a full config reload. + const h = install({}) + syncInternals.which = () => { + throw new Error("PATH unreadable") + } + await beforeTurn("s1") + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.invalidates).toBe(0) + h.clock += FAILED_PROBE_TTL_MS + await beforeTurn("s1") + expect(h.invalidates).toBe(1) + await beforeTurn("s1") + expect(h.invalidates).toBe(1) + }) + + test("a datamate key set by managed preferences is left alone and is not managed here", async () => { + const h = install({ mcp: { datamate: IDE_ENTRY }, managed: true }) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.config.mcp).toEqual({ datamate: IDE_ENTRY }) + expect(managedWorkspace()).toBeNull() + expect(h.probes).toBe(0) + // The feature is off here: no per-turn reload, no toast, nothing removed. + expect(h.invalidates).toBe(0) + expect(h.removes).toBe(0) + expect(h.toasts).toEqual([]) + expect(settledOutcome("s1")).toEqual({ kind: "disabled" }) + }) + + test("a transient overlay failure after attach keeps the running engine", async () => { + const h = install({}) + // MCP bootstrapped the engine from the config as loaded; the hook adds nothing. + await beforeTurn("s1") + expect(h.added).toHaveLength(0) + expect(settledOutcome("s1")?.kind).toBe("attached") + // Something else invalidates config, and the overlay's probe now throws. + // The usable-engine memo would mask the throw; forget it so the fault fires. + syncInternals.which = () => { + throw new Error("PATH unreadable") + } + invalidateProbe() + await syncInternals.config!.invalidate() + await beforeTurn("s1") + expect(h.removes).toBe(0) + expect(h.added).toHaveLength(0) + expect(settledOutcome("s1")?.kind).toBe("attached") + // The fault was real: the overlay is gone until its retry, the engine is not + // — and the key stays owned while that engine is still the one in use. + expect(overlayForTests()).toBeNull() + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + // The fault clears and the TTL passes: still the same engine, not a second one. + h.which = "/usr/local/bin/datamate" + syncInternals.which = () => h.which + h.clock += FAILED_PROBE_TTL_MS + await beforeTurn("s1") + expect(h.added).toHaveLength(0) + expect(h.removes).toBe(0) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("the same workspace id under another account is another workspace: engine and inventory replaced", async () => { + // Ids are tenant-local. After an account switch the directory may be bound + // to "workspace 42" of the new tenant; the engine started under the old + // credentials and the old allowlist must not be kept. + const h = install({}) + let lookups = 0 + const inner = syncInternals.declared! + syncInternals.declared = async (id) => { + lookups += 1 + return inner(id) + } + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("attached") + expect(h.added).toHaveLength(0) + expect(lookups).toBe(1) + const toastsBefore = h.toasts.length + h.binding = bound(42, "analytics", "globex|https://api.globex.example") + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("attached") + expect(h.added).toHaveLength(1) + expect(lookups).toBe(2) + // A new workspace's attachment is announced even with identical counts. + expect(h.toasts.length).toBe(toastsBefore + 1) + // Same account, same workspace: nothing is replaced. + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(lookups).toBe(2) + }) + + test("a relink whose overlay then fails releases the old workspace's engine and says so", async () => { + // Workspace A's engine may not go on serving a directory now bound to B — + // and neither may the IDE entry the reloaded config still carries. + const h = install({ mcp: { datamate: IDE_ENTRY } }) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("attached") + h.binding = bound(43, "ops") + syncInternals.which = () => { + throw new Error("PATH unreadable") + } + invalidateProbe() + await beforeTurn("s1") + expect(h.removes).toBe(1) + expect(h.added).toHaveLength(0) + expect(settledOutcome("s1")?.kind).toBe("connect-failed") + // The attach toast, then the failure's — naming the workspace now bound. + expect(h.toasts).toHaveLength(2) + expect(h.toasts[1].title).toContain("ops") + expect(h.toasts[1].title).toContain("unavailable") + }) + + test("an unlink hands the key back to the entry the reloaded config restores", async () => { + // The overlay had shadowed the user's own hosted entry; once unbound, config + // reloads with that entry and MCP must be told to start it, because MCP only + // enumerates live clients. + const h = install({ mcp: { datamate: HOSTED_ENTRY } }) + await beforeTurn("s1") + expect(pinnedWorkspace(h.config.mcp!.datamate as LocalMcpConfig)).toBe("42") + h.binding = null + await beforeTurn("s1") + expect(h.removes).toBe(1) + expect(h.added).toEqual([HOSTED_ENTRY]) + expect(h.config.mcp!.datamate).toEqual(HOSTED_ENTRY) + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + }) + + test("an unlink after a refused overlay still hands the key back to the restored entry", async () => { + // The overlay had removed the user's hosted entry (no engine, no fallback); + // once unbound there is nothing to remove but the restored entry must start. + const h = install({ which: null, mcp: { datamate: HOSTED_ENTRY } }) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("engine-missing") + expect(h.config.mcp!.datamate).toBeUndefined() + h.binding = null + await beforeTurn("s1") + expect(h.removes).toBe(0) + expect(h.added).toEqual([HOSTED_ENTRY]) + expect(h.config.mcp!.datamate).toEqual(HOSTED_ENTRY) + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + }) + + test("an unlink does not start an entry the user had disabled", async () => { + const h = install({ mcp: { datamate: { ...HOSTED_ENTRY, enabled: false } } }) + await beforeTurn("s1") + h.binding = null + await beforeTurn("s1") + expect(h.removes).toBe(1) + expect(h.added).toEqual([]) + }) + + test("an engine installed after a refusal is picked up once the probe is asked again", async () => { + const h = install({ which: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("engine-missing") + h.which = "/usr/local/bin/datamate" + // Within the TTL the failed probe is not repeated... + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("engine-missing") + expect(h.added).toEqual([]) + // ...the install offer invalidates it explicitly; a later turn re-probes on its own. + invalidateProbe() + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(pinnedWorkspace(h.added[0])).toBe("42") + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a failed probe is repeated on its own after the TTL", async () => { + const h = install({ version: "0.6.3" }) + await beforeTurn("s1") + h.version = "0.7.0" + h.clock += FAILED_PROBE_TTL_MS + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("the session memo is bounded", async () => { + install({}) + for (let i = 0; i < MAX_TRACKED_SESSIONS + 10; i++) await beforeTurn(`s${i}`) + expect(trackedSessionsForTests()).toBe(MAX_TRACKED_SESSIONS) + expect(settledOutcome("s0")).toBeUndefined() + expect(settledOutcome(`s${MAX_TRACKED_SESSIONS + 9}`)?.kind).toBe("attached") + }) + + test("turn hooks for one directory run one at a time, so a re-link cannot interleave with another session's hook", async () => { + const h = install({}) + // Session A's binding read blocks until released; a re-link lands and + // session B's hook starts while A is inside its hook. + let calls = 0 + let releaseA: () => void = () => {} + const gateA = new Promise((resolve) => { + releaseA = resolve + }) + syncInternals.resolveBinding = async () => { + calls += 1 + // The read observes the binding as it was when asked; only the delivery + // of the answer is held back. + const snapshot = h.binding + if (calls === 2) await gateA + return snapshot + } + const a = beforeTurn("A") + await new Promise((r) => setTimeout(r, 5)) + h.binding = bound(7, "growth") + const b = beforeTurn("B") + await new Promise((r) => setTimeout(r, 5)) + // B is queued behind A: nothing has been applied for workspace 7 yet. + expect(h.added).toEqual([]) + releaseA() + await Promise.all([a, b]) + // A settled for the workspace it read; B's boundary then moved the engine. + expect(h.toasts.map((t) => t.title)).toEqual(['Workspace "analytics"', 'Workspace "growth"']) + expect(h.added).toHaveLength(1) + expect(pinnedWorkspace(h.added[0] as LocalMcpConfig)).toBe("7") + expect(managedWorkspace()?.id).toBe("7") + }) + + test("the lock is held through the turn's catalog, so another boundary waits for the snapshot", async () => { + const h = install({}) + let releaseCatalog: () => void = () => {} + const gate = new Promise((resolve) => { + releaseCatalog = resolve + }) + let catalogued = "" + const a = atTurnStart("A", async () => { + await gate + catalogued = managedWorkspace()?.id ?? "none" + return "A-catalog" + }) + await new Promise((r) => setTimeout(r, 5)) + h.binding = bound(7, "growth") + const b = beforeTurn("B") + await new Promise((r) => setTimeout(r, 5)) + expect(h.added).toEqual([]) + releaseCatalog() + expect(await a).toBe("A-catalog") + await b + // A catalogued while its own workspace was still the one applied. + expect(catalogued).toBe("42") + expect(managedWorkspace()?.id).toBe("7") + }) + + test("a body failure propagates to the caller but does not wedge the directory's lock", async () => { + install({}) + await expect( + atTurnStart("A", async () => { + throw new Error("catalog exploded") + }), + ).rejects.toThrow("catalog exploded") + await expect(atTurnStart("B", async () => "ok")).resolves.toBe("ok") + }) + + test("a turn keeps the engine tools it catalogued first for its later catalogs", async () => { + install({}) + const first = { datamate_a: { id: "a1" }, sql_execute: { id: "sql" } } + pinTurnTools("s1", true, first) + // Another session's boundary replaced the engine mid-turn: step 2 re-catalogs + // a different tool set under the same prefix. + const later: Record = { + datamate_b: { id: "b1" }, + datamate_a: { id: "a2" }, + sql_execute: { id: "sql" }, + } + pinTurnTools("s1", false, later) + expect(later).toEqual({ sql_execute: { id: "sql" }, datamate_a: { id: "a1" } }) + // A new turn takes a fresh snapshot. + pinTurnTools("s1", true, { datamate_b: { id: "b1" } }) + const step2: Record = {} + pinTurnTools("s1", false, step2) + expect(step2).toEqual({ datamate_b: { id: "b1" } }) + }) + + test("pinning is a no-op with the flag off and for a session with no step-1 snapshot", async () => { + install({ flag: false }) + const tools: Record = { datamate_a: { id: "a1" } } + pinTurnTools("s1", false, tools) + expect(tools).toEqual({ datamate_a: { id: "a1" } }) + install({}) + pinTurnTools("s9", false, tools) + expect(tools).toEqual({ datamate_a: { id: "a1" } }) + }) + + test("the turn hook never throws", async () => { + install({}) + syncInternals.config = { + invalidate: async () => {}, + get: async () => { + throw new Error("config exploded") + }, + } + await expect(beforeTurn("s1")).resolves.toBeUndefined() + }) + + test("the key stays managed while the engine is missing, so writers still refuse", async () => { + install({ which: null }) + await beforeTurn("s1") + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-probes.test.ts b/packages/opencode/test/altimate/workspace/engine-probes.test.ts new file mode 100644 index 0000000000..c5871ca89e --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-probes.test.ts @@ -0,0 +1,35 @@ +// altimate_change - new file +// +// The engine probes against real processes: `versionOf` must settle on the +// engine's own exit, never wait on a descendant that inherited its stdout. +import { describe, expect, test } from "bun:test" +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { versionOf } from "../../../src/altimate/workspace/engine-probes" + +const posix = process.platform !== "win32" + +function fakeEngine(script: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "engine-probe-")) + const bin = path.join(dir, "datamate") + writeFileSync(bin, `#!/bin/sh\n${script}\n`) + chmodSync(bin, 0o755) + return bin +} + +describe("versionOf", () => { + test.skipIf(!posix)("reads the version even when a descendant keeps stdout open", async () => { + const bin = fakeEngine('echo "0.7.0"; sleep 5 & exit 0') + const t0 = Date.now() + expect(await versionOf(bin)).toBe("0.7.0") + expect(Date.now() - t0).toBeLessThan(2_000) + }) + test.skipIf(!posix)("a non-zero exit is unreadable", async () => { + const bin = fakeEngine('echo "0.7.0"; exit 3') + expect(await versionOf(bin)).toBeNull() + }) + test("a binary that cannot be spawned is unreadable", async () => { + expect(await versionOf(path.join(os.tmpdir(), "definitely-not-here-" + process.pid))).toBeNull() + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-types.test.ts b/packages/opencode/test/altimate/workspace/engine-types.test.ts new file mode 100644 index 0000000000..06f43732f2 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-types.test.ts @@ -0,0 +1,147 @@ +// altimate_change - new file +// +// The pure vocabulary of the workspace engine overlay: version floor, pin +// parser, tool keys, and the meaning tables over the outcome union. +import { describe, expect, test } from "bun:test" +import { + INSTALL_COMMAND, + INSTALL_HELPS, + MIN_ENGINE_VERSION, + REPAIRABLE, + SERVING, + attributableEngine, + clearsFloor, + compareVersions, + describeMissing, + describeRefusal, + engineEntry, + engineToolKeys, + installWouldHelp, + pinnedWorkspace, + type Outcome, +} from "../../../src/altimate/workspace/engine-types" + +describe("compareVersions", () => { + test("orders by major, minor, patch", () => { + expect(compareVersions("0.7.0", "0.7.0")).toBe(0) + expect(compareVersions("0.7.1", "0.7.0")).toBeGreaterThan(0) + expect(compareVersions("0.8.0", "0.7.9")).toBeGreaterThan(0) + expect(compareVersions("1.0.0", "0.9.9")).toBeGreaterThan(0) + expect(compareVersions("0.6.3", "0.7.0")).toBeLessThan(0) + }) + test("a release outranks every pre-release of it", () => { + expect(compareVersions("0.7.0-beta.1", "0.7.0")).toBeLessThan(0) + expect(compareVersions("0.7.0", "0.7.0-rc.2")).toBeGreaterThan(0) + expect(compareVersions("0.7.0-beta.2", "0.7.0-beta.10")).toBeLessThan(0) + expect(compareVersions("0.7.0-alpha", "0.7.0-beta")).toBeLessThan(0) + expect(compareVersions("0.7.0-1", "0.7.0-alpha")).toBeLessThan(0) + }) + test("ignores build metadata and a leading v", () => { + expect(compareVersions("v0.7.0+build.5", "0.7.0")).toBe(0) + }) + test("an unreadable core ranks below any readable one", () => { + expect(compareVersions("0.7rc.0", "0.7.0")).toBeLessThan(0) + expect(compareVersions("1", "0.7.0")).toBeLessThan(0) + expect(compareVersions("garbage", "0.0.1")).toBeLessThan(0) + expect(compareVersions("garbage", "nonsense")).toBe(0) + }) +}) + +describe("clearsFloor", () => { + test("only a readable version at or above the floor clears it", () => { + expect(clearsFloor(null)).toBe(false) + expect(clearsFloor("")).toBe(false) + expect(clearsFloor(MIN_ENGINE_VERSION)).toBe(true) + expect(clearsFloor("0.7.1")).toBe(true) + expect(clearsFloor("1.0.0")).toBe(true) + expect(clearsFloor("0.6.9")).toBe(false) + expect(clearsFloor(`${MIN_ENGINE_VERSION}-beta.1`)).toBe(false) + expect(clearsFloor("0.7rc.0")).toBe(false) + }) +}) + +describe("pinnedWorkspace", () => { + test("reads the pin from opencode's argv shape", () => { + expect(pinnedWorkspace({ command: ["datamate", "start-stdio", "--datamate", "5"] })).toBe("5") + }) + test("reads the pin from an IDE's command + args shape, either spelling", () => { + expect(pinnedWorkspace({ command: "datamate", args: ["start-stdio", "--datamate=7"] })).toBe("7") + expect(pinnedWorkspace({ command: "datamate", args: ["start-stdio", "--datamate", "8"] })).toBe("8") + }) + test("a repeated pin resolves last-wins", () => { + expect(pinnedWorkspace({ command: ["datamate", "--datamate", "1", "start-stdio", "--datamate=2"] })).toBe("2") + }) + test("fails open on every miss", () => { + expect(pinnedWorkspace(null)).toBeNull() + expect(pinnedWorkspace({})).toBeNull() + expect(pinnedWorkspace({ url: "https://example.invalid/sse" })).toBeNull() + expect(pinnedWorkspace({ command: ["datamate", "start-stdio"] })).toBeNull() + expect(pinnedWorkspace({ command: ["datamate", "start-stdio", "--datamate"] })).toBeNull() + expect(pinnedWorkspace({ command: ["datamate", "--datamate="] })).toBeNull() + expect(pinnedWorkspace({ command: ["datamate", "--datamate", 5 as unknown as string] })).toBeNull() + }) + test("the derived entry is pinned to the workspace it was derived for", () => { + const entry = engineEntry("42") + expect(entry).toEqual({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }) + expect(pinnedWorkspace(entry)).toBe("42") + }) +}) + +describe("engineToolKeys", () => { + test("strips the datamate prefix and ignores everything else", () => { + const keys = engineToolKeys({ + datamate_dbt_build_model: {}, + datamate_snowflake_execute_database_query: {}, + "datamate-prod_other_tool": {}, + sql_execute: {}, + }) + expect([...keys].sort()).toEqual(["dbt_build_model", "snowflake_execute_database_query"]) + }) +}) + +describe("outcome tables", () => { + const kinds: Outcome["kind"][] = [ + "disabled", + "unbound", + "attached", + "engine-missing", + "engine-too-old", + "connect-failed", + ] + test("every table names every variant", () => { + for (const table of [SERVING, INSTALL_HELPS, REPAIRABLE]) { + expect(Object.keys(table).sort()).toEqual([...kinds].sort()) + } + }) + test("only an attached engine is attributable", () => { + expect(kinds.filter((k) => SERVING[k])).toEqual(["attached"]) + expect(attributableEngine(undefined)).toBe(false) + expect(attributableEngine({ kind: "attached", available: 1 })).toBe(true) + expect(attributableEngine({ kind: "connect-failed", error: "x" })).toBe(false) + }) + test("only genuine unobtainability is fixed by an install", () => { + expect(kinds.filter((k) => INSTALL_HELPS[k]).sort()).toEqual(["engine-missing", "engine-too-old"]) + expect(installWouldHelp(undefined)).toBe(false) + expect(installWouldHelp({ kind: "engine-missing" })).toBe(true) + expect(installWouldHelp({ kind: "connect-failed", error: "x" })).toBe(false) + }) + test("refusals the user can act on are repairable; verdicts about the project are not", () => { + expect(kinds.filter((k) => REPAIRABLE[k]).sort()).toEqual(["connect-failed", "engine-missing", "engine-too-old"]) + }) +}) + +describe("messages", () => { + test("a broken engine is described as broken, an old one as old", () => { + expect(describeRefusal(null, "analytics")).toContain("more likely broken than out of date") + expect(describeRefusal(null, "analytics")).toContain(INSTALL_COMMAND) + expect(describeRefusal("0.6.3", "analytics")).toContain(`needs ${MIN_ENGINE_VERSION} or newer`) + expect(describeRefusal("0.6.3", "analytics")).toContain("Found datamate 0.6.3") + }) + test("the missing list is truncated after five", () => { + expect(describeMissing([])).toBe("") + expect(describeMissing(["a", "b"])).toBe(" Declared but not available: a, b.") + expect(describeMissing(["a", "b", "c", "d", "e", "f", "g"])).toBe( + " Declared but not available: a, b, c, d, e (+2 more).", + ) + }) +}) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 9d71a0db25..3503eda1b8 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -526,6 +526,34 @@ it.instance( }, ) +// altimate_change start — remove forgets the runtime config +it.instance( + "remove forgets the key entirely, so it no longer reports as disabled", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "rm-server" + getOrCreateClientState("rm-server") + + yield* mcp.add("rm-server", { + type: "local", + command: ["echo", "test"], + }) + expect((yield* mcp.status())["rm-server"]?.status).toBe("connected") + + yield* mcp.remove("rm-server") + + // A retained runtime config would have status() synthesise "disabled" + // for the rest of the process, and connect() re-spawn the removed entry. + expect((yield* mcp.status())["rm-server"]).toBeUndefined() + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((k) => k.startsWith("rm-server"))).toBe(false) + }), + ), + { config: { mcp: {} } }, +) +// altimate_change end + it.instance( "connect() after disconnect() re-establishes the server", () => diff --git a/packages/opencode/test/release-validation/question-937.test.ts b/packages/opencode/test/release-validation/question-937.test.ts index e0f0ec5b00..f61748810f 100644 --- a/packages/opencode/test/release-validation/question-937.test.ts +++ b/packages/opencode/test/release-validation/question-937.test.ts @@ -107,8 +107,22 @@ describe("tool.question non-interactive autoAnswer mapping", () => { process.env["ALTIMATE_AUTO_ANSWER"] = "first" const tool = await initTool(QuestionTool) const questions = [ - { question: "Q1", header: "Q1", options: [{ label: "A", description: "" }, { label: "B", description: "" }] }, - { question: "Q2", header: "Q2", options: [{ label: "C", description: "" }, { label: "D", description: "" }] }, + { + question: "Q1", + header: "Q1", + options: [ + { label: "A", description: "" }, + { label: "B", description: "" }, + ], + }, + { + question: "Q2", + header: "Q2", + options: [ + { label: "C", description: "" }, + { label: "D", description: "" }, + ], + }, ] const result = await tool.execute({ questions }, ctx) @@ -124,8 +138,22 @@ describe("tool.question non-interactive autoAnswer mapping", () => { test("no ALTIMATE_AUTO_ANSWER returns Unanswered for every question independently", async () => { const tool = await initTool(QuestionTool) const questions = [ - { question: "Q1", header: "Q1", options: [{ label: "A", description: "" }, { label: "B", description: "" }] }, - { question: "Q2", header: "Q2", options: [{ label: "C", description: "" }, { label: "D", description: "" }] }, + { + question: "Q1", + header: "Q1", + options: [ + { label: "A", description: "" }, + { label: "B", description: "" }, + ], + }, + { + question: "Q2", + header: "Q2", + options: [ + { label: "C", description: "" }, + { label: "D", description: "" }, + ], + }, ] const result = await tool.execute({ questions }, ctx) @@ -185,7 +213,9 @@ describe("tool.question non-interactive autoAnswer mapping", () => { else process.env["ALTIMATE_AUTO_ANSWER"] = mode const tool = await initTool(QuestionTool) - const questions = [{ question: "Empty?", header: "Empty", options: [] as { label: string; description: string }[] }] + const questions = [ + { question: "Empty?", header: "Empty", options: [] as { label: string; description: string }[] }, + ] const result = await tool.execute({ questions }, ctx) expect(askSpy).not.toHaveBeenCalled() @@ -259,6 +289,46 @@ describe("tool.bash strips ALTIMATE_NON_INTERACTIVE from child env", () => { }) }) +// --------------------------------------------------------------------------- +// Workspace engine — bash tool strips ALTIMATE_CODE_HEADLESS from child env. +// `run` sets it so the engine's refusals degrade to a printed line; a nested +// entrypoint launched from the bash tool may have a TUI and must not inherit it. +// Same shape as Gap #7 so a future removal of the delete fails here. +// --------------------------------------------------------------------------- +describe("tool.bash strips ALTIMATE_CODE_HEADLESS from child env", () => { + let prev: string | undefined + + beforeEach(() => { + prev = process.env["ALTIMATE_CODE_HEADLESS"] + process.env["ALTIMATE_CODE_HEADLESS"] = "1" + }) + + afterEach(() => { + if (prev === undefined) delete process.env["ALTIMATE_CODE_HEADLESS"] + else process.env["ALTIMATE_CODE_HEADLESS"] = prev + }) + + test("child process does not inherit ALTIMATE_CODE_HEADLESS", async () => { + const projectRoot = require("path").join(__dirname, "../..") + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initTool(BashTool) + const result = await bash.execute( + { + command: "printenv ALTIMATE_CODE_HEADLESS || echo MISSING", + description: "Echo headless env var from child", + }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output.trim()).toBe("MISSING") + expect(process.env["ALTIMATE_CODE_HEADLESS"]).toBe("1") + }, + }) + }) +}) + // --------------------------------------------------------------------------- // Gap #8 — run command sets ALTIMATE_NON_INTERACTIVE only when undefined and // not --attach.