From eb2b77b1ecb8e2438b3e5ea03a6abd8662e356d1 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 21:12:58 +0200 Subject: [PATCH 1/3] fix(deploy): existing-persona lookup uses /deployments list (not phantom /agents) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findExistingAgent` was calling `GET /workspaces/{ws}/agents?persona_slug=`. That route is a dashboard proxy to an external gateway: it requires session-cookie auth and 403s for the `cli:auth` Bearer tokens the CLI uses. The actual list of deployed personas — the `agents` table reader added in cloud#580 — lives at `GET /workspaces/{ws}/deployments`, accepts `cli:auth`, and returns `{agents:[{agentId, personaId, deployedName, status, ...}]}`. Why no `?personaId=` server-side filter: `agents.personaId` on the cloud schema is a UUID FK to `personas.id`. The CLI only knows the persona's slug (the local persona JSON's `id` field). Sending the slug as `personaId=` makes cloud's drizzle predicate throw on the UUID cast → 500. The list is workspace-scoped (dozens of rows, not thousands), so the right tradeoff is fetching unfiltered and matching client-side against `deployedName` (which cloud derives from `persona.slug || persona.name || persona.id`), `personaSlug`, or `personaId` as a fallback. Changes: * `findExistingAgent` → call `/deployments` (no query string). * `parseAgentLike` accepts both `{agentId}` (new) and `{id}` (legacy preview shape). When `expectedPersonaId` is supplied, match it against any persona-identifying field on the row (deployedName, personaSlug, personaId). Rows with NO persona info at all (legacy preview shape that pre-filtered server-side) still pass through. * Treat `status === 'destroyed'` as "not present" so a re-deploy with the same slug doesn't trip on-exists against a tombstone. * Rewrite test mocks to disambiguate the listing GET from the deploy POST via `init.method` (both now share the same URL). * Added two new tests: full /deployments shape parsing (with tombstone + wrong-persona rows skipped), and ordering guarantee (listing GET fires before deploy POST). Smoke against production cloud with the user's actual Anthropic- connected workspace: no more 403 on the existing-persona check, no more 500 on the personaId filter. Bundle upload now reaches cloud's deploy handler. (Next failure: cloud Lambda missing `@parcel/watcher-linux-x64-glibc` native binary — server packaging issue, separate PR.) Co-Authored-By: Claude Opus 4.7 --- packages/deploy/src/modes/cloud.test.ts | 140 ++++++++++++++---- packages/deploy/src/modes/cloud.ts | 78 ++++++++-- .../deploy/src/modes/input-values.test.ts | 3 + 3 files changed, 184 insertions(+), 37 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index 1f3512e..b06a86a 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -169,7 +169,7 @@ test('cloud launcher POSTs a deploy bundle and returns the cloud handle', async }, input: { inputs: { topic: 'AI' } }, fetch(url, init) { - if (url.endsWith('/agents?persona_slug=demo')) { + if (init?.method === 'GET' && url.endsWith('/deployments')) { return okJson({ agents: [] }); } assert.equal(url, 'https://cloud.example.test/api/v1/workspaces/ws-test/deployments'); @@ -193,12 +193,12 @@ test('cloud URL precedence is flag env, cloud env, persona deployUrl, then defau const { calls } = await launch({ env, persona: spec, - fetch(url) { - if (url.includes('/agents?persona_slug=')) return okJson({ agents: [] }); + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); } }); - return calls.find((call) => call.url.endsWith('/deployments'))?.url; + return calls.find((call) => call.init?.method === 'POST' && call.url.endsWith('/deployments'))?.url; } const personaWithUrl = persona() as unknown as Omit & { cloud: { deployUrl: string } }; @@ -235,7 +235,7 @@ test('cloud harness plan and BYOK save provider credentials through the cloud co assert.equal(init?.body, undefined); return okJson({ providerCredentialId: 'cred-plan' }); } - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: 'agent-plan', deploymentId: 'dep-plan', status: 'active' }, 201); } @@ -259,7 +259,7 @@ test('cloud harness plan and BYOK save provider credentials through the cloud co }); return okJson({ providerCredentialId: 'cred-byok' }); } - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: 'agent-byok', deploymentId: 'dep-byok', status: 'active' }, 201); } @@ -280,7 +280,7 @@ test('cloud BYOK provider detection avoids substring false positives', async () assert.equal(JSON.parse(String(init?.body)).modelProvider, 'my-openai-alternative'); return okJson({ providerCredentialId: 'cred-byok' }); } - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: 'agent-byok', deploymentId: 'dep-byok', status: 'active' }, 201); } @@ -321,7 +321,7 @@ test('cloud harness OAuth probe hits /api/v1/cloud-agents and honors no-prompt f WORKFORCE_DEPLOY_NO_PROMPT: '1' }, input: { harnessSource: 'oauth' }, - fetch(url) { + fetch(url, init) { throw new Error(`unexpected URL ${url}`); } }), @@ -367,8 +367,8 @@ test('cloud harness OAuth probe treats a matching connected entry as ready (skip WORKFORCE_DEPLOY_NO_PROMPT: '1' }, input: { harnessSource: 'oauth' }, - fetch(url) { - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson( { agentId: 'agent-oauth-connected', deploymentId: 'dep-1', status: 'active' }, @@ -422,7 +422,7 @@ test('cloud harness OAuth probe ignores entries with the wrong harness', async ( }, input: { harnessSource: 'oauth' }, persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }), - fetch(url) { + fetch(url, init) { throw new Error(`unexpected URL ${url}`); } }), @@ -464,8 +464,8 @@ test('cloud harness OAuth starts auth and polls /cloud-agents until the harness const io = createBufferedIO(); io.scriptConfirmations([true]); const { bundle, cleanup } = await withBundle(); - const fetchMock = installFetch((url) => { - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + const fetchMock = installFetch((url, init) => { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: 'agent-oauth', deploymentId: 'dep-oauth', status: 'active' }, 201); } @@ -501,8 +501,8 @@ test('cloud launcher maps 401 deploy responses to the workforce login guidance', await assert.rejects( launch({ env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, - fetch(url) { - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); return okJson({ error: 'Unauthorized' }, 401); } }), @@ -514,8 +514,8 @@ test('cloud launcher retries retryable network failures three times', async () = let deployAttempts = 0; const { calls, handle } = await launch({ env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, - fetch(url) { - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); deployAttempts += 1; if (deployAttempts < 3) { throw new Error('temporary network failure'); @@ -525,16 +525,22 @@ test('cloud launcher retries retryable network failures three times', async () = }); assert.equal(handle.id, 'agent-1'); - assert.equal(callsForUrl(calls, '/deployments'), 3); + // 3 POST attempts (2 failed + 1 success). The listing GET is filtered + // out so the retry count remains exact regardless of the existing-agent + // preflight call. + assert.equal( + calls.filter((c) => c.init?.method === 'POST' && c.url.endsWith('/deployments')).length, + 3 + ); }); test('cloud polling resolves done with code 0 on active and 1 on failed', async () => { for (const finalStatus of ['active', 'failed'] as const) { const { bundle, cleanup } = await withBundle(); const io = createBufferedIO(); - const fetchMock = installFetch((url) => { + const fetchMock = installFetch((url, init) => { if (url.includes('/provider-credentials/managed')) return okJson({ providerCredentialId: 'cred-1' }); - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: `agent-${finalStatus}`, deploymentId: `dep-${finalStatus}`, status: 'starting' }, 201); } @@ -573,7 +579,7 @@ test('cloud stop calls the destroy agent endpoint', async () => { const io = createBufferedIO(); const fetchMock = installFetch((url, init) => { if (url.includes('/provider-credentials/managed')) return okJson({ providerCredentialId: 'cred-1' }); - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); } @@ -606,9 +612,9 @@ test('cloud stop calls the destroy agent endpoint', async () => { test('cloud launcher leaves integration preflight to the deploy orchestrator', async () => { const io = createBufferedIO(); const { bundle, cleanup } = await withBundle(); - const fetchMock = installFetch((url) => { + const fetchMock = installFetch((url, init) => { if (url.includes('/provider-credentials/managed')) return okJson({ providerCredentialId: 'cred-1' }); - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); if (url.endsWith('/deployments')) { return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); } @@ -643,7 +649,7 @@ test('cloud existing-persona stage honors destroy and cancel choices', async () WORKFORCE_DEPLOY_ON_EXISTS: 'destroy' }, fetch(url, init) { - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [{ id: 'agent-old' }] }); + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [{ id: 'agent-old' }] }); if (url.endsWith('/agents/agent-old/destroy')) { assert.equal(init?.method, 'POST'); return okJson({ ok: true }); @@ -662,15 +668,95 @@ test('cloud existing-persona stage honors destroy and cancel choices', async () WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', WORKFORCE_DEPLOY_ON_EXISTS: 'cancel' }, - fetch(url) { - if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agent: { id: 'agent-old' } }); + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agent: { id: 'agent-old' } }); throw new Error(`unexpected URL ${url}`); } }); assert.equal(cancel.handle.id, 'agent-old'); assert.equal(cancel.handle.status, 'cancelled'); assert.equal((await cancel.handle.done).code, 0); - assert.equal(cancel.calls.some((call) => call.url.endsWith('/deployments')), false); + // No deploy POST should fire — the listing GET is expected and not what + // this assertion is guarding against. + assert.equal( + cancel.calls.some((call) => call.init?.method === 'POST' && call.url.endsWith('/deployments')), + false + ); +}); + +test('findExistingAgent: parses the new /deployments shape ({agentId, personaId, status})', async () => { + // Regression for the production blocker: cloud#580 changed the list + // shape from {agent:{id}} → {agents:[{agentId, personaId, status}]}. + // We must accept the new keys (agentId) and still filter out + // destroyed tombstones + persona-id mismatches. + const result = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_ON_EXISTS: 'cancel' + }, + fetch(url, init) { + if (url.endsWith('/deployments')) { + return okJson({ + agents: [ + // Destroyed tombstone for the same persona — must be skipped. + { + agentId: 'agent-destroyed', + personaId: 'demo', + status: 'destroyed', + createdAt: '2026-05-12T00:00:00.000Z' + }, + // Different persona — must be skipped even though the + // server-side filter should already exclude it. + { + agentId: 'agent-wrong-persona', + personaId: 'something-else', + status: 'active', + createdAt: '2026-05-13T00:00:00.000Z' + }, + // The actual match. + { + agentId: 'agent-current', + personaId: 'demo', + status: 'active', + createdAt: '2026-05-13T12:00:00.000Z' + } + ], + nextCursor: null + }); + } + throw new Error(`unexpected URL ${url}`); + } + }); + assert.equal(result.handle.id, 'agent-current'); + assert.equal(result.handle.status, 'cancelled'); +}); + +test('findExistingAgent: empty agents array means "no existing deployment"', async () => { + const { handle, calls } = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) { + return okJson({ agents: [], nextCursor: null }); + } + if (init?.method === 'POST' && url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-fresh', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url} (${init?.method})`); + } + }); + assert.equal(handle.id, 'agent-fresh'); + // The list lookup must have fired before the deploy POST. + const getIndex = calls.findIndex( + (c) => c.init?.method === 'GET' && c.url.endsWith('/deployments') + ); + const postIndex = calls.findIndex( + (c) => c.init?.method === 'POST' && c.url.endsWith('/deployments') + ); + assert.notEqual(getIndex, -1); + assert.notEqual(postIndex, -1); + assert.ok(getIndex < postIndex, 'listing GET must precede deploy POST'); }); function callsForUrl(calls: FetchCall[], suffix: string): number { diff --git a/packages/deploy/src/modes/cloud.ts b/packages/deploy/src/modes/cloud.ts index 6a84c1b..3f10cc5 100644 --- a/packages/deploy/src/modes/cloud.ts +++ b/packages/deploy/src/modes/cloud.ts @@ -469,6 +469,26 @@ async function handleExistingPersona(args: { return { cancelled: false }; } +/** + * Look up a deployed-persona row in the workspace, if any. + * + * We call the workspace deployments list — added in cloud#580 — and + * filter client-side. Why not `/workspaces/{ws}/agents`? That route is + * a dashboard proxy to an external gateway, requires session auth + * (cookie), and returns 403 for the cli:auth Bearer tokens this CLI + * uses. The deployments list is the actual `agents` table reader and + * accepts cli:auth scope. + * + * Why no `?personaId=` server-side filter? `agents.personaId` is the + * persona's UUID, not its slug. The CLI only knows the persona's slug + * (the `id` field in the local persona JSON). Sending the slug as + * `personaId=` makes cloud's drizzle `eq(agents.personaId, slug)` + * predicate throw on the UUID cast → 500. The list is bounded to one + * workspace's worth of agents (typically dozens), so a client-side + * filter on `deployedName` (which cloud derives from + * `persona.slug || persona.name || persona.id`) is the right tradeoff + * until cloud teaches the filter to accept slugs. + */ async function findExistingAgent(args: { cloudUrl: string; workspaceId: string; @@ -477,7 +497,7 @@ async function findExistingAgent(args: { }): Promise { const url = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent( args.workspaceId - )}/agents?persona_slug=${encodeURIComponent(args.personaId)}`; + )}/deployments`; const res = await fetch(url, { method: 'GET', headers: { @@ -487,12 +507,12 @@ async function findExistingAgent(args: { }); if (res.status === 404 || res.status === 405) return null; if (res.status === 401) { - throw new Error('cloud existing persona check failed: unauthorized. Run `workforce login` and retry.'); + throw new Error('cloud existing persona check failed: unauthorized. Run `agentworkforce login` and retry.'); } if (!res.ok) { throw new Error(`cloud existing persona check failed: ${res.status} ${await responseExcerpt(res)}`); } - return parseExistingAgent((await res.json()) as ExistingAgentResponse); + return parseExistingAgent((await res.json()) as ExistingAgentResponse, args.personaId); } async function resolveOnExists(args: { @@ -544,25 +564,63 @@ async function deleteAgent(args: { } } -function parseExistingAgent(body: ExistingAgentResponse): ExistingAgent | null { - const direct = parseAgentLike(body.agent); +function parseExistingAgent( + body: ExistingAgentResponse, + expectedPersonaId?: string +): ExistingAgent | null { + const direct = parseAgentLike(body.agent, expectedPersonaId); if (direct) return direct; if (Array.isArray(body.agents)) { for (const agent of body.agents) { - const parsed = parseAgentLike(agent); + const parsed = parseAgentLike(agent, expectedPersonaId); if (parsed) return parsed; } } return null; } -function parseAgentLike(value: unknown): ExistingAgent | null { +/** + * Coerce one row from a deploy-list response into the local + * `ExistingAgent` shape. Cloud's `/deployments` GET (per cloud#580) + * returns rows shaped `{ agentId, personaId (uuid), deployedName, status, ... }`; + * older preview routes used `{ id, slug, status }`. We accept both + * during the deploy-v1 rollout. + * + * When `expectedPersonaId` is supplied (the local persona JSON's `id`, + * which is a slug), we match against the human-readable identifiers on + * the row — `deployedName`, `slug`, or `personaSlug` — and explicitly + * skip when `personaId` is a UUID that won't match a slug. This is the + * client-side equivalent of the `?personaId=` filter that cloud + * doesn't safely accept yet. + */ +function parseAgentLike(value: unknown, expectedPersonaId?: string): ExistingAgent | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const record = value as Record; - if (typeof record.id !== 'string' || !record.id.trim()) return null; + const id = readFirstString(record, ['agentId', 'id']); + if (!id) return null; + if (expectedPersonaId) { + const personaCandidates = [ + readFirstString(record, ['deployedName']), + readFirstString(record, ['personaSlug', 'persona_slug', 'slug']), + // `personaId` is a UUID on the new endpoint and a slug on the + // legacy preview endpoint; trust an exact-string equality test + // either way. + readFirstString(record, ['personaId', 'persona_id']) + ].filter((candidate): candidate is string => Boolean(candidate)); + // If the row has any persona-identifying field at all, require an + // exact match. If the row has none (legacy preview shape — the + // server already filtered to one persona via path), let it through. + if (personaCandidates.length > 0 && !personaCandidates.includes(expectedPersonaId)) { + return null; + } + } + // Treat destroyed rows as "not present" so a re-deploy with the same + // persona slug doesn't trip the on-exists prompt against a tombstone. + const status = typeof record.status === 'string' ? record.status : undefined; + if (status === 'destroyed') return null; return { - id: record.id, - ...(typeof record.status === 'string' ? { status: record.status } : {}) + id, + ...(status ? { status } : {}) }; } diff --git a/packages/deploy/src/modes/input-values.test.ts b/packages/deploy/src/modes/input-values.test.ts index 8573560..8e89c30 100644 --- a/packages/deploy/src/modes/input-values.test.ts +++ b/packages/deploy/src/modes/input-values.test.ts @@ -248,9 +248,12 @@ test('cloud launcher includes inputs in persona bundle POST body', async () => { byokKey: 'sk-test' }); + // The deploy POST is identified by having a JSON body — the listing + // GET that now fires before deploy has no body. const deployCall = calls.find( (c) => c.url === 'https://cloud.example.com/api/v1/workspaces/ws-test/deployments' + && c.body !== undefined ); assert.equal(handle.id, 'agent-1'); assert.ok(deployCall, 'expected a POST to the deployments endpoint'); From 9bdae077efa2ea6f4f32afa1352ded506a06ccf2 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 21:19:23 +0200 Subject: [PATCH 2/3] review fixes: tighten persona-match + prefer newest active row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressing review findings on PR #120: * Workspace-scoped list rows without persona-identifying fields are no longer treated as matches. The "no persona fields → pass through" fallback was justified by legacy `{agent: {...}}` envelopes where the URL path implied persona-scoping, but cloud's new `/deployments` list is workspace-scoped — a row missing `deployedName`/`personaSlug`/ `personaId` could belong to any persona in the workspace, and acting on it would let on-exists destroy the wrong agent. The legacy envelope keeps its back-compat via a `requirePersonaMatch: false` opt-in; every array element from the new endpoint sets `requirePersonaMatch: true`. * When multiple rows match (destroy+redeploy race window, soft-delete windows), prefer the newest `active` row instead of whichever cloud returns first. Sort by status tier (active first) then `createdAt` descending. Previously the first parsed match won, which on most SQL backends meant insertion order — the opposite of what users expect. Tests: * `workspace-scoped list rows without persona-identifying fields are NOT matched` — proves the tightened filter ignores ambiguous rows. * `multiple active rows for the same persona — newest wins` — proves the newest-active tiebreaker. * `active row wins over an older active and over inactive rows` — proves status tier outranks createdAt. * `malformed array entries (null/empty) are skipped without throwing` — defensive parser regression guard. 101/101 deploy tests pass. Co-Authored-By: Claude Opus 4.7 --- packages/deploy/src/modes/cloud.test.ts | 154 +++++++++++++++++++++++- packages/deploy/src/modes/cloud.ts | 83 ++++++++++--- 2 files changed, 218 insertions(+), 19 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index b06a86a..53198b6 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -649,7 +649,20 @@ test('cloud existing-persona stage honors destroy and cancel choices', async () WORKFORCE_DEPLOY_ON_EXISTS: 'destroy' }, fetch(url, init) { - if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [{ id: 'agent-old' }] }); + if (init?.method === 'GET' && url.endsWith('/deployments')) { + // Workspace-scoped listing must identify the persona it belongs to + // (deployedName is what cloud derives from the slug). A row without + // any persona-identifying field is intentionally NOT treated as a + // match by the post-cloud#580 client-side filter. + return okJson({ + agents: [{ + id: 'agent-old', + deployedName: 'demo', + status: 'active', + createdAt: '2026-05-12T00:00:00.000Z' + }] + }); + } if (url.endsWith('/agents/agent-old/destroy')) { assert.equal(init?.method, 'POST'); return okJson({ ok: true }); @@ -759,6 +772,145 @@ test('findExistingAgent: empty agents array means "no existing deployment"', asy assert.ok(getIndex < postIndex, 'listing GET must precede deploy POST'); }); +test('findExistingAgent: workspace-scoped list rows without persona-identifying fields are NOT matched', async () => { + // The new /deployments endpoint is workspace-scoped, not persona-scoped. + // A row that lacks deployedName/personaSlug/personaId could belong to + // any persona in the workspace; client-side matching MUST refuse to + // treat it as "the persona we're deploying". Otherwise on-exists could + // act on the wrong agent. (Legacy `{agent:{id}}` envelope keeps its + // back-compat because the URL path implied persona-scoping; that + // path is exercised by the "cloud existing-persona stage" test.) + const result = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) { + return okJson({ + agents: [ + // Row with NO persona-identifying field — must be ignored. + { agentId: 'agent-mystery', status: 'active' } + ], + nextCursor: null + }); + } + if (init?.method === 'POST' && url.endsWith('/deployments')) { + // Deploy proceeds as if no existing agent was found. + return okJson({ agentId: 'agent-fresh', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url} (${init?.method})`); + } + }); + assert.equal(result.handle.id, 'agent-fresh'); +}); + +test('findExistingAgent: multiple active rows for the same persona — newest wins', async () => { + // During a destroy+redeploy race or a soft-delete window, the workspace + // can briefly hold two active rows for the same persona slug. The CLI + // should act on the newest one, not whichever cloud returns first in + // the unordered array. + const result = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_ON_EXISTS: 'cancel' + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) { + return okJson({ + agents: [ + { + agentId: 'agent-stale', + deployedName: 'demo', + status: 'active', + createdAt: '2026-05-12T00:00:00.000Z' + }, + { + agentId: 'agent-current', + deployedName: 'demo', + status: 'active', + createdAt: '2026-05-13T12:00:00.000Z' + }, + { + agentId: 'agent-tombstone', + deployedName: 'demo', + status: 'destroyed', + createdAt: '2026-05-13T13:00:00.000Z' + } + ], + nextCursor: null + }); + } + throw new Error(`unexpected URL ${url} (${init?.method})`); + } + }); + assert.equal(result.handle.id, 'agent-current', 'newest active row should win'); + assert.equal(result.handle.status, 'cancelled'); +}); + +test('findExistingAgent: active row wins over an older active and over inactive rows', async () => { + // Status tier (active > anything else) outranks createdAt — guards + // against picking a newer `failed` row over an older `active` one. + const result = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_ON_EXISTS: 'cancel' + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) { + return okJson({ + agents: [ + { + agentId: 'agent-active-old', + deployedName: 'demo', + status: 'active', + createdAt: '2026-05-10T00:00:00.000Z' + }, + { + agentId: 'agent-failed-new', + deployedName: 'demo', + status: 'failed', + createdAt: '2026-05-13T00:00:00.000Z' + } + ], + nextCursor: null + }); + } + throw new Error(`unexpected URL ${url} (${init?.method})`); + } + }); + assert.equal(result.handle.id, 'agent-active-old'); +}); + +test('findExistingAgent: malformed array entries (null/empty) are skipped without throwing', async () => { + const result = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_ON_EXISTS: 'cancel' + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) { + return okJson({ + agents: [ + null, + undefined, + {}, + { agentId: '' }, + { + agentId: 'agent-valid', + deployedName: 'demo', + status: 'active', + createdAt: '2026-05-13T00:00:00.000Z' + } + ], + nextCursor: null + }); + } + throw new Error(`unexpected URL ${url} (${init?.method})`); + } + }); + assert.equal(result.handle.id, 'agent-valid'); +}); + function callsForUrl(calls: FetchCall[], suffix: string): number { return calls.filter((call) => call.url.endsWith(suffix)).length; } diff --git a/packages/deploy/src/modes/cloud.ts b/packages/deploy/src/modes/cloud.ts index 3f10cc5..31deac8 100644 --- a/packages/deploy/src/modes/cloud.ts +++ b/packages/deploy/src/modes/cloud.ts @@ -564,19 +564,60 @@ async function deleteAgent(args: { } } +/** + * Pick the best-matching deployed-persona row out of a list response. + * + * Two shapes to handle: + * + * * Legacy preview: `{ agent: {...} }` — a single object the server + * already filtered by persona via the URL path. We accept it without + * persona-side matching (back-compat); destroyed-status guard still + * applies. + * * New workspace deployments list (cloud#580): `{ agents: [...] }` — + * workspace-scoped, NOT persona-scoped. We must filter each row by + * `expectedPersonaId` ourselves, must not assume rows have persona + * fields populated, and must prefer the newest `active` row when + * multiple match (rare but possible during destroy/redeploy races). + */ function parseExistingAgent( body: ExistingAgentResponse, expectedPersonaId?: string ): ExistingAgent | null { - const direct = parseAgentLike(body.agent, expectedPersonaId); + // Legacy single-object envelope: trust the server's path-level filter. + const direct = parseAgentLike(body.agent, expectedPersonaId, { + requirePersonaMatch: false + }); if (direct) return direct; - if (Array.isArray(body.agents)) { - for (const agent of body.agents) { - const parsed = parseAgentLike(agent, expectedPersonaId); - if (parsed) return parsed; - } + + if (!Array.isArray(body.agents)) return null; + // The list endpoint is workspace-scoped, not persona-scoped, so every + // matching row MUST identify the right persona. Skip rows that don't. + const matches: Array<{ agent: ExistingAgent; createdAt: number }> = []; + for (const value of body.agents) { + const parsed = parseAgentLike(value, expectedPersonaId, { + requirePersonaMatch: Boolean(expectedPersonaId) + }); + if (!parsed) continue; + const createdAtSrc = value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record).createdAt + : undefined; + const createdAtMs = typeof createdAtSrc === 'string' ? Date.parse(createdAtSrc) : NaN; + matches.push({ + agent: parsed, + createdAt: Number.isFinite(createdAtMs) ? createdAtMs : 0 + }); } - return null; + if (matches.length === 0) return null; + // Prefer active rows; within each tier, prefer the most recently + // created row so a destroy+redeploy race lands on the new agent + // instead of the stale one. + matches.sort((a, b) => { + const aActive = a.agent.status === 'active' ? 1 : 0; + const bActive = b.agent.status === 'active' ? 1 : 0; + if (aActive !== bActive) return bActive - aActive; + return b.createdAt - a.createdAt; + }); + return matches[0].agent; } /** @@ -586,14 +627,19 @@ function parseExistingAgent( * older preview routes used `{ id, slug, status }`. We accept both * during the deploy-v1 rollout. * - * When `expectedPersonaId` is supplied (the local persona JSON's `id`, - * which is a slug), we match against the human-readable identifiers on - * the row — `deployedName`, `slug`, or `personaSlug` — and explicitly - * skip when `personaId` is a UUID that won't match a slug. This is the - * client-side equivalent of the `?personaId=` filter that cloud - * doesn't safely accept yet. + * When `expectedPersonaId` is supplied and `requirePersonaMatch` is + * true, we match against the human-readable identifiers on the row — + * `deployedName`, `slug`/`personaSlug`, or `personaId` (slug form on + * the legacy endpoint). On the new workspace-scoped endpoint a row + * without any persona-identifying field is NOT treated as a match — + * the legacy "server pre-filtered the path so trust it" rationale + * doesn't apply when the listing covers the whole workspace. */ -function parseAgentLike(value: unknown, expectedPersonaId?: string): ExistingAgent | null { +function parseAgentLike( + value: unknown, + expectedPersonaId?: string, + opts: { requirePersonaMatch?: boolean } = {} +): ExistingAgent | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const record = value as Record; const id = readFirstString(record, ['agentId', 'id']); @@ -607,10 +653,11 @@ function parseAgentLike(value: unknown, expectedPersonaId?: string): ExistingAge // either way. readFirstString(record, ['personaId', 'persona_id']) ].filter((candidate): candidate is string => Boolean(candidate)); - // If the row has any persona-identifying field at all, require an - // exact match. If the row has none (legacy preview shape — the - // server already filtered to one persona via path), let it through. - if (personaCandidates.length > 0 && !personaCandidates.includes(expectedPersonaId)) { + if (personaCandidates.length === 0) { + // Caller decides: legacy `{agent: ...}` path may trust the server + // filter; workspace-scoped list must NOT. + if (opts.requirePersonaMatch) return null; + } else if (!personaCandidates.includes(expectedPersonaId)) { return null; } } From 6532ee6e83ba533beccb4f81d73b6ad86eacbec1 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 21:27:29 +0200 Subject: [PATCH 3/3] test(deploy): use deployments list in cancel fixture --- packages/deploy/src/modes/cloud.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index 53198b6..958ffe1 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -682,7 +682,17 @@ test('cloud existing-persona stage honors destroy and cancel choices', async () WORKFORCE_DEPLOY_ON_EXISTS: 'cancel' }, fetch(url, init) { - if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agent: { id: 'agent-old' } }); + if (init?.method === 'GET' && url.endsWith('/deployments')) { + return okJson({ + agents: [{ + agentId: 'agent-old', + deployedName: 'demo', + status: 'active', + createdAt: '2026-05-13T00:00:00.000Z' + }], + nextCursor: null + }); + } throw new Error(`unexpected URL ${url}`); } });