diff --git a/apps/docs/content/docs/platform/enterprise/forks.mdx b/apps/docs/content/docs/platform/enterprise/forks.mdx
index 94334615944..c33c941c936 100644
--- a/apps/docs/content/docs/platform/enterprise/forks.mdx
+++ b/apps/docs/content/docs/platform/enterprise/forks.mdx
@@ -29,7 +29,7 @@ On Sim Cloud, your organization may also need the feature turned on for your acc
### 1. Open Forks
-Go to **Settings → Organization → Workspace forks** in the workspace you want to fork from (or manage).
+Go to **Settings → Workspace → Workspace forks** in the workspace you want to fork from (or manage).
@@ -62,7 +62,7 @@ Click **Fork**. The child workspace is created immediately. Deployed workflows l
### 3. Open the parent edge (from the child)
-Open the **child** workspace → **Settings → Organization → Workspace forks**. On the **Parent** row, open the menu and choose **Edit mappings**.
+Open the **child** workspace → **Settings → Workspace → Workspace forks**. On the **Parent** row, open the menu and choose **Edit mappings**.
Child rows (when you are on the parent) only offer **Open workspace** and **Disconnect** — mapping and sync are owned by the child configuring how it relates to its parent.
@@ -136,14 +136,14 @@ Above the list, **Sync new workflows by default** decides where a **newly create
| Setting | A new workflow… |
|---------|-----------------|
-| **On** (default) | joins fork sync — it arrives checked and syncs as soon as you deploy it |
-| **Off** | starts outside fork sync — it arrives unchecked and only syncs after you check it |
+| **Sync** (default) | joins fork sync — it arrives checked and syncs as soon as you deploy it |
+| **Don't sync** | starts outside fork sync — it arrives unchecked and only syncs after you check it |
Three things to know:
-- **It applies to the whole fork lineage.** The toggle writes every workspace in the lineage — the root, every ancestor, every descendant — so a parent and its forks can never disagree about what "new" means. Any workspace admin in the lineage can change it, and each member gets its own audit entry naming the workspace the change came from. A new fork inherits the value at creation.
+- **It applies to the whole fork lineage.** The toggle writes every workspace in the lineage — the root, every ancestor, every descendant — so a parent and its forks can never disagree about what "new" means. Any workspace admin in the lineage can change it, and each workspace whose value changes gets its own audit entry naming the workspace the change came from. A new fork inherits the value at creation.
- **It is forward-only.** Flipping it never moves an existing workflow in or out of sync. The checkbox list above stays the record of what syncs.
-- **"New" means genuinely new.** Creating, duplicating, or importing a workflow takes this setting, as does the blank starter workflow a fork gets when there is nothing to copy. A workflow that arrives as a **copy** — from a fork, or from a push or pull — inherits its source's own checkbox instead, so a workflow you deliberately synced never lands unsynced in the child.
+- **"New" means genuinely new.** Creating, duplicating, or importing a workflow takes this setting, as does the blank starter workflow a fork gets when there is nothing to copy. A workflow that arrives as a **copy** — from a fork, or from a push or pull — ignores this setting. Only synced workflows are copied, and they always arrive synced, so a workflow you deliberately synced never lands unsynced on the other side.
**Example:** a template workspace turns this off so every scratch workflow the team creates stays local, then checks only the handful meant to reach the forks.
@@ -399,7 +399,7 @@ Schedules, webhooks, and triggers are not live in the child until you **deploy**
{ question: "Why is Sync greyed out?", answer: "Usually a blocking reference, an unmapped credential or secret, or a required dependent field (label, channel, document, …) still empty. Open Blocking sync and the mapping sections — each row explains what to fix. Sync also stays disabled while details are loading or if loading failed (reload the page)." },
{ question: "Is sync a merge?", answer: "No. Deploy is like a commit; sync is a force push or force pull of deployed workflows onto the target. Use Rollback only for the last sync into a workspace, and remember copied resources may remain." },
{ question: "Who can disconnect a fork I cannot open?", answer: "Any admin on your side of the edge. Disconnect does not require access to the other workspace — so you are not stuck if the other side lost membership." },
- { question: "I deployed a new workflow and sync ignored it. Why?", answer: "Sync new workflows by default is off for this fork lineage, so the workflow was created outside fork sync. Open Settings → Organization → Workspace forks and check it under Synced workflows. Turning the toggle back on only affects workflows created after that — it never moves an existing one." },
+ { question: "I deployed a new workflow and sync ignored it. Why?", answer: "Sync new workflows by default is off for this fork lineage, so the workflow was created outside fork sync. Open Settings → Workspace → Workspace forks and check it under Synced workflows. Turning the toggle back on only affects workflows created after that — it never moves an existing one." },
{ question: "Does turning Sync new workflows by default off stop my current syncs?", answer: "No. It is forward-only and never rewrites an existing workflow's checkbox, so everything already synced keeps syncing. It also applies to every workspace in the fork lineage, not just the one you changed it from." }
]} />
@@ -413,4 +413,4 @@ Self-hosted deployments turn Forks on with an environment variable instead of th
|----------|-------------|
| `FORKING_ENABLED`, `NEXT_PUBLIC_FORKING_ENABLED` | Enables workspace forking when billing is not used as the entitlement gate |
-Once enabled, use the same **Settings → Organization → Workspace forks** UI as Sim Cloud. Only workspace admins can manage forks.
+Once enabled, use the same **Settings → Workspace → Workspace forks** UI as Sim Cloud. Only workspace admins can manage forks.
diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts
index f84d160f6e5..cc59d7627bf 100644
--- a/apps/sim/app/api/superuser/import-workflow/route.ts
+++ b/apps/sim/app/api/superuser/import-workflow/route.ts
@@ -12,13 +12,13 @@ import { loadCopilotChatMessages } from '@/lib/mothership/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store'
import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
+import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
-import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
const logger = createLogger('SuperUserImportWorkflow')
@@ -130,30 +130,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// Create new workflow record
const newWorkflowId = generateId()
- const now = new Date()
const dedupedName = await deduplicateWorkflowName(
`[Debug Import] ${sourceWorkflow.name}`,
targetWorkspaceId,
null
)
- await db.insert(workflow).values({
- id: newWorkflowId,
- userId: session.user.id,
- workspaceId: targetWorkspaceId,
- folderId: null,
- name: dedupedName,
- description: sourceWorkflow.description,
- lastSynced: now,
- createdAt: now,
- updatedAt: now,
- isDeployed: false, // Never copy deployment status
- runCount: 0,
- variables: sourceWorkflow.variables || {},
- // An imported workflow is a NEW workflow in the target workspace, so it takes that
- // workspace's fork-sync policy rather than the column default.
- forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, targetWorkspaceId),
- })
+ await db.insert(workflow).values(
+ await buildNewWorkflowRow(db, {
+ id: newWorkflowId,
+ userId: session.user.id,
+ workspaceId: targetWorkspaceId,
+ folderId: null,
+ name: dedupedName,
+ description: sourceWorkflow.description,
+ variables: sourceWorkflow.variables || {},
+ })
+ )
// Save using existing persistence logic
const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData, {
diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts
index 5e0b85791bc..0c7f0f3c405 100644
--- a/apps/sim/app/api/v1/admin/workflows/import/route.ts
+++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts
@@ -30,6 +30,7 @@ import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
+import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
@@ -41,7 +42,6 @@ import {
notFoundResponse,
} from '@/app/api/v1/admin/responses'
import { extractWorkflowMetadata, type WorkflowImportRequest } from '@/app/api/v1/admin/types'
-import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
const logger = createLogger('AdminWorkflowImportAPI')
@@ -113,27 +113,18 @@ export const POST = withRouteHandler(
)
const workflowId = generateId()
- const now = new Date()
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, folderId || null)
- await db.insert(workflow).values({
- id: workflowId,
- userId: workspaceData.ownerId,
- workspaceId,
- folderId: folderId || null,
- name: dedupedName,
- description: workflowDescription,
- lastSynced: now,
- createdAt: now,
- updatedAt: now,
- isDeployed: false,
- runCount: 0,
- variables: {},
- // An imported workflow is a NEW workflow in this workspace, so it takes the
- // workspace's fork-sync policy. Without this it lands on the column default and
- // silently joins fork sync in a workspace that opted out.
- forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId),
- })
+ await db.insert(workflow).values(
+ await buildNewWorkflowRow(db, {
+ id: workflowId,
+ userId: workspaceData.ownerId,
+ workspaceId,
+ folderId: folderId || null,
+ name: dedupedName,
+ description: workflowDescription,
+ })
+ )
/**
* Same normalization the editor and the v1 import API run, via the one
diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
index 12b26db0c28..f19a1c072a7 100644
--- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
+++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
@@ -45,6 +45,7 @@ import {
extractWorkflowsFromZip,
parseWorkflowJson,
} from '@/lib/workflows/operations/import-export'
+import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
@@ -62,7 +63,6 @@ import type {
WorkspaceImportRequest,
WorkspaceImportResponse,
} from '@/app/api/v1/admin/types'
-import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
const logger = createLogger('AdminWorkspaceImportAPI')
@@ -348,27 +348,18 @@ async function importSingleWorkflow(
}
const workflowId = generateId()
- const now = new Date()
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId)
- await db.insert(workflow).values({
- id: workflowId,
- userId: ownerId,
- workspaceId,
- folderId: targetFolderId,
- name: dedupedName,
- description: workflowData.metadata?.description || 'Imported via Admin API',
- lastSynced: now,
- createdAt: now,
- updatedAt: now,
- isDeployed: false,
- runCount: 0,
- variables: {},
- // An imported workflow is a NEW workflow in this workspace, so it takes the
- // workspace's fork-sync policy. Without this it lands on the column default and
- // silently joins fork sync in a workspace that opted out.
- forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId),
- })
+ await db.insert(workflow).values(
+ await buildNewWorkflowRow(db, {
+ id: workflowId,
+ userId: ownerId,
+ workspaceId,
+ folderId: targetFolderId,
+ name: dedupedName,
+ description: workflowData.metadata?.description || 'Imported via Admin API',
+ })
+ )
/**
* Same normalization the editor, the v1 import API and the single-workflow
diff --git a/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts b/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts
index 1f9cdaf9d27..d33985f4cb2 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts
@@ -13,10 +13,8 @@ export const PUT = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: forkOperations.syncDefault,
/**
- * Rated, unlike its sibling fork routes. This is the one that writes workspaces the
- * caller may not administer, under the feature's coarsest advisory lock, so an admin of
- * any single lineage member could otherwise loop it and starve fork creation across the
- * whole lineage.
+ * Rate-limited, unlike sibling fork routes: it writes the whole lineage under the coarsest
+ * fork lock, so looping it could starve fork creation lineage-wide.
*/
rateLimit: internalRateLimits.user({ bucketName: 'workspace-fork-sync-default' }),
errorPolicy: internalForkErrorPolicy,
diff --git a/apps/sim/ee/workspace-forking/application/lineage-details.ts b/apps/sim/ee/workspace-forking/application/lineage-details.ts
index a2143b28b91..0b640febdc0 100644
--- a/apps/sim/ee/workspace-forking/application/lineage-details.ts
+++ b/apps/sim/ee/workspace-forking/application/lineage-details.ts
@@ -1,10 +1,10 @@
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
+import { readForkSyncNewWorkflowsExcluded } from '@/lib/workflows/persistence/new-workflow-row'
import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils'
import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage'
import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store'
-import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
/**
* Annotates a lineage node with whether the viewer holds any access to it (explicit
@@ -40,7 +40,7 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({
getForkChildren(workspaceId),
getUndoableRunForTarget(db, workspaceId),
// Lineage-uniform, so this workspace's own value is the lineage's value.
- resolveForkSyncExclusionForNewWorkflow(db, workspaceId),
+ readForkSyncNewWorkflowsExcluded(db, workspaceId),
])
const [parent, children] = await Promise.all([
diff --git a/apps/sim/ee/workspace-forking/application/operations.ts b/apps/sim/ee/workspace-forking/application/operations.ts
index 27759c9e9b5..e744d99f92f 100644
--- a/apps/sim/ee/workspace-forking/application/operations.ts
+++ b/apps/sim/ee/workspace-forking/application/operations.ts
@@ -99,12 +99,8 @@ export const forkOperations = {
oauthScope: 'api:write',
}),
/**
- * Admin on the CALLING workspace is sufficient, and the write then fans out to every
- * ancestor and descendant, because the default is meaningless unless it is uniform
- * across a lineage. Flipping it to "sync new workflows" restores the historical
- * behaviour rather than granting anything new, and it never moves an existing workflow
- * in or out of sync - so each member records its own audit entry rather than the write
- * being restricted to one workspace.
+ * Admin on the calling workspace is sufficient; the write fans out to the whole lineage
+ * because the default must be uniform, and it never moves an existing workflow.
*
* permission-group-exempt: the new-workflow fork-sync default is workspace configuration governed by the admin role.
*/
diff --git a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts
index eb519e299bc..f679f2686e2 100644
--- a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts
+++ b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts
@@ -52,10 +52,8 @@ export const updateWorkspaceForkMappings = defineForkUseCase<
input.direction === 'push' ? input.otherWorkspaceId : input.workspaceId
return db.transaction(async (tx) => {
await setForkLockTimeout(tx)
- // Rank 4 - see the rank table on `acquireForkLineageLock`. Unlike promote and
- // rollback this takes no rank-3 target lock: it rewrites only this edge's mapping
- // rows, never the target's workflows, so nothing contends with a sync into the
- // target. Skipping a higher rank is not an ordering violation.
+ // Rank 4 - see the rank table on `acquireForkLineageLock`. No target lock: this
+ // rewrites only the edge's mapping rows.
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
const [currentEdge] = await tx
.select({ parentId: workspace.forkedFromWorkspaceId })
diff --git a/apps/sim/ee/workspace-forking/application/revision.ts b/apps/sim/ee/workspace-forking/application/revision.ts
index 4ca0fb3b96d..1bfb81aaf92 100644
--- a/apps/sim/ee/workspace-forking/application/revision.ts
+++ b/apps/sim/ee/workspace-forking/application/revision.ts
@@ -143,11 +143,8 @@ export async function loadForkPreviewRevision(
/**
* Locks normalized graph rows as well as workflow metadata, including realtime-only writes.
*
- * Rank 5 - the heaviest acquirer in the fork module, and the one the rank table on
- * `acquireForkLineageLock` exists for. It takes `FOR UPDATE` on the `workspace` rows, so
- * any caller that also needs the rank-2 lineage lock must take that one FIRST; doing it
- * the other way round deadlocks against `unlinkForkEdge`, which holds the lineage key and
- * then updates the same `workspace` row.
+ * Rank 5 - see the rank table on `acquireForkLineageLock`. Takes `FOR UPDATE` on `workspace`
+ * rows, so a caller needing the rank-2 lineage lock must take it first.
*/
export async function lockForkRevision(tx: DbTransaction, scope: ForkRevisionScope): Promise {
const workspaceIds = [
@@ -207,21 +204,11 @@ export async function assertForkSourceVersions(
sourceWorkspaceId: string,
expected: ReadonlyMap
): Promise {
- // Verify exactly the workflows that were ADMITTED, rather than re-deriving the source
- // predicate here. Re-deriving it duplicated `listDeployedWorkflows`'s filter, so the day
- // a caller admitted a different set - "Copy unsynced workflows" admits sync-excluded
- // workflows - this query returned fewer rows and every such fork failed on a phantom
- // size mismatch. Keying off `expected` cannot drift from the admitted set by construction.
- if (expected.size === 0) return
- const admittedIds = sql.join(
- [...expected.keys()].map((id) => sql`${id}`),
- sql`, `
- )
const rows = await tx.execute<{ workflowId: string; id: string; digest: string }>(sql`
SELECT w.id AS "workflowId", d.id, md5(d.state::text) AS digest FROM ${workflow} w
JOIN ${workflowDeploymentVersion} d ON d.workflow_id = w.id AND d.is_active = true
WHERE w.workspace_id = ${sourceWorkspaceId} AND w.is_deployed = true
- AND w.archived_at IS NULL AND w.id IN (${admittedIds})
+ AND w.archived_at IS NULL AND w.fork_sync_excluded = false
`)
if (
rows.length !== expected.size ||
diff --git a/apps/sim/ee/workspace-forking/application/sync-default.test.ts b/apps/sim/ee/workspace-forking/application/sync-default.test.ts
index 953001acd26..5eb0b50df73 100644
--- a/apps/sim/ee/workspace-forking/application/sync-default.test.ts
+++ b/apps/sim/ee/workspace-forking/application/sync-default.test.ts
@@ -1,9 +1,10 @@
/**
* @vitest-environment node
*/
+import { workspace } from '@sim/db/schema'
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock'
-import { dbChainMockFns, resetDbChainMock } from '@sim/testing/mocks/database.mock'
+import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock'
import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock'
import {
@@ -12,12 +13,14 @@ import {
} from '@sim/testing/mocks/workspace-authorization.mock'
import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock'
import { workspaceForkingLineageMock } from '@sim/testing/mocks/workspace-forking-lineage.mock'
+import {
+ workspaceForkingLineageRootMock,
+ workspaceForkingLineageRootMockFns,
+} from '@sim/testing/mocks/workspace-forking-lineage-root.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const hoisted = vi.hoisted(() => ({
- resolveRootId: vi.fn(),
- resolveLineage: vi.fn(),
-}))
+const { mockResolveForkLineageRootId, mockResolveForkLineageWorkspaceIds } =
+ workspaceForkingLineageRootMockFns
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock)
@@ -25,10 +28,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/posthog/server', () => posthogServerMock)
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock)
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock)
-vi.mock('@/ee/workspace-forking/lib/sync-default', () => ({
- resolveForkLineageRootId: hoisted.resolveRootId,
- resolveForkLineageWorkspaceIds: hoisted.resolveLineage,
-}))
+vi.mock('@/ee/workspace-forking/lib/lineage/lineage-root', () => workspaceForkingLineageRootMock)
import { setForkSyncDefault } from '@/ee/workspace-forking/application/sync-default'
@@ -37,7 +37,6 @@ const LINEAGE = ['root-ws', 'fork-a', 'fork-b', 'grandchild']
beforeEach(() => {
resetDbChainMock()
- vi.clearAllMocks()
permissionsMockFns.mockGetWorkspaceWithOwner.mockResolvedValue({
id: 'fork-a',
name: 'Fork A',
@@ -45,11 +44,13 @@ beforeEach(() => {
allowPersonalApiKeys: true,
})
workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined)
- hoisted.resolveRootId.mockResolvedValue('root-ws')
- hoisted.resolveLineage.mockResolvedValue(LINEAGE)
- // The use case's `UPDATE ... RETURNING` over the global @sim/db mock: the rows it returns
- // are the lineage members whose value actually changed, each with the name the audit
- // entry is filed under.
+ mockResolveForkLineageRootId.mockResolvedValue('root-ws')
+ mockResolveForkLineageWorkspaceIds.mockResolvedValue(LINEAGE)
+ // The live-member row lock, then the `UPDATE ... RETURNING` of the members that changed.
+ queueTableRows(
+ workspace,
+ LINEAGE.map((id) => ({ id }))
+ )
dbChainMockFns.returning.mockResolvedValue(LINEAGE.map((id) => ({ id, name: `Name of ${id}` })))
})
@@ -60,18 +61,11 @@ const run = (excludeNewWorkflows: boolean) =>
})
describe('setForkSyncDefault', () => {
- it('writes every lineage member when issued from a mid-lineage fork', async () => {
- await expect(run(true)).resolves.toMatchObject({
- excludeNewWorkflows: true,
- changedWorkspaces: LINEAGE.map((id) => ({ id, name: `Name of ${id}` })),
- })
- })
-
/**
* One entry per member, because the default genuinely changed for all of them. A single
* entry on the calling workspace would leave the other members' admins with no record.
*/
- it('records one entry per CHANGED member, naming where the change was issued from', async () => {
+ it("files one audit entry per updated member, in that member's own workspace and name", async () => {
await run(true)
const audited = auditMockFns.mockRecordAudit.mock.calls.map(([entry]) => entry)
expect(audited).toHaveLength(LINEAGE.length)
@@ -82,9 +76,7 @@ describe('setForkSyncDefault', () => {
// defaults it to the caller's workspace, so every entry would pile into one log and
// the other members' admins would see nothing.
expect(entry.workspaceId).toBe(entry.resourceId)
- // The member's OWN name, not a raw id and not the caller's name. A lineage-wide
- // change that named only the calling workspace left every other member's audit
- // entry reading as an opaque identifier.
+ // The member's OWN name, not a raw id and not the caller's name.
expect(entry.resourceName).toBe(`Name of ${entry.resourceId}`)
expect(entry.metadata).toMatchObject({
forkSyncNewWorkflowsExcluded: true,
@@ -94,44 +86,9 @@ describe('setForkSyncDefault', () => {
}
})
- /**
- * `projectAudit` maps over `changedWorkspaces` and `afterSuccess` returns early when it
- * is empty, so an empty result IS "no audit, no analytics". Asserting the result rather
- * than the mocks' call counts keeps this pinned to the value the fan-out reads.
- */
- it('reports no changed members when the value already matched everywhere', async () => {
- dbChainMockFns.returning.mockResolvedValue([])
- await expect(run(true)).resolves.toMatchObject({ changedWorkspaces: [] })
- })
-
- /**
- * The update only touches members whose value differs, so auditing the whole lineage
- * would file a change record against a workspace that already held the requested value.
- */
- it('records nothing for a member that already held the requested value', async () => {
- dbChainMockFns.returning.mockResolvedValue([{ id: 'fork-b', name: 'Fork B' }])
- await expect(run(true)).resolves.toMatchObject({
- changedWorkspaces: [{ id: 'fork-b', name: 'Fork B' }],
- })
- const audited = auditMockFns.mockRecordAudit.mock.calls.map(([entry]) => entry)
- expect(audited.map((entry) => entry.resourceId)).toEqual(['fork-b'])
- })
-
- /**
- * The fan-out reaches workspaces the caller may not administer, so the admission check
- * is the only thing standing between a non-admin and a lineage-wide write. Assert it
- * rejects rather than trusting that the wrapper was wired up.
- */
- it('rejects a caller who fails workspace admission', async () => {
- workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockRejectedValue(
- new Error('forbidden')
- )
- await expect(run(true)).rejects.toThrow('forbidden')
- })
-
- it('carries the chosen value through, so turning the default back on is symmetric', async () => {
- await expect(run(false)).resolves.toMatchObject({ excludeNewWorkflows: false })
- const [entry] = auditMockFns.mockRecordAudit.mock.calls[0]
- expect(entry.metadata).toMatchObject({ forkSyncNewWorkflowsExcluded: false })
+ /** An unlink that moved the caller out of the locked root's lineage must not be written. */
+ it('refuses when the locked root no longer reaches the calling workspace', async () => {
+ mockResolveForkLineageWorkspaceIds.mockResolvedValue(['root-ws', 'fork-b'])
+ await expect(run(true)).rejects.toMatchObject({ statusCode: 409 })
})
})
diff --git a/apps/sim/ee/workspace-forking/application/sync-default.ts b/apps/sim/ee/workspace-forking/application/sync-default.ts
index 1b8ea739670..8c5592aa3ca 100644
--- a/apps/sim/ee/workspace-forking/application/sync-default.ts
+++ b/apps/sim/ee/workspace-forking/application/sync-default.ts
@@ -1,8 +1,7 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
-import { compareStrings } from '@sim/utils/string'
-import { and, inArray, isNull, ne, sql } from 'drizzle-orm'
+import { and, asc, inArray, isNull, ne } from 'drizzle-orm'
import { captureServerEvent } from '@/lib/posthog/server'
import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case'
import { forkOperations } from '@/ee/workspace-forking/application/operations'
@@ -14,7 +13,7 @@ import {
import {
resolveForkLineageRootId,
resolveForkLineageWorkspaceIds,
-} from '@/ee/workspace-forking/lib/sync-default'
+} from '@/ee/workspace-forking/lib/lineage/lineage-root'
export interface SetForkSyncDefaultInput {
workspaceId: string
@@ -50,98 +49,68 @@ export const setForkSyncDefault = defineForkUseCase<
>({
operation: forkOperations.syncDefault,
async execute({ input }) {
+ // The root is the lock key, so it is resolved before the lock; membership is expanded under it.
+ const rootId = await resolveForkLineageRootId(db, input.workspaceId)
return db.transaction(async (tx) => {
await setForkLockTimeout(tx)
- const rootId = await resolveForkLineageRootId(tx, input.workspaceId)
- // Rank 2, and the only fork lock this transaction takes before its rank-6 row
- // locks - see the rank table on `acquireForkLineageLock`.
+ // Rank 2 - see the rank table on `acquireForkLineageLock`.
await acquireForkLineageLock(tx, rootId)
- // Re-resolve under the lock and refuse if the root moved. An unlink committing
- // between the read and the lock would leave us holding the OLD lineage's key while
- // writing the new one's members, so a second write rooted at the new lineage could
- // run concurrently over the same rows. Mirrors the organization re-check `createFork`
- // performs under its own lock.
- if ((await resolveForkLineageRootId(tx, input.workspaceId)) !== rootId) {
+ // Expanded under the lock, so a fork created a moment ago is included and one being
+ // created now waits on the same key. A root that no longer reaches the caller means an
+ // unlink moved it before we locked, and this key no longer covers its lineage.
+ const lineageWorkspaceIds = await resolveForkLineageWorkspaceIds(tx, rootId)
+ if (!lineageWorkspaceIds.includes(input.workspaceId)) {
throw new ForkError(
'The fork lineage changed while this request was being applied. Try again.',
409
)
}
- // Resolve the membership AFTER the lock: a fork created a moment ago must be
- // included, and one being created right now is blocked on the same key.
- const lineageWorkspaceIds = await resolveForkLineageWorkspaceIds(tx, input.workspaceId)
- if (lineageWorkspaceIds.length === 0) {
+ // Rank 6: lock the live members whose value differs, in id order, since a multi-row
+ // UPDATE locks in plan order and could deadlock against other multi-workspace writers.
+ // NO KEY UPDATE keeps FK inserts into these workspaces (KEY SHARE) from queueing behind
+ // the write. Nothing else writes this column under the lineage lock, so the filter holds.
+ const toChange = await tx
+ .select({ id: workspace.id })
+ .from(workspace)
+ .where(
+ and(
+ inArray(workspace.id, lineageWorkspaceIds),
+ isNull(workspace.archivedAt),
+ ne(workspace.forkSyncNewWorkflowsExcluded, input.excludeNewWorkflows)
+ )
+ )
+ .orderBy(asc(workspace.id))
+ .for('no key update')
+ if (toChange.length === 0) {
return { excludeNewWorkflows: input.excludeNewWorkflows, changedWorkspaces: [] }
}
- // Rank 6: take the member row locks in sorted id order BEFORE the update. A bare
- // multi-row `UPDATE ... WHERE id IN (...)` acquires its row locks in whatever order
- // the plan produces, so it is unordered against any other multi-workspace writer
- // that takes no lineage lock - `lockWorkspaceRowsForPayerChanges` on the
- // organization-attach path is one today. `revision.ts` locks this same table with an
- // explicit `ORDER BY id FOR UPDATE` for exactly this reason; Drizzle's
- // `.update().where(inArray(...))` cannot express ORDER BY, so this is the same
- // lock-then-update shape.
- const memberIds = sql.join(
- [...lineageWorkspaceIds].sort(compareStrings).map((id) => sql`${id}`),
- sql`, `
- )
- await tx.execute(
- sql`SELECT id FROM ${workspace} WHERE id IN (${memberIds}) ORDER BY id FOR UPDATE`
- )
const changed = await tx
.update(workspace)
.set({ forkSyncNewWorkflowsExcluded: input.excludeNewWorkflows, updatedAt: new Date() })
.where(
- and(
- inArray(workspace.id, lineageWorkspaceIds),
- // Re-assert liveness at write time: `resolveForkLineageWorkspaceIds` filtered
- // archived members when it read, but a workspace can be archived between that
- // read and this update, and a policy write must never touch one.
- isNull(workspace.archivedAt),
- ne(workspace.forkSyncNewWorkflowsExcluded, input.excludeNewWorkflows)
+ inArray(
+ workspace.id,
+ toChange.map((member) => member.id)
)
)
- // Return the NAME too, so the audit fan-out can identify each member the way a
- // reader knows it. Projecting a bare id as `resourceName` made every entry but the
- // caller's read as a raw identifier in the audit log.
.returning({ id: workspace.id, name: workspace.name })
- return {
- excludeNewWorkflows: input.excludeNewWorkflows,
- // The members whose value ACTUALLY changed, not every member considered. Auditing
- // the whole lineage would file a change record against a workspace that already
- // held the requested value, making its history claim something that did not happen.
- changedWorkspaces: changed,
- }
+ return { excludeNewWorkflows: input.excludeNewWorkflows, changedWorkspaces: changed }
})
},
- /**
- * One entry per workspace whose value actually changed. Every such member gets its own
- * record, because the default genuinely moved for each of them and a single entry on the
- * calling workspace would leave the others' admins with no trace. A member that already
- * held the requested value gets nothing - it did not change.
- */
+ /** One entry per member whose value changed, so every affected workspace's admins see it. */
projectAudit: ({ input, context, result }) =>
result.changedWorkspaces.map((member) => ({
action: AuditAction.WORKSPACE_FORK_SYNC_DEFAULT_CHANGED,
- // File each entry in the workspace it describes, not the caller's. The audit
- // wrapper defaults `workspaceId` to the initiating workspace, which would land
- // every entry in one log and leave the other members' admins with no record of
- // their own workspace changing - the exact gap this per-member fan-out exists
- // to close.
+ // The audit wrapper defaults `workspaceId` to the caller; file each entry in its own workspace.
workspaceId: member.id,
resourceType: AuditResourceType.WORKSPACE,
resourceId: member.id,
- // The member's own name, read back from the UPDATE. Falling back to the id for
- // every member but the caller made a lineage-wide change read as one named
- // workspace and N opaque identifiers.
resourceName: member.name,
description: input.excludeNewWorkflows
? 'New workflows no longer sync to forks by default'
: 'New workflows sync to forks by default',
metadata: {
forkSyncNewWorkflowsExcluded: input.excludeNewWorkflows,
- // Which workspace in the lineage the admin changed it from, so a member's own
- // log explains why its behaviour moved without an action taken on it.
originWorkspaceId: context.workspace.id,
originWorkspaceName: context.workspace.name,
workspacesChanged: result.changedWorkspaces.length,
diff --git a/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx b/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx
index 7e3fa15cf97..f2f86ca30b9 100644
--- a/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx
+++ b/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx
@@ -14,48 +14,25 @@ interface ForkSyncDefaultToggleProps {
workspaceId: string
/** The lineage's stored policy: true means new workflows start outside fork sync. */
excludeNewWorkflows: boolean
- /**
- * True while the value on screen is not this workspace's own. Covers the first load AND
- * the placeholder window after a workspace switch: `useForkLineage` sets
- * `placeholderData: keepPreviousData`, so it serves the PREVIOUS workspace's policy with
- * `isLoading: false`. Rendering then would show one workspace's value under another's
- * name, and a click would write that stale value to the newly selected lineage.
- */
- loading: boolean
}
/**
* Whether a newly created workflow in this lineage joins fork sync automatically.
*
- * Positive polarity, matching the checkbox list below it - on means new workflows sync,
- * which is the historical default. The stored column is the negative
- * `forkSyncNewWorkflowsExcluded`, so this component owns that inversion.
- *
- * The description is not decoration: this writes to every workspace in the lineage, and
- * without it the control reads as a local preference. That is the "prevents a
- * misunderstanding" case `sim-ui-copy.md` reserves supporting copy for.
- *
- * Forward-only - no existing workflow's checkbox moves, so flipping it can never silently
- * pull a workflow into or out of sync.
+ * Positive polarity, matching the checkbox list below it; this component owns the inversion
+ * from the stored `forkSyncNewWorkflowsExcluded`. The write reaches every workspace in the
+ * lineage and is forward-only: no existing workflow moves.
*/
export function ForkSyncDefaultToggle({
workspaceId,
excludeNewWorkflows,
- loading,
}: ForkSyncDefaultToggleProps) {
const updateDefault = useUpdateForkSyncDefault()
- // Render nothing until the lineage resolves, matching the workflow list below. A
- // placeholder would have to guess a value, and guessing `false` selects "Sync" - the
- // opposite of the truth for an opt-in lineage, which then visibly snaps once the real
- // value lands. Disabled-but-wrong is worse than absent for a cross-workspace policy.
- if (loading) return null
-
return (
- {/* No `htmlFor`: `ChipSwitch` is a radio group and takes no id, so the group
- carries its own `aria-label`. Matches `inbox-enable-toggle.tsx`. */}
+ {/* No `htmlFor`: `ChipSwitch` is a radio group that takes no id; it carries its own `aria-label`. */}
Applies to every workspace in this fork lineage.
diff --git a/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts
deleted file mode 100644
index 6e75b29b2c1..00000000000
--- a/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { buildForkSyncWorkflowTree } from '@/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows'
-
-const wf = (over: Record) => ({
- id: 'w',
- name: 'W',
- isDeployed: true,
- archivedAt: null,
- folderId: null,
- forkSyncExcluded: false,
- ...over,
-})
-
-describe('buildForkSyncWorkflowTree', () => {
- it('lists only deployed, non-archived workflows — the only ones that sync', () => {
- const tree = buildForkSyncWorkflowTree(
- [
- wf({ id: 'live', name: 'Live' }),
- wf({ id: 'draft', name: 'Draft', isDeployed: false }),
- wf({ id: 'gone', name: 'Gone', archivedAt: new Date() }),
- ] as never,
- []
- )
- expect(tree.rootWorkflows.map((w) => w.id)).toEqual(['live'])
- })
-
- it('falls a workflow whose folder was deleted back to root so it stays selectable', () => {
- const tree = buildForkSyncWorkflowTree([wf({ id: 'orphan', folderId: 'missing' })] as never, [])
- expect(tree.rootWorkflows.map((w) => w.id)).toEqual(['orphan'])
- })
-
- it('prunes folders with no deployed workflows anywhere beneath them', () => {
- const folders = [
- { id: 'f-empty', name: 'Empty', parentId: null, sortOrder: 0 },
- { id: 'f-full', name: 'Full', parentId: null, sortOrder: 1 },
- ]
- const tree = buildForkSyncWorkflowTree(
- [wf({ id: 'a', folderId: 'f-full' })] as never,
- folders as never
- )
- expect(tree.folders.map((f) => f.id)).toEqual(['f-full'])
- })
-
- /**
- * The folder-level select-all writes this list verbatim, so a subtree that misses a
- * nested workflow silently leaves it out of a "sync all in this folder" click.
- */
- it('collects nested descendants into the parent folder select-all list', () => {
- const folders = [
- { id: 'parent', name: 'Parent', parentId: null, sortOrder: 0 },
- { id: 'child', name: 'Child', parentId: 'parent', sortOrder: 0 },
- ]
- const tree = buildForkSyncWorkflowTree(
- [wf({ id: 'top', folderId: 'parent' }), wf({ id: 'nested', folderId: 'child' })] as never,
- folders as never
- )
- expect([...tree.folders[0].descendantWorkflowIds].sort()).toEqual(['nested', 'top'])
- })
-})
diff --git a/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx
index b6014a11c5b..f7a03674c28 100644
--- a/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx
+++ b/apps/sim/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows.tsx
@@ -1,7 +1,8 @@
'use client'
import { useId, useMemo, useState } from 'react'
-import { Checkbox, ChevronDown, cn, OverflowText, toast } from '@sim/emcn'
+import { Checkbox, cn, OverflowText, toast } from '@sim/emcn'
+import { ChevronDown } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { useUpdateForkSyncedWorkflows } from '@/ee/workspace-forking/hooks/workspace-fork'
@@ -36,7 +37,7 @@ interface SyncTreeFolder {
* a workflow whose folder was deleted falls into the root bucket so it stays selectable.
* Folders sort like the sidebar (sortOrder, then name); workflows keep the list order.
*/
-export function buildForkSyncWorkflowTree(
+function buildForkSyncWorkflowTree(
workflows: WorkflowMetadata[],
folders: WorkflowFolder[]
): { folders: SyncTreeFolder[]; rootWorkflows: SyncWorkflowItem[] } {
@@ -136,12 +137,8 @@ export function ForkSyncedWorkflows({ workspaceId }: ForkSyncedWorkflowsProps) {
)
}
- // `useWorkflows` and `useFolders` both set `placeholderData: keepPreviousData`, so during
- // a workspace switch they serve the PREVIOUS workspace's rows with `isLoading: false`.
- // Gating on loading alone rendered workspace A's workflows under workspace B's id, and a
- // click then posted A's workflow ids against B. `sim-react-performance.md`: "Never carry
- // placeholder data between protected resource keys ... an explicit loading state is
- // truthful." Matches `custom-tools.tsx` and `integration-skills-section.tsx`.
+ // Both queries keep the previous workspace's rows as placeholder data on a switch;
+ // rendering them would post workspace A's workflow ids against workspace B.
const isLoading =
workflowsQuery.isPending ||
workflowsQuery.isPlaceholderData ||
@@ -149,6 +146,14 @@ export function ForkSyncedWorkflows({ workspaceId }: ForkSyncedWorkflowsProps) {
foldersQuery.isPlaceholderData
if (isLoading) return null
+ if (workflowsQuery.isError || foldersQuery.isError) {
+ return (
+
+ {getErrorMessage(workflowsQuery.error ?? foldersQuery.error, 'Failed to load workflows')}
+
+ )
+ }
+
if (tree.folders.length === 0 && tree.rootWorkflows.length === 0) {
return (
@@ -196,11 +201,15 @@ function SyncFolderRow({ folder, level, syncedIds, onToggle, disabled }: SyncFol
const total = folder.descendantWorkflowIds.length
const selectedCount = folder.descendantWorkflowIds.filter((id) => syncedIds.has(id)).length
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
+ const countLabel = selectedCount > 0 ? `${selectedCount}/${total}` : String(total)
return (