Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions apps/docs/content/docs/en/platform/enterprise/forks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a
| [Excluded workflows](#excluded-workflows) | Never | Never — not sent, not overwritten, not archived |
| Files | Optional copy (default on) | Map or copy |
| Tables | Optional copy (default on) | Map or copy |
| Knowledge bases (+ documents) | Optional copy; referenced docs come with the KB | Map or copy; documents follow the KB |
| Knowledge bases (+ documents) | Optional copy; uploaded documents come with the KB, [connector-synced ones do not](#connector-synced-documents-are-not-copied) | Map or copy; documents follow the KB |
| Custom tools | Optional copy (default on) | Map or copy |
| Skills | Optional copy (default on) | Map or copy |
| External MCP servers | Optional copy (config only; sign-in cleared) | Map or copy (config only; sign-in cleared) |
Expand Down Expand Up @@ -227,10 +227,27 @@ Only **deployed** workflows move. Deploy is the commit; sync is the force push/p

| | Behavior |
|---|----------|
| **Fork** | Optional copy (default on). Tag definitions come with the knowledge base. Documents that the forked workflows actually reference are included. Deselect → knowledge base / document fields clear. |
| **Fork** | Optional copy (default on). Tag definitions come with the knowledge base, along with every **uploaded** document in it. Deselect → knowledge base / document fields clear. |
| **Sync** | Map or copy the knowledge base. Documents are not mapped by themselves — they follow the knowledge base (copied with it, or re-picked when you map to an existing one). |

**Example:** An agent searches knowledge base “Product docs.” Fork with that knowledge base selected → the child gets the base, tags, and the documents the agent used. On sync, mapping to the child’s existing “Product docs” means re-picking which document the tool should use.
**Example:** An agent searches knowledge base “Product docs.” Fork with that knowledge base selected → the child gets the base, tags, and the uploaded documents. On sync, mapping to the child’s existing “Product docs” means re-picking which document the tool should use.

#### Connector-synced documents are not copied

Connectors themselves never cross a fork edge — the child gets no Confluence, Notion, Google Drive, or other sync running against it. Documents that a **connector** put in the knowledge base are therefore not copied either. Only documents you **uploaded** come across.

<Callout type="warn">
Fork a knowledge base whose content is entirely connector-synced and the child gets the base, its tags, and its settings — but **no documents**. Add the connector in the child to fill it.
</Callout>

This is deliberate. A copied connector document would arrive detached from any connector, so nothing would ever update, re-sync, or remove it — and when you added the connector in the child it would ingest every page again *alongside* the stale copy. Chain a few forks (prod → UAT → staging) and each hop leaves another dead generation behind, so one page comes back several times in a single knowledge search. Skipping them keeps the child’s own connector the single owner of that content.

| To get connector content into the child | Do this |
|---|---|
| Keep it live | Add the same connector in the child and let it sync. It re-ingests everything, so nothing is lost. |
| Keep a frozen snapshot | Download the documents from the source and upload them to the child’s knowledge base — uploaded documents copy on every later fork. |

A document whose connector was **deleted** in the source is no longer connector-managed, so it copies like any other uploaded document.

---

Expand Down
191 changes: 186 additions & 5 deletions apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { folder as folderTable } from '@sim/db/schema'
import { sha256Hex } from '@sim/security/hash'
import {
dbChainMockFns,
flattenMockConditions,
resetDbChainMock,
schemaMock,
storageServiceMock,
storageServiceMockFns,
} from '@sim/testing'
Expand Down Expand Up @@ -311,6 +313,106 @@ describe('copyForkResourceContent', () => {
expect(mockPersistCopiedResourceMappings).not.toHaveBeenCalled()
})

it('never copies a connector-managed document out of the source knowledge base', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])

const result = await copyForkResourceContent({
contentPlan: basePlan({
knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }],
}),
requestId: 'test',
})

expect(result).toEqual({ copied: 1, failed: 0, failures: [] })
// The row queue returns whatever is enqueued regardless of the predicate, so the exclusion
// is only observable in the condition tree. Pinned to the column so the assertion keeps its
// meaning if another nullable filter joins the same clause.
const pageWhere = dbChainMockFns.where.mock.calls.at(-1)?.[0]
expect(
flattenMockConditions(pageWhere).some(
(node) => node.type === 'isNull' && node.column === schemaMock.document.connectorId
)
).toBe(true)
})

