Skip to content

Commit bb4d256

Browse files
mzxchandraclaude
andauthored
feat(forks): opt-in fork sync for new workflows (#8318)
* feat(forks): opt-in fork sync for new workflows Workspace forks gain a lineage-wide policy for whether a NEWLY created workflow joins fork sync. Today a workflow joins the moment it is deployed, so deploying something experimental in a parent pushes it into every fork on the next sync with no step where anyone chose that. Opt-out remains the default, so no existing or new workspace changes behaviour until someone flips the toggle. The policy lives on workspace.fork_sync_new_workflows_excluded (default false, the historical behaviour); workflow.fork_sync_excluded is untouched including its default. It is uniform across a fork lineage: settable from any member, fanning out to every ancestor and descendant under an advisory lock keyed on the lineage root, which fork creation and unlink also take. It is forward-only - flipping it never rewrites an existing workflow's sync state - and each changed member records its own audit entry naming where the change was issued from. Genuinely new workflows (create, duplicate, admin/superuser import, a fork's starter) take the workspace policy. A copy (fork creation, promote-create) inherits the SOURCE workflow's flag, because it is the same logical workflow in another workspace; without that an opted-in workflow would copy into a fork already excluded and never sync again. The fork modal gains "Copy unsynced workflows" (off by default, shown only when the source has unsynced deployed workflows, disabled when the combined set would exceed the fork ceiling) so forking an opt-in workspace cannot silently produce an empty fork. The settings section becomes "Synced workflows" with the polarity flipped - checked means the workflow syncs - above a "Sync new workflows by default" toggle row that states its lineage-wide reach. The wire field stays forkSyncExcluded, so the tree owns the single inversion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(forks): break the fork lock cycle and derive review-round values once Round 3 review left 7 threads. Five were the same mistake: a guard added at one read site of a value while the other read sites kept the unguarded one. Each fix below is one derived value used everywhere, or a deleted duplicate path, rather than another guard at another call site. Lock-order deadlock (reported independently by both reviewers). `lineage.ts` already declared `fork-lineage` the coarsest fork lock, but ranked only the three advisory locks and said nothing about `lockForkRevision`, which takes FOR UPDATE on `workspace`. `createFork` took it first, so it held that row while waiting for `fork-lineage`, while `unlinkForkEdge` held `fork-lineage` and waited to UPDATE the same row. Hoist the lineage lock above it, and replace the partial contract with a rank table covering every lock in the module. All six fork transactions now acquire in ascending rank. Stale workspace rows in the synced-workflows list. `useWorkflows` and `useFolders` both set `placeholderData: keepPreviousData`, so a workspace switch served the previous workspace's rows with `isLoading: false` and a click posted workspace A's ids against B. Gate on `isPending || isPlaceholderData`, matching `custom-tools.tsx`. Fork modal submitted a value the switch showed as off. `copyUnsyncedWorkflows` had two readings and submit used the raw one. Derive it once from the request plus the limit, and read that at all four sites. Audit entries named workspaces by id. Return the name from the UPDATE and project `resourceName` from it, so a lineage-wide change no longer reads as one named workspace and N opaque identifiers. Also: give `setForkSyncDefault`'s multi-row UPDATE a deterministic row-lock order, delete the non-barrel re-export left behind when the workflow limit moved to `limits.ts`, and correct a TSDoc invariant that claimed the `fork-target` lock for both callers when only one holds it. Tests, per the repo's test-audit gate: add one `*.integration.ts` that races the two real lock sequences against real Postgres and asserts the pre-fix order deadlocks while the shipped order does not, so the check cannot pass vacuously. Drop the re-added contract-schema test (an identical file was pruned as low-signal in #8295), fold the fork-sync inheritance assertion into its sibling, and reduce the synced-workflows test to the pure tree builder whose checkbox stub no longer implements the polarity it asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(forks): drive the lock-order suite through the production helpers cubic's round-4 finding on the new integration suite was right: it mirrored the lock SQL as string constants instead of executing production's, so a `createFork` regression to the pre-fix order would have left it green. Closed on both halves. The fixture now calls the real `setForkLockTimeout`, `acquireForkLineageLock` and `lockForkRevision`, against the tables the last of those actually locks, so a change to the advisory-lock key or to the revision lock's coverage is carried into the test rather than silently diverging from a copy. A third check pins why the pair conflicts at all: the revision lock really does hold the `workspace` row, which is the edge of the cycle. The order inside `createFork` is asserted where it lives, in `create-fork.test.ts`, on invocation order through the admission path. Order is the entire contract here, which is the case the retention bar keeps call ordering for. Verified red for the right reason: reordering the two acquisitions in `create-fork.ts` fails the new unit assertion, and the integration suite's negative control still fails in ~1.03s with the server reporting `deadlock detected` rather than a lock timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(forks): scope the lock-order barrier to the unlink backend cubic was right that counting any Lock waiter on the database could let an unrelated session release the barrier before the unlink had blocked, leaving the pre-fix negative control to run with its cycle still open. A weak negative control is the failure mode this suite exists to avoid. The unlink transaction now publishes its own backend pid before it can block, and the barrier waits on exactly that pid. If it never blocks the race did not set up, so it throws rather than quietly proceeding and reporting a pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(forks): settle every lock-order barrier on the failure path Both reviewers independently caught the same real defect: a session that died before resolving its barrier left `raceForkAgainstUnlink` awaiting a deferred that would never settle, so a clean database error surfaced as a 30-second vitest timeout. That hides exactly the diagnostics a concurrency test exists to provide. Every barrier is now settled on the failure path as well as the happy one. The fork session releases `forkHoldsFirstLock` in a `finally`, the unlink session reports a `null` backend when it dies before `pg_backend_pid()` returns, and the barrier is skipped outright once a session has already failed, so the session's own error is what the test reports. Releasing the sessions and draining them moved into a `finally` too, along with closing both connections, so a throw inside the barrier cannot strand the fork transaction on `unlinkMayFinish`. Verified by simulating the failure they described - a fork session that throws before taking its first lock. It now fails in 1s with "simulated early connection failure" instead of timing out at 30s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(forks): drop call-count assertions from the sync-default suite CLAUDE.md forbids tests that assert a mock was called. Three assertions in this suite were exactly that shape - `mock.calls).toHaveLength(0)` on the audit and analytics mocks - and they proved only what the mock itself decided, not anything about the use case. The no-op case now asserts the result instead: `projectAudit` maps over `changedWorkspaces` and `afterSuccess` returns early when it is empty, so an empty result IS "no audit, no analytics", pinned to the value the fan-out actually reads. The admission case keeps its rejection assertion, which is the real guarantee: admission runs before the transaction opens. What stays reads the CONTENT of the entries filed - each one carried by its own workspace id and name - which is a fan-out invariant type-check cannot see and the reason this suite exists. Confirmed it still goes red on the pre-fix code: reverting the audit-name guard fails with "expected 'root-ws' to be 'Name of root-ws'". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * revert(forks): drop the "Copy unsynced workflows" fork override Unchecking a workflow meant "keep it out of forking entirely", and the docs said so: never sent, never received, never copied into a new fork. The override made the last of those three conditional on a modal toggle. Sync participation was never at risk - copies inherited the source's flag, so an overridden copy landed unsynced and could not sync back - but "never copied into a new fork" stopped being unconditional, and that is the guarantee someone is relying on when they uncheck a workflow. It also conflated two different states. "Unsynced" covers both "I deliberately excluded this" and "this was never checked because the lineage default is off", and the override copied both. The first case is the one the guarantee exists for. This restores the single predicate: `forkSyncExcluded` workflows are invisible to fork creation, the diff preview, promote in both directions, and the mapping scan, with no caller able to lift it. Removed the toggle and its section, `copyUnsyncedWorkflows` from the request contract, `includeSyncExcluded` from `listDeployedWorkflows` and `loadSourceDeployedStates`, the `unsyncedDeployedWorkflowCount` field, and the split count query, which goes back to one count carrying the exclusion predicate. `lib/limits.ts` goes too. It existed only so a client component could read `MAX_FORK_DEPLOYED_WORKFLOWS` without pulling the database client into the browser bundle, and the override's toggle was the only client reader. The constant returns to `copy/deploy-bridge.ts`, where it is enforced. Unaffected, and still the point of the feature: the lineage-wide new-workflow default, its forward-only write, and the rule that a genuinely new workflow takes the workspace policy while a copy inherits its source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(forks): stop projecting a column the source query already pins `listDeployedWorkflows` filters on `fork_sync_excluded = false` and then selected the same column, so every row it returned carried `false` by construction - `SELECT x ... WHERE x = false`. The field only ever varied while `includeSyncExcluded` could make that predicate drop out, which the previous commit removed, so it is scaffolding from the reverted override rather than anything load-bearing. Keeping it would not have been defensive either. If someone widens that predicate they have to revisit the projection anyway, and a constant field hides the coupling between the two instead of enforcing it. Dropped from the query and from `DeployedWorkflowSummary`. The two write sites now state the invariant they actually mean: a copy is not a new workflow, so it is written synced and never takes the target workspace's new-workflow default - which in an opt-out lineage would land a deliberately synced workflow unsynced on the other side. That explicit write is kept precisely because it is a semantic claim, not a read of something the query had already decided. Untouched: the target-side read in `promote-plan.ts`, which queries target workflows with no exclusion filter and genuinely varies. That is what keeps a promote from overwriting a target the user unchecked. Dropped the copy test for an unsynced source, an input no caller can now produce, and renamed its sibling to the property that still holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(knowledge): give the member-tombstone budget test its own timeout `finishes a pass within its page budget` times out against the shared 30s default on a loaded CI runner. It is a load flake, not a regression: the same commit went green on the `push` integration job and timed out on `migrate`, and it runs in ~4s locally. The cost is real work, not a hang. The test seeds `MEMBER_TOMBSTONE_RECONCILE_PAGES_PER_RUN * 500 + 500` documents specifically so one pass cannot finish inside a single run's budget, then observes and re-lists all of it. That volume is the assertion, so trimming it to fit the default would stop proving the multi-run path. Given its own 120s budget instead, matching the per-test timeouts already used elsewhere in this directory, with a comment recording why. Unrelated to this branch's fork-sync work - the file is byte-identical to staging and arrived with the merge - but it was failing this PR's CI, and a flake left alone becomes one everybody learns to ignore. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(forks): render the fork-sync default with ChipSwitch CLAUDE.md makes the chip family the canonical control chrome, and `ChipSwitch` is what the equivalent settings row already uses (`inbox-enable-toggle.tsx`). Two knock-on details, both forced by the control rather than chosen. `ChipSwitch` is a Radix radio group over a string, so the boolean inversion now runs through named values instead of `!`: `exclude` and `sync` map onto the stored `forkSyncNewWorkflowsExcluded`. And it takes no `id`, so the `Label` drops its `htmlFor` and the group carries its own `aria-label` - the same pairing `inbox-enable-toggle.tsx` uses. It also reads better here. This row's "off" means new workflows stop syncing across the whole lineage, which a thumb position leaves the reader to infer from the label; naming both outcomes puts it on screen. No behavior change beyond the control: the same mutation, the same error toast, the same placeholder-data gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 52cc796 commit bb4d256

