Skip to content

Commit 325ecea

Browse files
committed
improvement(projects): unify environment archive and align with shared patterns
- Archive a workspace's workflows atomically with it through one archiveEnvironmentInTransaction shared by workspace delete and Project archive; the workspace row is locked before the sweep so concurrent creates are covered - Scope the Project lock timeout to lock acquisition and map lock timeouts and deadlocks to a retryable conflict; backfill and multi-Project locks use the shared advisory-lock helpers in code-unit order - Shared orchestrationFailureResponse for raw routes; contracts use the ID primitives and export only what is consumed; audit enums and mock in sync - Project restrictions section matches its sibling settings rows - Batch account-deletion Project loads/locks; skip inconsistent Projects in lists - Harden the foundation integration suite (user-keyed cleanup, poll helper, pid-scoped waits, precise assertions)
1 parent 5bb042c commit 325ecea

33 files changed

Lines changed: 518 additions & 464 deletions

File tree

‎apps/sim/app/api/files/uploads/utils.ts‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import {
44
type InternalFileUploadSession,
55
internalFileUploadSessionSchema,
66
} from '@/lib/api/contracts/upload-sessions'
7+
import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response'
78
import { getSession } from '@/lib/auth'
8-
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
99
import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service'
1010
import type { UploadActor, UploadPurposeResult } from '@/app/api/files/uploads/finalizers'
1111

@@ -32,13 +32,7 @@ export async function requireUploadUser(): Promise<AuthenticatedUploadActor | Ne
3232
}
3333

3434
export function uploadSessionErrorResponse(error: unknown): NextResponse | null {
35-
const classified = asOrchestrationError(error)
36-
return classified
37-
? NextResponse.json(
38-
{ error: classified.message },
39-
{ status: statusForOrchestrationError(classified.code) }
40-
)
41-
: null
35+
return orchestrationFailureResponse(error)
4236
}
4337

4438
export function toInternalUploadSession(

‎apps/sim/app/api/superuser/import-workflow/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server'
77
import { importWorkflowAsSuperuserContract } from '@/lib/api/contracts/workflows'
88
import { parseRequest } from '@/lib/api/server'
99
import { getSession } from '@/lib/auth'
10-
import { OrchestrationError } from '@/lib/core/orchestration/types'
10+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { loadCopilotChatMessages } from '@/lib/mothership/chat/lifecycle'
1313
import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store'
@@ -226,7 +226,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
226226
copilotChatsImported,
227227
})
228228
} catch (error) {
229-
if (error instanceof OrchestrationError && error.code === 'not_found') {
229+
if (asOrchestrationError(error)?.code === 'not_found') {
230230
return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 })
231231
}
232232
logger.error('Error importing workflow', error)

‎apps/sim/app/api/table/utils.ts‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { createLogger } from '@sim/logger'
22
import { permissionSatisfies } from '@sim/platform-authz/workspace'
33
import { toError } from '@sim/utils/errors'
44
import { NextResponse } from 'next/server'
5+
import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response'
56
import {
6-
asOrchestrationError,
77
messageForOrchestrationError,
88
type OrchestrationErrorCode,
99
statusForOrchestrationError,
@@ -137,13 +137,7 @@ export function orchestrationErrorResponse(error: unknown): NextResponse | null
137137
const lockResponse = tableLockErrorResponse(error)
138138
if (lockResponse) return lockResponse
139139

140-
const classified = asOrchestrationError(error)
141-
if (!classified) return null
142-
143-
return NextResponse.json(
144-
{ error: classified.message },
145-
{ status: statusForOrchestrationError(classified.code) }
146-
)
140+
return orchestrationFailureResponse(error)
147141
}
148142