it('drops a full-KB placeholder a pre-change worker planned for a connector-managed doc', async () => {
// Rolling deploy: the fork tx ran on the old code and planned a placeholder for a
// connector-managed document, which this worker's page query no longer returns. Nothing
// would ever fill it, so it must be reported for cleanup rather than left archived behind a
// live mapping that a remapped document-selector still resolves to.
dbChainMockFns.where.mockImplementationOnce(() => ({
// The skipped-document count.
then: (resolve: (rows: unknown[]) => unknown) => resolve([{ total: 1 }]),
}))
dbChainMockFns.where.mockImplementationOnce(() => ({
// The stale-plan probe: the planned source is connector-managed.
then: (resolve: (rows: unknown[]) => unknown) => resolve([{ id: 'doc-1' }]),
}))
dbChainMockFns.limit.mockResolvedValueOnce([])

const result = await copyForkResourceContent({
contentPlan: basePlan({
knowledgeBases: [
{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: { 'doc-1': 'child-doc-1' } },
],
documentMappingContext: { edgeChildWorkspaceId: 'edge-child-ws', sourceIsParent: false },
}),
requestId: 'test',
})

expect(result.failed).toBe(1)
expect(result.failures).toEqual([{ kind: 'knowledge-document', childId: 'child-doc-1' }])
// The persisted identity goes too, or a later sync resolves to the row cleanup deletes.
expect(mockDeleteCopiedResourceMappingsByTargets).toHaveBeenCalledWith({
executor: expect.anything(),
edgeChildWorkspaceId: 'edge-child-ws',
sourceIsParent: false,
targets: [{ resourceType: 'knowledge_document', resourceId: 'child-doc-1' }],
})
})

it('keeps a copied KB alive when the stale-plan probe fails', async () => {
// The probe runs on every KB with referenced documents, but the state it repairs only exists
// inside a rollout window. Letting it reach the KB catch would delete a complete copy and
// clear every reference to it over a transient SELECT.
dbChainMockFns.where.mockImplementationOnce(() => ({
then: (resolve: (rows: unknown[]) => unknown) => resolve([{ total: 0 }]),
}))
dbChainMockFns.where.mockImplementationOnce(() => {
throw new Error('stale-plan probe failed')
})
dbChainMockFns.limit.mockResolvedValueOnce([])

const result = await copyForkResourceContent({
contentPlan: basePlan({
knowledgeBases: [
{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: { 'doc-1': 'child-doc-1' } },
],
}),
requestId: 'test',
})

expect(result).toEqual({ copied: 1, failed: 0, failures: [] })
})

it('keeps a copied KB alive when the skipped-document count fails', async () => {
// The count only feeds a log line. Letting it throw into the KB's catch would roll back a
// perfectly good copy and clear every reference to it over a failed COUNT(*).
dbChainMockFns.where.mockImplementationOnce(() => {
throw new Error('count failed')
})
dbChainMockFns.limit.mockResolvedValueOnce([])

const result = await copyForkResourceContent({
contentPlan: basePlan({
knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }],
}),
requestId: 'test',
})

expect(result).toEqual({ copied: 1, failed: 0, failures: [] })
})

it('uses the blob content digest so a retry cannot adopt an older failed snapshot', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([sourceDoc])
Expand Down Expand Up @@ -1051,6 +1153,25 @@ describe('copyForkResourceContent', () => {
})
})

it('U-docs: refuses a connector-managed source planned before the exclusion existed', async () => {
// A payload queued by a pre-change worker during a rolling deploy: the planner would no
// longer emit this entry, so the fill must drop the placeholder rather than detach a copy
// of a connector-managed document into the existing target KB.
dbChainMockFns.limit
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ ...sourceDoc, connectorId: 'connector-1' }])

const result = await copyForkResourceContent({
contentPlan: mappedDocumentPlan(),
requestId: 'test',
})

expect(result.copied).toBe(0)
expect(result.failures).toEqual([{ kind: 'knowledge-document', childId: 'child-doc-1' }])
expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled()
expect(mockIncrementStorageUsageInTx).not.toHaveBeenCalled()
})

