-
Notifications
You must be signed in to change notification settings - Fork 134
fix(workspace): create a quick workspace from an already-linked project #1318
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. | ||||||||
| * 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 } | ||||||||
|
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: Include the authenticated principal in the account fingerprint URL and tenant do not uniquely identify the caller. Reply with |
||||||||
| } | ||||||||
|
|
||||||||
| /** 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 | ||||||||
| } | ||||||||
|
|
||||||||
| 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 | ||||||||
|
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: When Prompt for AI agents
Suggested change
|
||||||||
| 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 this fix, 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) | ||||||||||
|
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: Fail closed when the pre-create fingerprint cannot be captured Catching this as Reply with 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: Abort Prompt for AI agentsThere 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. P2: When credentials change while the workspace picker is open, this captures the new account but still uses Prompt for AI agents |
||||||||||
| 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 | ||||||||||
| // 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. P1: Pin one credential snapshot across the unbound create, account check, and rebind instead of rereading credentials for each request. A credential switch after Prompt for AI agents |
||||||||||
| 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({ | ||||||||||
| identifier, | ||||||||||
| targetDatamateId: created.datamate.id, | ||||||||||
| expectedCurrentDatamateId: existing.datamate.id, | ||||||||||
| matchedBy: existing.matchedBy, | ||||||||||
| }) | ||||||||||
|
Comment on lines
505
to
583
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 '360,490p' packages/opencode/src/altimate/workspace/api-client.ts
rg -n 'function rebindByMatchedIdentifier|const rebindByMatchedIdentifier|rebindByMatchedIdentifier|function req|const req|async function creds|const creds|function creds' packages/opencode/src/altimate packages/opencode/src/cli/cmd/link.ts packages/opencode/src/plugin/tui/altimate/workspace.tsx
sed -n '470,635p' packages/opencode/src/cli/cmd/link.ts
sed -n '490,610p' packages/opencode/src/plugin/tui/altimate/workspace.tsxRepository: AltimateAI/altimate-code Length of output: 30384 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- api-client request and rebind definitions ---'
sed -n '129,235p' packages/opencode/src/altimate/workspace/api-client.ts
sed -n '400,510p' packages/opencode/src/altimate/workspace/api-client.ts
printf '%s\n' '--- CLI rebind helper ---'
sed -n '750,820p' packages/opencode/src/cli/cmd/link.ts
printf '%s\n' '--- TUI rebind helper ---'
sed -n '645,710p' packages/opencode/src/plugin/tui/altimate/workspace.tsx
printf '%s\n' '--- credential implementation ---'
sed -n '1,125p' packages/opencode/src/altimate/api/client.ts
sed -n '220,280p' packages/opencode/src/altimate/api/client.tsRepository: AltimateAI/altimate-code Length of output: 21018 Pass one credential snapshot through both requests.
A changed API key alone does not prove an account change because key rotation for the same user and tenant is intentionally allowed. However, the requests still need one consistent credential set. Capture the complete 🤖 Prompt for AI Agents |
||||||||||
| 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. P2: When the rebind is path-keyed, the server returns (Based on your team's feedback about preserving relinked binding identity.) Prompt for AI agents
Suggested change
|
||||||||||
| linkedAt: Date.now(), | ||||||||||
| }, { awaitBackfill: true }) | ||||||||||
|
Comment on lines
+603
to
610
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 Pass the transaction scope to After the CLI rebind succeeds, Extend 🤖 Prompt for AI Agents |
||||||||||
| 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( | ||||||
|
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. P3: This PR was caused by two copies of the same create-then-bind flow drifting apart, and it now duplicates the fix into both. Prompt for AI agents |
||||||
| 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`, 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) | ||||||
|
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: Fail closed when the pre-create fingerprint cannot be captured This mirrors the CLI's fail-open gap: a fingerprint error becomes Reply with |
||||||
| 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: When the pre-create account fingerprint cannot be read, this guard skips the account check and still permits a recovered create/rebind with an unverified tenant-local workspace ID. Fail closed when Prompt for AI agents
Suggested change
|
||||||
| 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: Include a non-secret authenticated-principal discriminator, such as a digest of
c.apiKey, inaccountFingerprintand compare it insameAccount. URL and tenant alone do not distinguish users who share the same deployment.Prompt for AI agents