Skip to content

Commit 2efd7f3

Browse files
saravmajesticclaude
andcommitted
fix: [AI-9171] create a quick workspace from an already-linked project
The picker's "+ Create a quick workspace here" row always failed when the project was already linked, and told the user to re-run the command they were already inside. `createThenBindOrRebind` already had a correct rebind branch — it was simply unreachable. Step one called `createAndBind`, and the server's `create_and_bind` pre-checks both identifiers and 409s *before* creating anything, deliberately, so a binding conflict cannot strand a half-created workspace. On an already linked project that refuses the whole call, so nothing was created and the rebind below it was dead code. The row's own hint promised the opposite: "Creates a new workspace and repoints this project to it". Split the two cases: - unlinked: unchanged — `createAndBind` still creates and binds in one server-side transaction, which is what makes a stranded workspace impossible there. - already linked: create the workspace unbound via `POST /datamates/`, then repoint through the existing rebind path. `createWorkspaceUnbound` sends `memory_enabled` and `knowledge_engine_enabled` explicitly. `POST /datamates/` is the SaaS/extension creation path and defaults both to false, while the create-and-bind path sets both true — so without this the same menu row would hand back a differently configured workspace depending only on whether the project happened to be linked, with memory and the knowledge engine silently off. The comment names the coupling so the two move together. Also corrected the 409 message. After this change a conflict can only mean another workspace claimed the project mid-selection, so it says that instead of directing the user back into the command they are already running. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1 parent 024e800 commit 2efd7f3

3 files changed

Lines changed: 230 additions & 22 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,46 @@ export namespace WorkspaceApi {
395395
})
396396
}
397397

