From 41b0d152317c13f890055b1b2cd2a5992ea707a2 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 13:57:52 +0800 Subject: [PATCH 1/3] feat(workspace): choose the connection an integration uses from `/integrations` Integrations that use one chosen connection show it under their row in `/integrations`; Enter picks another, recorded in `~/.altimate/settings.json` like the IDE does. A changed choice restarts the engine at the next message. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HRUhVhg6XwxSKJs1iTufiA --- packages/opencode/src/altimate/api/client.ts | 38 +++ .../altimate/workspace/connection-choice.ts | 242 ++++++++++++++++++ .../src/altimate/workspace/engine-overlay.ts | 36 ++- .../src/altimate/workspace/engine-seams.ts | 2 + .../src/altimate/workspace/status-view.ts | 98 ++++++- .../src/plugin/tui/altimate/workspace.tsx | 106 +++++++- .../test/altimate/plugin/workspace.test.ts | 149 +++++++++++ .../workspace/connection-choice.test.ts | 229 +++++++++++++++++ .../altimate/workspace/engine-overlay.test.ts | 88 +++++++ .../altimate/workspace/status-view.test.ts | 101 +++++++- 10 files changed, 1075 insertions(+), 14 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/connection-choice.ts create mode 100644 packages/opencode/test/altimate/workspace/connection-choice.test.ts diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 492d9dd702..ea80079a51 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -49,6 +49,12 @@ const IntegrationSummary = z.object({ // extension-type integrations have no meaning on the CLI surface. type: z.string().optional(), // altimate_change end + // altimate_change start — connection picker: the connection fields the engine checks a + // connection against. Left unparsed here so a field shape this schema does not know + // cannot fail the whole catalog; `connection-choice.ts` reads them. + config: z.unknown().optional(), + additionalConfigVariants: z.unknown().optional(), + // altimate_change end description: z.string().nullable().optional(), tools: z .array( @@ -61,6 +67,15 @@ const IntegrationSummary = z.object({ .optional(), }) +// altimate_change start — connection picker: one of the caller's connections +const RemoteConnection = z.object({ + id: z.coerce.string(), + name: z.string(), + integration_id: z.coerce.string(), + config: z.record(z.string(), z.unknown()).nullable().optional(), +}) +// altimate_change end + export namespace AltimateApi { export function credentialsPath(): string { return path.join(Global.Path.home, ".altimate", "altimate.json") @@ -356,6 +371,29 @@ export namespace AltimateApi { return list.map((d: unknown) => IntegrationSummary.parse(d)) as z.infer[] } + // altimate_change start — connection picker: custom integrations and the user's connections + /** The tenant's custom integrations (api, mcp and code types), first page, as the engine reads them. */ + export async function listCustomIntegrations() { + const creds = await getCredentials() + const data = await request(creds, "GET", "/datamate_integrations/custom?page=1&size=100") + const list = Array.isArray(data) ? data : (data.items ?? []) + return list.map((d: unknown) => IntegrationSummary.parse(d)) as z.infer[] + } + + /** The caller's own connections, every integration. Configs arrive decrypted: the + * caller keeps only what it needs and never logs them. An entry this schema does + * not read is left out, not a failure of the list. */ + export async function listConnections() { + const creds = await getCredentials() + const data = await request(creds, "GET", "/connections/") + const list: unknown[] = Array.isArray(data?.connections) ? data.connections : [] + return list.flatMap((d) => { + const parsed = RemoteConnection.safeParse(d) + return parsed.success ? [parsed.data] : [] + }) + } + // altimate_change end + /** Resolve integration IDs to full integration objects with all tools enabled (matching frontend behavior). */ export async function resolveIntegrations( integrationIds: string[], diff --git a/packages/opencode/src/altimate/workspace/connection-choice.ts b/packages/opencode/src/altimate/workspace/connection-choice.ts new file mode 100644 index 0000000000..9b3c1c92c9 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/connection-choice.ts @@ -0,0 +1,242 @@ +// altimate_change - new file +// +// Which connection each of a workspace's integrations uses, and the user's +// choice of it: the picker behind `/integrations`, at parity with the IDE +// extension's integrations panel. +// +// The choice lives where the engine reads it, `~/.altimate/settings.json`: +// `{ "": { "": "" } }`, beside keys +// other writers own (the extension's `_activeTeammates`, other workspaces). +// The engine re-reads the file on every call, but fetches its connections once +// when it starts and never says its tools changed, so the overlay starts it +// again when this workspace's entry changes (`choiceFingerprint`). +// +// The engine's rules are mirrored here because nothing exposes them: which +// integrations take a choice (`takesChoice`), which connections an integration +// can use (`usable`), and the one used when nothing is chosen (the usable +// connection with the lowest id). A choice that names a connection the +// integration cannot use turns the integration off: the engine does not fall +// back to the default. +// +// Connection configs arrive decrypted. They are read here only to tell whether +// a connection is usable, and never leave this module. +import path from "node:path" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { Global } from "@/global" + +/** One connection field of the integration catalog. */ +interface ConfigField { + key: string + required: boolean +} + +/** An integration as the catalog describes it; `config` and the variants as + * the API returns them, read leniently. */ +export interface CatalogIntegration { + id: string + type?: string + config?: unknown + additionalConfigVariants?: unknown +} + +/** A connection as fetched or read from disk, config included. */ +export interface RawConnection { + id: string + name: string + integrationId: string + config: Record + remote: boolean +} + +/** A connection as the picker shows it: no config. */ +export interface ConnectionOption { + id: string + name: string + remote: boolean + /** Carries every field the integration requires. */ + usable: boolean +} + +/** The connection an integration uses now. `chosen`: the explicit choice. + * `default`: no choice, so the lowest usable id. `off`: the choice names a + * connection that is gone or incomplete, so the integration has no tools. + * `none`: no choice and no usable connection. */ +export type InUse = + | { kind: "chosen"; connection: ConnectionOption } + | { kind: "default"; connection: ConnectionOption } + | { kind: "off"; id: string; name?: string } + | { kind: "none" } + +export interface ConnectionChoice { + integrationId: string + /** The integration's connections, in the engine's default order. */ + options: ConnectionOption[] + inUse: InUse +} + +/** In-house integrations whose tools take a connection per call; the engine's + * other integrations of type `tool` use the one chosen (the base default). */ +const PER_CALL_TOOLS = new Set(["dbt", "altimate", "altimate_knowledge", "bigquery", "snowflake", "postgresql"]) + +function fields(raw: unknown): ConfigField[] | undefined { + if (!Array.isArray(raw)) return undefined + return raw.flatMap((f) => + f && typeof f === "object" && typeof (f as { key?: unknown }).key === "string" + ? [{ key: (f as { key: string }).key, required: Boolean((f as { required?: unknown }).required) }] + : [], + ) +} + +/** Whether the engine uses one chosen connection for this integration, rather + * than a connection named per call. Needs connection fields at all; then by + * type: `api` and `mcp` do, `code` and `extension` do not, and an in-house + * `tool` does unless its tools take the connection per call. */ +export function takesChoice(integration: CatalogIntegration): boolean { + if (!fields(integration.config)?.length) return false + if (integration.type === "api" || integration.type === "mcp") return true + if (integration.type === "tool") return !PER_CALL_TOOLS.has(integration.id) + return false +} + +/** Whether the integration can use the connection: it owns it, and every field + * required by the connection's variant (or by the integration, for the default + * variant) is set. A variant the integration does not declare is unusable, + * unless the integration declares no variants at all. */ +export function usable(integration: CatalogIntegration, connection: RawConnection): boolean { + if (connection.integrationId !== integration.id) return false + const config = connection.config + const type = config.type + const isDefault = type === undefined || type === null || type === "" || type === "default" + const variants = Array.isArray(integration.additionalConfigVariants) + ? (integration.additionalConfigVariants as { id?: unknown; config?: unknown }[]) + : [] + const variant = isDefault ? undefined : variants.find((v) => v && v.id === type) + const variantFields = variant ? fields(variant.config) : undefined + if (!isDefault && variantFields === undefined && variants.length > 0) return false + const required = (isDefault ? fields(integration.config) : variantFields) ?? [] + return required.filter((f) => f.required).every((f) => Boolean(config[f.key])) +} + +/** The engine's default order: numeric ids first, numerically, then the rest + * by id. Remote connections have numeric ids, local ones UUIDs. */ +function byDefaultOrder(a: { id: string }, b: { id: string }): number { + const an = Number(a.id) + const bn = Number(b.id) + const aNumeric = Number.isFinite(an) + const bNumeric = Number.isFinite(bn) + if (aNumeric && bNumeric) return an - bn + if (aNumeric) return -1 + if (bNumeric) return 1 + return a.id.localeCompare(b.id) +} + +/** What one integration uses, from its connections and the choice recorded for + * it (`undefined` when there is none; anything else the engine compares as is). */ +export function choiceFor( + integration: CatalogIntegration, + connections: RawConnection[], + chosen: unknown, +): ConnectionChoice { + const options = connections + .filter((c) => c.integrationId === integration.id) + .map((c) => ({ id: c.id, name: c.name, remote: c.remote, usable: usable(integration, c) })) + .sort(byDefaultOrder) + let inUse: InUse + if (chosen === undefined) { + const fallback = options.find((o) => o.usable) + inUse = fallback ? { kind: "default", connection: fallback } : { kind: "none" } + } else { + const match = typeof chosen === "string" ? options.find((o) => o.id === chosen) : undefined + inUse = match?.usable + ? { kind: "chosen", connection: match } + : { kind: "off", id: String(chosen), ...(match ? { name: match.name } : {}) } + } + return { integrationId: integration.id, options, inUse } +} + +// ── settings.json ─────────────────────────────────────────────────────────── + +export function settingsPath(): string { + return path.join(Global.Path.home, ".altimate", "settings.json") +} + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v) +} + +/** The whole file: `{}` when it is missing or empty, as the engine reads it. */ +function readSettings(): Record { + const p = settingsPath() + if (!existsSync(p)) return {} + const text = readFileSync(p, "utf8") + if (text.trim() === "") return {} + const parsed: unknown = JSON.parse(text) + if (!isRecord(parsed)) throw new Error(`${p} does not hold a JSON object`) + return parsed +} + +/** The choices recorded for a workspace, by integration id; `{}` when none. + * Throws when the file cannot be read or parsed. */ +export function readChoices(workspaceId: string): Record { + const entry = readSettings()[workspaceId] + return isRecord(entry) ? entry : {} +} + +/** This workspace's entry in a stable form, to tell whether it changed; null + * when the file cannot be read, so an unreadable file is never a change. */ +export function choiceFingerprint(workspaceId: string): string | null { + try { + const entry = readChoices(workspaceId) + return JSON.stringify(Object.keys(entry).sort().map((k) => [k, entry[k]])) + } catch { + return null + } +} + +/** Record the choice: read, change this one integration, write back, every + * other key kept. Written in place, as the engine writes it: the extension's + * engine watches the file, and a rename would leave that watch on the old file. + * A file that does not parse is not written over (the engine's own writer + * would start it from empty); the error says why. */ +export function chooseConnection(workspaceId: string, integrationId: string, connectionId: string): void { + const settings = readSettings() + const entry = settings[workspaceId] + if (entry !== undefined && !isRecord(entry)) + throw new Error(`the entry for workspace ${workspaceId} in ${settingsPath()} is not an object`) + settings[workspaceId] = { ...entry, [integrationId]: connectionId } + mkdirSync(path.dirname(settingsPath()), { recursive: true }) + writeFileSync(settingsPath(), JSON.stringify(settings, null, 2)) +} + +// ── local connections ─────────────────────────────────────────────────────── + +export function localConnectionsPath(): string { + return path.join(Global.Path.home, ".altimate", "connections.json") +} + +/** Connections created in the extension and kept on this machine, which the + * engine serves beside the account's. A missing or unreadable file is none, as + * the engine reads it; an entry without an id or an integration is skipped. */ +export function readLocalConnections(): RawConnection[] { + try { + const p = localConnectionsPath() + if (!existsSync(p)) return [] + const parsed: unknown = JSON.parse(readFileSync(p, "utf8")) + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((c) => + isRecord(c) && typeof c.id === "string" && c.integration_id !== undefined && c.integration_id !== null + ? [ + { + id: c.id, + name: typeof c.name === "string" ? c.name : c.id, + integrationId: String(c.integration_id), + config: isRecord(c.config) ? c.config : {}, + remote: false, + }, + ] + : [], + ) + } catch { + return [] + } +} diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 074f940ae8..eb9a3c5d68 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -33,6 +33,7 @@ import { Config } from "@/config/config" import { Global } from "@/global" import { Instance } from "@/project/instance" import { displayWorkspaceName } from "./workspace-name" +import { choiceFingerprint } from "./connection-choice" import { currentDirectory, isEnabled, @@ -152,6 +153,9 @@ type Overlay = { refusal: Extract | null /** The probed engine version when the engine ran; null when it is missing. */ version: string | null + /** The workspace's connection choices (`choiceFingerprint`) as an engine + * started from this overlay read them; null when unknown. */ + choices: string | null } /** Per-directory state. Config and MCP state are per project instance, and one @@ -209,6 +213,10 @@ function workspaceKey(binding: ScopedBinding): string { return workspaceIdentity(binding.scope, binding.datamateId) } +function choicesOf(workspaceId: string): string | null { + return syncInternals.choices ? syncInternals.choices(workspaceId) : choiceFingerprint(workspaceId) +} + function sameEntry(a: LocalMcpConfig | null, b: LocalMcpConfig | null): boolean { return !!a && !!b && a.command.join("\0") === b.command.join("\0") } @@ -272,7 +280,14 @@ export async function overlay( const entry = engineEntry(workspace.id) config.mcp ??= {} config.mcp[DATAMATE_KEY] = entry - state.current = { directory, workspace, entry, refusal: null, version: probe.version } + state.current = { + directory, + workspace, + entry, + refusal: null, + version: probe.version, + choices: choicesOf(workspace.id), + } log.info("workspace engine overlay applied", { workspaceId: workspace.id, version: probe.version }) return } @@ -287,6 +302,7 @@ export async function overlay( entry: null, refusal: probe.kind === "missing" ? { kind: "engine-missing" } : { kind: "engine-too-old", found: probe.found }, version: probe.kind === "missing" ? null : probe.found, + choices: null, } log.info("workspace engine overlay refused", { workspaceId: workspace.id, reason: probe.kind }) } catch (err) { @@ -754,20 +770,34 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // Bring MCP in line with the overlay: start or replace the engine when the // derived entry changed, drop it when there is none any more. + let applied = overlayNow if (overlayNow.entry) { // The argv is the same for the same id in another tenant; the engine // reads its credentials when it starts, so it is replaced on identity, not // only on argv. const replaced = !sameEntry(state.applied?.entry ?? null, overlayNow.entry) || state.applied?.workspace.key !== workspace.key - if (replaced) await mcp().add(DATAMATE_KEY, overlayNow.entry) + // A changed connection choice for this workspace (from `/integrations` or + // the IDE) starts the engine again too: it fetches connections only when it + // starts, and never says its tools changed. The running engine's choices + // are the ones it started with; unknown on either side is not a change. + const choices = choicesOf(workspace.id) + const started = replaced ? null : (state.applied?.choices ?? null) + const rechosen = started !== null && choices !== null && choices !== started + if (replaced || rechosen) { + if (rechosen) log.info("workspace connection choices changed; restarting the engine", { workspaceId: workspace.id }) + await mcp().add(DATAMATE_KEY, overlayNow.entry) + applied = { ...overlayNow, choices } + } else { + applied = { ...overlayNow, choices: started ?? choices } + } } else if (state.applied?.entry || DATAMATE_KEY in (await mcp().status())) { // Ours to drop — or a client that predates the link, which MCP bootstrapped // from an IDE or hosted entry while the directory was unbound. With the // overlay refusing, nothing may serve the workspace under the key. await mcp().remove(DATAMATE_KEY) } - state.applied = overlayNow + state.applied = applied state.droppedForeign = false if (!overlayNow.entry) { diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 73d6609159..f3a4ec0675 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -48,6 +48,8 @@ export const syncInternals: { now?: () => number /** Tests keep the attach snapshot out of the real state directory. */ persistSnapshot?: (directory: string, snapshot: AttachSnapshot) => void + /** A workspace's connection choices (`choiceFingerprint`). */ + choices?: (workspaceId: string) => string | null mcp?: { status: () => Promise add: (name: string, cfg: LocalMcpConfig | McpEntry) => Promise diff --git a/packages/opencode/src/altimate/workspace/status-view.ts b/packages/opencode/src/altimate/workspace/status-view.ts index 9d5a362466..26901ac42b 100644 --- a/packages/opencode/src/altimate/workspace/status-view.ts +++ b/packages/opencode/src/altimate/workspace/status-view.ts @@ -22,6 +22,15 @@ import { type AttachSnapshot, } from "./attach-snapshot" import { reasonPhrase, type Unfulfilled } from "./engine-types" +import { + choiceFor, + readChoices, + readLocalConnections, + takesChoice, + type CatalogIntegration, + type ConnectionChoice, + type RawConnection, +} from "./connection-choice" import { currentScope, scopeStringOf } from "./state" export { statusHeadline } from "./attach-snapshot" @@ -52,6 +61,9 @@ export interface IntegrationRow { /** Declared keys the engine neither served nor reported: it dropped them * without saying why. */ unreported: string[] + /** The connection it uses, for an integration that uses one chosen + * connection; absent for the others, and when connections could not be read. */ + connection?: ConnectionChoice } export interface StatusView extends Omit { @@ -65,6 +77,9 @@ export interface StatusView extends Omit { /** The workspace's selection now differs from the one this attach was * measured against; the rows describe the attach. */ selectionChanged: boolean + /** Why the connections in use could not be read; absent when they were, or + * when no read was attempted. */ + connectionsError?: string } interface SelectionIntegration { @@ -81,11 +96,18 @@ interface CatalogEntry { * be missing on its own: without the catalog, rows carry the names recorded at * attach time; without the selection, a change since the attach cannot be told. * A key the engine reported for an integration the declaration does not list - * still gets a row, named by its id, so a report is never silently dropped. */ + * still gets a row, named by its id, so a report is never silently dropped. + * `choices`, by integration id, are the connections in use; a string says why + * they could not be read. */ export function buildStatusView( snapshot: AttachSnapshot, - live: { selection: SelectionIntegration[] | null; catalog: CatalogEntry[] | null } | null, + live: { + selection: SelectionIntegration[] | null + catalog: CatalogEntry[] | null + choices?: Map | string + } | null, ): StatusView { + const choices = live?.choices instanceof Map ? live.choices : undefined const names = new Map((live?.catalog ?? []).map((c) => [String(c.id), c.name])) const present = new Set(snapshot.present) const reportedKeys = new Set((snapshot.unfulfilled ?? []).map((u) => u.key)) @@ -130,6 +152,7 @@ export function buildStatusView( gaps, unreported, state: rowState({ declared: integration.keys, served, gaps, extension: integration.extension }), + ...(choices?.has(integration.id) ? { connection: choices.get(integration.id) } : {}), }) } // Reported for an integration the declaration does not carry: keep it visible. @@ -166,6 +189,7 @@ export function buildStatusView( !!snapshot.declared?.integrations && !snapshot.declared.partial && !sameSelection(declaredIntegrations, live.selection), + ...(typeof live?.choices === "string" ? { connectionsError: live.choices } : {}), } } @@ -220,6 +244,32 @@ export function rowLine(row: IntegrationRow): string { return `${counts} · ${phrases.join("; ")}${details.length > 0 ? ` (${details.join("; ")})` : ""}` } +/** The line under an integration that uses one chosen connection: which one, + * how it came to be used, and that Enter changes it. Short: the dialog cuts a + * description at about 38 columns; the picker says the rest. */ +export function connectionLine(choice: ConnectionChoice): { title: string; note: string } { + const where = (remote: boolean) => (remote ? "Remote" : "Local") + switch (choice.inUse.kind) { + case "chosen": + return { + title: choice.inUse.connection.name, + note: `chosen · ${where(choice.inUse.connection.remote)} · Enter to change`, + } + case "default": + return { + title: choice.inUse.connection.name, + note: `default · ${where(choice.inUse.connection.remote)} · Enter to change`, + } + case "off": + return { + title: choice.inUse.name ?? `connection ${choice.inUse.id}`, + note: `${choice.inUse.name ? "incomplete" : "deleted"} · tools off · Enter to fix`, + } + case "none": + return { title: "no usable connection", note: "add one on the web" } + } +} + /** The `/workspace` menu's Status row: the headline, or why there is none. */ export function menuStatusLine(snapshot: AttachSnapshot | undefined): string { if (!snapshot) return "No session has attached yet — send a message first." @@ -271,17 +321,49 @@ export async function loadStatusView( if (!snapshot) return null // Read independently: a catalog outage must not hide a changed selection, nor // the other way round. - const [workspace, catalog] = await Promise.allSettled([ + const [workspace, catalog, custom, connections] = await Promise.allSettled([ AltimateApi.getDatamate(snapshot.workspace.id), AltimateApi.listIntegrations(), + AltimateApi.listCustomIntegrations(), + AltimateApi.listConnections(), ]) - for (const r of [workspace, catalog]) + for (const r of [workspace, catalog, custom, connections]) if (r.status === "rejected") log.warn("status view: an API read failed", { err: String(r.reason) }) + // Custom integrations are named and checked like the rest; without them, only + // they go unnamed and without a connection. + const integrations = + catalog.status === "fulfilled" ? [...catalog.value, ...(custom.status === "fulfilled" ? custom.value : [])] : null return buildStatusView(snapshot, { selection: workspace.status === "fulfilled" ? selectionOf(workspace.value.integrations) : null, - catalog: - catalog.status === "fulfilled" - ? catalog.value.map((c) => ({ id: String(c.id), name: c.name ?? `Integration ${c.id}`, type: c.type })) - : null, + catalog: integrations?.map((c) => ({ id: String(c.id), name: c.name ?? `Integration ${c.id}`, type: c.type })) ?? null, + choices: + integrations && connections.status === "fulfilled" + ? choicesFor(snapshot.workspace.id, integrations, connections.value) + : "the connections could not be read from Altimate", }) } + +/** The connection each catalog integration that takes one uses, from the + * account's connections, this machine's and the recorded choices; a string + * when the choices file cannot be read. Connection configs stay in here. */ +function choicesFor( + workspaceId: string, + catalog: CatalogIntegration[], + remote: { id: string; name: string; integration_id: string; config?: Record | null }[], +): Map | string { + let chosen: Record + try { + chosen = readChoices(workspaceId) + } catch (err) { + log.warn("status view: the connection choices could not be read", { err: String(err) }) + return "~/.altimate/settings.json could not be read" + } + const connections: RawConnection[] = [ + ...remote.map((c) => ({ id: c.id, name: c.name, integrationId: c.integration_id, config: c.config ?? {}, remote: true })), + ...readLocalConnections(), + ] + const choices = new Map() + for (const integration of catalog) + if (takesChoice(integration)) choices.set(integration.id, choiceFor(integration, connections, chosen[integration.id])) + return choices +} diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 0895f828d3..1c793a223a 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -44,12 +44,15 @@ import { import { currentAttachSnapshot } from "@/altimate/workspace/attach-snapshot" import { accountScope, + connectionLine, loadStatusView, menuStatusLine, rowLine, statusHeadline, type IntegrationRow, + type StatusView, } from "@/altimate/workspace/status-view" +import { chooseConnection, type ConnectionChoice } from "@/altimate/workspace/connection-choice" // altimate_change end import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { @@ -2327,6 +2330,14 @@ async function showWorkspaceStatus( category: "Integrations", }) } + if (view.connectionsError) { + rows.push({ + title: "The connections in use could not be read", + value: "key:connections-unread", + description: `${view.connectionsError}. Re-read to try again.`, + category: "Integrations", + }) + } for (const row of view.rows) { rows.push({ title: `${STATE_MARK[row.state]} ${row.name}`, @@ -2334,6 +2345,16 @@ async function showWorkspaceStatus( description: rowLine(row), category: "Integrations", }) + // The one row here that acts: Enter opens the connection picker. + if (row.connection) { + const line = connectionLine(row.connection) + rows.push({ + title: ` ⇄ ${line.title}`, + value: `conn:${row.id}`, + description: line.note, + category: "Integrations", + }) + } for (const line of rowDetails(row)) { rows.push({ title: ` ${line.key}`, @@ -2397,9 +2418,14 @@ async function showWorkspaceStatus( showWorkspaceStatus(api, directory, bound, mine).catch((err) => reportFlowFailure(api, err)) return } + const value = String(option.value) + const picked = value.startsWith("conn:") ? view.rows.find((r) => `conn:${r.id}` === value) : undefined + if (picked?.connection) { + showConnectionPicker(api, directory, bound, view, picked, picked.connection, mine) + return + } // Integration and key rows are information, not actions: choosing one // keeps the view open (it opens focused on the first integration row). - const value = String(option.value) if (value.startsWith("key:") || value.startsWith("row:")) return api.ui.dialog.clear() }} @@ -2407,6 +2433,84 @@ async function showWorkspaceStatus( )) } +/** Choose the connection one integration uses. Lists the integration's usable + * connections, the one in use marked; incomplete ones are counted, not offered, + * since choosing one would turn the integration off. A choice is recorded where + * the engine reads it and takes effect on the next message, when the engine + * starts again with it. `from` is the Status view the picker opened from. */ +function showConnectionPicker( + api: TuiPluginApi, + directory: string, + bound: BoundWorkspace, + view: StatusView, + row: IntegrationRow, + connection: ConnectionChoice, + from: symbol, +): void { + if (ownedDialog !== from) return + const { options: all, inUse } = connection + const inUseId = inUse.kind === "chosen" || inUse.kind === "default" ? inUse.connection.id : undefined + const offered = all.filter((o) => o.usable) + const incomplete = all.length - offered.length + const rows: { title: string; value: string; description: string; category: string }[] = offered.map((o) => ({ + title: o.name, + value: `pick:${o.id}`, + description: [ + o.remote ? "Remote" : "Local", + ...(o.id === inUseId ? [inUse.kind === "chosen" ? "in use, chosen" : "in use by default (lowest id)"] : []), + ].join(" · "), + category: "Connections", + })) + if (offered.length === 0) + rows.push({ + title: "No usable connection", + value: "info:none", + description: "Add one, or complete one, on the web.", + category: "Connections", + }) + if (incomplete > 0) + rows.push({ + title: `${incomplete} incomplete connection${incomplete === 1 ? "" : "s"} not shown`, + value: "info:incomplete", + description: "Missing required fields; complete them on the web to choose them here.", + category: "Connections", + }) + const back = () => showWorkspaceStatus(api, directory, bound, mine).catch((err) => reportFlowFailure(api, err)) + const mine = replaceOwnedDialog(api, () => ( + { + const value = String(option.value) + if (value.startsWith("info:")) return + if (value === "back") return void back() + const choice = offered.find((o) => `pick:${o.id}` === value) + if (!choice) return + if (inUse.kind === "chosen" && inUse.connection.id === choice.id) return void back() + try { + chooseConnection(view.workspace.id, row.id, choice.id) + } catch (err) { + api.ui.toast({ + variant: "error", + message: `Could not record the connection: ${err instanceof Error ? err.message : String(err)}`, + }) + return + } + api.ui.toast({ + variant: "success", + message: `${row.name} uses "${choice.name}" from your next message.`, + }) + back() + }} + /> + )) +} + /** The sub-rows under an integration: gaps with their reason, keys the engine * dropped without one, then what is available (or would be through a VS Code * window), capped. */ diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index 9670a24113..1ebf098c92 100644 --- a/packages/opencode/test/altimate/plugin/workspace.test.ts +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -60,6 +60,7 @@ import { findNamesakes } from "../../../src/altimate/workspace/workspace-name" import { createRoot } from "solid-js" import * as Manage from "../../../src/altimate/workspace/manage" import * as StatusView from "../../../src/altimate/workspace/status-view" +import * as ConnectionChoice from "../../../src/altimate/workspace/connection-choice" const originalIsConfigured = AltimateApi.isConfigured const originalGetCreds = AltimateApi.getCredentials type Creds = Awaited> @@ -1239,6 +1240,154 @@ describe("/integrations", () => { expect(read.mock.calls[0]![1]).toMatchObject({ datamateId: 43 }) }) + describe("choosing the connection an integration uses", () => { + const prod = { id: "9", name: "Jira prod", remote: true, usable: true } + const staging = { id: "12", name: "Jira staging", remote: true, usable: true } + const old = { id: "2", name: "Jira old", remote: true, usable: false } + type Choice = NonNullable + const row = (id: string, name: string, connection?: Choice): StatusView.IntegrationRow => ({ + id, + name, + state: "served", + extension: false, + declared: [`${id}_a`], + served: [`${id}_a`], + gaps: [], + unreported: [], + ...(connection ? { connection } : {}), + }) + const view = (jira: Choice | undefined, over: Partial = {}): StatusView.StatusView => ({ + workspace: { id: "42", name: "analytics" }, + engineVersion: "0.7.3", + at: Date.now(), + served: 2, + declared: 2, + gaps: 0, + extServed: 0, + rows: [row("jira", "Jira", jira), row("altimate", "Altimate")], + extras: [], + selectionChanged: false, + ...over, + }) + const settle = async () => { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)) + } + async function open(jira: Choice | undefined, over: Partial = {}) { + linked(binding) + engine("0.7.3") + const read = spyOn(StatusView, "loadStatusView").mockResolvedValue(view(jira, over)) + const choose = spyOn(ConnectionChoice, "chooseConnection").mockImplementation(() => {}) + spies.push(read, choose) + const { tui, h } = harness() + await runIntegrations(tui, os.tmpdir()) + const status = h.selects.at(-1)! + return { h, read, choose, status } + } + + test("an integration that uses one chosen connection shows it under its row; the others show none", async () => { + const { status } = await open({ integrationId: "jira", options: [prod, staging], inUse: { kind: "default", connection: prod } }) + const values = status.options.map((o) => o.value) + expect(values.filter((v) => String(v).startsWith("conn:"))).toEqual(["conn:jira"]) + // Directly under its integration. + expect(values[values.indexOf("row:jira") + 1]).toBe("conn:jira") + const line = status.options.find((o) => o.value === "conn:jira")! + expect(line.title).toBe(" ⇄ Jira prod") + expect(line.description).toBe("default · Remote · Enter to change") + }) + + test("Enter opens the picker: usable connections, the one in use marked and focused, incomplete ones counted", async () => { + const { h, status } = await open({ + integrationId: "jira", + options: [old, prod, staging], + inUse: { kind: "default", connection: prod }, + }) + status.onSelect({ value: "conn:jira" }) + const picker = h.selects.at(-1)! + expect(picker.title).toBe("Jira · choose a connection") + expect(picker.options.map((o) => o.value)).toEqual(["pick:9", "pick:12", "info:incomplete", "back"]) + expect(picker.options[0]!.description).toBe("Remote · in use by default (lowest id)") + expect(picker.options[1]!.description).toBe("Remote") + expect(picker.options[2]!.title).toBe("1 incomplete connection not shown") + expect(picker.current).toBe("pick:9") + }) + + test("choosing records the choice for the workspace, says when it applies, and shows the view again", async () => { + const { h, read, choose, status } = await open({ + integrationId: "jira", + options: [prod, staging], + inUse: { kind: "default", connection: prod }, + }) + status.onSelect({ value: "conn:jira" }) + h.selects.at(-1)!.onSelect({ value: "pick:12" }) + expect(choose).toHaveBeenCalledWith("42", "jira", "12") + expect(h.toasts).toEqual(['Jira uses "Jira staging" from your next message.']) + await settle() + expect(read).toHaveBeenCalledTimes(2) + expect(h.selects.at(-1)!.title).toContain("analytics") + }) + + test("choosing the default makes it the explicit choice", async () => { + const { h, choose, status } = await open({ + integrationId: "jira", + options: [prod, staging], + inUse: { kind: "default", connection: prod }, + }) + status.onSelect({ value: "conn:jira" }) + h.selects.at(-1)!.onSelect({ value: "pick:9" }) + expect(choose).toHaveBeenCalledWith("42", "jira", "9") + }) + + test("choosing the connection already chosen writes nothing and goes back", async () => { + const { h, read, choose, status } = await open({ + integrationId: "jira", + options: [prod, staging], + inUse: { kind: "chosen", connection: prod }, + }) + status.onSelect({ value: "conn:jira" }) + h.selects.at(-1)!.onSelect({ value: "pick:9" }) + expect(choose).not.toHaveBeenCalled() + expect(h.toasts).toEqual([]) + await settle() + expect(read).toHaveBeenCalledTimes(2) + }) + + test("a choice that cannot be recorded says why and stays on the picker", async () => { + const { h, read, choose, status } = await open({ + integrationId: "jira", + options: [prod, staging], + inUse: { kind: "default", connection: prod }, + }) + choose.mockImplementation(() => { + throw new Error("settings.json does not hold a JSON object") + }) + status.onSelect({ value: "conn:jira" }) + const pickers = h.selects.length + h.selects.at(-1)!.onSelect({ value: "pick:12" }) + expect(h.toasts).toEqual(["Could not record the connection: settings.json does not hold a JSON object"]) + await settle() + expect(read).toHaveBeenCalledTimes(1) + expect(h.selects).toHaveLength(pickers) + }) + + test("with no usable connection the picker says where to add one, and offers nothing to pick", async () => { + const { h, status } = await open({ integrationId: "jira", options: [old], inUse: { kind: "none" } }) + expect(status.options.find((o) => o.value === "conn:jira")!.title).toBe(" ⇄ no usable connection") + status.onSelect({ value: "conn:jira" }) + const picker = h.selects.at(-1)! + expect(picker.options.map((o) => o.value)).toEqual(["info:none", "info:incomplete", "back"]) + const count = h.selects.length + picker.onSelect({ value: "info:none" }) + expect(h.selects).toHaveLength(count) + }) + + test("connections that could not be read are said once, at the top", async () => { + const { status } = await open(undefined, { connectionsError: "the connections could not be read from Altimate" }) + expect(status.options[0]!.value).toBe("key:connections-unread") + expect(status.options[0]!.description).toBe("the connections could not be read from Altimate. Re-read to try again.") + expect(status.options.some((o) => String(o.value).startsWith("conn:"))).toBe(false) + }) + }) + test("a usable engine opens the Status view", async () => { linked(binding) engine("0.7.3") diff --git a/packages/opencode/test/altimate/workspace/connection-choice.test.ts b/packages/opencode/test/altimate/workspace/connection-choice.test.ts new file mode 100644 index 0000000000..f1e989d025 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/connection-choice.test.ts @@ -0,0 +1,229 @@ +import { afterAll, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" + +// `Global.Path.home` reads this on every access: settings.json and +// connections.json land in a sandbox, never the real home. +const SANDBOX = mkdtempSync(path.join(tmpdir(), "connection-choice-")) +const ORIGINAL_HOME = process.env.OPENCODE_TEST_HOME +process.env.OPENCODE_TEST_HOME = SANDBOX +afterAll(() => { + if (ORIGINAL_HOME === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = ORIGINAL_HOME + rmSync(SANDBOX, { recursive: true, force: true }) +}) + +const { + choiceFingerprint, + choiceFor, + chooseConnection, + localConnectionsPath, + readChoices, + readLocalConnections, + settingsPath, + takesChoice, + usable, +} = await import("../../../src/altimate/workspace/connection-choice") +type RawConnection = import("../../../src/altimate/workspace/connection-choice").RawConnection + +const jira = { + id: "jira", + type: "tool", + config: [ + { key: "url", required: true }, + { key: "token", required: true }, + { key: "project", required: false }, + ], +} +const conn = (id: string, over: Partial = {}): RawConnection => ({ + id, + name: `Jira ${id}`, + integrationId: "jira", + config: { url: "https://x.atlassian.net", token: "t" }, + remote: true, + ...over, +}) + +describe("takesChoice: the engine's requiresConnectionSelection", () => { + const fields = [{ key: "token", required: true }] + test.each([ + ["in-house tool with a choice", { id: "jira", type: "tool", config: fields }, true], + ["in-house tool the engine does not know (base default)", { id: "newtool", type: "tool", config: fields }, true], + ["snowflake takes a connection per call", { id: "snowflake", type: "tool", config: fields }, false], + ["postgresql per call", { id: "postgresql", type: "tool", config: fields }, false], + ["bigquery per call", { id: "bigquery", type: "tool", config: fields }, false], + ["dbt per call", { id: "dbt", type: "tool", config: fields }, false], + ["altimate per call", { id: "altimate", type: "tool", config: fields }, false], + ["knowledge per call", { id: "altimate_knowledge", type: "tool", config: fields }, false], + ["custom api", { id: "7", type: "api", config: fields }, true], + ["custom or catalog mcp", { id: "github", type: "mcp", config: fields }, true], + ["custom code", { id: "8", type: "code", config: fields }, false], + ["extension", { id: "power-user-for-dbt", type: "extension", config: fields }, false], + ["no connection fields at all", { id: "jira", type: "tool", config: [] }, false], + ["connection fields unreadable", { id: "jira", type: "tool", config: "nope" }, false], + ["no type", { id: "jira", config: fields }, false], + ])("%s", (_, integration, expected) => { + expect(takesChoice(integration)).toBe(expected) + }) +}) + +describe("usable: owned, every required field of its variant set", () => { + const variants = { + ...jira, + additionalConfigVariants: [ + { id: "oauth", config: [{ key: "client_id", required: true }] }, + { id: "bare" }, + ], + } + test.each([ + ["all required fields", jira, conn("1"), true], + ["a required field empty", jira, conn("1", { config: { url: "u", token: "" } }), false], + ["a required field absent", jira, conn("1", { config: { url: "u" } }), false], + ["only optional fields missing", jira, conn("1", { config: { url: "u", token: "t" } }), true], + ["another integration's connection", jira, conn("1", { integrationId: "linear" }), false], + ["type 'default' is the default variant", jira, conn("1", { config: { type: "default", url: "u", token: "t" } }), true], + ["a declared variant, its own fields set", variants, conn("1", { config: { type: "oauth", client_id: "c" } }), true], + ["a declared variant, its own field missing", variants, conn("1", { config: { type: "oauth", url: "u", token: "t" } }), false], + ["a declared variant without fields", variants, conn("1", { config: { type: "bare" } }), false], + ["an undeclared variant, others declared", variants, conn("1", { config: { type: "sso" } }), false], + ["an undeclared variant, none declared", jira, conn("1", { config: { type: "sso" } }), true], + ])("%s", (_, integration, connection, expected) => { + expect(usable(integration, connection)).toBe(expected) + }) +}) + +describe("choiceFor: the connection in use", () => { + const all = [conn("10"), conn("9"), conn("2", { config: { url: "u" } }), conn("a1b2", { remote: false })] + + test("no choice: the lowest usable id, numeric before local ids, in that order", () => { + const choice = choiceFor(jira, all, undefined) + expect(choice.options.map((o) => [o.id, o.usable])).toEqual([ + ["2", false], + ["9", true], + ["10", true], + ["a1b2", true], + ]) + expect(choice.inUse).toEqual({ kind: "default", connection: { id: "9", name: "Jira 9", remote: true, usable: true } }) + }) + + test("a local connection is the default only when no remote one is usable", () => { + const choice = choiceFor(jira, [conn("2", { config: {} }), conn("a1b2", { remote: false })], undefined) + expect(choice.inUse.kind === "default" && choice.inUse.connection.id).toBe("a1b2") + }) + + test("an explicit choice wins over the default", () => { + const choice = choiceFor(jira, all, "10") + expect(choice.inUse.kind === "chosen" && choice.inUse.connection.id).toBe("10") + }) + + test.each([ + ["names a deleted connection", "77", { kind: "off", id: "77" }], + ["names an incomplete connection", "2", { kind: "off", id: "2", name: "Jira 2" }], + ["is not a string (the engine compares as is)", 9, { kind: "off", id: "9" }], + ["is null", null, { kind: "off", id: "null" }], + ])("a choice that %s turns the integration off, with no fallback", (_, chosen, inUse) => { + expect(choiceFor(jira, all, chosen).inUse).toEqual(inUse as never) + }) + + test("no usable connection and no choice", () => { + expect(choiceFor(jira, [conn("2", { config: {} })], undefined).inUse).toEqual({ kind: "none" }) + expect(choiceFor(jira, [], undefined)).toEqual({ integrationId: "jira", options: [], inUse: { kind: "none" } }) + }) + + test("options carry no config", () => { + for (const option of choiceFor(jira, all, undefined).options) expect(Object.keys(option).sort()).toEqual(["id", "name", "remote", "usable"]) + }) +}) + +describe("settings.json", () => { + beforeEach(() => rmSync(path.join(SANDBOX, ".altimate"), { recursive: true, force: true })) + const write = (value: unknown) => { + mkdirSync(path.dirname(settingsPath()), { recursive: true }) + writeFileSync(settingsPath(), typeof value === "string" ? value : JSON.stringify(value)) + } + const read = () => JSON.parse(readFileSync(settingsPath(), "utf8")) + + test("a missing or empty file has no choices", () => { + expect(readChoices("6")).toEqual({}) + write(" ") + expect(readChoices("6")).toEqual({}) + }) + + test("choosing creates the file, the id as a string", () => { + chooseConnection("6", "jira", "9") + expect(read()).toEqual({ "6": { jira: "9" } }) + expect(readChoices("6")).toEqual({ jira: "9" }) + }) + + test("choosing keeps every other key: other integrations, other workspaces, the extension's", () => { + const teammates = [{ folders: ["/p"], teammateId: "6" }] + write({ "6": { linear: "3", jira: "1" }, "7": { jira: "4" }, _activeTeammates: teammates }) + chooseConnection("6", "jira", "9") + expect(read()).toEqual({ "6": { linear: "3", jira: "9" }, "7": { jira: "4" }, _activeTeammates: teammates }) + }) + + test("written in place: the file the extension's engine watches stays the same file", () => { + write({ "6": { jira: "1" } }) + const before = statSync(settingsPath()).ino + chooseConnection("6", "jira", "9") + expect(statSync(settingsPath()).ino).toBe(before) + }) + + test("a file that does not parse is not written over", () => { + write("{ not json") + expect(() => chooseConnection("6", "jira", "9")).toThrow() + expect(readFileSync(settingsPath(), "utf8")).toBe("{ not json") + write("[1, 2]") + expect(() => chooseConnection("6", "jira", "9")).toThrow(/JSON object/) + }) + + test("an entry for the workspace that is not an object is not written over", () => { + write({ "6": "jira" }) + expect(() => chooseConnection("6", "jira", "9")).toThrow(/not an object/) + expect(read()).toEqual({ "6": "jira" }) + }) + + test("the fingerprint is the workspace's entry in a stable order; unreadable is null", () => { + write({ "6": { linear: "3", jira: "1" }, "7": { jira: "4" } }) + const first = choiceFingerprint("6") + write({ "7": { jira: "5" }, "6": { jira: "1", linear: "3" } }) + expect(choiceFingerprint("6")).toBe(first) + chooseConnection("6", "jira", "9") + expect(choiceFingerprint("6")).not.toBe(first) + expect(choiceFingerprint("8")).toBe("[]") + write("{ not json") + expect(choiceFingerprint("6")).toBeNull() + }) +}) + +describe("readLocalConnections", () => { + beforeEach(() => rmSync(path.join(SANDBOX, ".altimate"), { recursive: true, force: true })) + const write = (text: string) => { + mkdirSync(path.dirname(localConnectionsPath()), { recursive: true }) + writeFileSync(localConnectionsPath(), text) + } + + test("reads this machine's connections as local; skips entries without an id or integration", () => { + write( + JSON.stringify([ + { id: "a1b2", name: "Mine", integration_id: "jira", config: { url: "u", token: "t" } }, + { name: "no id", integration_id: "jira", config: {} }, + { id: "c3", name: "no integration", config: {} }, + { id: "d4", integration_id: 5 }, + ]), + ) + expect(readLocalConnections()).toEqual([ + { id: "a1b2", name: "Mine", integrationId: "jira", config: { url: "u", token: "t" }, remote: false }, + { id: "d4", name: "d4", integrationId: "5", config: {}, remote: false }, + ]) + }) + + test("a missing or unreadable file is none", () => { + expect(readLocalConnections()).toEqual([]) + write("{ nope") + expect(readLocalConnections()).toEqual([]) + write("{}") + expect(readLocalConnections()).toEqual([]) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index ca845e28f5..81c694dfc9 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -61,6 +61,8 @@ type Harness = { * the first config load, then tracked through add/remove, as in the runtime. */ live?: boolean fingerprint: string | null + /** The workspace's connection choices as the engine would read them. */ + choices: string | null } function install(opts: { @@ -104,6 +106,7 @@ function install(opts: { lines: [], clock: 1_000_000, fingerprint: "bin-1", + choices: "[]", } if (opts.flag === false) process.env.ALTIMATE_DISABLE_WORKSPACE = "1" else delete process.env.ALTIMATE_DISABLE_WORKSPACE @@ -131,6 +134,7 @@ function install(opts: { } syncInternals.now = () => h.clock syncInternals.fingerprint = () => h.fingerprint + syncInternals.choices = () => h.choices // 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 @@ -832,6 +836,90 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.toasts.at(-1)?.title).toContain("growth") }) + test("a changed connection choice starts the engine again at the next boundary, once", async () => { + // The engine fetches its connections only when it starts and never says its + // tools changed: a new choice reaches the catalog through a fresh engine. + const h = install({}) + await beforeTurn("s1") + expect(h.added).toEqual([]) + h.choices = '[["jira","9"]]' + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(pinnedWorkspace(h.added[0] as LocalMcpConfig)).toBe("42") + expect(h.invalidates).toBe(0) + expect(settledOutcome("s1")?.kind).toBe("attached") + // Unchanged since that start: nothing more, from any session. + await beforeTurn("s1") + await beforeTurn("s2") + expect(h.added).toHaveLength(1) + }) + + test("a choice made before the first boundary counts against the engine's own start", async () => { + // MCP bootstrapped the engine from the first config load, which read "[]". + const h = install({}) + await syncInternals.config!.get() + expect(h.live).toBe(true) + h.choices = '[["jira","9"]]' + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + }) + + test("a config reload between turns does not hide a changed choice from the running engine", async () => { + // Any consumer can reload config, re-deriving the overlay with the new + // choices while the engine that started with the old ones keeps running. + const h = install({}) + await beforeTurn("s1") + h.choices = '[["jira","9"]]' + await syncInternals.config!.invalidate() + await syncInternals.config!.get() + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + }) + + test("a choices file that cannot be read is never a change", async () => { + const h = install({}) + await beforeTurn("s1") + h.choices = null + await beforeTurn("s1") + // Readable again and as it was: still no restart. + h.choices = "[]" + await beforeTurn("s1") + expect(h.added).toEqual([]) + }) + + test("an engine whose start-time choices are unknown adopts the current ones without a restart", async () => { + const h = install({}) + h.choices = null + await beforeTurn("s1") + h.choices = '[["jira","9"]]' + await beforeTurn("s1") + expect(h.added).toEqual([]) + // From then on a change is a change. + h.choices = '[["jira","12"]]' + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + }) + + test("a re-link that also changed the choices replaces the engine once", async () => { + const h = install({}) + await beforeTurn("s1") + h.binding = bound(7, "growth") + h.choices = '[["jira","9"]]' + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + }) + + test("with no engine to start, a changed choice does nothing", async () => { + const h = install({ which: null }) + await beforeTurn("s1") + h.choices = '[["jira","9"]]' + await beforeTurn("s1") + expect(h.added).toEqual([]) + expect(settledOutcome("s1")?.kind).toBe("engine-missing") + }) + test("an unlink mid-session removes the engine and settles unbound", async () => { const h = install({}) await beforeTurn("s1") diff --git a/packages/opencode/test/altimate/workspace/status-view.test.ts b/packages/opencode/test/altimate/workspace/status-view.test.ts index 47cb9ec4b4..55faaad8a3 100644 --- a/packages/opencode/test/altimate/workspace/status-view.test.ts +++ b/packages/opencode/test/altimate/workspace/status-view.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" -import { mkdtempSync, rmSync } from "node:fs" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import type { AttachSnapshot } from "../../../src/altimate/workspace/attach-snapshot" @@ -10,9 +10,14 @@ const SANDBOX = mkdtempSync(path.join(tmpdir(), "status-view-")) // when the preload first imports it, so overriding that here would be too late. const ORIGINAL_STATE_HOME = process.env.OPENCODE_TEST_STATE_HOME process.env.OPENCODE_TEST_STATE_HOME = path.join(SANDBOX, "state") +// And `Global.Path.home` this, for the connection choices in `~/.altimate`. +const ORIGINAL_HOME = process.env.OPENCODE_TEST_HOME +process.env.OPENCODE_TEST_HOME = path.join(SANDBOX, "home") afterAll(() => { if (ORIGINAL_STATE_HOME === undefined) delete process.env.OPENCODE_TEST_STATE_HOME else process.env.OPENCODE_TEST_STATE_HOME = ORIGINAL_STATE_HOME + if (ORIGINAL_HOME === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = ORIGINAL_HOME rmSync(SANDBOX, { recursive: true, force: true }) }) @@ -20,6 +25,7 @@ const { accountScope, boundAttachSnapshot, buildStatusView, + connectionLine, loadStatusView, menuStatusLine, rowLine, @@ -31,6 +37,8 @@ const { snapshotDir, workspaceIdentity, writeAttachSnapshot } = await import( "../../../src/altimate/workspace/attach-snapshot" ) const { AltimateApi } = await import("../../../src/altimate/api/client") +const { chooseConnection, settingsPath } = await import("../../../src/altimate/workspace/connection-choice") +type ConnectionChoice = import("../../../src/altimate/workspace/connection-choice").ConnectionChoice const SCOPE = "acme|https://api.example.com" const integrations: DeclaredIntegration[] = [ @@ -89,6 +97,24 @@ describe("buildStatusView", () => { expect(renamed.rows.find((r) => r.id === "altimate")!.name).toBe("Altimate") }) + test("an integration that uses a chosen connection carries it; the others carry none", () => { + const jiraChoice: ConnectionChoice = { + integrationId: "jira", + options: [{ id: "9", name: "Jira prod", remote: true, usable: true }], + inUse: { kind: "default", connection: { id: "9", name: "Jira prod", remote: true, usable: true } }, + } + const view = buildStatusView(snapshot(), { selection, catalog, choices: new Map([["jira", jiraChoice]]) }) + expect(view.rows.find((r) => r.id === "jira")!.connection).toEqual(jiraChoice) + expect(view.rows.filter((r) => r.connection).map((r) => r.id)).toEqual(["jira"]) + expect(view.connectionsError).toBeUndefined() + }) + + test("connections that could not be read say why, and no row claims a connection", () => { + const view = buildStatusView(snapshot(), { selection, catalog, choices: "the connections could not be read" }) + expect(view.connectionsError).toBe("the connections could not be read") + expect(view.rows.some((r) => r.connection)).toBe(false) + }) + test("with the API unreachable the view is still whole: rows, counts and names from the attach", () => { const offline = buildStatusView(snapshot(), null) const online = buildStatusView(snapshot(), { selection, catalog }) @@ -252,20 +278,61 @@ describe("the menu row and the sidebar line", () => { }) }) +describe("connectionLine", () => { + const prod = { id: "9", name: "Jira prod", remote: true, usable: true } + const mine = { id: "a1", name: "Jira mine", remote: false, usable: true } + const line = (inUse: ConnectionChoice["inUse"]) => connectionLine({ integrationId: "jira", options: [], inUse }) + test.each([ + ["chosen", { kind: "chosen", connection: prod }, { title: "Jira prod", note: "chosen · Remote · Enter to change" }], + ["default", { kind: "default", connection: mine }, { title: "Jira mine", note: "default · Local · Enter to change" }], + [ + "chosen, incomplete", + { kind: "off", id: "2", name: "Jira old" }, + { title: "Jira old", note: "incomplete · tools off · Enter to fix" }, + ], + ["chosen, deleted", { kind: "off", id: "77" }, { title: "connection 77", note: "deleted · tools off · Enter to fix" }], + ["none", { kind: "none" }, { title: "no usable connection", note: "add one on the web" }], + ])("%s", (_, inUse, expected) => { + expect(line(inUse as ConnectionChoice["inUse"])).toEqual(expected) + // The dialog cuts a description at about 38 columns. + expect(expected.note.length).toBeLessThanOrEqual(38) + }) +}) + describe("loadStatusView", () => { const DIR = "/proj/status" const bound = { scope: SCOPE, datamateId: 6 } let getDatamate: ReturnType let listIntegrations: ReturnType + let listCustomIntegrations: ReturnType + let listConnections: ReturnType + const jiraFields = [ + { key: "url", required: true }, + { key: "token", required: true }, + ] + const remote = [ + { id: "12", name: "Jira staging", integration_id: "jira", config: { url: "u", token: "t" } }, + { id: "9", name: "Jira prod", integration_id: "jira", config: { url: "u", token: "t" } }, + { id: "3", name: "Snowflake", integration_id: "snowflake", config: { account: "a" } }, + ] beforeEach(() => { rmSync(snapshotDir(), { recursive: true, force: true }) + rmSync(path.dirname(settingsPath()), { recursive: true, force: true }) getDatamate = spyOn(AltimateApi, "getDatamate").mockResolvedValue({ integrations: selection } as never) - listIntegrations = spyOn(AltimateApi, "listIntegrations").mockResolvedValue(catalog as never) + listIntegrations = spyOn(AltimateApi, "listIntegrations").mockResolvedValue( + catalog.map((c) => (c.id === "jira" ? { ...c, config: jiraFields } : c)) as never, + ) + listCustomIntegrations = spyOn(AltimateApi, "listCustomIntegrations").mockResolvedValue([ + { id: "1", name: "Demo MCP", type: "mcp" }, + ] as never) + listConnections = spyOn(AltimateApi, "listConnections").mockResolvedValue(remote as never) }) afterEach(() => { getDatamate.mockRestore() listIntegrations.mockRestore() + listCustomIntegrations.mockRestore() + listConnections.mockRestore() }) test("returns null before any request when no attach matches the bound workspace", async () => { @@ -301,6 +368,36 @@ describe("loadStatusView", () => { } }) + test("the connection in use: the lowest id by default, then the one chosen, as the engine reads it", async () => { + writeAttachSnapshot(DIR, snapshot()) + const before = await loadStatusView(DIR, bound) + const jira = before?.rows.find((r) => r.id === "jira")?.connection + expect(jira?.options.map((o) => o.name)).toEqual(["Jira prod", "Jira staging"]) + expect(jira?.inUse).toEqual({ kind: "default", connection: { id: "9", name: "Jira prod", remote: true, usable: true } }) + chooseConnection("6", "jira", "12") + const after = await loadStatusView(DIR, bound) + expect(after?.rows.find((r) => r.id === "jira")?.connection?.inUse.kind).toBe("chosen") + // Only the integrations that use one chosen connection carry one. + expect(after?.rows.filter((r) => r.connection).map((r) => r.id)).toEqual(["jira"]) + }) + + test("custom integrations are named from their own catalog", async () => { + writeAttachSnapshot(DIR, snapshot()) + expect((await loadStatusView(DIR, bound))?.rows.find((r) => r.id === "1")?.name).toBe("Demo MCP") + }) + + test("connections or choices that cannot be read leave the rows whole and say why", async () => { + writeAttachSnapshot(DIR, snapshot()) + listConnections.mockRejectedValue(new Error("offline")) + const offline = await loadStatusView(DIR, bound) + expect(offline?.connectionsError).toBe("the connections could not be read from Altimate") + expect(offline?.rows).toHaveLength(4) + listConnections.mockResolvedValue(remote as never) + mkdirSync(path.dirname(settingsPath()), { recursive: true }) + writeFileSync(settingsPath(), "{ not json") + expect((await loadStatusView(DIR, bound))?.connectionsError).toBe("~/.altimate/settings.json could not be read") + }) + test("falls back to the attach alone when the API fails", async () => { getDatamate.mockRejectedValue(new Error("offline")) listIntegrations.mockRejectedValue(new Error("offline")) From fdbb1ffee029d3faa722ae94dfd34e3c21f7f866 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 14:53:59 +0800 Subject: [PATCH 2/3] test(workspace): a restart for a changed choice keeps the session's one handshake retry Same as a re-link: the start and the failed-handshake retry are separate rules. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HRUhVhg6XwxSKJs1iTufiA --- .../altimate/workspace/engine-overlay.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 81c694dfc9..65e8ee5754 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -854,6 +854,34 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.added).toHaveLength(1) }) + test.each([ + [ + "a changed choice", + (h: Harness): void => { + h.choices = '[["jira","9"]]' + }, + ], + [ + "a re-link", + (h: Harness): void => { + h.binding = bound(7, "growth") + }, + ], + ] as const)("an engine started for %s that fails its handshake gets the session's one retry, like any start", async (_, change) => { + // The restart and the failed-handshake retry are separate rules: a start that + // fails is retried once per session, whatever started it. (codex r1) + const h = install({}) + await beforeTurn("s1") + h.status = "failed" + change(h) + await beforeTurn("s1") + expect(h.added).toHaveLength(2) + expect(settledOutcome("s1")?.kind).toBe("connect-failed") + // Spent: later boundaries neither restart nor retry. + await beforeTurn("s1") + expect(h.added).toHaveLength(2) + }) + test("a choice made before the first boundary counts against the engine's own start", async () => { // MCP bootstrapped the engine from the first config load, which read "[]". const h = install({}) From 514934ccf2a2391079ce154c4c586bb3e998dc42 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Thu, 15 Oct 2026 15:23:05 +0800 Subject: [PATCH 3/3] fix(workspace): compare the first boundary with the choices the engine started with A config reload before the first message re-derived the overlay with the new choices, so the bootstrapped engine was never restarted for them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HRUhVhg6XwxSKJs1iTufiA --- .../src/altimate/workspace/engine-overlay.ts | 19 ++++++++++--------- .../altimate/workspace/engine-overlay.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index eb9a3c5d68..b1f88d859a 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -190,6 +190,10 @@ type DirectoryState = { droppedForeign?: boolean /** The key is set by organisation-managed config: nothing here claims it. */ managed?: boolean + /** The connection choices as the first config load read them: the ones the + * engine MCP bootstrapped from that load started with. A reload before the + * first turn boundary re-derives `current`, not the engine. */ + bootChoices?: string | null /** The workspace the directory's binding last read as, while workspace routing * applies to it: set whenever the overlay or a turn boundary reads the binding, * whatever happens to the engine afterwards. Null when unlinked, when the read @@ -280,14 +284,9 @@ export async function overlay( const entry = engineEntry(workspace.id) config.mcp ??= {} config.mcp[DATAMATE_KEY] = entry - state.current = { - directory, - workspace, - entry, - refusal: null, - version: probe.version, - choices: choicesOf(workspace.id), - } + const choices = choicesOf(workspace.id) + if (state.applied === undefined && state.bootChoices === undefined) state.bootChoices = choices + state.current = { directory, workspace, entry, refusal: null, version: probe.version, choices } log.info("workspace engine overlay applied", { workspaceId: workspace.id, version: probe.version }) return } @@ -681,7 +680,9 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS await config().get() // First turn boundary: MCP bootstrapped from the config as loaded, i.e. from // the overlay as it stands now. - if (state.applied === undefined) state.applied = state.current + if (state.applied === undefined) + state.applied = + state.current && state.bootChoices !== undefined ? { ...state.current, choices: state.bootChoices } : state.current // Organisation-managed config owns the key: the feature is off for this // directory, whatever the binding says. Nothing to reload per turn. diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 65e8ee5754..51d979468e 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -892,6 +892,20 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.added).toHaveLength(1) }) + test("a config reload before the first boundary does not hide a changed choice from the bootstrapped engine", async () => { + // The reload re-derives the overlay with the new choices; the engine MCP + // started from the first load still has the old ones. (codex r2) + const h = install({}) + await syncInternals.config!.get() + h.choices = '[["jira","9"]]' + await syncInternals.config!.invalidate() + await syncInternals.config!.get() + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + }) + test("a config reload between turns does not hide a changed choice from the running engine", async () => { // Any consumer can reload config, re-deriving the overlay with the new // choices while the engine that started with the old ones keeps running.