-
Notifications
You must be signed in to change notification settings - Fork 134
fix: [AI-9171] create a quick workspace from an already-linked project #1314
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -395,6 +395,77 @@ export namespace WorkspaceApi { | |
| }) | ||
| } | ||
|
|
||
| /** Create a workspace WITHOUT binding anything to it. | ||
| * | ||
| * ``createAndBind`` is the right call for an unlinked project: it creates and | ||
| * binds in one server-side transaction, so a binding conflict cannot strand a | ||
| * workspace. But it pre-checks the identifiers and 409s *before* creating, | ||
| * which makes it unusable when the project is already linked — there is | ||
| * nothing to create, and the caller's rebind never gets a target (AI-9171). | ||
| * This is the two-step path for that case: create here, then rebind. | ||
| * | ||
| * The flags below deliberately mirror ``_create_datamate_flush_only`` in | ||
| * altimate-backend, which is what ``createAndBind`` reaches. ``POST | ||
| * /datamates/`` is the SaaS/extension creation path and defaults BOTH to | ||
| * false, so omitting them would hand a differently-configured workspace to | ||
| * whichever caller happened to be already linked — same menu row, memory and | ||
| * knowledge engine silently off. If the backend's workspace defaults move, | ||
| * this has to move with them; there is no endpoint that applies them without | ||
| * also binding. | ||
| */ | ||
| /** Who the next call will act as. | ||
| * | ||
| * A create-then-rebind pair is two requests, and `req()` resolves credentials | ||
| * independently for each. If the account changes in between — a re-login, an | ||
| * edited `altimate.json` — the workspace is created in one tenant and the | ||
| * rebind is sent to another with an id that is local to the first. Callers | ||
| * capture this before the create and re-check it before the rebind. | ||
| * | ||
| * The API key is deliberately not part of it: rotating a key for the same | ||
| * user on the same tenant is not an identity change, and comparing it would | ||
| * abort a legitimate flow. */ | ||
| export async function accountFingerprint(): Promise<{ apiUrl: string; tenant: string }> { | ||
| const c = await creds() | ||
| return { apiUrl: c.url, tenant: c.instance } | ||
| } | ||
|
|
||
| /** True when `before` still describes the account in effect. */ | ||
| export async function sameAccount(before: { apiUrl: string; tenant: string }): Promise<boolean> { | ||
| const now = await accountFingerprint().catch(() => null) | ||
| return now !== null && now.apiUrl === before.apiUrl && now.tenant === before.tenant | ||
|
Comment on lines
+433
to
+435
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: sed -n '1,180p' packages/opencode/src/altimate/workspace/api-client.ts
sed -n '380,490p' packages/opencode/src/altimate/workspace/api-client.ts
sed -n '470,640p' packages/opencode/src/cli/cmd/link.ts
sed -n '490,630p' packages/opencode/src/plugin/tui/altimate/workspace.tsx
rg -n -C 3 'function credentials|const credentials|AltimateApi|accountFingerprint|sameAccount|rebindByRemote|rebindByPath|recordApprovedBinding|save.*binding|write.*binding' packages/opencode/src/altimate packages/opencode/src/cli/cmd/link.ts packages/opencode/src/plugin/tui/altimate/workspace.tsxRepository: AltimateAI/altimate-code Length of output: 50380 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- api-client request and rebind definitions ---'
sed -n '70,190p' packages/opencode/src/altimate/workspace/api-client.ts
sed -n '220,390p' packages/opencode/src/altimate/workspace/api-client.ts
printf '%s\n' '--- CLI caller and helper ---'
sed -n '490,625p' packages/opencode/src/cli/cmd/link.ts
sed -n '750,800p' packages/opencode/src/cli/cmd/link.ts
printf '%s\n' '--- TUI caller ---'
sed -n '525,615p' packages/opencode/src/plugin/tui/altimate/workspace.tsx
printf '%s\n' '--- cache persistence ---'
sed -n '160,245p' packages/opencode/src/altimate/workspace/state.ts
sed -n '660,730p' packages/opencode/src/altimate/workspace/state.ts
printf '%s\n' '--- AltimateApi credential declarations/usages ---'
rg -n -C 5 'export (const|namespace|function)|getCredentials|isConfigured|altimateApiKey|altimateInstanceName|altimateUrl' packagesRepository: AltimateAI/altimate-code Length of output: 50381 🏁 Script executed: #!/bin/bash
set -e
LOG=/tmp/coderabbit-shell-logs/shell-output-2Fm0iI
printf '%s\n' '--- targeted source output from prior request ---'
sed -n '1,900p' "$LOG"Repository: AltimateAI/altimate-code Length of output: 45803 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- workspace rebind methods ---'
rg -n -A35 -B5 'export async function rebindBy(Remote|Path)|function rebindBy(Remote|Path)' packages/opencode/src/altimate/workspace/api-client.ts
printf '%s\n' '--- cache persistence continuation ---'
sed -n '680,780p' packages/opencode/src/altimate/workspace/state.ts
printf '%s\n' '--- Altimate API client files ---'
rg --files packages/opencode/src/altimate | rg '(^|/)(client|api)(\.[^/]+)?$|api/client'
printf '%s\n' '--- Altimate credential methods in likely client ---'
rg -n -A12 -B8 'isConfigured|getCredentials' packages/opencode/src/altimate/api/client.tsRepository: AltimateAI/altimate-code Length of output: 12213 Bind the create, rebind, and cache write to one credential snapshot. Replace the separate 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| export async function createWorkspaceUnbound(input: { | ||
| name: string | ||
| description?: string | ||
| }): Promise<{ id: number; name: string }> { | ||
| const data = await req<{ id: number }>("POST", "/", { | ||
| base: "/datamates", | ||
| body: { | ||
| name: input.name, | ||
| description: input.description ?? null, | ||
| integrations: [], | ||
| memory_enabled: true, | ||
| knowledge_engine_enabled: true, | ||
| privacy: "private", | ||
| }, | ||
| }) | ||
| // `typeof` FIRST, before any arithmetic. `Number()` coerces, so the | ||
| // previous `Number.isSafeInteger(Number(data?.id))` accepted `true` as 1, | ||
| // `"7"` as 7 and `[5]` as 5 — a malformed body would have rebound the | ||
| // project to whatever those coerced to (workspace 1, in the boolean case) | ||
| // instead of failing. The server's `CreateDatamateResponse` is `{id: int}` | ||
| // and FastAPI enforces it, so anything else here is a contract break worth | ||
| // refusing loudly rather than guessing at. | ||
| const id: unknown = (data as { id?: unknown } | null | undefined)?.id | ||
| if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) { | ||
| throw new WorkspaceApiError( | ||
| `Workspace was created but the server returned no usable id (${JSON.stringify(id) ?? "undefined"}).`, | ||
| ) | ||
| } | ||
| return { id, name: input.name } | ||
| } | ||
|
|
||
| export async function bindExisting( | ||
| datamateId: number, | ||
| identifier: ProjectIdentifier, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ import { | |
| NotConfiguredError, | ||
| NotFoundError, | ||
| PreconditionFailedError, | ||
| type Binding, | ||
| type DatamateRef, | ||
| type MatchedIdentifier, | ||
| type ProjectBindingLookup, | ||
|
|
@@ -475,29 +476,72 @@ function handoffFailureMessage(result: Extract<HandoffResult, { ok: false }>): s | |
| * real (billable) SaaS resource the CLI knows nothing about and the project | ||
| * is still bound to the old workspace (M2 in the consensus review). When | ||
| * rebind fails, the error message tells the user the workspace was created | ||
| * and how to recover; we do NOT silently swallow the orphan. */ | ||
| async function createThenBindOrRebind( | ||
| * and how to recover; we do NOT silently swallow the orphan. | ||
| * | ||
| * Exported for tests. The branch it picks — atomic create-and-bind when the | ||
| * project is free, unbound-create-then-rebind when it is already linked — is | ||
| * the whole of AI-9171, and nothing else in this file can assert it. */ | ||
| export async function createThenBindOrRebind( | ||
| identifier: ProjectIdentifier, | ||
| name: string, | ||
| directory: string, | ||
| existing: ProjectBindingLookup | null, | ||
| ): Promise<void> { | ||
| const spin = prompts.spinner() | ||
| spin.start(`Creating workspace "${name}"...`) | ||
| let created: Awaited<ReturnType<typeof WorkspaceApi.createAndBind>> | ||
| // Discriminated on how the workspace was made, because the two creates return | ||
| // genuinely different things: only `bound` carries a server binding row and a | ||
| // manage_url. An optional-field shape let the rest of this function reach for | ||
| // `binding` on the path that never has one and silently fall through to a | ||
| // default. (review, PR #1314) | ||
| type Created = | ||
| | { via: "bound"; datamate: DatamateRef; binding: Binding; manage_url: string } | ||
| | { via: "unbound"; datamate: DatamateRef } | ||
| let created: Created | ||
| // Captured BEFORE the create and re-checked before the rebind. Each request | ||
| // resolves credentials on its own, so an account switch in between would | ||
| // create the workspace on one tenant and rebind on another using an id that | ||
| // is local to the first. (review, PR #1314) | ||
| const account = await WorkspaceApi.accountFingerprint().catch(() => null) | ||
| try { | ||
| created = await WorkspaceApi.createAndBind({ name, identifier }) | ||
| // Two different creates, because the server offers two different things. | ||
| // | ||
| // Unlinked: ``createAndBind`` creates and binds in ONE transaction, so a | ||
| // conflicting binding can never strand a half-created workspace. | ||
| // | ||
| // Already linked: that same atomicity makes it unusable. ``create_and_bind`` | ||
| // pre-checks the identifiers and 409s *before* creating anything, so the | ||
| // rebind below never got a target and this row simply always failed — with | ||
| // an error telling the user to re-run the command they were already inside | ||
| // (AI-9171). Create unbound first, then repoint, which is what the row's | ||
| // own hint promises. | ||
| if (existing) { | ||
| const ws = await WorkspaceApi.createWorkspaceUnbound({ name }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Pin the account across the two-step operation
Reply with
cubic-dev-ai[bot] marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major: This path is now three sequential requests (
Suggest pinning one credential snapshot across the whole create/rebind/cache sequence (or having |
||
| created = { via: "unbound", datamate: ws } | ||
| } else { | ||
| const res = await WorkspaceApi.createAndBind({ name, identifier }) | ||
| created = { via: "bound", datamate: res.datamate, binding: res.binding, manage_url: res.manage_url } | ||
| } | ||
| } catch (err) { | ||
| spin.stop("Failed to create workspace.", 1) | ||
| // A 409 from create means someone else's binding on the same | ||
| // remote/path beat us. If the pre-check already knew about it, the user | ||
| // can pick from the list; if the pre-check missed it, this is the | ||
| // authoritative signal — surface it and hint the picker. | ||
| // Split by which call actually ran, because they cannot 409 for the same | ||
| // reason. `createAndBind` sends the project identifiers, so its conflict is | ||
| // a binding race. The unbound create sends none — it cannot produce an | ||
| // identity conflict at all, so attributing one there would have sent the | ||
| // user looking for a race that did not happen. (review, PR #1314) | ||
| if (err instanceof ConflictError) { | ||
| const existingName = conflictExistingName(err.detail) | ||
| prompts.log.error( | ||
| `This project is already linked to "${existingName}". Re-run \`altimate-code link\` to switch to a different workspace.`, | ||
| ) | ||
| if (existing) { | ||
| prompts.log.error( | ||
| `The workspace could not be created: ${err.message}. Nothing was created, and this ` + | ||
| `project is still linked to "${stripControlChars(existing.datamate.name)}".`, | ||
| ) | ||
| } else { | ||
| const existingName = conflictExistingName(err.detail) | ||
| prompts.log.error( | ||
| `Another workspace, "${existingName}", claimed this project while you were choosing. ` + | ||
| `Nothing was created. Run \`altimate-code link\` again to see the current list.`, | ||
| ) | ||
| } | ||
| } else { | ||
| prompts.log.error(err instanceof Error ? err.message : String(err)) | ||
| } | ||
|
|
@@ -510,20 +554,34 @@ async function createThenBindOrRebind( | |
| const safeCreatedName = stripControlChars(created.datamate.name) | ||
| spin.stop(`Workspace "${safeCreatedName}" created.`) | ||
|
|
||
| // If the project was already linked, the new workspace exists but the | ||
| // binding still points at the OLD workspace — rebind so the project is | ||
| // now bound to the freshly-created one. Otherwise createAndBind already | ||
| // wrote the binding as part of the atomic create; we're done. | ||
| // The already-linked path created an unbound workspace above, so the binding | ||
| // still points at the OLD one — repoint it now. The unlinked path already got | ||
| // its binding from the atomic create, so there is nothing left to do. | ||
| let reboundBinding: Binding | null = null | ||
| if (existing) { | ||
| const rebindSpin = prompts.spinner() | ||
| rebindSpin.start(`Repointing project at "${safeCreatedName}"...`) | ||
| // The workspace exists on the account that was in effect a moment ago, and | ||
| // its id means nothing anywhere else. Rebinding under a different account | ||
| // would point this project at whatever id collides there. | ||
| if (account && !(await WorkspaceApi.sameAccount(account))) { | ||
| rebindSpin.stop("Could not repoint the project.", 1) | ||
| prompts.log.error( | ||
| `The signed-in account changed while "${safeCreatedName}" was being created, so it was ` + | ||
| `not linked to this project. The workspace exists on the previous account. Re-run ` + | ||
| `\`altimate-code link\` to link this project on the account you are on now.`, | ||
| ) | ||
| process.exitCode = 1 | ||
| return | ||
| } | ||
| try { | ||
| await rebindByMatchedIdentifier({ | ||
| const res = await rebindByMatchedIdentifier({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: If the project remote or path changes after the pre-check, this call sends the new (Based on your team's feedback about preserving relinked binding identity.) Prompt for AI agents |
||
| identifier, | ||
| targetDatamateId: created.datamate.id, | ||
| expectedCurrentDatamateId: existing.datamate.id, | ||
| matchedBy: existing.matchedBy, | ||
| }) | ||
| reboundBinding = res.binding | ||
| rebindSpin.stop(`Project is now linked to "${safeCreatedName}".`) | ||
| } catch (err) { | ||
| rebindSpin.stop("Could not repoint the project.", 1) | ||
|
|
@@ -537,23 +595,35 @@ async function createThenBindOrRebind( | |
| // Prefer the canonicalized ``identifier.projectPath`` over the raw | ||
| // ``--directory`` argument so ``altimate-code link -d ./myproj`` and its | ||
| // symlink-resolved twin both write under the same cache key (Kilo cycle 6). | ||
| // Whichever call last wrote the row is what gets cached. `createAndBind` | ||
| // returns it directly; the unbound path gets it from the rebind. Caching the | ||
| // local identifiers instead would record fields the server never stored — a | ||
| // 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, { | ||
| datamateId: created.datamate.id, | ||
| datamateName: created.datamate.name, | ||
| repoRemote: created.binding.repo_remote, | ||
| projectPath: created.binding.project_path, | ||
| repoRemote: serverBinding?.repo_remote ?? identifier.repoRemote ?? null, | ||
| projectPath: serverBinding?.project_path ?? identifier.projectPath ?? null, | ||
|
Comment on lines
+607
to
+608
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '400,490p' packages/opencode/src/altimate/workspace/api-client.ts
sed -n '540,635p' packages/opencode/src/cli/cmd/link.ts
sed -n '530,620p' packages/opencode/src/plugin/tui/altimate/workspace.tsx
sed -n '650,790p' packages/opencode/src/altimate/workspace/state.ts
rg -n -C 3 'rebindByRemote|rebindByPath|recordApprovedBinding|repo_remote|project_path' packages/opencode/src packages/opencode/test/altimate/workspaceRepository: AltimateAI/altimate-code Length of output: 50380 🏁 Script executed: set -o pipefail
printf '%s\n' '--- api-client binding types and methods ---'
rg -n -C 12 'type BindingResponse|interface BindingResponse|BindingResponse|rebindBy(Remote|Path)|/by-(remote|path)|bindExisting' packages/opencode/src/altimate/workspace/api-client.ts
printf '%s\n' '--- CLI rebind helper ---'
sed -n '750,815p' packages/opencode/src/cli/cmd/link.ts
printf '%s\n' '--- TUI rebind helper ---'
rg -n -C 18 'function rebindByMatchedIdentifier|const rebindByMatchedIdentifier|rebindByMatchedIdentifier' packages/opencode/src/plugin/tui/altimate/workspace.tsx
printf '%s\n' '--- focused rebind tests ---'
rg -n -C 10 'by-path|by-remote|repo_remote: null|project_path: null|reboundBinding|serverBinding' packages/opencode/test/altimate/workspace/create-then-rebind.test.ts packages/opencode/test/altimate/workspace/*.test.tsRepository: AltimateAI/altimate-code Length of output: 50380 🏁 Script executed: printf '%s\n' '--- binding declarations ---'
sed -n '1,115p' packages/opencode/src/altimate/workspace/api-client.ts
printf '%s\n' '--- cache binding declaration and persistence ---'
rg -n -C 8 'interface CachedBinding|type CachedBinding|sameBinding|bindings:' packages/opencode/src/altimate/workspace/state.ts
printf '%s\n' '--- create flow invariant around serverBinding ---'
sed -n '520,615p' packages/opencode/src/cli/cmd/link.tsRepository: AltimateAI/altimate-code Length of output: 14298 Persist only the server binding fields. Require 🤖 Prompt for AI Agents |
||
| linkedAt: Date.now(), | ||
| }, { awaitBackfill: true }) | ||
| prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.") | ||
| prompts.log.info(`Manage it at: ${created.manage_url}`) | ||
| // Guard against a server that hands back a non-http(s) manage_url — ``open`` | ||
| // delegates to the OS handler, so a rogue value could launch an unrelated | ||
| // application. Log a warning and skip the auto-open rather than trusting | ||
| // whatever protocol the URL parses to. | ||
| if (isSafeHttpUrl(created.manage_url)) { | ||
| await open(created.manage_url).catch(() => undefined) | ||
| } else { | ||
| prompts.log.warn(`Skipped auto-open: manage_url is not an http/https URL.`) | ||
| // ``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. | ||
| const manageUrl = created.via === "bound" ? created.manage_url : await manageUrlFor(created.datamate.id) | ||
| if (manageUrl) { | ||
| prompts.log.info(`Manage it at: ${manageUrl}`) | ||
| // Guard against a server that hands back a non-http(s) manage_url — ``open`` | ||
| // delegates to the OS handler, so a rogue value could launch an unrelated | ||
| // application. Log a warning and skip the auto-open rather than trusting | ||
| // whatever protocol the URL parses to. | ||
| if (isSafeHttpUrl(manageUrl)) { | ||
| await open(manageUrl).catch(() => undefined) | ||
| } else { | ||
| prompts.log.warn(`Skipped auto-open: manage_url is not an http/https URL.`) | ||
| } | ||
| } | ||
| prompts.outro("Done.") | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ import { | |
| NotFoundError, | ||
| PreconditionFailedError, | ||
| WorkspaceApi, | ||
| type Binding, | ||
| type DatamateRef, | ||
| type MatchedIdentifier, | ||
| type ProjectBindingLookup, | ||
|
|
@@ -493,22 +494,48 @@ function toastHandoffFailure(api: TuiPluginApi, result: Extract<HandoffResult, { | |
| } | ||
| } | ||
|
|
||
| async function createAndBindInline( | ||
| /** Exported for tests — see `createThenBindOrRebind`. This is the TUI's copy of | ||
| * the same flow, and it carried the same bug. */ | ||
| export async function createAndBindInline( | ||
| api: TuiPluginApi, | ||
| identifier: ProjectIdentifier, | ||
| name: string, | ||
| /** When present, this project is already bound to another workspace. | ||
| * createAndBind succeeds but leaves the binding pointing at the OLD | ||
| * workspace; without this rebind step the new workspace is an orphaned | ||
| * (billable) SaaS resource the CLI knows nothing about (M2). */ | ||
| /** When present, this project is already bound to another workspace — which | ||
| * changes WHICH create runs, not just whether a rebind follows. See the note | ||
| * in the body: the atomic create-and-bind cannot be used here, because the | ||
| * server refuses it before creating anything. Without the rebind that follows | ||
| * the unbound create, the new workspace is an orphaned (billable) SaaS | ||
| * resource the CLI knows nothing about (M2). */ | ||
| rebindFrom?: { expectedCurrentDatamateId: number; matchedBy: MatchedIdentifier }, | ||
| ): Promise<void> { | ||
| api.ui.dialog.clear() | ||
| let res: Awaited<ReturnType<typeof WorkspaceApi.createAndBind>> | ||
| // The same split the CLI makes in `cli/cmd/link.ts` (AI-9171), for the same | ||
| // reason. `createAndBind` pre-checks the project identifiers server-side and | ||
| // 409s BEFORE creating anything, so on an already-linked project the catch | ||
| // below fired and the rebind further down was unreachable — this row never | ||
| // worked. The `rebindFrom` comment above described the opposite ("creates and | ||
| // binds, leaving the binding pointing at the OLD workspace"); that was the | ||
| // assumption the bug rested on. Create unbound first, then repoint. | ||
| type Created = | ||
| | { via: "bound"; datamate: DatamateRef; binding: Binding } | ||
| | { via: "unbound"; datamate: DatamateRef } | ||
| let res: Created | ||
| // Captured before the create and re-checked before the rebind: the two | ||
| // requests resolve credentials independently, and a workspace id means | ||
| // nothing on a different account. | ||
| const account = await WorkspaceApi.accountFingerprint().catch(() => null) | ||
| try { | ||
| res = await WorkspaceApi.createAndBind({ name, identifier }) | ||
| if (rebindFrom) { | ||
| const ws = await WorkspaceApi.createWorkspaceUnbound({ name }) | ||
| res = { via: "unbound", datamate: ws } | ||
| } else { | ||
| const created = await WorkspaceApi.createAndBind({ name, identifier }) | ||
| res = { via: "bound", datamate: created.datamate, binding: created.binding } | ||
| } | ||
| } catch (err) { | ||
| if (err instanceof ConflictError) { | ||
| // Only the bound path sends identifiers, so only it can lose an identity | ||
| // race. The unbound create sends none. | ||
| if (err instanceof ConflictError && !rebindFrom) { | ||
| api.ui.toast({ | ||
| variant: "warning", | ||
| message: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Use the palette's "Link this project to a workspace" to change.`, | ||
|
|
@@ -522,19 +549,27 @@ async function createAndBindInline( | |
| return | ||
| } | ||
|
|
||
| let reboundBinding: Binding | null = null | ||
| if (rebindFrom) { | ||
| // The atomic create-and-bind wrote a NEW binding for the new workspace, | ||
| // but the existing binding for THIS project's remote/path still points | ||
| // at the old workspace. Repoint via the matched-identifier rebind | ||
| // endpoint. If rebind fails, tell the user the workspace exists but | ||
| // the link didn't switch — do not silently orphan. | ||
| // The workspace above was created UNBOUND, so this project's binding still | ||
| // points at the old one. Repoint it. If this fails the workspace exists but | ||
| // the link did not switch — say so rather than silently orphan it. | ||
| if (account && !(await WorkspaceApi.sameAccount(account))) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: After the unbound create, changing credentials between Prompt for AI agents |
||
| api.ui.toast({ | ||
| variant: "error", | ||
| message: `The signed-in account changed while "${res.datamate.name}" was being created, so it was not linked to this project. The workspace exists on the previous account.`, | ||
| duration: 15_000, | ||
| }) | ||
| return | ||
| } | ||
| try { | ||
| await rebindByMatchedIdentifier({ | ||
| const rebound = await rebindByMatchedIdentifier({ | ||
| identifier, | ||
| targetDatamateId: res.datamate.id, | ||
| expectedCurrentDatamateId: rebindFrom.expectedCurrentDatamateId, | ||
| matchedBy: rebindFrom.matchedBy, | ||
| }) | ||
| reboundBinding = rebound.binding | ||
| } catch (err) { | ||
| api.ui.toast({ | ||
| variant: "error", | ||
|
|
@@ -545,6 +580,10 @@ async function createAndBindInline( | |
| } | ||
| } | ||
|
|
||
| // Whichever call actually wrote the server row: the atomic create returns it, | ||
| // the unbound path gets it from the rebind. | ||
| const serverBinding = res.via === "bound" ? res.binding : reboundBinding | ||
|
|
||
| // Post-success tail — this function is invoked fire-and-forget | ||
| // (``void createAndBindInline(...)``), so a bare rejection here would | ||
| // surface as an unhandled promise and terminate the TUI. Contain the | ||
|
|
@@ -557,8 +596,8 @@ async function createAndBindInline( | |
| await recordApprovedBinding(api.state.path.directory, { | ||
| datamateId: res.datamate.id, | ||
| datamateName: res.datamate.name, | ||
| repoRemote: res.binding.repo_remote, | ||
| projectPath: res.binding.project_path, | ||
| repoRemote: serverBinding?.repo_remote ?? identifier.repoRemote ?? null, | ||
| projectPath: serverBinding?.project_path ?? identifier.projectPath ?? null, | ||
| linkedAt: Date.now(), | ||
| }) | ||
| await showLinkedConfirmation(api, "Created", res.datamate.id, res.datamate.name) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: When two users use the same API URL and tenant,
sameAccountreturns true becauseaccountFingerprintdropsc.apiKey, so a workspace created under user A can be rebound under user B using the same tenant-local ID. Compare a non-secret API-key fingerprint as part of the account identity instead of treating the API key as irrelevant.(Based on your team's feedback about account-scoped ownership.)
View Feedback
Prompt for AI agents