398+
/** Create a workspace WITHOUT binding anything to it.
399+
*
400+
* ``createAndBind`` is the right call for an unlinked project: it creates and
401+
* binds in one server-side transaction, so a binding conflict cannot strand a
402+
* workspace. But it pre-checks the identifiers and 409s *before* creating,
403+
* which makes it unusable when the project is already linked — there is
404+
* nothing to create, and the caller's rebind never gets a target (AI-9171).
405+
* This is the two-step path for that case: create here, then rebind.
406+
*
407+
* The flags below deliberately mirror ``_create_datamate_flush_only`` in
408+
* altimate-backend, which is what ``createAndBind`` reaches. ``POST
409+
* /datamates/`` is the SaaS/extension creation path and defaults BOTH to
410+
* false, so omitting them would hand a differently-configured workspace to
411+
* whichever caller happened to be already linked — same menu row, memory and
412+
* knowledge engine silently off. If the backend's workspace defaults move,
413+
* this has to move with them; there is no endpoint that applies them without
414+
* also binding.
415+
*/
416+
export async function createWorkspaceUnbound(input: {
417+
name: string
418+
description?: string
419+
}): Promise<{ id: number; name: string }> {
420+
const data = await req<{ id: number }>("POST", "/", {
421+
base: "/datamates",
422+
body: {
423+
name: input.name,
424+
description: input.description ?? null,
425+
integrations: [],
426+
memory_enabled: true,
427+
knowledge_engine_enabled: true,
428+
privacy: "private",
429+
},
430+
})
431+
const id = Number(data?.id)
432+
if (!Number.isSafeInteger(id) || id <= 0) {
433+
throw new Error(`Workspace was created but the server returned no usable id (${String(data?.id)}).`)
434+
}
435+
return { id, name: input.name }
436+
}
437+
398438
export async function bindExisting(
399439
datamateId: number,
400440
identifier: ProjectIdentifier,

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

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
NotConfiguredError,
2323
NotFoundError,
2424
PreconditionFailedError,
25+
type Binding,
2526
type DatamateRef,
2627
type MatchedIdentifier,
2728
type ProjectBindingLookup,
@@ -484,19 +485,36 @@ async function createThenBindOrRebind(
484485
): Promise<void> {
485486
const spin = prompts.spinner()
486487
spin.start(`Creating workspace "${name}"...`)
487-
let created: Awaited<ReturnType<typeof WorkspaceApi.createAndBind>>
488+
let created: { datamate: DatamateRef; binding?: Binding; manage_url?: string }
488489
try {
489-
created = await WorkspaceApi.createAndBind({ name, identifier })
490+
// Two different creates, because the server offers two different things.
491+
//
492+
// Unlinked: ``createAndBind`` creates and binds in ONE transaction, so a
493+
// conflicting binding can never strand a half-created workspace.
494+
//
495+
// Already linked: that same atomicity makes it unusable. ``create_and_bind``
496+
// pre-checks the identifiers and 409s *before* creating anything, so the
497+
// rebind below never got a target and this row simply always failed — with
498+
// an error telling the user to re-run the command they were already inside
499+
// (AI-9171). Create unbound first, then repoint, which is what the row's
500+
// own hint promises.
501+
if (existing) {
502+
const ws = await WorkspaceApi.createWorkspaceUnbound({ name })
503+
created = { datamate: ws }
504+
} else {
505+
created = await WorkspaceApi.createAndBind({ name, identifier })
506+
}
490507
} catch (err) {
491508
spin.stop("Failed to create workspace.", 1)
492-
// A 409 from create means someone else's binding on the same
493-
// remote/path beat us. If the pre-check already knew about it, the user
494-
// can pick from the list; if the pre-check missed it, this is the
495-
// authoritative signal — surface it and hint the picker.
509+
// A 409 here can only be a binding this caller did not know about: the
510+
// already-linked path does not bind at all, and the unlinked path only
511+
// reaches the server after a pre-check that said the project was free. So
512+
// someone else's binding on the same remote/path landed in between.
496513
if (err instanceof ConflictError) {
497514
const existingName = conflictExistingName(err.detail)
498515
prompts.log.error(
499-
`This project is already linked to "${existingName}". Re-run \`altimate-code link\` to switch to a different workspace.`,
516+
`Another workspace, "${existingName}", claimed this project while you were choosing. ` +
517+
`Nothing was created. Run \`altimate-code link\` again to see the current list.`,
500518
)
501519
} else {
502520
prompts.log.error(err instanceof Error ? err.message : String(err))
@@ -510,10 +528,9 @@ async function createThenBindOrRebind(
510528
const safeCreatedName = stripControlChars(created.datamate.name)
511529
spin.stop(`Workspace "${safeCreatedName}" created.`)
512530

513-
// If the project was already linked, the new workspace exists but the
514-
// binding still points at the OLD workspace — rebind so the project is
515-
// now bound to the freshly-created one. Otherwise createAndBind already
516-
// wrote the binding as part of the atomic create; we're done.
531+
// The already-linked path created an unbound workspace above, so the binding
532+
// still points at the OLD one — repoint it now. The unlinked path already got
533+
// its binding from the atomic create, so there is nothing left to do.
517534
if (existing) {
518535
const rebindSpin = prompts.spinner()
519536
rebindSpin.start(`Repointing project at "${safeCreatedName}"...`)
@@ -537,23 +554,31 @@ async function createThenBindOrRebind(
537554
// Prefer the canonicalized ``identifier.projectPath`` over the raw
538555
// ``--directory`` argument so ``altimate-code link -d ./myproj`` and its
539556
// symlink-resolved twin both write under the same cache key (Kilo cycle 6).
557+
// The unbound path has no server binding row to echo, so fall back to the
558+
// identifier we just rebound on — the same values the rebind wrote.
540559
await recordApprovedBinding(identifier.projectPath ?? directory, {
541560
datamateId: created.datamate.id,
542561
datamateName: created.datamate.name,
543-
repoRemote: created.binding.repo_remote,
544-
projectPath: created.binding.project_path,
562+
repoRemote: created.binding?.repo_remote ?? identifier.repoRemote ?? null,
563+
projectPath: created.binding?.project_path ?? identifier.projectPath ?? null,
545564
linkedAt: Date.now(),
546565
}, { awaitBackfill: true })
547566
prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.")
548-
prompts.log.info(`Manage it at: ${created.manage_url}`)
549-
// Guard against a server that hands back a non-http(s) manage_url — ``open``
550-
// delegates to the OS handler, so a rogue value could launch an unrelated
551-
// application. Log a warning and skip the auto-open rather than trusting
552-
// whatever protocol the URL parses to.
553-
if (isSafeHttpUrl(created.manage_url)) {
554-
await open(created.manage_url).catch(() => undefined)
555-
} else {
556-
prompts.log.warn(`Skipped auto-open: manage_url is not an http/https URL.`)
567+
// ``createAndBind`` hands back a manage_url; the unbound create does not, so
568+
// derive it from credentials exactly as the rest of this file does. Null on
569+
// BYOK / unresolvable deployments — then there is simply nothing to show.
570+
const manageUrl = created.manage_url ?? (await manageUrlFor(created.datamate.id))
571+
if (manageUrl) {
572+
prompts.log.info(`Manage it at: ${manageUrl}`)
573+
// Guard against a server that hands back a non-http(s) manage_url — ``open``
574+
// delegates to the OS handler, so a rogue value could launch an unrelated
575+
// application. Log a warning and skip the auto-open rather than trusting
576+
// whatever protocol the URL parses to.
577+
if (isSafeHttpUrl(manageUrl)) {
578+
await open(manageUrl).catch(() => undefined)
579+
} else {
580+
prompts.log.warn(`Skipped auto-open: manage_url is not an http/https URL.`)
581+
}
557582
}
558583
prompts.outro("Done.")
559584
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// altimate_change - new file
2+
// Coverage for WorkspaceApi.createWorkspaceUnbound (AI-9171).
3+
//
4+
// This exists because `altimate link`'s "create a quick workspace" row always
5+
// failed on an already-linked project: it went through `createAndBind`, whose
6+
// server handler pre-checks the identifiers and 409s BEFORE creating, so the
7+
// rebind that was supposed to follow never got a target. The fix creates the
8+
// workspace unbound first, then repoints.
9+
//
10+
// The assertions worth having are about the REQUEST, not the response. Two
11+
// creation paths now exist, and the silent failure mode is that they disagree:
12+
// `POST /datamates/` defaults memory and the knowledge engine to false, while
13+
// the create-and-bind path sets both true. If this drifts, the same menu row
14+
// produces a differently-configured workspace depending only on whether the
15+
// project happened to be linked — which nothing else would catch.
16+
import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"
17+
import { mkdirSync, writeFileSync } from "node:fs"
18+
import path from "node:path"
19+
import os from "node:os"
20+
21+
const ORIGINAL_TEST_HOME = process.env.OPENCODE_TEST_HOME
22+
const SANDBOX = path.join(os.tmpdir(), `altimate-createunbound-${process.pid}-${Date.now()}`)
23+
mkdirSync(path.join(SANDBOX, "home", ".altimate"), { recursive: true })
24+
process.env.OPENCODE_TEST_HOME = path.join(SANDBOX, "home")
25+
26+
const API_URL = "https://api.example.test"
27+
const TENANT = "acme"
28+
29+
// A real credentials file, so the module resolves them the same way it does in
30+
// production rather than through a stubbed export.
31+
writeFileSync(
32+
path.join(SANDBOX, "home", ".altimate", "altimate.json"),
33+
JSON.stringify({
34+
altimateUrl: API_URL,
35+
altimateInstanceName: TENANT,
36+
altimateApiKey: "test-key",
37+
}),
38+
)
39+
40+
const { WorkspaceApi } = await import("@/altimate/workspace/api-client")
41+
42+
const ORIGINAL_FETCH = globalThis.fetch
43+
44+
interface Captured {
45+
url: string
46+
method: string
47+
body: Record<string, unknown>
48+
}
49+
50+
let captured: Captured[] = []
51+
52+
/** Stub fetch, recording each request and replying with `reply`. */
53+
function respondWith(status: number, reply: unknown) {
54+
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
55+
captured.push({
56+
url: String(input),
57+
method: String(init?.method ?? "GET"),
58+
body: init?.body ? JSON.parse(String(init.body)) : {},
59+
})
60+
return new Response(JSON.stringify(reply), {
61+
status,
62+
headers: { "content-type": "application/json" },
63+
})
64+
}) as typeof globalThis.fetch
65+
}
66+
67+
beforeEach(() => {
68+
captured = []
69+
})
70+
71+
afterEach(() => {
72+
globalThis.fetch = ORIGINAL_FETCH
73+
})
74+
75+
afterAll(() => {
76+
if (ORIGINAL_TEST_HOME === undefined) delete process.env.OPENCODE_TEST_HOME
77+
else process.env.OPENCODE_TEST_HOME = ORIGINAL_TEST_HOME
78+
})
79+
80+
describe("createWorkspaceUnbound", () => {
81+
test("posts to /datamates/, NOT to the binding router", async () => {
82+
respondWith(200, { id: 77 })
83+
await WorkspaceApi.createWorkspaceUnbound({ name: "jaffle_shop" })
84+
85+
expect(captured).toHaveLength(1)
86+
expect(captured[0].method).toBe("POST")
87+
// The whole point: this must not reach create_and_bind, which would 409.
88+
expect(captured[0].url).not.toContain("datamate-project-bindings")
89+
expect(captured[0].url).toContain("/datamates/")
90+
})
91+
92+
test("sends no project identifier — binding is the caller's next step", async () => {
93+
respondWith(200, { id: 77 })
94+
await WorkspaceApi.createWorkspaceUnbound({ name: "jaffle_shop" })
95+
96+
expect(captured[0].body).not.toHaveProperty("repo_remote")
97+
expect(captured[0].body).not.toHaveProperty("project_path")
98+
})
99+
100+
test("applies the workspace defaults, not the SaaS ones", async () => {
101+
respondWith(200, { id: 77 })
102+
await WorkspaceApi.createWorkspaceUnbound({ name: "jaffle_shop" })
103+
104+
// `POST /datamates/` defaults both to false. Sending them explicitly is what
105+
// keeps an already-linked project's new workspace configured like every
106+
// other CLI-created one. Dropping either line is the regression.
107+
expect(captured[0].body.memory_enabled).toBe(true)
108+
expect(captured[0].body.knowledge_engine_enabled).toBe(true)
109+
expect(captured[0].body.privacy).toBe("private")
110+
// Required by CreateDatamateRequest — omitting it is a 422.
111+
expect(captured[0].body.integrations).toEqual([])
112+
})
113+
114+
test("returns the created id and the caller's name", async () => {
115+
respondWith(200, { id: 77 })
116+
const created = await WorkspaceApi.createWorkspaceUnbound({ name: "jaffle_shop" })
117+
expect(created).toEqual({ id: 77, name: "jaffle_shop" })
118+
})
119+
120+
test("passes a description through when given, null when not", async () => {
121+
respondWith(200, { id: 77 })
122+
await WorkspaceApi.createWorkspaceUnbound({ name: "a", description: "from the CLI" })
123+
expect(captured[0].body.description).toBe("from the CLI")
124+
125+
captured = []
126+
respondWith(200, { id: 78 })
127+
await WorkspaceApi.createWorkspaceUnbound({ name: "b" })
128+
expect(captured[0].body.description).toBeNull()
129+
})
130+
131+
test("rejects a response with no usable id rather than returning NaN", async () => {
132+
// A workspace the caller cannot then rebind to is worse than a clear error:
133+
// `Number(undefined)` is NaN, which would reach the rebind as a garbage
134+
// target id.
135+
respondWith(200, { id: null })
136+
await expect(WorkspaceApi.createWorkspaceUnbound({ name: "x" })).rejects.toThrow(/no usable id/)
137+
})
138+
139+
test("rejects a non-integer id", async () => {
140+
respondWith(200, { id: "not-a-number" })
141+
await expect(WorkspaceApi.createWorkspaceUnbound({ name: "x" })).rejects.toThrow(/no usable id/)
142+
})
143+
})

0 commit comments

Comments
 (0)