Skip to content

Commit cfa587f

Browse files
anandgupta42claude
andcommitted
fix(workspace): explain a link to a teammate's private workspace; warn that quick workspaces are private
- A 409 whose `existing_datamate_name` is withheld means the project is linked to a workspace the caller cannot see. `link` (three paths) and the TUI (three toasts) said "another workspace claimed this project while you were choosing" / "another workspace", sending users round a retry loop. They now say it is most likely a teammate's private workspace and to ask its owner to share it. - `link`'s quick create warns that only the creator can see the new workspace, so teammates who clone the repo are told it is unlinked until it is shared. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 94eb701 commit cfa587f

4 files changed

Lines changed: 49 additions & 5 deletions

File tree

‎packages/opencode/src/altimate/workspace/api-client.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ export class NotConfiguredError extends Error {
9393
}
9494
}
9595

96+
/** A 409 whose `existing_datamate_name` is withheld: the server hides the name of a workspace
97+
* the caller cannot see, which is almost always a teammate's private one. Reading that as a race
98+
* ("another workspace claimed this project while you were choosing") sent users round a retry
99+
* loop with no way out. */
100+
export const HIDDEN_BINDING_MESSAGE =
101+
"This project is already linked to a workspace you can't see, most likely a teammate's private one. " +
102+
"Ask its owner to share it with you in the Altimate web app, or to unlink the project, then run `altimate-code link` again."
103+
104+
export function isHiddenBindingConflict(err: unknown): boolean {
105+
return err instanceof ConflictError && !err.detail.existing_datamate_name
106+
}
107+
96108
export class ConflictError extends Error {
97109
constructor(public readonly detail: ConflictDetail) {
98110
super(detail.message)

‎packages/opencode/src/cli/cmd/link.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { AltimateApi } from "@/altimate/api/client"
1818
import {
1919
WorkspaceApi,
2020
ConflictError,
21+
HIDDEN_BINDING_MESSAGE,
22+
isHiddenBindingConflict,
2123
ForbiddenError,
2224
NotConfiguredError,
2325
NotFoundError,
@@ -416,7 +418,9 @@ async function runBrowserHandoff(
416418
prompts.outro("Done.")
417419
} catch (err) {
418420
bindSpin.stop("Link failed.", 1)
419-
if (err instanceof ConflictError) {
421+
if (isHiddenBindingConflict(err)) {
422+
prompts.log.error(`${HIDDEN_BINDING_MESSAGE} Workspace "${projectName}" was created but is not linked.`)
423+
} else if (err instanceof ConflictError) {
420424
const existingName = conflictExistingName(err.detail)
421425
prompts.log.error(
422426
`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.`,
@@ -536,6 +540,8 @@ export async function createThenBindOrRebind(
536540
`The workspace could not be created: ${err.message}. Nothing was created, and this ` +
537541
`project is still linked to "${stripControlChars(existing.datamate.name)}".`,
538542
)
543+
} else if (isHiddenBindingConflict(err)) {
544+
prompts.log.error(`${HIDDEN_BINDING_MESSAGE} Nothing was created.`)
539545
} else {
540546
const existingName = conflictExistingName(err.detail)
541547
prompts.log.error(
@@ -610,6 +616,9 @@ export async function createThenBindOrRebind(
610616
linkedAt: Date.now(),
611617
}, { awaitBackfill: true })
612618
prompts.log.info(seedMessage(seed))
619+
// The quick create is private, and the server hides a private workspace's link from
620+
// everyone else: a teammate who clones this repo is told it is unlinked.
621+
prompts.log.warn(QUICK_WORKSPACE_PRIVATE_NOTE)
613622
// ``createAndBind`` hands back a manage_url; the unbound create does not, so
614623
// derive it from credentials exactly as the rest of this file does. Null on
615624
// BYOK / unresolvable deployments — then there is simply nothing to show.
@@ -743,7 +752,9 @@ async function bindOrRebind(
743752
prompts.outro("Done.")
744753
} catch (err) {
745754
spin.stop(isRebind ? `Re-link failed.` : `Link failed.`, 1)
746-
if (err instanceof ConflictError) {
755+
if (isHiddenBindingConflict(err)) {
756+
prompts.log.error(HIDDEN_BINDING_MESSAGE)
757+
} else if (err instanceof ConflictError) {
747758
const existingName = conflictExistingName(err.detail)
748759
prompts.log.error(`Already linked to "${existingName}". Re-run \`altimate-code link\` to switch.`)
749760
} else if (err instanceof PreconditionFailedError) {
@@ -759,6 +770,9 @@ async function bindOrRebind(
759770
}
760771
}
761772

773+
export const QUICK_WORKSPACE_PRIVATE_NOTE =
774+
"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."
775+
762776
/** What `link` says about this machine's saved memory after the bind. A seed that left
763777
* blocks behind used to print the same line as one that stored everything. */
764778
export function seedMessage(seed: SeedOutcome | null): string {

‎packages/opencode/src/plugin/tui/altimate/workspace.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { inertWorkspaceName } from "@/altimate/workspace/workspace-name"
3333
import { createSignal, onCleanup, onMount } from "solid-js"
3434
import {
3535
ConflictError,
36+
HIDDEN_BINDING_MESSAGE,
3637
ForbiddenError,
3738
NotFoundError,
3839
PreconditionFailedError,
@@ -419,7 +420,9 @@ async function runBrowserHandoff(
419420
if (err instanceof ConflictError) {
420421
api.ui.toast({
421422
variant: "warning",
422-
message: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Run \`altimate-code link\` to change.`,
423+
message: err.detail.existing_datamate_name
424+
? `This project is already linked to "${err.detail.existing_datamate_name}". Run \`altimate-code link\` to change.`
425+
: HIDDEN_BINDING_MESSAGE,
423426
})
424427
} else if (err instanceof NotFoundError) {
425428
api.ui.toast({
@@ -896,7 +899,9 @@ function PickerDialog(props: PickerProps) {
896899
// The picker doesn't have a "Re-link" option; the referral used to
897900
// point at OfferDialog's Re-link, which doesn't exist either. Point
898901
// at the concrete next action instead. (kilo cycle 6.)
899-
msg = `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to change the workspace.`
902+
msg = err.detail.existing_datamate_name
903+
? `Already linked to "${err.detail.existing_datamate_name}". Re-run \`altimate-code link\` to change the workspace.`
904+
: HIDDEN_BINDING_MESSAGE
900905
} else if (err instanceof PreconditionFailedError) {
901906
msg = "Someone else re-linked this project — reload and try again."
902907
} else if (err instanceof NotFoundError) {
@@ -1091,7 +1096,9 @@ async function bindOrRebindInline(
10911096
} catch (err) {
10921097
let msg: string
10931098
if (err instanceof ConflictError) {
1094-
msg = `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}".`
1099+
msg = err.detail.existing_datamate_name
1100+
? `Already linked to "${err.detail.existing_datamate_name}".`
1101+
: HIDDEN_BINDING_MESSAGE
10951102
} else if (err instanceof PreconditionFailedError) {
10961103
msg = "Someone else re-linked this project — reload and try again."
10971104
} else if (err instanceof NotFoundError) {

‎packages/opencode/test/altimate/workspace/link-seed-message.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,14 @@ test("link reports a completed seed and memory that is off without a retry hint"
1414
expect(seedMessage({ status: "off", sent: 0, pending: 0 })).not.toContain("Sync")
1515
expect(seedMessage(null)).not.toContain("Sync")
1616
})
17+
18+
test("a 409 that withholds the workspace name is explained as a teammate's private workspace, not a race", async () => {
19+
const { ConflictError, isHiddenBindingConflict, HIDDEN_BINDING_MESSAGE } = await import(
20+
"../../../src/altimate/workspace/api-client"
21+
)
22+
expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_name: null } as any))).toBe(true)
23+
expect(isHiddenBindingConflict(new ConflictError({} as any))).toBe(true)
24+
expect(isHiddenBindingConflict(new ConflictError({ existing_datamate_name: "team" } as any))).toBe(false)
25+
expect(isHiddenBindingConflict(new Error("x"))).toBe(false)
26+
expect(HIDDEN_BINDING_MESSAGE).toContain("share it with you")
27+
})

0 commit comments

Comments
 (0)