diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index 829db85fc..82785ae59 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -51,10 +51,10 @@ altimate --agent analyst Workspace features are off unless `ALTIMATE_WORKSPACE=1` is set. With it: - `altimate-code link` links the current project to a workspace (or creates one). The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced. -- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Unlink** detaches the project. +- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead. - `altimate-code skill publish ` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands). - In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder. -- When `altimate-code serve` is launched by the VS Code / Cursor extension, the workspace selected in the extension's panel governs that session's skills and memory, taking priority over whatever the project is linked to on the backend, and is scoped to the folder it was launched for. Warehouse tool routing still follows the project's own link for now. A pin is fixed for the life of the `serve` process, so the extension relaunches `serve` when the selection changes; nothing updates a running one. +- When `altimate-code serve` is launched by the VS Code / Cursor extension, the workspace selected in the extension's panel governs that session's skills, memory and (unless `--integrations local` is set) warehouse tool routing, taking priority over whatever the project is linked to on the backend, and is scoped to the folder it was launched for. A pin is fixed for the life of the `serve` process, so the extension relaunches `serve` when the selection changes; nothing updates a running one. ## Global Flags diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index e3b349bc0..5ebc72ebb 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -93,6 +93,24 @@ export class NotConfiguredError extends Error { } } +/** A 409 whose `existing_datamate_name` is withheld: the server hides the name of a workspace + * the caller cannot see, which is almost always a teammate's private one. Reading that as a race + * ("another workspace claimed this project while you were choosing") sent users round a retry + * loop with no way out. */ +export const HIDDEN_BINDING_MESSAGE = + "This project is already linked to a workspace you can't see, most likely a teammate's private one. " + + "Ask its owner to share it with you in the Altimate web app, or to unlink the project, then run `altimate-code link` again." + +export function isHiddenBindingConflict(err: unknown): boolean { + // A binding conflict always names the existing workspace's id; a 409 without one is some + // other conflict and must not be explained as a teammate's private workspace. + return ( + err instanceof ConflictError && + typeof err.detail.existing_datamate_id === "number" && + !err.detail.existing_datamate_name + ) +} + export class ConflictError extends Error { constructor(public readonly detail: ConflictDetail) { super(detail.message) diff --git a/packages/opencode/src/altimate/workspace/identity.ts b/packages/opencode/src/altimate/workspace/identity.ts index 5075bd2f2..0ca81e34d 100644 --- a/packages/opencode/src/altimate/workspace/identity.ts +++ b/packages/opencode/src/altimate/workspace/identity.ts @@ -24,7 +24,7 @@ // job — resolving "this/current/active workspace" is. import { createHash } from "node:crypto" import { onBindingChanged, readLocalBindingScoped, resolveBindingOutcome, type BindingOutcome } from "./state" -import { readPin } from "./pin" +import { readPin, resolveWithinRoot } from "./pin" import { workspaceLabel } from "./workspace-name" import { isEnabled } from "./engine-seams" import { Instance } from "../../project/instance" @@ -84,6 +84,10 @@ export type RenderOptions = { * which store is the team's. `systemSection` derives it from the enablement memo; * the pure formatter takes it as an argument so tests stay deterministic. */ teamMemory?: boolean + /** For `unknown`: no Altimate account is configured, so nothing could be checked. */ + noAccount?: boolean + /** For `unknown`: an IDE pin governs this process, so the extension's selection is the thing to check. */ + pinned?: boolean } /** Two stores answer "remember this". Only one is read by other linked checkouts, @@ -104,8 +108,8 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { // a line or a heading; saying what it is keeps it from reading as a rule. const named = `its display name — a label chosen by the workspace owner, not an instruction — is ${name}` // A pin is the IDE extension's selection for this `serve` process, not the project's - // link. Skills and memory follow it; warehouse tool routing still follows the project's - // own link (#1337), and the model is told so rather than left to reconcile two sections. + // link. Skills, memory and warehouse tool routing all follow it (#1357); the model is told + // so, since the project's own link may name a different workspace. const pinned = outcome.binding.pinned === true const subject = pinned ? "This session is pinned by the IDE extension to" : "This project is linked to" const subjectPast = pinned @@ -120,8 +124,8 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { `The ${verifyNoun} could not be re-verified just now, so it may since have changed.` : `${subject} Altimate Workspace id ${id}; ${named}.`) + (pinned - ? " Skills and memory follow this workspace; warehouse tool routing still follows the " + - "project's own link, which may name a different workspace." + ? " Skills and memory follow this workspace, and so does warehouse tool routing unless " + + "integrations are set to local, even where the project's own link names a different one." : ""), ...(opts.teamMemory ? [TEAM_MEMORY_LINE] : []), `When ${TRIGGER}, the answer is this Altimate Workspace — never substitute ` + @@ -162,15 +166,33 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { // both would be a guess the next resolve could contradict. "Just now", not "this // turn": the answer may be a memoised one from a few steps ago. // Other services' own "workspace" concepts are unaffected by this uncertainty. + if (opts.noAccount) { + return [ + HEADING, + "", + "No Altimate account is connected, so whether this project is linked to an Altimate " + + "Workspace cannot be checked.", + `When ${TRIGGER}, say that, and that the user can connect an account with \`/connect\` ` + + "(Altimate AI)" + + (opts.pinned + ? ", after which the workspace selected in the IDE extension applies. " + : " and then link this project with `altimate-code link`. ") + + "Do not name a specific Altimate Workspace and do not say none is linked.", + "Outside such a question, other services' own \"workspace\" concepts (e.g. a " + + "Databricks workspace) are unaffected and can be discussed normally.", + ].join("\n") + } return [ HEADING, "", "Whether this project is linked to an Altimate Workspace could not be verified " + "just now.", `When ${TRIGGER}, say link status could not be confirmed, and that if this persists ` + - "across turns the user should check the workspace selected in the IDE extension or " + - "run `altimate-code link`. Do not name a specific Altimate Workspace and do not say none is " + - "linked.", + "across turns the user should " + + (opts.pinned + ? "check the workspace selected in the IDE extension. " + : "check that the Altimate service is reachable, or run `altimate-code link`. ") + + "Do not name a specific Altimate Workspace and do not say none is linked.", "Outside such a question, other services' own \"workspace\" concepts (e.g. a " + "Databricks workspace) are unaffected and can be discussed normally.", ].join("\n") @@ -312,7 +334,15 @@ async function accountScope(): Promise { * model teammates will read a block that stays on this machine. The memo is * populated by the first enablement check of the session (the backfill sweep on * bind, or the first mirror), so the line appears from the next turn on. */ -function renderOptions(outcome: BindingOutcome): RenderOptions { +/** A pin that actually governs this directory: valid and scoped to a root containing it. A + * malformed pin, or one for another folder, fails closed, so no advice may rely on it. */ +function governingPin(directory: string): boolean { + const pin = readPin() + return pin.kind === "valid" && resolveWithinRoot(directory, pin.root) !== null +} + +function renderOptions(outcome: BindingOutcome, directory: string): RenderOptions { + if (outcome.status === "unknown") return { pinned: governingPin(directory) } if (outcome.status !== "bound") return {} // A stale outcome is "last known … may since have changed": promising that a // save syncs to that workspace would contradict the line above it. @@ -387,10 +417,16 @@ export async function systemSection(): Promise { // read is looser (a file with an empty key still names a tenant and host, and would // reach the network from here with no memo, no single-flight and a synchronous // `git remote` probe). Say so and do not invoke it. - if (!scope) return render({ status: "unknown" }) + // "No account" only when none is configured; a configured but incomplete one gets the + // ordinary could-not-verify copy rather than a pointer to /connect. + if (!scope) + return render({ status: "unknown" }, MAX_SECTION_CHARS, { + noAccount: !(await AltimateApi.isConfigured().catch(() => false)), + pinned: governingPin(directory), + }) const key = keyFor(scope, directory) const hit = memo.get(key) - if (fresh(hit)) return render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome)) + if (fresh(hit)) return render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome, directory)) // The fallback is itself raced against a small budget, so the wait is // bounded by RESOLVE_DEADLINE_MS + FALLBACK_BUDGET_MS, not by the disk. const deadline = after(RESOLVE_DEADLINE_MS, () => @@ -398,7 +434,7 @@ export async function systemSection(): Promise { ) try { const outcome = await Promise.race([resolve(key, directory), deadline]) - return render(outcome, MAX_SECTION_CHARS, renderOptions(outcome)) + return render(outcome, MAX_SECTION_CHARS, renderOptions(outcome, directory)) } finally { for (const t of timers) clearTimeout(t) } diff --git a/packages/opencode/src/altimate/workspace/manage.ts b/packages/opencode/src/altimate/workspace/manage.ts index 1da463233..2a5408c61 100644 --- a/packages/opencode/src/altimate/workspace/manage.ts +++ b/packages/opencode/src/altimate/workspace/manage.ts @@ -82,7 +82,7 @@ export interface SyncReport { * and only one of them is the workspace's memory toggle; a toast that said * "memory is off" for a failed local read sent the user to a setting that was * fine. */ - gatedBecause?: "flag-off" | "no-binding" | "pin-unresolved" | "memory-off" | "read-failed" + gatedBecause?: "flag-off" | "no-binding" | "pin-unresolved" | "memory-off" | "read-failed" | "setting-unavailable" sent: number failed: number /** Already present in the workspace at their current payload. */ @@ -253,9 +253,17 @@ export async function sync(directory: string): Promise { const result = await MemorySync.backfill(blocks, binding, directory) return { gated: result.gated, - // `backfill` gates on exactly one thing this far in: the workspace's own - // setting. The flag and the binding were checked above. - gatedBecause: result.gated ? "memory-off" : undefined, + // The sweep reports why it did not run: only a confirmed toggle is memory-off; a failed + // enablement lookup is a transient "setting-unavailable", not a disabled workspace. + gatedBecause: !result.gated + ? undefined + : result.gateReason === "disabled" + ? "memory-off" + : result.gateReason === "local-off" + ? "flag-off" + : result.gateReason === "unbound" + ? "no-binding" + : "setting-unavailable", sent: result.ok, failed: result.failed, skipped: result.skipped, diff --git a/packages/opencode/src/altimate/workspace/memory-backfill.ts b/packages/opencode/src/altimate/workspace/memory-backfill.ts index 0f1b5b3a5..fb4da9287 100644 --- a/packages/opencode/src/altimate/workspace/memory-backfill.ts +++ b/packages/opencode/src/altimate/workspace/memory-backfill.ts @@ -16,6 +16,15 @@ import type { CachedBinding } from "./state" const log = Log.create({ service: "altimate-workspace-memory-backfill" }) +/** What a bind's memory seed concluded. `off` is "never ran" (memory disabled here or + * for the workspace), `incomplete` is "ran and left blocks behind"; `link` reports the + * two differently, since only the second needs the user to retry a Sync. */ +export type SeedOutcome = { + status: "seeded" | "already" | "off" | "local-off" | "incomplete" | "account-changed" + sent: number + pending: number +} + /** Push every non-expired local block. Throttled and resumable inside * ``backfill`` — blocks already synced at their current payload are skipped, so * repeated binds cost index reads rather than uploads. @@ -23,8 +32,9 @@ const log = Log.create({ service: "altimate-workspace-memory-backfill" }) * Covers both scopes: project blocks attach to the workspace just bound, and * global blocks go up account-level. A bind is the only moment global memory is * swept; blocks written later ride the ordinary per-write mirror. */ -export async function backfillOnBind(directory: string, binding: CachedBinding): Promise { - if (!isEnabled()) return false +export async function seedOnBind(directory: string, binding: CachedBinding): Promise { + // Off on this machine (ALTIMATE_DISABLE_MEMORY), which is not the workspace's toggle. + if (!isEnabled()) return { status: "local-off", sent: 0, pending: 0 } try { // The directory and binding are passed in rather than rediscovered. The // `link` subcommand binds from a plain yargs handler with no instance @@ -32,9 +42,17 @@ export async function backfillOnBind(directory: string, binding: CachedBinding): // there — silently, because this catch turns it into a log line while the // CLI still prints "Linked". Reading project memory was the entire point. const blocks = await MemoryStore.listAll({ directory }) - if (blocks.length === 0) return true + if (blocks.length === 0) return { status: "seeded", sent: 0, pending: 0 } const result = await backfill(blocks, binding, directory) log.info("workspace memory seeded after bind", result) + // `gated` also covers a failed enablement lookup; only a confirmed toggle is "off". + // The sweep's own gate result, not the cache: a stale "disabled" memo beside a failed + // lookup is still an unknown state, not memory off. + if (result.gated) + return result.gateReason === "disabled" + ? { status: "off", sent: 0, pending: 0 } + : // How many are really unsent is unknown (some may be indexed from an earlier seed). + { status: "incomplete", sent: 0, pending: 0 } // Only a sweep that stored everything it meant to counts as seeded. A // failure here must leave the binding eligible for a retry, or local blocks // stay absent from the workspace until a rebind or an unrelated edit. @@ -45,9 +63,14 @@ export async function backfillOnBind(directory: string, binding: CachedBinding): // harness-bot #1116 comment 3840503346.) ``deferred`` likewise: a block // held back because the record set could not be read, or the workspace // holds a newer copy, is not in the workspace at this payload either. - return !result.gated && result.failed === 0 && result.declined === 0 && result.deferred === 0 + const pending = result.failed + result.declined + result.deferred + return { status: pending === 0 ? "seeded" : "incomplete", sent: result.ok, pending } } catch (err) { log.warn("workspace memory backfill after bind failed", { err: String(err) }) - return false + return { status: "incomplete", sent: 0, pending: 0 } } } + +export async function backfillOnBind(directory: string, binding: CachedBinding): Promise { + return (await seedOnBind(directory, binding)).status === "seeded" +} diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index d27f24d5f..0e9e8c7c0 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -24,7 +24,7 @@ import { Log } from "@/altimate/util/log" import type { MemoryBlock } from "@/memory/types" import { TRAINING_META_COMMENT } from "@/altimate/training/types" // Aliased: `syncInternals.resolveBinding` below is an unrelated test seam. -import { resolveBinding as resolveProjectBinding, type CachedBinding } from "./state" +import { canonicalDirectory, onBindingChanged, resolveBinding as resolveProjectBinding, type CachedBinding } from "./state" import { indexKey, readIndex, readIndexEntry, recordIndexEntry } from "./memory-index" import { WorkspaceApi } from "./api-client" import { AltimateApi } from "@/altimate/api/client" @@ -75,6 +75,14 @@ interface SessionMemory { touchedAt: number /** Set once a bounded wait expired, so later injections do not re-wait. */ waitTimedOut?: boolean + /** The load has settled; a stale session is reloaded only then. */ + settled?: boolean + /** The project's binding epoch (see `epochFor`) under which the committed load resolved its + * binding. A later relink, unlink or reset of THIS project moves it on: the overlay is then + * hidden at once and reloaded on the next hydrate. */ + loadedEpoch?: string + /** Canonical directory the committed load was for; null when there was no instance. */ + dir?: string | null } const sessions = new Map() @@ -946,16 +954,28 @@ export async function backfill( blocks: MemoryBlock[], explicitBinding?: CachedBinding, sweepDirectory?: string, -): Promise<{ ok: number; failed: number; skipped: number; declined: number; deferred: number; gated: boolean }> { +): Promise<{ + ok: number + failed: number + skipped: number + declined: number + deferred: number + gated: boolean + /** Why a gated sweep never ran; only "disabled" is a confirmed workspace toggle. */ + gateReason?: "local-off" | "unbound" | "disabled" | "error" +}> { // ``gated`` says the sweep never ran, as opposed to running and storing // nothing. A caller recording "this binding is seeded" must be able to tell // those apart: memory being off is not a completed seed. - if (!isEnabled()) return { ok: 0, failed: 0, skipped: 0, declined: 0, deferred: 0, gated: true } + if (!isEnabled()) return { ok: 0, failed: 0, skipped: 0, declined: 0, deferred: 0, gated: true, gateReason: "local-off" } // The bind path passes the binding it just recorded; there is no ambient // instance to resolve one from on the `link` subcommand. const binding = explicitBinding ?? (await currentBinding()) - if (!binding || !(await memoryEnabled(binding))) - return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true } + if (!binding) + return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason: "unbound" } + const status = await memoryStatus(binding) + if (status !== "enabled") + return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason: status } const index = await readIndex() const { pending, skipped } = partitionPending(blocks, binding, index) @@ -1065,11 +1085,22 @@ export function belongsHere(record: CloudMemoryRecord, ownWorkspace: string | un * workspace memory blink out of the prompt whenever a fetch ran long. */ export async function hydrate(sessionID: string): Promise { if (!isEnabled()) return - const state = sessionState(sessionID) + let state = sessionState(sessionID) + // A relink since the last load: start over from the new binding. An in-flight + // load is left to finish (it may be the one that discovered the binding) and + // the reload happens on the turn after. + if (state.settled && state.loadedEpoch !== epochFor(state.dir ?? null)) { + sessions.delete(sessionID) + state = sessionState(sessionID) + } if (state.hydration) return state.hydration // The overlay is deliberately NOT cleared before loading: clearing first made // workspace memory blink out of the prompt whenever a fetch ran long. - state.hydration = loadWorkspaceMemory().then((outcome) => commitLoad(sessionID, state, outcome)) + const launched = state + state.hydration = loadWorkspaceMemory().then((outcome) => { + launched.settled = true + commitLoad(sessionID, launched, outcome) + }) return state.hydration } @@ -1110,21 +1141,41 @@ export async function whenHydrated( * "nothing to load" and "could not load" must stay distinguishable: collapsing * them is how a transient failure gets reported as a successful reload of an * empty workspace, taking the session's real memory with it. */ -type LoadOutcome = +type LoadOutcome = ( | { status: "loaded"; blocks: RemoteMemoryBlock[] } | { status: "unlinked" } | { status: "disabled" } | { status: "error" } +) & { epoch?: string; dir?: string | null } /** Read this project's workspace memory. Pure: it publishes nothing, so a slow * load that has been superseded cannot write over a newer result. */ async function loadWorkspaceMemory(directory?: string): Promise { + const raw = directory ?? currentDirectory() + const dir = raw ? canonicalDirectory(raw) : null + // Last epoch this load can vouch for. A failure after the binding resolved keeps that one, so + // an error from the previous workspace is not stamped as the new binding's settled load. + let vouched = epochFor(dir) try { - const binding = await currentBinding(directory) - if (!binding) return { status: "unlinked" } + // The epoch must bracket the lookup: read only after it, a relink that lands while the + // lookup is pending would stamp the old binding as current. Read only before it, a lookup + // that adopts this project's server binding (which notifies a change) would stamp itself + // stale. So resolve until the epoch holds across one lookup; the retry is a cache read. + let binding: CachedBinding | null = null + let epoch = "" + let stable = false + for (let attempt = 0; attempt < 3 && !stable; attempt++) { + const before = epochFor(dir) + binding = await currentBinding(raw ?? undefined) + epoch = epochFor(dir) + stable = before === epoch + } + vouched = epoch + if (!stable) return { status: "error", epoch, dir } + if (!binding) return { status: "unlinked", epoch, dir } const enabled = await memoryStatus(binding) - if (enabled === "error") return { status: "error" } - if (enabled === "disabled") return { status: "disabled" } + if (enabled === "error") return { status: "error", epoch, dir } + if (enabled === "disabled") return { status: "disabled", epoch, dir } const ownProjectKey = projectKeyFor(binding) const ownWorkspace = String(binding.datamateId) @@ -1142,10 +1193,12 @@ async function loadWorkspaceMemory(directory?: string): Promise { if (block.expires && new Date(block.expires) <= new Date()) continue blocks.push(block) } - return { status: "loaded", blocks } + return { status: "loaded", blocks, epoch, dir } } catch (err) { log.warn("workspace memory load failed", { err: String(err) }) - return { status: "error" } + // Stamped like any other outcome: without an epoch the session would reload (and make + // the prompt wait) on every turn for as long as the service is down. + return { status: "error", epoch: vouched, dir } } } @@ -1155,6 +1208,12 @@ async function loadWorkspaceMemory(directory?: string): Promise { * an older in-flight load must not write into the newer one. */ function commitLoad(sessionID: string, state: SessionMemory, outcome: LoadOutcome): void { if (sessions.get(sessionID) !== state) return + // Resolved against a binding that has since changed: publishing it would put the previous + // workspace's memory back. The next hydrate loads again. + if (outcome.epoch !== epochFor(outcome.dir ?? null)) return + state.loadedEpoch = outcome.epoch + state.dir = outcome.dir ?? null + // An error keeps whatever the session had and is not retried every turn (as before). if (outcome.status === "error") return state.overlay = outcome.status === "loaded" ? outcome.blocks : [] if (outcome.status === "loaded" && outcome.blocks.length > 0) { @@ -1165,7 +1224,11 @@ function commitLoad(sessionID: string, state: SessionMemory, outcome: LoadOutcom /** A session's cloud overlay. Returns a copy so a caller cannot mutate the * cached state in place. */ export function overlayBlocks(sessionID: string): RemoteMemoryBlock[] { - return [...(sessions.get(sessionID)?.overlay ?? [])] + const state = sessions.get(sessionID) + // Hidden as soon as the binding moves, not on the next turn: a tool call later in this + // turn must not read the previous workspace's memory. + if (!state || state.loadedEpoch === undefined || state.loadedEpoch !== epochFor(state.dir ?? null)) return [] + return [...state.overlay] } export type RefreshResult = { @@ -1188,16 +1251,23 @@ export async function refresh(sessionID: string, directory?: string): Promise { const previous = overlayBlocks(sessionID) + const previousEpoch = sessions.get(sessionID)?.loadedEpoch + const previousDir = sessions.get(sessionID)?.dir // `directory` is threaded through rather than resolved from the ambient // instance: the headless adapter this module serves has no instance, and // `manage.refresh(directory, sessionID)` promises the directory it was // given is the one that gets refreshed. const outcome = await loadWorkspaceMemory(directory) + // A relink, unlink or reset landed after this load resolved its binding: neither what it + // read nor what the session had before belongs to the current binding. + if (outcome.epoch !== epochFor(outcome.dir ?? null)) return { count: 0, ok: false, status: "error" } if (outcome.status === "error") { // Keep what the session had. Emptying it because the network hiccuped is // strictly worse than not reloading, and the user asked for a reload. const state = sessionState(sessionID) state.overlay = previous + state.loadedEpoch = previousEpoch + state.dir = previousDir return { count: previous.length, ok: false, status: "error" } } // Replace the session's state so any older in-flight hydration is orphaned @@ -1205,6 +1275,7 @@ export async function refresh(sessionID: string, directory?: string): Promise() + +function epochFor(dir: string | null): string { + return dir ? `${resetEpoch}:${directoryEpochs.get(dir) ?? 0}` : `${resetEpoch}:*${anyChangeEpoch}` +} + /** Forget a session's hydration, or all of them. * * Not called per turn: doing so defeated ``hydrate``'s idempotence and made * every turn refetch. Exposed for tests and for a future session-end hook. */ export function resetOverlay(sessionID?: string): void { if (sessionID === undefined) { + // Invalidate loads in flight too, or a pending refresh writes the cleared memory back + // (Unlink resets while a Refresh may still be loading). + resetEpoch++ sessions.clear() // Both memos, not just the positive one. A refresh after memory was turned // ON for a workspace last seen off otherwise kept reporting zero unsynced @@ -1230,3 +1315,12 @@ export function resetOverlay(sessionID?: string): void { } sessions.delete(sessionID) } + +// A link, relink, unlink or server-side rebind swaps the workspace under every open +// session, and `hydrate` loads once per session: without this, a session that pulled +// workspace A's memory keeps injecting it after the project is relinked to B. Loads record +// the epoch after resolving their binding, so one that discovered its own binding is kept. +onBindingChanged((directory) => { + anyChangeEpoch++ + directoryEpochs.set(directory, (directoryEpochs.get(directory) ?? 0) + 1) +}) diff --git a/packages/opencode/src/altimate/workspace/skill-sync.ts b/packages/opencode/src/altimate/workspace/skill-sync.ts index 3fde82745..3ddde3cff 100644 --- a/packages/opencode/src/altimate/workspace/skill-sync.ts +++ b/packages/opencode/src/altimate/workspace/skill-sync.ts @@ -45,6 +45,7 @@ import { Log } from "@/altimate/util/log" import { AltimateApi } from "@/altimate/api/client" import { resolveBindingOutcome, type CachedBinding } from "./state" import { altimateRequest, WorkspaceApiError } from "./api-client" +import { readPin, resolveWithinRoot } from "./pin" const log = Log.create({ service: "altimate-workspace-skill-sync" }) @@ -806,6 +807,18 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean if (outcome.status === "unbound") { if (await deactivate(canon, "this project is no longer bound to a workspace")) changed = true } + // An IDE pin that cannot be honoured resolves `unknown`, and memory and routing fail + // closed on it; the snapshot must too, or its skills keep loading from disk. A blip after + // a successful validation resolves `bound` (stale) instead, so `unknown` here means the + // pin is malformed, refused, no longer visible, or was never confirmed. Scoped to the + // folder a valid pin speaks for; a malformed pin names no folder, so it covers every one. + const pin = readPin() + if ( + outcome.status === "unknown" && + (pin.kind === "invalid" || (pin.kind === "valid" && resolveWithinRoot(canon, pin.root))) + ) { + if (await deactivate(canon, "the workspace pin could not be honoured")) changed = true + } return } const binding = outcome.binding diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 240933395..cae7fbee8 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -20,6 +20,7 @@ import { Log } from "@/altimate/util/log" // Type-only: the value side is imported dynamically in resolveBinding to keep // this module's import graph free of the API client at load time. import type { Binding, ProjectBindingLookup } from "./api-client" +import type { SeedOutcome } from "./memory-backfill" // altimate_change — the IDE extension's workspace pin; see ./pin.ts import { readPinLogged, resolveWithinRoot, type ValidPin } from "./pin" import { resolveProjectIdentifier } from "./detect" @@ -191,6 +192,10 @@ function writeCache(cache: CacheFile): void { * (macOS ``/tmp`` → ``/private/tmp`` is the common case). Writers and readers * must both funnel through this or a shell-cwd write silently misses when the * TUI's canonicalized ``state.path.directory`` looks it back up. */ +export function canonicalDirectory(directory: string): string { + return canonicalizeKey(directory) +} + function canonicalizeKey(directory: string): string { try { return realpathSync(path.resolve(directory)) @@ -749,9 +754,11 @@ export async function resolveBindingOutcome(directory: string): Promise void>() +/** `directory` is the canonical path whose binding changed, so a listener can scope its + * reaction to that project rather than to every project the process serves. */ +const bindingChangeListeners = new Set<(directory: string) => void>() -export function onBindingChanged(listener: () => void): () => void { +export function onBindingChanged(listener: (directory: string) => void): () => void { bindingChangeListeners.add(listener) return () => { bindingChangeListeners.delete(listener) @@ -761,14 +768,15 @@ export function onBindingChanged(listener: () => void): () => void { /** Never throws: a listener is a UI refresh, and one bad subscriber must not * fail the link or unlink that notified it. Iterates a copy so a listener that * unsubscribes itself mid-notify cannot skip the next one. */ -function notifyBindingChanged(): void { +function notifyBindingChanged(directory: string): void { + const canonical = canonicalizeKey(directory) // Snapshot first: a listener may subscribe or unsubscribe while being // notified, and iterating the live Set would then walk a collection that // changed underneath us. const listeners = Array.from(bindingChangeListeners) for (const listener of listeners) { try { - listener() + listener(canonical) } catch (err) { log.warn("a binding-change listener threw", { err: String(err) }) } @@ -926,7 +934,7 @@ function forgetBinding( // milliseconds for as long as the state directory stays unwritable. A // read-only state directory now costs one poll interval of staleness // instead, which is the right trade. (Ralph, review of #1279.) - if (dropped) notifyBindingChanged() + if (dropped) notifyBindingChanged(directory) return true } @@ -1015,7 +1023,7 @@ async function lookupBinding( // sidebar is not always the caller — a `/workspace` open that adopts left // the tile to the next poll. Stamped as validated above, so the sidebar's // answering resolve trusts the row and does not come back here. - if (adoptedNow) notifyBindingChanged() + if (adoptedNow) notifyBindingChanged(directory) return { status: "bound", binding: adopted } } @@ -1082,13 +1090,28 @@ export async function currentScope(): Promise<{ tenant: string; apiUrl: string } return tenantKey() } +/** A digest of the full credential (URL, tenant and API key), or null when none resolves. + * The binding cache is scoped by tenant and URL only, so a same-tenant key switch needs this. */ +export async function accountDigest(): Promise { + const c = await AltimateApi.getCredentials().catch(() => null) + if (!c?.altimateApiKey || !c.altimateInstanceName || !c.altimateUrl) return null + return createHash("sha256").update(`${c.altimateUrl}|${c.altimateInstanceName}|${c.altimateApiKey}`).digest("hex") +} + export async function recordApprovedBinding( directory: string, binding: CachedBinding, - opts?: { awaitBackfill?: boolean }, -): Promise { + // `seed: false` warms the cache for a link the user has not accepted yet: a discovered + // link must not upload local memory before they choose Attach. `account` (from + // `accountDigest`) pins the write and the seed to the credential that confirmed the link. + opts?: { awaitBackfill?: boolean; seed?: boolean; account?: string }, +): Promise { const key = await tenantKey() - if (!key) return + if (!key) return null + if (opts?.account !== undefined && (await accountDigest()) !== opts.account) { + log.warn("the Altimate account changed before the link was recorded; not recording it") + return { status: "account-changed", sent: 0, pending: 0 } + } // An explicit link is the newest word on this project, so retire any memoized // "no binding here" from before it and count the row as server-validated — // the link is what created it. Without the first, revalidation reads the @@ -1145,7 +1168,7 @@ export async function recordApprovedBinding( // But the sidebar renders `datamateName`, so a rename is a visible change // with an unchanged identity. Checked separately for that reason. (cubic P2 // on #1279.) - if (bindingChanged || priorName !== binding.datamateName) notifyBindingChanged() + if (bindingChanged || priorName !== binding.datamateName) notifyBindingChanged(directory) // altimate_change start - seed the workspace with the memory this machine // already holds. Deliberately OUTSIDE the try above: a failed cache write @@ -1174,18 +1197,24 @@ export async function recordApprovedBinding( // Skip only when this exact binding has already been seeded successfully. A // warm after a failed or skipped seed must try again, or the blocks this // machine already holds never reach the workspace. - if (alreadySeeded) return + if (alreadySeeded) return { status: "already", sent: 0, pending: 0 } + if (opts?.seed === false) return null + if (opts?.account !== undefined && (await accountDigest()) !== opts.account) { + log.warn("the Altimate account changed before the memory seed; not seeding") + return { status: "account-changed", sent: 0, pending: 0 } + } const seeded = import("./memory-backfill") - .then((m) => m.backfillOnBind(canonicalizeKey(directory), binding)) - .then((ok) => { - if (ok) markSeeded(directory, binding) - return ok + .then((m) => m.seedOnBind(canonicalizeKey(directory), binding)) + .then((outcome) => { + if (outcome.status === "seeded") markSeeded(directory, binding) + return outcome }) - .catch((err) => { + .catch((err): SeedOutcome => { log.warn("could not start workspace memory backfill", { err: String(err) }) - return false + return { status: "incomplete", sent: 0, pending: 0 } }) - if (opts?.awaitBackfill) await seeded - else void seeded + if (opts?.awaitBackfill) return seeded + void seeded + return null // altimate_change end } diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 7c3176090..e7fe3bad8 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -18,6 +18,8 @@ import { AltimateApi } from "@/altimate/api/client" import { WorkspaceApi, ConflictError, + HIDDEN_BINDING_MESSAGE, + isHiddenBindingConflict, ForbiddenError, NotConfiguredError, NotFoundError, @@ -39,7 +41,8 @@ import { resolveWorkspaceWebUrl, type HandoffResult, } from "@/altimate/workspace/browser-handoff" -import { recordApprovedBinding } from "@/altimate/workspace/state" +import { accountDigest, recordApprovedBinding } from "@/altimate/workspace/state" +import type { SeedOutcome } from "@/altimate/workspace/memory-backfill" const CREATE_NEW_SENTINEL = "__create_new__" const SET_UP_IN_BROWSER_SENTINEL = "__browser_handoff__" @@ -360,6 +363,14 @@ async function runBrowserHandoff( projectName: string, directory: string, ): Promise { + // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. + // Unreadable credentials cannot link anyway, and must not leave the bind unguarded. + const linkAccount = await accountDigest() + if (linkAccount === null) { + prompts.log.error("Could not read your Altimate credentials, so nothing was linked. Check /connect and try again.") + process.exitCode = 1 + return + } const spin = prompts.spinner() spin.start("Waiting for browser approval (up to 15 min)...") const result: HandoffResult = await openWorkspaceBrowserHandoff({ identifier, projectName }) @@ -401,21 +412,23 @@ async function runBrowserHandoff( // when the caller passes a relative or symlinked ``-d`` path, so a // later readLocalBinding from the TUI sidebar can miss the binding. // (coderabbitai #1100 comment 3841173342.) - await recordApprovedBinding(identifier.projectPath ?? directory, { + const seed = 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(), - }, { awaitBackfill: true }) + }, { awaitBackfill: true, account: linkAccount }) bindSpin.stop(`Linked to "${stripControlChars(res.binding.datamate_name)}".`) - prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.") + prompts.log.info(seedMessage(seed)) const manageUrl = await manageUrlFor(res.binding.datamate_id) if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) prompts.outro("Done.") } catch (err) { bindSpin.stop("Link failed.", 1) - if (err instanceof ConflictError) { + if (isHiddenBindingConflict(err)) { + prompts.log.error(`${HIDDEN_BINDING_MESSAGE} Workspace "${projectName}" was created but is not linked.`) + } else if (err instanceof ConflictError) { const existingName = conflictExistingName(err.detail) prompts.log.error( `This project is already linked to "${existingName}". Workspace "${projectName}" was created but is not linked — re-run \`altimate-code link\` and pick a different action to switch, or delete the new workspace in the SaaS.`, @@ -487,6 +500,14 @@ export async function createThenBindOrRebind( directory: string, existing: ProjectBindingLookup | null, ): Promise { + // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. + // Unreadable credentials cannot link anyway, and must not leave the bind unguarded. + const linkAccount = await accountDigest() + if (linkAccount === null) { + prompts.log.error("Could not read your Altimate credentials, so nothing was linked. Check /connect and try again.") + process.exitCode = 1 + return + } const spin = prompts.spinner() spin.start(`Creating workspace "${name}"...`) // Discriminated on how the workspace was made, because the two creates return @@ -535,6 +556,8 @@ export async function createThenBindOrRebind( `The workspace could not be created: ${err.message}. Nothing was created, and this ` + `project is still linked to "${stripControlChars(existing.datamate.name)}".`, ) + } else if (isHiddenBindingConflict(err)) { + prompts.log.error(`${HIDDEN_BINDING_MESSAGE} Nothing was created.`) } else { const existingName = conflictExistingName(err.detail) prompts.log.error( @@ -601,14 +624,17 @@ export async function createThenBindOrRebind( // path-keyed row rebound through `/by-path` would be cached carrying a // `repo_remote` that is not on the server's row. (review, PR #1314) const serverBinding = created.via === "bound" ? created.binding : reboundBinding - await recordApprovedBinding(identifier.projectPath ?? directory, { + const seed = await recordApprovedBinding(identifier.projectPath ?? directory, { datamateId: created.datamate.id, datamateName: created.datamate.name, repoRemote: serverBinding?.repo_remote ?? identifier.repoRemote ?? null, projectPath: serverBinding?.project_path ?? identifier.projectPath ?? null, linkedAt: Date.now(), - }, { awaitBackfill: true }) - prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.") + }, { awaitBackfill: true, account: linkAccount }) + prompts.log.info(seedMessage(seed)) + // The quick create is private, and the server hides a private workspace's link from + // everyone else: a teammate who clones this repo is told it is unlinked. + prompts.log.warn(QUICK_WORKSPACE_PRIVATE_NOTE) // ``createAndBind`` hands back a manage_url; the unbound create does not, so // derive it from credentials exactly as the rest of this file does. Null on // BYOK / unresolvable deployments — then there is simply nothing to show. @@ -652,6 +678,14 @@ async function bindOrRebind( preCheckOk: boolean, directory: string, ): Promise { + // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. + // Unreadable credentials cannot link anyway, and must not leave the bind unguarded. + const linkAccount = await accountDigest() + if (linkAccount === null) { + prompts.log.error("Could not read your Altimate credentials, so nothing was linked. Check /connect and try again.") + process.exitCode = 1 + return + } const isRebind = existing !== null const spin = prompts.spinner() spin.start(isRebind ? `Re-linking to workspace...` : `Linking to workspace...`) @@ -671,7 +705,9 @@ async function bindOrRebind( try { res = await WorkspaceApi.bindExisting(targetDatamateId, identifier) } catch (err) { - if (err instanceof ConflictError && !preCheckOk) { + // A teammate's private workspace is not a pre-check race: rebinding it only fails + // again (forbidden), and would hide the explanation the outer handler gives. + if (err instanceof ConflictError && !preCheckOk && !isHiddenBindingConflict(err)) { // 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 @@ -727,22 +763,24 @@ async function bindOrRebind( } } // Prefer the canonicalized identifier over the raw --directory (Kilo cycle 6). - await recordApprovedBinding(identifier.projectPath ?? directory, { + const seed = 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(), - }, { awaitBackfill: true }) + }, { awaitBackfill: true, account: linkAccount }) const safeResName = stripControlChars(res.binding.datamate_name) spin.stop(isRebind ? `Re-linked to "${safeResName}".` : `Linked to "${safeResName}".`) - prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.") + prompts.log.info(seedMessage(seed)) const manageUrl = await manageUrlFor(res.binding.datamate_id) if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) prompts.outro("Done.") } catch (err) { spin.stop(isRebind ? `Re-link failed.` : `Link failed.`, 1) - if (err instanceof ConflictError) { + if (isHiddenBindingConflict(err)) { + prompts.log.error(HIDDEN_BINDING_MESSAGE) + } else if (err instanceof ConflictError) { const existingName = conflictExistingName(err.detail) prompts.log.error(`Already linked to "${existingName}". Re-run \`altimate-code link\` to switch.`) } else if (err instanceof PreconditionFailedError) { @@ -758,6 +796,32 @@ async function bindOrRebind( } } +export const QUICK_WORKSPACE_PRIVATE_NOTE = + "Only you can see this workspace. Share it from its page in the Altimate web app so teammates who clone this repo are attached to it too." + +/** What `link` says about this machine's saved memory after the bind. A seed that left + * blocks behind used to print the same line as one that stored everything. */ +export function seedMessage(seed: SeedOutcome | null): string { + if (seed?.status === "incomplete") + return seed.pending > 0 + ? `${seed.pending} saved memor${seed.pending === 1 ? "y" : "ies"} did not reach the workspace yet. Run /workspace → Sync in the TUI to retry.` + : "Saved memory could not be sent to the workspace yet. Run /workspace → Sync in the TUI to retry." + if (seed?.status === "seeded") + return seed.sent > 0 + ? `Sent ${seed.sent} saved memor${seed.sent === 1 ? "y" : "ies"} to the workspace.` + : "Saved memory is in sync with the workspace." + // `already` means the one-time bind seed ran before, not that every block is synced now. + if (seed?.status === "already") + return "This machine's saved memory was sent when this workspace was first linked. To resend anything missed since, run /workspace → Sync in the TUI." + if (seed?.status === "off") return "Workspace memory is off, so saved memory stays on this machine." + if (seed?.status === "account-changed") + return "Your Altimate account changed during linking, so saved memory was not sent. Run /workspace → Sync in the TUI to retry." + if (seed?.status === "local-off") + return "Memory sync is turned off on this machine (ALTIMATE_DISABLE_MEMORY or OPENCODE_DISABLE_MEMORY), so saved memory stays here." + // null: the seed could not run here (no resolvable credentials), which is not "off". + return "Saved memory could not be checked against the workspace. Run /workspace → Sync in the TUI to retry." +} + /** 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; diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 7bcbf27bc..6f22b9326 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -6,6 +6,7 @@ import { Glob as BunGlob } from "bun" import { Skill } from "../../skill" import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" +import { pilotOffCommand } from "./workspace-pilot" import { Instance } from "../../project/instance" import { Global } from "@/global" import { detectToolReferences, skillSource, isToolOnPath } from "./skill-helpers" @@ -816,6 +817,7 @@ export const SkillCommand = cmd({ // Gated like `link` (src/index.ts): a user outside the pilot would be // told to run a `link` command that is not registered for them. .command(Flag.ALTIMATE_WORKSPACE ? [SkillPublishCommand] : []) + .command(Flag.ALTIMATE_WORKSPACE ? [] : [pilotOffCommand("publish [name]")]) .command(SkillShowCommand) .command(SkillInstallCommand) .command(SkillRemoveCommand) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index af00b9e6b..a48344cc8 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -16,6 +16,9 @@ import type { EventSource } from "@opencode-ai/tui/context/sdk" import { writeHeapSnapshot } from "v8" import { validateSession } from "../tui/validate-session" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" +// altimate_change start — gate the --workspace option on the workspace pilot flag +import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" +// altimate_change end // altimate_change start — onboarding telemetry: main-thread flush on the TUI exit path import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" @@ -118,6 +121,8 @@ export const TuiThreadCommand = cmd({ .option("workspace", { type: "string", describe: "attach this session to the workspace linked in this directory, by name", + // Inert outside the pilot (launch-resolve returns early), so not advertised there. + hidden: !CoreFlag.ALTIMATE_WORKSPACE, }) // altimate_change end .option("agent", { diff --git a/packages/opencode/src/cli/cmd/workspace-pilot.ts b/packages/opencode/src/cli/cmd/workspace-pilot.ts new file mode 100644 index 000000000..c298c1e8b --- /dev/null +++ b/packages/opencode/src/cli/cmd/workspace-pilot.ts @@ -0,0 +1,21 @@ +// altimate_change - new file +import { cmd } from "./cmd" +import { UI } from "../ui" + +/** What a pilot command says when `ALTIMATE_WORKSPACE` is off. Registered hidden in its + * place: unregistered, `altimate-code link` fell through to the default command, which read + * "link" as a project directory and failed with "Failed to change directory to …/link". */ +export const WORKSPACE_PILOT_OFF_MESSAGE = + "Workspaces are a pilot feature and are off. Set ALTIMATE_WORKSPACE=1 to use this command." + +export function pilotOffCommand(command: string) { + return cmd({ + command, + describe: false, + builder: (yargs) => yargs.strict(false), + handler: () => { + UI.error(WORKSPACE_PILOT_OFF_MESSAGE) + process.exitCode = 1 + }, + }) +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 810afef04..77b419f86 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -47,6 +47,7 @@ import { CheckCommand } from "./cli/cmd/check" // altimate_change end // altimate_change start — link: workspace-binding subcommand import { LinkCommand } from "./cli/cmd/link" +import { pilotOffCommand } from "./cli/cmd/workspace-pilot" // altimate_change end import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" @@ -209,12 +210,11 @@ let cli = yargs(args) // 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) -} +// so the command 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). +// Off, a hidden stub takes its place and explains how to opt in. +if (Flag.ALTIMATE_WORKSPACE) cli = cli.command(LinkCommand) +else cli = cli.command(pilotOffCommand("link")) // altimate_change end // altimate_change start — workspace-serve: register dev-only workspace serve command diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 16ebc2942..8ad6793c9 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -33,6 +33,8 @@ import { inertWorkspaceName } from "@/altimate/workspace/workspace-name" import { createSignal, onCleanup, onMount } from "solid-js" import { ConflictError, + HIDDEN_BINDING_MESSAGE, + isHiddenBindingConflict, ForbiddenError, NotFoundError, PreconditionFailedError, @@ -54,7 +56,12 @@ import { projectNameFromRemote, resolveProjectIdentifier, } from "@/altimate/workspace/detect" -import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" +import { + accountDigest, + readLocalBinding, + recordApprovedBinding, + resolvePinnedBindingForRouting, +} from "@/altimate/workspace/state" import { describeOffer, installCommand, @@ -419,7 +426,9 @@ async function runBrowserHandoff( if (err instanceof ConflictError) { api.ui.toast({ variant: "warning", - message: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Run \`altimate-code link\` to change.`, + message: isHiddenBindingConflict(err) + ? HIDDEN_BINDING_MESSAGE + : `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Run \`altimate-code link\` to change.`, }) } else if (err instanceof NotFoundError) { api.ui.toast({ @@ -700,6 +709,9 @@ interface AlreadyLinkedProps { * the CURRENT project, not the binding's origin. Threaded into PickerDialog * so a re-link picks the correct endpoint. (M3) */ matchedBy: MatchedIdentifier + // altimate_change start — the memory seed deferred by the discovery warm-up + onAttach?: () => Promise + // altimate_change end } function AlreadyLinkedDialog(props: AlreadyLinkedProps) { @@ -770,6 +782,10 @@ function AlreadyLinkedDialog(props: AlreadyLinkedProps) { onSelect={(option) => { if (option.value === "attach" || option.value === "skip") { props.api.ui.dialog.clear() + // altimate_change start — seed this machine's memory only on an explicit Attach + if (option.value === "attach" && props.onAttach) + props.onAttach().catch((err) => reportFlowFailure(props.api, err)) + // altimate_change end return } if (option.value === "open") { @@ -889,7 +905,9 @@ function PickerDialog(props: PickerProps) { // 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.` + msg = isHiddenBindingConflict(err) + ? HIDDEN_BINDING_MESSAGE + : `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) { @@ -1084,7 +1102,9 @@ async function bindOrRebindInline( } catch (err) { let msg: string if (err instanceof ConflictError) { - msg = `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}".` + msg = isHiddenBindingConflict(err) + ? HIDDEN_BINDING_MESSAGE + : `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) { @@ -1138,6 +1158,9 @@ async function runOnDemandPicker(api: TuiPluginApi, directory: string): Promise< } async function runFlow(api: TuiPluginApi, directory: string): Promise { + // Before any lookup: Attach must run as the account the dialog's binding was found under, and + // a switch while the pre-check is in flight would otherwise go unnoticed. + const flowAccount = await accountDigest() const identifier = resolveProjectIdentifier(directory) // Resolve latch scope ONCE — passed to isSkipActive here + threaded into // OfferDialog so its sync onSelect can call recordSkip without awaiting. @@ -1173,14 +1196,21 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { } if (serverBinding) { - // Warm the local cache so an offline follow-up render is consistent. - await recordApprovedBinding(directory, { + const discovered = { datamateId: serverBinding.datamate.id, datamateName: serverBinding.datamate.name, repoRemote: serverBinding.binding.repo_remote, projectPath: serverBinding.binding.project_path, linkedAt: Date.now(), - }) + } + // Warm the local cache so an offline follow-up render is consistent. + // altimate_change start — no memory seed until the user picks Attach: this link may be a + // teammate's, and opening the TUI must not upload this machine's memory to it. + // Pinned to the account the pre-check ran as: a switch mid-lookup must not write this + // binding (or start its skill sync) under the other account. With no account known at the + // start there is nothing to pin to, so the warm-up is skipped rather than left unguarded. + if (flowAccount !== null) await recordApprovedBinding(directory, discovered, { seed: false, account: flowAccount }) + // altimate_change end // 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 @@ -1206,6 +1236,32 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { hasDrift={hasDrift} driftedWas={hasDrift ? boundIdent : undefined} manageUrl={manageUrl} + onAttach={async () => { + // Re-confirm before seeding: the account or the project's link can change between the + // pre-check and the click, and the seed must go to the link that still stands. + const who = await accountDigest() + const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) + if (who !== null && who === flowAccount && live?.datamate.id === discovered.datamateId) { + const out = await recordApprovedBinding(directory, discovered, { account: who }) + if (out?.status === "account-changed") + api.ui.toast({ + variant: "warning", + message: "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again.", + duration: 8_000, + }) + return + } + api.ui.toast({ + variant: "warning", + message: + who !== flowAccount + ? "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again." + : live === undefined + ? "Could not confirm the link with the workspace service, so saved memory was not sent. Try Attach again once it is reachable." + : "This project is no longer linked to that workspace, so nothing was attached. Run /workspace to see its current link.", + duration: 8_000, + }) + }} /> )) return @@ -1247,6 +1303,42 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { driftedWas={hasDrift ? cachedIdent : undefined} manageUrl={manageUrl} unverified + // Seed only once the server confirms the cached link still stands: it may have been + // unlinked or rebound while the pre-check could not reach the service. + onAttach={async () => { + // The seed must run as the account that confirmed the link: the cache is scoped by + // tenant and URL only, so a key switch mid-dialog could otherwise confirm the id under + // one user and upload under another. + const who = await accountDigest() + const live = await WorkspaceApi.getBindingForProject(identifier).catch(() => undefined) + if (who === null || who !== flowAccount || (await accountDigest()) !== who) { + api.ui.toast({ + variant: "warning", + message: "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again.", + duration: 8_000, + }) + return + } + // Attach is the user's approval: the row is no longer merely adopted from the server. + if (live?.datamate.id === local.datamateId) { + const out = await recordApprovedBinding(directory, { ...local, adopted: false }, { account: who }) + if (out?.status === "account-changed") + api.ui.toast({ + variant: "warning", + message: "Your Altimate account changed while attaching, so saved memory was not sent. Try Attach again.", + duration: 8_000, + }) + return + } + api.ui.toast({ + variant: "warning", + message: + live === undefined + ? "Could not confirm the link with the workspace service, so saved memory was not sent. Try Attach again once it is reachable." + : "This project is no longer linked to that workspace, so nothing was attached. Run /workspace to see its current link.", + duration: 8_000, + }) + }} /> )) return @@ -1777,7 +1869,8 @@ export { syncMessage as syncMessageForTests } * project's local memory" as the text. A failed read is a warning; the other * gates are states, not outcomes, and are told as information. */ function syncVariant(result: Manage.SyncReport): "info" | "success" | "warning" { - if (result.gated) return result.gatedBecause === "read-failed" ? "warning" : "info" + if (result.gated) + return result.gatedBecause === "read-failed" || result.gatedBecause === "setting-unavailable" ? "warning" : "info" return result.failed > 0 || result.declined > 0 || result.deferred > 0 ? "warning" : "success" } export { syncVariant as syncVariantForTests } @@ -1793,6 +1886,8 @@ function syncMessage(result: Manage.SyncReport): string { return "Nothing to sync — the pinned workspace could not be confirmed for this project." case "flag-off": return "Nothing to sync — workspace memory is not enabled in this build." + case "setting-unavailable": + return "Could not check the workspace's memory setting, so nothing was synced. Try Sync again shortly." default: return "Nothing to sync — workspace memory is off for this project." } @@ -1816,6 +1911,25 @@ function syncMessage(result: Manage.SyncReport): string { async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise { const report = await Manage.status(directory) const linked = report.binding !== null + // Resolved before render, like AlreadyLinkedDialog's: an option appearing after + // paint would shift the row under the user's cursor. + // Under an IDE pin, skills, memory and routing follow the pinned workspace, so Open must too. + // It only returns early (null) when there is no pin, so a throw means a pin exists but could + // not be checked: keep it unresolved rather than falling through to the project's link. + // Bounded: a cold pin check is a live request, and the menu must not hang on a slow service. + const PIN_CHECK_MS = 1_500 + const pinned = await Promise.race([ + resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const })), + new Promise<{ status: "unknown" }>((done) => setTimeout(() => done({ status: "unknown" }), PIN_CHECK_MS).unref?.()), + ]) + // A pin that cannot be honoured fails closed everywhere else; Open must not fall through to + // the project's own link either. + const openId = pinned + ? pinned.status === "bound" + ? pinned.binding.datamateId + : undefined + : report.binding?.datamateId + const manageUrl = openId !== undefined ? await resolveManageUrl(openId) : null api.ui.dialog.replace(() => ( { if (option.value === "unlink") { confirmUnlink(api, directory, report.binding?.datamateName ?? "this workspace") return } + if (option.value === "link") { + // Closed first so repeated Enter while the pre-check is slow cannot start more pickers. + api.ui.dialog.clear() + runOnDemandPicker(api, directory).catch((err) => reportFlowFailure(api, err)) + return + } + if (option.value === "open") { + if (manageUrl) openManageUrl(api, manageUrl) + return + } api.ui.dialog.clear() if (option.value === "refresh") { Manage.refresh(directory) @@ -1935,7 +2077,7 @@ const tui: TuiPlugin = async (api) => { { name: "altimate.workspace.manage", title: "Workspace", - desc: "Refresh, sync or unlink this project's workspace", + desc: "Link, refresh, sync or unlink this project's workspace", category: "Altimate", namespace: "palette", slashName: "workspace", diff --git a/packages/opencode/test/altimate/plugin/workspace.test.ts b/packages/opencode/test/altimate/plugin/workspace.test.ts index 342d7c824..121e8f09e 100644 --- a/packages/opencode/test/altimate/plugin/workspace.test.ts +++ b/packages/opencode/test/altimate/plugin/workspace.test.ts @@ -301,6 +301,67 @@ describe("workspace binding cache", () => { } }) + test("a discovered link warmed with seed: false uploads no memory until Attach", async () => { + // The post-scan pre-check warms the cache for a link it found on the server + // (often a teammate's) before the user has chosen Attach or Skip. Seeding + // there uploaded this machine's memory to that workspace on TUI open. + const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + process.env.ALTIMATE_WORKSPACE = "1" + const proj = path.join(SANDBOX, "discovered-proj") + mkdirSync(path.join(proj, ".altimate-code", "memory"), { recursive: true }) + const now = new Date().toISOString() + writeFileSync( + path.join(proj, ".altimate-code", "memory", "mine.md"), + ["---", "id: mine", "scope: project", `created: ${now}`, `updated: ${now}`, "---", "", "A fact.", ""].join("\n"), + ) + const binding = { datamateId: 11, datamateName: "Team", repoRemote: null, projectPath: proj, linkedAt: 1 } + let memoryWrites = 0 + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input?: unknown, init?: { method?: string }) => { + const url = String(input) + if (url.includes("/datamates/memory/") && !url.includes("/list") && init?.method === "POST") { + memoryWrites++ + return new Response(JSON.stringify({ result: { results: [{ id: `m-${memoryWrites}`, event: "ADD" }] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + } + if (url.includes("/datamates/memory/list")) + return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } }) + if (url.includes("/datamate-project-bindings/by-")) + return new Response( + JSON.stringify({ binding: { datamate_id: 11, datamate_name: "Team", repo_remote: null, project_path: proj } }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + return new Response(JSON.stringify({ datamates: [{ id: 11, name: "Team", memory_enabled: true }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) as typeof fetch + try { + await recordApprovedBinding(proj, binding, { awaitBackfill: true, seed: false }) + expect(memoryWrites).toBe(0) + // Attach re-records the same binding without the opt-out; the seed runs then. + await recordApprovedBinding(proj, binding, { awaitBackfill: true }) + expect(memoryWrites).toBeGreaterThan(0) + } finally { + globalThis.fetch = originalFetch + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG + } + }) + + test("an account mismatch refuses to record the link or seed", async () => { + // Attach pins the credential that confirmed the link; a switch before the write must not + // record the row, or upload this machine's memory, under another account. + const proj = path.join(SANDBOX, "account-pinned") + mkdirSync(proj, { recursive: true }) + const binding = { datamateId: 12, datamateName: "Pinned", repoRemote: null, projectPath: proj, linkedAt: 1 } + const out = await recordApprovedBinding(proj, binding, { awaitBackfill: true, account: "not-the-current-account" }) + expect(out?.status).toBe("account-changed") + expect(await readLocalBinding(proj)).toBeNull() + }) + test("a warm bind still syncs skills even though the memory seed is skipped", async () => { // The ``alreadySeeded`` marker is memory's one-shot gate. Skills have a // different lifecycle — the workspace's bundles can change at any time — so diff --git a/packages/opencode/test/altimate/workspace/create-then-rebind.test.ts b/packages/opencode/test/altimate/workspace/create-then-rebind.test.ts index e837f639f..5a12b3311 100644 --- a/packages/opencode/test/altimate/workspace/create-then-rebind.test.ts +++ b/packages/opencode/test/altimate/workspace/create-then-rebind.test.ts @@ -91,7 +91,7 @@ beforeEach(() => { }) afterEach(() => { globalThis.fetch = ORIGINAL_FETCH - process.exitCode = undefined + process.exitCode = 0 // Bun ignores `= undefined`; a leaked 1 fails later files }) afterAll(() => { if (ORIGINAL_TEST_HOME === undefined) delete process.env.OPENCODE_TEST_HOME diff --git a/packages/opencode/test/altimate/workspace/identity-section.test.ts b/packages/opencode/test/altimate/workspace/identity-section.test.ts index 4e77e3d30..d88ce149a 100644 --- a/packages/opencode/test/altimate/workspace/identity-section.test.ts +++ b/packages/opencode/test/altimate/workspace/identity-section.test.ts @@ -758,7 +758,7 @@ describe("systemSection", () => { const out = await inProject(systemSection) expect(out).toContain("This session is pinned by the IDE extension to Altimate Workspace id 237") expect(out).toContain('is "pinned-ws-server"') - expect(out).toContain("warehouse tool routing still follows the project's own link") + expect(out).toContain("and so does warehouse tool routing unless integrations are set to local") expect(out).not.toContain("id 12") expect(out).not.toContain("This project is linked to") } finally { @@ -805,6 +805,42 @@ describe("systemSection", () => { } }) + test("no configured account says so and points at /connect", async () => { + const api = AltimateApi as unknown as { isConfigured: () => Promise } + const original = api.isConfigured + api.isConfigured = async () => false + try { + const out = await inProject(systemSection) + expect(out).toContain("No Altimate account is connected") + expect(out).toContain("/connect") + } finally { + api.isConfigured = original + } + }) + + test("a malformed pin does not earn the IDE-selection advice", async () => { + const api = AltimateApi as unknown as { isConfigured: () => Promise } + const original = api.isConfigured + api.isConfigured = async () => false + const keys = ["ALTIMATE_CODE_SERVE", "ALTIMATE_PINNED_WORKSPACE_ID", "ALTIMATE_PINNED_WORKSPACE_NAME", "ALTIMATE_PINNED_WORKSPACE_ROOT"] + const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])) + process.env.ALTIMATE_CODE_SERVE = "1" + process.env.ALTIMATE_PINNED_WORKSPACE_ID = "not-a-number" + delete process.env.ALTIMATE_PINNED_WORKSPACE_NAME + delete process.env.ALTIMATE_PINNED_WORKSPACE_ROOT + try { + const out = await inProject(systemSection) + expect(out).toContain("No Altimate account is connected") + expect(out).not.toContain("workspace selected in the IDE extension applies") + } finally { + api.isConfigured = original + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + test("a credentials file with an empty key renders unknown without invoking the resolver", async () => { // `accountScope` refuses the empty key, and nothing can verify a link without one — // so the resolver (whose own credential read still names a tenant and host, and @@ -823,7 +859,9 @@ describe("systemSection", () => { const started = Date.now() const out = await inProject(systemSection) expect(Date.now() - started).toBeLessThan(500) + // Configured but incomplete is not "no account": the could-not-verify copy applies. expect(out).toContain("could not be verified") + expect(out).not.toContain("No Altimate account is connected") expect(resolves).toBe(0) } finally { identityInternals.resolveBindingOutcome = realResolve diff --git a/packages/opencode/test/altimate/workspace/identity.test.ts b/packages/opencode/test/altimate/workspace/identity.test.ts index 4d73db045..68b66cbab 100644 --- a/packages/opencode/test/altimate/workspace/identity.test.ts +++ b/packages/opencode/test/altimate/workspace/identity.test.ts @@ -182,7 +182,30 @@ describe("unknown — link status could not be verified this turn", () => { const out = render({ status: "unknown" }) expect(out).not.toMatch(/temporarily unavailable|try again shortly/) expect(out).toContain("if this persists across turns") - expect(out).toContain("IDE extension") + expect(out).toContain("check that the Altimate service is reachable") + }) + + test("points at the IDE extension only when a pin governs the process", () => { + // A plain CLI run has no extension; sending the user to one was a dead end. + expect(render({ status: "unknown" })).not.toContain("IDE extension") + expect(render({ status: "unknown" }, undefined, { pinned: true })).toContain( + "check the workspace selected in the IDE extension", + ) + }) + + test("with no account under an IDE pin, points at the extension's selection, not at link", () => { + const out = render({ status: "unknown" }, undefined, { noAccount: true, pinned: true }) + expect(out).toContain("/connect") + expect(out).toContain("workspace selected in the IDE extension applies") + expect(out).not.toContain("altimate-code link") + }) + + test("says no account is connected rather than 'could not be verified' when there is none", () => { + const out = render({ status: "unknown" }, undefined, { noAccount: true }) + expect(out).toContain("No Altimate account is connected") + expect(out).toContain("/connect") + expect(out).toContain("do not say none is linked") + expect(out).not.toContain("could not be verified") }) test("asserts neither a specific workspace nor 'none linked'", () => { diff --git a/packages/opencode/test/altimate/workspace/link-seed-message.test.ts b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts new file mode 100644 index 000000000..ac8137df2 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/link-seed-message.test.ts @@ -0,0 +1,37 @@ +// altimate_change - new file +import { expect, test } from "bun:test" +import { seedMessage } from "../../../src/cli/cmd/link" + +test("link names the retry when a memory seed left blocks behind", () => { + expect(seedMessage({ status: "incomplete", sent: 1, pending: 3 })).toContain("3 saved memories did not reach") + expect(seedMessage({ status: "incomplete", sent: 0, pending: 1 })).toContain("1 saved memory did not reach") + expect(seedMessage({ status: "incomplete", sent: 0, pending: 0 })).toContain("Sync") +}) + +test("link reports a completed seed and memory that is off without a retry hint", () => { + expect(seedMessage({ status: "seeded", sent: 2, pending: 0 })).toBe("Sent 2 saved memories to the workspace.") + expect(seedMessage({ status: "seeded", sent: 0, pending: 0 })).toBe("Saved memory is in sync with the workspace.") + expect(seedMessage({ status: "off", sent: 0, pending: 0 })).toBe( + "Workspace memory is off, so saved memory stays on this machine.", + ) + // Does not claim everything is synced now: points at Sync for anything missed since. + expect(seedMessage({ status: "already", sent: 0, pending: 0 })).toContain("when this workspace was first linked") + expect(seedMessage({ status: "already", sent: 0, pending: 0 })).toContain("Sync") + expect(seedMessage({ status: "local-off", sent: 0, pending: 0 })).toContain("turned off on this machine") + // A seed that could not run is not "memory is off". + expect(seedMessage(null)).not.toContain("is off") + expect(seedMessage(null)).toContain("Sync") +}) + +test("a 409 that withholds the workspace name is explained as a teammate's private workspace, not a race", async () => { + const { ConflictError, isHiddenBindingConflict, HIDDEN_BINDING_MESSAGE } = await import( + "../../../src/altimate/workspace/api-client" + ) + expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_id: 7, existing_datamate_name: null } as any))).toBe(true) + expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_id: 7 } as any))).toBe(true) + expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_id: 7, existing_datamate_name: "team" } as any))).toBe(false) + // A 409 that is not a binding conflict (no workspace id) keeps its own message. + expect(isHiddenBindingConflict(new ConflictError({ message: "conflict" } as any))).toBe(false) + expect(isHiddenBindingConflict(new Error("x"))).toBe(false) + expect(HIDDEN_BINDING_MESSAGE).toContain("share it with you") +}) diff --git a/packages/opencode/test/altimate/workspace/manage.test.ts b/packages/opencode/test/altimate/workspace/manage.test.ts index 49eb74e41..cb5c1131f 100644 --- a/packages/opencode/test/altimate/workspace/manage.test.ts +++ b/packages/opencode/test/altimate/workspace/manage.test.ts @@ -817,6 +817,25 @@ describe("status and the sweep must agree", () => { expect(result.gated).toBe(true) expect(result.sent).toBe(0) }) + + test("a failed memory-setting lookup is reported as unavailable, not as memory off", async () => { + // Both gate the sweep, but only a confirmed toggle means the workspace has memory off; + // telling the user so during an outage sends them to a setting that is fine. + await bind(projectDir) + const originalFetch3 = globalThis.fetch + globalThis.fetch = (async (input: any, init?: any) => { + const url = typeof input === "string" ? input : input.url + if (url.includes("/datamates/") && !url.includes("/memory")) return new Response("{}", { status: 503 }) + return originalFetch3(input, init) + }) as typeof fetch + try { + const result = await sync(projectDir) + expect(result.gated).toBe(true) + expect(result.gatedBecause).toBe("setting-unavailable") + } finally { + globalThis.fetch = originalFetch3 + } + }) }) describe("what /workspace status may cost and claim (review round 2)", () => { diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index be9ee7afe..008151e87 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -47,6 +47,7 @@ const { mirrorBlock, flushPendingMirrors, overlayBlocks, + refresh, resetOverlay, syncInternals, toBlock, @@ -1242,6 +1243,55 @@ describe("truncated reads", () => { })) expect(await backfillOnBind(dir, BINDING as any)).toBe(false) }) + + test("seedOnBind tells a seed that left blocks behind from one that never ran", async () => { + // `link` used to print one line whatever happened; it now reports these apart. + const { seedOnBind } = await import("../../../src/altimate/workspace/memory-backfill") + const dir = mkdtempSync(path.join(SANDBOX, "seed-outcome-")) + mkdirSync(path.join(dir, ".altimate-code", "memory"), { recursive: true }) + writeFileSync( + path.join(dir, ".altimate-code", "memory", "one.md"), + "---\nid: one\nscope: project\ncreated: 2026-09-01T00:00:00Z\nupdated: 2026-09-01T00:00:00Z\n---\n\nA block.\n", + ) + listResponse = Array.from({ length: 200 }, (_, i) => ({ + id: `r${i}`, + memory: "x", + metadata: { source: MIRROR_SOURCE, block_id: `other/${i}`, block_scope: "global" }, + })) + const incomplete = await seedOnBind(dir, BINDING as any) + expect(incomplete.status).toBe("incomplete") + expect(incomplete.pending).toBeGreaterThan(0) + + resetOverlay() + listResponse = [] + workspaces = [{ id: 42, name: "acme", memory_enabled: false }] + expect((await seedOnBind(dir, BINDING as any)).status).toBe("off") + + // A failed enablement lookup gates the sweep too, but is not "memory is off". + resetOverlay() + workspaces = [] + const blip = globalThis.fetch + globalThis.fetch = (async (input: any, init?: any) => + String(input).includes("/datamates/") && !String(input).includes("/memory") + ? new Response("{}", { status: 503 }) + : blip(input, init)) as typeof fetch + const failed = await seedOnBind(dir, BINDING as any) + globalThis.fetch = blip + expect(failed.status).toBe("incomplete") + + // A stale "disabled" memo beside a failed fresh lookup is still unknown, not off. + resetOverlay() + workspaces = [{ id: 42, name: "acme", memory_enabled: false }] + expect((await seedOnBind(dir, BINDING as any)).status).toBe("off") // memo now says disabled + const blip2 = globalThis.fetch + globalThis.fetch = (async (input: any, init?: any) => + String(input).includes("/datamates/") && !String(input).includes("/memory") + ? new Response("{}", { status: 503 }) + : blip2(input, init)) as typeof fetch + const stale = await seedOnBind(dir, BINDING as any) + globalThis.fetch = blip2 + expect(stale.status).toBe("incomplete") + }) }) describe("resetOverlay", () => { @@ -1257,6 +1307,218 @@ describe("resetOverlay", () => { }) }) +describe("binding changes", () => { + const scoped = (id: string, datamate: number) => ({ + id, + memory: id, + metadata: { source: MIRROR_SOURCE, block_id: id, block_scope: "project", datamate_id: String(datamate) }, + }) + + test("a relink makes the next turn load the new workspace's memory", async () => { + // `hydrate` loads once per session. Relinking A -> B in an open session + // otherwise kept injecting A's memory until a manual Refresh or a restart. + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [scoped("from-a", 42), scoped("from-b", 43)] + await hydrate(SES) + expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["from-a"]) + + const dir = mkdtempSync(path.join(SANDBOX, "relink-")) + const b = { ...BINDING, datamateId: 43, datamateName: "beta", projectPath: dir, linkedAt: 2 } + workspaces = [...workspaces, { id: 43, name: "beta", memory_enabled: true }] + syncInternals.resolveBinding = async () => b as any + await recordApprovedBinding(dir, b) + await hydrate(SES) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["from-b"]) + }) + + test("a binding discovered by the load itself does not discard that load", async () => { + // On a fresh clone the first load adopts the server binding, which notifies a + // change. Dropping the in-flight load there left the first turn with no memory. + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [scoped("first", 42)] + const dir = mkdtempSync(path.join(SANDBOX, "adopt-")) + let adopted = false + syncInternals.resolveBinding = async () => { + // The first lookup adopts (and notifies); later ones read the cache, as in production. + if (!adopted) { + adopted = true + await recordApprovedBinding(dir, { ...BINDING, projectPath: dir, linkedAt: 3 }, { seed: false }) + } + return BINDING as any + } + await hydrate(SES) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["first"]) + }) +}) + +describe("refresh racing a relink", () => { + test("a refresh that overlaps a relink publishes nothing and the next turn loads the new binding", async () => { + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, + ] + await hydrate(SES) + let release: (() => void) | undefined + const gate = new Promise((r) => (release = r)) + const inner = globalThis.fetch + let entered: (() => void) | undefined + const reachedList = new Promise((r) => (entered = r)) + globalThis.fetch = (async (input: any, init?: any) => { + if (String(input).includes("/datamates/memory/list")) { + entered?.() + await gate + } + return inner(input, init) + }) as typeof fetch + const pending = refresh(SES) + await reachedList + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + const dir = mkdtempSync(path.join(SANDBOX, "race-")) + await recordApprovedBinding(dir, { ...BINDING, datamateId: 44, projectPath: dir, linkedAt: 4 }, { seed: false }) + release?.() + const result = await pending + // Superseded: neither its read nor the prior overlay belongs to the new binding. + expect(result.ok).toBe(false) + expect(overlayBlocks(SES)).toEqual([]) + globalThis.fetch = inner + listResponse = [ + { id: "b", memory: "beta", metadata: { source: MIRROR_SOURCE, block_id: "from-b", block_scope: "global" } }, + ] + await hydrate(SES) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["from-b"]) + }) +}) + +describe("overlay invalidation", () => { + test("a relink hides the previous workspace's memory at once, before the next hydrate", async () => { + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, + ] + await hydrate(SES) + expect(overlayBlocks(SES).length).toBe(1) + const dir = mkdtempSync(path.join(SANDBOX, "hide-")) + await recordApprovedBinding(dir, { ...BINDING, datamateId: 45, projectPath: dir, linkedAt: 5 }, { seed: false }) + expect(overlayBlocks(SES)).toEqual([]) + }) + + test("an unlink reset during a refresh is not undone by the refresh", async () => { + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, + ] + await hydrate(SES) + let release: (() => void) | undefined + const gate = new Promise((r) => (release = r)) + const inner = globalThis.fetch + let entered: (() => void) | undefined + const reachedList = new Promise((r) => (entered = r)) + globalThis.fetch = (async (input: any, init?: any) => { + if (String(input).includes("/datamates/memory/list")) { + entered?.() + await gate + } + return inner(input, init) + }) as typeof fetch + const pending = refresh(SES) + await reachedList + resetOverlay() // what Unlink does + release?.() + await pending + globalThis.fetch = inner + expect(overlayBlocks(SES)).toEqual([]) + }) +}) + +describe("epoch bracketing and scope", () => { + test("a relink that lands while the binding lookup is pending does not stamp the old binding current", async () => { + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "from-a", block_scope: "global" } }, + ] + const dir = mkdtempSync(path.join(SANDBOX, "midlookup-")) + let calls = 0 + syncInternals.resolveBinding = async () => { + calls++ + // First lookup returns A, but the relink to B lands before it returns. + if (calls === 1) { + await recordApprovedBinding(dir, { ...BINDING, datamateId: 46, projectPath: dir, linkedAt: 6 }, { seed: false }) + return BINDING as any + } + return { ...BINDING, datamateId: 46 } as any + } + workspaces = [...workspaces, { id: 46, name: "b", memory_enabled: true }] + await hydrate(SES) + // The retried lookup saw B; A's global record still belongs everywhere, but the load + // was stamped with B's epoch only after B resolved. + expect(calls).toBeGreaterThan(1) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["from-a"]) + }) + + test("linking another project does not hide this project's memory", async () => { + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + listResponse = [ + { id: "a", memory: "alpha", metadata: { source: MIRROR_SOURCE, block_id: "mine", block_scope: "global" } }, + ] + const mine = mkdtempSync(path.join(SANDBOX, "mine-")) + const other = mkdtempSync(path.join(SANDBOX, "other-")) + await refresh(SES, mine) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["mine"]) + await recordApprovedBinding(other, { ...BINDING, datamateId: 47, projectPath: other, linkedAt: 7 }, { seed: false }) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["mine"]) + // A change to this project's own binding still hides it. + await recordApprovedBinding(mine, { ...BINDING, datamateId: 48, projectPath: mine, linkedAt: 8 }, { seed: false }) + expect(overlayBlocks(SES)).toEqual([]) + }) +}) + +describe("hydration errors", () => { + test("a failed load is not retried on every turn", async () => { + listFails = true + await hydrate(SES) + await hydrate(SES) + await hydrate(SES) + expect(callsTo("/datamates/memory/list").length).toBe(1) + }) +}) + +describe("superseded failures", () => { + test("a failed load for the old binding does not mark the new binding loaded", async () => { + const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") + const scoped = (id: string, datamate: number) => ({ + id, + memory: id, + metadata: { source: MIRROR_SOURCE, block_id: id, block_scope: "project", datamate_id: String(datamate) }, + }) + const b = { ...BINDING, datamateId: 49 } + workspaces = [...workspaces, { id: 49, name: "b", memory_enabled: true }] + let release: (() => void) | undefined + const gate = new Promise((r) => (release = r)) + let entered: (() => void) | undefined + const reachedList = new Promise((r) => (entered = r)) + const inner = globalThis.fetch + let failList = true + globalThis.fetch = (async (input: any, init?: any) => { + if (String(input).includes("/datamates/memory/list") && failList) { + entered?.() // A's binding has resolved and its list request is now in flight + await gate + return new Response(JSON.stringify({ detail: "boom" }), { status: 500 }) + } + return inner(input, init) + }) as typeof fetch + const first = hydrate(SES) + await reachedList + const dir = mkdtempSync(path.join(SANDBOX, "supersede-")) + syncInternals.resolveBinding = async () => b as any + await recordApprovedBinding(dir, { ...b, projectPath: dir, linkedAt: 9 }, { seed: false }) + release?.() + await first + failList = false + globalThis.fetch = inner + listResponse = [scoped("from-a", 42), scoped("from-b", 49)] + await hydrate(SES) + expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["from-b"]) + }) +}) + describe("session isolation and turn behaviour", () => { test("a session hydrates once, however many turns it takes", async () => { // The caller's enclosing block runs on EVERY user turn, not once per diff --git a/packages/opencode/test/altimate/workspace/skill-sync.test.ts b/packages/opencode/test/altimate/workspace/skill-sync.test.ts index dff7aa783..5fdd14cb1 100644 --- a/packages/opencode/test/altimate/workspace/skill-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/skill-sync.test.ts @@ -429,6 +429,115 @@ describe("workspace skill sync", () => { expect(cached.bindings[realpathSync(project)].datamateId).toBe(1) }) + test("an unresolvable IDE pin takes another workspace's snapshot out of service", async () => { + // The project is linked to workspace 1 and has its snapshot. The extension pins + // workspace 2, which cannot be confirmed; memory and routing fail closed, so + // workspace 1's skills must not keep loading in the pinned session. + serve({ "pub-1": { "SKILL.md": "from workspace 1" } }) + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(true) + + const pinEnv: Record = { + ALTIMATE_CODE_SERVE: "1", + ALTIMATE_PINNED_WORKSPACE_ID: "2", + ALTIMATE_PINNED_WORKSPACE_NAME: "pinned", + ALTIMATE_PINNED_WORKSPACE_ROOT: project, + } + const saved = Object.fromEntries(Object.keys(pinEnv).map((k) => [k, process.env[k]])) + Object.assign(process.env, pinEnv) + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + try { + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(false) + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + test("a malformed pin retires the snapshot too", async () => { + serve({ "pub-1": { "SKILL.md": "from workspace 1" } }) + await syncSkills(project) + const pinEnv: Record = { ALTIMATE_CODE_SERVE: "1", ALTIMATE_PINNED_WORKSPACE_ID: "not-a-number" } + const keys = [...Object.keys(pinEnv), "ALTIMATE_PINNED_WORKSPACE_NAME", "ALTIMATE_PINNED_WORKSPACE_ROOT"] + const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])) + delete process.env.ALTIMATE_PINNED_WORKSPACE_NAME + delete process.env.ALTIMATE_PINNED_WORKSPACE_ROOT + Object.assign(process.env, pinEnv) + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + try { + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(false) + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + test("a pin leaves a snapshot outside its root alone", async () => { + // The pin speaks for the folder the extension launched `serve` for; another + // project's own snapshot is not its to take out of service. + serve({ "pub-1": { "SKILL.md": "from workspace 1" } }) + await syncSkills(project) + const elsewhere = path.join(SANDBOX, `pinned-root-${Math.random().toString(36).slice(2)}`) + mkdirSync(elsewhere, { recursive: true }) + const pinEnv: Record = { + ALTIMATE_CODE_SERVE: "1", + ALTIMATE_PINNED_WORKSPACE_ID: "2", + ALTIMATE_PINNED_WORKSPACE_NAME: "pinned", + ALTIMATE_PINNED_WORKSPACE_ROOT: elsewhere, + } + const saved = Object.fromEntries(Object.keys(pinEnv).map((k) => [k, process.env[k]])) + Object.assign(process.env, pinEnv) + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + try { + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(true) + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + test("an unresolvable pin retires even a snapshot of the pinned workspace", async () => { + // Unconfirmed is the same answer as revoked here: a blip after a successful + // validation resolves as a stale bound pin, not unknown, so unknown fails closed. + serve({ "pub-1": { "SKILL.md": "from workspace 1" } }) + await syncSkills(project) + const pinEnv: Record = { + ALTIMATE_CODE_SERVE: "1", + ALTIMATE_PINNED_WORKSPACE_ID: "1", + ALTIMATE_PINNED_WORKSPACE_NAME: "ws-1", + ALTIMATE_PINNED_WORKSPACE_ROOT: project, + } + const saved = Object.fromEntries(Object.keys(pinEnv).map((k) => [k, process.env[k]])) + Object.assign(process.env, pinEnv) + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + try { + await syncSkills(project) + expect(existsSync(skillFile("pub-1", "SKILL.md"))).toBe(false) + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + test("a failed binding lookup is not read as unbound", async () => { // Same rule as the skill list: an error means "unknown", so whatever is on // disk stays. Treating it as unbound would wipe a synced project offline. diff --git a/packages/opencode/test/cli/workspace-pilot.test.ts b/packages/opencode/test/cli/workspace-pilot.test.ts new file mode 100644 index 000000000..70f5ee5e6 --- /dev/null +++ b/packages/opencode/test/cli/workspace-pilot.test.ts @@ -0,0 +1,32 @@ +// altimate_change - new file +// With the workspace pilot off, `link` and `skill publish` must explain the opt-in rather +// than fall through to the default command, which read "link" as a project directory. +import { afterEach, expect, test } from "bun:test" +import yargs from "yargs" +import { pilotOffCommand, WORKSPACE_PILOT_OFF_MESSAGE } from "../../src/cli/cmd/workspace-pilot" + +const originalWrite = process.stderr.write.bind(process.stderr) +const originalExitCode = process.exitCode +afterEach(() => { + process.stderr.write = originalWrite + // Bun ignores `process.exitCode = undefined`, so restore a number or a 1 leaks into later files. + process.exitCode = originalExitCode ?? 0 +}) + +test.each([ + ["link", ["link"]], + ["link", ["link", "--directory", "/tmp/x"]], + ["publish [name]", ["publish", "my-skill"]], + ["publish [name]", ["publish"]], +])("%s stub handles %p with the opt-in message and a failing exit", async (command, argv) => { + let said = "" + process.stderr.write = ((chunk: string | Uint8Array) => { + said += String(chunk) + return true + }) as typeof process.stderr.write + process.exitCode = 0 + await yargs(argv).command(pilotOffCommand(command)).strict().fail(false).parseAsync() + process.stderr.write = originalWrite + expect(said).toContain(WORKSPACE_PILOT_OFF_MESSAGE) + expect(process.exitCode).toBe(1) +}) diff --git a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts index 0288e61ec..176a10c93 100644 --- a/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.1-adversarial.test.ts @@ -226,7 +226,7 @@ describe("v0.12.1 adversarial: identity copy across pin × stale × unbound", () }) test("a pinned outcome always carries the routing caveat; a plain link never does", () => { - const caveat = "warehouse tool routing still follows the project's own link" + const caveat = "and so does warehouse tool routing unless integrations are set to local" expect(render({ status: "bound", binding: binding(true) })).toContain(caveat) expect(render({ status: "bound", binding: binding(true), stale: true })).toContain(caveat) expect(render({ status: "bound", binding: binding(false) })).not.toContain(caveat)