diff --git a/packages/opencode/src/altimate/workspace/engine-offer.ts b/packages/opencode/src/altimate/workspace/engine-offer.ts new file mode 100644 index 0000000000..ff1f0db906 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-offer.ts @@ -0,0 +1,287 @@ +// altimate_change - new file +// +// The install offer for a workspace whose engine is missing or too old. +// +// State-free on purpose: the TUI plugin runs in its own module realm and +// receives the offer as a bare command over the event bus, so it re-derives +// the detail here from disk and PATH rather than from the overlay's memory. +// Offer, never install on the flow's own account — `installEngine` only ever +// runs from an explicit "Install now". +import { execFile, type ChildProcess } from "node:child_process" +import launch from "cross-spawn" +import { Process } from "@/util/process" +import { AppRuntime } from "@/effect/app-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { readLocalBinding } from "./state" +import { isHeadless, log, syncInternals } from "./engine-seams" +import { declaredBounded, notify, printLine, versionOf, which } from "./engine-probes" +import { ENGINE_BINARY, ENGINE_PACKAGE, MIN_ENGINE_VERSION, clearsFloor, type Toast } from "./engine-types" + +/** 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 +/** 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, because the plugin runtime loads plugins in a separate realm. + * `CommandExecute` carries no payload, so the plugin re-derives the offer + * with `describeOffer()`. */ +export const OFFER_COMMAND = "altimate.workspace.engineInstallOffer" +/** How long "Not now" silences the offer for a workspace. The TUI latch and + * the per-session announce dedupe both key on this, so a session that + * outlives the latch sees the offer again instead of waiting for a new one. */ +export const OFFER_SKIP_TTL_MS = 7 * 24 * 60 * 60 * 1000 +/** Once a session's offer is older than the latch, how often it is raised + * again while the verdict stands. The TUI's latch starts when "Not now" is + * chosen, not when the offer was raised, so the attach side cannot know when + * it ends: it re-raises at this cadence and the TUI suppresses until then. */ +export const OFFER_RECHECK_MS = 60 * 60 * 1000 + +/** 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, in the same realm. Returns true when it + * took ownership. Deliberately synchronous: it claims the offer and renders + * out-of-band rather than making the turn boundary wait for a person. */ +export type OfferHandler = (offer: EngineOffer) => boolean + +export type InstallResult = { ok: true } | { ok: false; error: string } + +/** 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()}` +} + +/** Re-derive the current "no usable engine" state for a directory, from the + * binding on disk and the engine on PATH. Null when there is nothing to offer: + * unbound, or an engine that clears the floor. */ +export async function describeOffer(directory: string): Promise { + const binding = syncInternals.resolveBinding + ? await syncInternals.resolveBinding(directory) + : 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 && clearsFloor(found)) return null + const declared = (await declaredBounded(workspaceId))?.keys.length ?? 0 + return { + reason: bin ? "engine-too-old" : "engine-missing", + workspaceId, + workspaceName: binding.datamateName, + declared, + ...(bin ? { found: found ?? "unknown" } : {}), + command: installCommand(), + } +} + +/** Node major on PATH, or null when Node is absent. Gates "Install now": with + * no Node there is nothing to run npm with, so the offer shows 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+ does not imply `npm i -g` runs. */ +export function npmAvailable(): boolean { + if (syncInternals.npmAvailable) return syncInternals.npmAvailable() + return which(process.platform === "win32" ? "npm.cmd" : "npm") !== null +} + +/** Options are the process-group and deadline handling for the one command + * the offer runs. Nothing here spawns a shell. */ +export type InstallRun = { code: number | null; timedOut: boolean; stderr: string } +/** After the deadline's SIGTERM, how long the tree gets before SIGKILL and the + * run is reported as timed out regardless of what is still alive. */ +export const INSTALL_KILL_GRACE_MS = 5_000 + +/** Run the install command with a real deadline. npm forks a tree (scripts, + * node), and a descendant that outlives npm can keep the stderr pipe open, so + * the run settles on the child's `exit`, never on `close`, and the deadline + * signals the whole process group (POSIX: the child is its own group leader; + * Windows: taskkill /T) — SIGTERM first, SIGKILL after the grace, then the run + * reports the timeout whether or not anything is still holding a pipe. */ +export function runInstall( + argv: string[], + timeoutMs = INSTALL_TIMEOUT_MS, + graceMs = INSTALL_KILL_GRACE_MS, +): Promise { + if (syncInternals.runInstall) return syncInternals.runInstall(argv, timeoutMs) + return new Promise((resolve) => { + const grouped = process.platform !== "win32" + let child: ChildProcess + try { + child = launch(argv[0], argv.slice(1), { + stdio: ["ignore", "ignore", "pipe"], + detached: grouped, + windowsHide: process.platform === "win32", + }) + } catch (err) { + resolve({ code: null, timedOut: false, stderr: err instanceof Error ? err.message : String(err) }) + return + } + let stderr = "" + child.stderr?.on("data", (chunk) => { + stderr = (stderr + String(chunk)).slice(-4096) + }) + let timedOut = false + let settled = false + let hard: ReturnType | undefined + const finish = (code: number | null) => { + if (settled) return + settled = true + clearTimeout(timer) + // Past the deadline the escalation stays armed: npm (the group leader) + // usually dies on SIGTERM, but a descendant that ignores it must still + // get the SIGKILL, so the leader's exit does not cancel it. + if (hard && !timedOut) clearTimeout(hard) + resolve({ code, timedOut, stderr }) + } + const killTree = (signal: NodeJS.Signals) => { + if (grouped && child.pid) { + try { + process.kill(-child.pid, signal) + return + } catch { + // The group is already gone; fall through to the child itself. + } + } + if (process.platform === "win32") { + void Process.stop(child) + return + } + try { + child.kill(signal) + } catch { + // Already exited. + } + } + const timer = setTimeout(() => { + timedOut = true + killTree("SIGTERM") + hard = setTimeout(() => { + // The group outlives its leader while any member is alive, so this + // reaches survivors even after npm itself has exited. + killTree("SIGKILL") + finish(null) + }, graceMs) + }, timeoutMs) + child.once("exit", (code) => finish(code)) + child.once("error", (err) => { + stderr = stderr || err.message + finish(null) + }) + }) +} + +/** `npm i -g ` with a deadline (`runInstall`). A zero exit is not a + * usable engine — npm's global bin directory need not be on PATH — so the same + * discovery the turn boundary does runs before success. */ +export async function installEngine(): Promise { + const spec = installSpec() + if (syncInternals.install) return syncInternals.install(spec) + const npm = process.platform === "win32" ? "npm.cmd" : "npm" + const run = await runInstall([npm, "i", "-g", spec]) + if (run.timedOut) { + return { ok: false, error: `npm did not finish within ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes` } + } + if (run.code === 0) { + 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 (!clearsFloor(installedVersion)) { + return { + ok: false, + error: `npm installed it, but ${ENGINE_BINARY} on PATH reports ${installedVersion ?? "no version"}`, + } + } + return { ok: true } + } + const detail = run.stderr.trim().split(/\r?\n/).slice(-3).join(" ") + return { ok: false, error: detail || `npm exited with code ${run.code ?? "unknown"}` } +} + +/** Ask the TUI to raise the offer. False when the bus is unavailable. The + * session is carried so an attached headless run, which reads the same event + * stream, prints the offer raised for its own session only. */ +async function publishOffer(sessionID: string): Promise { + if (syncInternals.publishOffer) return syncInternals.publishOffer(sessionID) + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => + events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND, sessionID }), + ), + ) + return true + } catch (err) { + log.warn("could not publish the engine install offer", { err: String(err) }) + return false + } +} + +/** Hand the offer to a same-realm 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 + } +} + +/** One printed line for headless `run`. */ +export function describeOfferLine(offer: EngineOffer): string { + const tools = `${offer.declared} integration tool${offer.declared === 1 ? "" : "s"}` + return 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}` +} + +/** Offer via the dialog surface when there is one; otherwise print (headless) + * or toast (bus unavailable). Exactly one of these happens. */ +export async function offerOrNotify(offer: EngineOffer, toast: Toast, sessionID: string): Promise { + if (isHeadless()) { + printLine(describeOfferLine(offer)) + return + } + if (offerInstall(offer)) return + if (await publishOffer(sessionID)) return + await notify(toast) +} diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 6110c86848..47ffda6b2c 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -35,10 +35,11 @@ import { syncInternals, type ScopedBinding, } from "./engine-seams" -import { declaredBounded, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" +import { declaredBounded, fingerprint, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" +import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" import { ENGINE_BINARY, - INSTALL_COMMAND, + INSTALL_HELPS, REPAIRABLE, TOOL_PREFIX, clearsFloor, @@ -56,6 +57,7 @@ import { } from "./engine-types" export * from "./engine-types" +export * from "./engine-offer" export { isEnabled, isHeadless, isServe, syncInternals } from "./engine-seams" /** Sessions remembered per process. It is a memo; an evicted session just re-settles. */ @@ -70,7 +72,7 @@ const DECLARED_RETRY_MS = 60_000 type Probe = { kind: "ok"; version: string } | { kind: "missing" } | { kind: "too-old"; found: string | null } -let probeMemo: { result: Probe; at: number } | null = null +let probeMemo: { result: Probe; at: number; fingerprint: string | null } | null = null function now(): number { return syncInternals.now ? syncInternals.now() : Date.now() @@ -78,10 +80,26 @@ function now(): number { async function probeEngine(): Promise { const at = now() - if (probeMemo && (probeMemo.result.kind === "ok" || at - probeMemo.at < FAILED_PROBE_TTL_MS)) { + const bin = which(ENGINE_BINARY) + // A usable engine is remembered for the process. A missing one is asked + // about on every call — `which` is a PATH scan, no process spawn — so an + // install made from the offer dialog (which runs in another module realm and + // cannot reach this memo) is seen on the next turn. A too-old or broken one + // costs a spawn to re-check, so that is rate-limited by the TTL — but only + // while the file on PATH is the same one: an update written over it (the + // offer's `npm i -g` on an old engine) changes the fingerprint and is + // re-probed on the next turn, just as an install is. + const seen = bin ? fingerprint(bin) : null + if (probeMemo && probeMemo.result.kind === "ok") return probeMemo.result + if ( + probeMemo && + probeMemo.result.kind === "too-old" && + bin && + probeMemo.fingerprint === seen && + at - probeMemo.at < FAILED_PROBE_TTL_MS + ) { return probeMemo.result } - const bin = which(ENGINE_BINARY) let result: Probe if (!bin) { result = { kind: "missing" } @@ -89,12 +107,14 @@ async function probeEngine(): Promise { const version = await versionOf(bin) result = clearsFloor(version) ? { kind: "ok", version: version! } : { kind: "too-old", found: version } } - probeMemo = { result, at } + probeMemo = { result, at, fingerprint: seen } 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. */ + * again immediately. Nothing in production calls this — the offer dialog runs + * in another module realm — which is why the probe itself notices an engine + * that appeared or changed on PATH. Kept for tests and diagnostics. */ export function invalidateProbe(): void { probeMemo = null } @@ -257,14 +277,21 @@ export async function managedWorkspaceLoaded( /** `retried`: this session already spent its one re-add on a failed handshake. * Per session, so "start a new session to try again" is true. */ -type SessionRecord = { outcome: Outcome; announced?: string; retried?: boolean } +type SessionRecord = { outcome: Outcome; announced?: string; announcedAt?: number; retried?: boolean } const sessions = new Map() const declaredCache = new Map() +/** Verdict signatures a headless process has already printed to stderr. */ +const headlessPrinted = new Set() 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 } + const next: SessionRecord = { + outcome, + announced: previous?.announced, + announcedAt: previous?.announcedAt, + retried: previous?.retried, + } sessions.set(sessionID, next) while (sessions.size > MAX_TRACKED_SESSIONS) { const oldest = sessions.keys().next().value @@ -494,19 +521,43 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS 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", - }) + await announceRefusal( + sessionID, + outcome, + { + title: `Workspace "${workspace.name}" needs the local engine`, + message: `${what} Install it with: ${installCommand()}`, + variant: "warning", + }, + { + reason: "engine-missing", + workspaceId: workspace.id, + workspaceName: workspace.name, + declared: count ?? 0, + command: installCommand(), + }, + ) return } record(sessionID, refusal) - await announceRefusal(sessionID, refusal, { - title: `Workspace "${workspace.name}": engine not usable`, - message: describeRefusal(refusal.found, workspace.name), - variant: "warning", - }) + const declared = (await declaredFor(workspace))?.keys.length ?? 0 + await announceRefusal( + sessionID, + refusal, + { + title: `Workspace "${workspace.name}": engine not usable`, + message: describeRefusal(refusal.found, workspace.name), + variant: "warning", + }, + { + reason: "engine-too-old", + workspaceId: workspace.id, + workspaceName: workspace.name, + declared, + found: refusal.found ?? "unknown", + command: installCommand(), + }, + ) return } @@ -574,16 +625,49 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS /** 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 { + * The substitution point for the install offer: when installing would help + * and an `offer` is supplied, the offer surface (dialog, headless line, or + * toast fallback) replaces the toast — never adds to it. Otherwise headless + * `run` prints one stderr line and the TUI gets the toast. + * + * The offer route's "once" expires with the "Not now" latch: a session that + * stays open past `OFFER_SKIP_TTL_MS` is offered again, so the latch (which + * the TUI checks on every offer) decides, not the age of the session. The + * latch is measured from the user's "Not now", which can come well after the + * offer was raised, so after the first expiry the offer is re-raised every + * `OFFER_RECHECK_MS` rather than once per further window — the TUI keeps + * suppressing it until its latch really ends. */ +export async function announceRefusal( + sessionID: string, + outcome: Outcome, + toast: Toast, + offer?: EngineOffer, +): 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 + const offering = !!offer && INSTALL_HELPS[outcome.kind] + const at = now() + let repeat = false + if (rec.announced === signature) { + const expired = offering && rec.announcedAt !== undefined && at - rec.announcedAt >= OFFER_SKIP_TTL_MS + if (!expired) return + repeat = true + } rec.announced = signature + rec.announcedAt = repeat ? at - OFFER_SKIP_TTL_MS + OFFER_RECHECK_MS : at + if (isHeadless()) { + // A headless `run` is one process with one stderr, whatever sessions it + // creates along the way (a sub-agent's session settles the same verdict + // and would print the same line). One line per verdict per process. + if (headlessPrinted.has(signature)) return + headlessPrinted.add(signature) + } + if (offering) { + await offerOrNotify(offer, toast, sessionID) + return + } if (isHeadless()) { printLine(`${toast.title}: ${toast.message}`) return @@ -592,8 +676,8 @@ export async function announceRefusal(sessionID: string, outcome: Outcome, 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. */ + * offer, which schedules nothing itself: it installs, and the next turn + * boundary sees the new or changed binary on PATH and attaches. */ export function isRepairable(outcome: Outcome | undefined): boolean { return !!outcome && REPAIRABLE[outcome.kind] } @@ -605,6 +689,7 @@ export function resetForTests(): void { sessions.clear() turnTools.clear() declaredCache.clear() + headlessPrinted.clear() } /** Test-only views. */ diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index 1ca22bb5b1..fb4656d8bb 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -2,6 +2,7 @@ // // Everything that asks the outside world a question: the binary, its // version, the workspace allowlist, and the user-facing surfaces. +import { statSync } from "fs" import launch from "cross-spawn" import { which as whichBinary } from "@opencode-ai/core/util/which" import { AltimateApi } from "@/altimate/api/client" @@ -34,6 +35,20 @@ export function which(cmd: string): string | null { return syncInternals.which ? syncInternals.which(cmd) : whichBinary(cmd) } +/** Identity of the file behind a PATH hit, cheap enough to ask every turn: + * size and mtime of the target (symlinks followed, so an npm bin shim whose + * package was reinstalled reads as changed). Null when it cannot be stat'ed; + * with nothing to compare, the caller's memo falls back to its TTL. */ +export function fingerprint(bin: string): string | null { + if (syncInternals.fingerprint) return syncInternals.fingerprint(bin) + try { + const stat = statSync(bin) + return `${stat.size}:${stat.mtimeMs}` + } catch { + return null + } +} + /** `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. diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 0e53f00632..d2a795ece4 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -7,6 +7,7 @@ import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import type { CachedBinding } from "./state" import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types" +import type { EngineOffer, InstallResult } from "./engine-offer" export const log = Log.create({ service: "workspace-engine" }) @@ -19,9 +20,20 @@ export const syncInternals: { resolveBinding?: (directory: string) => Promise which?: (cmd: string) => string | null versionOf?: (bin: string) => Promise + fingerprint?: (bin: string) => string | null declared?: (workspaceId: string) => Promise notify?: (toast: Toast) => Promise printLine?: (line: string) => void + /** Install-offer seams (see engine-offer.ts). */ + offer?: (offer: EngineOffer) => boolean + publishOffer?: (sessionID: string) => Promise + runInstall?: ( + argv: string[], + timeoutMs: number, + ) => Promise<{ code: number | null; timedOut: boolean; stderr: string }> + nodeMajor?: () => Promise + npmAvailable?: () => boolean + install?: (spec: string) => Promise instanceDirectory?: () => string | null headless?: () => boolean serve?: () => boolean diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3f6bf66bd7..f8fd0492fe 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 { OFFER_COMMAND, installCommand } from "@/altimate/workspace/engine-offer" // altimate_change end // When a tool's parameters can't be statically inferred (legacy fork tools whose @@ -752,6 +754,29 @@ 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. With --attach the turn boundary and + // isHeadless() run in the server process, which 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. + // Placed before the idle break: the loop stops on idle, so a handler + // after it never runs for an offer that arrives in the same batch. + // The stream carries every session's events for this directory; only + // the offer raised for this run's session is this run's to print. + if ( + event.type === "tui.command.execute" && + (event.properties as { command?: string }).command === OFFER_COMMAND && + (event.properties as { sessionID?: string }).sessionID === sessionID + ) { + // stderr: 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: ${installCommand()}` + + EOL, + ) + continue + } + // altimate_change end + if ( event.type === "session.status" && event.properties.sessionID === sessionID && diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 2a75f93c47..0c40fbf138 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,18 @@ 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, + OFFER_SKIP_TTL_MS, + type EngineOffer, +} from "@/altimate/workspace/engine-offer" +import { useClipboard } from "@opencode-ai/tui/context/clipboard" import { AltimateApi } from "@/altimate/api/client" import { Log } from "@/altimate/util/log" @@ -1143,6 +1156,384 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { )) } +// ───────────────────────────────────────────────────────────────────────────── +// Engine install offer. The workspace engine overlay 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") + ) +} + +/** The KV store starts empty and fills in once the persisted file is read + * (`api.kv.ready`). A latch checked before that reads as absent, so an offer + * raised on the first message after a restart would ignore a "Not now" that + * is still in force. `ready` is a plain getter with nothing to await, so poll + * it — bounded, and on timeout proceed as if hydrated rather than never + * answer. Resolves to whether the store was ready. */ +const KV_READY_TIMEOUT_MS = 3_000 +const KV_READY_POLL_MS = 25 +async function awaitKvReady( + kv: { readonly ready: boolean }, + timeoutMs = KV_READY_TIMEOUT_MS, + pollMs = KV_READY_POLL_MS, +): Promise { + const deadline = Date.now() + timeoutMs + while (!kv.ready) { + if (Date.now() >= deadline) return false + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } + return true +} + +/** Same clock-rewind handling as the post-scan latch; the TTL is the one the + * attach side's announce dedupe expires on, so both agree on "7 days". */ +function isEngineSkipActive( + api: TuiPluginApi, + workspaceId: string, + scope: LatchScope | null, + nowMs: number, +): boolean { + 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 < OFFER_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 reconciling this session from here. The plugin runtime + // loads this file in its own realm, so the overlay module here is not the + // one the server consults. Nothing needs to: the turn boundary looks for a + // missing engine on PATH again every turn, so the engine just installed is + // picked up on the next message without a restart. + 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. + * + * The overlay 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 (!(await awaitKvReady(api.kv))) { + log.warn("kv store not hydrated in time; checking the engine install latch against what is loaded") + } + 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 +1564,17 @@ const tui: TuiPlugin = async (api) => { runFlow(api, api.state.path.directory).catch((err) => reportFlowFailure(api, err)) }, }, + { + // Raised by the workspace engine overlay 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 +1597,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, awaitKvReady } // altimate_change end diff --git a/packages/opencode/src/server/tui-event.ts b/packages/opencode/src/server/tui-event.ts index 73412b8778..93d3f7e6df 100644 --- a/packages/opencode/src/server/tui-event.ts +++ b/packages/opencode/src/server/tui-event.ts @@ -31,6 +31,12 @@ export const TuiEvent = { ]), Schema.String, ]), + // altimate_change start — the workspace engine install offer is published + // as a command for the TUI plugin, and an attached headless run reads the + // same stream: the session it was raised for lets that run print only its + // own offer, not another session's in the same directory. + sessionID: Schema.optional(Schema.String), + // altimate_change end }, }), ToastShow: EventV2.define({ diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index cf8ed145fe..d4cf5abfe4 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, awaitKvReady } = await import( "../../../src/plugin/tui/altimate/workspace" ) const { projectNameFromRemote, detectProjectRemote } = await import( @@ -462,3 +462,107 @@ 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) + }) +}) + +describe("engine install offer — kv hydration", () => { + // The store starts empty until kv.json has been read; a "Not now" latch + // checked before that reads as absent. The offer waits for `ready`. + test("waits for the store to hydrate before the latch is consulted", async () => { + let ready = false + setTimeout(() => { + ready = true + }, 60) + const t0 = Date.now() + expect( + await awaitKvReady( + { + get ready() { + return ready + }, + }, + 1_000, + 5, + ), + ).toBe(true) + expect(Date.now() - t0).toBeGreaterThanOrEqual(50) + }) + test("returns at once when the store is already hydrated", async () => { + const t0 = Date.now() + expect(await awaitKvReady({ ready: true }, 1_000, 5)).toBe(true) + expect(Date.now() - t0).toBeLessThan(50) + }) + test("gives up after the timeout so a stuck read never blocks the offer", async () => { + const t0 = Date.now() + expect(await awaitKvReady({ ready: false }, 40, 5)).toBe(false) + expect(Date.now() - t0).toBeGreaterThanOrEqual(35) + }) +}) 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 0000000000..7665f649cd --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -0,0 +1,371 @@ +// altimate_change - new file +// +// The "no usable engine" offer: which surface gets it, what the fallback +// emits when there is no surface, what the TUI re-derives, and the install +// path's gates and verification. Everything routes through `syncInternals`. +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { + ENGINE_BINARY, + ENGINE_PACKAGE, + MIN_ENGINE_VERSION, + beforeTurn, + describeOffer, + installCommand, + installEngine, + runInstall, + installSpec, + nodeMajor, + resetForTests, + settledOutcome, + syncInternals, + type EngineOffer, + type Toast, +} from "../../../src/altimate/workspace/engine-overlay" +import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS } from "../../../src/altimate/workspace/engine-offer" +import type { CachedBinding } from "../../../src/altimate/workspace/state" + +const DIR = "/tmp/analytics" +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE +const ORIGINAL_SPEC = process.env.ALTIMATE_ENGINE_INSTALL_SPEC + +const binding: CachedBinding = { + datamateId: 42, + datamateName: "analytics", + repoRemote: null, + projectPath: DIR, + linkedAt: 0, +} as CachedBinding + +type Harness = { offers: EngineOffer[]; toasts: Toast[]; printed: string[]; published: number; publishedFor: string[] } + +/** No engine on PATH (or an old one) plus captured surfaces. */ +function install(opts: { + which?: string | null + version?: string | null + declaredKeys?: string[] + headless?: boolean + bus?: boolean + surface?: boolean + bound?: boolean +}): Harness { + const h: Harness = { offers: [], toasts: [], printed: [], published: 0, publishedFor: [] } + process.env.ALTIMATE_WORKSPACE = "1" + syncInternals.serve = () => false + syncInternals.headless = () => opts.headless === true + syncInternals.instanceDirectory = () => DIR + syncInternals.resolveBinding = async () => (opts.bound === false ? null : 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.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.printLine = (line) => { + h.printed.push(line) + } + syncInternals.publishOffer = async (sessionID) => { + if (opts.bus === false) return false + h.published += 1 + h.publishedFor.push(sessionID) + return true + } + if (opts.surface) { + syncInternals.offer = (offer) => { + h.offers.push(offer) + return true + } + } + const config: { mcp?: Record } = { mcp: {} } + let loaded = false + syncInternals.config = { + invalidate: async () => { + loaded = false + }, + get: async () => { + if (!loaded) { + const { overlay } = await import("../../../src/altimate/workspace/engine-overlay") + config.mcp = {} + await overlay(DIR, config) + loaded = true + } + return config + }, + } + syncInternals.mcp = { + status: async () => ({ datamate: { status: "connected" } }), + add: async () => {}, + remove: async () => {}, + tools: async () => ({}), + } + 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 + 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", () => { + delete process.env.ALTIMATE_ENGINE_INSTALL_SPEC + 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.tgz" + expect(installCommand()).toBe("npm i -g /tmp/datamate.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("a same-realm surface takes the offer and nothing else is emitted", async () => { + const h = install({ surface: true }) + await beforeTurn("s1") + expect(h.offers).toEqual([ + { + reason: "engine-missing", + workspaceId: "42", + workspaceName: "analytics", + declared: 2, + command: installCommand(), + }, + ]) + expect(h.published).toBe(0) + expect(h.toasts).toEqual([]) + expect(h.printed).toEqual([]) + expect(settledOutcome("s1")).toEqual({ kind: "engine-missing", declared: 2 }) + }) + test("in a TUI the offer is published over the bus and nothing is printed or toasted", async () => { + const h = install({}) + await beforeTurn("s1") + expect(h.published).toBe(1) + expect(h.toasts).toEqual([]) + expect(h.printed).toEqual([]) + }) + test("falls back to the toast only when the bus is unavailable", async () => { + const h = install({ bus: false }) + await beforeTurn("s1") + expect(h.published).toBe(0) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].message).toContain(installCommand()) + }) + test("headless prints exactly one line naming workspace and command, and no toast", async () => { + const h = install({ headless: true }) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.printed).toEqual([ + `Workspace "analytics": 2 integration tools need the local engine, which is not installed. Install it with: ${installCommand()}`, + ]) + expect(h.toasts).toEqual([]) + expect(h.published).toBe(0) + }) + test("singularises the tool count", async () => { + const h = install({ headless: true, declaredKeys: ["dbt_build_model"] }) + await beforeTurn("s1") + expect(h.printed[0]).toContain("1 integration tool need") + }) + test("the offer is raised once per session per verdict, naming the session it is for", async () => { + const h = install({}) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.published).toBe(1) + await beforeTurn("s2") + expect(h.published).toBe(2) + // An attached headless run reads every session's events for the directory + // and prints only the offer raised for its own session. + expect(h.publishedFor).toEqual(["s1", "s2"]) + }) + test("a session that outlives the Not-now latch is offered again", async () => { + // The TUI re-checks its 7-day latch on every offer; the dedupe here must + // not outlast that latch, or a long-lived session never sees the offer + // return after "Not now" expires. + let clock = 1_000_000 + syncInternals.now = () => clock + const h = install({}) + await beforeTurn("s1") + clock += OFFER_SKIP_TTL_MS - 1 + await beforeTurn("s1") + expect(h.published).toBe(1) + clock += 1 + await beforeTurn("s1") + expect(h.published).toBe(2) + await beforeTurn("s1") + expect(h.published).toBe(2) + // The latch runs from "Not now", which may come long after the offer was + // raised, so once the window has passed the offer is re-raised hourly — + // never held for another full window. + clock += OFFER_RECHECK_MS - 1 + await beforeTurn("s1") + expect(h.published).toBe(2) + clock += 1 + await beforeTurn("s1") + expect(h.published).toBe(3) + }) +}) + +describe("offer routing — engine too old", () => { + test("carries the found version and the update command", async () => { + const h = install({ surface: true, which: "/usr/local/bin/datamate", version: "0.6.3" }) + await beforeTurn("s1") + expect(h.offers).toEqual([ + { + reason: "engine-too-old", + workspaceId: "42", + workspaceName: "analytics", + declared: 2, + found: "0.6.3", + command: installCommand(), + }, + ]) + expect(settledOutcome("s1")).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + }) + test("headless, the printed line names the found version", async () => { + const h = install({ headless: true, which: "/usr/local/bin/datamate", version: "0.6.3" }) + await beforeTurn("s1") + expect(h.printed).toEqual([ + `Workspace "analytics": 2 integration tools need ${ENGINE_BINARY} ${MIN_ENGINE_VERSION}+ (found 0.6.3). Update with: ${installCommand()}`, + ]) + }) + test("headless, a sub-agent's session in the same process prints nothing more", async () => { + // One `run` is one process with one stderr: the task tool's child session + // settles the same verdict and must not repeat the line. + const h = install({ headless: true, which: "/usr/local/bin/datamate", version: "0.6.3" }) + await beforeTurn("parent") + await beforeTurn("child") + await beforeTurn("parent") + expect(h.printed).toHaveLength(1) + }) + test("a broken engine reports 'unknown' rather than a version", async () => { + const h = install({ surface: true, which: "/usr/local/bin/datamate", version: null }) + await beforeTurn("s1") + expect(h.offers[0]).toMatchObject({ reason: "engine-too-old", found: "unknown" }) + }) +}) + +describe("offer is not raised when an engine is usable", () => { + test("a healthy engine never reaches the offer path", async () => { + const h = install({ surface: true, which: "/usr/local/bin/datamate", version: "0.7.0" }) + await beforeTurn("s1") + expect(h.offers).toEqual([]) + expect(h.published).toBe(0) + expect(h.printed).toEqual([]) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) +}) + +describe("describeOffer — the TUI re-derives its own detail", () => { + test("describes a missing engine", async () => { + install({}) + expect(await describeOffer(DIR)).toEqual({ + reason: "engine-missing", + workspaceId: "42", + workspaceName: "analytics", + declared: 2, + command: installCommand(), + }) + }) + test("describes an engine below the floor, naming the version found", async () => { + install({ which: "/usr/local/bin/datamate", version: "0.6.3" }) + expect(await describeOffer(DIR)).toMatchObject({ reason: "engine-too-old", found: "0.6.3" }) + }) + test("returns null when an engine already clears the floor", async () => { + install({ which: "/usr/local/bin/datamate", version: "0.7.0" }) + expect(await describeOffer(DIR)).toBeNull() + }) + test("returns null when the project is not bound", async () => { + install({ bound: false }) + expect(await describeOffer(DIR)).toBeNull() + }) +}) + +describe("headless notice stream", () => { + test("the default printer writes to stderr, never stdout", async () => { + const h = install({ headless: true }) + delete syncInternals.printLine + const err = spyOn(process.stderr, "write").mockImplementation(() => true) + const out = spyOn(process.stdout, "write").mockImplementation(() => true) + try { + await beforeTurn("s1") + expect(err).toHaveBeenCalledTimes(1) + expect(String(err.mock.calls[0]?.[0])).toContain(installCommand()) + expect(out).not.toHaveBeenCalled() + } finally { + err.mockRestore() + out.mockRestore() + } + expect(h.printed).toEqual([]) + }) +}) + +describe("install deadline", () => { + const posix = process.platform !== "win32" + test.skipIf(!posix)("settles on the child's exit even when a descendant keeps stderr open", async () => { + // npm forks a tree; a straggler holding the pipe must not hold the run. + const t0 = Date.now() + const run = await runInstall(["sh", "-c", "sleep 5 >&2 2>/dev/null & exit 0"], 4_000, 200) + expect(run.code).toBe(0) + expect(run.timedOut).toBe(false) + expect(Date.now() - t0).toBeLessThan(2_000) + }) + test.skipIf(!posix)("the deadline terminates a tree that ignores SIGTERM and reports the timeout", async () => { + const t0 = Date.now() + const run = await runInstall(["sh", "-c", "trap '' TERM; sleep 30"], 200, 200) + expect(run.timedOut).toBe(true) + expect(Date.now() - t0).toBeLessThan(3_000) + }) + test.skipIf(!posix)("a descendant that ignores SIGTERM is still killed after the leader exits", async () => { + // npm (the leader) dies on the deadline's SIGTERM; the escalation must + // survive its exit and reach the straggler. + const marker = `31.${process.pid}` + const run = await runInstall(["sh", "-c", `(trap '' TERM; exec sleep ${marker}) & sleep 30`], 200, 300) + expect(run.timedOut).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 600)) + const survivors = Bun.spawnSync(["pgrep", "-f", `sleep ${marker}`]) + .stdout.toString() + .trim() + expect(survivors).toBe("") + }) + test("a timed-out run is reported as such, not as an npm failure", async () => { + syncInternals.runInstall = async () => ({ code: null, timedOut: true, stderr: "" }) + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("did not finish within") + }) +}) + +describe("install success is verified, not assumed", () => { + test("a zero exit with the engine still absent from PATH is a failure", async () => { + syncInternals.which = () => null + syncInternals.runInstall = async () => ({ code: 0, timedOut: false, stderr: "" }) + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("not on PATH") + }) + test("a zero exit with a below-floor engine on PATH is a failure", async () => { + syncInternals.which = () => "/usr/local/bin/datamate" + syncInternals.versionOf = async () => "0.6.3" + syncInternals.runInstall = async () => ({ code: 0, timedOut: false, stderr: "" }) + const result = await installEngine() + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain("0.6.3") + }) + test("a non-zero exit reports npm's last lines", async () => { + syncInternals.runInstall = async () => ({ code: 1, timedOut: false, stderr: "boom\nEACCES denied" }) + expect(await installEngine()).toEqual({ ok: false, error: "boom EACCES denied" }) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index eaea653cb9..ed78236b11 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -54,6 +54,7 @@ type Harness = { /** 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 + fingerprint: string | null } function install(opts: { @@ -89,6 +90,7 @@ function install(opts: { toasts: [], lines: [], clock: 1_000_000, + fingerprint: "bin-1", } process.env.ALTIMATE_WORKSPACE = opts.flag === false ? "" : "1" syncInternals.serve = () => opts.serve === true @@ -111,6 +113,10 @@ function install(opts: { h.lines.push(line) } syncInternals.now = () => h.clock + syncInternals.fingerprint = () => h.fingerprint + // No TUI bus in this harness, so a refusal that would raise the install + // offer falls back to the toast, which is what these tests observe. + syncInternals.publishOffer = async () => false syncInternals.mcp = { status: async () => h.live ? { datamate: { status: h.status, ...(h.statusError ? { error: h.statusError } : {}) } } : {}, @@ -246,13 +252,13 @@ describe("overlay — what the config loader gets", () => { 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) + const old = install({ version: "0.6.3" }) + await overlay(DIR, old.config) + await overlay(DIR, old.config) + expect(old.probes).toBe(1) + old.clock += FAILED_PROBE_TTL_MS + await overlay(DIR, old.config) + expect(old.probes).toBe(2) }) test("a binding read that throws leaves the config as loaded", async () => { @@ -438,7 +444,7 @@ describe("beforeTurn — what a turn boundary does", () => { await beforeTurn("s1") expect(h.invalidates).toBe(1) expect(h.added).toHaveLength(1) - expect(pinnedWorkspace(h.added[0])).toBe("7") + expect(pinnedWorkspace(h.added[0] as LocalMcpConfig)).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") @@ -626,20 +632,60 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.added).toEqual([]) }) - test("an engine installed after a refusal is picked up once the probe is asked again", async () => { + test("an engine installed after a refusal is picked up at the next turn boundary", async () => { + // The install dialog runs in another module realm and cannot reach the + // probe memo, so a missing engine is looked for on PATH every turn. 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. + expect(h.added).toHaveLength(1) + expect(pinnedWorkspace(h.added[0] as LocalMcpConfig)).toBe("42") + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a too-old engine is re-probed only after the TTL, or when the probe is invalidated", async () => { + const h = install({ version: "0.6.3" }) + await beforeTurn("s1") + expect(h.probes).toBe(1) + h.version = "0.7.0" + await beforeTurn("s1") + expect(h.probes).toBe(1) + expect(settledOutcome("s1")?.kind).toBe("engine-too-old") invalidateProbe() await beforeTurn("s1") - expect(h.added).toHaveLength(1) - expect(pinnedWorkspace(h.added[0])).toBe("42") + expect(h.probes).toBe(2) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a too-old engine updated in place is re-probed on the next turn, inside the TTL", async () => { + // The offer's install writes the new engine over the old one at the same + // PATH entry, from another module realm that cannot invalidate this memo. + // The file's fingerprint changing is what ends the memo, not the clock. + const h = install({ version: "0.6.3" }) + await beforeTurn("s1") + expect(h.probes).toBe(1) + expect(settledOutcome("s1")?.kind).toBe("engine-too-old") + h.version = "0.7.0" + h.fingerprint = "bin-2" + h.clock += 1_000 + await beforeTurn("s1") + expect(h.probes).toBe(2) + expect(settledOutcome("s1")?.kind).toBe("attached") + // A binary that cannot be stat'ed has no fingerprint to compare, so the + // memo falls back to its TTL alone — never a spawn on every turn. + resetForTests() + const u = install({ version: "0.6.3" }) + u.fingerprint = null + await beforeTurn("s1") + u.version = "0.7.0" + u.clock += 1_000 + await beforeTurn("s1") + expect(u.probes).toBe(1) + u.clock += FAILED_PROBE_TTL_MS + await beforeTurn("s1") + expect(u.probes).toBe(2) expect(settledOutcome("s1")?.kind).toBe("attached") }) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index b96a5d7b7a..70c15b8f46 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -180,6 +180,9 @@ export type TuiDialogSelectProps = { onFilter?: (query: string) => void onSelect?: (option: TuiDialogSelectOption) => void skipFilter?: boolean + // altimate_change start — a fixed-option dialog can hide the filter box entirely + renderFilter?: boolean + // altimate_change end current?: Value } diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index fb2b104b63..5fb7b7dd02 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -245,6 +245,9 @@ export function createTuiApiAdapters(input: Input): Omit )