diff --git a/packages/opencode/src/cli/cmd/serve-upgrade-check.ts b/packages/opencode/src/cli/cmd/serve-upgrade-check.ts new file mode 100644 index 000000000..32d3f4a4f --- /dev/null +++ b/packages/opencode/src/cli/cmd/serve-upgrade-check.ts @@ -0,0 +1,68 @@ +// altimate_change start — self-update trigger for headless serve +import { Instance } from "../../project/instance" +import { InstanceBootstrap } from "../../project/bootstrap" +import { upgrade } from "../upgrade" +import { Log } from "../../util/log" + +const log = Log.create({ service: "serve" }) + +/** Delay before the one-shot startup check, letting the listener settle first. */ +export const STARTUP_UPGRADE_DELAY_MS = 1000 + +/** + * Collaborators for {@link runStartupUpgradeCheck}, injectable for tests. + * + * Injected rather than module-mocked on purpose: bun's `mock.module` is + * process-global, so mocking `../upgrade` / `../../project/instance` here would + * clobber those modules for every other test in the run (e.g. blow away + * `cli/upgrade`'s `compareVersions`/`isValidVersion` exports). + */ +export interface StartupUpgradeDeps { + /** + * Runs `fn` inside an ambient `Instance` for `directory` and, like the TUI + * worker, does NOT dispose it (see note on the default below). + */ + provide: (directory: string, fn: () => Promise) => Promise + /** The upgrade check itself. */ + run: () => Promise +} + +const defaultDeps: StartupUpgradeDeps = { + // Mirror the TUI worker (cli/cmd/tui/worker.ts → checkUpgrade): provide an + // Instance context for upgrade() — Bus.publish needs one — but never dispose. + // + // We use the same process.cwd() key the server's default-directory requests + // use (server/server.ts:196), so we reuse/seed that shared cached instance and + // Bus notifications still reach default-directory SSE subscribers. Crucially we + // do NOT dispose: an earlier version wrapped this in bootstrap(), whose + // finally → Instance.dispose() tears down the entire process.cwd() bucket — + // including state created by concurrent server requests that defaulted to that + // directory (use-after-dispose / needless churn). The worker avoids this by + // running in a separate thread; in-process we avoid it by not disposing. + provide: (directory, fn) => Instance.provide({ directory, init: InstanceBootstrap, fn }), + run: upgrade, +} + +/** + * Runs a single best-effort upgrade check. Resolves, never rejects, via two + * layers: the inner `.catch` swallows any error from `run()` (`upgrade()` can + * throw, e.g. from `Config.global()` / `Installation.method()`), and the outer + * try/catch guards an `Instance.provide` (bootstrap) failure. A flaky + * network/registry therefore can't take the server down. Errors are passed as + * `Error` objects so `Log` formats the message + `cause` chain. + */ +export async function runStartupUpgradeCheck(deps: StartupUpgradeDeps = defaultDeps): Promise { + try { + await deps.provide(process.cwd(), () => + deps.run().catch((err) => log.error("startup upgrade check failed", { error: err })), + ) + } catch (err) { + log.error("startup upgrade instance failed", { error: err }) + } +} + +/** Schedules {@link runStartupUpgradeCheck} after a short settle delay; non-blocking. */ +export function scheduleStartupUpgradeCheck(): void { + setTimeout(() => void runStartupUpgradeCheck(), STARTUP_UPGRADE_DELAY_MS).unref?.() +} +// altimate_change end diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 528498801..05c46f156 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,12 +2,12 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" -import { Workspace } from "../../control-plane/workspace" -import { Project } from "../../project/project" -import { Installation } from "../../installation" // altimate_change start — trace: session tracing in headless serve import { subscribeTraceConsumer } from "../../altimate/observability/trace-consumer" // altimate_change end +// altimate_change start — self-update on headless serve startup +import { scheduleStartupUpgradeCheck } from "./serve-upgrade-check" +// altimate_change end export const ServeCommand = cmd({ command: "serve", @@ -35,6 +35,17 @@ export const ServeCommand = cmd({ // (`tracing.dir`, default ~/.local/share/altimate-code/traces/). const traceSub = subscribeTraceConsumer({ directory: process.cwd() }) + // altimate_change start — self-update on startup + // A headless `serve` is how the VS Code / Cursor extension runs + // altimate-code, and it is the ONLY long-running entrypoint that never + // checked for updates: auto-update was wired solely into the TUI bootstrap + // (cli/cmd/tui/thread.ts → worker.checkUpgrade → upgrade()). As a result the + // extension fleet froze at whatever version was installed at onboarding. + // Fire the missing trigger here; see serve-upgrade-check.ts for why it runs + // in (but never disposes) the process.cwd() instance. + scheduleStartupUpgradeCheck() + // altimate_change end + // Finalize traces on shutdown. `serve` blocks forever on the promise below // and otherwise dies abruptly on signal, so without these handlers the // consumer's stop()/flush()/endTrace() never runs and serve traces are diff --git a/packages/opencode/test/cli/serve-upgrade-check.test.ts b/packages/opencode/test/cli/serve-upgrade-check.test.ts new file mode 100644 index 000000000..ff9e2c2c3 --- /dev/null +++ b/packages/opencode/test/cli/serve-upgrade-check.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, test } from "bun:test" +import { Log } from "../../src/util/log" +import { runStartupUpgradeCheck, STARTUP_UPGRADE_DELAY_MS, type StartupUpgradeDeps } from "../../src/cli/cmd/serve-upgrade-check" + +Log.init({ print: false }) + +// No mock.module here: it is process-global in bun and would clobber +// ../upgrade / ../../project/instance for every other test in the run. The +// collaborators are injected instead. +describe("serve-upgrade-check", () => { + let runCalls: number + let runShouldThrow: boolean + let provideCalls: number + let provideDirectory: string | undefined + + function makeDeps(): StartupUpgradeDeps { + return { + provide: async (directory, fn) => { + provideCalls++ + provideDirectory = directory + return fn() + }, + run: async () => { + runCalls++ + if (runShouldThrow) throw new Error("boom") + }, + } + } + + beforeEach(() => { + runCalls = 0 + runShouldThrow = false + provideCalls = 0 + provideDirectory = undefined + }) + + test("runs upgrade() once inside the process.cwd() instance", async () => { + await runStartupUpgradeCheck(makeDeps()) + expect(runCalls).toBe(1) + expect(provideCalls).toBe(1) + expect(provideDirectory).toBe(process.cwd()) + }) + + test("resolves without throwing when upgrade() rejects", async () => { + runShouldThrow = true + // Must not reject — a flaky network/registry can't take the server down. + await expect(runStartupUpgradeCheck(makeDeps())).resolves.toBeUndefined() + expect(runCalls).toBe(1) + }) + + test("resolves without throwing when provide() itself rejects", async () => { + const deps: StartupUpgradeDeps = { + provide: async () => { + throw new Error("instance boom") + }, + run: async () => { + runCalls++ + }, + } + await expect(runStartupUpgradeCheck(deps)).resolves.toBeUndefined() + expect(runCalls).toBe(0) + }) + + test("uses a short, sane settle delay", () => { + expect(STARTUP_UPGRADE_DELAY_MS).toBeGreaterThan(0) + expect(STARTUP_UPGRADE_DELAY_MS).toBeLessThanOrEqual(5000) + }) +})