Skip to content

Commit f2c4d32

Browse files
committed
fix(lifecycle): preview successor blockers and sync Project enforcement
2 parents b7c4cd1 + eef9d29 commit f2c4d32

184 files changed

Lines changed: 36637 additions & 1862 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env python3
2+
"""Read-only, fail-closed ECS retirement check for the Project column contract migration.
3+
4+
The expected digest acknowledges fully deployed #8830 transaction-barrier and durable-authority support
5+
and retirement of every incompatible server and relevant old worker job before switching. Every live and supported
6+
rollback application must tolerate project_workspace being absent.
7+
AWS checks below independently verify ECS retirement, not worker drainage.
8+
"""
9+
import argparse
10+
import datetime
11+
import json
12+
import re
13+
import subprocess
14+
import sys
15+
16+
17+
def aws(region, *args):
18+
result = subprocess.run(
19+
['aws', '--region', region, '--no-cli-pager', '--cli-connect-timeout', '10', '--cli-read-timeout', '30', *args, '--output', 'json'],
20+
capture_output=True, text=True, timeout=90, check=False,
21+
)
22+
if result.returncode:
23+
raise RuntimeError('AWS preflight read failed; check deployment-read permissions')
24+
return json.loads(result.stdout)
25+
26+
27+
def latest_execution(region, pipeline):
28+
executions = aws(region, 'codepipeline', 'list-pipeline-executions', '--pipeline-name', pipeline).get('pipelineExecutionSummaries', [])
29+
def epoch(execution):
30+
value = execution['startTime']
31+
return value if isinstance(value, (int, float)) else datetime.datetime.fromisoformat(value.replace('Z', '+00:00')).timestamp()
32+
if not executions:
33+
raise RuntimeError('No application deployment execution was found')
34+
return max(executions, key=epoch)
35+
36+
37+
def matches_release(execution, digest):
38+
return execution.get('status') == 'Succeeded' and any(
39+
revision.get('actionName') == 'ECR_Source' and revision.get('revisionId') == digest
40+
for revision in execution.get('sourceRevisions', [])
41+
)
42+
43+
44+
def verify(environment, region, digest):
45+
if not re.fullmatch(r'sha256:[0-9a-f]{64}', digest):
46+
raise RuntimeError('Set the environment-specific PROJECT_COLUMN_ENFORCEMENT_READY_IMAGE_DIGEST after verifying #8830 authority-aware readers/writers, transaction barriers and all incompatible server/worker drainage')
47+
pipeline = f'sim-{environment}-{region}-app-deployment'
48+
execution = latest_execution(region, pipeline)
49+
if not matches_release(execution, digest):
50+
raise RuntimeError('The latest app pipeline has not completed for the acknowledged image; traffic cutover alone is insufficient')
51+
execution_id = execution['pipelineExecutionId']
52+
group = aws(region, 'deploy', 'get-deployment-group', '--application-name', f'sim-{environment}-{region}-ecs-app',
53+
'--deployment-group-name', f'sim-{environment}-{region}-app-dg')['deploymentGroupInfo']
54+
services = group.get('ecsServices', [])
55+
if len(services) != 1:
56+
raise RuntimeError('Expected exactly one application ECS service')
57+
cluster, service = services[0]['clusterName'], services[0]['serviceName']
58+
description = aws(region, 'ecs', 'describe-services', '--cluster', cluster, '--services', service)
59+
if description.get('failures') or len(description.get('services', [])) != 1:
60+
raise RuntimeError('Cannot inspect the application ECS service')
61+
record = description['services'][0]
62+
if record.get('desiredCount', 0) < 1 or record.get('runningCount') != record['desiredCount'] or record.get('pendingCount') != 0:
63+
raise RuntimeError('Application ECS service is not stable')
64+
arns = set()
65+
for status in ('RUNNING', 'STOPPED'):
66+
arns.update(aws(region, 'ecs', 'list-tasks', '--cluster', cluster, '--service-name', service,
67+
'--desired-status', status).get('taskArns', []))
68+
live = []
69+
ordered = sorted(arns)
70+
for start in range(0, len(ordered), 100):
71+
response = aws(region, 'ecs', 'describe-tasks', '--cluster', cluster, '--tasks', *ordered[start:start + 100])
72+
if response.get('failures'):
73+
raise RuntimeError('Cannot account for every ECS task')
74+
if len(response.get('tasks', [])) != len(ordered[start:start + 100]):
75+
raise RuntimeError('Incomplete ECS task response')
76+
live.extend(task for task in response['tasks'] if task.get('lastStatus') != 'STOPPED')
77+
if len(live) != record['desiredCount']:
78+
raise RuntimeError('Old, stopping, or pending ECS tasks remain')
79+
for task in live:
80+
app = [container for container in task.get('containers', []) if container.get('name') == 'app']
81+
if task.get('lastStatus') != 'RUNNING' or task.get('desiredStatus') != 'RUNNING' or len(app) != 1 or app[0].get('imageDigest') != digest:
82+
raise RuntimeError('A live ECS task does not match the acknowledged compatible release')
83+
latest = latest_execution(region, pipeline)
84+
if latest.get('pipelineExecutionId') != execution_id or not matches_release(latest, digest):
85+
raise RuntimeError('Application deployment changed during preflight')
86+
print(json.dumps({'ecsRetired': True, 'expectedImageDigest': digest, 'pipelineExecutionId': execution_id,
87+
'operatorAcknowledgedColumnWritersAndWorkers': True}))
88+
89+
90+
if __name__ == '__main__':
91+
parser = argparse.ArgumentParser(description=__doc__)
92+
parser.add_argument('--environment', required=True, choices=['production', 'staging'])
93+
parser.add_argument('--region', required=True)
94+
parser.add_argument('--expected-image-digest', required=True)
95+
args = parser.parse_args()
96+
try:
97+
verify(args.environment, args.region, args.expected_image_digest)
98+
except (RuntimeError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as error:
99+
print(f'Project column rollout preflight refused: {error}', file=sys.stderr)
100+
sys.exit(1)

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ jobs:
112112
# in place before the new app version deploys (replaces the removed ECS
113113
# migration sidecar)
114114
migrate:
115+
permissions:
116+
contents: read
117+
id-token: write
115118
name: migrate
116119
needs: [checks]
117120
# Explicit need results instead of the implicit success(): a skipped job
@@ -131,9 +134,16 @@ jobs:
131134
STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
132135
MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }}
133136
STAGING_MIGRATION_DATABASE_URL: ${{ secrets.STAGING_MIGRATION_DATABASE_URL }}
137+
AWS_ROLE_TO_ASSUME: ${{ secrets.AWS_ROLE_TO_ASSUME }}
138+
STAGING_AWS_ROLE_TO_ASSUME: ${{ secrets.STAGING_AWS_ROLE_TO_ASSUME }}
139+
AWS_REGION: ${{ secrets.AWS_REGION }}
140+
STAGING_AWS_REGION: ${{ secrets.STAGING_AWS_REGION }}
134141

