-
Notifications
You must be signed in to change notification settings - Fork 134
feat(workspace): offer to install the engine a bound workspace needs #1169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
ralphstodomingo
wants to merge
9
commits into
feat/workspace-precedence-v2
Choose a base branch
from
feat/workspace-install-offer-v2
base: feat/workspace-precedence-v2
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,395
−44
Draft
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
1905301
feat(workspace): offer to install the engine a bound workspace needs
a5a563a
fix: let the install offer return in a session that outlives the Not-…
63f4e7a
fix: re-probe a too-old engine as soon as the file on PATH changes
f945a98
fix: re-raise the offer hourly past the latch window; scope the attac…
bd50331
fix: print the headless refusal line once per process, not once per s…
edc94cf
fix: wait for the TUI kv store to hydrate before consulting the Not-n…
5c8587f
fix: give the engine install a real deadline over npm's whole process…
07a80a1
fix: keep the install deadline's SIGKILL armed after npm itself exits
b645742
chore: rebase onto the tenant-scoped overlay (declaredFor takes the w…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
287 changes: 287 additions & 0 deletions
287
packages/opencode/src/altimate/workspace/engine-offer.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<EngineOffer | null> { | ||
| 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<number | null> { | ||
| 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<InstallRun> { | ||
| 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<typeof setTimeout> | 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 <spec>` 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<InstallResult> { | ||
| 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<boolean> { | ||
| 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<void> { | ||
| if (isHeadless()) { | ||
| printLine(describeOfferLine(offer)) | ||
| return | ||
| } | ||
| if (offerInstall(offer)) return | ||
| if (await publishOffer(sessionID)) return | ||
| await notify(toast) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.