From 464d736620843d9555cfa3a4dab2696d03842cd5 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 23 May 2026 00:22:31 +0200 Subject: [PATCH] fix(deploy): accept pending/syncing/degraded as connected in preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud's deriveProviderState (provider-status.ts:214) emits one of: ready | pending | syncing | degraded | error. The preflight matcher only accepted "ready", which forced users to wait for Nango's initial sync to complete between OAuth completion and their first usable deploy — every freshly-connected integration sits in `pending` or `syncing` for a few minutes and tripped the "not connected" branch. Concretely the failure mode is: user runs `agentworkforce login`, deploys (gets prompted for github + slack, completes OAuth, deploy succeeds because the deploy run itself proceeds past the prompt), then re-deploys minutes later — preflight queries /me/integrations, sees status="pending"/"syncing", rejects, prompts OAuth a second time even though the connections are fully real. Fix: accept ready | pending | syncing | degraded. Reject only `error` and missing rows. From a persona's runtime perspective these all represent a live OAuth grant — writes go through immediately; sync-backed reads return data once Nango finishes. `error` stays rejected so users can repair a broken integration via fresh OAuth. Added test coverage for each of the 4 accepted states + the rejected error state. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deploy/src/connect.test.ts | 33 ++++++++++++++++++++++++++++ packages/deploy/src/connect.ts | 34 +++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts index d1017add..34af3e6e 100644 --- a/packages/deploy/src/connect.test.ts +++ b/packages/deploy/src/connect.test.ts @@ -120,6 +120,39 @@ test('relayfileIntegrationResolver isConnected falls back to provider-name match ); }); +for (const status of ['ready', 'pending', 'syncing', 'degraded'] as const) { + test(`relayfileIntegrationResolver isConnected accepts status="${status}" as connected`, async () => { + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + fetch: async () => okJson([{ provider: 'slack', providerConfigKey: 'slack-relay', status }]) + }); + assert.equal( + await resolver.isConnected({ workspace: 'ws-1', provider: 'slack' }), + true, + `status="${status}" should count as connected` + ); + }); +} + +test('relayfileIntegrationResolver isConnected rejects status="error"', async () => { + // A failed initial sync or errored writeback means the persona cannot + // rely on the integration at dispatch time. Re-prompt OAuth so the user + // can repair it instead of silently shipping a broken deploy. + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + fetch: async () => + okJson([{ provider: 'slack', providerConfigKey: 'slack-relay', status: 'error' }]) + }); + assert.equal( + await resolver.isConnected({ workspace: 'ws-1', provider: 'slack' }), + false + ); +}); + test('relayfileIntegrationResolver isConnected ignores rows with only a connectionId (no status)', async () => { // The previous matcher treated any truthy connectionId as connected. That // caused false positives whenever an abandoned OAuth left an orphan row. diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index 61978c41..734d0148 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -598,13 +598,30 @@ function listHasConnectedProvider( } /** - * A row counts as "connected" only when the cloud's derived state is - * affirmatively healthy. The previous implementation also accepted "any - * truthy connectionId" as a yes, which produced false positives whenever a - * stale row was left behind by an abandoned OAuth attempt. The cloud now - * derives `status` from `initialSync + writeback` (see - * `cloud/packages/web/app/api/v1/workspaces/[workspaceId]/integrations/route.ts:62`), - * so trusting that field is both correct and sufficient. + * A row counts as "connected" when the cloud's derived state represents a + * live OAuth grant, even if Nango's initial sync hasn't finished. The cloud + * derives `status` from `initialSync + writeback` and emits one of: + * + * - `ready` — sync complete, writeback healthy. Fully usable. + * - `pending` — OAuth grant exists, sync queued (the gap between OAuth + * completion and sync start). Persona can use it for + * writes immediately; reads will see data once sync runs. + * - `syncing` — initial sync running. Same operational status as `pending` + * from the persona's perspective. + * - `degraded` — sync complete but writeback lagging or paused. Connection + * still works; reading at-rest data is fine; new writes may + * queue but won't fail. + * - `error` — sync failed or writeback errored. Treat as not-connected + * so the user re-runs OAuth (or fixes the upstream cause). + * + * The preflight accepts everything except `error` and missing rows. The + * previous implementation accepted only `ready`, which forced users to wait + * for the initial sync to complete between `agentworkforce login` and their + * first deploy — every fresh integration sat in `pending`/`syncing` for a + * few minutes and tripped the "not connected" branch. + * + * Legacy fields (`connected`, `active`, `state`, `ready: true`, `oauth.connected`) + * are kept for compatibility with older cloud surfaces and the env resolver. */ function isConnectedStatus(value: unknown): boolean { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; @@ -612,6 +629,9 @@ function isConnectedStatus(value: unknown): boolean { return record.status === 'connected' || record.status === 'active' || record.status === 'ready' + || record.status === 'pending' + || record.status === 'syncing' + || record.status === 'degraded' || record.state === 'connected' || record.state === 'ready' || record.ready === true