it('U-docs: refuses to charge when the target knowledge base moved workspaces', async () => {
queueMappedDocumentCopy()
dbChainMockFns.for.mockResolvedValueOnce([{ workspaceId: 'other-workspace' }])
Expand Down Expand Up @@ -1359,10 +1480,12 @@ describe('copyForkResourceContainers knowledge-base tag definitions', () => {
// would make every source folder look already-present and suppress the mirroring.
let folderCall = 0
const inserts: Array<Array<Record<string, unknown>>> = []
const wheres: Array<{ table: unknown; condition: unknown }> = []
const tx = {
select: () => ({
from: (table: unknown) => ({
where: () => {
where: (condition: unknown) => {
wheres.push({ table, condition })
if (table === folderTable) {
return Promise.resolve(folderCall++ === 0 ? sourceFolders : [])
}
Expand All @@ -1377,7 +1500,7 @@ describe('copyForkResourceContainers knowledge-base tag definitions', () => {
},
}),
}
return { tx: tx as unknown as DbOrTx, inserts }
return { tx: tx as unknown as DbOrTx, inserts, wheres }
}

const kbSelection = {
Expand Down Expand Up @@ -1458,6 +1581,35 @@ describe('copyForkResourceContainers knowledge-base tag definitions', () => {
expect(inserts).toHaveLength(1)
})

it('does not pre-create a placeholder for a referenced connector-managed document', async () => {
const { tx, wheres } = makeKbTx([[sourceBase], [], []])

const result = await copyForkResourceContainers({
tx,
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
now: new Date(),
selection: kbSelection,
workflowIdMap: new Map(),
referencedDocumentIds: ['doc-1'],
documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
})

// Must agree with the content phase's exclusion: a placeholder with no content copy behind
// it would stay archived forever while its persisted mapping pointed at it.
const placeholderWhere = wheres.find(({ table }) => table === schemaMock.document)?.condition
expect(
flattenMockConditions(placeholderWhere).some(
(node) => node.type === 'isNull' && node.column === schemaMock.document.connectorId
)
).toBe(true)
expect(result.mappingEntries.some((entry) => entry.resourceType === 'knowledge_document')).toBe(
false
)
expect(result.contentPlan.knowledgeBases[0].documentIdMap).toEqual({})
})

it('mirrors the source knowledge-base folder and copies the KB into it, not the target root', async () => {
const foldered = { ...sourceBase, folderId: 'kb-folder' }
const { tx, inserts } = makeKbTx(
Expand Down Expand Up @@ -1510,7 +1662,9 @@ describe('planForkMappedKbDocumentCopies', () => {
fileSize: 123,
filename: `${id}.pdf`,
mimeType: 'application/pdf',
connectorId: 'connector-1',
// Hand-uploaded: connector-managed documents are filtered out by the candidate query and
// can never reach the placeholder insert.
connectorId: null,
deletedAt: null,
archivedAt: null,
})
Expand All @@ -1526,11 +1680,19 @@ describe('planForkMappedKbDocumentCopies', () => {
}> = []
) {
const inserted: Array<Record<string, unknown>> = []
const wheres: unknown[] = []
let selectCalls = 0
const tx = {
select: () => {
const rows = selectCalls++ === 0 ? docs : existingTargets
return { from: () => ({ where: () => Promise.resolve(rows) }) }
return {
from: () => ({
where: (condition: unknown) => {
wheres.push(condition)
return Promise.resolve(rows)
},
}),
}
},
insert: () => ({
values: (rows: Array<Record<string, unknown>>) => {
Expand All @@ -1539,7 +1701,7 @@ describe('planForkMappedKbDocumentCopies', () => {
},
}),
}
return { tx: tx as unknown as DbOrTx, inserted, selectCalls: () => selectCalls }
return { tx: tx as unknown as DbOrTx, inserted, wheres, selectCalls: () => selectCalls }
}

const mappedKbResolver: ForkReferenceResolver = (kind, id) =>
Expand Down Expand Up @@ -1584,6 +1746,25 @@ describe('planForkMappedKbDocumentCopies', () => {
])
})

it('never considers a connector-managed doc as a candidate for the mapped target KB', async () => {
const { tx, wheres } = makeTx([])
await planForkMappedKbDocumentCopies({
tx,
resolver: mappedKbResolver,
referencedDocumentIds: ['doc-1'],
alreadyCopiedSourceDocIds: new Set(),
now,
})

// The tx mock returns its rows regardless of the predicate, so the exclusion is only
// observable in the condition tree.
expect(
flattenMockConditions(wheres[0]).some(
(node) => node.type === 'isNull' && node.column === schemaMock.document.connectorId
)
).toBe(true)
})

it('skips a referenced doc whose parent KB is not mapped (reference is left to be cleared)', async () => {
const { tx, inserted } = makeTx([sourceRow('doc-1', 'unmapped-kb')])
const result = await planForkMappedKbDocumentCopies({
Expand Down
Loading
Loading