Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8f13d58
fix(workspace): dogfood fixes for relink, consent, pin skills and lin…
anandgupta42 Sep 25, 2026
4f89839
fix(workspace): address review on the dogfood fixes
anandgupta42 Sep 25, 2026
a1342e7
fix(workspace): address second review round
anandgupta42 Sep 25, 2026
1539751
fix(workspace): clear the adopted flag when Attach approves a cached …
anandgupta42 Sep 25, 2026
da83923
fix(workspace): scope memory epochs per project and bracket the bindi…
anandgupta42 Sep 25, 2026
3090eaa
fix(workspace): stamp failed memory loads; use the hidden-conflict ch…
anandgupta42 Sep 25, 2026
c67ec52
fix(workspace): pin the account across offline Attach; keep a failed …
anandgupta42 Sep 25, 2026
15ecfca
fix(workspace): pin offline Attach to the account the dialog was show…
anandgupta42 Sep 25, 2026
2ede9c7
fix(workspace): pin the account inside recordApprovedBinding; harden …
anandgupta42 Sep 25, 2026
0d54c65
fix(workspace): revalidate online Attach; Open follows the IDE pin; s…
anandgupta42 Sep 25, 2026
1349da2
fix(workspace): capture the account before the pre-check; Open fails …
anandgupta42 Sep 25, 2026
dd92f91
fix(workspace): pin the account when warming a discovered binding
anandgupta42 Sep 25, 2026
c0143bd
fix(workspace): skip the discovery warm-up when no account is known
anandgupta42 Sep 25, 2026
ab01381
fix(workspace): keep a failed pin check unresolved for Open in browser
anandgupta42 Sep 25, 2026
62ba138
fix(workspace): report the seed gate from the sweep itself; Open for …
anandgupta42 Sep 25, 2026
1b1dfd6
fix(workspace): report an unavailable memory setting in Sync; pin the…
anandgupta42 Sep 25, 2026
39dd901
fix(workspace): surface an account-changed Attach; do not overstate u…
anandgupta42 Sep 25, 2026
010b87c
fix(workspace): pin the account in link; bound the menu pin check; no…
anandgupta42 Sep 25, 2026
b3cf963
fix(workspace): refuse link without readable credentials; no Unlink u…
anandgupta42 Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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

Expand Down
18 changes: 18 additions & 0 deletions packages/opencode/src/altimate/workspace/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 48 additions & 12 deletions packages/opencode/src/altimate/workspace/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 ` +
Expand Down Expand Up @@ -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. "
Comment thread
anandgupta42 marked this conversation as resolved.
: " 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")
Expand Down Expand Up @@ -312,7 +334,15 @@ async function accountScope(): Promise<AccountScope | null> {
* 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.
Expand Down Expand Up @@ -387,18 +417,24 @@ export async function systemSection(): Promise<string> {
// 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, () =>
Promise.race([lastKnown(key, directory), after(FALLBACK_BUDGET_MS, () => ({ status: "unknown" }))]),
)
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)
}
Expand Down
16 changes: 12 additions & 4 deletions packages/opencode/src/altimate/workspace/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -253,9 +253,17 @@ export async function sync(directory: string): Promise<SyncReport> {
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,
Expand Down
33 changes: 28 additions & 5 deletions packages/opencode/src/altimate/workspace/memory-backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,43 @@ 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.
*
* 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<boolean> {
if (!isEnabled()) return false
export async function seedOnBind(directory: string, binding: CachedBinding): Promise<SeedOutcome> {
// 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
// context, so resolving project scope from the ambient instance throws
// 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.
Expand All @@ -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<boolean> {
return (await seedOnBind(directory, binding)).status === "seeded"
}
Loading
Loading