Skip to content

Commit 0e9c9ea

Browse files
fix(scripts): skip locked table workflows
1 parent 4c1a538 commit 0e9c9ea

4 files changed

Lines changed: 117 additions & 24 deletions

File tree

‎apps/sim/lib/workflows/orchestration/deploy.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,7 @@ describe('mutation lock on the orchestration entry points', () => {
896896

897897
expect(result.success).toBe(false)
898898
expect(result.error).toContain('locked')
899+
expect(result.errorCode).toBe('locked')
899900
expect(mockRecordAudit).not.toHaveBeenCalled()
900901
})
901902

‎apps/sim/lib/workflows/orchestration/deploy.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export async function performFullDeploy(
151151
// Backstop for every caller — routes may assert first to render their own 423,
152152
// but the copilot deploy tools call this directly.
153153
const lockDenial = await workflowLockDenial(workflowId)
154-
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' }
154+
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'locked' }
155155

156156
const [workflowRecord] = await db
157157
.select()
@@ -509,6 +509,7 @@ export interface PerformFullUndeployParams {
509509
export interface PerformFullUndeployResult {
510510
success: boolean
511511
error?: string
512+
errorCode?: OrchestrationErrorCode
512513
warnings?: string[]
513514
}
514515

@@ -526,7 +527,7 @@ export async function performFullUndeploy(
526527
const requestId = params.requestId ?? generateRequestId()
527528

528529
const lockDenial = await workflowLockDenial(workflowId)
529-
if (lockDenial) return { success: false, error: lockDenial }
530+
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'locked' }
530531

531532
const [workflowRecord] = await db
532533
.select()
@@ -661,7 +662,7 @@ export async function performActivateVersion(
661662
const idempotencyKey = params.idempotencyKey ?? generateId()
662663

663664
const lockDenial = await workflowLockDenial(workflowId)
664-
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' }
665+
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'locked' }
665666

666667
const [versionRow] = await db
667668
.select({

‎apps/sim/scripts/backfill-table-workflow-deployments.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ describe('backfillTableWorkflowDeployments', () => {
172172
scanned: 3,
173173
deployed: 3,
174174
alreadyDeployed: 0,
175+
skippedLocked: 0,
175176
})
176177
expect(listCandidates.mock.calls).toEqual([
177178
['', 2],
@@ -207,10 +208,59 @@ describe('backfillTableWorkflowDeployments', () => {
207208
scanned: 1,
208209
deployed: 0,
209210
alreadyDeployed: 1,
211+
skippedLocked: 0,
210212
})
211213
expect(deploy).not.toHaveBeenCalled()
212214
})
213215

216+
it('reports and skips locked workflows while continuing the backfill', async () => {
217+
const listCandidates = vi
218+
.fn<TableWorkflowDeploymentStore['listCandidates']>()
219+
.mockResolvedValueOnce([candidate('workflow-a'), candidate('workflow-b')])
220+
.mockResolvedValueOnce([])
221+
.mockResolvedValueOnce([candidate('workflow-a')])
222+
.mockResolvedValueOnce([])
223+
const deploymentState = new Map<string, boolean>()
224+
const isDeployed = vi
225+
.fn<TableWorkflowDeploymentStore['isDeployed']>()
226+
.mockImplementation(async (workflowId) => deploymentState.get(workflowId) ?? false)
227+
const deploy = vi.fn(async (workflow: TableWorkflowDeploymentCandidate) => {
228+
if (workflow.workflowId === 'workflow-a') {
229+
return {
230+
success: false,
231+
error: 'Workflow is locked by its containing folder',
232+
errorCode: 'locked' as const,
233+
}
234+
}
235+
deploymentState.set(workflow.workflowId, true)
236+
return {
237+
success: true,
238+
activeDeployment: {
239+
deploymentVersionId: `version-${workflow.workflowId}`,
240+
version: 1,
241+
deployedAt: new Date().toISOString(),
242+
},
243+
}
244+
})
245+
246+
await expect(
247+
backfillTableWorkflowDeployments(store({ listCandidates, isDeployed }), deploy)
248+
).resolves.toEqual({
249+
scanned: 2,
250+
deployed: 1,
251+
alreadyDeployed: 0,
252+
skippedLocked: 1,
253+
})
254+
expect(deploy.mock.calls.map(([workflow]) => workflow.workflowId)).toEqual([
255+
'workflow-a',
256+
'workflow-b',
257+
])
258+
expect(listCandidates.mock.calls.slice(-2)).toEqual([
259+
['', 1],
260+
['workflow-a', 1],
261+
])
262+
})
263+
214264
it('fails fast when canonical deployment fails', async () => {
215265
const listCandidates = vi
216266
.fn<TableWorkflowDeploymentStore['listCandidates']>()

‎apps/sim/scripts/backfill-table-workflow-deployments.ts‎

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env bun
22

33
/**
4-
* Deploys every workflow referenced by a table workflow group.
4+
* Deploys every mutable workflow referenced by a table workflow group.
55
*
66
* The script is idempotent and resumable: it pages over only workflows that do
77
* not have the full desired state, and each deployment uses a stable idempotency
@@ -52,6 +52,7 @@ export interface TableWorkflowDeploymentSummary {
5252
scanned: number
5353
deployed: number
5454
alreadyDeployed: number
55+
skippedLocked: number
5556
}
5657

5758
interface TableWorkflowDeploymentBackfillOptions {
@@ -158,6 +159,50 @@ async function getDatabase() {
158159
return db
159160
}
160161

162+
function validateCandidatePage(
163+
candidates: TableWorkflowDeploymentCandidate[],
164+
afterWorkflowId: string,
165+
limit: number
166+
): string | null {
167+
if (candidates.length === 0) return null
168+
if (candidates.length > limit) {
169+
throw new Error('Table workflow deployment store returned an oversized page')
170+
}
171+
172+
const pageIds = new Set(candidates.map((candidate) => candidate.workflowId))
173+
if (pageIds.size !== candidates.length) {
174+
throw new Error('Table workflow deployment store returned duplicate workflow ids')
175+
}
176+
177+
const lastWorkflowId = candidates.at(-1)?.workflowId
178+
if (!lastWorkflowId || lastWorkflowId === afterWorkflowId) {
179+
throw new Error('Table workflow deployment store returned a non-advancing page')
180+
}
181+
return lastWorkflowId
182+
}
183+
184+
async function assertOnlyLockedCandidatesRemain(
185+
store: TableWorkflowDeploymentStore,
186+
lockedWorkflowIds: ReadonlySet<string>
187+
): Promise<void> {
188+
let afterWorkflowId = ''
189+
for (;;) {
190+
const candidates = await store.listCandidates(afterWorkflowId, 1)
191+
const lastWorkflowId = validateCandidatePage(candidates, afterWorkflowId, 1)
192+
if (!lastWorkflowId) return
193+
194+
const unexpectedCandidate = candidates.find(
195+
(candidate) => !lockedWorkflowIds.has(candidate.workflowId)
196+
)
197+
if (unexpectedCandidate) {
198+
throw new Error(
199+
`Table workflow deployment backfill left workflow ${unexpectedCandidate.workflowId} undeployed`
200+
)
201+
}
202+
afterWorkflowId = lastWorkflowId
203+
}
204+
}
205+
161206
/** Ensures the table group references can be traversed without silently dropping corrupt data. */
162207
async function assertTableWorkflowIntegrity(): Promise<void> {
163208
const db = await getDatabase()
@@ -343,7 +388,7 @@ export async function deployTableWorkflow(
343388
}
344389

345390
/**
346-
* Reaches and verifies that every table workflow has an active deployment.
391+
* Reaches and verifies that every mutable table workflow has an active deployment.
347392
*/
348393
export async function backfillTableWorkflowDeployments(
349394
store: TableWorkflowDeploymentStore,
@@ -361,24 +406,15 @@ export async function backfillTableWorkflowDeployments(
361406
scanned: 0,
362407
deployed: 0,
363408
alreadyDeployed: 0,
409+
skippedLocked: 0,
364410
}
411+
const lockedWorkflowIds = new Set<string>()
365412
let afterWorkflowId = ''
366413

367414
for (;;) {
368415
const candidates = await store.listCandidates(afterWorkflowId, batchSize)
369-
if (candidates.length === 0) break
370-
if (candidates.length > batchSize) {
371-
throw new Error('Table workflow deployment store returned an oversized page')
372-
}
373-
374-
const pageIds = new Set(candidates.map((candidate) => candidate.workflowId))
375-
if (pageIds.size !== candidates.length) {
376-
throw new Error('Table workflow deployment store returned duplicate workflow ids')
377-
}
378-
const lastWorkflowId = candidates.at(-1)?.workflowId
379-
if (!lastWorkflowId || lastWorkflowId === afterWorkflowId) {
380-
throw new Error('Table workflow deployment store returned a non-advancing page')
381-
}
416+
const lastWorkflowId = validateCandidatePage(candidates, afterWorkflowId, batchSize)
417+
if (!lastWorkflowId) break
382418

383419
for (const candidate of candidates) {
384420
summary.scanned += 1
@@ -391,6 +427,16 @@ export async function backfillTableWorkflowDeployments(
391427
})
392428
const result = await deploy(candidate)
393429
if (!result.success) {
430+
if (result.errorCode === 'locked') {
431+
lockedWorkflowIds.add(candidate.workflowId)
432+
summary.skippedLocked += 1
433+
logger.warn('Skipping locked workflow referenced by a table workflow group', {
434+
workflowId: candidate.workflowId,
435+
workspaceId: candidate.workspaceId,
436+
reason: result.error ?? 'Workflow is locked',
437+
})
438+
continue
439+
}
394440
throw new Error(
395441
`Failed to deploy table workflow ${candidate.workflowId}: ${result.error ?? 'deployment returned no error'}`
396442
)
@@ -413,12 +459,7 @@ export async function backfillTableWorkflowDeployments(
413459
}
414460

415461
await store.assertIntegrity()
416-
const remaining = await store.listCandidates('', 1)
417-
if (remaining.length > 0) {
418-
throw new Error(
419-
`Table workflow deployment backfill left workflow ${remaining[0].workflowId} undeployed`
420-
)
421-
}
462+
await assertOnlyLockedCandidatesRemain(store, lockedWorkflowIds)
422463

423464
return summary
424465
}

0 commit comments

Comments
 (0)