42 files changed

Lines changed: 31121 additions & 102 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.

‎apps/docs/content/docs/platform/enterprise/forks.mdx‎

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Everything under **Copy resources** starts **selected**. That is usually what yo
5757
Click **Fork**. The child workspace is created immediately. Deployed workflows land as **drafts** in the child. Large content (table rows, knowledge base files, file blobs) may finish copying in the background — watch **Activity** on the source workspace.
5858

5959
<Callout type="info">
60-
Only **deployed** workflows are forked. Drafts and undeployed work stay in the parent. If the parent has nothing deployed, the child starts with a blank starter workflow.
60+
Only **deployed** workflows are forked, and only the ones that are [synced](#synced-workflows). Drafts and undeployed work stay in the parent. If there is nothing to copy, the child starts with a blank starter workflow.
6161
</Callout>
6262

6363
### 3. Open the parent edge (from the child)
@@ -117,16 +117,35 @@ On success you will see a toast such as **Pushed to "…"** or **Pulled from "
117117

118118
---
119119

120-
## Excluded workflows
120+
## Synced workflows
121121

122-
The **Excluded workflows** section on the Forks page lists this workspace's deployed workflows in their sidebar folder structure. Check a workflow — or a whole folder at once — to keep it out of forking entirely. Think of it as a `.gitignore` for syncs:
122+
The **Synced workflows** section on the Forks page lists this workspace's deployed workflows in their sidebar folder structure, each with a checkbox. **Checked means the workflow syncs.** Uncheck one — or a whole folder at once — to keep it out of forking entirely. Think of an unchecked workflow as `.gitignore`d:
123123

124124
- **Never sent** — pushes from this workspace do not carry it, the other side pulling from this workspace does not receive it, and creating a new fork does not copy it
125-
- **Never touched** — a sync into this workspace will not overwrite or archive it, even if its counterpart was deleted on the other side
125+
- **Never touched** — a sync into this workspace will not overwrite or archive it, even if its counterpart was deleted on the other side. It stays deployed and keeps serving, and a previously-synced counterpart on the other side keeps running on its last deployed version.
126126

127-
The setting belongs to **this workspace's copy** only. Excluding a workflow here does not exclude its counterpart in the parent or a fork — each workspace manages its own list. If the pair has synced before, the link between them is kept, so un-excluding later resumes updating the same counterpart instead of creating a duplicate.
127+
The checkbox list belongs to **this workspace's copy** only. Unchecking a workflow here does not unsync its counterpart in the parent or a fork — each workspace manages its own list. If the pair has synced before, the link between them is kept, so re-checking later resumes updating the same counterpart instead of creating a duplicate.
128128

129-
**Example:** a staging fork excludes `Scratch experiment` so it can never reach production, and production excludes `Billing hotfix` so no push from staging can ever overwrite it.
129+
On the sync page, unsynced workflows still appear in the **Deployed workflows** list, greyed out, with a tooltip naming which workspace they are unsynced in. The sync will not touch them.
130+
131+
**Example:** a staging fork leaves `Scratch experiment` unchecked so it can never reach production, and production leaves `Billing hotfix` unchecked so no push from staging can ever overwrite it.
132+
133+
### Sync new workflows by default
134+
135+
Above the list, **Sync new workflows by default** decides where a **newly created** workflow starts:
136+
137+
| Setting | A new workflow… |
138+
|---------|-----------------|
139+
| **On** (default) | joins fork sync — it arrives checked and syncs as soon as you deploy it |
140+
| **Off** | starts outside fork sync — it arrives unchecked and only syncs after you check it |
141+
142+
Three things to know:
143+
144+
- **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.
145+
- **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.
146+
- **"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.
147+
148+
**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.
130149

131150
---
132151

@@ -155,6 +174,8 @@ Expand a row for names of workflows and resources that were created, updated, or
155174
|--------|-----|
156175
| See Forks / create a fork | Admin on this workspace (+ feature available) |
157176
| Sync / edit mappings | Admin on **both** sides of the edge |
177+
| Check / uncheck **Synced workflows** | Admin on the workspace those workflows live in |
178+
| Change **Sync new workflows by default** | Admin on any one workspace in the lineage — the change applies to every member |
158179
| Rollback | Admin on the workspace the sync landed in |
159180
| Disconnect | Admin on **this** side only (you can disconnect even without access to the other workspace) |
160181
| Open the other workspace | You must be a member of that workspace |
@@ -169,9 +190,9 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a
169190

170191
| Resource | Fork | Sync |
171192
|----------|------|------|
172-
| Deployed workflows | Copied as drafts (unless excluded) | Updated / created / archived (force overwrite) |
193+
| Deployed workflows | Copied as drafts when [synced](#synced-workflows) | Updated / created / archived (force overwrite) |
173194
| Undeployed workflows | Not copied | Not synced |
174-
| [Excluded workflows](#excluded-workflows) | Never | Never — not sent, not overwritten, not archived |
195+
| [Unsynced workflows](#synced-workflows) | Never | Never — not sent, not overwritten, not archived |
175196
| Files | Optional copy (default on) | Map or copy |
176197
| File folders referenced by workflows | Mirrored with their ancestor folders, even when empty | Map by canonical path |
177198
| Tables | Optional copy (default on) | Map or copy |
@@ -191,11 +212,11 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a
191212

192213
### Workflows
193214

194-
Only **deployed** workflows move. Deploy is the commit; sync is the force push/pull of those commits. Workflows marked [excluded](#excluded-workflows) never move in either direction.
215+
Only **deployed** workflows move, and only the ones checked under [Synced workflows](#synced-workflows). An unsynced workflow never moves in either direction.
195216

196217
| Feature | Behavior |
197218
|---|----------|
198-
| **Fork** | Each deployed workflow becomes a **draft** in the child. Run history is not copied. Only folders that contain a copied workflow are kept. |
219+
| **Fork** | Each synced deployed workflow becomes a **draft** in the child. Run history is not copied. Only folders that contain a copied workflow are kept. |
199220
| **Sync** | The change list shows what will be updated, created, or archived. The target is overwritten for those workflows. |
200221

201222
**Example:** Parent has `Support triage` deployed and `WIP experiment` as a draft. The fork gets only `Support triage` as a draft. A later push updates the child from the parent’s latest deploy of `Support triage`.
@@ -370,13 +391,16 @@ Schedules, webhooks, and triggers are not live in the child until you **deploy**
370391
- **Rollback ≠ undo copies** — Workflow versions roll back; copied resources can remain as orphans.
371392
- **Disconnect is permanent** — You cannot “reconnect” the same edge; you would fork again into a new workspace.
372393
- **No grandparent sync** — Only the direct parent↔child pair.
394+
- **The sync default is lineage-wide** — **Sync new workflows by default** is one shared setting for the whole lineage, so changing it from a fork also changes it in the parent and every sibling fork.
373395

374396
---
375397

376398
<FAQ items={[
377399
{ 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)." },
378400
{ 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." },
379-
{ 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." }
401+
{ 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." },
402+
{ 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." },
403+
{ 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." }
380404
]} />
381405

382406
---

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '@/lib/workflows/persistence/utils'
1919
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
2020
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
21+
import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
2122

2223
const logger = createLogger('SuperUserImportWorkflow')
2324

@@ -149,6 +150,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
149150
isDeployed: false, // Never copy deployment status
150151
runCount: 0,
151152
variables: sourceWorkflow.variables || {},
153+
// An imported workflow is a NEW workflow in the target workspace, so it takes that
154+
// workspace's fork-sync policy rather than the column default.
155+
forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, targetWorkspaceId),
152156
})
153157

154158
// Save using existing persistence logic

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
notFoundResponse,
4242
} from '@/app/api/v1/admin/responses'
4343
import { extractWorkflowMetadata, type WorkflowImportRequest } from '@/app/api/v1/admin/types'
44+
import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
4445

4546
const logger = createLogger('AdminWorkflowImportAPI')
4647

@@ -128,6 +129,10 @@ export const POST = withRouteHandler(
128129
isDeployed: false,
129130
runCount: 0,
130131
variables: {},
132+
// An imported workflow is a NEW workflow in this workspace, so it takes the
133+
// workspace's fork-sync policy. Without this it lands on the column default and
134+
// silently joins fork sync in a workspace that opted out.
135+
forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId),
131136
})
132137

133138
/**

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import type {
6262
WorkspaceImportRequest,
6363
WorkspaceImportResponse,
6464
} from '@/app/api/v1/admin/types'
65+
import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
6566

6667
const logger = createLogger('AdminWorkspaceImportAPI')
6768

@@ -363,6 +364,10 @@ async function importSingleWorkflow(
363364
isDeployed: false,
364365
runCount: 0,
365366
variables: {},
367+
// An imported workflow is a NEW workflow in this workspace, so it takes the
368+
// workspace's fork-sync policy. Without this it lands on the column default and
369+
// silently joins fork sync in a workspace that opted out.
370+
forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId),
366371
})
367372

368373
/**
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { updateForkSyncDefaultContract } from '@/lib/api/contracts/workspace-fork'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies'
8+
import { forkOperations } from '@/ee/workspace-forking/application/operations'
9+
import { setForkSyncDefault } from '@/ee/workspace-forking/application/sync-default'
10+
11+
export const PUT = defineInternalJsonRoute({
12+
contract: updateForkSyncDefaultContract,
13+
auth: internalSessionAuth,
14+
operation: forkOperations.syncDefault,
15+
/**
16+
* Rated, unlike its sibling fork routes. This is the one that writes workspaces the
17+
* caller may not administer, under the feature's coarsest advisory lock, so an admin of
18+
* any single lineage member could otherwise loop it and starve fork creation across the
19+
* whole lineage.
20+
*/
21+
rateLimit: internalRateLimits.user({ bucketName: 'workspace-fork-sync-default' }),
22+
errorPolicy: internalForkErrorPolicy,
23+
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
24+
present: ({ excludeNewWorkflows, changedWorkspaces }) => ({
25+
excludeNewWorkflows,
26+
workspacesUpdated: changedWorkspaces.length,
27+
}),
28+
useCase: setForkSyncDefault,
29+
})

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm'
44
import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils'
55
import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage'
66
import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store'
7+
import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
78

89
/**
910
* Annotates a lineage node with whether the viewer holds any access to it (explicit
@@ -34,10 +35,12 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({
3435
context: { userId: string }
3536
}) {
3637
const { workspaceId } = input
37-
const [rawParent, rawChildren, run] = await Promise.all([
38+
const [rawParent, rawChildren, run, forkSyncNewWorkflowsExcluded] = await Promise.all([
3839
getForkParent(workspaceId),
3940
getForkChildren(workspaceId),
4041
getUndoableRunForTarget(db, workspaceId),
42+
// Lineage-uniform, so this workspace's own value is the lineage's value.
43+
resolveForkSyncExclusionForNewWorkflow(db, workspaceId),
4144
])
4245

4346
const [parent, children] = await Promise.all([
@@ -71,6 +74,7 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({
7174
createdAt: child.createdAt.toISOString(),
7275
})),
7376
undoableRun,
77+
forkSyncNewWorkflowsExcluded,
7478
}
7579
},
7680
})

‎apps/sim/ee/workspace-forking/application/operations.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,20 @@ export const forkOperations = {
9898
id: 'workspaces.fork.exclusions',
9999
oauthScope: 'api:write',
100100
}),
101+
/**
102+
* Admin on the CALLING workspace is sufficient, and the write then fans out to every
103+
* ancestor and descendant, because the default is meaningless unless it is uniform
104+
* across a lineage. Flipping it to "sync new workflows" restores the historical
105+
* behaviour rather than granting anything new, and it never moves an existing workflow
106+
* in or out of sync - so each member records its own audit entry rather than the write
107+
* being restricted to one workspace.
108+
*
109+
* permission-group-exempt: the new-workflow fork-sync default is workspace configuration governed by the admin role.
110+
*/
111+
syncDefault: defineWorkspaceOperation({
112+
...adminPolicy,
113+
capability: 'none',
114+
id: 'workspaces.fork.sync_default',
115+
oauthScope: 'api:write',
116+
}),
101117
} as const

‎apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export const updateWorkspaceForkMappings = defineForkUseCase<
5252
input.direction === 'push' ? input.otherWorkspaceId : input.workspaceId
5353
return db.transaction(async (tx) => {
5454
await setForkLockTimeout(tx)
55+
// Rank 4 - see the rank table on `acquireForkLineageLock`. Unlike promote and
56+
// rollback this takes no rank-3 target lock: it rewrites only this edge's mapping
57+
// rows, never the target's workflows, so nothing contends with a sync into the
58+
// target. Skipping a higher rank is not an ordering violation.
5559
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
5660
const [currentEdge] = await tx
5761
.select({ parentId: workspace.forkedFromWorkspaceId })

‎apps/sim/ee/workspace-forking/application/revision.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,15 @@ export async function loadForkPreviewRevision(
140140
}
141141
}
142142

143-
/** Locks normalized graph rows as well as workflow metadata, including realtime-only writes. */
143+
/**
144+
* Locks normalized graph rows as well as workflow metadata, including realtime-only writes.
145+
*
146+
* Rank 5 - the heaviest acquirer in the fork module, and the one the rank table on
147+
* `acquireForkLineageLock` exists for. It takes `FOR UPDATE` on the `workspace` rows, so
148+
* any caller that also needs the rank-2 lineage lock must take that one FIRST; doing it
149+
* the other way round deadlocks against `unlinkForkEdge`, which holds the lineage key and
150+
* then updates the same `workspace` row.
151+
*/
144152
export async function lockForkRevision(tx: DbTransaction, scope: ForkRevisionScope): Promise<void> {
145153
const workspaceIds = [
146154
...new Set([
@@ -199,11 +207,21 @@ export async function assertForkSourceVersions(
199207
sourceWorkspaceId: string,
200208
expected: ReadonlyMap<string, { id: string; digest: string }>
201209
): Promise<void> {
210+
// Verify exactly the workflows that were ADMITTED, rather than re-deriving the source
211+
// predicate here. Re-deriving it duplicated `listDeployedWorkflows`'s filter, so the day
212+
// a caller admitted a different set - "Copy unsynced workflows" admits sync-excluded
213+
// workflows - this query returned fewer rows and every such fork failed on a phantom
214+
// size mismatch. Keying off `expected` cannot drift from the admitted set by construction.
215+
if (expected.size === 0) return
216+
const admittedIds = sql.join(
217+
[...expected.keys()].map((id) => sql`${id}`),
218+
sql`, `
219+
)
202220
const rows = await tx.execute<{ workflowId: string; id: string; digest: string }>(sql`
203221
SELECT w.id AS "workflowId", d.id, md5(d.state::text) AS digest FROM ${workflow} w
204222
JOIN ${workflowDeploymentVersion} d ON d.workflow_id = w.id AND d.is_active = true
205223
WHERE w.workspace_id = ${sourceWorkspaceId} AND w.is_deployed = true
206-
AND w.archived_at IS NULL AND w.fork_sync_excluded = false
224+
AND w.archived_at IS NULL AND w.id IN (${admittedIds})
207225
`)
208226
if (
209227
rows.length !== expected.size ||

0 commit comments

Comments
 (0)