diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index abe3da75e5..71caffb6c9 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -63,6 +63,19 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), + // altimate_change start — pilot flag for the Workspaces feature (post-scan prompt + + // altimate link subcommand). Read as a getter so tests and the runtime `--` middleware + // can flip it between plugin activation and command execution. + // + // Opt-in only — deliberately does NOT inherit ``OPENCODE_EXPERIMENTAL`` (as + // ``enabledByExperimental`` would). The pilot ships behind its own explicit + // gate so users already opted into other experimental features don't get + // this one turned on for them. (Kilo cycle 6.) + get ALTIMATE_WORKSPACE() { + return truthy("ALTIMATE_WORKSPACE") + }, + // altimate_change end + // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. get OPENCODE_DISABLE_PROJECT_CONFIG() { diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index c18b890d02..d37888b04d 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -12,6 +12,108 @@ // session loop. import type { Hooks, PluginInput } from "@opencode-ai/plugin" import * as OnboardingTelemetry from "../telemetry/onboarding" +// altimate_change start — AI-8398 workspaces trigger. Reaches into the same +// EventV2 bridge the server/routes/tui.ts uses to publish TuiEvent.CommandExecute +// so the workspace TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) +// runs its post-scan flow. Feature-flagged via Flag.ALTIMATE_WORKSPACE. +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { AltimateApi } from "@/altimate/api/client" +import { AppRuntime } from "@/effect/app-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { Event as SessionEvent } from "@/session/status" +import { Log } from "@/altimate/util/log" + +const workspaceLog = Log.create({ service: "altimate-workspace" }) + +/** + * Publish the workspace-postScan command AFTER the session goes idle, not on + * `project_scan`'s tool.execute.after. Rationale: project_scan tool RETURNS while + * the LLM is still generating the activation-menu text; the dialog paints in + * that window but user interactions queue behind the streaming. Waiting for + * session.idle costs a few seconds of latency but sidesteps the race entirely — + * the dialog appears once things are quiet. + * + * One-shot per sessionID: pending sessions live in a Set, and when a session + * emits idle its id is removed. When the Set drains, the EventV2 listener is + * torn down via the unsubscribe returned by ``events.listen()`` so a + * permanently-installed no-op handler isn't left behind for the process + * lifetime (m4 in the consensus review). A pending arm is dropped if a + * second project_scan fires in the same session. + */ +const pendingWorkspacePromptSessions = new Set() +/** The unsubscribe returned by ``events.listen()`` is an Effect (not a plain + * function) — running it removes the listener. Store the Effect and execute + * it through ``AppRuntime.runPromise`` on teardown; earlier code cast it to + * ``() => void`` and called it directly, which threw because Effects are not + * callable as functions. (cubic-dev-ai round 3.) */ +let workspacePromptUnsubscribe: Effect.Effect | null = null +// Guard against two concurrent scans passing the ``!workspacePromptUnsubscribe`` +// check before either install completes — both would then install a listener +// and the later assignment would overwrite the first disposer, leaking the +// first listener for the process lifetime. Store the in-flight install as a +// shared promise so concurrent callers await the same result. (CR round 2.) +let workspacePromptInstall: Promise | null = null + +async function armWorkspacePromptOnSessionIdle(sessionID: string): Promise { + pendingWorkspacePromptSessions.add(sessionID) + if (workspacePromptUnsubscribe) return + if (workspacePromptInstall) return workspacePromptInstall + + // Capture the set of pending sessions at install-time so an install + // failure drains EVERY caller that awaited this install, not just the + // one whose sessionID we happen to be handling. Later waiters would + // otherwise see success from the shared promise and stop retrying, + // leaving permanently-stale entries in the pending set. (cubic round 3.) + const armingSessions = new Set(pendingWorkspacePromptSessions) + + workspacePromptInstall = (async () => { + try { + const unsubscribe = await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => + events.listen((event) => + Effect.gen(function* () { + if (event.type !== SessionEvent.Idle.type) return + const sid = (event.data as { sessionID?: string } | undefined)?.sessionID + if (!sid || !pendingWorkspacePromptSessions.has(sid)) return + pendingWorkspacePromptSessions.delete(sid) + yield* events.publish(TuiEvent.CommandExecute, { + command: "altimate.workspace.postScan", + }) + // Once the Set drains, tear the listener down. A later scan + // that adds a new pending session re-arms it from scratch. + // ``teardown`` is an Effect — run it through the app runtime, + // don't call it as a function. (cubic round 3.) + if (pendingWorkspacePromptSessions.size === 0 && workspacePromptUnsubscribe) { + const teardown = workspacePromptUnsubscribe + workspacePromptUnsubscribe = null + AppRuntime.runPromise(teardown).catch((err) => { + workspaceLog.warn("session-idle listener teardown failed", { + err: String(err), + }) + }) + } + }), + ), + ), + ) + workspacePromptUnsubscribe = unsubscribe + } catch (err) { + // Install failed — drop every session that was waiting on this install + // so the next scan retries from scratch. Dropping only the current + // caller's ID would leave later waiters (already resolved by the + // shared install promise) with permanently-stale pending entries. + // (cubic round 3.) + for (const sid of armingSessions) pendingWorkspacePromptSessions.delete(sid) + workspaceLog.warn("session-idle listener install failed", { err: String(err) }) + } finally { + workspacePromptInstall = null + } + })() + return workspacePromptInstall +} +// altimate_change end const ONBOARD_CONNECT = "onboard-connect" @@ -134,6 +236,14 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise false))) { + void armWorkspacePromptOnSessionIdle(input.sessionID) + } + // altimate_change end return } diff --git a/packages/opencode/src/altimate/tools/project-scan.ts b/packages/opencode/src/altimate/tools/project-scan.ts index 130338cee4..b98a89d2e6 100644 --- a/packages/opencode/src/altimate/tools/project-scan.ts +++ b/packages/opencode/src/altimate/tools/project-scan.ts @@ -173,8 +173,13 @@ export async function detectGit(): Promise { * SSH-form remotes (`git@github.com:owner/repo.git`) have no userinfo * concept and are left untouched. URLs we can't parse are dropped to * undefined (better to lose the breadcrumb than leak creds). + * + * Exported so the workspace TuiPlugin (packages/opencode/src/plugin/tui/ + * altimate/workspace.tsx) can reuse the exact same scrubbing rules when + * deriving the project's git remote for the post-scan prompt — the alt + * of duplicating the logic risks the two callers drifting. */ -function stripGitRemoteCredentials(url: string): string | undefined { +export function stripGitRemoteCredentials(url: string): string | undefined { if (!url) return undefined // SSH form: `git@host:path` — no creds to strip. if (/^[\w.-]+@[\w.-]+:/.test(url) && !url.includes("://")) return url diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts new file mode 100644 index 0000000000..10a429e385 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -0,0 +1,384 @@ +// altimate_change - new file +// +// Wire client for the workspace-binding endpoints in altimate-backend +// (/datamate-project-bindings/*, added by AI-8398). Shared between the TUI +// plugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) and the +// `altimate link` CLI subcommand (packages/opencode/src/cli/cmd/link.ts) so +// the two entry points can't drift on request shape / error handling. +// +// Reads AltimateApi credentials on every call so an account switch is picked +// up immediately without a plugin restart. All FastAPI HTTPException.detail +// bodies come out as `{"detail": }` — we parse the object form +// for 409/412 and surface it as a typed error rather than a bare status code. +import { AltimateApi } from "@/altimate/api/client" + +const REQUEST_TIMEOUT_MS = 15_000 + +export interface DatamateRef { + id: number + name: string +} + +export interface Binding { + id: number + datamate_id: number + datamate_name: string + /** Either ``repo_remote`` OR ``project_path`` is populated (at least one). */ + repo_remote: string | null + project_path: string | null + created_at?: string +} + +/** Project identifier passed to create/bind endpoints. At least one field is + * required by the backend's CHECK constraint; the CLI's resolveProjectIdentifier + * always populates ``projectPath`` and populates ``repoRemote`` when available. */ +export interface ProjectIdentifier { + repoRemote?: string + projectPath?: string +} + +export interface CreateAndBindResponse { + datamate: DatamateRef + binding: Binding + manage_url: string +} + +export interface BindingResponse { + binding: Binding +} + +export interface GetBindingResponse { + binding: Binding + datamate: DatamateRef +} + +/** Which identifier arm the pre-check lookup actually matched on. Callers use + * this to pick the correct rebind endpoint (``/by-remote`` vs ``/by-path``) + * regardless of what the CURRENT identifier has — a repo whose remote was + * renamed still resolves via its ``project_path``, and a later ``rebindByRemote`` + * would 404 because no binding exists under the new remote. (M3) */ +export type MatchedIdentifier = "remote" | "path" + +export interface ProjectBindingLookup extends GetBindingResponse { + matchedBy: MatchedIdentifier +} + +export interface ConflictDetail { + message: string + existing_datamate_id?: number + existing_datamate_name?: string | null + repo_remote?: string + project_path?: string +} + +export interface PreconditionDetail { + message: string + actual_current_datamate_id?: number + expected_current_datamate_id?: number +} + +export class NotConfiguredError extends Error { + constructor() { + super("Altimate credentials not configured — sign in first.") + this.name = "NotConfiguredError" + } +} + +export class ConflictError extends Error { + constructor(public readonly detail: ConflictDetail) { + super(detail.message) + this.name = "ConflictError" + } +} + +export class PreconditionFailedError extends Error { + constructor(public readonly detail: PreconditionDetail) { + super(detail.message) + this.name = "PreconditionFailedError" + } +} + +export class NotFoundError extends Error { + constructor(msg = "Not found") { + super(msg) + this.name = "NotFoundError" + } +} + +export class ForbiddenError extends Error { + constructor(msg = "Forbidden") { + super(msg) + this.name = "ForbiddenError" + } +} + +export class WorkspaceApiError extends Error { + constructor( + msg: string, + public readonly status?: number, + ) { + super(msg) + this.name = "WorkspaceApiError" + } +} + +async function creds(): Promise<{ url: string; instance: string; apiKey: string }> { + if (!(await AltimateApi.isConfigured())) throw new NotConfiguredError() + const c = await AltimateApi.getCredentials() + return { url: c.altimateUrl, instance: c.altimateInstanceName, apiKey: c.altimateApiKey } +} + +async function req( + method: string, + subpath: string, + opts: { + body?: unknown + query?: Record + /** Override the base path prefix. Defaults to + * ``/datamate-project-bindings`` (this module's namespace). Pass e.g. + * ``/datamates`` to hit the sibling datamates_router through the same + * timeout / typed-error / empty-body machinery. */ + base?: string + /** If true, a 2xx with an empty body returns ``undefined`` typed as T + * instead of throwing. Only set for endpoints known to return 204 or a + * bare 200 with no payload. */ + allowEmptyBody?: boolean + } = {}, +): Promise { + const { url, instance, apiKey } = await creds() + const qs = opts.query ? "?" + new URLSearchParams(opts.query).toString() : "" + const basePath = opts.base ?? "/datamate-project-bindings" + const target = `${url}${basePath}${subpath}${qs}` + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + let res: Response + let text: string + try { + res = await fetch(target, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "x-tenant": instance, + }, + signal: controller.signal, + ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), + }) + // Keep the AbortController timeout ACTIVE while we read the response body. + // ``fetch()`` resolves after headers arrive; a server can send headers then + // stall the body stream indefinitely, so pulling the body inside the same + // try/finally is the difference between our 15s cap and hanging until TCP + // gives up. (CR round 2.) Do NOT wrap in ``.catch(() => "")`` — that + // swallows the AbortError from the timeout firing during the body read + // and turns a stalled response into a false "empty body". Rejection + // rethrows into the outer catch and is classified there. (cubic round 3.) + text = await res.text() + } catch (err) { + // Distinguish "we hit our 15s abort" from "network stack failed" so the + // caller can decide differently (retry, longer timeout, offline banner). + // The abort fires equally when it kills the fetch OR the body read. (m8) + const name = (err as { name?: string } | undefined)?.name + if (name === "AbortError") { + throw new WorkspaceApiError( + `Request to ${target} timed out after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s`, + ) + } + const msg = err instanceof Error ? err.message : String(err) + throw new WorkspaceApiError(`Cannot reach ${target}: ${msg}`) + } finally { + clearTimeout(timeout) + } + let json: unknown = undefined + if (text) { + try { + json = JSON.parse(text) + } catch { + /* non-JSON body — surface as opaque via status code below */ + } + } + const detail = (json as { detail?: unknown } | undefined)?.detail + if (res.status === 404) throw new NotFoundError(typeof detail === "string" ? detail : "Not found") + if (res.status === 403) throw new ForbiddenError(typeof detail === "string" ? detail : "Forbidden") + if (res.status === 409) { + const d = + typeof detail === "object" && detail !== null + ? (detail as ConflictDetail) + : { message: typeof detail === "string" ? detail : "Conflict" } + throw new ConflictError(d) + } + if (res.status === 412) { + const d = + typeof detail === "object" && detail !== null + ? (detail as PreconditionDetail) + : { message: typeof detail === "string" ? detail : "Precondition failed" } + throw new PreconditionFailedError(d) + } + if (!res.ok) { + throw new WorkspaceApiError( + typeof detail === "string" ? detail : `Request failed with status ${res.status}`, + res.status, + ) + } + // A 2xx with an empty (or unparseable) body is not the same as a resource. + // Callers dereference the return immediately (``.binding``, ``.datamate``, + // ``.manage_url``), so silently handing back ``undefined as T`` produces a + // ``TypeError`` inside caller code that the typed-error switches can't + // classify. Surface it as a WorkspaceApiError instead — unless the caller + // opted in via ``allowEmptyBody`` (e.g. 204 endpoints). Use ``== null`` so a + // literal ``JSON.parse("null")`` (which sets json to null, not undefined) + // is treated as an empty body too — otherwise ``null as T`` reaches callers + // and .foo throws in a way the typed switches can't classify. (m7 + CR) + if (json == null && !opts.allowEmptyBody) { + throw new WorkspaceApiError( + `Empty ${res.status} body from ${target} — expected JSON payload`, + res.status, + ) + } + return json as T +} + +export namespace WorkspaceApi { + /** Server-authoritative pre-check by git remote. Returns null on 404. */ + export async function getBindingForRemote(remote: string): Promise { + try { + return await req("GET", "/by-remote", { query: { repo_remote: remote } }) + } catch (err) { + if (err instanceof NotFoundError) return null + throw err + } + } + + /** Symmetric pre-check by absolute project directory path (for projects + * without a git remote). Returns null on 404. */ + export async function getBindingForPath(projectPath: string): Promise { + try { + return await req("GET", "/by-path", { query: { project_path: projectPath } }) + } catch (err) { + if (err instanceof NotFoundError) return null + throw err + } + } + + /** Tries remote first (stronger identity), then path. Returns the first hit + * TAGGED with which identifier matched, so a caller that later rebinds + * picks the right endpoint even if the current identifier's remote has + * changed since the binding was created (M3). Both fields on the + * identifier are optional but at least one must be present. */ + export async function getBindingForProject(id: ProjectIdentifier): Promise { + if (id.repoRemote) { + const hit = await getBindingForRemote(id.repoRemote) + if (hit) return { ...hit, matchedBy: "remote" } + } + if (id.projectPath) { + const hit = await getBindingForPath(id.projectPath) + if (hit) return { ...hit, matchedBy: "path" } + } + return null + } + + export async function createAndBind(input: { + name: string + identifier: ProjectIdentifier + description?: string + }): Promise { + return req("POST", "/", { + body: { + name: input.name, + repo_remote: input.identifier.repoRemote ?? null, + project_path: input.identifier.projectPath ?? null, + description: input.description ?? null, + }, + }) + } + + export async function bindExisting( + datamateId: number, + identifier: ProjectIdentifier, + ): Promise { + return req("POST", "/bind", { + body: { + datamate_id: datamateId, + repo_remote: identifier.repoRemote ?? null, + project_path: identifier.projectPath ?? null, + }, + }) + } + + export async function rebindByRemote(input: { + remote: string + targetDatamateId: number + expectedCurrentDatamateId?: number + }): Promise { + return req("PUT", "/by-remote", { + body: { + repo_remote: input.remote, + target_datamate_id: input.targetDatamateId, + ...(input.expectedCurrentDatamateId !== undefined + ? { expected_current_datamate_id: input.expectedCurrentDatamateId } + : {}), + }, + }) + } + + /** Path-identified rebind — symmetric to ``rebindByRemote`` for projects + * without a git remote. */ + export async function rebindByPath(input: { + projectPath: string + targetDatamateId: number + expectedCurrentDatamateId?: number + }): Promise { + return req("PUT", "/by-path", { + body: { + project_path: input.projectPath, + target_datamate_id: input.targetDatamateId, + ...(input.expectedCurrentDatamateId !== undefined + ? { expected_current_datamate_id: input.expectedCurrentDatamateId } + : {}), + }, + }) + } + + /** Populates the "link to existing workspace" picker. Reuses the existing + * ``/datamates/`` list endpoint on the datamates_router — routed through + * the shared ``req()`` machinery so it inherits the 15s abort, typed + * error mapping, empty-body guard, and detail-parsing everyone else + * gets. (M5) Filters out non-integer / non-positive ids so a corrupt row + * doesn't reach the picker as a "NaN" label that the caller then binds + * against. */ + export async function listDatamates(): Promise { + // Accept THREE response envelopes — today's ``{datamates: [...]}``, a + // bare ``[...]``, and a generic ``{data: [...]}`` — so a backend + // contract change (or compat layer) doesn't silently empty the picker. + // (cubic-dev-ai round 3.) + type Row = { id: number | string; name: string } + const body = await req("GET", "/", { + base: "/datamates", + }) + let rows: Row[] + if (Array.isArray(body)) { + rows = body + } else if (body && typeof body === "object") { + // Guard each envelope field with Array.isArray — a non-array + // ``datamates`` or ``data`` value (object / string / null) would + // otherwise slip through and throw on ``.map`` below, taking the + // picker down before it renders. (cubic round 4.) + rows = Array.isArray(body.datamates) + ? body.datamates + : Array.isArray(body.data) + ? body.data + : [] + } else { + rows = [] + } + // Filter valid row objects BEFORE map (Kilo cycle 5) — a single ``null`` + // (or non-object) element in an otherwise-valid array would otherwise + // throw ``TypeError: Cannot read properties of null`` on ``d.id`` before + // the post-map filter can drop it. That's the exact picker-down failure + // the round-3/4 envelope guards were added to prevent, just from a + // per-element rather than per-envelope malformed value. + return rows + .filter((d): d is Row => d !== null && typeof d === "object") + .map((d) => ({ id: Number(d.id), name: d.name })) + .filter((d) => Number.isInteger(d.id) && d.id > 0 && typeof d.name === "string") + } +} diff --git a/packages/opencode/src/altimate/workspace/detect.ts b/packages/opencode/src/altimate/workspace/detect.ts new file mode 100644 index 0000000000..71a7d6cc74 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/detect.ts @@ -0,0 +1,75 @@ +// altimate_change - new file +// +// Cheap, sync git-remote detection for the workspace-binding flow. Shared by +// the TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) and +// the `altimate link` CLI subcommand so both entry points identify projects +// identically. Reuses the credential-scrubbing helper the ProjectScan tool +// exports (../tools/project-scan.ts) so an HTTPS remote with embedded +// basic-auth (e.g. `https://:@github.com/...`) never +// reaches the server or the local cache in clear. +import { spawnSync } from "node:child_process" +import { realpathSync } from "node:fs" +import path from "node:path" +import { stripGitRemoteCredentials } from "../tools/project-scan" + +export function detectProjectRemote(directory: string): string | undefined { + try { + const r = spawnSync("git", ["remote", "get-url", "origin"], { + cwd: directory, + encoding: "utf8", + timeout: 3000, + }) + if (r.status !== 0 || !r.stdout) return undefined + return stripGitRemoteCredentials(r.stdout.trim()) + } catch { + return undefined + } +} + +/** Project identity for the binding system. Prefers ``repo_remote`` when the + * project has a git remote (stronger identity — survives directory moves); falls + * back to ``project_path`` (absolute, symlink-resolved directory path) for + * projects without one (materialized sample scaffolds, fresh scratch dirs). + * + * Both fields can be populated simultaneously; callers pick which to use for + * lookup or send both to create/bind (server uses whichever it needs for its + * partial unique index). At least one field is always populated — falling back + * to the raw ``directory`` argument keeps the "no remote AND unresolvable path" + * degenerate case from returning empty. */ +export function resolveProjectIdentifier(directory: string): { + repoRemote?: string + projectPath: string +} { + const repoRemote = detectProjectRemote(directory) + let projectPath: string + try { + projectPath = realpathSync(path.resolve(directory)) + } catch { + projectPath = path.resolve(directory) + } + return repoRemote ? { repoRemote, projectPath } : { projectPath } +} + +/** Sensible default workspace name from a remote URL — best-effort. Used to + * prefill the workspace-name prompt in the CreateDialog and `altimate link`. + * ``github.com/foo/bar.git`` → ``bar`` ; ``git@github.com:foo/bar.git`` → ``bar`` ; + * ``https://x/foo/bar.git/`` (trailing slash after .git) → ``bar``. */ +export function projectNameFromRemote(remote: string): string { + // Strip trailing slashes FIRST, then the ``.git`` suffix, then any + // trailing slashes the suffix strip exposed. Order matters — the + // previous ``.git$`` → ``/$`` pipeline missed ``.git/`` because the + // final ``/`` wasn't ``.git`` any more. (cubic round 3.) + const trimmed = remote + .replace(/[/]+$/, "") + .replace(/\.git$/, "") + .replace(/[/]+$/, "") + const parts = trimmed.split(/[/:]/) + return parts[parts.length - 1] || "workspace" +} + +/** Fallback name source when the project has no git remote — uses the directory's + * basename (e.g. ``/Users/x/sample-dbt-project`` → ``sample-dbt-project``). */ +export function projectNameFromPath(projectPath: string): string { + const base = path.basename(projectPath.replace(/\/$/, "")) + return base || "workspace" +} diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts new file mode 100644 index 0000000000..5ada4971f7 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -0,0 +1,184 @@ +// altimate_change - new file +// +// Local binding cache — offline fallback for the server-authoritative +// pre-check. Scoped to (tenant, apiUrl) at the top level so an account switch +// silently invalidates every cached entry (the switched-to session never sees +// another tenant's workspace names). +// +// Shared between the TuiPlugin and the `altimate link` CLI subcommand so both +// entry points see the same view of local state. File lives under +// ``Global.Path.state`` at 0o600 — chmod is applied post-write since +// ``Filesystem.writeJsonAtomic`` does not chmod (see filesystem.ts:294 for +// why; codex round-2 flagged this gap). +import { chmodSync, existsSync, readFileSync, realpathSync } from "node:fs" +import path from "node:path" +import { AltimateApi } from "@/altimate/api/client" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { Log } from "@/altimate/util/log" + +const CACHE_VERSION = 1 + +const log = Log.create({ service: "altimate-workspace-state" }) + +/** Canonicalize a directory into a stable cache key so callers passing + * ``/tmp/foo``, ``/private/tmp/foo`` (macOS symlink), ``/tmp/foo/``, or a + * relative path all read/write the same row. Uses ``path.resolve`` first so + * relative inputs anchor to cwd, then ``realpathSync`` to collapse symlinks + * and trailing separators. Falls back to the resolved-only form when the + * path doesn't exist on disk (e.g. cache written for a repo that has since + * moved) — better a stable-if-unresolved key than an exception that skips + * the cache entirely. (cubic + kilo cycle 6 — different clients keyed the + * same project under different paths.) */ +function canonicalDirKey(directory: string): string { + const resolved = path.resolve(directory) + try { + return realpathSync(resolved) + } catch { + return resolved + } +} + +export interface CachedBinding { + datamateId: number + datamateName: string + /** Either ``repoRemote`` or ``projectPath`` is populated (at least one). + * Mirrors the server-side binding row, which is identified by whichever + * fields it has. */ + repoRemote: string | null + projectPath: string | null + linkedAt: number +} + +interface CacheFile { + version: 1 + tenant: string + apiUrl: string + bindings: Record +} + +export function cachePath(): string { + return path.join(Global.Path.state, "altimate-workspace-bindings.json") +} + +/** Runtime shape check for a parsed cache file — the JSON blob comes from + * disk and could be anything (older CLI version, hand-edited, corrupted + * mid-write). The type assertion alone doesn't guard against e.g. + * ``{"version": 1, "bindings": null}`` which then throws on + * ``cache.bindings[k]``. Discard anything that fails the shape check so + * readers always get a valid ``CacheFile`` or null. (CR round 2.) */ +function isValidCacheFile(raw: unknown): raw is CacheFile { + if (!raw || typeof raw !== "object") return false + const r = raw as Record + if (r.version !== CACHE_VERSION) return false + if (typeof r.tenant !== "string" || !r.tenant) return false + if (typeof r.apiUrl !== "string" || !r.apiUrl) return false + if (!r.bindings || typeof r.bindings !== "object" || Array.isArray(r.bindings)) return false + for (const [k, v] of Object.entries(r.bindings)) { + if (typeof k !== "string") return false + if (!v || typeof v !== "object") return false + const b = v as Record + if (typeof b.datamateId !== "number" || !Number.isInteger(b.datamateId)) return false + if (typeof b.datamateName !== "string") return false + if (b.repoRemote !== null && typeof b.repoRemote !== "string") return false + if (b.projectPath !== null && typeof b.projectPath !== "string") return false + // At least one identity — otherwise the cached row can never be verified + // against a project and would surface as a "phantom" workspace on the + // offline-fallback render path. (cubic round 3.) + const hasIdentity = + (typeof b.repoRemote === "string" && b.repoRemote.length > 0) || + (typeof b.projectPath === "string" && b.projectPath.length > 0) + if (!hasIdentity) return false + if (typeof b.linkedAt !== "number") return false + } + return true +} + +function readCache(): CacheFile | null { + const p = cachePath() + if (!existsSync(p)) return null + try { + const raw = JSON.parse(readFileSync(p, "utf8")) as unknown + if (!isValidCacheFile(raw)) return null + return raw + } catch (err) { + log.warn("workspace binding cache is corrupt, discarding", { + code: (err as NodeJS.ErrnoException)?.code, + }) + return null + } +} + +function writeCache(cache: CacheFile): void { + const p = cachePath() + Filesystem.writeJsonAtomic(p, cache) + // Best-effort chmod — if the process dies before this line the file exists + // with umask perms, and the next successful write repairs it. Acceptable + // window given the cache holds workspace names, not credentials. + try { + chmodSync(p, 0o600) + } catch (err) { + log.warn("could not chmod workspace binding cache", { + code: (err as NodeJS.ErrnoException)?.code, + }) + } +} + +async function tenantKey(): Promise<{ tenant: string; apiUrl: string } | null> { + // Best-effort: ``AltimateApi.getCredentials`` can throw ``SyntaxError`` on + // a corrupt credentials JSON, ``ZodError`` on schema drift, or a raw + // ``Error`` on an unresolvable ``${env:...}`` reference — anything the + // credential-loader library can produce. This helper is the last gate + // between those errors and callers who treat their failures as fatal (the + // TUI's fire-and-forget bind path terminates on unhandled rejections), so + // swallow them and treat as "no credentials". (Kilo cycle 6.) + try { + if (!(await AltimateApi.isConfigured())) return null + const c = await AltimateApi.getCredentials() + return { tenant: c.altimateInstanceName, apiUrl: c.altimateUrl } + } catch (err) { + log.warn("could not resolve workspace credentials for cache scoping", { + err: String(err), + }) + return null + } +} + +/** Read the local binding for ``directory`` — only returns a hit when the + * cache's stored (tenant, apiUrl) matches the current credentials. Directory + * is canonicalized so raw / symlink / trailing-slash variants collide. */ +export async function readLocalBinding(directory: string): Promise { + const key = await tenantKey() + if (!key) return null + const cache = readCache() + if (!cache) return null + if (cache.tenant !== key.tenant || cache.apiUrl !== key.apiUrl) return null + return cache.bindings[canonicalDirKey(directory)] ?? null +} + +export async function recordApprovedBinding( + directory: string, + binding: CachedBinding, +): Promise { + const key = await tenantKey() + if (!key) return + // Best-effort: cache persistence is a UX convenience, not the source of + // truth (the server-side binding is). If the state directory is read-only + // or the disk is full, callers otherwise report "link failed" and prompt + // duplicate retries against a workspace that IS bound server-side. + // (cubic round 3.) + try { + const existing = readCache() + const cache: CacheFile = + existing && existing.tenant === key.tenant && existing.apiUrl === key.apiUrl + ? existing + : { version: CACHE_VERSION, tenant: key.tenant, apiUrl: key.apiUrl, bindings: {} } + cache.bindings[canonicalDirKey(directory)] = binding + writeCache(cache) + } catch (err) { + log.warn("could not persist workspace binding cache", { + code: (err as NodeJS.ErrnoException)?.code, + err: String(err), + }) + } +} diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts new file mode 100644 index 0000000000..3c9371d73a --- /dev/null +++ b/packages/opencode/src/cli/cmd/link.ts @@ -0,0 +1,400 @@ +// altimate_change - new file +// +// On-demand "link this project to a workspace" subcommand. User invoked it +// explicitly, so we skip the Create/Link/Skip funnel the post-scan trigger +// uses and jump straight to a picker over the user's workspaces — the +// currently-linked one is marked, and "+ Create a new workspace" is the +// first row. New workspaces are auto-named from the git repo (or directory +// name for path-only projects) so the user never has to type anything. +// +// Deliberately shares the WorkspaceApi + state + detect modules with the +// TuiPlugin so the two entry points can't drift on request shape, project +// identity, or error handling. +import { cmd } from "./cmd" +import { UI } from "../ui" +import * as prompts from "@clack/prompts" +import open from "open" +import { AltimateApi } from "@/altimate/api/client" +import { + WorkspaceApi, + ConflictError, + ForbiddenError, + NotConfiguredError, + NotFoundError, + PreconditionFailedError, + type DatamateRef, + type MatchedIdentifier, + type ProjectBindingLookup, + type ProjectIdentifier, +} from "@/altimate/workspace/api-client" +import { + projectNameFromPath, + projectNameFromRemote, + resolveProjectIdentifier, +} from "@/altimate/workspace/detect" +import { recordApprovedBinding } from "@/altimate/workspace/state" + +const CREATE_NEW_SENTINEL = "__create_new__" + +export const LinkCommand = cmd({ + command: "link", + describe: "Link this project to an Altimate workspace", + builder: (yargs) => + yargs.option("directory", { + alias: "d", + describe: "Project directory (defaults to cwd)", + type: "string", + default: process.cwd(), + }), + handler: async (args) => { + // Fail fast on non-TTY stdin — the whole subcommand is a series of + // ``@clack/prompts`` interactive selects (workspace picker, name prompt, + // confirm), so a piped or redirected stdin (``altimate-code link < /dev/null``, + // CI runner, background job) makes every prompt.select() block forever + // with no output — the user sees a hung process at 0% CPU. Bail with a + // clear message directing them to the alternative that actually works + // headless (the TUI plugin's palette command). (kilo cycle 6.) + if (!process.stdin.isTTY) { + UI.error( + "`altimate-code link` needs an interactive terminal (stdin must be a TTY). " + + "Run it directly in a shell, or use the TUI palette command " + + '"Link this project to a workspace".', + ) + process.exitCode = 1 + return + } + + if (!(await AltimateApi.isConfigured())) { + UI.error( + "Not signed in to Altimate. Run the TUI (altimate-code) and sign in first, then re-run `altimate-code link`.", + ) + process.exitCode = 1 + return + } + + const identifier = resolveProjectIdentifier(args.directory) + + prompts.intro("Link this project to a workspace") + if (identifier.repoRemote) prompts.log.info(`Project remote: ${identifier.repoRemote}`) + else prompts.log.info(`Project path: ${identifier.projectPath} (no git remote)`) + + // Pre-check for the currently-linked marker + workspace list. Both are + // fetched up-front so the picker can annotate the current binding. + // ``preCheckOk = false`` means the pre-check itself failed (network, + // 5xx) rather than "not linked" — used later to retry a 409 as a rebind + // instead of surfacing "already linked to X" with no next step. (m10) + let existing: ProjectBindingLookup | null = null + let preCheckOk = true + try { + existing = await WorkspaceApi.getBindingForProject(identifier) + } catch (err) { + if (err instanceof NotConfiguredError) { + UI.error(err.message) + process.exitCode = 1 + return + } + preCheckOk = false + prompts.log.warn( + `Could not reach the workspace service to look up existing bindings (${err instanceof Error ? err.message : String(err)}). Continuing without the currently-linked marker.`, + ) + } + + const spin = prompts.spinner() + spin.start("Loading workspaces...") + let list: DatamateRef[] + try { + list = await WorkspaceApi.listDatamates() + } catch (err) { + spin.stop("Could not load workspaces.", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + return + } + spin.stop(`Found ${list.length} workspace${list.length === 1 ? "" : "s"}.`) + + const autoName = identifier.repoRemote + ? projectNameFromRemote(identifier.repoRemote) + : projectNameFromPath(identifier.projectPath) + const currentId = existing?.datamate.id + const currentName = existing?.datamate.name + + const options: Array<{ value: string; label: string; hint?: string }> = [ + { + value: CREATE_NEW_SENTINEL, + label: `+ Create a new workspace "${autoName}"`, + hint: existing + ? "Creates a new workspace and repoints this project to it." + : "Named from this project; rename in the SaaS after.", + }, + ...list.map((dm) => ({ + value: String(dm.id), + label: dm.id === currentId ? `● ${dm.name}` : ` ${dm.name}`, + hint: dm.id === currentId ? "currently linked here" : undefined, + })), + ] + + const pick = await prompts.select({ + message: existing + ? `Currently linked to "${currentName}". Pick a workspace (or create a new one):` + : "Pick a workspace to link (or create a new one):", + options, + initialValue: currentId !== undefined ? String(currentId) : CREATE_NEW_SENTINEL, + }) + + if (prompts.isCancel(pick)) { + prompts.outro("No changes.") + return + } + + if (pick === CREATE_NEW_SENTINEL) { + await createThenBindOrRebind(identifier, autoName, args.directory, existing) + return + } + + const targetId = Number(pick) + if (targetId === currentId) { + prompts.outro(`Kept "${currentName}" — nothing changed.`) + return + } + + await bindOrRebind(identifier, targetId, existing, preCheckOk, args.directory) + }, +}) + +/** "+ Create a new workspace" flow. When the project is already linked, this + * MUST rebind after create — otherwise the new workspace is a real (billable) + * SaaS resource the CLI knows nothing about and the project is still bound to + * the old workspace (M2 in the consensus review). When rebind fails, the + * error message tells the user the workspace was created and how to recover; + * we do NOT silently swallow the orphan. */ +async function createThenBindOrRebind( + identifier: ProjectIdentifier, + name: string, + directory: string, + existing: ProjectBindingLookup | null, +): Promise { + const spin = prompts.spinner() + spin.start(`Creating workspace "${name}"...`) + let created: Awaited> + try { + created = await WorkspaceApi.createAndBind({ name, identifier }) + } catch (err) { + spin.stop("Failed to create workspace.", 1) + // A 409 from create means someone else's binding on the same + // remote/path beat us. If the pre-check already knew about it, the user + // can pick from the list; if the pre-check missed it, this is the + // authoritative signal — surface it and hint the picker. + if (err instanceof ConflictError) { + prompts.log.error( + `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to switch to a different workspace.`, + ) + } else { + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + process.exitCode = 1 + return + } + spin.stop(`Workspace "${created.datamate.name}" created.`) + + // If the project was already linked, the new workspace exists but the + // binding still points at the OLD workspace — rebind so the project is + // now bound to the freshly-created one. Otherwise createAndBind already + // wrote the binding as part of the atomic create; we're done. + if (existing) { + const rebindSpin = prompts.spinner() + rebindSpin.start(`Repointing project at "${created.datamate.name}"...`) + try { + await rebindByMatchedIdentifier({ + identifier, + targetDatamateId: created.datamate.id, + expectedCurrentDatamateId: existing.datamate.id, + matchedBy: existing.matchedBy, + }) + rebindSpin.stop(`Project is now linked to "${created.datamate.name}".`) + } catch (err) { + rebindSpin.stop("Could not repoint the project.", 1) + prompts.log.error( + `Workspace "${created.datamate.name}" was CREATED but could not be linked to this project. ${err instanceof Error ? err.message : String(err)} — re-run \`altimate-code link\` to retry (or delete the workspace in the SaaS).`, + ) + process.exitCode = 1 + return + } + } + // Prefer the canonicalized ``identifier.projectPath`` over the raw + // ``--directory`` argument so ``altimate-code link -d ./myproj`` and its + // symlink-resolved twin both write under the same cache key (Kilo cycle 6). + await recordApprovedBinding(identifier.projectPath ?? directory, { + datamateId: created.datamate.id, + datamateName: created.datamate.name, + repoRemote: created.binding.repo_remote, + projectPath: created.binding.project_path, + linkedAt: Date.now(), + }) + prompts.log.info(`Manage it at: ${created.manage_url}`) + // Guard against a server that hands back a non-http(s) manage_url — ``open`` + // delegates to the OS handler, so a rogue value could launch an unrelated + // application. Log a warning and skip the auto-open rather than trusting + // whatever protocol the URL parses to. + if (isSafeHttpUrl(created.manage_url)) { + await open(created.manage_url).catch(() => undefined) + } else { + prompts.log.warn(`Skipped auto-open: manage_url is not an http/https URL.`) + } + prompts.outro("Done.") +} + +/** True when the URL parses and its protocol is exactly ``http:`` or ``https:``. + * Used before handing a server-supplied URL to ``open()`` (which would otherwise + * dispatch to whatever OS scheme handler matches the protocol). */ +function isSafeHttpUrl(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === "http:" || u.protocol === "https:" + } catch { + return false + } +} + +async function bindOrRebind( + identifier: ProjectIdentifier, + targetDatamateId: number, + existing: ProjectBindingLookup | null, + preCheckOk: boolean, + directory: string, +): Promise { + const isRebind = existing !== null + const spin = prompts.spinner() + spin.start(isRebind ? `Re-linking to workspace...` : `Linking to workspace...`) + try { + let res + if (isRebind) { + res = await rebindByMatchedIdentifier({ + identifier, + targetDatamateId, + expectedCurrentDatamateId: existing.datamate.id, + matchedBy: existing.matchedBy, + }) + } else { + // No known binding OR pre-check failed. Try bindExisting first — if the + // pre-check missed a real binding, the server will 409, and we retry as + // rebind when we're allowed to. (m10) + try { + res = await WorkspaceApi.bindExisting(targetDatamateId, identifier) + } catch (err) { + if (err instanceof ConflictError && !preCheckOk) { + // Pre-check failed and the server confirms this project IS linked + // already. Retry as an unconditional rebind — we don't have an + // ``expected_current_datamate_id`` (pre-check gave us nothing) so + // this is last-writer-wins. Callers who need optimistic concurrency + // should re-run once the network is back and the pre-check succeeds. + // + // Pick the rebind endpoint from the CONFLICT DETAIL, not from the + // current identifier — the existing binding may be keyed by a + // different identifier than the project's current one (path-keyed + // legacy binding + newly-added remote, or vice versa). Keying off + // the current identifier reproduces the M3 hazard on this fallback + // path. (Kilo cycle 6.) + spin.stop("Pre-check missed an existing binding — retrying as re-link.", 1) + const rebindSpin = prompts.spinner() + rebindSpin.start("Re-linking...") + try { + // detail.project_path present → the conflicting binding is + // path-keyed; use /by-path. Else the conflict was on repo_remote. + const conflictPath = err.detail.project_path + const conflictRemote = err.detail.repo_remote + if (conflictPath) { + res = await WorkspaceApi.rebindByPath({ + projectPath: conflictPath, + targetDatamateId, + }) + } else if (conflictRemote) { + res = await WorkspaceApi.rebindByRemote({ + remote: conflictRemote, + targetDatamateId, + }) + } else { + // Server didn't tell us which identifier owned the conflict — + // fall back to the current identifier's preference (better than + // nothing, but shouldn't happen with a well-formed 409 body). + res = identifier.repoRemote + ? await WorkspaceApi.rebindByRemote({ + remote: identifier.repoRemote, + targetDatamateId, + }) + : await WorkspaceApi.rebindByPath({ + projectPath: identifier.projectPath!, + targetDatamateId, + }) + } + rebindSpin.stop(`Re-linked to "${res.binding.datamate_name}".`) + } catch (retryErr) { + rebindSpin.stop("Re-link failed.", 1) + throw retryErr + } + } else { + throw err + } + } + } + // Prefer the canonicalized identifier over the raw --directory (Kilo cycle 6). + await recordApprovedBinding(identifier.projectPath ?? directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + spin.stop( + isRebind + ? `Re-linked to "${res.binding.datamate_name}".` + : `Linked to "${res.binding.datamate_name}".`, + ) + prompts.outro("Done.") + } catch (err) { + spin.stop(isRebind ? `Re-link failed.` : `Link failed.`, 1) + if (err instanceof ConflictError) { + prompts.log.error( + `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to switch.`, + ) + } else if (err instanceof PreconditionFailedError) { + prompts.log.error("Someone else re-linked this project — re-run and try again.") + } else if (err instanceof NotFoundError) { + prompts.log.error("No existing binding to re-link. Re-run and pick again.") + } else if (err instanceof ForbiddenError) { + prompts.log.error("Only the workspace owner can attach projects to it.") + } else { + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + process.exitCode = 1 + } +} + +/** Pick the rebind endpoint that matches which identifier the pre-check + * resolved the binding on — NOT which identifier the current call happens to + * carry. A repo whose remote was renamed still has a binding under its path; + * rebindByRemote against the new remote would 404 with no repair path from + * the CLI. (M3) */ +async function rebindByMatchedIdentifier(input: { + identifier: ProjectIdentifier + targetDatamateId: number + expectedCurrentDatamateId: number + matchedBy: MatchedIdentifier +}) { + if (input.matchedBy === "remote" && input.identifier.repoRemote) { + return WorkspaceApi.rebindByRemote({ + remote: input.identifier.repoRemote, + targetDatamateId: input.targetDatamateId, + expectedCurrentDatamateId: input.expectedCurrentDatamateId, + }) + } + if (input.matchedBy === "path" && input.identifier.projectPath) { + return WorkspaceApi.rebindByPath({ + projectPath: input.identifier.projectPath, + targetDatamateId: input.targetDatamateId, + expectedCurrentDatamateId: input.expectedCurrentDatamateId, + }) + } + throw new Error( + `Cannot rebind — the pre-check matched on ${input.matchedBy} but that field is not present on the current project identifier.`, + ) +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 69da571611..d2fe2b3506 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -10,6 +10,7 @@ import { UninstallCommand } from "./cli/cmd/uninstall" import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version" +import { Flag } from "@opencode-ai/core/flag/flag" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" // altimate_change start — workspace-serve: dev-only workspace serve command @@ -44,6 +45,9 @@ import { SkillCommand } from "./cli/cmd/skill" // altimate_change start — check: deterministic SQL check command import { CheckCommand } from "./cli/cmd/check" // altimate_change end +// altimate_change start — link: workspace-binding subcommand +import { LinkCommand } from "./cli/cmd/link" +// altimate_change end import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" @@ -169,6 +173,15 @@ let cli = yargs(args) // altimate_change end // altimate_change start — check: register deterministic SQL check command .command(CheckCommand) + // altimate_change end + +// altimate_change start — link: gated on Flag.ALTIMATE_WORKSPACE (pilot) +// so the command isn't registered — and doesn't show in --help — for users +// who haven't opted in to the workspaces feature via ALTIMATE_WORKSPACE=1. +// (M1 in the consensus review.) +if (Flag.ALTIMATE_WORKSPACE) { + cli = cli.command(LinkCommand) +} // altimate_change end // altimate_change start — workspace-serve: register dev-only workspace serve command diff --git a/packages/opencode/src/plugin/tui/altimate/index.ts b/packages/opencode/src/plugin/tui/altimate/index.ts index 766e0593e5..adb5662e6b 100644 --- a/packages/opencode/src/plugin/tui/altimate/index.ts +++ b/packages/opencode/src/plugin/tui/altimate/index.ts @@ -9,10 +9,12 @@ // plugin list in ../internal.ts. import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import type { RuntimeFlags } from "@/effect/runtime-flags" +import { Flag } from "@opencode-ai/core/flag/flag" import ProviderCredentials from "./provider-credentials" import PromptEnhance from "./prompt-enhance" import SkillOps from "./skill-ops" import TraceViewer from "./trace-viewer" +import Workspace from "./workspace" // Feature plugins are registered here as they are ported from the pre-merge sources on `main` // (see the ADR re-home plan). Each lives in its own file under this directory and default-exports @@ -21,7 +23,13 @@ import TraceViewer from "./trace-viewer" // import SkillOps from "./skill-ops" // import PromptEnhance from "./prompt-enhance" // import TraceViewer from "./trace-viewer" +// import Workspace from "./workspace" export function altimateTuiPlugins(_flags: Pick): BuiltinTuiPlugin[] { - return [ProviderCredentials, PromptEnhance, SkillOps, TraceViewer] + const base = [ProviderCredentials, PromptEnhance, SkillOps, TraceViewer] + // Workspace TUI plugin is pilot-gated: only registered for users who + // opted into ALTIMATE_WORKSPACE. Otherwise the post-scan dialog + the + // altimate.workspace.link palette command would ship to 100% of users + // regardless of the flag setting. (M1 in the consensus review.) + return Flag.ALTIMATE_WORKSPACE ? [...base, Workspace] : base } // altimate_change end diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx new file mode 100644 index 0000000000..440fa84127 --- /dev/null +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -0,0 +1,901 @@ +// altimate_change start — fork TUI feature: Workspaces (pilot). +// +// Post-scan prompt to create or link a "Workspace" (server-side: a Datamate) +// to the current project, plus an on-demand "Link this project to a workspace" +// palette command. Server API lives in altimate-backend under +// /datamate-project-bindings/* (top-level router). Backend contract: +// +// POST /datamate-project-bindings/ create-and-bind (atomic) +// POST /datamate-project-bindings/bind attach to existing workspace +// PUT /datamate-project-bindings/by-remote atomic re-link (FOR UPDATE) +// GET /datamate-project-bindings/by-remote server-authoritative lookup +// +// Fork-owned plugin per docs/internal/2026-06-23-tui-fork-features-as-plugins-adr.md. +// Registered by ./index.ts's altimateTuiPlugins() aggregator; upstream +// packages/tui is not touched. Uses `api.ui.*`, `api.keymap.registerLayer`, +// `api.state.path.directory`, `api.kv` (persistent) — the real TuiPluginApi +// surface, not a made-up one (see codex round-2 report for the history). +// +// Trigger: the /altimate-workspace.postScan command is dispatched by the +// existing onboarding-telemetry.ts plugin's `tool.execute.after` hook when +// `project_scan` completes AND `AltimateApi.isConfigured()` returns true AND +// `Flag.ALTIMATE_WORKSPACE` is on. Dispatch travels via the existing +// `TuiEvent.CommandExecute` event bus. +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" +import { createHash } from "node:crypto" +import open from "open" +import { createSignal, onMount } from "solid-js" +import { + ConflictError, + ForbiddenError, + NotFoundError, + PreconditionFailedError, + WorkspaceApi, + type DatamateRef, + type MatchedIdentifier, + type ProjectBindingLookup, + type ProjectIdentifier, +} from "@/altimate/workspace/api-client" +import { + projectNameFromPath, + projectNameFromRemote, + resolveProjectIdentifier, +} from "@/altimate/workspace/detect" +import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" +import { AltimateApi } from "@/altimate/api/client" +import { Log } from "@/altimate/util/log" + +const PLUGIN_ID = "altimate:workspace" + +const log = Log.create({ service: "altimate-workspace" }) + +// ───────────────────────────────────────────────────────────────────────────── +// Skip latch (TUI-only). Uses TuiPluginApi.kv — persistent across sessions +// via packages/tui/src/context/kv.tsx (state/kv.json). The `altimate link` +// subcommand deliberately bypasses this latch (it's user-initiated). +// ───────────────────────────────────────────────────────────────────────────── + +const SKIP_TTL_MS = 7 * 24 * 60 * 60 * 1000 +const KV_SKIP_PREFIX = "altimate.workspace.postScan.skip." + +/** (tenant, apiUrl) scope for the Skip latch — matches the local binding + * cache's top-level scoping. Without this a Skip in one Altimate account + * suppresses the post-scan prompt for the same project in every other + * account for 7 days. Sync-provided by the caller because ``recordSkip`` is + * invoked inside the dialog's synchronous ``onSelect`` handler. Null means + * "unscoped" (used only when credentials are unavailable). (cubic round 3.) */ +export interface LatchScope { + tenant: string + apiUrl: string +} + +/** Latch key from (tenant, apiUrl, primary identifier). Path-only projects + * also get a latch — sample-scaffold users are still users. */ +function skipKey(id: ProjectIdentifier, scope: LatchScope | null): string { + const primary = id.repoRemote ?? id.projectPath ?? "" + const scopeString = scope ? `${scope.tenant}|${scope.apiUrl}|` : "" + return ( + KV_SKIP_PREFIX + + createHash("sha1") + .update(scopeString + primary) + .digest("hex") + ) +} + +function isSkipActive( + api: TuiPluginApi, + id: ProjectIdentifier, + scope: LatchScope | null, + nowMs: number, +): boolean { + const rec = api.kv.get<{ skippedAt: number }>(skipKey(id, scope)) + if (!rec || typeof rec.skippedAt !== "number") return false + // Reject records timestamped in the future — a system-clock rewind after + // ``recordSkip`` would otherwise produce ``nowMs - rec.skippedAt < 0``, + // trivially below the 7-day TTL, and suppress the prompt indefinitely. + // Treat future timestamps as "corrupt, retry" so the next scan re-offers. + // (CodeRabbit cycle 6.) + const delta = nowMs - rec.skippedAt + if (delta < 0) return false + return delta < SKIP_TTL_MS +} + +function recordSkip( + api: TuiPluginApi, + id: ProjectIdentifier, + scope: LatchScope | null, + nowMs: number, +): void { + api.kv.set(skipKey(id, scope), { skippedAt: nowMs }) +} + +/** Best-effort ``LatchScope`` from the current CLI credentials. Returns null + * on any credential failure — the latch then falls back to an unscoped key. */ +async function currentLatchScope(): Promise { + try { + if (!(await AltimateApi.isConfigured().catch(() => false))) return null + const creds = await AltimateApi.getCredentials() + return { tenant: creds.altimateInstanceName, apiUrl: creds.altimateUrl } + } catch { + return null + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Dialog components — deliberate three-way selects; no LLM-generated copy. +// Every dialog closes via `api.ui.dialog.clear()` when the user picks a +// terminal action, so onboarding is never blocked by a stuck workspace prompt. +// ───────────────────────────────────────────────────────────────────────────── + +interface OfferProps { + api: TuiPluginApi + identifier: ProjectIdentifier + defaultName: string + /** (tenant, apiUrl) scope for the Skip latch. Resolved once by the caller + * so the sync ``onSelect`` handler can call ``recordSkip`` without a + * mid-render await. Null when creds are unavailable — latch falls back + * to unscoped. (cubic round 3.) */ + latchScope: LatchScope | null +} + +function OfferDialog(props: OfferProps) { + const identLabel = () => props.identifier.repoRemote ?? props.identifier.projectPath ?? "this project" + return ( + { + if (option.value === "skip") { + recordSkip(props.api, props.identifier, props.latchScope, Date.now()) + props.api.ui.dialog.clear() + return + } + if (option.value === "create") { + // Auto-name from git repo — no name prompt. The SaaS UI is the place to + // rename / configure; the CLI's job is just to establish the binding. + void createAndBindInline(props.api, props.identifier, props.defaultName) + return + } + // link → picker (fresh-project attach path) + props.api.ui.dialog.replace(() => ( + + )) + }} + /> + ) +} + +async function createAndBindInline( + api: TuiPluginApi, + identifier: ProjectIdentifier, + name: string, + /** When present, this project is already bound to another workspace. + * createAndBind succeeds but leaves the binding pointing at the OLD + * workspace; without this rebind step the new workspace is an orphaned + * (billable) SaaS resource the CLI knows nothing about (M2). */ + rebindFrom?: { expectedCurrentDatamateId: number; matchedBy: MatchedIdentifier }, +): Promise { + api.ui.dialog.clear() + let res: Awaited> + try { + res = await WorkspaceApi.createAndBind({ name, identifier }) + } catch (err) { + if (err instanceof ConflictError) { + api.ui.toast({ + variant: "warning", + message: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Use the palette's "Link this project to a workspace" to change.`, + }) + } else { + api.ui.toast({ + variant: "error", + message: err instanceof Error ? err.message : "Failed to create workspace", + }) + } + return + } + + if (rebindFrom) { + // The atomic create-and-bind wrote a NEW binding for the new workspace, + // but the existing binding for THIS project's remote/path still points + // at the old workspace. Repoint via the matched-identifier rebind + // endpoint. If rebind fails, tell the user the workspace exists but + // the link didn't switch — do not silently orphan. + try { + await rebindByMatchedIdentifier({ + identifier, + targetDatamateId: res.datamate.id, + expectedCurrentDatamateId: rebindFrom.expectedCurrentDatamateId, + matchedBy: rebindFrom.matchedBy, + }) + } catch (err) { + api.ui.toast({ + variant: "error", + message: `Workspace "${res.datamate.name}" was CREATED but could not be linked to this project (${err instanceof Error ? err.message : String(err)}). Run \`altimate-code link\` to retry.`, + duration: 15_000, + }) + return + } + } + + // Post-success tail — this function is invoked fire-and-forget + // (``void createAndBindInline(...)``), so a bare rejection here would + // surface as an unhandled promise and terminate the TUI. Contain the + // fallout inside the function itself: ``recordApprovedBinding`` already + // swallows its own errors (state.ts is best-effort), but ``open()`` and + // the toast APIs can reject unexpectedly. Fall back to a plain info + // toast so the user still sees the URL. (Kilo cycle 5.) + try { + await recordApprovedBinding(api.state.path.directory, { + datamateId: res.datamate.id, + datamateName: res.datamate.name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + // Guard against a non-http(s) manage_url — ``open`` dispatches to whatever + // OS handler matches the protocol, so a rogue value could launch an + // unrelated app. Fall through to the info toast (with the URL for manual + // copy) if the URL isn't a safe http/https link. + if (isSafeHttpUrl(res.manage_url)) { + try { + await open(res.manage_url) + api.ui.toast({ + variant: "success", + message: `Workspace "${res.datamate.name}" created. Opened ${res.manage_url} in your browser.`, + }) + return + } catch { + /* fall through to the "open manually" toast below */ + } + } + api.ui.toast({ + variant: "info", + message: `Workspace "${res.datamate.name}" created. Open ${res.manage_url} to configure it.`, + duration: 10_000, + }) + } catch (err) { + api.ui.toast({ + variant: "info", + message: `Workspace "${res.datamate.name}" created and linked.`, + }) + void err + } +} + +/** True when the URL parses and its protocol is exactly ``http:`` or ``https:``. + * Used before handing a server-supplied URL to ``open()`` (which would otherwise + * dispatch to whatever OS scheme handler matches the protocol). */ +function isSafeHttpUrl(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === "http:" || u.protocol === "https:" + } catch { + return false + } +} + +/** Pick the rebind endpoint that matches which identifier the pre-check + * resolved the binding on. Shared with cli/cmd/link.ts through duplicated + * code (M3) — the modules deliberately don't cross-import so the CLI + * subcommand stays self-contained. */ +async function rebindByMatchedIdentifier(input: { + identifier: ProjectIdentifier + targetDatamateId: number + expectedCurrentDatamateId: number + matchedBy: MatchedIdentifier +}) { + if (input.matchedBy === "remote" && input.identifier.repoRemote) { + return WorkspaceApi.rebindByRemote({ + remote: input.identifier.repoRemote, + targetDatamateId: input.targetDatamateId, + expectedCurrentDatamateId: input.expectedCurrentDatamateId, + }) + } + if (input.matchedBy === "path" && input.identifier.projectPath) { + return WorkspaceApi.rebindByPath({ + projectPath: input.identifier.projectPath, + targetDatamateId: input.targetDatamateId, + expectedCurrentDatamateId: input.expectedCurrentDatamateId, + }) + } + throw new Error( + `Cannot rebind — pre-check matched on ${input.matchedBy} but that field is not present on the current project identifier.`, + ) +} + +interface AlreadyLinkedProps { + api: TuiPluginApi + identifier: ProjectIdentifier + workspaceName: string + workspaceId: number + hasDrift: boolean + driftedWas?: string | null + unverified?: boolean + /** Which identifier arm resolved the binding — remote-matched projects + * rebind via ``/by-remote``, path-matched via ``/by-path``. Not the same + * as ``identifier.repoRemote`` / ``identifier.projectPath``, which reflect + * the CURRENT project, not the binding's origin. Threaded into PickerDialog + * so a re-link picks the correct endpoint. (M3) */ + matchedBy: MatchedIdentifier +} + +function AlreadyLinkedDialog(props: AlreadyLinkedProps) { + // Title carries the primary context (workspace name + drift/unverified hint) + // since DialogSelect doesn't take a top-level description block. Verbose but + // it puts the critical info in the user's field of view before they pick. + const title = () => { + const parts: string[] = [`Project is linked to workspace "${props.workspaceName}"`] + const now = props.identifier.repoRemote ?? props.identifier.projectPath + if (props.hasDrift && props.driftedWas) parts.push(`(was ${props.driftedWas}, now ${now})`) + if (props.unverified) parts.push("(⚠ unverified — server unreachable, showing cached value)") + return parts.join(" ") + } + return ( + { + if (option.value === "attach" || option.value === "skip") { + props.api.ui.dialog.clear() + return + } + // relink → picker with the current workspace id as expected_current so + // a concurrent re-link by another client 412s cleanly. matchedBy + // determines which rebind endpoint the picker will call (M3). + props.api.ui.dialog.replace(() => ( + + )) + }} + /> + ) +} + +interface PickerProps { + api: TuiPluginApi + identifier: ProjectIdentifier + mode: "attach" | "relink" + expectedCurrentDatamateId?: number + /** Set for ``mode: "relink"`` — which identifier arm the pre-check matched + * on so we pick the correct rebind endpoint. (M3) */ + matchedBy?: MatchedIdentifier +} + +function PickerDialog(props: PickerProps) { + const [datamates, setDatamates] = createSignal(null) + const [loadError, setLoadError] = createSignal(null) + // DialogSelect delivers ``onSelect`` synchronously per Enter keypress, but + // ``pick()`` awaits the network round-trip — a second Enter before the + // first bind resolves would fire a duplicate ``bindExisting`` / + // ``rebindBy…`` against a project that may already be bound by the first + // call. The second call typically 409s, but the toast then contradicts the + // success toast the first call is about to render. Latch on the first + // in-flight ``pick()``. (kilo cycle 6.) + let submitting = false + + onMount(async () => { + try { + const list = await WorkspaceApi.listDatamates() + setDatamates(list) + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to load workspaces" + setLoadError(msg) + props.api.ui.toast({ variant: "error", message: msg }) + props.api.ui.dialog.clear() + } + }) + + async function pick(datamateId: number) { + if (submitting) return + submitting = true + try { + if (props.mode === "attach") { + const res = await WorkspaceApi.bindExisting(datamateId, props.identifier) + await recordApprovedBinding(props.api.state.path.directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + props.api.ui.toast({ + variant: "success", + message: `Linked to workspace "${res.binding.datamate_name}".`, + }) + } else { + // Rebind: pick the endpoint that matches which identifier the + // pre-check RESOLVED the binding on — not what the current identifier + // happens to carry. A repo whose remote was renamed still has its + // binding under its path; rebindByRemote against the new remote would + // 404 with no repair path from the TUI. (M3) + if (!props.matchedBy || !props.expectedCurrentDatamateId) { + throw new Error("relink picker opened without matchedBy / expectedCurrentDatamateId") + } + const res = await rebindByMatchedIdentifier({ + identifier: props.identifier, + targetDatamateId: datamateId, + expectedCurrentDatamateId: props.expectedCurrentDatamateId, + matchedBy: props.matchedBy, + }) + await recordApprovedBinding(props.api.state.path.directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + props.api.ui.toast({ + variant: "success", + message: `Re-linked to workspace "${res.binding.datamate_name}".`, + }) + } + props.api.ui.dialog.clear() + } catch (err) { + // Surface as a toast so the user sees the specific failure. Dialog + // closes either way — the palette command ``altimate.workspace.link`` + // (or re-running ``altimate-code link``) re-enters the flow, so the + // user always has a way to try again from a clean slate. + let msg: string + if (err instanceof ConflictError) { + // The picker doesn't have a "Re-link" option; the referral used to + // point at OfferDialog's Re-link, which doesn't exist either. Point + // at the concrete next action instead. (kilo cycle 6.) + msg = `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to change the workspace.` + } else if (err instanceof PreconditionFailedError) { + msg = "Someone else re-linked this project — reload and try again." + } else if (err instanceof NotFoundError) { + msg = "No existing binding for this remote to re-link. Re-run `altimate-code link` and pick Create." + } else if (err instanceof ForbiddenError) { + msg = "Only the workspace owner can attach projects to it." + } else { + msg = err instanceof Error ? err.message : "Failed to link workspace" + } + props.api.ui.toast({ variant: "error", message: msg }) + props.api.ui.dialog.clear() + } finally { + submitting = false + } + } + + // While loading (or on error before dialog closes), render a placeholder + // row the user can dismiss with Enter. NOTE: DialogSelect's ``filtered()`` + // drops rows with ``disabled: true`` (packages/tui/src/ui/dialog-select.tsx), + // so the placeholder MUST be rendered without that flag — otherwise the + // picker shows an empty list, hiding both the loading state and the + // "no workspaces yet" hint. Selection is dispatched to ``value === -1`` + // in ``onSelect`` below and simply clears the dialog. (Kilo cycle 6.) + const options = () => { + const list = datamates() + if (!list) return [{ title: "Loading workspaces...", value: -1 }] + if (list.length === 0) + return [ + { + title: "No workspaces yet — cancel and pick 'Create a new workspace' instead.", + value: -1, + }, + ] + return list.map((dm: DatamateRef) => ({ title: dm.name, value: dm.id })) + } + + return ( + + title={props.mode === "attach" ? "Link to workspace" : "Re-link to workspace"} + options={options()} + onSelect={(option) => { + if (option.value === -1) { + props.api.ui.dialog.clear() + return + } + void pick(option.value) + }} + /> + ) +} + +// Sentinel value for the "+ Create a new workspace" row in the on-demand +// picker. Negative so it can't collide with any real datamate id (SERIAL PK). +const CREATE_NEW_SENTINEL = -2 + +interface OnDemandPickerProps { + api: TuiPluginApi + identifier: ProjectIdentifier + currentlyLinkedDatamateId?: number + currentlyLinkedDatamateName?: string + /** Which identifier arm the pre-check matched on. Required to pick the + * correct rebind endpoint when the user swaps workspaces or picks Create + * on an already-linked project. (M3) */ + matchedBy?: MatchedIdentifier + defaultName: string +} + +/** Picker-first flow for the on-demand `altimate.workspace.link` command. + * + * Skips the Create/Link/Skip funnel — the user already opted in by invoking + * the palette. Immediately lists workspaces; currently-linked one is marked; + * "+ Create a new workspace" is the first row. No Skip option (user chose to + * be here). Auto-names any new workspace from the git repo. */ +function OnDemandPickerDialog(props: OnDemandPickerProps) { + const [datamates, setDatamates] = createSignal(null) + + onMount(async () => { + try { + const list = await WorkspaceApi.listDatamates() + setDatamates(list) + } catch (err) { + props.api.ui.toast({ + variant: "error", + message: err instanceof Error ? err.message : "Failed to load workspaces", + }) + props.api.ui.dialog.clear() + } + }) + + const options = () => { + const list = datamates() + // No ``disabled: true`` — DialogSelect filters those out (Kilo cycle 6). + if (!list) return [{ title: "Loading workspaces...", value: -1 }] + // Deliberately short titles + hint in description so the dialog's narrow + // width doesn't truncate either. The "●" marker stays in the title (single + // char, cheap) so the currently-linked row is scannable at a glance. + return [ + { + title: "+ Create a new workspace", + value: CREATE_NEW_SENTINEL, + description: `Auto-named "${props.defaultName}" from this repo — rename in the SaaS.`, + }, + ...list.map((dm) => ({ + title: dm.id === props.currentlyLinkedDatamateId ? `● ${dm.name}` : ` ${dm.name}`, + value: dm.id, + description: dm.id === props.currentlyLinkedDatamateId ? "currently linked to this project" : undefined, + })), + ] + } + + return ( + + title="Link this project to a workspace" + options={options()} + current={props.currentlyLinkedDatamateId ?? CREATE_NEW_SENTINEL} + onSelect={(option) => { + if (option.value === -1) { + props.api.ui.dialog.clear() + return + } + if (option.value === CREATE_NEW_SENTINEL) { + // If already linked, thread the pre-check outcome so createAndBind + // is followed by a rebind — otherwise the new workspace is a real + // (billable) SaaS resource left orphaned while the project is + // still bound to the OLD workspace. (M2) + const rebindFrom = + props.currentlyLinkedDatamateId !== undefined && props.matchedBy + ? { + expectedCurrentDatamateId: props.currentlyLinkedDatamateId, + matchedBy: props.matchedBy, + } + : undefined + void createAndBindInline(props.api, props.identifier, props.defaultName, rebindFrom) + return + } + // Picked an existing workspace. + if (option.value === props.currentlyLinkedDatamateId) { + // No-op — user picked the workspace this project is already linked to. + props.api.ui.toast({ + variant: "info", + message: `Already linked to "${props.currentlyLinkedDatamateName}" — nothing changed.`, + }) + props.api.ui.dialog.clear() + return + } + const existing = + props.currentlyLinkedDatamateId !== undefined && props.matchedBy + ? { datamateId: props.currentlyLinkedDatamateId, matchedBy: props.matchedBy } + : undefined + void bindOrRebindInline(props.api, props.identifier, option.value, existing) + }} + /> + ) +} + +async function bindOrRebindInline( + api: TuiPluginApi, + identifier: ProjectIdentifier, + targetDatamateId: number, + /** Pre-check outcome. Absent means "not linked / pre-check missed" and + * we call bindExisting; present means "linked" and we rebind via the + * matched-identifier endpoint (M3). */ + existing: { datamateId: number; matchedBy: MatchedIdentifier } | undefined, +): Promise { + api.ui.dialog.clear() + const isRebind = existing !== undefined + try { + const res = await (async () => { + if (existing) { + return rebindByMatchedIdentifier({ + identifier, + targetDatamateId, + expectedCurrentDatamateId: existing.datamateId, + matchedBy: existing.matchedBy, + }) + } + return WorkspaceApi.bindExisting(targetDatamateId, identifier) + })() + await recordApprovedBinding(api.state.path.directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + api.ui.toast({ + variant: "success", + message: isRebind + ? `Re-linked to workspace "${res.binding.datamate_name}".` + : `Linked to workspace "${res.binding.datamate_name}".`, + }) + } catch (err) { + let msg: string + if (err instanceof ConflictError) { + msg = `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}".` + } else if (err instanceof PreconditionFailedError) { + msg = "Someone else re-linked this project — reload and try again." + } else if (err instanceof NotFoundError) { + msg = "No existing binding to re-link. Try again." + } else if (err instanceof ForbiddenError) { + msg = "Only the workspace owner can attach projects to it." + } else { + msg = err instanceof Error ? err.message : "Failed to link workspace" + } + api.ui.toast({ variant: "error", message: msg }) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flow orchestrators. +// +// `runFlow` — the post-scan trigger flow. Three-way Create/Link/Skip funnel +// for a user we're prompting FROM ZERO (they haven't opted in). Skip is a +// first-class outcome; Create branch auto-names. +// +// `runOnDemandPicker` — the palette / `/altimate.workspace.link` flow. User +// already opted in by invoking, so no Skip. Fetches the workspace list + +// pre-check binding, opens the picker with the currently-linked one marked. +// ───────────────────────────────────────────────────────────────────────────── + +async function runOnDemandPicker(api: TuiPluginApi, directory: string): Promise { + const identifier = resolveProjectIdentifier(directory) + // Pre-check for the currently-linked marker. Failures are non-fatal — we + // still show the picker without the "(currently linked here)" annotation. + let existing: ProjectBindingLookup | null = null + try { + existing = await WorkspaceApi.getBindingForProject(identifier) + } catch (err) { + log.warn("on-demand picker pre-check failed", { + err: err instanceof Error ? err.message : String(err), + }) + } + const defaultName = identifier.repoRemote + ? projectNameFromRemote(identifier.repoRemote) + : projectNameFromPath(identifier.projectPath) + api.ui.dialog.replace(() => ( + + )) +} + +async function runFlow(api: TuiPluginApi, directory: string): Promise { + const identifier = resolveProjectIdentifier(directory) + // Resolve latch scope ONCE — passed to isSkipActive here + threaded into + // OfferDialog so its sync onSelect can call recordSkip without awaiting. + // (cubic round 3.) + const latchScope = await currentLatchScope() + // Path is always populated by resolveProjectIdentifier — projects without a + // git remote (sample dbt scaffolds, scratch dirs) still get a binding offer. + if (isSkipActive(api, identifier, latchScope, Date.now())) { + log.info("workspace prompt suppressed by 7-day Skip latch", { + identifier: identifier.repoRemote ?? identifier.projectPath, + }) + return + } + + const defaultName = identifier.repoRemote + ? projectNameFromRemote(identifier.repoRemote) + : projectNameFromPath(identifier.projectPath) + + let serverBinding: ProjectBindingLookup | null | undefined + try { + serverBinding = await WorkspaceApi.getBindingForProject(identifier) + } catch (err) { + log.warn("workspace pre-check server call failed, falling back to local cache", { + err: err instanceof Error ? err.message : String(err), + }) + serverBinding = undefined + } + + if (serverBinding) { + // Warm the local cache so an offline follow-up render is consistent. + await recordApprovedBinding(directory, { + datamateId: serverBinding.datamate.id, + datamateName: serverBinding.datamate.name, + repoRemote: serverBinding.binding.repo_remote, + projectPath: serverBinding.binding.project_path, + linkedAt: Date.now(), + }) + // Drift = the identifier the server matched on doesn't equal the + // corresponding identifier this project currently has. E.g. we matched + // on remote but the current remote differs from what the binding + // stored — the repo was renamed / remote swapped. The dialog surfaces + // this so the user isn't silently attached to a stale binding. (M3) + const boundIdent = + serverBinding.matchedBy === "remote" + ? serverBinding.binding.repo_remote + : serverBinding.binding.project_path + const currentIdent = + serverBinding.matchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + const hasDrift = boundIdent != null && currentIdent != null && boundIdent !== currentIdent + api.ui.dialog.replace(() => ( + + )) + return + } + + if (serverBinding === null) { + // Server confirmed unbound → offer create-or-link. + api.ui.dialog.replace(() => ( + + )) + return + } + + // Server unreachable — fall back to the local cache (marked as unverified). + const local = await readLocalBinding(directory) + if (local) { + // Prefer whichever identifier the cache remembers as populated. Same + // ordering as the server-side pre-check: remote first, path fallback. + const cachedMatchedBy: MatchedIdentifier = local.repoRemote ? "remote" : "path" + const cachedIdent = local.repoRemote ?? local.projectPath ?? "" + const currentIdent = + cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + const hasDrift = cachedIdent !== "" && currentIdent != null && cachedIdent !== currentIdent + api.ui.dialog.replace(() => ( + + )) + return + } + // No local cache either → offer, but flag the server-unreachable state so the + // user can decide whether to proceed. + api.ui.toast({ + variant: "warning", + message: "Could not reach the Altimate workspace service — pre-check skipped.", + }) + api.ui.dialog.replace(() => ( + + )) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin registration +// ───────────────────────────────────────────────────────────────────────────── + +/** Report a fire-and-forget flow failure. The keymap ``run()`` callbacks + * discard the returned promise with ``void``, so any rejection from + * ``recordApprovedBinding`` / ``readLocalBinding`` / anything else awaited + * inside would otherwise surface as an unhandled rejection and terminate + * the TUI process. Log + surface a toast so the user knows the workspace + * flow bailed. (CR round 2.) */ +function reportFlowFailure(api: TuiPluginApi, err: unknown): void { + log.error("workspace flow failed", { err: err instanceof Error ? err.message : String(err) }) + api.ui.toast({ + variant: "error", + message: "Workspace setup failed — see the CLI log for details.", + }) +} + +const tui: TuiPlugin = async (api) => { + api.keymap.registerLayer({ + commands: [ + { + name: "altimate.workspace.postScan", + title: "Post-scan workspace prompt", + category: "Altimate", + namespace: "internal", + run() { + runFlow(api, api.state.path.directory).catch((err) => reportFlowFailure(api, err)) + }, + }, + { + name: "altimate.workspace.link", + title: "Link this project to a workspace", + category: "Altimate", + namespace: "palette", + run() { + // User-initiated → jump straight to picker (currently-linked marked, + // "+ Create new" as the first row). No Skip funnel — they invoked. + runOnDemandPicker(api, api.state.path.directory).catch((err) => + reportFlowFailure(api, err), + ) + }, + }, + ], + }) +} + +export default { id: PLUGIN_ID, tui } satisfies BuiltinTuiPlugin + +// Exported for unit tests only. The shared logic (WorkspaceApi, cache, detect, +// project-name) lives in `@/altimate/workspace/*` and should be tested there; +// the plugin owns just the TUI-specific latch semantics. +export { isSkipActive, recordSkip } +// altimate_change end diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts new file mode 100644 index 0000000000..02172c67e7 --- /dev/null +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -0,0 +1,297 @@ +// altimate_change - new file +// Unit coverage for the pure-logic pieces of the workspace TuiPlugin +// (packages/opencode/src/plugin/tui/altimate/workspace.tsx). The JSX +// components and the AltimateApi credential fetch are not exercised here — +// they need a running TUI harness and are covered by the manual smoke plan. +// This file focuses on the deterministic layer: URL parsing, git detection, +// state read/write + chmod, latch semantics, and error classification. +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, rmSync, statSync } from "node:fs" +import path from "node:path" +import os from "node:os" + +// Redirect Global.Path.state BEFORE importing the module under test so its +// module-level cachePath() resolves inside the sandbox. Restore the original +// XDG_STATE_HOME in afterAll so parallel test files aren't polluted by our +// process-scoped tempdir. (CR round 2 — test isolation.) +const ORIGINAL_XDG_STATE_HOME = process.env.XDG_STATE_HOME +const SANDBOX = path.join(os.tmpdir(), `altimate-workspace-test-${process.pid}-${Date.now()}`) +mkdirSync(path.join(SANDBOX, "state"), { recursive: true }) +process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") +afterAll(() => { + if (ORIGINAL_XDG_STATE_HOME === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = ORIGINAL_XDG_STATE_HOME + try { + rmSync(SANDBOX, { recursive: true, force: true }) + } catch { + /* best effort */ + } +}) + +const { isSkipActive, recordSkip } = await import( + "../../../src/plugin/tui/altimate/workspace" +) +const { projectNameFromRemote, detectProjectRemote } = await import( + "../../../src/altimate/workspace/detect" +) +const { cachePath, readLocalBinding, recordApprovedBinding } = await import( + "../../../src/altimate/workspace/state" +) + +// Stub AltimateApi.getCredentials / isConfigured — used by readLocalBinding +// and recordApprovedBinding for tenant/apiUrl scoping. Re-import allows +// per-test override of the module state. +import { AltimateApi } from "../../../src/altimate/api/client" +const originalIsConfigured = AltimateApi.isConfigured +const originalGetCreds = AltimateApi.getCredentials +type Creds = Awaited> +function stubCreds(tenant: string, apiUrl: string) { + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = async () => true + ;(AltimateApi as unknown as { getCredentials: () => Promise }).getCredentials = async () => + ({ + altimateInstanceName: tenant, + altimateUrl: apiUrl, + altimateApiKey: "dummy", + }) as Creds +} +function unstubCreds() { + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = originalIsConfigured + ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = originalGetCreds +} + +// Minimal TuiKV shim for latch tests. Reads/writes are process-local, matching +// what the plugin uses via api.kv in production. +function makeKv(): { get: (k: string, fb?: T) => T; set: (k: string, v: unknown) => void } { + const store = new Map() + return { + get: (k: string, fb?: T): T => (store.has(k) ? (store.get(k) as T) : (fb as T)), + set: (k: string, v: unknown) => { + store.set(k, v) + }, + } +} + +afterEach(() => { + unstubCreds() + // Wipe the cache file between tests so scoping tests don't bleed state. + try { + rmSync(cachePath(), { force: true }) + } catch { + /* not created by every test */ + } +}) + +// ───────────────────────────────────────────────────────────────────────────── +// projectNameFromRemote +// ───────────────────────────────────────────────────────────────────────────── + +describe("projectNameFromRemote", () => { + test("extracts repo name from HTTPS remote", () => { + expect(projectNameFromRemote("https://github.com/foo/bar.git")).toBe("bar") + }) + test("extracts repo name from SSH-form remote", () => { + expect(projectNameFromRemote("git@github.com:foo/bar.git")).toBe("bar") + }) + test("handles remote without .git suffix", () => { + expect(projectNameFromRemote("https://github.com/foo/bar")).toBe("bar") + }) + test("handles trailing slash", () => { + expect(projectNameFromRemote("https://github.com/foo/bar/")).toBe("bar") + }) + test("falls back for empty-ish inputs", () => { + expect(projectNameFromRemote("")).toBe("workspace") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// detectProjectRemote — thin wrapper over git; only assert graceful failure +// (the git-not-a-repo case) since happy paths would need a live repo fixture. +// ───────────────────────────────────────────────────────────────────────────── + +describe("detectProjectRemote", () => { + test("returns undefined when directory is not a git repo", () => { + // Cubic round 3 caught that "empty dir under SANDBOX" still shares + // ``os.tmpdir()``'s ancestor chain — if any ancestor is a git worktree, + // ``git remote get-url`` walks up and returns that repo's remote. Set + // ``GIT_CEILING_DIRECTORIES`` to stop the walk at SANDBOX so this test + // is deterministic regardless of where ``os.tmpdir()`` lives on the + // runner. + const emptyDir = path.join(SANDBOX, `empty-${Date.now()}`) + mkdirSync(emptyDir, { recursive: true }) + const prevCeiling = process.env.GIT_CEILING_DIRECTORIES + process.env.GIT_CEILING_DIRECTORIES = SANDBOX + try { + const result = detectProjectRemote(emptyDir) + expect(result).toBeUndefined() + } finally { + if (prevCeiling === undefined) delete process.env.GIT_CEILING_DIRECTORIES + else process.env.GIT_CEILING_DIRECTORIES = prevCeiling + } + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Local state: cache + chmod + tenant scoping +// ───────────────────────────────────────────────────────────────────────────── + +describe("workspace binding cache", () => { + beforeEach(() => { + stubCreds("acme", "https://api.acme.example.com") + }) + + test("records and reads back a binding for the same directory + tenant", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 42, + datamateName: "Marketing", + repoRemote: "git@github.com:acme/proj-a.git", + projectPath: "/work/proj-a", + linkedAt: 1_700_000_000_000, + }) + + const read = await readLocalBinding("/work/proj-a") + expect(read).not.toBeNull() + expect(read!.datamateId).toBe(42) + expect(read!.datamateName).toBe("Marketing") + }) + + test("chmods the cache file to 0o600 after write", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 1, + datamateName: "X", + repoRemote: "git@github.com:acme/x.git", + projectPath: "/work/proj-a", + linkedAt: 1, + }) + expect(existsSync(cachePath())).toBe(true) + const mode = statSync(cachePath()).mode & 0o777 + expect(mode).toBe(0o600) + }) + + test("returns null when the cached tenant differs from current credentials", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 42, + datamateName: "Marketing", + repoRemote: "git@github.com:acme/proj-a.git", + projectPath: "/work/proj-a", + linkedAt: 1, + }) + + // Switch account → the cached binding must not be surfaced. + unstubCreds() + stubCreds("other-tenant", "https://api.acme.example.com") + + const read = await readLocalBinding("/work/proj-a") + expect(read).toBeNull() + }) + + test("returns null when the cached apiUrl differs from current credentials", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 42, + datamateName: "Marketing", + repoRemote: "git@github.com:acme/proj-a.git", + projectPath: "/work/proj-a", + linkedAt: 1, + }) + + unstubCreds() + stubCreds("acme", "https://different-host.example.com") + + const read = await readLocalBinding("/work/proj-a") + expect(read).toBeNull() + }) + + test("returns null when directory has no cached binding", async () => { + await recordApprovedBinding("/work/proj-a", { + datamateId: 42, + datamateName: "Marketing", + repoRemote: "git@github.com:acme/proj-a.git", + projectPath: "/work/proj-a", + linkedAt: 1, + }) + const read = await readLocalBinding("/work/proj-b") + expect(read).toBeNull() + }) + + test("returns null when credentials are missing entirely", async () => { + unstubCreds() + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = async () => false + const read = await readLocalBinding("/work/proj-a") + expect(read).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Skip latch (TuiKV shim) +// ───────────────────────────────────────────────────────────────────────────── + +describe("Skip latch", () => { + const ident = { repoRemote: "git@github.com:acme/proj-a.git", projectPath: "/work/proj-a" } + const scope = { tenant: "acme", apiUrl: "https://api.acme.example.com" } + + test("no record → not active", () => { + const api = { kv: makeKv() } as any + expect(isSkipActive(api, ident, scope, Date.now())).toBe(false) + }) + + test("recorded within 7 days → active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip(api, ident, scope, now) + expect(isSkipActive(api, ident, scope, now + 6 * 24 * 60 * 60 * 1000)).toBe(true) + }) + + test("recorded past 7 days → not active", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip(api, ident, scope, now) + expect(isSkipActive(api, ident, scope, now + 8 * 24 * 60 * 60 * 1000)).toBe(false) + }) + + test("boundary at exactly 7 days → not active (>= rejects)", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip(api, ident, scope, now) + expect(isSkipActive(api, ident, scope, now + 7 * 24 * 60 * 60 * 1000)).toBe(false) + }) + + test("different remotes have independent latches", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip( + api, + { repoRemote: "git@github.com:acme/one.git", projectPath: "/w/one" }, + scope, + now, + ) + expect( + isSkipActive( + api, + { repoRemote: "git@github.com:acme/two.git", projectPath: "/w/two" }, + scope, + now, + ), + ).toBe(false) + }) + + test("path-only projects (no remote) also get a latch — key derives from path", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + const pathOnly = { projectPath: "/scratch/sample-dbt" } + recordSkip(api, pathOnly, scope, now) + expect(isSkipActive(api, pathOnly, scope, now + 3 * 24 * 60 * 60 * 1000)).toBe(true) + // A different path is not affected. + expect(isSkipActive(api, { projectPath: "/scratch/other" }, scope, now)).toBe(false) + }) + + test("different tenant scopes are independent latches (cubic round 3)", () => { + const api = { kv: makeKv() } as any + const now = 1_700_000_000_000 + recordSkip(api, ident, { tenant: "acme", apiUrl: "https://api.acme.example.com" }, now) + expect( + isSkipActive(api, ident, { tenant: "other", apiUrl: "https://api.acme.example.com" }, now), + ).toBe(false) + expect( + isSkipActive(api, ident, { tenant: "acme", apiUrl: "https://api.other.example.com" }, now), + ).toBe(false) + }) +})