135142
# Same ordering for dev (schema push before the dev image lands in ECR)
136143
migrate-dev:
144+
permissions:
145+
contents: read
146+
id-token: write
137147
name: migrate-dev
138148
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
139149
uses: ./.github/workflows/migrate.yml

‎.github/workflows/migrate.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ on:
1818
required: false
1919
STAGING_MIGRATION_DATABASE_URL:
2020
required: false
21+
AWS_ROLE_TO_ASSUME:
22+
required: false
23+
STAGING_AWS_ROLE_TO_ASSUME:
24+
required: false
25+
AWS_REGION:
26+
required: false
27+
STAGING_AWS_REGION:
28+
required: false
2129
workflow_dispatch:
2230
inputs:
2331
environment:
@@ -31,6 +39,7 @@ on:
3139

3240
permissions:
3341
contents: read
42+
id-token: write
3443

3544
jobs:
3645
migrate:
@@ -51,6 +60,33 @@ jobs:
5160
provider: ${{ vars.CI_PROVIDER }}
5261
node-version: ''
5362

63+
- name: Check Project column expansion and whether enforcement needs rollout evidence
64+
id: project-contract
65+
if: inputs.environment != 'dev'
66+
working-directory: ./packages/db
67+
env:
68+
DATABASE_URL: ${{ inputs.environment == 'production' && secrets.DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_DATABASE_URL || '' }}
69+
MIGRATION_DATABASE_URL: ${{ inputs.environment == 'production' && secrets.MIGRATION_DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_MIGRATION_DATABASE_URL || '' }}
70+
run: bun --no-env-file scripts/project-contract-required.ts >> "$GITHUB_OUTPUT"
71+
72+
- name: Configure AWS for Project connector retirement verification
73+
if: steps.project-contract.outputs.required == 'true'
74+
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b
75+
with:
76+
role-to-assume: ${{ inputs.environment == 'production' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
77+
aws-region: ${{ inputs.environment == 'production' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
78+
79+
# Acknowledge #8830 only after authority-aware readers/writers deploy and all incompatible
80+
# servers and relevant old Trigger.dev runs drain. The runner commits the column-authority
81+
# switch before backfill, validation, and connector retirement; retries never revert it.
82+
# The preflight independently checks ECS retirement; it does not inspect worker runs.
83+
- name: Require completed authority-aware rollout before Project cutover
84+
if: steps.project-contract.outputs.required == 'true'
85+
env:
86+
ENVIRONMENT: ${{ inputs.environment }}
87+
EXPECTED_IMAGE_DIGEST: ${{ inputs.environment == 'production' && vars.PROJECT_COLUMN_ENFORCEMENT_READY_IMAGE_DIGEST_PRODUCTION || inputs.environment == 'staging' && vars.PROJECT_COLUMN_ENFORCEMENT_READY_IMAGE_DIGEST_STAGING || '' }}
88+
run: python3 .github/scripts/check-project-rollout.py --environment "$ENVIRONMENT" --region "$AWS_REGION" --expected-image-digest "$EXPECTED_IMAGE_DIGEST"
89+
5490
# The expression maps the explicit environment input to exactly one repo
5591
# secret, so the job never holds another environment's database URL. An
5692
# unknown environment resolves to empty and the guard below fails the job.

‎apps/desktop/e2e/fixtures/live-sim.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -651,9 +651,16 @@ export class SimDatabase {
651651
}
652652

653653
async close(): Promise<void> {
654-
if (this.userIds.length > 0)
655-
await this.sql`delete from "user" where id in ${this.sql(this.userIds)}`.catch(() => {})
656-
await this.sql.end({ timeout: 5 })
654+
try {
655+
if (this.userIds.length > 0)
656+
await this.sql.begin(async (tx) => {
657+
await tx`delete from workspace where owner_id in ${tx(this.userIds)}`
658+
await tx`delete from project where owner_id in ${tx(this.userIds)}`
659+
await tx`delete from "user" where id in ${tx(this.userIds)}`
660+
})
661+
} finally {
662+
await this.sql.end({ timeout: 5 })
663+
}
657664
}
658665

659666
/** A user with a workspace, a session as the desktop sign-in creates, and one chat per title. */
@@ -669,8 +676,10 @@ export class SimDatabase {
669676
await tx`insert into "user" (id, name, email, normalized_email, email_verified, created_at, updated_at)
670677
values (${userId}, ${name}, ${email}, ${email}, true, now(), now())`
671678
await tx`insert into user_stats (id, user_id) values (${generateId()}, ${userId})`
672-
await tx`insert into workspace (id, name, owner_id, billed_account_user_id)
673-
values (${workspaceId}, 'Desktop tools E2E', ${userId}, ${userId})`
679+
await tx`insert into project (id, name, owner_id)
680+
values (${workspaceId}, 'E2E fixture project', ${userId})`
681+
await tx`insert into workspace (id, project_id, name, owner_id, billed_account_user_id)
682+
values (${workspaceId}, ${workspaceId}, 'Desktop tools E2E', ${userId}, ${userId})`
674683
await tx`insert into permissions (id, user_id, entity_type, entity_id, permission_type)
675684
values (${generateId()}, ${userId}, 'workspace', ${workspaceId}, 'admin')`
676685
await tx`insert into session (id, token, user_id, user_agent, expires_at, created_at, updated_at)

‎apps/sim/app/api/copilot/chat/stream/desktop-executor.integration.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Redis through the production reconnect route, for the live tail and the replay batch a view
55
* attaches with after a reload.
66
*/
7+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
78
import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock'
89
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
910

@@ -72,7 +73,7 @@ describe.runIf(Boolean(redisUrl))('desktop executor binding on a chat stream', (
7273
createdAt: now,
7374
updatedAt: now,
7475
})
75-
await db.insert(workspace).values({
76+
await insertWorkspaceFixture(db, {
7677
id: workspaceId,
7778
name: 'Desktop stream fixture',
7879
ownerId: userId,
@@ -105,7 +106,7 @@ describe.runIf(Boolean(redisUrl))('desktop executor binding on a chat stream', (
105106
await db.delete(copilotChats).where(eq(copilotChats.id, chatId))
106107
await db.delete(desktopDevices).where(eq(desktopDevices.id, deviceId))
107108
await db.delete(permissions).where(eq(permissions.userId, userId))
108-
await db.delete(workspace).where(eq(workspace.id, workspaceId))
109+
await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
109110
await db.delete(user).where(eq(user.id, userId))
110111
await closeRedisConnection()
111112
})

‎apps/sim/app/api/knowledge/connectors/member-sync/route.integration.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
12
/**
23
* The member sync scheduler's reclaim against real PostgreSQL: a members-mode connector whose
34
* member lease went stale is put back on the failure ladder, and a connector in any other access
@@ -51,7 +52,7 @@ describe('member sync reclaim in PostgreSQL', () => {
5152
})
5253

5354
afterAll(async () => {
54-
await db.delete(workspace).where(eq(workspace.id, ids.workspaceId))
55+
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId))
5556
await db.delete(organization).where(eq(organization.id, ids.organizationId))
5657
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
5758
await db.$client.end()

‎apps/sim/app/api/v1/knowledge/route.integration.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import type { Principal } from '@sim/auth/principal'
88
import { db } from '@sim/db'
99
import { document, organization, user, workspace } from '@sim/db/schema'
10+
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
1011
import { authMock, authMockFns, createMockRequest } from '@sim/testing'
1112
import { generateId } from '@sim/utils/id'
1213
import { eq, inArray } from 'drizzle-orm'
@@ -85,7 +86,7 @@ describe('knowledge-base document totals in PostgreSQL', () => {
8586
})
8687

8788
afterAll(async () => {
88-
await db.delete(workspace).where(eq(workspace.id, ids.workspaceId))
89+
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId))
8990
await db.delete(organization).where(eq(organization.id, ids.organizationId))
9091
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
9192
vi.unstubAllGlobals()

‎apps/sim/app/api/webhooks/agentmail/thread-scope.integration.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
12
/**
23
* An inbox reply continues its parent task's chat, and the parent is found by the
34
* `In-Reply-To` header — a value every recipient of the agent's reply holds. Against
@@ -97,7 +98,7 @@ async function seedWorkspace(ws: InboxWorkspace): Promise<void> {
9798
createdAt: now,
9899
updatedAt: now,
99100
})
100-
await db.insert(workspace).values({
101+
await insertWorkspaceFixture(db, {
101102
id: ws.id,
102103
name: 'Inbox thread scope fixture',
103104
ownerId: ws.ownerId,
@@ -224,7 +225,7 @@ describe('inbox reply threading stays inside the receiving workspace', () => {
224225
afterAll(async () => {
225226
const ownerIds = [victim.ownerId, attacker.ownerId]
226227
await db.delete(permissions).where(inArray(permissions.userId, ownerIds))
227-
await db.delete(workspace).where(inArray(workspace.id, [victim.id, attacker.id]))
228+
await deleteWorkspaceFixture(db, inArray(workspace.id, [victim.id, attacker.id]))
228229
await db.delete(user).where(inArray(user.id, ownerIds))
229230
})
230231

‎apps/sim/background/cleanup-soft-deletes.integration.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,28 @@ async function seedRelease(deletedAt: string | null, sizeBytes: number, patch: n
4343

4444
describe('Retention cleanup of deleted owned bodies', () => {
4545
beforeAll(async () => {
46-
await control`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
47-
VALUES (${userId}, 'Test cleanup fixture', ${`${userId}@example.test`}, true, now(), now())`
48-
await control`INSERT INTO user_stats (id, user_id, storage_used_bytes)
49-
VALUES (${generateId()}, ${userId}, 100)`
50-
await control`INSERT INTO workspace (id, name, owner_id, billed_account_user_id, storage_used_bytes)
51-
VALUES (${workspaceId}, 'Test cleanup fixtures', ${userId}, ${userId}, 100)`
46+
await control.begin(async (tx) => {
47+
await tx`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
48+
VALUES (${userId}, 'Test cleanup fixture', ${`${userId}@example.test`}, true, now(), now())`
49+
await tx`INSERT INTO user_stats (id, user_id, storage_used_bytes)
50+
VALUES (${generateId()}, ${userId}, 100)`
51+
await tx`INSERT INTO project (id, name, owner_id)
52+
VALUES (${workspaceId}, 'Test cleanup project', ${userId})`
53+
await tx`INSERT INTO workspace (id, project_id, name, owner_id, billed_account_user_id, storage_used_bytes)
54+
VALUES (${workspaceId}, ${workspaceId}, 'Test cleanup fixtures', ${userId}, ${userId}, 100)`
55+
})
5256
})
5357

5458
afterAll(async () => {
55-
await control`DELETE FROM workspace WHERE id = ${workspaceId}`
56-
await control`DELETE FROM "user" WHERE id = ${userId}`
57-
await control.end()
59+
try {
60+
await control.begin(async (tx) => {
61+
await tx`DELETE FROM workspace WHERE id = ${workspaceId}`
62+
await tx`DELETE FROM project WHERE id = ${workspaceId} AND owner_id = ${userId}`
63+
await tx`DELETE FROM "user" WHERE id = ${userId}`
64+
})
65+
} finally {
66+
await control.end()
67+
}
5868
})
5969

6070
it('purges an expired test with its source and releases the source bytes', async () => {

‎apps/sim/background/cleanup-table-row-ttl.integration.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,11 @@ describe.skipIf(!migrated)('Expiration with real PostgreSQL transactions', () =>
109109
beforeAll(async () => {
110110
await control`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
111111
VALUES (${userId}, 'Expiration integration fixture', ${`${userId}@example.test`}, true, now(), now())`
112-
await control`INSERT INTO workspace (id, name, owner_id, billed_account_user_id)
113-
VALUES (${workspaceId}, 'Expiration integration fixtures', ${userId}, ${userId})`
112+
await control.begin(async (tx) => {
113+
await tx`INSERT INTO project (id, name, owner_id) VALUES (${workspaceId}, 'Fixture project', ${userId})`
114+
await tx`INSERT INTO workspace (id, project_id, name, owner_id, billed_account_user_id)
115+
VALUES (${workspaceId}, ${workspaceId}, 'Expiration integration fixtures', ${userId}, ${userId})`
116+
})
114117
})
115118

116119
beforeEach(async () => {
@@ -124,7 +127,10 @@ describe.skipIf(!migrated)('Expiration with real PostgreSQL transactions', () =>
124127
})
125128

126129
afterAll(async () => {
127-
await control`DELETE FROM workspace WHERE id = ${workspaceId}`
130+
await control.begin(async (tx) => {
131+
await tx`DELETE FROM workspace WHERE id = ${workspaceId}`
132+
await tx`DELETE FROM project WHERE id = ${workspaceId}`
133+
})
128134
await control`DELETE FROM "user" WHERE id = ${userId}`
129135
writeFileSync(
130136
join(tmpdir(), 'expiration-qa-measurements.json'),

0 commit comments

Comments
 (0)