diff --git a/packages/opencode/src/altimate/workspace/engine-sync.ts b/packages/opencode/src/altimate/workspace/engine-sync.ts index dc8c56660..4eb4ecca4 100644 --- a/packages/opencode/src/altimate/workspace/engine-sync.ts +++ b/packages/opencode/src/altimate/workspace/engine-sync.ts @@ -61,6 +61,7 @@ import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { which as whichBinary } from "@opencode-ai/core/util/which" import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" +import { Process } from "@/util/process" import { MCP } from "@/mcp" import { addMcpToConfig, resolveConfigPath } from "@/mcp/config" import { Config } from "@/config/config" @@ -85,6 +86,17 @@ const log = Log.create({ service: "workspace-engine" }) export const MIN_ENGINE_VERSION = "0.7.0" export const INSTALL_HINT = "npm i -g @altimateai/datamate" export const ENGINE_BINARY = "datamate" +/** npm package that provides the engine binary. */ +export const ENGINE_PACKAGE = "@altimateai/datamate" +/** The exact command the offer installs, copies, and prints. Version-pinned so + * what the user runs by hand equals what "Install now" runs. E2E points this at + * a local tarball via ALTIMATE_ENGINE_INSTALL_SPEC. */ +export const INSTALL_COMMAND = `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}` +/** Node major the npm install path needs. The CLI itself is a self-contained + * binary and does not need Node — only this install route does. */ +export const MIN_NODE_MAJOR = 20 +/** How long "Install now" waits for npm before giving up. */ +export const INSTALL_TIMEOUT_MS = 300_000 /** Engine tools arrive under the MCP server key as `_`. */ const TOOL_PREFIX = `${DATAMATE_KEY}_` @@ -110,6 +122,33 @@ export type ExistingEntry = { type?: string; url?: string; command?: string[] | type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } +/** A "no usable engine" state, described well enough for an interactive + * surface to act on it without re-deriving anything. */ +export type EngineOffer = { + reason: "engine-missing" | "engine-too-old" + /** Stable id — the 7-day "Not now" latch keys on this, not the name. */ + workspaceId: string + workspaceName: string + /** Declared, CLI-servable integration tools that are unavailable without it. */ + declared: number + /** Version found — only set for "engine-too-old". */ + found?: string + /** The exact install/update command. */ + command: string +} + +/** Interactive surface for the offer, registered by the workspace TUI plugin. + * Returns true when it took ownership. + * + * Deliberately synchronous: `run()` awaits the offer, and that await sits + * inside the window `whenAttached` caps. A handler that blocked on user input + * would spend the turn's ATTACH_WAIT_MS budget, so a surface claims the offer + * and renders it out-of-band rather than making the attach wait for a person. + * Rule 3 stands either way: this offers, it never installs on its own. */ +export type OfferHandler = (offer: EngineOffer) => boolean + +export type InstallResult = { ok: true } | { ok: false; error: string } + type McpStatus = Record /** Declared allowlist for a workspace, split by whether the CLI can serve it. @@ -134,8 +173,32 @@ export const syncInternals: { existingEntry?: (name: string) => Promise declared?: (datamateId: string) => Promise notify?: (toast: Toast) => Promise + offer?: OfferHandler + publishOffer?: () => Promise + printLine?: (line: string) => void + nodeMajor?: () => Promise + npmAvailable?: () => boolean + install?: (spec: string) => Promise } = {} +/** Command the TUI plugin registers to raise the install offer. + * + * The offer crosses to the TUI over the SAME event bus toasts use. It cannot + * cross in-process: the TUI plugin runtime loads plugins in a separate realm, + * so neither a module-level binding nor a `globalThis` key set by the plugin is + * visible here. Both were tried and both silently degraded to the toast — the + * dialog never appeared. `CommandExecute` carries no payload, so the plugin + * re-derives the offer with `describeOffer()`; that is the same shape the + * post-scan workspace prompt already uses. */ +export const OFFER_COMMAND = "altimate.workspace.engineInstallOffer" + +/** True in headless `run`, where no TUI can render a dialog or a toast and the + * single printed line is the only way to say anything. Set by the run command; + * an env var because it must be readable from every realm. */ +export function isHeadless(): boolean { + return process.env["ALTIMATE_CODE_HEADLESS"] === "1" +} + export function isEnabled(): boolean { return CoreFlag.ALTIMATE_WORKSPACE } @@ -376,6 +439,195 @@ async function notify(toast: Toast): Promise { } } +/** Ask the TUI to raise the offer. False when the bus is unavailable. */ +async function publishOffer(): Promise { + if (syncInternals.publishOffer) return syncInternals.publishOffer() + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND })), + ) + return true + } catch (err) { + log.warn("could not publish the engine install offer", { err: String(err) }) + return false + } +} + +/** Re-derive the current "no usable engine" state for a directory. + * + * The offer reaches the TUI as a bare command, so the plugin rebuilds the + * detail here rather than receiving it. Returns null when there is nothing to + * offer — unbound, or an engine that already clears the floor. + * + * This describes the CURRENT state, not the decision that raised the offer, + * and the two can differ. `run()`'s rule-1 branch reports `engine-too-old` + * using the version of a reused entry's binary; that branch only fires when + * PATH has nothing better, so this function — which probes PATH alone — may + * describe the same situation as `engine-missing`. That is deliberate: the + * user's actionable state is "no usable engine, run this command", and the + * command is identical either way. Describing live state rather than echoing + * a trigger also keeps this correct when the conditions upstream change, + * which is the failure mode where describing code silently keeps asserting + * what used to be true. */ +export async function describeOffer(directory: string): Promise { + const binding = syncInternals.resolveBinding + ? await syncInternals.resolveBinding() + : await readLocalBinding(directory).catch(() => null) + if (!binding) return null + const workspaceId = String(binding.datamateId) + const bin = which(ENGINE_BINARY) + const found = bin ? await versionOf(bin) : null + if (bin && found && compareVersions(found, MIN_ENGINE_VERSION) >= 0) return null + const declaredCount = (await declared(workspaceId))?.keys.length ?? 0 + return { + reason: bin ? "engine-too-old" : "engine-missing", + workspaceId, + workspaceName: binding.datamateName, + declared: declaredCount, + ...(bin ? { found: found ?? "unknown" } : {}), + command: installCommand(), + } +} + +/** npm spec to install. ALTIMATE_ENGINE_INSTALL_SPEC overrides it so E2E can + * point the real install path at a local tarball instead of the registry. */ +export function installSpec(): string { + return process.env["ALTIMATE_ENGINE_INSTALL_SPEC"] || `${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}` +} + +/** The command shown, copied, printed, and run — always the same string, so + * "Copy command" hands over exactly what "Install now" would have executed. */ +export function installCommand(): string { + return `npm i -g ${installSpec()}` +} + +/** Node major on PATH, or null when Node is absent. Gates the "Install now" + * option: with no Node there is nothing to run `npm` with, so the offer + * degrades to showing the command. */ +export function nodeMajor(): Promise { + if (syncInternals.nodeMajor) return syncInternals.nodeMajor() + const bin = which("node") + if (!bin) return Promise.resolve(null) + return new Promise((resolve) => { + execFile(bin, ["--version"], { timeout: 5000 }, (err, stdout) => { + if (err) return resolve(null) + const major = Number.parseInt(stdout.trim().replace(/^v/, "").split(".")[0] ?? "", 10) + resolve(Number.isFinite(major) ? major : null) + }) + }) +} + +/** Whether npm can be invoked at all. Node and npm are separate packages on + * several Linux distributions, so Node 20+ is not sufficient to conclude that + * `npm i -g` will run — without this the offer enables "Install now" and the + * install fails immediately with ENOENT. */ +export function npmAvailable(): boolean { + if (syncInternals.npmAvailable) return syncInternals.npmAvailable() + return which(process.platform === "win32" ? "npm.cmd" : "npm") !== null +} + +/** Run the global install. Only ever reached from an explicit user choice — + * rule 3 forbids reaching it from the attach flow itself. + * + * `npm.cmd` on Windows: a normal Node install exposes npm as a command shim, + * not an `npm` executable, and nothing here spawns a shell — so the bare name + * fails with ENOENT on the one platform where the Node gate has just told the + * user they are good to go. Same platform split the existing install path in + * `lsp/server.ts` uses. `Process.run` takes an argv array, so a spec with + * spaces (an E2E tarball path) needs no quoting. */ +export async function installEngine(): Promise { + const spec = installSpec() + if (syncInternals.install) return syncInternals.install(spec) + const npm = process.platform === "win32" ? "npm.cmd" : "npm" + // The deadline has to come from an abort signal, not from `timeout`. + // `Process.spawn` only consults `timeout` inside its abort handler, as the + // grace period before escalating to SIGKILL — with no signal supplied that + // handler never runs and there is no deadline at all. An npm that stops + // making progress would leave the dialog on "Installing…" forever. + const deadline = AbortSignal.timeout(INSTALL_TIMEOUT_MS) + try { + const result = await Process.run([npm, "i", "-g", spec], { abort: deadline, nothrow: true }) + if (result.code === 0) { + // A zero exit is not the same as a usable engine. npm installs into its + // configured global prefix, whose bin directory need not be on PATH — so + // the install genuinely succeeds while the next attach still finds + // nothing, and the offer is raised again after a success message. Re-run + // the same discovery the attach will do, and fail with the reason. + const installedBin = which(ENGINE_BINARY) + if (!installedBin) { + return { + ok: false, + error: `npm installed it, but ${ENGINE_BINARY} is not on PATH — add your npm global bin directory to PATH`, + } + } + const installedVersion = await versionOf(installedBin) + if (!installedVersion || compareVersions(installedVersion, MIN_ENGINE_VERSION) < 0) { + return { + ok: false, + error: `npm installed it, but ${ENGINE_BINARY} on PATH reports ${installedVersion ?? "no version"}`, + } + } + return { ok: true } + } + if (deadline.aborted) { + return { ok: false, error: `npm did not finish within ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes` } + } + const detail = result.stderr.toString().trim().split(/\r?\n/).slice(-3).join(" ") + return { ok: false, error: detail || `npm exited with code ${result.code}` } + } catch (err) { + if (deadline.aborted) { + return { ok: false, error: `npm did not finish within ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes` } + } + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} + +/** Hand the offer to an interactive surface. False when none is registered. */ +function offerInstall(offer: EngineOffer): boolean { + const handler = syncInternals.offer + if (!handler) return false + try { + return handler(offer) + } catch (err) { + log.warn("install offer surface failed; falling back to toast", { err: String(err) }) + return false + } +} + +/** stderr, deliberately, not stdout. + * + * `run --format json` documents stdout as raw JSON events and routes every + * record through its `emit()` helper, so a human-readable line written there + * lands mid-stream and breaks line-oriented consumers — verified: the notice + * was line 1 of an otherwise-valid JSON stream. This is a status notice rather + * than run output, which is the same reason `run` already writes its own + * status line to stderr, so stderr is correct in both formats. */ +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 attach flow. + } +} + +/** Offer via the dialog surface when there is one; otherwise print (headless) + * or toast (bus unavailable). */ +async function offerOrNotify(offer: EngineOffer, toast: Toast): Promise { + if (isHeadless()) { + const tools = `${offer.declared} integration tool${offer.declared === 1 ? "" : "s"}` + printLine( + offer.reason === "engine-too-old" + ? `Workspace "${offer.workspaceName}": ${tools} need ${ENGINE_BINARY} ${MIN_ENGINE_VERSION}+ (found ${offer.found ?? "unknown"}). Update with: ${offer.command}` + : `Workspace "${offer.workspaceName}": ${tools} need the local engine, which is not installed. Install it with: ${offer.command}`, + ) + return + } + if (offerInstall(offer)) return + if (await publishOffer()) return + await notify(toast) +} + // --------------------------------------------------------------------------- // The attach flow // --------------------------------------------------------------------------- @@ -600,13 +852,28 @@ async function run(): Promise { // Rejected and irreplaceable: detach anyway. Leaving it connected would // return "too old" while still serving the too-old engine's tools. await detachRejected({ workspaceId, reason: "below-floor", found: label }) - await notify({ + // Same problem the offer exists for — an engine the user must update — + // so it gets the same actionable dialog rather than a transient toast. + // `declared` is not resolved this early in rule 1; fetch it here since + // this branch is rare and the count is what makes the offer concrete. + const reusedDeclared = (await declared(workspaceId))?.keys.length ?? 0 + await offerOrNotify( + { + reason: "engine-too-old", + workspaceId, + workspaceName: binding.datamateName, + declared: reusedDeclared, + found: label, + command: installCommand(), + }, + { title: "Workspace engine is too old", message: `The engine serving workspace "${binding.datamateName}" reports ${label}; this client needs ` + `${MIN_ENGINE_VERSION} or newer. Update with: ${INSTALL_HINT}`, variant: "warning", - }) + }, + ) return { kind: "engine-too-old", found: label } } replaced = describeEntry(entry) @@ -627,24 +894,43 @@ async function run(): Promise { // Rule 2 / 3 — opportunistic use, or an offer. Never an install. const bin = which(ENGINE_BINARY) if (!bin) { - await notify({ - title: "Workspace integrations unavailable", + await offerOrNotify( + { + reason: "engine-missing", + workspaceId, + workspaceName: binding.datamateName, + declared: declaredCount, + command: installCommand(), + }, + { + title: "Workspace integrations unavailable", message: `Workspace "${binding.datamateName}" declares ${declaredCount} integration tool${declaredCount === 1 ? "" : "s"}. ` + `They run on the local engine, which is not installed. Install it with: ${INSTALL_HINT}`, - variant: "warning", - }) + variant: "warning", + }, + ) return { kind: "engine-missing", declared: declaredCount } } const found = await versionOf(bin) if (!found || compareVersions(found, MIN_ENGINE_VERSION) < 0) { const label = found ?? "unknown" - await notify({ + await offerOrNotify( + { + reason: "engine-too-old", + workspaceId, + workspaceName: binding.datamateName, + declared: declaredCount, + found: label, + command: installCommand(), + }, + { title: "Workspace engine is too old", message: `Found ${ENGINE_BINARY} ${label}; this client needs ${MIN_ENGINE_VERSION} or newer. Update with: ${INSTALL_HINT}`, variant: "warning", - }) + }, + ) return { kind: "engine-too-old", found: label } } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 50638bcd1..b9533fc82 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -30,6 +30,8 @@ import { Locale } from "../../util/locale" import { Tracer, FileExporter, HttpExporter, type TraceExporter } from "../../altimate/observability/tracing" // altimate_change start — upstream_fix: type-only import for the tracing-config cast (see tracer setup below) import type { ConfigV1 } from "@opencode-ai/core/v1/config/config" +// altimate_change - render the workspace engine offer in an attached run +import * as WorkspaceEngine from "@/altimate/workspace/engine-sync" // altimate_change end // When a tool's parameters can't be statically inferred (legacy fork tools whose @@ -375,6 +377,12 @@ export const RunCommand = cmd({ // altimate_change end }, handler: async (args) => { + // altimate_change start — mark the headless surface. Nothing here can render + // a dialog or a toast, so the workspace engine offer degrades to a single + // printed line. An env var because it must be readable from every module + // realm (the TUI plugin runtime loads plugins in its own). + 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 @@ -747,6 +755,32 @@ You are speaking to a non-technical business executive. Follow these rules stric UI.error(err) } + // altimate_change start — an attached run is the only surface that can + // show the workspace engine offer. The headless marker is set on THIS + // process, but with --attach the attach flow and isHeadless() run in + // the server process, so the server publishes the offer command and + // treats the publish as delivery — while this event loop, which has + // no handler for it, is the only thing the user is looking at. + if ( + event.type === "tui.command.execute" && + (event.properties as { command?: string }).command === WorkspaceEngine.OFFER_COMMAND + ) { + // stderr for the same reason as the local headless notice: stdout + // is raw JSON events under --format json. + process.stderr.write( + `This workspace's integration tools need the local engine on the server. Install it there with: ${WorkspaceEngine.installCommand()}` + + EOL, + ) + continue + } + // Placed BEFORE the idle break deliberately: the loop stops on idle, + // so a handler after it never runs for an offer that arrives in the + // same batch. An offer published strictly after idle still cannot be + // shown here — the stream is over by then — which is a real gap when + // the attach exceeds its bounded wait. The TUI and local headless + // paths are unaffected. + // altimate_change end + if ( event.type === "session.status" && event.properties.sessionID === sessionID && @@ -755,6 +789,7 @@ You are speaking to a non-technical business executive. Follow these rules stric break } + if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 2a75f93c4..8b64296bb 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -24,8 +24,9 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createHash } from "node:crypto" +import { existsSync } from "node:fs" import open from "open" -import { createSignal, onMount } from "solid-js" +import { createSignal, onCleanup, onMount } from "solid-js" import { ConflictError, ForbiddenError, @@ -48,6 +49,17 @@ import { resolveProjectIdentifier, } from "@/altimate/workspace/detect" import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" +import { + describeOffer, + installCommand, + installEngine, + nodeMajor as detectNodeMajor, + npmAvailable, + MIN_NODE_MAJOR, + OFFER_COMMAND, + type EngineOffer, +} from "@/altimate/workspace/engine-sync" +import { useClipboard } from "@opencode-ai/tui/context/clipboard" import { AltimateApi } from "@/altimate/api/client" import { Log } from "@/altimate/util/log" @@ -1143,6 +1155,359 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { )) } +// ───────────────────────────────────────────────────────────────────────────── +// Engine install offer. engine-sync decides there is no usable engine and hands +// the offer here; this file owns the interaction. Offer, never silently +// install — the install only ever runs from an explicit "Install now". +// ───────────────────────────────────────────────────────────────────────────── + +const KV_ENGINE_SKIP_PREFIX = "altimate.workspace.engineInstall.skip." + +/** Latch key from (tenant, apiUrl, workspace id). Keyed on the workspace so a + * "Not now" for one workspace doesn't silence the offer for another, and on + * the id rather than the name so a rename doesn't reset it. */ +function engineSkipKey(workspaceId: string, scope: LatchScope | null): string { + const scopeString = scope ? `${scope.tenant}|${scope.apiUrl}|` : "" + return ( + KV_ENGINE_SKIP_PREFIX + + createHash("sha1") + .update(scopeString + workspaceId) + .digest("hex") + ) +} + +/** Same 7-day TTL and clock-rewind handling as the post-scan latch. */ +function isEngineSkipActive( + api: TuiPluginApi, + workspaceId: string, + scope: LatchScope | null, + nowMs: number, +): boolean { + const rec = api.kv.get<{ skippedAt: number }>(engineSkipKey(workspaceId, scope)) + if (!rec || typeof rec.skippedAt !== "number") return false + const delta = nowMs - rec.skippedAt + if (delta < 0) return false + return delta < SKIP_TTL_MS +} + +function recordEngineSkip( + api: TuiPluginApi, + workspaceId: string, + scope: LatchScope | null, + nowMs: number, +): void { + api.kv.set(engineSkipKey(workspaceId, scope), { skippedAt: nowMs }) +} + +interface EngineOfferProps { + api: TuiPluginApi + offer: EngineOffer + /** Node major on PATH, or null when Node is absent. Resolved by the caller + * so the dialog itself stays sync (same shape as ``browserAvailable``). */ + nodeMajor: number | null + /** Whether npm itself can be invoked. Node 20+ is not enough: several Linux + * distributions package node and npm separately. */ + hasNpm: boolean + latchScope: LatchScope | null + /** Which raise owns the single-offer latch; only the owner releases it. */ + generation: number +} + +/** One DialogSelect for every phase — the row set changes, the component never + * does. Swapping the top-level dialog component between states tears down and + * remounts the dialog, which drops focus and loses the phase signal. Sentinel + * rows carry the non-idle phases, and never use ``disabled: true`` ( + * DialogSelect's ``filtered()`` drops those, leaving an empty list). */ +function EngineInstallOfferDialog(props: EngineOfferProps) { + const clipboard = useClipboard() + const [phase, setPhase] = createSignal<"idle" | "installing" | "installed" | "failed">("idle") + const [failure, setFailure] = createSignal(null) + // The install outlives this component: Escape or a click outside dismisses + // the dialog while npm keeps running. Signals set after that update nothing + // anyone can see — a failed install or the five-minute timeout would be + // completely silent — and clearing the dialog stack would close whatever + // opened in our place. So completion reports through a toast when we are + // gone, and only touches the dialog while we still own it. + let mounted = true + onCleanup(() => { + mounted = false + // Release the single-offer latch however this dialog goes away — chosen, + // dismissed, or replaced — but only if this dialog still owns it. A + // superseded dialog tearing down must not free a slot the newer one holds. + if (engineOfferGeneration === props.generation) engineOfferVisible = false + }) + // ``onSelect`` is delivered synchronously per Enter keypress; the install is + // a multi-minute await. Without this latch a second Enter starts a second + // ``npm i -g`` against the same global prefix. + let installing = false + + const command = () => props.offer.command + const canInstall = () => props.nodeMajor !== null && props.nodeMajor >= MIN_NODE_MAJOR && props.hasNpm + + const title = () => { + const tools = `${props.offer.declared} integration tool${props.offer.declared === 1 ? "" : "s"}` + const head = + props.offer.reason === "engine-too-old" + ? `Workspace "${props.offer.workspaceName}" needs a newer local engine (found ${props.offer.found ?? "unknown"}) — ${tools} unavailable` + : `Workspace "${props.offer.workspaceName}" declares ${tools}, which need the local engine` + const parts = [head, command()] + if (!canInstall()) { + parts.push( + props.nodeMajor === null + ? `(needs Node ${MIN_NODE_MAJOR}+ to install — Node was not found on PATH)` + : props.nodeMajor < MIN_NODE_MAJOR + ? `(needs Node ${MIN_NODE_MAJOR}+ to install — found Node ${props.nodeMajor})` + : `(needs npm to install — npm was not found on PATH)`, + ) + } + const err = failure() + if (err) parts.push(`(install failed: ${err})`) + return parts.join(" · ") + } + + const options = () => { + switch (phase()) { + case "installing": + return [{ title: "Installing… this can take a minute.", value: "busy" }] + case "installed": + return [{ title: "Installed — attaching integrations.", value: "close" }] + case "failed": + return [ + { title: "Copy command", value: "copy", description: "Run it yourself, then start a new session." }, + { title: "Close", value: "close" }, + ] + default: + return [ + ...(canInstall() + ? [ + { + title: "Install now", + value: "install", + description: `Runs ${command()} and attaches this session when it finishes.`, + }, + ] + : []), + { + title: "Copy command", + value: "copy", + description: "Copy the install command to your clipboard.", + }, + { + title: "Not now", + value: "skip", + description: "Won't ask again for this workspace for 7 days.", + }, + ] + } + } + + const runInstall = async () => { + setPhase("installing") + engineInstallInFlight = true + try { + await performInstall() + } finally { + engineInstallInFlight = false + } + } + + const performInstall = async () => { + const result = await installEngine() + if (!result.ok) { + installing = false + if (!mounted) { + // Dismissed mid-install: the failed-phase rows have nowhere to render, + // so the error reaches the user as a toast or not at all. + props.api.ui.toast({ + variant: "error", + message: `Workspace engine install failed: ${result.error}. Run: ${command()}`, + duration: 30_000, + }) + return + } + setFailure(result.error) + setPhase("failed") + return + } + setPhase("installed") + // Only clear a dialog we still own — by now the user may have opened + // another, and clearing the stack would take theirs down instead. + if (mounted) props.api.ui.dialog.clear() + // Deliberately NOT re-attaching this session from here. The plugin runtime + // loads this file in its own realm, so `ensure()` called here would run a + // SECOND, independent attach — spawning an engine the session never uses + // and leaving it running. Nothing needs to: attach re-probes a repairable + // failure on the next turn, so the engine we just installed is picked up + // without a restart. Verified end-to-end — after this toast, the very next + // message in the SAME session listed the datamate_* tools. + props.api.ui.toast({ + variant: "success", + message: + `Workspace engine installed. Integration tools for "${props.offer.workspaceName}" ` + + `attach on your next message.`, + duration: 15_000, + }) + } + + const copyCommand = () => { + const cmd = command() + void (async () => { + try { + await clipboard.write?.(cmd) + // A resolved write is NOT proof of a copy. The host's writer picks + // xclip/xsel on Linux and otherwise falls back to clipboardy, and it + // swallows backend failures (`.catch(() => undefined)`), so on the many + // Linux/WSL boxes with neither tool installed the write silently does + // nothing. Read back and compare before claiming success. (Caught by + // E2E: the toast said "Copied:" while the clipboard was untouched.) + const back = await clipboard.read?.() + if (back?.data.trim() === cmd) { + props.api.ui.toast({ variant: "info", message: `Copied: ${cmd}` }) + return + } + } catch { + // Unreadable or unwritable clipboard — fall through and show it. + } + props.api.ui.toast({ + variant: "warning", + message: `Could not confirm the clipboard. Run: ${cmd}`, + duration: 30_000, + }) + })() + } + + return ( + { + if (option.value === "busy") return + if (option.value === "install") { + if (installing) return + installing = true + void runInstall().catch((err) => { + setFailure(err instanceof Error ? err.message : String(err)) + setPhase("failed") + installing = false + }) + return + } + if (option.value === "copy") { + copyCommand() + props.api.ui.dialog.clear() + return + } + if (option.value === "skip") { + recordEngineSkip(props.api, props.offer.workspaceId, props.latchScope, Date.now()) + } + props.api.ui.dialog.clear() + }} + /> + ) +} + +/** Show the offer unless the 7-day latch suppresses it. + * + * engine-sync raises this as a bare command over the event bus — it cannot hand + * us the offer object, because the plugin runtime loads this file in a separate + * realm from the attach flow. So the detail is re-derived here, the same way + * the post-scan prompt re-derives its own state from the directory. Node + * availability and latch scope are resolved before rendering so the dialog + * itself stays sync. */ +let engineOfferVisible = false +/** Identifies which raise owns the latch, so a superseded dialog's teardown + * cannot free a slot that a newer dialog is still holding. */ +let engineOfferGeneration = 0 +/** Held for the lifetime of an `npm i -g`, independently of the dialog. + * + * The install outlives the dialog that started it: dismissing mid-install + * tears the component down and frees the offer latch, but npm keeps running. + * Without this, the next turn's repair retry raises a fresh offer whose + * "Install now" starts a SECOND `npm i -g` against the same global prefix. + * The dialog latch answers "is an offer on screen"; this one answers "is an + * install still running", and only the second survives dismissal. */ +let engineInstallInFlight = false + +async function showEngineInstallOffer(api: TuiPluginApi): Promise { + // The attach re-probes a repairable failure on every turn, so the offer can + // be raised again while an earlier one is still up — including mid-install, + // where a fresh idle dialog replaces the "Installing…" one and then swallows + // the user's keystrokes into its own filter. Observed end-to-end: after a + // successful install the pane showed a second offer in its idle phase and + // typing went to the dialog rather than the prompt. One offer at a time. + // + // The slot is reserved BEFORE the first await. Discovery below awaits three + // times, and a check-then-act guard placed after them lets two dispatches + // that arrive close together both pass — which is worse than the bug it + // fixes, because the second dialog can replace an installing one and start a + // concurrent global npm install. + // `attach ` runs this plugin on the CLIENT while the binding, the PATH + // that matters and the MCP session all live on the SERVER. Probing PATH here + // would describe the wrong machine, and "Install now" would install npm on + // the client, leaving the server exactly as it was behind a success toast. + // attach.ts recognises that case the same way — the server's directory does + // not exist locally — so use it and refuse to act, saying where the fix goes. + // + // Not a complete answer: a client that happens to have the same path, with a + // binding, is still misread. Closing that needs server-side discovery and + // install behind an API, which this PR does not add. + if (!existsSync(api.state.path.directory)) { + log.info("engine install offer suppressed: not the host that owns this workspace") + api.ui.toast({ + variant: "warning", + message: `This workspace's engine is missing on the server, not on this machine. Run there: ${installCommand()}`, + duration: 30_000, + }) + return + } + if (engineOfferVisible) return + if (engineInstallInFlight) { + // An install started from an earlier dialog is still running; offering + // again would invite a second concurrent global install. + log.info("engine install offer suppressed while an install is in flight") + return + } + engineOfferVisible = true + const generation = ++engineOfferGeneration + const release = () => { + // Only the current owner may free the slot. + if (engineOfferGeneration === generation) engineOfferVisible = false + } + try { + const offer = await describeOffer(api.state.path.directory) + // Null means the situation resolved between the attach and this dialog — an + // engine appeared, or the project is no longer bound. Say nothing. + if (!offer) return release() + const latchScope = await currentLatchScope() + if (isEngineSkipActive(api, offer.workspaceId, latchScope, Date.now())) { + log.info("engine install offer suppressed by 7-day latch", { workspaceId: offer.workspaceId }) + return release() + } + const major = await detectNodeMajor() + const hasNpm = npmAvailable() + api.ui.dialog.replace(() => ( + + )) + } catch (err) { + release() + throw err + } +} + // ───────────────────────────────────────────────────────────────────────────── // Plugin registration // ───────────────────────────────────────────────────────────────────────────── @@ -1173,6 +1538,17 @@ const tui: TuiPlugin = async (api) => { runFlow(api, api.state.path.directory).catch((err) => reportFlowFailure(api, err)) }, }, + { + // Raised by engine-sync over the event bus when a bound workspace has + // no usable engine. Internal: dispatched, never shown in the palette. + name: OFFER_COMMAND, + title: "Workspace engine install offer", + category: "Altimate", + namespace: "internal", + run() { + showEngineInstallOffer(api).catch((err) => reportFlowFailure(api, err)) + }, + }, { name: "altimate.workspace.link", title: "Link this project to a workspace", @@ -1195,5 +1571,5 @@ export default { id: PLUGIN_ID, tui } satisfies BuiltinTuiPlugin // Exported for unit tests only. The shared logic (WorkspaceApi, cache, detect, // project-name) lives in `@/altimate/workspace/*` and should be tested there; // the plugin owns just the TUI-specific latch semantics. -export { isSkipActive, recordSkip } +export { isSkipActive, recordSkip, isEngineSkipActive, recordEngineSkip } // altimate_change end diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 4773f11ec..42207b426 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -176,6 +176,12 @@ 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 offer degrades 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 the notice to stderr instead of showing + // the install dialog. + 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/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index cf8ed145f..391b92697 100644 --- a/packages/opencode/test/altimate/plugin/workspace.test.ts +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -28,7 +28,7 @@ afterAll(() => { } }) -const { isSkipActive, recordSkip } = await import( +const { isSkipActive, recordSkip, isEngineSkipActive, recordEngineSkip } = await import( "../../../src/plugin/tui/altimate/workspace" ) const { projectNameFromRemote, detectProjectRemote } = await import( @@ -462,3 +462,73 @@ describe("Skip latch", () => { ).toBe(false) }) }) + +// ───────────────────────────────────────────────────────────────────────────── +// Engine install-offer latch — "Not now" silences the offer for 7 days, per +// workspace. Keyed on the workspace id so a rename doesn't reset it, and +// scoped by (tenant, apiUrl) like the post-scan latch. +// ───────────────────────────────────────────────────────────────────────────── + +describe("Engine install-offer latch", () => { + const scope = { tenant: "acme", apiUrl: "https://api.acme.example.com" } + const workspaceId = "42" + const DAY = 24 * 60 * 60 * 1000 + + test("no record → not active", () => { + const api = { kv: makeKv() } as any + expect(isEngineSkipActive(api, workspaceId, scope, Date.now())).toBe(false) + }) + + test("recorded within 7 days → active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now) + expect(isEngineSkipActive(api, workspaceId, scope, now + 6 * DAY)).toBe(true) + }) + + test("recorded past 7 days → not active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now) + expect(isEngineSkipActive(api, workspaceId, scope, now + 8 * DAY)).toBe(false) + }) + + test("boundary at exactly 7 days → not active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now) + expect(isEngineSkipActive(api, workspaceId, scope, now + 7 * DAY)).toBe(false) + }) + + test("latching one workspace does not silence another", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, "42", scope, now) + expect(isEngineSkipActive(api, "42", scope, now + DAY)).toBe(true) + expect(isEngineSkipActive(api, "43", scope, now + DAY)).toBe(false) + }) + + test("a latch in one account does not apply to another", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now) + const other = { tenant: "globex", apiUrl: "https://api.globex.example.com" } + expect(isEngineSkipActive(api, workspaceId, other, now + DAY)).toBe(false) + }) + + test("a future timestamp (clock rewind) re-offers instead of latching forever", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordEngineSkip(api, workspaceId, scope, now + 5 * DAY) + expect(isEngineSkipActive(api, workspaceId, scope, now)).toBe(false) + }) + + test("the post-scan latch and the engine latch are independent", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + const ident = { repoRemote: "git@github.com:acme/proj-a.git", projectPath: "/work/proj-a" } + recordSkip(api, ident, scope, now) + expect(isSkipActive(api, ident, scope, now + DAY)).toBe(true) + expect(isEngineSkipActive(api, workspaceId, scope, now + DAY)).toBe(false) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts new file mode 100644 index 000000000..506541f6c --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -0,0 +1,358 @@ +// altimate_change - new file +// +// Coverage for the "no usable engine" offer: which surface gets it, what the +// fallback emits when there is no surface, and the command/Node detection the +// dialog's "Install now" gate depends on. Everything routes through +// `syncInternals`, so no process is spawned and no MCP state is touched. +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { + ensure, + installCommand, + installEngine, + installSpec, + nodeMajor, + describeOffer, + isHeadless, + resetForTests, + syncInternals, + ENGINE_BINARY, + ENGINE_PACKAGE, + MIN_ENGINE_VERSION, + type EngineOffer, + type LocalMcpConfig, +} from "../../../src/altimate/workspace/engine-sync" +import { Process } from "../../../src/util/process" +import type { CachedBinding } from "../../../src/altimate/workspace/state" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE +const ORIGINAL_SPEC = process.env.ALTIMATE_ENGINE_INSTALL_SPEC + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: "/tmp/analytics", +} as CachedBinding + +type Harness = { + offers: EngineOffer[] + toasts: Array<{ title: string; message: string; variant: string }> + printed: string[] + published: number +} + +/** No engine on PATH (or an old one) plus a captured notify/print pair. */ +function install(opts: { + which?: string | null + version?: string | null + declaredKeys?: string[] + existing?: { type?: string; url?: string; command?: string[] | string; args?: string[] } | null +}): Harness { + const h: Harness = { offers: [], toasts: [], printed: [], published: 0 } + syncInternals.publishOffer = async () => { + h.published += 1 + return true + } + syncInternals.resolveBinding = async () => binding + syncInternals.which = () => (opts.which === undefined ? null : opts.which) + syncInternals.versionOf = async () => (opts.version === undefined ? null : opts.version) + syncInternals.declared = async () => ({ + keys: opts.declaredKeys ?? ["dbt_build_model", "dbt_compile_model"], + extensionKeys: [], + }) + syncInternals.existingEntry = async () => (opts.existing === undefined ? null : opts.existing) + syncInternals.persist = async () => {} + syncInternals.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.printLine = (line) => { + h.printed.push(line) + } + syncInternals.mcp = { + status: async () => ({}), + add: async (_n: string, _c: LocalMcpConfig) => {}, + connect: async () => {}, + remove: async () => {}, + tools: async () => ({}), + } + return h +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + delete process.env.ALTIMATE_ENGINE_INSTALL_SPEC + resetForTests() + delete process.env.ALTIMATE_CODE_HEADLESS +}) + +afterEach(() => { + for (const key of Object.keys(syncInternals) as Array) delete syncInternals[key] + delete process.env.ALTIMATE_CODE_HEADLESS + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG + if (ORIGINAL_SPEC === undefined) delete process.env.ALTIMATE_ENGINE_INSTALL_SPEC + else process.env.ALTIMATE_ENGINE_INSTALL_SPEC = ORIGINAL_SPEC +}) + +describe("install command", () => { + test("pins the minimum engine version by default", () => { + expect(installSpec()).toBe(`${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`) + expect(installCommand()).toBe(`npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`) + }) + + test("honours ALTIMATE_ENGINE_INSTALL_SPEC so E2E can point at a tarball", () => { + process.env.ALTIMATE_ENGINE_INSTALL_SPEC = "/tmp/datamate-0.6.3.tgz" + expect(installCommand()).toBe("npm i -g /tmp/datamate-0.6.3.tgz") + }) +}) + +describe("nodeMajor", () => { + test("null when node is not on PATH", async () => { + syncInternals.which = () => null + expect(await nodeMajor()).toBeNull() + }) +}) + +describe("offer routing — engine missing", () => { + test("headless prints exactly one line naming workspace and command, and no toast", async () => { + expect(isHeadless()).toBe(false) + process.env.ALTIMATE_CODE_HEADLESS = "1" + expect(isHeadless()).toBe(true) + const h = install({}) + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.toasts).toHaveLength(0) + expect(h.published).toBe(0) + expect(h.printed).toHaveLength(1) + expect(h.printed[0]).toContain('"analytics"') + expect(h.printed[0]).toContain(`npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`) + expect(h.printed[0]).toContain("2 integration tools") + }) + + test("singularises the tool count", async () => { + process.env.ALTIMATE_CODE_HEADLESS = "1" + const h = install({ declaredKeys: ["dbt_build_model"] }) + await ensure("s1") + expect(h.printed[0]).toContain("1 integration tool need") + expect(h.printed[0]).not.toContain("1 integration tools") + }) + + test("in a TUI the offer is published and nothing is printed to stdout", async () => { + const h = install({}) + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.published).toBe(1) + expect(h.toasts).toHaveLength(0) + // Printing here would corrupt the TUI's own render. + expect(h.printed).toHaveLength(0) + }) + + test("falls back to the toast only when the bus is unavailable", async () => { + const h = install({}) + syncInternals.publishOffer = async () => false + expect(await ensure("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + expect(h.toasts).toHaveLength(1) + expect(h.printed).toHaveLength(0) + }) +}) + +describe("offer routing — engine too old", () => { + test("carries the found version and the update command", async () => { + const h = install({ which: "/usr/local/bin/datamate", version: "0.5.9" }) + syncInternals.offer = (offer) => { + h.offers.push(offer) + return true + } + expect(await ensure("s1")).toEqual({ kind: "engine-too-old", found: "0.5.9" }) + expect(h.offers[0]).toMatchObject({ + reason: "engine-too-old", + workspaceId: "42", + workspaceName: "analytics", + found: "0.5.9", + declared: 2, + }) + expect(h.toasts).toHaveLength(0) + expect(h.printed).toHaveLength(0) + }) + + test("headless, the printed line names the found version", async () => { + process.env.ALTIMATE_CODE_HEADLESS = "1" + const h = install({ which: "/usr/local/bin/datamate", version: "0.5.9" }) + await ensure("s1") + expect(h.printed).toHaveLength(1) + expect(h.printed[0]).toContain("found 0.5.9") + expect(h.printed[0]).toContain('"analytics"') + }) +}) + +describe("offer is not raised when an engine is usable", () => { + test("a healthy engine never reaches the offer path", async () => { + // Version is taken from the floor itself, not a literal: MIN_ENGINE_VERSION + // moves (0.6.3 -> 0.7.0 already), and a hardcoded version silently turns + // this into a too-old test the next time it does. + const h = install({ + which: "/usr/local/bin/datamate", + version: MIN_ENGINE_VERSION, + // Rule 1 reuses an entry only when it is pinned to the bound workspace. + existing: { type: "local", command: [ENGINE_BINARY, "start-stdio", "--datamate", "42"] }, + }) + syncInternals.offer = (offer) => { + h.offers.push(offer) + return true + } + syncInternals.mcp = { + status: async () => ({ datamate: { status: "connected" } }), + add: async () => {}, + connect: async () => {}, + remove: async () => {}, + tools: async () => ({ datamate_dbt_build_model: 1, datamate_dbt_compile_model: 1 }), + } + await ensure("s1") + expect(h.offers).toHaveLength(0) + expect(h.printed).toHaveLength(0) + }) +}) + +describe("describeOffer — the TUI re-derives its own detail", () => { + // The offer reaches the plugin as a bare command (CommandExecute carries no + // payload) and the plugin runtime is a separate realm, so this re-derivation + // is the only way the dialog learns what to say. Regression guard for the + // defect E2E caught, where an in-process handoff silently degraded to a toast. + test("describes a missing engine", async () => { + install({}) + const offer = await describeOffer("/tmp/whatever") + expect(offer).toMatchObject({ + reason: "engine-missing", + workspaceId: "42", + workspaceName: "analytics", + declared: 2, + }) + expect(offer?.found).toBeUndefined() + }) + + test("describes an engine below the floor, naming the version found", async () => { + install({ which: "/usr/local/bin/datamate", version: "0.5.9" }) + const offer = await describeOffer("/tmp/whatever") + expect(offer).toMatchObject({ reason: "engine-too-old", found: "0.5.9" }) + }) + + test("returns null when an engine already clears the floor", async () => { + install({ which: "/usr/local/bin/datamate", version: MIN_ENGINE_VERSION }) + expect(await describeOffer("/tmp/whatever")).toBeNull() + }) + + test("returns null when the project is not bound", async () => { + install({}) + syncInternals.resolveBinding = async () => null + expect(await describeOffer("/tmp/whatever")).toBeNull() + }) +}) + +describe("headless notice stream", () => { + // Regression guard: the notice used to go to stdout, which `run --format + // json` documents as raw JSON events. A human-readable line there was line 1 + // of an otherwise-valid JSON stream and broke line-oriented consumers. + test("the default printer writes to stderr, never stdout", async () => { + process.env.ALTIMATE_CODE_HEADLESS = "1" + const h = install({}) + // Exercise the real printer, not the seam. + delete syncInternals.printLine + const outChunks: string[] = [] + const errChunks: string[] = [] + const realOut = process.stdout.write.bind(process.stdout) + const realErr = process.stderr.write.bind(process.stderr) + process.stdout.write = ((c: string) => { + outChunks.push(String(c)) + return true + }) as typeof process.stdout.write + process.stderr.write = ((c: string) => { + errChunks.push(String(c)) + return true + }) as typeof process.stderr.write + try { + await ensure("s1") + } finally { + process.stdout.write = realOut + process.stderr.write = realErr + } + expect(errChunks.join("")).toContain("need the local engine") + expect(outChunks.join("")).not.toContain("need the local engine") + expect(h.toasts).toHaveLength(0) + }) +}) + +describe("install deadline", () => { + // The invariant is that installEngine hands the spawn an abort signal, which + // is the ONLY thing that produces a deadline: Process.spawn consults + // `timeout` solely inside its abort handler, as the grace before SIGKILL, so + // without a signal a stalled npm runs forever and the dialog sits on + // "Installing…". Measured on the real helper — an 8s sleep took 8004ms under + // `timeout` alone and 502ms under an abort signal. + // + // An earlier version of this test stubbed syncInternals.install to return a + // timeout error and asserted that error came back. That asserted nothing: + // it echoed the stub and passed just as happily with the abort signal + // deleted. This spies on the real call instead. + test("passes an abort signal to the spawn, not just a timeout", async () => { + // Discovery now runs after a zero exit, so the installed engine has to look + // usable or installEngine reports the PATH failure instead. That is the + // round-9 behaviour; this test is about the abort signal, so make discovery + // succeed and keep the assertion on the spawn options. + install({ which: "/usr/local/bin/datamate", version: MIN_ENGINE_VERSION }) + delete syncInternals.install + const spy = spyOn(Process, "run").mockResolvedValue({ + code: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }) + try { + const result = await installEngine() + expect(result.ok).toBe(true) + expect(spy).toHaveBeenCalled() + const opts = spy.mock.calls[0]?.[1] as { abort?: AbortSignal } | undefined + // The load-bearing assertion: a real AbortSignal was supplied. + expect(opts?.abort).toBeInstanceOf(AbortSignal) + expect(opts?.abort?.aborted).toBe(false) + } finally { + spy.mockRestore() + } + }) +}) + +describe("install success is verified, not assumed", () => { + // npm installs into its configured global prefix, whose bin directory need + // not be on PATH. A zero exit therefore does not mean the next attach will + // find anything — and reporting success there promises tools that never + // arrive, then raises the offer again. + test("a zero exit with the engine still absent from PATH is a failure", async () => { + install({ which: null }) + delete syncInternals.install + const spy = spyOn(Process, "run").mockResolvedValue({ + code: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }) + try { + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("not on PATH") + } finally { + spy.mockRestore() + } + }) + + test("a zero exit with a below-floor engine on PATH is a failure", async () => { + install({ which: "/usr/local/bin/datamate", version: "0.5.9" }) + delete syncInternals.install + const spy = spyOn(Process, "run").mockResolvedValue({ + code: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }) + try { + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("0.5.9") + } finally { + spy.mockRestore() + } + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-sync.test.ts b/packages/opencode/test/altimate/workspace/engine-sync.test.ts index a59996c54..d932b4471 100644 --- a/packages/opencode/test/altimate/workspace/engine-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-sync.test.ts @@ -75,6 +75,14 @@ function install(opts: { syncInternals.notify = async (toast) => { h.toasts.push(toast) } + // No dialog surface is registered in this suite, so the offer path falls back + // to toast + a printed line. Swallow the line so the suite stays quiet; the + // printed-line contract itself is covered in engine-install-offer.test.ts. + syncInternals.printLine = () => {} + // The "no usable engine" branches now offer through the event bus, which a + // unit test has no bus for. Force the publish to fail so this suite keeps + // exercising — and asserting on — the toast fallback it was written against. + syncInternals.publishOffer = async () => false syncInternals.mcp = { status: async () => h.statusQueue.length > 1 ? h.statusQueue.shift()! : h.statusQueue[0]!, add: async (name, cfg) => {