Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/deploy/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 27 additions & 7 deletions packages/deploy/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,20 +598,40 @@ 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;
const record = value as Record<string, unknown>;
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
Expand Down
Loading