fix(workspace): create a quick workspace from an already-linked project

Merged
SA
2mo ago
+634-44

Replaces #1314, which could not be salvaged: Tracker Leaks rejects internal tracker keys from the branch name as well as the diff and commit messages, and GitHub does not allow a PR's head branch to be renamed. Same change, clean branch, rebased onto current main. The review on #1314 is fully addressed here — see below.

Problem

In a project that is already linked, the altimate-code link picker's "+ Create a quick workspace … here" row always failed, and the error told the user to re-run the command they were already inside.

◇  Currently linked to "jaffle_shop-altimate". Pick a workspace (or create a new one):
│  + Create a quick workspace "jaffle_shop-altimate" here
│
■  Failed to create workspace.
■  This project is already linked to "jaffle_shop-altimate". Re-run `altimate-code link`
   to switch to a different workspace.

The row's own hint promises the opposite: "Creates a new workspace and repoints this project to it (no browser step)."

Cause

createThenBindOrRebind already had a correct rebind branch. It was unreachable. Step one called createAndBind → POST /datamate-project-bindings/, whose handler pre-checks both identifiers and returns 409 before creating anything — deliberately, so a binding conflict cannot strand a half-created workspace. On an already-linked project that refuses the entire call, so the rebind below it was dead code.

The handler's own comment shows the broken assumption — "the new workspace exists but the binding still points at the OLD workspace". It does not exist: create and bind are atomic server-side.

The neighbouring "+ Set up in browser" row is correctly hidden when already linked, with a comment explaining this exact 409. One of the two guards was applied; the other was missed.

Fix

Split by case, because the server offers two different things:

  • Unlinked — unchanged. createAndBind creates and binds in one transaction, which is what makes a stranded workspace impossible there.
  • Already linked — create the workspace unbound via POST /datamates/, then repoint through the rebind path that already existed.

createWorkspaceUnbound sends memory_enabled and knowledge_engine_enabled explicitly. POST /datamates/ defaults both to false while the create-and-bind path sets both true, so without those two lines the same menu row would return a differently-configured workspace depending only on whether the project happened to be linked — memory and the knowledge engine silently off.

Review from #1314, addressed

The Major one — the TUI had the identical bug. createAndBindInline called createAndBind unconditionally, so on an already-linked project it 409'd before creating and its rebind was unreachable too. Fixing one surface and shipping the other would leave the CLI and TUI disagreeing about what the same row does. Its rebindFrom doc also asserted the assumption the bug rested on; corrected.

FindingResolution
409 message misattributes on the unbound branchSplit by which call ran — the unbound create sends no identifiers, so it cannot produce an identity conflict
created's inline typeDiscriminated union; binding/manage_url only reachable where they exist
Bare Error in api-client.tsTyped WorkspaceApiError, matching the module
Pin the account across the two-stepCaptured before the create, re-checked before the rebind, on both surfaces. API key excluded — rotating a key for the same user on the same tenant is not an identity change
Cache the authoritative bindingThe rebind response is kept and used. A path-keyed row rebound via /by-path was being cached with a repo_remote the server never stored
Reject non-number idsNumber.isSafeInteger(Number(x)) accepted true→1, "7"→7, [5]→5. The boolean case would have rebound the project to workspace 1 rather than failing. typeof now runs first
Test global stateDocumented why the env assignment must be module-scope (the module resolves Global.Path at import); sandbox keyed by pid+clock, original restored in afterAll, fetch restored per test

One not taken, with evidence. A reviewer asked for the nested {datamate:{id}} shape to be accepted. POST /datamates/ declares response_model=CreateDatamateResponse ({id: int}) and FastAPI enforces it, so that shape cannot come back from this endpoint. AltimateApi.createDatamate's ?? data.datamate?.id is defensive legacy for a different call. An unreachable branch would only give a real contract break somewhere to hide. Happy to add it if preferred.

Verification

Against a real backend (local, tenant hackdev, project bound to workspace 63):

BEFORE  POST /datamate-project-bindings/   -> 409 "already linked to a different workspace"
                                              nothing created, rebind unreachable

AFTER   POST /datamates/ {memory_enabled:true, knowledge_engine_enabled:true}
          -> {"id": 66}
        PUT  /datamate-project-bindings/by-remote {target:66, expected_current:63}
          -> {"binding":{"id":44,"datamate_id":66,...}}
        GET  /datamate-project-bindings/by-remote
          -> bound to workspace 66

        memory_enabled = True, knowledge_engine_enabled = True

Tests. create-then-rebind.test.ts asserts the sequence of endpoints for both surfaces — the bug was never in a payload, it was in which request got sent, and a payload-shaped test cannot see that. Covers unlinked, already-linked, a failed rebind (non-zero exit / error toast rather than a claimed success), and a 409 that must not reach the rebind. Plus the id-coercion cases and the account fingerprint.

Mutation-tested rather than assumed:

RevertedResult
CLI fix3 fail
TUI fix2 fail
memory_enabled / knowledge_engine_enabled1 fail
strict id guard3 fail

560 pass / 0 fail across test/cli/cmd/link.test.ts + test/altimate/workspace/. Typecheck clean. oxlint adds no new errors. script/check-tracker-leaks.ts passes.

Both flow functions are exported purely so those tests can reach them — flagged in case a different approach is preferred.

Note for review

The cleaner long-term shape is backend-side: a replace_existing flag on POST /datamate-project-bindings/ would make this atomic and remove the duplicated defaults entirely. That is a two-repo change; this keeps the fix in the CLI and reuses the rebind path that already exists.

🤖 Generated with Claude Code


Summary by cubic

Fixes the "create a quick workspace" row in altimate-code link so it works when the project is already linked — it previously always failed with an error telling the user to re-run the command they were already inside. The row now creates the workspace unbound and repoints the project, as its hint already promised.

Bug Fixes

  • createAndBind is refused server-side with a 409 before creating anything when the project is already linked, which made the rebind below it unreachable; the two paths now diverge — unlinked keeps the atomic create-and-bind, already-linked creates via POST /datamates/ then rebinds.
  • The TUI's copy of the same flow (createAndBindInline) had the identical bug and now makes the same split.
  • createWorkspaceUnbound explicitly sends memory_enabled and knowledge_engine_enabled, since POST /datamates/ defaults both to false — omitting them would produce a differently-configured workspace depending on link state.
  • The rebind re-checks the account captured before the create so a mid-flow account switch can't use a workspace id local to another tenant; the id guard also type-checks before coercing, so true→1, "7"→7, and [5]→5 fail loudly instead of rebinding to the wrong workspace.

Tests

  • New endpoint-sequence tests cover both surfaces, a failed rebind (non-zero exit / error toast instead of a claimed success), and a 409 that must not reach the rebind.

Summary by CodeRabbit

  • New Features

    • Improved workspace creation for projects that are already linked, including safe create-then-rebind handling.
    • Added account-change verification before rebinding workspaces.
    • Preserved atomic creation and binding for unlinked projects.
    • Added validation and clearer handling for workspace creation and rebinding failures.
  • Tests

    • Added coverage for linked and unlinked workspace flows, account changes, invalid workspace responses, and failure scenarios.