149143
/**

‎apps/sim/app/api/v1/admin/workflows/import/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { and, eq, isNull } from 'drizzle-orm'
2828
import { NextResponse } from 'next/server'
2929
import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin'
3030
import { parseRequest } from '@/lib/api/server'
31-
import { OrchestrationError } from '@/lib/core/orchestration/types'
31+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
3232
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
3333
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
3434
import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
@@ -181,7 +181,7 @@ export const POST = withRouteHandler(
181181

182182
return NextResponse.json(response)
183183
} catch (error) {
184-
if (error instanceof OrchestrationError && error.code === 'not_found') {
184+
if (asOrchestrationError(error)?.code === 'not_found') {
185185
return notFoundResponse('Workspace')
186186
}
187187
if (error instanceof FolderNotFoundError) {

‎apps/sim/app/api/workspaces/[id]/route.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { and, eq, isNull } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { deleteWorkspaceBodySchema, updateWorkspaceContract } from '@/lib/api/contracts'
77
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
8+
import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response'
89
import { getSession } from '@/lib/auth'
910
import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer'
10-
import { OrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
1111
import { captureServerEvent } from '@/lib/posthog/server'
1212
import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
1313

@@ -331,12 +331,8 @@ export const DELETE = withRouteHandler(
331331

332332
return NextResponse.json({ success: true })
333333
} catch (error) {
334-
if (error instanceof OrchestrationError) {
335-
return NextResponse.json(
336-
{ error: error.message },
337-
{ status: statusForOrchestrationError(error.code) }
338-
)
339-
}
334+
const failure = orchestrationFailureResponse(error, 'Failed to delete workspace')
335+
if (failure) return failure
340336
logger.error(`Error deleting workspace ${workspaceId}:`, error)
341337
return NextResponse.json({ error: 'Failed to delete workspace' }, { status: 500 })
342338
}

‎apps/sim/ee/access-control/components/group-detail.tsx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,16 +1784,6 @@ export function GroupDetail({
17841784

17851785
{configTab === 'platform' && (
17861786
<div className='flex flex-col gap-7'>
1787-
<ProjectIssueRestrictions
1788-
organizationId={organizationId}
1789-
value={editingConfig.deniedPartialAccessProjectIssues}
1790-
onChange={(value) =>
1791-
setEditingConfig((previous) => ({
1792-
...previous,
1793-
deniedPartialAccessProjectIssues: value,
1794-
}))
1795-
}
1796-
/>
17971787
<div className='flex items-center gap-2'>
17981788
<ChipInput
17991789
icon={Search}
@@ -1871,6 +1861,16 @@ export function GroupDetail({
18711861
</div>
18721862
</SettingsSection>
18731863
))}
1864+
<ProjectIssueRestrictions
1865+
organizationId={organizationId}
1866+
value={editingConfig.deniedPartialAccessProjectIssues}
1867+
onChange={(value) =>
1868+
setEditingConfig((previous) => ({
1869+
...previous,
1870+
deniedPartialAccessProjectIssues: value,
1871+
}))
1872+
}
1873+
/>
18741874
</div>
18751875
)}
18761876
</SettingsPanel>

‎apps/sim/ee/access-control/components/project-issue-restrictions.tsx‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { Checkbox, Chip, Info } from '@sim/emcn'
3+
import { Checkbox, Chip, Info, OverflowText } from '@sim/emcn'
44
import { isApiClientError } from '@/lib/api/client/errors'
55
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
66
import { useProjects } from '@/hooks/queries/projects'
@@ -24,12 +24,14 @@ export function ProjectIssueRestrictions({
2424
if (projects.isPending || (isApiClientError(projects.error) && projects.error.status === 503)) {
2525
return null
2626
}
27+
const choices = projects.data?.pages.flatMap((page) => page.projects) ?? []
28+
if (!projects.error && choices.length === 0) return null
2729
const selected = new Set(value)
2830
return (
2931
<SettingsSection
30-
label='Restrict Issues for partial-access teammates'
32+
label='Project Issues'
3133
headerAccessory={
32-
<Info side='top' className='shrink-0'>
34+
<Info side='top'>
3335
For selected Projects, teammates governed by this group need access to every active
3436
environment to use Issues.
3537
</Info>
@@ -49,13 +51,14 @@ export function ProjectIssueRestrictions({
4951
<p className='pl-2 text-[var(--text-error)] text-caption'>{projects.error.message}</p>
5052
)}
5153
<div className='flex flex-col gap-0.5'>
52-
{projects.data?.pages
53-
.flatMap((page) => page.projects)
54-
.map((project) => (
54+
{choices.map((project) => (
55+
<div
56+
key={project.id}
57+
className='flex items-center gap-1.5 rounded-md pr-2 transition-colors hover-hover:bg-[var(--surface-active)]'
58+
>
5559
<label
5660
htmlFor={`project-issues-${project.id}`}
57-
key={project.id}
58-
className='flex cursor-pointer items-center gap-2 rounded-md py-[5px] pl-2 transition-colors hover-hover:bg-[var(--surface-active)]'
61+
className='flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-[5px] pl-2'
5962
>
6063
<Checkbox
6164
id={`project-issues-${project.id}`}
@@ -68,9 +71,10 @@ export function ProjectIssueRestrictions({
6871
)
6972
}
7073
/>
71-
<span className='text-sm'>{project.name}</span>
74+
<OverflowText label={project.name} className='text-sm' />
7275
</label>
73-
))}
76+
</div>
77+
))}
7478
</div>
7579
</SettingsSection>
7680
)

‎apps/sim/ee/workspace-forking/application/lineage-details.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { db } from '@sim/db'
22
import { workspace } from '@sim/db/schema'
3-
import { eq } from 'drizzle-orm'
4-
import { readForkSyncNewWorkflowsExcluded } from '@/lib/workflows/persistence/new-workflow-row'
3+
import { and, eq, isNull } from 'drizzle-orm'
54
import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils'
65
import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage'
76
import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store'
@@ -24,6 +23,21 @@ async function withViewerAccess<T extends { id: string; organizationId: string |
2423
import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case'
2524
import { forkOperations } from '@/ee/workspace-forking/application/operations'
2625

26+
/**
27+
* The workspace's `forkSyncNewWorkflowsExcluded` policy: whether a workflow created now
28+
* starts outside fork sync. `false` for an archived or missing workspace: a wrongly-synced
29+
* workflow is visible and fixable in the Forks list, while a wrongly-excluded one silently
30+
* stops syncing.
31+
*/
32+
async function readForkSyncNewWorkflowsExcluded(workspaceId: string): Promise<boolean> {
33+
const [row] = await db
34+
.select({ excluded: workspace.forkSyncNewWorkflowsExcluded })
35+
.from(workspace)
36+
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
37+
.limit(1)
38+
return row?.excluded ?? false
39+
}
40+
2741
export const getWorkspaceForkLineageDetails = defineForkUseCase({
2842
operation: forkOperations.discover,
2943
availability: true,
@@ -40,7 +54,7 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({
4054
getForkChildren(workspaceId),
4155
getUndoableRunForTarget(db, workspaceId),
4256
// Lineage-uniform, so this workspace's own value is the lineage's value.
43-
readForkSyncNewWorkflowsExcluded(db, workspaceId),
57+
readForkSyncNewWorkflowsExcluded(workspaceId),
4458
])
4559

4660
const [parent, children] = await Promise.all([

‎apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
type SubBlockTransform,
2727
} from '@/lib/workflows/references/remap-references'
2828
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
29-
import { lockActiveWorkspace } from '@/lib/workspaces/active-workspace'
3029
import {
3130
deriveForkBlockId,
3231
type ForkBlockIdResolver,
@@ -503,7 +502,6 @@ export async function copyWorkflowStateIntoTarget(
503502
requestId = 'unknown',
504503
} = params
505504

506-
await lockActiveWorkspace(tx, targetWorkspaceId)
507505
const targetFolderId = sourceMeta.folderId ? (folderIdMap.get(sourceMeta.folderId) ?? null) : null
508506

509507
const varIdMapping = new Map<string, string>()

‎apps/sim/ee/workspace-forking/lib/promote/promote.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { resetDbChainMock } from '@sim/testing/mocks/database.mock'
1+
import { workspace } from '@sim/db/schema'
2+
import { queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock'
23
import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
34
import {
45
workflowsPersistenceUtilsMock,
@@ -366,6 +367,11 @@ describe('promoteFork gates', () => {
366367
})
367368

368369
describe('promoteFork dependent values', () => {
370+
beforeEach(() => {
371+
queueTableRows(workspace, [{ name: 'Target' }])
372+
queueTableRows(workspace, [{ archivedAt: null, forkSyncNewWorkflowsExcluded: false }])
373+
})
374+
369375
it('unions the dependent-value picks into the copy discovery set (a re-picked document must be copied)', async () => {
370376
mockComputePlan.mockResolvedValue(
371377
makePlan({
@@ -476,6 +482,8 @@ describe('promoteFork dependent values', () => {
476482

477483
describe('promoteFork trigger URLs', () => {
478484
beforeEach(() => {
485+
queueTableRows(workspace, [{ name: 'Target' }])
486+
queueTableRows(workspace, [{ archivedAt: null, forkSyncNewWorkflowsExcluded: false }])
479487
// A block only holds a public URL when its config declares a `useWebhookUrl` field, so the
480488
// fixture has to look like a webhook trigger to the shared predicate.
481489
vi.mocked(getBlock).mockReturnValue({

0 commit comments

Comments
 (0)